Jelajahi Sumber

Merge branch '0.1.6-final' into feature/stl-thumbnail

MartinNYHC 3 bulan lalu
induk
melakukan
c4b3b69914
100 mengubah file dengan 12945 tambahan dan 1972 penghapusan
  1. 11 11
      .github/workflows/ci.yml
  2. 2 0
      .gitignore
  3. 1 1
      .pre-commit-config.yaml
  4. 133 0
      CHANGELOG.md
  5. 10 0
      README.md
  6. 1 0
      backend/app/api/routes/archives.py
  7. 442 7
      backend/app/api/routes/camera.py
  8. 36 2
      backend/app/api/routes/cloud.py
  9. 319 0
      backend/app/api/routes/github_backup.py
  10. 37 6
      backend/app/api/routes/library.py
  11. 418 0
      backend/app/api/routes/metrics.py
  12. 4 0
      backend/app/api/routes/notifications.py
  13. 51 0
      backend/app/api/routes/print_queue.py
  14. 77 1
      backend/app/api/routes/printers.py
  15. 396 1
      backend/app/api/routes/projects.py
  16. 189 1
      backend/app/api/routes/settings.py
  17. 9 2
      backend/app/core/config.py
  18. 112 16
      backend/app/core/database.py
  19. 523 72
      backend/app/main.py
  20. 3 0
      backend/app/models/__init__.py
  21. 4 1
      backend/app/models/archive.py
  22. 65 0
      backend/app/models/github_backup.py
  23. 10 1
      backend/app/models/library.py
  24. 3 0
      backend/app/models/notification.py
  25. 6 0
      backend/app/models/notification_template.py
  26. 11 0
      backend/app/models/printer.py
  27. 2 0
      backend/app/schemas/archive.py
  28. 154 0
      backend/app/schemas/github_backup.py
  29. 6 0
      backend/app/schemas/notification.py
  30. 27 0
      backend/app/schemas/print_queue.py
  31. 61 0
      backend/app/schemas/printer.py
  32. 55 0
      backend/app/schemas/project.py
  33. 10 4
      backend/app/schemas/settings.py
  34. 37 0
      backend/app/services/archive.py
  35. 2 2
      backend/app/services/bambu_ftp.py
  36. 71 4
      backend/app/services/bambu_mqtt.py
  37. 643 0
      backend/app/services/external_camera.py
  38. 731 0
      backend/app/services/github_backup.py
  39. 875 0
      backend/app/services/hms_errors.py
  40. 5 5
      backend/app/services/homeassistant.py
  41. 274 0
      backend/app/services/layer_timelapse.py
  42. 28 3
      backend/app/services/notification_service.py
  43. 797 0
      backend/app/services/plate_detection.py
  44. 70 24
      backend/app/services/print_scheduler.py
  45. 50 2
      backend/app/services/printer_manager.py
  46. 0 133
      backend/app/services/telemetry.py
  47. 17 0
      backend/tests/conftest.py
  48. 22 0
      backend/tests/integration/test_archives_api.py
  49. 233 0
      backend/tests/integration/test_camera_api.py
  50. 255 0
      backend/tests/integration/test_github_backup_api.py
  51. 27 116
      backend/tests/integration/test_library_api.py
  52. 139 0
      backend/tests/integration/test_metrics_api.py
  53. 229 0
      backend/tests/integration/test_print_queue_api.py
  54. 327 0
      backend/tests/integration/test_projects_api.py
  55. 105 0
      backend/tests/integration/test_settings_api.py
  56. 51 0
      backend/tests/unit/services/test_archive_service.py
  57. 181 0
      backend/tests/unit/services/test_bambu_mqtt.py
  58. 259 0
      backend/tests/unit/services/test_external_camera.py
  59. 76 0
      backend/tests/unit/services/test_hms_errors.py
  60. 320 0
      backend/tests/unit/services/test_layer_timelapse.py
  61. 227 0
      backend/tests/unit/services/test_notification_service.py
  62. 185 0
      backend/tests/unit/services/test_plate_detection.py
  63. 68 1
      backend/tests/unit/services/test_printer_manager.py
  64. 0 229
      backend/tests/unit/services/test_telemetry.py
  65. 0 83
      bambuddy-issue-notes.txt
  66. 196 1062
      frontend/package-lock.json
  67. 3 2
      frontend/package.json
  68. 283 0
      frontend/src/__tests__/api/githubBackupApi.test.ts
  69. 1 1
      frontend/src/__tests__/components/BackupModal.test.tsx
  70. 256 14
      frontend/src/__tests__/components/Dashboard.test.tsx
  71. 398 11
      frontend/src/__tests__/components/FileManagerModal.test.tsx
  72. 62 0
      frontend/src/__tests__/components/Layout.test.tsx
  73. 290 7
      frontend/src/__tests__/components/UploadModal.test.tsx
  74. 11 0
      frontend/src/__tests__/mocks/handlers.ts
  75. 40 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  76. 1 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  77. 21 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  78. 11 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  79. 11 0
      frontend/src/__tests__/pages/StatsPage.test.tsx
  80. 336 14
      frontend/src/api/client.ts
  81. 2 2
      frontend/src/components/AMSHistoryModal.tsx
  82. 28 9
      frontend/src/components/BackupModal.tsx
  83. 2 2
      frontend/src/components/CompareArchivesModal.tsx
  84. 21 1
      frontend/src/components/EditArchiveModal.tsx
  85. 106 4
      frontend/src/components/EmbeddedCameraViewer.tsx
  86. 4 4
      frontend/src/components/FilamentTrends.tsx
  87. 147 33
      frontend/src/components/FileManagerModal.tsx
  88. 776 0
      frontend/src/components/GitHubBackupSettings.tsx
  89. 50 0
      frontend/src/components/Layout.tsx
  90. 16 2
      frontend/src/components/NotificationProviderCard.tsx
  91. 3 3
      frontend/src/components/SmartPlugCard.tsx
  92. 4 4
      frontend/src/components/SwitchbarPopover.tsx
  93. 19 5
      frontend/src/contexts/AuthContext.tsx
  94. 12 0
      frontend/src/hooks/useWebSocket.ts
  95. 0 15
      frontend/src/i18n/locales/de.ts
  96. 0 16
      frontend/src/i18n/locales/en.ts
  97. 10 0
      frontend/src/index.css
  98. 113 17
      frontend/src/pages/ArchivesPage.tsx
  99. 106 4
      frontend/src/pages/CameraPage.tsx
  100. 114 12
      frontend/src/pages/FileManagerPage.tsx

+ 11 - 11
.github/workflows/ci.yml

@@ -6,8 +6,8 @@ on:
   pull_request:
     # Run on all PRs, but skip for repo owner (runs local tests)
 
-# Skip CI for repo owner's PRs (they run tests locally)
-# This check is applied to all jobs below
+# Skip CI for PRs authored by repo owner (they run tests locally)
+# Uses PR author instead of triggering actor so rebasing by owner doesn't skip CI
 
 env:
   PYTHON_VERSION: '3.11'
@@ -30,7 +30,7 @@ jobs:
   backend-lint:
     name: Backend Lint
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -51,7 +51,7 @@ jobs:
   backend-security:
     name: Backend Security
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     continue-on-error: true
     steps:
       - uses: actions/checkout@v4
@@ -73,7 +73,7 @@ jobs:
   backend-tests:
     name: Backend Tests
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: backend-lint
     steps:
       - uses: actions/checkout@v4
@@ -110,7 +110,7 @@ jobs:
   frontend-lint:
     name: Frontend Lint
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -132,7 +132,7 @@ jobs:
   frontend-security:
     name: Frontend Security
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     continue-on-error: true
     steps:
       - uses: actions/checkout@v4
@@ -155,7 +155,7 @@ jobs:
   frontend-typecheck:
     name: Frontend Type Check
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -177,7 +177,7 @@ jobs:
   frontend-tests:
     name: Frontend Tests
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: [frontend-lint, frontend-typecheck]
     steps:
       - uses: actions/checkout@v4
@@ -201,7 +201,7 @@ jobs:
   frontend-build:
     name: Frontend Build
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: [frontend-tests]
     steps:
       - uses: actions/checkout@v4
@@ -228,7 +228,7 @@ jobs:
   docker-test:
     name: Docker Build
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     timeout-minutes: 20
     needs: [backend-tests, frontend-build]
     steps:

+ 2 - 0
.gitignore

@@ -56,3 +56,5 @@ firmware/
 
 # Node modules
 node_modules/
+
+data/

+ 1 - 1
.pre-commit-config.yaml

@@ -24,7 +24,7 @@ repos:
         exclude: ^static/
       - id: check-yaml
       - id: check-json
-        exclude: ^static/
+        exclude: ^(static/|frontend/tsconfig\.)
       - id: check-added-large-files
         args: ['--maxkb=1000']
         exclude: ^static/assets/

+ 133 - 0
CHANGELOG.md

@@ -2,6 +2,139 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6-final] - Not released
+
+### New Features
+- **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
+  - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
+  - Useful for users who prefer to manage firmware manually or have network restrictions
+- **Archive Plate Browsing** - Browse plate thumbnails directly in archive cards (Issue #166):
+  - Hover over archive card to reveal plate navigation for multi-plate files
+  - Left/right arrows to cycle through plate thumbnails
+  - Dot indicators show current plate (clickable to jump to specific plate)
+  - Lazy-loads plate data only when user hovers
+- **GitHub Profile Backup** - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:
+  - Configure GitHub repository URL and Personal Access Token
+  - Schedule backups hourly, daily, or weekly
+  - Manual on-demand backup trigger
+  - Backs up K-profiles (per-printer), cloud profiles, and app settings
+  - Skip unchanged commits (only creates commit when data changes)
+  - Real-time progress tracking during backup
+  - Backup history log with status and commit links
+  - Requires Bambu Cloud login for full profile access
+  - New Settings → Backup & Restore tab (local backup/restore moved here)
+  - Included in local backup/restore (except PAT for security)
+- **Plate Not Empty Notification** - Dedicated notification category for build plate detection:
+  - New toggle in notification provider settings (enabled by default)
+  - Sends immediately (bypasses quiet hours and digest mode)
+  - Separate from general printer errors for granular control
+- **USB Camera Support** - Connect USB webcams directly to your Bambuddy host:
+  - New "USB Camera (V4L2)" option in external camera settings
+  - Auto-detection of available USB cameras via V4L2
+  - API endpoint to list connected USB cameras (`GET /api/v1/printers/usb-cameras`)
+  - Works with any V4L2-compatible camera on Linux
+  - Uses ffmpeg for frame capture and streaming
+- **Build Plate Empty Detection** - Automatically detect if objects are on the build plate before printing:
+  - Per-printer toggle to enable/disable plate detection
+  - Multi-reference calibration: Store up to 5 reference images of empty plates (different plate types)
+  - Automatic print pause when objects detected on plate at print start
+  - Push notification and WebSocket alert when print is paused due to plate detection
+  - ROI (Region of Interest) calibration UI with sliders to focus detection on build plate area
+  - Reference management: View thumbnails, add labels, delete references
+  - Works with both built-in and external cameras
+  - Uses buffered camera frames when stream is active (no blocking)
+  - Split button UI: Main button toggles detection on/off, chevron opens calibration modal
+  - Green visual indicator when plate detection is enabled
+  - Included in backup/restore
+- **Project Import/Export** - Export and import projects with full file support (Issue #152):
+  - Export single project as ZIP (includes project settings, BOM, and all files from linked library folders)
+  - Export all projects as JSON for metadata-only backup
+  - Import from ZIP (with files) or JSON (metadata only)
+  - Linked folders and files are automatically created on import
+  - Useful for sharing complete project bundles or migrating between instances
+- **BOM Item Editing** - Bill of Materials items are now fully editable:
+  - Edit name, quantity, price, URL, and remarks after creation
+  - Pencil icon on each BOM item to enter edit mode
+- **Prometheus Metrics Endpoint** - Export printer telemetry for external monitoring systems (Issue #161):
+  - Enable via Settings → Network → Prometheus Metrics
+  - Endpoint: `GET /api/v1/metrics` (Prometheus text format)
+  - Optional bearer token authentication for security
+  - Printer metrics: connection status, state, temperatures (bed, nozzle, chamber), fans, WiFi signal
+  - Print metrics: progress, remaining time, layer count
+  - Statistics: total prints by status, filament used, print time
+  - Queue metrics: pending and active jobs
+  - System metrics: connected printers count
+  - Labels include printer_id, printer_name, serial for filtering
+  - Ready for Grafana dashboards
+- **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
+  - Link archives to Printables, Thingiverse, or any other URL
+  - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
+  - Edit via archive edit modal
+  - Included in backup/restore
+- **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
+  - Configure per-printer external camera URL and type in Settings → Camera
+  - Live streaming uses external camera when enabled
+  - Finish photo capture uses external camera
+  - Layer-based timelapse: captures frame on each layer change, stitches to MP4 on print completion
+  - Test connection button to verify camera accessibility
+- **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
+- **Create Folder from ZIP** - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)
+- **Multi-File Selection in Printer Files** - Printer card file browser now supports multiple file selection (Issue #144):
+  - Checkbox selection for individual files
+  - Select All / Deselect All buttons
+  - Bulk download as ZIP when multiple files selected
+  - Bulk delete for multiple files at once
+- **Queue Bulk Edit** - Select and edit multiple queue items at once (Issue #159):
+  - Checkbox selection for pending queue items
+  - Select All / Deselect All in toolbar
+  - Bulk edit: printer assignment, print options, queue options
+  - Bulk cancel selected items
+  - Tri-state toggles: unchanged / on / off for each setting
+
+### Fixes
+- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
+  - Queue now displays the correct plate thumbnail based on selected plate
+  - Previously always showed plate 1 thumbnail regardless of selection
+- **A1/A1 Mini Shows Printing Instead of Idle** - Fixed incorrect status display for A1 series printers (Issue #168):
+  - Some A1/A1 Mini firmware versions incorrectly report stage 0 ("Printing") when idle
+  - Now checks gcode_state to correctly display "Idle" for affected printers
+  - Fix only applies to A1 models with the specific buggy condition
+- **HMS Error Notifications** - Get notified when printer errors occur (Issue #84):
+  - Automatic notifications for HMS errors (AMS issues, nozzle problems, etc.)
+  - Human-readable error messages (853 error codes translated)
+  - Friendly error type names (Print/Task, AMS/Filament, Nozzle/Extruder, Motion Controller, Chamber)
+  - Deduplication prevents spam from repeated error messages
+  - Publishes to MQTT relay for home automation integrations
+  - New "Printer Error" toggle in notification provider settings
+- **Plate Calibration Persistence** - Fixed plate detection reference images not persisting after restart in Docker deployments
+- **Telegram Notification Parsing** - Fixed Telegram markdown parsing errors when messages contain underscores (e.g., error codes)
+- **Settings API PATCH Method** - Added PATCH support to `/api/settings` for Home Assistant rest_command compatibility (Issue #152)
+- **P2S Empty Archive Tiles** - Fixed FTP file search for printers without SD card (Issue #146):
+  - Added root folder `/` to search paths when looking for 3MF files
+  - Printers without SD card store files in root instead of `/cache`
+- **Empty AMS Slot Not Recognized** - Fixed bug where removed spools still appeared in Bambuddy (Issue #147):
+  - Old AMS: Now properly applies empty values from tray data updates
+  - New AMS (AMS 2 Pro): Now checks `tray_exist_bits` bitmask to detect and clear empty slots
+- **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
+- **HA Energy Sensors Not Detected** - Home Assistant energy sensors with lowercase units (w, kwh) are now properly detected; unit matching is now case-insensitive (Issue #119)
+- **File Manager Upload** - Upload modal now accepts all file types, not just ZIP files
+- **Camera Zoom & Pan Improvements** - Enhanced camera viewer zoom/pan functionality (Issue #132):
+  - Pan range now based on actual container size, allowing full navigation of zoomed image
+  - Added pinch-to-zoom support for mobile/touch devices
+  - Added touch-based panning when zoomed in
+  - Both embedded camera viewer and standalone camera page updated
+- **Progress Milestone Time** - Fixed milestone notifications showing wrong time (e.g., "17m" instead of "17h 47m") by converting remaining_time from minutes to seconds (Issue #157)
+- **File Manager Folder Navigation** - Improved handling of long folder names (Issue #160):
+  - Resizable sidebar: Drag the edge to adjust width (200-500px), double-click to reset
+  - Text wrap toggle: "Wrap" button in header to wrap long names instead of truncating
+  - Both settings persist in localStorage
+  - Tooltip shows full name on hover
+- **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
+- **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
+
+### Maintenance
+- Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies
+
 ## [0.1.6b11] - 2026-01-22
 
 ### New Features

+ 10 - 0
README.md

@@ -52,11 +52,14 @@
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
+- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
+- 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)
 - Resizable printer cards (S/M/L/XL)
@@ -83,6 +86,8 @@
 
 ### 📁 File Manager (Library)
 - Upload and organize sliced files (3MF, gcode)
+- ZIP file extraction with folder structure preservation
+- Option to create folder from ZIP filename
 - Folder structure with drag-and-drop
 - Rename files and folders via context menu
 - Print directly to any printer with full options
@@ -97,6 +102,7 @@
 - Auto-detect parts count from 3MF files
 - Color-coded project badges
 - Bulk assign archives via multi-select toolbar
+- Import/Export projects as ZIP (includes files) or JSON
 
 </td>
 <td width="50%" valign="top">
@@ -108,12 +114,16 @@
 - Quiet hours & daily digest
 - Customizable message templates
 - Print finish photo URL in notifications
+- HMS error alerts (AMS, nozzle, etc.)
+- Build plate detection alerts
 
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync
 - MQTT publishing for Home Assistant, Node-RED, etc.
+- **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - Bambu Cloud profile management
 - K-profiles (pressure advance)
+- **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
 - External sidebar links
 - Webhooks & API keys
 - Interactive API browser with live testing

+ 1 - 0
backend/app/api/routes/archives.py

@@ -84,6 +84,7 @@ def archive_to_response(
         "extra_data": archive.extra_data,
         "makerworld_url": archive.makerworld_url,
         "designer": archive.designer,
+        "external_url": archive.external_url,
         "is_favorite": archive.is_favorite,
         "tags": archive.tags,
         "notes": archive.notes,

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

@@ -39,6 +39,9 @@ _last_frame_times: dict[int, float] = {}
 # Track stream start times for each printer
 _stream_start_times: dict[int, float] = {}
 
+# Track active external camera streams by printer ID
+_active_external_streams: set[int] = set()
+
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
@@ -350,7 +353,8 @@ async def camera_stream(
     This endpoint returns a multipart MJPEG stream that can be used directly
     in an <img> tag or video player.
 
-    Uses the appropriate protocol based on printer model:
+    Uses external camera if configured, otherwise uses built-in camera:
+    - External: MJPEG, RTSP, or HTTP snapshot
     - A1/P1: Chamber image protocol (port 6000)
     - X1/H2/P2: RTSP via ffmpeg (port 322)
 
@@ -362,6 +366,50 @@ async def camera_stream(
 
     printer = await get_printer_or_404(printer_id, db)
 
+    # Check for external camera first
+    if printer.external_camera_enabled and printer.external_camera_url:
+        import time
+
+        from backend.app.services.external_camera import generate_mjpeg_stream
+
+        # Limit external camera FPS to reduce browser load
+        fps = min(max(fps, 1), 15)
+        logger.info(f"Using external camera ({printer.external_camera_type}) for printer {printer_id} at {fps} fps")
+
+        # Track stream start
+        _stream_start_times[printer_id] = time.time()
+        _active_external_streams.add(printer_id)
+
+        async def external_stream_wrapper():
+            """Wrap external stream to track start/stop and update frame times."""
+            frame_interval = 1.0 / fps
+            last_yield_time = 0.0
+            try:
+                async for frame in generate_mjpeg_stream(
+                    printer.external_camera_url, printer.external_camera_type, fps
+                ):
+                    # Rate limit to prevent overwhelming browser
+                    current_time = time.time()
+                    elapsed = current_time - last_yield_time
+                    if elapsed < frame_interval:
+                        await asyncio.sleep(frame_interval - elapsed)
+                    last_yield_time = time.time()
+                    _last_frame_times[printer_id] = last_yield_time
+                    yield frame
+            finally:
+                _active_external_streams.discard(printer_id)
+                logger.info(f"External camera stream ended for printer {printer_id}")
+
+        return StreamingResponse(
+            external_stream_wrapper(),
+            media_type="multipart/x-mixed-replace; boundary=frame",
+            headers={
+                "Cache-Control": "no-cache, no-store, must-revalidate",
+                "Pragma": "no-cache",
+                "Expires": "0",
+            },
+        )
+
     # Validate FPS - A1/P1 models max out at ~5 FPS
     if is_chamber_image_model(printer.model):
         fps = min(max(fps, 1), 5)
@@ -554,13 +602,18 @@ async def camera_status(printer_id: int):
     # Check if there's an active stream for this printer
     has_active_stream = False
 
+    # Check external camera streams
+    if printer_id in _active_external_streams:
+        has_active_stream = True
+
     # Check ffmpeg/RTSP streams
-    for stream_id in _active_streams:
-        if stream_id.startswith(f"{printer_id}-"):
-            process = _active_streams[stream_id]
-            if process.returncode is None:
-                has_active_stream = True
-                break
+    if not has_active_stream:
+        for stream_id in _active_streams:
+            if stream_id.startswith(f"{printer_id}-"):
+                process = _active_streams[stream_id]
+                if process.returncode is None:
+                    has_active_stream = True
+                    break
 
     # Check chamber image streams
     if not has_active_stream:
@@ -597,3 +650,385 @@ async def camera_status(printer_id: int):
             and (seconds_since_frame is None or seconds_since_frame > 10)
         ),
     }
+
+
+@router.post("/{printer_id}/camera/external/test")
+async def test_external_camera(
+    printer_id: int,
+    url: str,
+    camera_type: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Test external camera connection.
+
+    Args:
+        printer_id: Printer ID (for authorization)
+        url: Camera URL or USB device path to test
+        camera_type: Camera type ("mjpeg", "rtsp", "snapshot", "usb")
+
+    Returns:
+        Dict with {success: bool, error?: str, resolution?: str}
+    """
+    # Verify printer exists (for authorization)
+    await get_printer_or_404(printer_id, db)
+
+    from backend.app.services.external_camera import test_connection
+
+    return await test_connection(url, camera_type)
+
+
+@router.get("/{printer_id}/camera/check-plate")
+async def check_plate_empty(
+    printer_id: int,
+    plate_type: str | None = None,
+    use_external: bool = False,
+    include_debug_image: bool = False,
+    db: AsyncSession = Depends(get_db),
+):
+    """Check if the build plate is empty using camera vision.
+
+    Uses calibration-based difference detection - compares current frame
+    to a reference image of the empty plate.
+
+    IMPORTANT: Chamber light must be ON for reliable detection.
+
+    Args:
+        printer_id: Printer ID
+        plate_type: Type of build plate (e.g., "High Temp Plate") for calibration lookup
+        use_external: If True, prefer external camera over built-in
+        include_debug_image: If True, return URL to annotated debug image
+
+    Returns:
+        Dict with detection results:
+        - is_empty: bool - Whether plate appears empty
+        - confidence: float - Confidence level (0.0 to 1.0)
+        - difference_percent: float - How different from calibration reference
+        - message: str - Human-readable result message
+        - needs_calibration: bool - True if calibration is required
+        - light_warning: bool - True if chamber light is off
+    """
+    from backend.app.services.plate_detection import (
+        check_plate_empty as do_check,
+        is_plate_detection_available,
+    )
+    from backend.app.services.printer_manager import printer_manager
+
+    if not is_plate_detection_available():
+        raise HTTPException(
+            status_code=503,
+            detail="Plate detection not available. Install opencv-python-headless to enable.",
+        )
+
+    printer = await get_printer_or_404(printer_id, db)
+
+    # Check chamber light status
+    light_warning = False
+    state = printer_manager.get_status(printer_id)
+    if state and not state.chamber_light:
+        light_warning = True
+
+    from backend.app.services.plate_detection import PlateDetector
+
+    # Build ROI tuple from printer settings if available
+    roi = None
+    if all(
+        [
+            printer.plate_detection_roi_x is not None,
+            printer.plate_detection_roi_y is not None,
+            printer.plate_detection_roi_w is not None,
+            printer.plate_detection_roi_h is not None,
+        ]
+    ):
+        roi = (
+            printer.plate_detection_roi_x,
+            printer.plate_detection_roi_y,
+            printer.plate_detection_roi_w,
+            printer.plate_detection_roi_h,
+        )
+
+    result = await do_check(
+        printer_id=printer.id,
+        ip_address=printer.ip_address,
+        access_code=printer.access_code,
+        model=printer.model,
+        plate_type=plate_type,
+        include_debug_image=include_debug_image,
+        external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,
+        external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
+        use_external=use_external,
+        roi=roi,
+    )
+
+    # Get reference count for the response
+    detector = PlateDetector()
+    ref_count = detector.get_calibration_count(printer.id)
+
+    response = result.to_dict()
+    response["light_warning"] = light_warning
+    response["reference_count"] = ref_count
+    response["max_references"] = detector.MAX_REFERENCES
+    # Include current ROI in response
+    if roi:
+        response["roi"] = {"x": roi[0], "y": roi[1], "w": roi[2], "h": roi[3]}
+    else:
+        # Return default ROI
+        response["roi"] = {"x": 0.15, "y": 0.35, "w": 0.70, "h": 0.55}
+
+    # If debug image requested and available, encode as base64 data URL
+    if include_debug_image and result.debug_image:
+        import base64
+
+        b64_image = base64.b64encode(result.debug_image).decode("utf-8")
+        response["debug_image_url"] = f"data:image/jpeg;base64,{b64_image}"
+
+    return response
+
+
+@router.post("/{printer_id}/camera/plate-detection/calibrate")
+async def calibrate_plate_detection(
+    printer_id: int,
+    label: str | None = None,
+    use_external: bool = False,
+    db: AsyncSession = Depends(get_db),
+):
+    """Calibrate plate detection by capturing a reference image of the empty plate.
+
+    The plate MUST be empty when calling this endpoint. The captured image
+    will be used as the reference for future detection comparisons.
+
+    Supports up to 5 reference images per printer. When adding a 6th, the oldest
+    is automatically removed.
+
+    IMPORTANT: Chamber light should be ON for calibration.
+
+    Args:
+        printer_id: Printer ID
+        label: Optional label for this reference (e.g., "High Temp Plate", "Wham Bam")
+        use_external: If True, prefer external camera over built-in
+
+    Returns:
+        Dict with:
+        - success: bool - Whether calibration succeeded
+        - message: str - Status message
+        - index: int - The reference slot used (0-4)
+    """
+    from backend.app.services.plate_detection import (
+        calibrate_plate,
+        is_plate_detection_available,
+    )
+    from backend.app.services.printer_manager import printer_manager
+
+    if not is_plate_detection_available():
+        raise HTTPException(
+            status_code=503,
+            detail="Plate detection not available. Install opencv-python-headless to enable.",
+        )
+
+    printer = await get_printer_or_404(printer_id, db)
+
+    # Check chamber light - warn but don't block
+    state = printer_manager.get_status(printer_id)
+    light_warning = state and not state.chamber_light
+
+    success, message, index = await calibrate_plate(
+        printer_id=printer.id,
+        ip_address=printer.ip_address,
+        access_code=printer.access_code,
+        model=printer.model,
+        label=label,
+        external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,
+        external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
+        use_external=use_external,
+    )
+
+    if light_warning and success:
+        message += " (Warning: Chamber light was off)"
+
+    return {"success": success, "message": message, "index": index}
+
+
+@router.delete("/{printer_id}/camera/plate-detection/calibrate")
+async def delete_plate_calibration(
+    printer_id: int,
+    plate_type: str | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete the plate detection calibration for a printer and plate type.
+
+    Args:
+        printer_id: Printer ID
+        plate_type: Type of build plate (if None, deletes legacy non-plate-specific calibration)
+
+    Returns:
+        Dict with:
+        - success: bool - Whether deletion succeeded
+        - message: str - Status message
+    """
+    from backend.app.services.plate_detection import (
+        delete_calibration,
+        is_plate_detection_available,
+    )
+
+    if not is_plate_detection_available():
+        raise HTTPException(
+            status_code=503,
+            detail="Plate detection not available. Install opencv-python-headless to enable.",
+        )
+
+    # Verify printer exists
+    await get_printer_or_404(printer_id, db)
+
+    deleted = delete_calibration(printer_id, plate_type)
+    plate_msg = f" for '{plate_type}'" if plate_type else ""
+
+    return {
+        "success": deleted,
+        "message": f"Calibration deleted{plate_msg}" if deleted else f"No calibration found{plate_msg}",
+    }
+
+
+@router.get("/{printer_id}/camera/plate-detection/status")
+async def get_plate_detection_status(
+    printer_id: int,
+    plate_type: str | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Check plate detection status for a printer and plate type.
+
+    Returns:
+        Dict with:
+        - available: bool - Whether OpenCV is installed
+        - calibrated: bool - Whether printer has calibration for this plate type
+        - plate_type: str - The plate type queried
+        - chamber_light: bool - Whether chamber light is on
+        - message: str - Status message
+    """
+    from backend.app.services.plate_detection import (
+        get_calibration_status,
+        is_plate_detection_available,
+    )
+    from backend.app.services.printer_manager import printer_manager
+
+    if not is_plate_detection_available():
+        return {
+            "available": False,
+            "calibrated": False,
+            "plate_type": plate_type,
+            "chamber_light": False,
+            "message": "OpenCV not installed",
+        }
+
+    # Verify printer exists
+    await get_printer_or_404(printer_id, db)
+
+    # Get chamber light status
+    state = printer_manager.get_status(printer_id)
+    chamber_light = state.chamber_light if state else False
+
+    status = get_calibration_status(printer_id, plate_type)
+    status["chamber_light"] = chamber_light
+
+    return status
+
+
+@router.get("/{printer_id}/camera/plate-detection/references")
+async def get_plate_references(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get all calibration references for a printer with metadata.
+
+    Returns list of references with index, label, timestamp, and thumbnail URL.
+    """
+    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    await get_printer_or_404(printer_id, db)
+
+    detector = PlateDetector()
+    references = detector.get_references(printer_id)
+
+    # Add thumbnail URLs
+    for ref in references:
+        ref["thumbnail_url"] = (
+            f"/api/v1/printers/{printer_id}/camera/plate-detection/references/{ref['index']}/thumbnail"
+        )
+
+    return {
+        "references": references,
+        "max_references": detector.MAX_REFERENCES,
+    }
+
+
+@router.get("/{printer_id}/camera/plate-detection/references/{index}/thumbnail")
+async def get_reference_thumbnail(
+    printer_id: int,
+    index: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get thumbnail image for a calibration reference."""
+    from fastapi.responses import Response
+
+    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    await get_printer_or_404(printer_id, db)
+
+    detector = PlateDetector()
+    thumbnail = detector.get_reference_thumbnail(printer_id, index)
+
+    if thumbnail is None:
+        raise HTTPException(404, "Reference not found")
+
+    return Response(content=thumbnail, media_type="image/jpeg")
+
+
+@router.put("/{printer_id}/camera/plate-detection/references/{index}")
+async def update_reference_label(
+    printer_id: int,
+    index: int,
+    label: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update the label for a calibration reference."""
+    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    await get_printer_or_404(printer_id, db)
+
+    detector = PlateDetector()
+    success = detector.update_reference_label(printer_id, index, label)
+
+    if not success:
+        raise HTTPException(404, "Reference not found")
+
+    return {"success": True, "index": index, "label": label}
+
+
+@router.delete("/{printer_id}/camera/plate-detection/references/{index}")
+async def delete_reference(
+    printer_id: int,
+    index: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a specific calibration reference."""
+    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    await get_printer_or_404(printer_id, db)
+
+    detector = PlateDetector()
+    success = detector.delete_reference(printer_id, index)
+
+    if not success:
+        raise HTTPException(404, "Reference not found")
+
+    return {"success": True, "message": "Reference deleted"}

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

@@ -267,6 +267,34 @@ _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
 
+def _filament_id_to_setting_id(filament_id: str) -> str:
+    """
+    Convert filament_id to setting_id format for Bambu Cloud API.
+
+    Printers report filament_id (e.g., GFA00, GFG02) but the API expects
+    setting_id format which has an "S" inserted after "GF" (e.g., GFSA00, GFSG02).
+
+    User presets (starting with "P") and already-correct IDs are returned unchanged.
+    """
+    if not filament_id:
+        return filament_id
+
+    # User presets start with "P" - leave unchanged
+    if filament_id.startswith("P"):
+        return filament_id
+
+    # Official Bambu presets: GFx## -> GFSx##
+    # Check if it matches the filament_id pattern (GF followed by letter and digits)
+    if filament_id.startswith("GF") and len(filament_id) >= 4:
+        # Check if it's already a setting_id (has S after GF)
+        if filament_id[2] == "S":
+            return filament_id
+        # Insert "S" after "GF": GFA00 -> GFSA00
+        return f"GFS{filament_id[2:]}"
+
+    return filament_id
+
+
 @router.post("/filament-info")
 async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
     """
@@ -308,7 +336,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
             continue
 
         try:
-            data = await cloud.get_setting_detail(setting_id)
+            # Transform filament_id to setting_id format (GFA00 -> GFSA00)
+            api_setting_id = _filament_id_to_setting_id(setting_id)
+
+            data = await cloud.get_setting_detail(api_setting_id)
             setting = data.get("setting", {})
 
             # Extract name (e.g., "Bambu PLA Basic Jade White")
@@ -323,11 +354,14 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
                     k_value = None
 
             info = {"name": name, "k": k_value}
+            # Cache using original ID so frontend gets expected response
             _filament_cache[setting_id] = info
             result[setting_id] = info
 
         except Exception as e:
-            logger.warning(f"Failed to get cloud preset {setting_id}: {e}")
+            logger.warning(
+                f"Failed to get cloud preset {setting_id} (API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
+            )
             # Cache the failure to avoid repeated requests
             _filament_cache[setting_id] = {"name": "", "k": None}
             result[setting_id] = {"name": "", "k": None}

+ 319 - 0
backend/app/api/routes/github_backup.py

@@ -0,0 +1,319 @@
+"""API routes for GitHub profile backup."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import delete, desc, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.schemas.github_backup import (
+    GitHubBackupConfigCreate,
+    GitHubBackupConfigResponse,
+    GitHubBackupConfigUpdate,
+    GitHubBackupLogResponse,
+    GitHubBackupStatus,
+    GitHubBackupTriggerResponse,
+    GitHubTestConnectionResponse,
+)
+from backend.app.services.github_backup import github_backup_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/github-backup", tags=["github-backup"])
+
+
+def _config_to_response(config: GitHubBackupConfig) -> dict:
+    """Convert config model to response dict."""
+    return {
+        "id": config.id,
+        "repository_url": config.repository_url,
+        "has_token": bool(config.access_token),
+        "branch": config.branch,
+        "schedule_enabled": config.schedule_enabled,
+        "schedule_type": config.schedule_type,
+        "backup_kprofiles": config.backup_kprofiles,
+        "backup_cloud_profiles": config.backup_cloud_profiles,
+        "backup_settings": config.backup_settings,
+        "enabled": config.enabled,
+        "last_backup_at": config.last_backup_at,
+        "last_backup_status": config.last_backup_status,
+        "last_backup_message": config.last_backup_message,
+        "last_backup_commit_sha": config.last_backup_commit_sha,
+        "next_scheduled_run": config.next_scheduled_run,
+        "created_at": config.created_at,
+        "updated_at": config.updated_at,
+    }
+
+
+@router.get("/config", response_model=GitHubBackupConfigResponse | None)
+async def get_config(db: AsyncSession = Depends(get_db)):
+    """Get the current GitHub backup configuration."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        return None
+
+    return _config_to_response(config)
+
+
+@router.post("/config", response_model=GitHubBackupConfigResponse)
+async def save_config(
+    config_data: GitHubBackupConfigCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create or update GitHub backup configuration.
+
+    Only one configuration is supported. If one exists, it will be updated.
+    """
+    # Check for existing config
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if config:
+        # Update existing
+        config.repository_url = config_data.repository_url
+        config.access_token = config_data.access_token
+        config.branch = config_data.branch
+        config.schedule_enabled = config_data.schedule_enabled
+        config.schedule_type = config_data.schedule_type.value
+        config.backup_kprofiles = config_data.backup_kprofiles
+        config.backup_cloud_profiles = config_data.backup_cloud_profiles
+        config.backup_settings = config_data.backup_settings
+        config.enabled = config_data.enabled
+
+        # Calculate next scheduled run if enabled
+        if config.schedule_enabled:
+            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
+        else:
+            config.next_scheduled_run = None
+
+        logger.info(f"Updated GitHub backup config: {config.repository_url}")
+    else:
+        # Create new
+        config = GitHubBackupConfig(
+            repository_url=config_data.repository_url,
+            access_token=config_data.access_token,
+            branch=config_data.branch,
+            schedule_enabled=config_data.schedule_enabled,
+            schedule_type=config_data.schedule_type.value,
+            backup_kprofiles=config_data.backup_kprofiles,
+            backup_cloud_profiles=config_data.backup_cloud_profiles,
+            backup_settings=config_data.backup_settings,
+            enabled=config_data.enabled,
+        )
+
+        if config.schedule_enabled:
+            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
+
+        db.add(config)
+        logger.info(f"Created GitHub backup config: {config.repository_url}")
+
+    await db.commit()
+    await db.refresh(config)
+
+    return _config_to_response(config)
+
+
+@router.patch("/config", response_model=GitHubBackupConfigResponse)
+async def update_config(
+    update_data: GitHubBackupConfigUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Partially update GitHub backup configuration."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found")
+
+    update_dict = update_data.model_dump(exclude_unset=True)
+
+    for key, value in update_dict.items():
+        if key == "schedule_type" and value is not None:
+            setattr(config, key, value.value)
+        else:
+            setattr(config, key, value)
+
+    # Recalculate next scheduled run if schedule settings changed
+    if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
+        if config.schedule_enabled:
+            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
+        else:
+            config.next_scheduled_run = None
+
+    await db.commit()
+    await db.refresh(config)
+
+    logger.info(f"Updated GitHub backup config: {config.repository_url}")
+
+    return _config_to_response(config)
+
+
+@router.delete("/config")
+async def delete_config(db: AsyncSession = Depends(get_db)):
+    """Delete the GitHub backup configuration and all logs."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found")
+
+    await db.delete(config)
+    await db.commit()
+
+    logger.info("Deleted GitHub backup config")
+
+    return {"message": "Configuration deleted"}
+
+
+@router.post("/test", response_model=GitHubTestConnectionResponse)
+async def test_connection(
+    repo_url: str = Query(..., description="GitHub repository URL"),
+    token: str = Query(..., description="Personal Access Token"),
+):
+    """Test GitHub connection with provided credentials."""
+    result = await github_backup_service.test_connection(repo_url, token)
+    return GitHubTestConnectionResponse(**result)
+
+
+@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
+async def test_stored_connection(db: AsyncSession = Depends(get_db)):
+    """Test GitHub connection using stored configuration."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found")
+
+    if not config.access_token:
+        raise HTTPException(status_code=400, detail="No access token configured")
+
+    test_result = await github_backup_service.test_connection(config.repository_url, config.access_token)
+    return GitHubTestConnectionResponse(**test_result)
+
+
+@router.post("/run", response_model=GitHubBackupTriggerResponse)
+async def trigger_backup(db: AsyncSession = Depends(get_db)):
+    """Manually trigger a backup."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    if not config.enabled:
+        raise HTTPException(status_code=400, detail="Backup is disabled")
+
+    backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
+
+    return GitHubBackupTriggerResponse(**backup_result)
+
+
+@router.get("/status", response_model=GitHubBackupStatus)
+async def get_status(db: AsyncSession = Depends(get_db)):
+    """Get current backup status."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        return GitHubBackupStatus(
+            configured=False,
+            enabled=False,
+            is_running=False,
+            progress=None,
+            last_backup_at=None,
+            last_backup_status=None,
+            next_scheduled_run=None,
+        )
+
+    return GitHubBackupStatus(
+        configured=True,
+        enabled=config.enabled,
+        is_running=github_backup_service.is_running,
+        progress=github_backup_service.progress,
+        last_backup_at=config.last_backup_at,
+        last_backup_status=config.last_backup_status,
+        next_scheduled_run=config.next_scheduled_run,
+    )
+
+
+@router.get("/logs", response_model=list[GitHubBackupLogResponse])
+async def get_logs(
+    limit: int = Query(default=50, ge=1, le=200),
+    offset: int = Query(default=0, ge=0),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get backup logs."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        return []
+
+    logs_result = await db.execute(
+        select(GitHubBackupLog)
+        .where(GitHubBackupLog.config_id == config.id)
+        .order_by(desc(GitHubBackupLog.started_at))
+        .offset(offset)
+        .limit(limit)
+    )
+    logs = logs_result.scalars().all()
+
+    return [
+        GitHubBackupLogResponse(
+            id=log.id,
+            config_id=log.config_id,
+            started_at=log.started_at,
+            completed_at=log.completed_at,
+            status=log.status,
+            trigger=log.trigger,
+            commit_sha=log.commit_sha,
+            files_changed=log.files_changed,
+            error_message=log.error_message,
+        )
+        for log in logs
+    ]
+
+
+@router.delete("/logs")
+async def clear_logs(
+    keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Clear backup logs, optionally keeping the most recent entries."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        return {"deleted": 0, "message": "No configuration found"}
+
+    if keep_last > 0:
+        # Get IDs to keep
+        keep_result = await db.execute(
+            select(GitHubBackupLog.id)
+            .where(GitHubBackupLog.config_id == config.id)
+            .order_by(desc(GitHubBackupLog.started_at))
+            .limit(keep_last)
+        )
+        keep_ids = [row[0] for row in keep_result.fetchall()]
+
+        if keep_ids:
+            delete_result = await db.execute(
+                delete(GitHubBackupLog).where(
+                    GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
+                )
+            )
+        else:
+            delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
+    else:
+        delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
+
+    await db.commit()
+
+    deleted_count = delete_result.rowcount
+    logger.info(f"Deleted {deleted_count} GitHub backup logs (kept {keep_last})")
+
+    return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}

+ 37 - 6
backend/app/api/routes/library.py

@@ -9,7 +9,7 @@ import shutil
 import uuid
 from pathlib import Path
 
-from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -761,8 +761,9 @@ async def upload_file(
 @router.post("/files/extract-zip", response_model=ZipExtractResponse)
 async def extract_zip_file(
     file: UploadFile = File(...),
-    folder_id: int | None = None,
-    preserve_structure: bool = True,
+    folder_id: int | None = Query(default=None),
+    preserve_structure: bool = Query(default=True),
+    create_folder_from_zip: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
 ):
     """Upload and extract a ZIP file to the library.
@@ -771,6 +772,7 @@ async def extract_zip_file(
         file: The ZIP file to extract
         folder_id: Target folder ID (None = root)
         preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
+        create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it
     """
     import tempfile
     import zipfile
@@ -798,6 +800,35 @@ async def extract_zip_file(
     folders_created = 0
     folder_cache: dict[str, int] = {}  # path -> folder_id
 
+    # If create_folder_from_zip is True, create a folder named after the ZIP file
+    zip_folder_id = folder_id
+    logger.info(
+        f"ZIP extraction: create_folder_from_zip={create_folder_from_zip}, folder_id={folder_id}, filename={file.filename}"
+    )
+    if create_folder_from_zip and file.filename:
+        # Remove .zip extension to get folder name
+        zip_folder_name = file.filename[:-4] if file.filename.lower().endswith(".zip") else file.filename
+        # Check if folder already exists
+        existing = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == zip_folder_name,
+                LibraryFolder.parent_id == folder_id if folder_id else LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing.scalar_one_or_none()
+        if existing_folder:
+            zip_folder_id = existing_folder.id
+            logger.info(f"Reusing existing folder '{zip_folder_name}' with id={zip_folder_id}")
+        else:
+            # Create folder
+            new_folder = LibraryFolder(name=zip_folder_name, parent_id=folder_id)
+            db.add(new_folder)
+            await db.flush()
+            await db.commit()  # Commit folder creation immediately
+            zip_folder_id = new_folder.id
+            folders_created += 1
+            logger.info(f"Created new folder '{zip_folder_name}' with id={zip_folder_id}")
+
     try:
         with zipfile.ZipFile(tmp_path, "r") as zf:
             # Filter out directories and hidden/system files
@@ -811,8 +842,8 @@ async def extract_zip_file(
 
             for zip_path in file_list:
                 try:
-                    # Determine target folder
-                    target_folder_id = folder_id
+                    # Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
+                    target_folder_id = zip_folder_id
 
                     if preserve_structure:
                         # Get directory path from ZIP
@@ -820,7 +851,7 @@ async def extract_zip_file(
                         if dir_path:
                             # Create folder structure
                             parts = dir_path.split("/")
-                            current_parent = folder_id
+                            current_parent = zip_folder_id
                             current_path = ""
 
                             for part in parts:

+ 418 - 0
backend/app/api/routes/metrics.py

@@ -0,0 +1,418 @@
+"""Prometheus metrics endpoint for external monitoring."""
+
+from fastapi import APIRouter, Depends, Header, HTTPException, Response
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.services.printer_manager import printer_manager, supports_chamber_temp
+
+router = APIRouter(tags=["metrics"])
+
+
+async def get_prometheus_settings(db: AsyncSession) -> tuple[bool, str]:
+    """Get Prometheus settings from database."""
+    result = await db.execute(select(Settings).where(Settings.key.in_(["prometheus_enabled", "prometheus_token"])))
+    settings_dict = {s.key: s.value for s in result.scalars().all()}
+
+    enabled = settings_dict.get("prometheus_enabled", "false").lower() == "true"
+    token = settings_dict.get("prometheus_token", "")
+    return enabled, token
+
+
+def format_labels(**labels: str) -> str:
+    """Format label key-value pairs for Prometheus."""
+    if not labels:
+        return ""
+    pairs = [f'{k}="{v}"' for k, v in labels.items() if v is not None]
+    return "{" + ",".join(pairs) + "}"
+
+
+def state_to_numeric(state: str) -> int:
+    """Convert printer state string to numeric value."""
+    state_map = {
+        "unknown": 0,
+        "IDLE": 1,
+        "RUNNING": 2,
+        "PAUSE": 3,
+        "FINISH": 4,
+        "FAILED": 5,
+        "PREPARE": 6,
+        "SLICING": 7,
+    }
+    return state_map.get(state, 0)
+
+
+@router.get("/metrics", response_class=Response)
+async def get_metrics(
+    db: AsyncSession = Depends(get_db),
+    authorization: str | None = Header(None),
+):
+    """
+    Prometheus metrics endpoint.
+
+    Returns metrics in Prometheus text exposition format.
+    Requires prometheus_enabled setting to be true.
+    If prometheus_token is set, requires Bearer token authentication.
+    """
+    # Check if enabled
+    enabled, token = await get_prometheus_settings(db)
+
+    if not enabled:
+        raise HTTPException(status_code=404, detail="Prometheus metrics not enabled")
+
+    # Check authentication if token is set
+    if token:
+        if not authorization:
+            raise HTTPException(status_code=401, detail="Authorization required")
+        if not authorization.startswith("Bearer "):
+            raise HTTPException(status_code=401, detail="Bearer token required")
+        provided_token = authorization[7:]  # Remove "Bearer " prefix
+        if provided_token != token:
+            raise HTTPException(status_code=401, detail="Invalid token")
+
+    lines: list[str] = []
+
+    # =========================================================================
+    # Printer metrics
+    # =========================================================================
+
+    # Get all printers from DB
+    result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
+    printers = list(result.scalars().all())
+
+    # Build lookup for printer info
+    printer_info = {p.id: p for p in printers}
+
+    # Get all connected printer statuses
+    all_statuses = printer_manager.get_all_statuses()
+
+    # Printer connection status
+    lines.append("# HELP bambuddy_printer_connected Printer connection status (1=connected, 0=disconnected)")
+    lines.append("# TYPE bambuddy_printer_connected gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        connected = 1 if status and status.connected else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+            model=printer.model or "unknown",
+        )
+        lines.append(f"bambuddy_printer_connected{labels} {connected}")
+
+    # Printer state
+    lines.append("")
+    lines.append(
+        "# HELP bambuddy_printer_state Printer state (0=unknown, 1=idle, 2=running, 3=pause, 4=finish, 5=failed, 6=prepare, 7=slicing)"
+    )
+    lines.append("# TYPE bambuddy_printer_state gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        state_val = state_to_numeric(status.state) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_printer_state{labels} {state_val}")
+
+    # Print progress
+    lines.append("")
+    lines.append("# HELP bambuddy_print_progress Current print progress (0-100)")
+    lines.append("# TYPE bambuddy_print_progress gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        progress = status.progress if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_progress{labels} {progress:.1f}")
+
+    # Remaining time
+    lines.append("")
+    lines.append("# HELP bambuddy_print_remaining_seconds Estimated remaining print time in seconds")
+    lines.append("# TYPE bambuddy_print_remaining_seconds gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        remaining = status.remaining_time * 60 if status else 0  # Convert minutes to seconds
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_remaining_seconds{labels} {remaining}")
+
+    # Layer progress
+    lines.append("")
+    lines.append("# HELP bambuddy_print_layer_current Current layer number")
+    lines.append("# TYPE bambuddy_print_layer_current gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        layer = status.layer_num if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_layer_current{labels} {layer}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_print_layer_total Total layers in current print")
+    lines.append("# TYPE bambuddy_print_layer_total gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        total = status.total_layers if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_print_layer_total{labels} {total}")
+
+    # =========================================================================
+    # Temperature metrics
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_bed_temp_celsius Current bed temperature")
+    lines.append("# TYPE bambuddy_bed_temp_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("bed", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_bed_temp_celsius{labels} {temp:.1f}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_bed_target_celsius Target bed temperature")
+    lines.append("# TYPE bambuddy_bed_target_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("bed_target", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_bed_target_celsius{labels} {temp:.1f}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_nozzle_temp_celsius Current nozzle temperature")
+    lines.append("# TYPE bambuddy_nozzle_temp_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        # Primary nozzle
+        temp = status.temperatures.get("nozzle", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+            nozzle="0",
+        )
+        lines.append(f"bambuddy_nozzle_temp_celsius{labels} {temp:.1f}")
+        # Second nozzle if present
+        if status and "nozzle_2" in status.temperatures:
+            temp2 = status.temperatures.get("nozzle_2", 0)
+            labels2 = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                nozzle="1",
+            )
+            lines.append(f"bambuddy_nozzle_temp_celsius{labels2} {temp2:.1f}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_nozzle_target_celsius Target nozzle temperature")
+    lines.append("# TYPE bambuddy_nozzle_target_celsius gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("nozzle_target", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+            nozzle="0",
+        )
+        lines.append(f"bambuddy_nozzle_target_celsius{labels} {temp:.1f}")
+        if status and "nozzle_2_target" in status.temperatures:
+            temp2 = status.temperatures.get("nozzle_2_target", 0)
+            labels2 = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                nozzle="1",
+            )
+            lines.append(f"bambuddy_nozzle_target_celsius{labels2} {temp2:.1f}")
+
+    lines.append("")
+    lines.append(
+        "# HELP bambuddy_chamber_temp_celsius Current chamber temperature (only for models with chamber sensor)"
+    )
+    lines.append("# TYPE bambuddy_chamber_temp_celsius gauge")
+    for printer in printers:
+        # Only report chamber temp for models that have a real sensor
+        if not supports_chamber_temp(printer.model):
+            continue
+        status = all_statuses.get(printer.id)
+        temp = status.temperatures.get("chamber", 0) if status else 0
+        labels = format_labels(
+            printer_id=str(printer.id),
+            printer_name=printer.name,
+            serial=printer.serial_number,
+        )
+        lines.append(f"bambuddy_chamber_temp_celsius{labels} {temp:.1f}")
+
+    # =========================================================================
+    # Fan speeds
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_fan_speed_percent Fan speed percentage")
+    lines.append("# TYPE bambuddy_fan_speed_percent gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        if not status:
+            continue
+        # Part cooling fan
+        if "part_fan" in status.temperatures:
+            val = status.temperatures["part_fan"]
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                fan="part",
+            )
+            lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
+        # Aux fan
+        if "aux_fan" in status.temperatures:
+            val = status.temperatures["aux_fan"]
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                fan="aux",
+            )
+            lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
+        # Chamber fan
+        if "chamber_fan" in status.temperatures:
+            val = status.temperatures["chamber_fan"]
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+                fan="chamber",
+            )
+            lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
+
+    # =========================================================================
+    # WiFi signal
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_wifi_signal_dbm WiFi signal strength in dBm")
+    lines.append("# TYPE bambuddy_wifi_signal_dbm gauge")
+    for printer in printers:
+        status = all_statuses.get(printer.id)
+        if status and status.wifi_signal is not None:
+            labels = format_labels(
+                printer_id=str(printer.id),
+                printer_name=printer.name,
+                serial=printer.serial_number,
+            )
+            lines.append(f"bambuddy_wifi_signal_dbm{labels} {status.wifi_signal}")
+
+    # =========================================================================
+    # Print statistics (from database)
+    # =========================================================================
+
+    # Total prints by status
+    lines.append("")
+    lines.append("# HELP bambuddy_prints_total Total number of prints by result")
+    lines.append("# TYPE bambuddy_prints_total counter")
+    result = await db.execute(select(PrintArchive.status, func.count(PrintArchive.id)).group_by(PrintArchive.status))
+    for print_result, count in result.all():
+        result_label = print_result or "unknown"
+        labels = format_labels(result=result_label)
+        lines.append(f"bambuddy_prints_total{labels} {count}")
+
+    # Total prints per printer
+    lines.append("")
+    lines.append("# HELP bambuddy_printer_prints_total Total prints per printer")
+    lines.append("# TYPE bambuddy_printer_prints_total counter")
+    result = await db.execute(
+        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
+    )
+    for printer_id, count in result.all():
+        if printer_id and printer_id in printer_info:
+            p = printer_info[printer_id]
+            labels = format_labels(
+                printer_id=str(printer_id),
+                printer_name=p.name,
+                serial=p.serial_number,
+            )
+            lines.append(f"bambuddy_printer_prints_total{labels} {count}")
+
+    # Total filament used
+    lines.append("")
+    lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
+    lines.append("# TYPE bambuddy_filament_used_grams counter")
+    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    total_filament = result.scalar() or 0
+    lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
+
+    # Total print time
+    lines.append("")
+    lines.append("# HELP bambuddy_print_time_seconds Total print time in seconds")
+    lines.append("# TYPE bambuddy_print_time_seconds counter")
+    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.print_time_seconds), 0)))
+    total_time = result.scalar() or 0
+    lines.append(f"bambuddy_print_time_seconds {total_time}")
+
+    # =========================================================================
+    # Queue metrics
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_queue_pending Number of pending queue items")
+    lines.append("# TYPE bambuddy_queue_pending gauge")
+    result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending"))
+    pending_count = result.scalar() or 0
+    lines.append(f"bambuddy_queue_pending {pending_count}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_queue_printing Number of currently printing queue items")
+    lines.append("# TYPE bambuddy_queue_printing gauge")
+    result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "printing"))
+    printing_count = result.scalar() or 0
+    lines.append(f"bambuddy_queue_printing {printing_count}")
+
+    # =========================================================================
+    # System metrics
+    # =========================================================================
+
+    lines.append("")
+    lines.append("# HELP bambuddy_printers_connected Number of connected printers")
+    lines.append("# TYPE bambuddy_printers_connected gauge")
+    connected_count = sum(1 for s in all_statuses.values() if s.connected)
+    lines.append(f"bambuddy_printers_connected {connected_count}")
+
+    lines.append("")
+    lines.append("# HELP bambuddy_printers_total Total number of configured printers")
+    lines.append("# TYPE bambuddy_printers_total gauge")
+    lines.append(f"bambuddy_printers_total {len(printers)}")
+
+    # Add trailing newline
+    lines.append("")
+
+    content = "\n".join(lines)
+    return Response(content=content, media_type="text/plain; version=0.0.4; charset=utf-8")

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

@@ -51,6 +51,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         # AMS-HT environmental alarms
         "on_ams_ht_humidity_high": provider.on_ams_ht_humidity_high,
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
+        # Build plate detection
+        "on_plate_not_empty": provider.on_plate_not_empty,
         # Quiet hours
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
@@ -112,6 +114,8 @@ async def create_notification_provider(
         # AMS-HT environmental alarms
         on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
+        # Build plate detection
+        on_plate_not_empty=provider_data.on_plate_not_empty,
         # Quiet hours
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_start=provider_data.quiet_hours_start,

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

@@ -15,6 +15,8 @@ from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.schemas.print_queue import (
+    PrintQueueBulkUpdate,
+    PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
     PrintQueueItemResponse,
     PrintQueueItemUpdate,
@@ -201,6 +203,55 @@ async def add_to_queue(
     return _enrich_response(item)
 
 
+@router.patch("/bulk", response_model=PrintQueueBulkUpdateResponse)
+async def bulk_update_queue_items(
+    data: PrintQueueBulkUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Bulk update multiple queue items with the same values.
+
+    Only pending items can be updated. Non-pending items are skipped.
+    """
+    if not data.item_ids:
+        raise HTTPException(400, "No item IDs provided")
+
+    # Get fields to update (exclude item_ids and unset fields)
+    update_data = data.model_dump(exclude={"item_ids"}, exclude_unset=True)
+    if not update_data:
+        raise HTTPException(400, "No fields to update")
+
+    # Validate printer_id if being changed
+    if "printer_id" in update_data and update_data["printer_id"] is not None:
+        result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Printer not found")
+
+    # Fetch all items
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
+    items = result.scalars().all()
+
+    updated_count = 0
+    skipped_count = 0
+
+    for item in items:
+        if item.status != "pending":
+            skipped_count += 1
+            continue
+
+        for field, value in update_data.items():
+            setattr(item, field, value)
+        updated_count += 1
+
+    await db.commit()
+
+    logger.info(f"Bulk updated {updated_count} queue items, skipped {skipped_count}")
+    return PrintQueueBulkUpdateResponse(
+        updated_count=updated_count,
+        skipped_count=skipped_count,
+        message=f"Updated {updated_count} items" + (f", skipped {skipped_count} non-pending" if skipped_count else ""),
+    )
+
+
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific queue item."""

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

@@ -68,6 +68,22 @@ async def create_printer(
     return printer
 
 
+@router.get("/usb-cameras")
+async def list_usb_cameras():
+    """List available USB cameras connected to the system.
+
+    Returns a list of detected V4L2 video devices with their info.
+    Only works on Linux systems with V4L2 support.
+
+    Returns:
+        List of dicts with {device: str, name: str, capabilities: list, formats?: list}
+    """
+    from backend.app.services.external_camera import list_usb_cameras
+
+    cameras = list_usb_cameras()
+    return {"cameras": cameras}
+
+
 @router.get("/{printer_id}", response_model=PrinterResponse)
 async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific printer."""
@@ -92,6 +108,22 @@ async def update_printer(
         raise HTTPException(404, "Printer not found")
 
     update_data = printer_data.model_dump(exclude_unset=True)
+
+    # Handle nested ROI object - flatten to individual columns
+    if "plate_detection_roi" in update_data:
+        roi = update_data.pop("plate_detection_roi")
+        if roi:
+            update_data["plate_detection_roi_x"] = roi.get("x")
+            update_data["plate_detection_roi_y"] = roi.get("y")
+            update_data["plate_detection_roi_w"] = roi.get("w")
+            update_data["plate_detection_roi_h"] = roi.get("h")
+        else:
+            # Clear ROI if set to null
+            update_data["plate_detection_roi_x"] = None
+            update_data["plate_detection_roi_y"] = None
+            update_data["plate_detection_roi_w"] = None
+            update_data["plate_detection_roi_h"] = None
+
     for field, value in update_data.items():
         setattr(printer, field, value)
 
@@ -376,7 +408,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         nozzles=nozzles,
         print_options=print_options,
         stg_cur=state.stg_cur,
-        stg_cur_name=get_derived_status_name(state),
+        stg_cur_name=get_derived_status_name(state, printer.model),
         stg=state.stg,
         airduct_mode=state.airduct_mode,
         speed_level=state.speed_level,
@@ -707,6 +739,50 @@ async def download_printer_file(
     )
 
 
+@router.post("/{printer_id}/files/download-zip")
+async def download_printer_files_as_zip(
+    printer_id: int,
+    request: dict,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download multiple files from the printer as a ZIP archive."""
+    import io
+
+    paths = request.get("paths", [])
+    if not paths:
+        raise HTTPException(400, "No files specified")
+
+    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")
+
+    # Create ZIP in memory
+    zip_buffer = io.BytesIO()
+    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+        for path in paths:
+            try:
+                data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
+                if data:
+                    filename = path.split("/")[-1]
+                    zf.writestr(filename, data)
+            except Exception as e:
+                logging.warning(f"Failed to add {path} to ZIP: {e}")
+                continue
+
+    zip_buffer.seek(0)
+    zip_data = zip_buffer.read()
+
+    if len(zip_data) == 0:
+        raise HTTPException(404, "No files could be downloaded")
+
+    return Response(
+        content=zip_data,
+        media_type="application/zip",
+        headers={"Content-Disposition": 'attachment; filename="printer-files.zip"'},
+    )
+
+
 @router.delete("/{printer_id}/files")
 async def delete_printer_file(
     printer_id: int,

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

@@ -1,18 +1,23 @@
+import io
+import json
 import logging
 import os
 import uuid
+import zipfile
 from datetime import datetime
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, StreamingResponse
 from sqlalchemy import case, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes.library import get_library_dir
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
@@ -25,6 +30,7 @@ from backend.app.schemas.project import (
     BOMItemUpdate,
     ProjectChildPreview,
     ProjectCreate,
+    ProjectImport,
     ProjectListResponse,
     ProjectResponse,
     ProjectStats,
@@ -1322,3 +1328,392 @@ async def get_project_timeline(
     events.sort(key=lambda e: e.timestamp, reverse=True)
 
     return events[:limit]
+
+
+# ============ Phase 10: Import/Export Endpoints ============
+
+
+@router.get("/{project_id}/export")
+async def export_project(
+    project_id: int,
+    format: str = "zip",  # "zip" (with files) or "json" (metadata only)
+    db: AsyncSession = Depends(get_db),
+):
+    """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get BOM items
+    bom_result = await db.execute(
+        select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id).order_by(ProjectBOMItem.sort_order)
+    )
+    bom_items = bom_result.scalars().all()
+
+    bom_export = [
+        {
+            "name": item.name,
+            "quantity_needed": item.quantity_needed,
+            "quantity_acquired": item.quantity_acquired,
+            "unit_price": item.unit_price,
+            "sourcing_url": item.sourcing_url,
+            "stl_filename": item.stl_filename,
+            "remarks": item.remarks,
+        }
+        for item in bom_items
+    ]
+
+    # Get linked folders and their files
+    folders_result = await db.execute(
+        select(LibraryFolder).where(LibraryFolder.project_id == project_id).order_by(LibraryFolder.name)
+    )
+    linked_folders = folders_result.scalars().all()
+
+    folders_export = []
+    files_to_include = []  # (archive_path, zip_path)
+
+    for folder in linked_folders:
+        # Get files in this folder
+        files_result = await db.execute(
+            select(LibraryFile).where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
+        )
+        files = files_result.scalars().all()
+
+        folder_files = []
+        for f in files:
+            folder_files.append(
+                {
+                    "filename": f.filename,
+                    "file_type": f.file_type,
+                    "notes": f.notes,
+                }
+            )
+            # Add file to include in ZIP
+            library_dir = get_library_dir()
+            file_path = library_dir / f.file_path
+            if file_path.exists():
+                zip_path = f"files/{folder.name}/{f.filename}"
+                files_to_include.append((file_path, zip_path))
+                # Also include thumbnail if exists
+                if f.thumbnail_path:
+                    thumb_path = library_dir / f.thumbnail_path
+                    if thumb_path.exists():
+                        thumb_zip_path = f"files/{folder.name}/.thumbnails/{f.filename}.png"
+                        files_to_include.append((thumb_path, thumb_zip_path))
+
+        folders_export.append(
+            {
+                "name": folder.name,
+                "files": folder_files,
+            }
+        )
+
+    # Build project JSON
+    project_data = {
+        "name": project.name,
+        "description": project.description,
+        "color": project.color,
+        "status": project.status,
+        "target_count": project.target_count,
+        "target_parts_count": project.target_parts_count,
+        "notes": project.notes,
+        "tags": project.tags,
+        "due_date": project.due_date.isoformat() if project.due_date else None,
+        "priority": project.priority,
+        "budget": project.budget,
+        "bom_items": bom_export,
+        "linked_folders": folders_export,
+    }
+
+    # Return JSON if requested (for bulk export)
+    if format == "json":
+        return project_data
+
+    # Create ZIP in memory
+    zip_buffer = io.BytesIO()
+    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+        # Add project.json
+        zf.writestr("project.json", json.dumps(project_data, indent=2))
+
+        # Add files
+        for file_path, zip_path in files_to_include:
+            zf.write(file_path, zip_path)
+
+    zip_buffer.seek(0)
+
+    # Generate filename
+    safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in project.name)
+    filename = f"{safe_name}_{datetime.now().strftime('%Y-%m-%d')}.zip"
+
+    return StreamingResponse(
+        zip_buffer,
+        media_type="application/zip",
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@router.post("/import", response_model=ProjectResponse)
+async def import_project(
+    data: ProjectImport,
+    db: AsyncSession = Depends(get_db),
+):
+    """Import a project with optional BOM items and linked folders."""
+    # Create the project
+    project = Project(
+        name=data.name,
+        description=data.description,
+        color=data.color,
+        status=data.status,
+        target_count=data.target_count,
+        target_parts_count=data.target_parts_count,
+        notes=data.notes,
+        tags=data.tags,
+        due_date=data.due_date,
+        priority=data.priority,
+        budget=data.budget,
+    )
+    db.add(project)
+    await db.flush()
+
+    # Create BOM items
+    for idx, bom_data in enumerate(data.bom_items):
+        bom_item = ProjectBOMItem(
+            project_id=project.id,
+            name=bom_data.name,
+            quantity_needed=bom_data.quantity_needed,
+            quantity_acquired=bom_data.quantity_acquired,
+            unit_price=bom_data.unit_price,
+            sourcing_url=bom_data.sourcing_url,
+            stl_filename=bom_data.stl_filename,
+            remarks=bom_data.remarks,
+            sort_order=idx,
+        )
+        db.add(bom_item)
+
+    # Create linked folders in library
+    for folder_data in data.linked_folders:
+        # Check if folder with this name already exists at root level
+        existing_result = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == folder_data.name,
+                LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing_result.scalar_one_or_none()
+
+        if existing_folder:
+            # Link existing folder to project
+            existing_folder.project_id = project.id
+        else:
+            # Create new folder linked to project
+            new_folder = LibraryFolder(
+                name=folder_data.name,
+                project_id=project.id,
+                is_external=False,
+                external_readonly=False,
+                external_show_hidden=False,
+            )
+            db.add(new_folder)
+
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+@router.post("/import/file", response_model=ProjectResponse)
+async def import_project_file(
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Import a project from a ZIP or JSON file."""
+    if not file.filename:
+        raise HTTPException(status_code=400, detail="No filename provided")
+
+    # Determine file type
+    filename_lower = file.filename.lower()
+    content = await file.read()
+
+    if filename_lower.endswith(".zip"):
+        # Extract project.json from ZIP
+        try:
+            with zipfile.ZipFile(io.BytesIO(content)) as zf:
+                if "project.json" not in zf.namelist():
+                    raise HTTPException(status_code=400, detail="ZIP must contain project.json")
+                project_json = zf.read("project.json")
+                data = json.loads(project_json)
+
+                # Get list of files in the ZIP
+                zip_files = {name: zf.read(name) for name in zf.namelist() if name.startswith("files/")}
+        except zipfile.BadZipFile:
+            raise HTTPException(status_code=400, detail="Invalid ZIP file")
+    elif filename_lower.endswith(".json"):
+        try:
+            data = json.loads(content)
+            zip_files = {}
+        except json.JSONDecodeError:
+            raise HTTPException(status_code=400, detail="Invalid JSON file")
+    else:
+        raise HTTPException(status_code=400, detail="File must be .zip or .json")
+
+    # Create the project
+    project = Project(
+        name=data.get("name", "Imported Project"),
+        description=data.get("description"),
+        color=data.get("color"),
+        status=data.get("status", "active"),
+        target_count=data.get("target_count"),
+        target_parts_count=data.get("target_parts_count"),
+        notes=data.get("notes"),
+        tags=data.get("tags"),
+        due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
+        priority=data.get("priority", 0),
+        budget=data.get("budget"),
+    )
+    db.add(project)
+    await db.flush()
+
+    # Create BOM items
+    for idx, bom_data in enumerate(data.get("bom_items", [])):
+        bom_item = ProjectBOMItem(
+            project_id=project.id,
+            name=bom_data.get("name", "Unnamed"),
+            quantity_needed=bom_data.get("quantity_needed", 1),
+            quantity_acquired=bom_data.get("quantity_acquired", 0),
+            unit_price=bom_data.get("unit_price"),
+            sourcing_url=bom_data.get("sourcing_url"),
+            stl_filename=bom_data.get("stl_filename"),
+            remarks=bom_data.get("remarks"),
+            sort_order=idx,
+        )
+        db.add(bom_item)
+
+    # Create linked folders and files
+    library_dir = get_library_dir()
+    for folder_data in data.get("linked_folders", []):
+        folder_name = folder_data.get("name")
+        if not folder_name:
+            continue
+
+        # Check if folder exists
+        existing_result = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == folder_name,
+                LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing_result.scalar_one_or_none()
+
+        if existing_folder:
+            # Link existing folder to project
+            existing_folder.project_id = project.id
+            folder = existing_folder
+        else:
+            # Create new folder
+            folder = LibraryFolder(
+                name=folder_name,
+                project_id=project.id,
+                is_external=False,
+                external_readonly=False,
+                external_show_hidden=False,
+            )
+            db.add(folder)
+            await db.flush()
+
+            # Create folder on disk
+            folder_path = library_dir / folder_name
+            folder_path.mkdir(parents=True, exist_ok=True)
+
+        # Import files for this folder from ZIP
+        folder_prefix = f"files/{folder_name}/"
+        for zip_path, file_content in zip_files.items():
+            if not zip_path.startswith(folder_prefix):
+                continue
+            if "/.thumbnails/" in zip_path:
+                continue  # Skip thumbnails, we'll regenerate them
+
+            relative_path = zip_path[len(folder_prefix) :]
+            if not relative_path:
+                continue
+
+            # Write file to disk
+            file_disk_path = library_dir / folder_name / relative_path
+            file_disk_path.parent.mkdir(parents=True, exist_ok=True)
+            file_disk_path.write_bytes(file_content)
+
+            # Determine file type
+            ext = Path(relative_path).suffix.lower()
+            if ext in [".stl", ".3mf", ".obj"]:
+                file_type = "model"
+            elif ext in [".gcode"]:
+                file_type = "gcode"
+            elif ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
+                file_type = "image"
+            else:
+                file_type = "other"
+
+            # Create library file record
+            lib_file = LibraryFile(
+                folder_id=folder.id,
+                filename=relative_path,
+                file_path=f"{folder_name}/{relative_path}",
+                file_type=file_type,
+                file_size=len(file_content),
+                is_external=False,
+            )
+            db.add(lib_file)
+
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )

+ 189 - 1
backend/app/api/routes/settings.py

@@ -15,6 +15,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.filament import Filament
+from backend.app.models.github_backup import GitHubBackupConfig
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
@@ -73,13 +74,14 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "capture_finish_photo",
                 "spoolman_enabled",
                 "check_updates",
-                "telemetry_enabled",
+                "check_printer_firmware",
                 "virtual_printer_enabled",
                 "ftp_retry_enabled",
                 "mqtt_enabled",
                 "mqtt_use_tls",
                 "ha_enabled",
                 "per_printer_mapping_expanded",
+                "prometheus_enabled",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
@@ -164,6 +166,16 @@ async def update_settings(
     return await get_settings(db)
 
 
+@router.patch("/", response_model=AppSettings)
+@router.patch("", response_model=AppSettings)
+async def patch_settings(
+    settings_update: AppSettingsUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Partially update application settings (same as PUT, for REST compatibility)."""
+    return await update_settings(settings_update, db)
+
+
 @router.post("/reset", response_model=AppSettings)
 async def reset_settings(db: AsyncSession = Depends(get_db)):
     """Reset all settings to defaults."""
@@ -233,6 +245,7 @@ async def export_backup(
     include_smart_plugs: bool = Query(True, description="Include smart plugs"),
     include_external_links: bool = Query(True, description="Include external sidebar links"),
     include_printers: bool = Query(False, description="Include printers (without access codes)"),
+    include_plate_calibration: bool = Query(False, description="Include plate detection reference images"),
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_maintenance: bool = Query(
         False, description="Include maintenance types, per-printer settings, and history"
@@ -246,6 +259,7 @@ async def export_backup(
     include_users: bool = Query(
         False, description="Include users (passwords not exported - users will need new passwords)"
     ),
+    include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
 ):
     """Export selected data as JSON backup."""
     backup: dict = {
@@ -296,6 +310,7 @@ async def export_backup(
                     "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False),
                     "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
                     "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
+                    "on_plate_not_empty": getattr(p, "on_plate_not_empty", True),
                     "quiet_hours_enabled": p.quiet_hours_enabled,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
@@ -404,6 +419,14 @@ async def export_backup(
                 "auto_archive": printer.auto_archive,
                 "print_hours_offset": printer.print_hours_offset,
                 "runtime_seconds": printer.runtime_seconds,
+                "external_camera_url": printer.external_camera_url,
+                "external_camera_type": printer.external_camera_type,
+                "external_camera_enabled": printer.external_camera_enabled,
+                "plate_detection_enabled": printer.plate_detection_enabled,
+                "plate_detection_roi_x": printer.plate_detection_roi_x,
+                "plate_detection_roi_y": printer.plate_detection_roi_y,
+                "plate_detection_roi_w": printer.plate_detection_roi_w,
+                "plate_detection_roi_h": printer.plate_detection_roi_h,
             }
             if include_access_codes:
                 printer_data["access_code"] = printer.access_code
@@ -412,6 +435,30 @@ async def export_backup(
         if include_access_codes:
             backup["included"].append("access_codes")
 
+    # Plate calibration references (requires include_printers)
+    if include_printers and include_plate_calibration:
+        plate_cal_dir = app_settings.plate_calibration_dir
+        if plate_cal_dir.exists():
+            backup["plate_calibration"] = {
+                "files": [],
+                "printer_id_to_serial": {},  # Map old printer IDs to serial numbers for restore
+            }
+            for cal_file in plate_cal_dir.iterdir():
+                if cal_file.is_file():
+                    backup["plate_calibration"]["files"].append(cal_file.name)
+                    # Extract printer ID from filename (e.g., "printer_1_ref_0.jpg" -> 1)
+                    if cal_file.name.startswith("printer_"):
+                        parts = cal_file.name.split("_")
+                        if len(parts) >= 2 and parts[1].isdigit():
+                            old_printer_id = int(parts[1])
+                            if old_printer_id not in backup["plate_calibration"]["printer_id_to_serial"]:
+                                # Look up serial number for this printer ID
+                                backup["plate_calibration"]["printer_id_to_serial"][old_printer_id] = (
+                                    printer_id_to_serial.get(old_printer_id)
+                                )
+            if backup["plate_calibration"]["files"]:
+                backup["included"].append("plate_calibration")
+
     # Filaments
     if include_filaments:
         result = await db.execute(select(Filament))
@@ -571,6 +618,17 @@ async def export_backup(
                 if icon_path.exists():
                     backup_files.append((link_data["custom_icon_path"], icon_path))
 
+    # Add plate calibration reference images
+    if "plate_calibration" in backup:
+        plate_cal_dir = app_settings.plate_calibration_dir
+        plate_cal_data = backup["plate_calibration"]
+        # Support both old list format and new dict format
+        filenames = plate_cal_data.get("files", []) if isinstance(plate_cal_data, dict) else plate_cal_data
+        for filename in filenames:
+            file_path = plate_cal_dir / filename
+            if file_path.exists():
+                backup_files.append((f"plate_calibration/{filename}", file_path))
+
     # Print archives with file paths for ZIP
     if include_archives:
         result = await db.execute(select(PrintArchive))
@@ -613,6 +671,7 @@ async def export_backup(
                 "completed_at": a.completed_at.isoformat() if a.completed_at else None,
                 "makerworld_url": a.makerworld_url,
                 "designer": a.designer,
+                "external_url": a.external_url,
                 "is_favorite": a.is_favorite,
                 "tags": a.tags,
                 "notes": a.notes,
@@ -799,6 +858,24 @@ async def export_backup(
             )
         backup["included"].append("users")
 
+    # GitHub backup configuration
+    if include_github_backup:
+        result = await db.execute(select(GitHubBackupConfig).limit(1))
+        config = result.scalar_one_or_none()
+        if config:
+            backup["github_backup"] = {
+                "repository_url": config.repository_url,
+                # access_token intentionally not exported for security
+                "branch": config.branch,
+                "schedule_enabled": config.schedule_enabled,
+                "schedule_type": config.schedule_type,
+                "backup_kprofiles": config.backup_kprofiles,
+                "backup_cloud_profiles": config.backup_cloud_profiles,
+                "backup_settings": config.backup_settings,
+                "enabled": config.enabled,
+            }
+            backup["included"].append("github_backup")
+
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
         zip_buffer = io.BytesIO()
@@ -844,6 +921,8 @@ async def import_backup(
         content = await file.read()
         base_dir = app_settings.base_dir
         files_restored = 0
+        # Store plate calibration files for later (need printer ID remapping after printers restored)
+        plate_cal_files: dict[str, bytes] = {}
 
         # Check if it's a ZIP file
         if file.filename and file.filename.endswith(".zip"):
@@ -864,6 +943,12 @@ async def import_backup(
                         # Ensure path is safe (no path traversal)
                         if ".." in zip_path or zip_path.startswith("/"):
                             continue
+                        # Plate calibration files - store for later processing after printers are restored
+                        if zip_path.startswith("plate_calibration/"):
+                            filename = zip_path.replace("plate_calibration/", "", 1)
+                            if filename:  # Skip directory entries
+                                plate_cal_files[filename] = zf.read(zip_path)
+                            continue
                         target_path = base_dir / zip_path
                         target_path.parent.mkdir(parents=True, exist_ok=True)
                         with zf.open(zip_path) as src, open(target_path, "wb") as dst:
@@ -890,6 +975,7 @@ async def import_backup(
         "projects": 0,
         "pending_uploads": 0,
         "users": 0,
+        "github_backup": 0,
     }
     skipped = {
         "settings": 0,
@@ -904,6 +990,7 @@ async def import_backup(
         "projects": 0,
         "pending_uploads": 0,
         "users": 0,
+        "github_backup": 0,
     }
     skipped_details = {
         "notification_providers": [],
@@ -959,6 +1046,18 @@ async def import_backup(
                             is_active_val = is_active_val.lower() == "true"
                         existing.is_active = is_active_val
 
+                    # Restore external camera settings
+                    existing.external_camera_url = printer_data.get("external_camera_url")
+                    existing.external_camera_type = printer_data.get("external_camera_type")
+                    existing.external_camera_enabled = printer_data.get("external_camera_enabled", False)
+
+                    # Restore plate detection settings
+                    existing.plate_detection_enabled = printer_data.get("plate_detection_enabled", False)
+                    existing.plate_detection_roi_x = printer_data.get("plate_detection_roi_x")
+                    existing.plate_detection_roi_y = printer_data.get("plate_detection_roi_y")
+                    existing.plate_detection_roi_w = printer_data.get("plate_detection_roi_w")
+                    existing.plate_detection_roi_h = printer_data.get("plate_detection_roi_h")
+
                     restored["printers"] += 1
                 else:
                     skipped["printers"] += 1
@@ -984,12 +1083,62 @@ async def import_backup(
                     auto_archive=printer_data.get("auto_archive", True),
                     print_hours_offset=printer_data.get("print_hours_offset", 0.0),
                     runtime_seconds=printer_data.get("runtime_seconds", 0),
+                    external_camera_url=printer_data.get("external_camera_url"),
+                    external_camera_type=printer_data.get("external_camera_type"),
+                    external_camera_enabled=printer_data.get("external_camera_enabled", False),
+                    plate_detection_enabled=printer_data.get("plate_detection_enabled", False),
+                    plate_detection_roi_x=printer_data.get("plate_detection_roi_x"),
+                    plate_detection_roi_y=printer_data.get("plate_detection_roi_y"),
+                    plate_detection_roi_w=printer_data.get("plate_detection_roi_w"),
+                    plate_detection_roi_h=printer_data.get("plate_detection_roi_h"),
                 )
                 db.add(printer)
                 restored["printers"] += 1
         # Flush printers so other sections can look them up
         await db.flush()
 
+    # Restore plate calibration files (remap printer IDs based on serial numbers)
+    if plate_cal_files:
+        # Build serial_number -> new_printer_id mapping
+        serial_to_new_id: dict[str, int] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            serial_to_new_id[pr.serial_number] = pr.id
+
+        # Get old_id -> serial mapping from backup (supports both old list format and new dict format)
+        plate_cal_data = backup.get("plate_calibration", {})
+        if isinstance(plate_cal_data, dict):
+            old_id_to_serial: dict[int, str | None] = {
+                int(k): v for k, v in plate_cal_data.get("printer_id_to_serial", {}).items()
+            }
+        else:
+            old_id_to_serial = {}
+
+        # Build old_id -> new_id mapping
+        old_id_to_new_id: dict[int, int] = {}
+        for old_id, serial in old_id_to_serial.items():
+            if serial and serial in serial_to_new_id:
+                old_id_to_new_id[old_id] = serial_to_new_id[serial]
+
+        app_settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True)
+
+        for filename, file_data in plate_cal_files.items():
+            # Parse old printer ID from filename (e.g., "printer_3_ref_0.jpg" -> 3)
+            new_filename = filename
+            if filename.startswith("printer_"):
+                parts = filename.split("_")
+                if len(parts) >= 2 and parts[1].isdigit():
+                    old_printer_id = int(parts[1])
+                    if old_printer_id in old_id_to_new_id:
+                        new_printer_id = old_id_to_new_id[old_printer_id]
+                        # Replace old ID with new ID in filename
+                        new_filename = filename.replace(f"printer_{old_printer_id}_", f"printer_{new_printer_id}_", 1)
+
+            target_path = app_settings.plate_calibration_dir / new_filename
+            with open(target_path, "wb") as f:
+                f.write(file_data)
+            files_restored += 1
+
     # Restore notification providers (skip or overwrite duplicates by name)
     # Build printer serial to ID lookup (printers were restored first)
     if "notification_providers" in backup:
@@ -1026,6 +1175,7 @@ async def import_backup(
                     existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False)
                     existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
                     existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
+                    existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True)
                     existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_end = provider_data.get("quiet_hours_end")
@@ -1055,6 +1205,7 @@ async def import_backup(
                     on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False),
                     on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
                     on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
+                    on_plate_not_empty=provider_data.get("on_plate_not_empty", True),
                     quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
@@ -1458,6 +1609,7 @@ async def import_backup(
                     status=archive_data.get("status", "completed"),
                     makerworld_url=archive_data.get("makerworld_url"),
                     designer=archive_data.get("designer"),
+                    external_url=archive_data.get("external_url"),
                     is_favorite=archive_data.get("is_favorite", False),
                     tags=archive_data.get("tags"),
                     notes=archive_data.get("notes"),
@@ -1838,6 +1990,42 @@ async def import_backup(
                 restored["users"] += 1
                 new_users.append(f"{user_data['username']} (temp password: {temp_password})")
 
+    # Restore GitHub backup configuration (note: access_token not included for security)
+    if "github_backup" in backup:
+        github_data = backup["github_backup"]
+        result = await db.execute(select(GitHubBackupConfig).limit(1))
+        existing = result.scalar_one_or_none()
+        if existing:
+            if overwrite:
+                existing.repository_url = github_data.get("repository_url", existing.repository_url)
+                existing.branch = github_data.get("branch", existing.branch)
+                existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled)
+                existing.schedule_type = github_data.get("schedule_type", existing.schedule_type)
+                existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles)
+                existing.backup_cloud_profiles = github_data.get(
+                    "backup_cloud_profiles", existing.backup_cloud_profiles
+                )
+                existing.backup_settings = github_data.get("backup_settings", existing.backup_settings)
+                existing.enabled = github_data.get("enabled", existing.enabled)
+                # Note: access_token must be re-entered after restore
+                restored["github_backup"] += 1
+            else:
+                skipped["github_backup"] += 1
+        else:
+            config = GitHubBackupConfig(
+                repository_url=github_data.get("repository_url", ""),
+                access_token="",  # Must be entered after restore
+                branch=github_data.get("branch", "main"),
+                schedule_enabled=github_data.get("schedule_enabled", False),
+                schedule_type=github_data.get("schedule_type", "daily"),
+                backup_kprofiles=github_data.get("backup_kprofiles", True),
+                backup_cloud_profiles=github_data.get("backup_cloud_profiles", True),
+                backup_settings=github_data.get("backup_settings", False),
+                enabled=False,  # Disabled until token is entered
+            )
+            db.add(config)
+            restored["github_backup"] += 1
+
     await db.commit()
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers

+ 9 - 2
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.1.6b11"
+APP_VERSION = "0.1.6"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)
@@ -16,6 +16,11 @@ _app_dir = Path(__file__).resolve().parent.parent.parent.parent
 _data_dir_env = os.environ.get("DATA_DIR")
 _data_dir = Path(_data_dir_env) if _data_dir_env else _app_dir
 
+# Plate calibration directory - special handling to maintain backwards compatibility
+# Docker: DATA_DIR/plate_calibration (e.g., /data/plate_calibration)
+# Local dev: project_root/data/plate_calibration (original location)
+_plate_cal_dir = Path(_data_dir_env) / "plate_calibration" if _data_dir_env else _app_dir / "data" / "plate_calibration"
+
 # Log directory - use LOG_DIR env var if set, otherwise use app_dir/logs
 _log_dir_env = os.environ.get("LOG_DIR")
 _log_dir = Path(_log_dir_env) if _log_dir_env else _app_dir / "logs"
@@ -52,6 +57,7 @@ class Settings(BaseSettings):
     # Paths
     base_dir: Path = _data_dir  # For backwards compatibility
     archive_dir: Path = _data_dir / "archive"
+    plate_calibration_dir: Path = _plate_cal_dir  # Plate detection references
     static_dir: Path = _app_dir / "static"  # Static files are part of app, not data
     log_dir: Path = _log_dir
     database_url: str = f"sqlite+aiosqlite:///{_db_path}"
@@ -71,7 +77,8 @@ class Settings(BaseSettings):
 settings = Settings()
 
 # Ensure directories exist
-settings.archive_dir.mkdir(exist_ok=True)
+settings.archive_dir.mkdir(parents=True, exist_ok=True)
+settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True)
 settings.static_dir.mkdir(exist_ok=True)
 if settings.log_to_file:
     settings.log_dir.mkdir(exist_ok=True)

+ 112 - 16
backend/app/core/database.py

@@ -38,6 +38,7 @@ async def init_db():
         archive,
         external_link,
         filament,
+        github_backup,
         kprofile_note,
         library,
         maintenance,
@@ -288,6 +289,12 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add plate not empty notification column to notification_providers
+    try:
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1"))
+    except Exception:
+        pass
+
     # Migration: Add notes column to projects (Phase 2)
     try:
         await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT"))
@@ -675,6 +682,84 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add external camera columns to printers
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
+    # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
+    try:
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)"))
+    except Exception:
+        pass
+
+    # Migration: Add is_external column to library_files for external cloud files
+    try:
+        await conn.execute(text("ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
+    # Migration: Add project_id column to library_files
+    try:
+        await conn.execute(
+            text("ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
+    # Migration: Add is_external column to library_folders for external cloud folders
+    try:
+        await conn.execute(text("ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
+    # Migration: Add external folder settings columns to library_folders
+    try:
+        await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)"))
+    except Exception:
+        pass
+
+    # Migration: Add plate_detection_enabled column to printers
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
+    # Migration: Add plate detection ROI columns to printers
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
@@ -683,21 +768,32 @@ async def seed_notification_templates():
     from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
 
     async with async_session() as session:
-        # Check if templates already exist
-        result = await session.execute(select(NotificationTemplate).limit(1))
-        if result.scalar_one_or_none() is not None:
-            # Templates already seeded
-            return
-
-        # Insert default templates
-        for template_data in DEFAULT_TEMPLATES:
-            template = NotificationTemplate(
-                event_type=template_data["event_type"],
-                name=template_data["name"],
-                title_template=template_data["title_template"],
-                body_template=template_data["body_template"],
-                is_default=True,
-            )
-            session.add(template)
+        # Get existing template event types
+        result = await session.execute(select(NotificationTemplate.event_type))
+        existing_types = {row[0] for row in result.fetchall()}
+
+        if not existing_types:
+            # No templates exist - insert all defaults
+            for template_data in DEFAULT_TEMPLATES:
+                template = NotificationTemplate(
+                    event_type=template_data["event_type"],
+                    name=template_data["name"],
+                    title_template=template_data["title_template"],
+                    body_template=template_data["body_template"],
+                    is_default=True,
+                )
+                session.add(template)
+        else:
+            # Templates exist - only add missing ones
+            for template_data in DEFAULT_TEMPLATES:
+                if template_data["event_type"] not in existing_types:
+                    template = NotificationTemplate(
+                        event_type=template_data["event_type"],
+                        name=template_data["name"],
+                        title_template=template_data["title_template"],
+                        body_template=template_data["body_template"],
+                        is_default=True,
+                    )
+                    session.add(template)
 
         await session.commit()

+ 523 - 72
backend/app/main.py

@@ -4,6 +4,127 @@ from contextlib import asynccontextmanager
 from datetime import UTC, datetime, timedelta
 from logging.handlers import RotatingFileHandler
 
+
+# =============================================================================
+# Dependency Check - runs before other imports to give helpful error messages
+# =============================================================================
+def _start_error_server(missing_packages: list):
+    """Start a minimal HTTP server to display dependency errors in browser."""
+    import os
+    import signal
+    from http.server import BaseHTTPRequestHandler, HTTPServer
+
+    packages_html = "".join(f"<li><code>{p}</code></li>" for p in missing_packages)
+
+    html = f"""<!DOCTYPE html>
+<html>
+<head>
+    <title>Bambuddy - Setup Required</title>
+    <style>
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #0f172a; color: #e2e8f0;
+            display: flex; justify-content: center; align-items: center;
+            min-height: 100vh; margin: 0; padding: 20px; box-sizing: border-box;
+        }}
+        .container {{
+            background: #1e293b; border-radius: 12px; padding: 40px;
+            max-width: 600px; text-align: center; box-shadow: 0 4px 20px rgba(0,0,0,0.3);
+        }}
+        h1 {{ color: #f87171; margin-bottom: 10px; }}
+        h2 {{ color: #94a3b8; font-weight: normal; margin-top: 0; }}
+        .packages {{
+            background: #0f172a; border-radius: 8px; padding: 20px;
+            margin: 20px 0; text-align: left;
+        }}
+        .packages ul {{ margin: 0; padding-left: 20px; }}
+        .packages li {{ color: #fbbf24; margin: 8px 0; }}
+        .command {{
+            background: #0f172a; border-radius: 8px; padding: 15px 20px;
+            margin: 15px 0; font-family: monospace; color: #4ade80;
+            text-align: left; overflow-x: auto;
+        }}
+        .note {{ color: #94a3b8; font-size: 14px; margin-top: 20px; }}
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>Setup Required</h1>
+        <h2>Missing Python packages</h2>
+        <div class="packages"><ul>{packages_html}</ul></div>
+        <p>To fix, run this command on your server:</p>
+        <div class="command">pip install -r requirements.txt</div>
+        <p>Or if using a virtual environment:</p>
+        <div class="command">./venv/bin/pip install -r requirements.txt</div>
+        <p class="note">After installing, restart Bambuddy:<br>
+        <code>sudo systemctl restart bambuddy</code></p>
+    </div>
+</body>
+</html>"""
+
+    class ErrorHandler(BaseHTTPRequestHandler):
+        def do_GET(self):
+            self.send_response(503)
+            self.send_header("Content-type", "text/html")
+            self.end_headers()
+            self.wfile.write(html.encode())
+
+        def log_message(self, format, *args):
+            print(f"[Error Server] {args[0]}")
+
+    port = int(os.environ.get("PORT", 8000))
+    print(f"\nStarting error server on http://0.0.0.0:{port}")
+    print("Visit this URL in your browser to see the error details.\n")
+
+    server = HTTPServer(("0.0.0.0", port), ErrorHandler)
+
+    def shutdown(signum, frame):
+        print("\nShutting down error server...")
+        raise SystemExit(0)
+
+    signal.signal(signal.SIGTERM, shutdown)
+    signal.signal(signal.SIGINT, shutdown)
+
+    server.serve_forever()
+
+
+def check_dependencies():
+    """Check that all required packages are installed."""
+    missing = []
+
+    # Map of import name -> package name (for pip install)
+    required = {
+        "jwt": "PyJWT",
+        "fastapi": "fastapi",
+        "uvicorn": "uvicorn",
+        "sqlalchemy": "sqlalchemy",
+        "aiosqlite": "aiosqlite",
+        "pydantic": "pydantic",
+        "paho.mqtt": "paho-mqtt",
+    }
+
+    for module, package in required.items():
+        try:
+            __import__(module)
+        except ImportError:
+            missing.append(package)
+
+    if missing:
+        print("\n" + "=" * 60)
+        print("ERROR: Missing required Python packages!")
+        print("=" * 60)
+        print(f"\nMissing packages: {', '.join(missing)}")
+        print("\nTo fix, run:")
+        print("  pip install -r requirements.txt")
+        print("\nOr if using a virtual environment:")
+        print("  ./venv/bin/pip install -r requirements.txt")
+        print("=" * 60 + "\n")
+        _start_error_server(missing)
+
+
+check_dependencies()
+# =============================================================================
+
 from fastapi import FastAPI
 
 # Import settings first for logging configuration
@@ -61,9 +182,11 @@ from backend.app.api.routes import (
     external_links,
     filaments,
     firmware,
+    github_backup,
     kprofiles,
     library,
     maintenance,
+    metrics,
     notification_templates,
     notifications,
     pending_uploads,
@@ -88,6 +211,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
 from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.github_backup import github_backup_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.notification_service import notification_service
@@ -100,7 +224,6 @@ from backend.app.services.printer_manager import (
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.tasmota import tasmota_service
-from backend.app.services.telemetry import start_telemetry_loop
 
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
@@ -112,6 +235,17 @@ _expected_prints: dict[tuple[int, str], int] = {}
 # Track starting energy for prints: {archive_id: starting_kwh}
 _print_energy_start: dict[int, float] = {}
 
+# Track reprints to add costs on completion: {archive_id}
+_reprint_archives: set[int] = set()
+
+# Track progress milestones for notifications: {printer_id: last_milestone_notified}
+# Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
+_last_progress_milestone: dict[int, int] = {}
+
+# Track HMS errors that have been notified: {printer_id: set of error codes}
+# This prevents sending duplicate notifications for the same error
+_notified_hms_errors: dict[int, set[str]] = {}
+
 
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota or Home Assistant).
@@ -279,6 +413,124 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
     _last_status_broadcast[printer_id] = status_key
 
+    # Check for progress milestone notifications (25%, 50%, 75%)
+    progress = state.progress or 0
+    is_printing = state.state in ("RUNNING", "PRINTING")
+
+    if is_printing and progress > 0:
+        # Determine which milestone we've reached
+        current_milestone = 0
+        if progress >= 75:
+            current_milestone = 75
+        elif progress >= 50:
+            current_milestone = 50
+        elif progress >= 25:
+            current_milestone = 25
+
+        last_milestone = _last_progress_milestone.get(printer_id, 0)
+
+        # If we've crossed a new milestone, send notification
+        if current_milestone > last_milestone:
+            _last_progress_milestone[printer_id] = current_milestone
+            try:
+                async with async_session() as db:
+                    from backend.app.models.printer import Printer
+
+                    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                    printer = result.scalar_one_or_none()
+                    printer_name = printer.name if printer else f"Printer {printer_id}"
+                    filename = state.subtask_name or state.gcode_file or "Unknown"
+                    # remaining_time is in minutes, convert to seconds for notification
+                    remaining_time_seconds = state.remaining_time * 60 if state.remaining_time else None
+
+                    await notification_service.on_print_progress(
+                        printer_id, printer_name, filename, current_milestone, db, remaining_time_seconds
+                    )
+            except Exception as e:
+                logging.getLogger(__name__).warning(f"Progress milestone notification failed: {e}")
+    elif progress < 5:
+        # Reset milestone tracking when print restarts or new print begins
+        _last_progress_milestone[printer_id] = 0
+
+    # Check for new HMS errors and send notifications
+    current_hms_errors = getattr(state, "hms_errors", []) or []
+    if current_hms_errors:
+        # Build set of current error codes (using attr for uniqueness)
+        current_error_codes = {f"{e.attr:08x}" for e in current_hms_errors}
+        previously_notified = _notified_hms_errors.get(printer_id, set())
+
+        # Find new errors that haven't been notified yet
+        new_error_codes = current_error_codes - previously_notified
+
+        if new_error_codes:
+            # Get the actual new errors for the notification
+            new_errors = [e for e in current_hms_errors if f"{e.attr:08x}" in new_error_codes]
+
+            try:
+                async with async_session() as db:
+                    from backend.app.models.printer import Printer
+
+                    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                    printer = result.scalar_one_or_none()
+                    printer_name = printer.name if printer else f"Printer {printer_id}"
+
+                    # Format error details for notification
+                    # Module 0x07 = AMS/Filament, 0x05 = Nozzle, 0x0C = Motion Controller, etc.
+                    module_names = {
+                        0x03: "Print/Task",
+                        0x05: "Nozzle/Extruder",
+                        0x07: "AMS/Filament",
+                        0x0C: "Motion Controller",
+                        0x12: "Chamber",
+                    }
+
+                    from backend.app.services.hms_errors import get_error_description
+
+                    for error in new_errors:
+                        module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
+                        # Build short code like "0700_8010"
+                        error_code_int = int(error.code.replace("0x", ""), 16) if error.code else 0
+                        short_code = f"{(error.attr >> 16) & 0xFFFF:04X}_{error_code_int:04X}"
+
+                        error_type = f"{module_name} Error"
+                        # Look up human-readable description
+                        description = get_error_description(short_code)
+                        error_detail = description if description else f"Error code: {short_code}"
+
+                        await notification_service.on_printer_error(
+                            printer_id, printer_name, error_type, db, error_detail
+                        )
+
+                    logging.getLogger(__name__).info(
+                        f"[HMS] Sent notification for {len(new_errors)} new error(s) on printer {printer_id}"
+                    )
+
+                    # Also publish to MQTT relay
+                    printer_info = printer_manager.get_printer(printer_id)
+                    if printer_info:
+                        errors_data = [
+                            {
+                                "code": e.code,
+                                "attr": e.attr,
+                                "module": e.module,
+                                "severity": e.severity,
+                            }
+                            for e in new_errors
+                        ]
+                        await mqtt_relay.on_printer_error(
+                            printer_id, printer_info.name, printer_info.serial_number, errors_data
+                        )
+
+            except Exception as e:
+                logging.getLogger(__name__).warning(f"HMS error notification failed: {e}")
+
+            # Update tracking with all current errors
+            _notified_hms_errors[printer_id] = current_error_codes
+    else:
+        # No HMS errors - clear tracking so future errors get notified
+        if printer_id in _notified_hms_errors:
+            _notified_hms_errors.pop(printer_id, None)
+
     await ws_manager.send_printer_status(
         printer_id,
         printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
@@ -461,6 +713,94 @@ async def on_print_start(printer_id: int, data: dict):
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printer = result.scalar_one_or_none()
 
+        # Plate detection check - pause if objects detected on build plate
+        if printer and printer.plate_detection_enabled:
+            try:
+                from backend.app.services.plate_detection import check_plate_empty
+
+                # Build ROI tuple from printer settings if available
+                roi = None
+                if all(
+                    [
+                        printer.plate_detection_roi_x is not None,
+                        printer.plate_detection_roi_y is not None,
+                        printer.plate_detection_roi_w is not None,
+                        printer.plate_detection_roi_h is not None,
+                    ]
+                ):
+                    roi = (
+                        printer.plate_detection_roi_x,
+                        printer.plate_detection_roi_y,
+                        printer.plate_detection_roi_w,
+                        printer.plate_detection_roi_h,
+                    )
+
+                # Auto-turn on chamber light if it's off for better detection
+                light_was_off = False
+                client = printer_manager.get_client(printer_id)
+                if client and client.state:
+                    light_was_off = not client.state.chamber_light
+                    if light_was_off:
+                        logger.info(f"[PLATE CHECK] Turning on chamber light for printer {printer_id}")
+                        client.set_chamber_light(True)
+                        # Wait for light to physically turn on and camera to adjust exposure
+                        await asyncio.sleep(2.5)
+
+                logger.info(f"[PLATE CHECK] Running plate detection for printer {printer_id}")
+                plate_result = await check_plate_empty(
+                    printer_id=printer_id,
+                    ip_address=printer.ip_address,
+                    access_code=printer.access_code,
+                    model=printer.model,
+                    include_debug_image=False,
+                    external_camera_url=printer.external_camera_url,
+                    external_camera_type=printer.external_camera_type,
+                    use_external=printer.external_camera_enabled,
+                    roi=roi,
+                )
+
+                # Restore chamber light to original state
+                if light_was_off and client:
+                    logger.info(f"[PLATE CHECK] Restoring chamber light to off for printer {printer_id}")
+                    client.set_chamber_light(False)
+
+                if not plate_result.needs_calibration and not plate_result.is_empty:
+                    # Objects detected - pause the print!
+                    logger.warning(
+                        f"[PLATE CHECK] Objects detected on plate for printer {printer_id}! "
+                        f"Confidence: {plate_result.confidence:.0%}, Diff: {plate_result.difference_percent:.1f}%"
+                    )
+                    client = printer_manager.get_client(printer_id)
+                    if client:
+                        client.pause_print()
+                        logger.info(f"[PLATE CHECK] Print paused for printer {printer_id}")
+
+                    # Send notification about plate not empty
+                    await ws_manager.broadcast(
+                        {
+                            "type": "plate_not_empty",
+                            "printer_id": printer_id,
+                            "printer_name": printer.name,
+                            "message": f"Objects detected on build plate! Print paused. (Diff: {plate_result.difference_percent:.1f}%)",
+                        }
+                    )
+
+                    # Also send push notification
+                    try:
+                        await notification_service.on_plate_not_empty(
+                            printer_id=printer_id,
+                            printer_name=printer.name,
+                            db=db,
+                            difference_percent=plate_result.difference_percent,
+                        )
+                    except Exception as notif_err:
+                        logger.warning(f"[PLATE CHECK] Failed to send notification: {notif_err}")
+                else:
+                    logger.info(f"[PLATE CHECK] Plate is empty for printer {printer_id}, proceeding with print")
+            except Exception as plate_err:
+                # Don't block print on plate detection errors
+                logger.warning(f"[PLATE CHECK] Plate detection failed for printer {printer_id}: {plate_err}")
+
         if not printer or not printer.auto_archive:
             # Send notification without archive data (auto-archive disabled)
             logger.info(
@@ -526,6 +866,10 @@ async def on_print_start(printer_id: int, data: dict):
                 if subtask_name:
                     _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
 
+                # Mark as reprint so we add cost on completion
+                _reprint_archives.add(archive.id)
+                logger.info(f"Marked archive {archive.id} as reprint for cost addition on completion")
+
                 # Set up energy tracking
                 try:
                     plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
@@ -665,6 +1009,8 @@ async def on_print_start(printer_id: int, data: dict):
             remote_paths = [
                 f"/cache/{try_filename}",
                 f"/model/{try_filename}",
+                f"/data/{try_filename}",
+                f"/data/Metadata/{try_filename}",
                 f"/{try_filename}",
             ]
 
@@ -706,48 +1052,54 @@ async def on_print_start(printer_id: int, data: dict):
             if downloaded_filename:
                 break
 
-        # If still not found, try listing /cache to find matching file
+        # If still not found, try listing directories to find matching file
+        # Different printer models use different directory structures
         if not downloaded_filename and (filename or subtask_name):
             search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
-            logger.info(f"Direct FTP download failed, listing /cache to find '{search_term}'")
-            try:
-                cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
-                threemf_files = [f.get("name") for f in cache_files if f.get("name", "").endswith(".3mf")]
-                logger.info(
-                    f"Found {len(threemf_files)} 3MF files in /cache: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
-                )
-                for f in cache_files:
-                    if f.get("is_directory"):
-                        continue
-                    fname = f.get("name", "")
-                    if fname.endswith(".3mf") and search_term in fname.lower():
-                        logger.info(f"Found matching file: {fname}")
-                        temp_path = app_settings.archive_dir / "temp" / fname
-                        temp_path.parent.mkdir(parents=True, exist_ok=True)
-                        if ftp_retry_enabled:
-                            downloaded = await with_ftp_retry(
-                                download_file_async,
-                                printer.ip_address,
-                                printer.access_code,
-                                f"/cache/{fname}",
-                                temp_path,
-                                max_retries=ftp_retry_count,
-                                retry_delay=ftp_retry_delay,
-                                operation_name=f"Download 3MF from /cache/{fname}",
-                            )
-                        else:
-                            downloaded = await download_file_async(
-                                printer.ip_address,
-                                printer.access_code,
-                                f"/cache/{fname}",
-                                temp_path,
-                            )
-                        if downloaded:
-                            downloaded_filename = fname
-                            logger.info(f"Found and downloaded from cache: {fname}")
-                            break
-            except Exception as e:
-                logger.warning(f"Failed to list cache: {e}")
+            logger.info(f"Direct FTP download failed, searching directories for '{search_term}'")
+            search_dirs = ["/cache", "/model", "/data", "/data/Metadata", "/"]
+            for search_dir in search_dirs:
+                if downloaded_filename:
+                    break
+                try:
+                    dir_files = await list_files_async(printer.ip_address, printer.access_code, search_dir)
+                    threemf_files = [f.get("name") for f in dir_files if f.get("name", "").endswith(".3mf")]
+                    if threemf_files:
+                        logger.info(
+                            f"Found {len(threemf_files)} 3MF files in {search_dir}: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
+                        )
+                    for f in dir_files:
+                        if f.get("is_directory"):
+                            continue
+                        fname = f.get("name", "")
+                        if fname.endswith(".3mf") and search_term in fname.lower():
+                            logger.info(f"Found matching file in {search_dir}: {fname}")
+                            temp_path = app_settings.archive_dir / "temp" / fname
+                            temp_path.parent.mkdir(parents=True, exist_ok=True)
+                            if ftp_retry_enabled:
+                                downloaded = await with_ftp_retry(
+                                    download_file_async,
+                                    printer.ip_address,
+                                    printer.access_code,
+                                    f"{search_dir}/{fname}",
+                                    temp_path,
+                                    max_retries=ftp_retry_count,
+                                    retry_delay=ftp_retry_delay,
+                                    operation_name=f"Download 3MF from {search_dir}/{fname}",
+                                )
+                            else:
+                                downloaded = await download_file_async(
+                                    printer.ip_address,
+                                    printer.access_code,
+                                    f"{search_dir}/{fname}",
+                                    temp_path,
+                                )
+                            if downloaded:
+                                downloaded_filename = fname
+                                logger.info(f"Found and downloaded from {search_dir}: {fname}")
+                                break
+                except Exception as e:
+                    logger.debug(f"Failed to list {search_dir}: {e}")
 
         if not downloaded_filename or not temp_path:
             logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
@@ -783,6 +1135,18 @@ async def on_print_start(printer_id: int, data: dict):
 
                 logger.info(f"Created fallback archive {fallback_archive.id} for {print_name} (no 3MF available)")
 
+                # Start timelapse session if external camera is enabled
+                if printer.external_camera_enabled and printer.external_camera_url:
+                    from backend.app.services.layer_timelapse import start_session
+
+                    start_session(
+                        printer_id,
+                        fallback_archive.id,
+                        printer.external_camera_url,
+                        printer.external_camera_type or "mjpeg",
+                    )
+                    logger.info(f"Started layer timelapse for printer {printer_id}, archive {fallback_archive.id}")
+
                 # Track as active print
                 _active_prints[(printer_id, fallback_archive.filename)] = fallback_archive.id
                 if filename:
@@ -857,6 +1221,18 @@ async def on_print_start(printer_id: int, data: dict):
 
                 logger.info(f"Created archive {archive.id} for {downloaded_filename}")
 
+                # Start timelapse session if external camera is enabled
+                if printer.external_camera_enabled and printer.external_camera_url:
+                    from backend.app.services.layer_timelapse import start_session
+
+                    start_session(
+                        printer_id,
+                        archive.id,
+                        printer.external_camera_url,
+                        printer.external_camera_type or "mjpeg",
+                    )
+                    logger.info(f"Started layer timelapse for printer {printer_id}, archive {archive.id}")
+
                 # Record starting energy from smart plug if available
                 try:
                     plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
@@ -1235,6 +1611,15 @@ async def on_print_complete(printer_id: int, data: dict):
             )
             logger.info(f"[ARCHIVE] Archive {archive_id} status updated to {status}, failure_reason={failure_reason}")
 
+            # Add cost for reprints (first prints have cost set in archive_print())
+            if status == "completed" and archive_id in _reprint_archives:
+                _reprint_archives.discard(archive_id)
+                try:
+                    await service.add_reprint_cost(archive_id)
+                    logger.info(f"[ARCHIVE] Added reprint cost for archive {archive_id}")
+                except Exception as e:
+                    logger.warning(f"[ARCHIVE] Failed to add reprint cost for archive {archive_id}: {e}")
+
             await ws_manager.send_archive_updated(
                 {
                     "id": archive_id,
@@ -1341,35 +1726,52 @@ async def on_print_complete(printer_id: int, data: dict):
                             archive_dir = app_settings.base_dir / Path(archive.file_path).parent
                             photo_filename = None
 
-                            # Check if camera stream is active - use buffered frame to avoid freeze
-                            # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
-                            active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
-                            active_chamber_for_printer = [
-                                k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
-                            ]
-                            buffered_frame = get_buffered_frame(printer_id)
-
-                            if (active_for_printer or active_chamber_for_printer) and buffered_frame:
-                                # Use frame from active stream
-                                logger.info("[PHOTO-BG] Using buffered frame from active stream")
-                                photos_dir = archive_dir / "photos"
-                                photos_dir.mkdir(parents=True, exist_ok=True)
-                                timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-                                photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-                                photo_path = photos_dir / photo_filename
-                                await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
-                                logger.info(f"[PHOTO-BG] Saved buffered frame: {photo_filename}")
-                            else:
-                                # No active stream - capture new frame
-                                from backend.app.services.camera import capture_finish_photo
-
-                                photo_filename = await capture_finish_photo(
-                                    printer_id=printer_id,
-                                    ip_address=printer.ip_address,
-                                    access_code=printer.access_code,
-                                    model=printer.model,
-                                    archive_dir=archive_dir,
+                            # Check for external camera first
+                            if printer.external_camera_enabled and printer.external_camera_url:
+                                logger.info("[PHOTO-BG] Using external camera")
+                                from backend.app.services.external_camera import capture_frame
+
+                                frame_data = await capture_frame(
+                                    printer.external_camera_url, printer.external_camera_type or "mjpeg"
                                 )
+                                if frame_data:
+                                    photos_dir = archive_dir / "photos"
+                                    photos_dir.mkdir(parents=True, exist_ok=True)
+                                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                                    photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                                    photo_path = photos_dir / photo_filename
+                                    await asyncio.to_thread(photo_path.write_bytes, frame_data)
+                                    logger.info(f"[PHOTO-BG] Saved external camera frame: {photo_filename}")
+                            else:
+                                # Check if camera stream is active - use buffered frame to avoid freeze
+                                # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
+                                active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
+                                active_chamber_for_printer = [
+                                    k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
+                                ]
+                                buffered_frame = get_buffered_frame(printer_id)
+
+                                if (active_for_printer or active_chamber_for_printer) and buffered_frame:
+                                    # Use frame from active stream
+                                    logger.info("[PHOTO-BG] Using buffered frame from active stream")
+                                    photos_dir = archive_dir / "photos"
+                                    photos_dir.mkdir(parents=True, exist_ok=True)
+                                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                                    photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                                    photo_path = photos_dir / photo_filename
+                                    await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
+                                    logger.info(f"[PHOTO-BG] Saved buffered frame: {photo_filename}")
+                                else:
+                                    # No active stream - capture new frame
+                                    from backend.app.services.camera import capture_finish_photo
+
+                                    photo_filename = await capture_finish_photo(
+                                        printer_id=printer_id,
+                                        ip_address=printer.ip_address,
+                                        access_code=printer.access_code,
+                                        model=printer.model,
+                                        archive_dir=archive_dir,
+                                    )
 
                             if photo_filename:
                                 photos = archive.photos or []
@@ -1505,6 +1907,43 @@ async def on_print_complete(printer_id: int, data: dict):
             await _background_notifications(None)
 
     asyncio.create_task(_photo_then_notify())
+
+    # Stitch external camera layer timelapse if session was active
+    print_status = data.get("status", "completed")
+
+    async def _background_layer_timelapse():
+        """Stitch layer timelapse and attach to archive."""
+        from backend.app.services.layer_timelapse import cancel_session, on_print_complete as tl_complete
+
+        try:
+            if print_status == "completed":
+                logger.info(f"[LAYER-TL] Stitching layer timelapse for printer {printer_id}")
+                timelapse_path = await tl_complete(printer_id)
+                if timelapse_path and archive_id:
+                    logger.info(f"[LAYER-TL] Attaching timelapse {timelapse_path} to archive {archive_id}")
+                    async with async_session() as db:
+                        service = ArchiveService(db)
+                        timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)
+                        await service.attach_timelapse(archive_id, timelapse_data, "layer_timelapse.mp4")
+                        # Clean up the temp file
+                        await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
+                        logger.info("[LAYER-TL] Layer timelapse attached successfully")
+                elif timelapse_path:
+                    # Timelapse created but no archive - just clean up
+                    await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
+            else:
+                # Print failed or cancelled - cancel timelapse session
+                cancel_session(printer_id)
+                logger.info(f"[LAYER-TL] Cancelled layer timelapse for printer {printer_id} (status: {print_status})")
+        except Exception as e:
+            logger.warning(f"[LAYER-TL] Failed: {e}")
+            # Try to cancel session on error
+            try:
+                cancel_session(printer_id)
+            except Exception:
+                pass
+
+    asyncio.create_task(_background_layer_timelapse())
     log_timing("All background tasks scheduled")
 
     # Auto-scan for timelapse if recording was active during the print
@@ -1904,6 +2343,15 @@ async def lifespan(app: FastAPI):
     printer_manager.set_print_complete_callback(on_print_complete)
     printer_manager.set_ams_change_callback(on_ams_change)
 
+    # Layer change callback for external camera timelapse
+    async def on_layer_change(printer_id: int, layer_num: int):
+        """Capture timelapse frame on layer change."""
+        from backend.app.services.layer_timelapse import on_layer_change as tl_layer_change
+
+        await tl_layer_change(printer_id, layer_num)
+
+    printer_manager.set_layer_change_callback(on_layer_change)
+
     # Initialize MQTT relay from settings
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting
@@ -1954,15 +2402,15 @@ async def lifespan(app: FastAPI):
     # Start the notification digest scheduler
     notification_service.start_digest_scheduler()
 
+    # Start the GitHub backup scheduler
+    await github_backup_service.start_scheduler()
+
     # Start AMS history recording
     start_ams_history_recording()
 
     # Start printer runtime tracking
     start_runtime_tracking()
 
-    # Start anonymous telemetry (opt-out via settings)
-    asyncio.create_task(start_telemetry_loop(async_session))
-
     # Initialize virtual printer manager
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -1996,6 +2444,7 @@ async def lifespan(app: FastAPI):
     print_scheduler.stop()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
+    github_backup_service.stop_scheduler()
     stop_ams_history_recording()
     stop_runtime_tracking()
     printer_manager.disconnect_all()
@@ -2042,6 +2491,8 @@ app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(discovery.router, prefix=app_settings.api_prefix)
 app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 app.include_router(firmware.router, prefix=app_settings.api_prefix)
+app.include_router(github_backup.router, prefix=app_settings.api_prefix)
+app.include_router(metrics.router, prefix=app_settings.api_prefix)
 
 
 # Serve static files (React build)

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

@@ -2,6 +2,7 @@ from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
+from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
@@ -33,4 +34,6 @@ __all__ = [
     "LibraryFolder",
     "LibraryFile",
     "User",
+    "GitHubBackupConfig",
+    "GitHubBackupLog",
 ]

+ 4 - 1
backend/app/models/archive.py

@@ -43,10 +43,13 @@ class PrintArchive(Base):
     # Extended metadata (JSON blob for flexibility)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
 
-    # MakerWorld info
+    # MakerWorld info (auto-extracted from 3MF)
     makerworld_url: Mapped[str | None] = mapped_column(String(500))
     designer: Mapped[str | None] = mapped_column(String(255))
 
+    # User-defined external link (Printables, Thingiverse, etc.)
+    external_url: Mapped[str | None] = mapped_column(String(500))
+
     # User additions
     is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
     tags: Mapped[str | None] = mapped_column(Text)

+ 65 - 0
backend/app/models/github_backup.py

@@ -0,0 +1,65 @@
+"""GitHub backup configuration and log models."""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class GitHubBackupConfig(Base):
+    """Configuration for GitHub profile backup."""
+
+    __tablename__ = "github_backup_config"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    repository_url: Mapped[str] = mapped_column(String(500))  # Full GitHub URL
+    access_token: Mapped[str] = mapped_column(Text)  # Personal Access Token
+    branch: Mapped[str] = mapped_column(String(100), default="main")
+
+    # Schedule configuration
+    schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    schedule_type: Mapped[str] = mapped_column(String(20), default="daily")  # hourly/daily/weekly
+    schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)  # For future cron support
+
+    # What to backup
+    backup_kprofiles: Mapped[bool] = mapped_column(Boolean, default=True)
+    backup_cloud_profiles: Mapped[bool] = mapped_column(Boolean, default=True)
+    backup_settings: Mapped[bool] = mapped_column(Boolean, default=False)
+
+    # Status tracking
+    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    last_backup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_backup_status: Mapped[str | None] = mapped_column(String(20), nullable=True)  # success/failed/skipped
+    last_backup_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    last_backup_commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
+    next_scheduled_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    # Timestamps
+    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())
+
+    # Relationships
+    logs: Mapped[list["GitHubBackupLog"]] = relationship(back_populates="config", cascade="all, delete-orphan")
+
+
+class GitHubBackupLog(Base):
+    """Log entry for GitHub backup runs."""
+
+    __tablename__ = "github_backup_logs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    config_id: Mapped[int] = mapped_column(ForeignKey("github_backup_config.id", ondelete="CASCADE"))
+
+    started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    status: Mapped[str] = mapped_column(String(20))  # running/success/failed/skipped
+    trigger: Mapped[str] = mapped_column(String(20))  # manual/scheduled
+
+    commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
+    files_changed: Mapped[int] = mapped_column(Integer, default=0)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Relationships
+    config: Mapped["GitHubBackupConfig"] = relationship(back_populates="logs")

+ 10 - 1
backend/app/models/library.py

@@ -2,7 +2,7 @@
 
 from datetime import datetime
 
-from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -17,6 +17,12 @@ class LibraryFolder(Base):
     name: Mapped[str] = mapped_column(String(255))
     parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
 
+    # External folder flags (for folders that point to external paths)
+    is_external: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_readonly: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
+
     # Link to project or archive
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
@@ -55,6 +61,9 @@ class LibraryFile(Base):
     folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
+    # External file flag
+    is_external: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # File info
     filename: Mapped[str] = mapped_column(String(255))  # Original filename
     file_path: Mapped[str] = mapped_column(String(500))  # Storage path

+ 3 - 0
backend/app/models/notification.py

@@ -80,6 +80,9 @@ class NotificationProvider(Base):
     on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold
     on_ams_ht_temperature_high = Column(Boolean, default=False)  # AMS-HT temperature above threshold
 
+    # Event triggers - Build plate detection
+    on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
+
     # Quiet hours (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)
     quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"

+ 6 - 0
backend/app/models/notification_template.py

@@ -67,6 +67,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Printer Error: {error_type}",
         "body_template": "{printer}\n{error_detail}",
     },
+    {
+        "event_type": "plate_not_empty",
+        "name": "Plate Not Empty",
+        "title_template": "Plate Not Empty - Print Paused",
+        "body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
+    },
     {
         "event_type": "filament_low",
         "name": "Filament Low",

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

@@ -24,6 +24,17 @@ class Printer(Base):
     last_runtime_update: Mapped[datetime | None] = mapped_column(
         DateTime, nullable=True
     )  # Last time runtime was updated
+    # External camera configuration
+    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)
+    # 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)
+    plate_detection_roi_x: Mapped[float | None] = mapped_column(Float, nullable=True)  # X start %
+    plate_detection_roi_y: Mapped[float | None] = mapped_column(Float, nullable=True)  # Y start %
+    plate_detection_roi_w: Mapped[float | None] = mapped_column(Float, nullable=True)  # Width %
+    plate_detection_roi_h: Mapped[float | None] = mapped_column(Float, nullable=True)  # Height %
     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())
 

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

@@ -11,6 +11,7 @@ class ArchiveBase(BaseModel):
     cost: float | None = None
     failure_reason: str | None = None
     quantity: int | None = None  # Number of items printed
+    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
 
 
 class ArchiveUpdate(ArchiveBase):
@@ -70,6 +71,7 @@ class ArchiveResponse(BaseModel):
 
     makerworld_url: str | None
     designer: str | None
+    external_url: str | None = None  # User-defined link (Printables, Thingiverse, etc.)
 
     is_favorite: bool
     tags: str | None

+ 154 - 0
backend/app/schemas/github_backup.py

@@ -0,0 +1,154 @@
+"""Pydantic schemas for GitHub backup configuration."""
+
+import re
+from datetime import datetime
+from enum import Enum
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class ScheduleType(str, Enum):
+    """Backup schedule types."""
+
+    HOURLY = "hourly"
+    DAILY = "daily"
+    WEEKLY = "weekly"
+
+
+class GitHubBackupConfigCreate(BaseModel):
+    """Schema for creating/updating GitHub backup config."""
+
+    repository_url: str = Field(..., min_length=1, max_length=500, description="GitHub repository URL")
+    access_token: str = Field(..., min_length=1, description="Personal Access Token")
+    branch: str = Field(default="main", max_length=100, description="Branch to push to")
+
+    schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
+    schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
+
+    backup_kprofiles: bool = Field(default=True, description="Backup K-profiles")
+    backup_cloud_profiles: bool = Field(default=True, description="Backup Bambu Cloud profiles")
+    backup_settings: bool = Field(default=False, description="Backup app settings")
+
+    enabled: bool = Field(default=True, description="Enable backup feature")
+
+    @field_validator("repository_url")
+    @classmethod
+    def validate_repo_url(cls, v: str) -> str:
+        """Validate GitHub repository URL format."""
+        # Accept various GitHub URL formats
+        patterns = [
+            r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
+            r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
+        ]
+        v = v.strip().rstrip("/")
+        if not any(re.match(p, v) for p in patterns):
+            raise ValueError("Invalid GitHub repository URL. Expected format: https://github.com/owner/repo")
+        return v
+
+
+class GitHubBackupConfigUpdate(BaseModel):
+    """Schema for updating GitHub backup config (all fields optional)."""
+
+    repository_url: str | None = Field(default=None, max_length=500)
+    access_token: str | None = Field(default=None)
+    branch: str | None = Field(default=None, max_length=100)
+
+    schedule_enabled: bool | None = None
+    schedule_type: ScheduleType | None = None
+
+    backup_kprofiles: bool | None = None
+    backup_cloud_profiles: bool | None = None
+    backup_settings: bool | None = None
+
+    enabled: bool | None = None
+
+    @field_validator("repository_url")
+    @classmethod
+    def validate_repo_url(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        patterns = [
+            r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
+            r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
+        ]
+        v = v.strip().rstrip("/")
+        if not any(re.match(p, v) for p in patterns):
+            raise ValueError("Invalid GitHub repository URL")
+        return v
+
+
+class GitHubBackupConfigResponse(BaseModel):
+    """Schema for GitHub backup config API response."""
+
+    id: int
+    repository_url: str
+    has_token: bool = Field(description="Whether an access token is configured")
+    branch: str
+
+    schedule_enabled: bool
+    schedule_type: str
+
+    backup_kprofiles: bool
+    backup_cloud_profiles: bool
+    backup_settings: bool
+
+    enabled: bool
+    last_backup_at: datetime | None
+    last_backup_status: str | None
+    last_backup_message: str | None
+    last_backup_commit_sha: str | None
+    next_scheduled_run: datetime | None
+
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class GitHubBackupLogResponse(BaseModel):
+    """Schema for backup log API response."""
+
+    id: int
+    config_id: int
+    started_at: datetime
+    completed_at: datetime | None
+    status: str
+    trigger: str
+    commit_sha: str | None
+    files_changed: int
+    error_message: str | None
+
+    class Config:
+        from_attributes = True
+
+
+class GitHubBackupStatus(BaseModel):
+    """Schema for current backup status."""
+
+    configured: bool = Field(description="Whether backup is configured")
+    enabled: bool = Field(description="Whether backup is enabled")
+    is_running: bool = Field(description="Whether a backup is currently running")
+    progress: str | None = Field(default=None, description="Current backup progress message")
+    last_backup_at: datetime | None
+    last_backup_status: str | None
+    next_scheduled_run: datetime | None
+
+
+class GitHubTestConnectionResponse(BaseModel):
+    """Schema for test connection response."""
+
+    success: bool
+    message: str
+    repo_name: str | None = None
+    permissions: dict | None = None
+
+
+class GitHubBackupTriggerResponse(BaseModel):
+    """Schema for manual backup trigger response."""
+
+    success: bool
+    message: str
+    log_id: int | None = None
+    commit_sha: str | None = None
+    files_changed: int = 0

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

@@ -50,6 +50,9 @@ class NotificationProviderBase(BaseModel):
         default=False, description="Notify when AMS-HT temperature exceeds threshold"
     )
 
+    # Event triggers - Build plate detection
+    on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
+
     # Quiet hours
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
     quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
@@ -114,6 +117,9 @@ class NotificationProviderUpdate(BaseModel):
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_temperature_high: bool | None = None
 
+    # Event triggers - Build plate detection
+    on_plate_not_empty: bool | None = None
+
     # Quiet hours
     quiet_hours_enabled: bool | None = None
     quiet_hours_start: str | None = None

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

@@ -100,3 +100,30 @@ class PrintQueueReorderItem(BaseModel):
 
 class PrintQueueReorder(BaseModel):
     items: list[PrintQueueReorderItem]
+
+
+class PrintQueueBulkUpdate(BaseModel):
+    """Bulk update multiple queue items with the same values."""
+
+    item_ids: list[int]
+    # Fields to update (all optional - only set fields are applied)
+    printer_id: int | None = None
+    scheduled_time: datetime | None = None
+    require_previous_success: bool | None = None
+    auto_off_after: bool | None = None
+    manual_start: bool | None = None
+    # Print options
+    bed_levelling: bool | None = None
+    flow_cali: bool | None = None
+    vibration_cali: bool | None = None
+    layer_inspect: bool | None = None
+    timelapse: bool | None = None
+    use_ams: bool | None = None
+
+
+class PrintQueueBulkUpdateResponse(BaseModel):
+    """Response for bulk update operation."""
+
+    updated_count: int
+    skipped_count: int  # Items that were not pending
+    message: str

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

@@ -11,12 +11,24 @@ class PrinterBase(BaseModel):
     model: str | None = None
     location: str | None = None  # Group/location name
     auto_archive: bool = True
+    external_camera_url: str | None = None
+    external_camera_type: str | None = None  # "mjpeg", "rtsp", "snapshot", "usb"
+    external_camera_enabled: bool = False
 
 
 class PrinterCreate(PrinterBase):
     pass
 
 
+class PlateDetectionROI(BaseModel):
+    """Region of interest for plate detection (percentages 0.0-1.0)."""
+
+    x: float = Field(..., ge=0.0, le=1.0)  # X start %
+    y: float = Field(..., ge=0.0, le=1.0)  # Y start %
+    w: float = Field(..., ge=0.0, le=1.0)  # Width %
+    h: float = Field(..., ge=0.0, le=1.0)  # Height %
+
+
 class PrinterUpdate(BaseModel):
     name: str | None = None
     ip_address: str | None = None
@@ -26,6 +38,11 @@ class PrinterUpdate(BaseModel):
     is_active: bool | None = None
     auto_archive: bool | None = None
     print_hours_offset: float | None = None
+    external_camera_url: str | None = None
+    external_camera_type: str | None = None
+    external_camera_enabled: bool | None = None
+    plate_detection_enabled: bool | None = None
+    plate_detection_roi: PlateDetectionROI | None = None
 
 
 class PrinterResponse(PrinterBase):
@@ -33,12 +50,56 @@ class PrinterResponse(PrinterBase):
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
     print_hours_offset: float = 0.0
+    external_camera_url: str | None = None
+    external_camera_type: str | None = None
+    external_camera_enabled: bool = False
+    plate_detection_enabled: bool = False
+    plate_detection_roi: PlateDetectionROI | None = None
     created_at: datetime
     updated_at: datetime
 
     class Config:
         from_attributes = True
 
+    @classmethod
+    def from_orm_with_roi(cls, printer) -> "PrinterResponse":
+        """Create response from ORM model, converting ROI fields to nested object."""
+        data = {
+            "id": printer.id,
+            "name": printer.name,
+            "serial_number": printer.serial_number,
+            "ip_address": printer.ip_address,
+            "access_code": printer.access_code,
+            "model": printer.model,
+            "location": printer.location,
+            "auto_archive": printer.auto_archive,
+            "external_camera_url": printer.external_camera_url,
+            "external_camera_type": printer.external_camera_type,
+            "external_camera_enabled": printer.external_camera_enabled,
+            "is_active": printer.is_active,
+            "nozzle_count": printer.nozzle_count,
+            "print_hours_offset": printer.print_hours_offset,
+            "plate_detection_enabled": printer.plate_detection_enabled,
+            "created_at": printer.created_at,
+            "updated_at": printer.updated_at,
+        }
+        # Build ROI object if any ROI field is set
+        if any(
+            [
+                printer.plate_detection_roi_x is not None,
+                printer.plate_detection_roi_y is not None,
+                printer.plate_detection_roi_w is not None,
+                printer.plate_detection_roi_h is not None,
+            ]
+        ):
+            data["plate_detection_roi"] = PlateDetectionROI(
+                x=printer.plate_detection_roi_x or 0.15,
+                y=printer.plate_detection_roi_y or 0.35,
+                w=printer.plate_detection_roi_w or 0.70,
+                h=printer.plate_detection_roi_h or 0.55,
+            )
+        return cls(**data)
+
 
 class HMSErrorResponse(BaseModel):
     code: str

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

@@ -205,3 +205,58 @@ class TimelineEvent(BaseModel):
     title: str
     description: str | None = None
     metadata: dict | None = None  # Additional event-specific data
+
+
+# Phase 10: Import/Export Schemas
+class BOMItemExport(BaseModel):
+    """Schema for exporting a BOM item."""
+
+    name: str
+    quantity_needed: int
+    quantity_acquired: int
+    unit_price: float | None
+    sourcing_url: str | None
+    stl_filename: str | None
+    remarks: str | None
+
+
+class LinkedFolderExport(BaseModel):
+    """Schema for exporting a linked library folder."""
+
+    name: str
+
+
+class ProjectExport(BaseModel):
+    """Schema for exporting a project."""
+
+    name: str
+    description: str | None
+    color: str | None
+    status: str
+    target_count: int | None
+    target_parts_count: int | None
+    notes: str | None
+    tags: str | None
+    due_date: datetime | None
+    priority: str
+    budget: float | None
+    bom_items: list[BOMItemExport] = []
+    linked_folders: list[LinkedFolderExport] = []
+
+
+class ProjectImport(BaseModel):
+    """Schema for importing a project."""
+
+    name: str
+    description: str | None = None
+    color: str | None = None
+    status: str = "active"
+    target_count: int | None = None
+    target_parts_count: int | None = None
+    notes: str | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
+    budget: float | None = None
+    bom_items: list[BOMItemExport] = []
+    linked_folders: list[LinkedFolderExport] = []

+ 10 - 4
backend/app/schemas/settings.py

@@ -26,6 +26,7 @@ class AppSettings(BaseModel):
 
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
+    check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
 
     # Language
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
@@ -53,9 +54,6 @@ class AppSettings(BaseModel):
     # Default printer for operations
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
 
-    # Telemetry
-    telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy")
-
     # Virtual Printer
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
@@ -116,6 +114,12 @@ class AppSettings(BaseModel):
         description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
     )
 
+    # Prometheus metrics endpoint
+    prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
+    prometheus_token: str = Field(
+        default="", description="Bearer token for Prometheus metrics authentication (optional)"
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -131,6 +135,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_url: str | None = None
     spoolman_sync_mode: str | None = None
     check_updates: bool | None = None
+    check_printer_firmware: bool | None = None
     notification_language: str | None = None
     ams_humidity_good: int | None = None
     ams_humidity_fair: int | None = None
@@ -141,7 +146,6 @@ class AppSettingsUpdate(BaseModel):
     date_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
-    telemetry_enabled: bool | None = None
     virtual_printer_enabled: bool | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_mode: str | None = None
@@ -168,3 +172,5 @@ class AppSettingsUpdate(BaseModel):
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
+    prometheus_enabled: bool | None = None
+    prometheus_token: str | None = None

+ 37 - 0
backend/app/services/archive.py

@@ -919,6 +919,43 @@ class ArchiveService:
         await self.db.commit()
         return True
 
+    async def add_reprint_cost(self, archive_id: int) -> bool:
+        """Add cost for a reprint to the existing archive cost."""
+        archive = await self.get_archive(archive_id)
+        if not archive:
+            return False
+
+        if not archive.filament_used_grams or not archive.filament_type:
+            return False
+
+        # Calculate cost based on filament type or default
+        from backend.app.api.routes.settings import get_setting
+
+        primary_type = archive.filament_type.split(",")[0].strip()
+
+        # Look up filament cost_per_kg from database
+        filament_result = await self.db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
+        filament = filament_result.scalar_one_or_none()
+
+        if filament:
+            cost_per_kg = filament.cost_per_kg
+        else:
+            # Use default filament cost from settings
+            default_cost_setting = await get_setting(self.db, "default_filament_cost")
+            cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
+
+        additional_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
+
+        # Add to existing cost (or set if None)
+        if archive.cost is None:
+            archive.cost = additional_cost
+        else:
+            archive.cost = round(archive.cost + additional_cost, 2)
+
+        await self.db.commit()
+        logger.info(f"Added reprint cost {additional_cost} to archive {archive_id}, new total: {archive.cost}")
+        return True
+
     async def list_archives(
         self,
         printer_id: int | None = None,

+ 2 - 2
backend/app/services/bambu_ftp.py

@@ -78,7 +78,7 @@ class BambuFTPClient:
     FTP_PORT = 990
     DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
     # Models that need SSL session reuse disabled (A1 series has FTP issues with session reuse)
-    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini", "P1S", "P1P")
+    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini", "P1S", "P1P", "P2S")
 
     def __init__(
         self,
@@ -323,7 +323,7 @@ class BambuFTPClient:
         # Calculate used space by listing root directories
         try:
             total_used = 0
-            dirs_to_scan = ["/cache", "/timelapse", "/model"]
+            dirs_to_scan = ["/cache", "/timelapse", "/model", "/data", "/data/Metadata", "/"]
 
             for dir_path in dirs_to_scan:
                 try:

+ 71 - 4
backend/app/services/bambu_mqtt.py

@@ -253,6 +253,7 @@ class BambuMQTTClient:
         on_print_start: Callable[[dict], None] | None = None,
         on_print_complete: Callable[[dict], None] | None = None,
         on_ams_change: Callable[[list], None] | None = None,
+        on_layer_change: Callable[[int], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -261,6 +262,7 @@ class BambuMQTTClient:
         self.on_print_start = on_print_start
         self.on_print_complete = on_print_complete
         self.on_ams_change = on_ams_change
+        self.on_layer_change = on_layer_change
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -932,9 +934,25 @@ class BambuMQTTClient:
                             # Merge: start with existing, update with new non-empty values
                             merged_tray = existing_trays[tray_id].copy()
                             for key, value in new_tray.items():
-                                # Only overwrite if new value is not empty/None
-                                # Exception: remain/k can be 0, which is valid
-                                if key in ("remain", "k", "id", "cali_idx") or value not in (
+                                # Fields that should always be updated (even with empty/zero values):
+                                # - remain, k, id, cali_idx: status indicators where 0 is valid
+                                # - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
+                                #   tray_color, tray_id_name: slot content indicators that must be
+                                #   cleared when a spool is removed (fixes #147 - old AMS empty slot)
+                                always_update_fields = (
+                                    "remain",
+                                    "k",
+                                    "id",
+                                    "cali_idx",
+                                    "tray_type",
+                                    "tray_sub_brands",
+                                    "tag_uid",
+                                    "tray_uuid",
+                                    "tray_info_idx",
+                                    "tray_color",
+                                    "tray_id_name",
+                                )
+                                if key in always_update_fields or value not in (
                                     None,
                                     "",
                                     "0000000000000000",
@@ -950,6 +968,48 @@ class BambuMQTTClient:
 
         # Convert back to list, sorted by ID for consistent ordering
         merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get("id", 0))
+
+        # Check tray_exist_bits to clear empty slots (Issue #147)
+        # New AMS models don't send empty tray data - they just update tray_exist_bits
+        # Each bit in tray_exist_bits represents a slot: bit=0 means empty, bit=1 means has spool
+        tray_exist_bits_str = ams_data.get("tray_exist_bits") if isinstance(ams_data, dict) else None
+        if tray_exist_bits_str:
+            try:
+                tray_exist_bits = int(tray_exist_bits_str, 16)
+                for ams_unit in merged_ams:
+                    ams_id_raw = ams_unit.get("id")
+                    if ams_id_raw is None:
+                        continue
+                    # Convert to int (may be string from JSON)
+                    ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
+                    if ams_id >= 128:  # Skip HT AMS (id >= 128)
+                        continue
+                    # Bits for this AMS unit: bits (ams_id*4) to (ams_id*4 + 3)
+                    for tray in ams_unit.get("tray", []):
+                        tray_id_raw = tray.get("id")
+                        if tray_id_raw is None:
+                            continue
+                        # Convert to int (may be string from JSON)
+                        tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
+                        global_bit = ams_id * 4 + tray_id
+                        slot_exists = (tray_exist_bits >> global_bit) & 1
+                        if not slot_exists and tray.get("tray_type"):
+                            # Slot is marked empty but has data - clear it
+                            logger.info(
+                                f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
+                                f"(tray_exist_bits bit {global_bit} = 0)"
+                            )
+                            tray["tray_type"] = ""
+                            tray["tray_sub_brands"] = ""
+                            tray["tray_color"] = ""
+                            tray["tray_id_name"] = ""
+                            tray["tag_uid"] = "0000000000000000"
+                            tray["tray_uuid"] = "00000000000000000000000000000000"
+                            tray["tray_info_idx"] = ""
+                            tray["remain"] = 0
+            except (ValueError, TypeError) as e:
+                logger.debug(f"[{self.serial_number}] Could not parse tray_exist_bits: {e}")
+
         self.state.raw_data["ams"] = merged_ams
 
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
@@ -1030,7 +1090,12 @@ class BambuMQTTClient:
                 )
             self.state.mc_print_sub_stage = new_sub_stage
         if "layer_num" in data:
-            self.state.layer_num = int(data["layer_num"])
+            new_layer = int(data["layer_num"])
+            old_layer = self.state.layer_num
+            self.state.layer_num = new_layer
+            # Trigger layer change callback if layer increased
+            if new_layer > old_layer and self.on_layer_change:
+                self.on_layer_change(new_layer)
         if "total_layer_num" in data:
             self.state.total_layers = int(data["total_layer_num"])
 
@@ -1736,6 +1801,8 @@ class BambuMQTTClient:
         if is_new_print or is_file_change:
             # Clear any old HMS errors when a new print starts
             self.state.hms_errors = []
+            # Reset layer tracking for new print (needed for layer-based timelapse)
+            self.state.layer_num = 0
             # Reset completion tracking for new print
             self._was_running = True
             self._completion_triggered = False

+ 643 - 0
backend/app/services/external_camera.py

@@ -0,0 +1,643 @@
+"""External camera service.
+
+Supports MJPEG streams, RTSP streams (via ffmpeg), HTTP snapshot URLs, and USB cameras.
+"""
+
+import asyncio
+import logging
+import re
+import shutil
+from collections.abc import AsyncGenerator
+from pathlib import Path
+
+import aiohttp
+
+logger = logging.getLogger(__name__)
+
+
+def list_usb_cameras() -> list[dict]:
+    """List available USB cameras (V4L2 devices on Linux).
+
+    Returns:
+        List of dicts with {device: str, name: str, capabilities: list}
+    """
+    cameras = []
+    video_devices = sorted(Path("/dev").glob("video*"))
+
+    for device in video_devices:
+        device_path = str(device)
+        info = {"device": device_path, "name": device.name, "capabilities": []}
+
+        # Try to get device info via v4l2-ctl
+        v4l2_ctl = shutil.which("v4l2-ctl")
+        if v4l2_ctl:
+            import subprocess
+
+            try:
+                result = subprocess.run(
+                    [v4l2_ctl, "-d", device_path, "--info"],
+                    capture_output=True,
+                    text=True,
+                    timeout=5,
+                )
+                if result.returncode == 0:
+                    # Parse device name from output
+                    for line in result.stdout.splitlines():
+                        if "Card type" in line:
+                            info["name"] = line.split(":", 1)[1].strip()
+                        elif "Driver name" in line:
+                            info["driver"] = line.split(":", 1)[1].strip()
+
+                    # Check if device supports video capture
+                    result = subprocess.run(
+                        [v4l2_ctl, "-d", device_path, "--list-formats"],
+                        capture_output=True,
+                        text=True,
+                        timeout=5,
+                    )
+                    if result.returncode == 0 and result.stdout.strip():
+                        info["capabilities"].append("capture")
+                        # Parse available formats
+                        formats = re.findall(r"'(\w+)'", result.stdout)
+                        info["formats"] = list(set(formats))
+
+            except (subprocess.TimeoutExpired, Exception) as e:
+                logger.debug(f"v4l2-ctl failed for {device_path}: {e}")
+
+        # Only include devices that look like video capture devices
+        # Skip metadata devices (typically odd numbered like video1, video3)
+        try:
+            device_num = int(device.name.replace("video", ""))
+            # Even numbered devices are usually capture, odd are metadata
+            # But also check if we got capabilities
+            if info.get("capabilities") or device_num % 2 == 0:
+                cameras.append(info)
+        except ValueError:
+            cameras.append(info)
+
+    return cameras
+
+
+def get_ffmpeg_path() -> str | None:
+    """Get the path to ffmpeg executable."""
+    # Try shutil.which first
+    path = shutil.which("ffmpeg")
+    if path:
+        return path
+    # Check common locations (systemd services may have limited PATH)
+    for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
+        if Path(common_path).exists():
+            return common_path
+    return None
+
+
+async def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes | None:
+    """Capture single frame from external camera.
+
+    Args:
+        url: Camera URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path)
+        camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
+        timeout: Connection timeout in seconds
+
+    Returns:
+        JPEG bytes or None on failure
+    """
+    logger.debug(f"capture_frame called: type={camera_type}, url={url[:50] if url else 'None'}...")
+    if camera_type == "mjpeg":
+        return await _capture_mjpeg_frame(url, timeout)
+    elif camera_type == "rtsp":
+        return await _capture_rtsp_frame(url, timeout)
+    elif camera_type == "snapshot":
+        return await _capture_snapshot(url, timeout)
+    elif camera_type == "usb":
+        return await _capture_usb_frame(url, timeout)
+    else:
+        logger.warning(f"Unknown camera type: {camera_type}")
+        return None
+
+
+async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
+    """Capture frame from USB camera using ffmpeg."""
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - required for USB camera capture")
+        return None
+
+    # Validate device path
+    if not device.startswith("/dev/video"):
+        logger.error(f"Invalid USB device path: {device}")
+        return None
+
+    if not Path(device).exists():
+        logger.error(f"USB device does not exist: {device}")
+        return None
+
+    # Use ffmpeg to grab a single frame from USB camera
+    cmd = [
+        ffmpeg,
+        "-f",
+        "v4l2",
+        "-i",
+        device,
+        "-frames:v",
+        "1",
+        "-f",
+        "image2pipe",
+        "-vcodec",
+        "mjpeg",
+        "-q:v",
+        "2",
+        "-",
+    ]
+
+    try:
+        logger.debug(f"Running USB capture: {' '.join(cmd)}")
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+
+        if process.returncode != 0:
+            logger.error(f"ffmpeg USB capture failed: {stderr.decode()[:200]}")
+            return None
+
+        if not stdout or len(stdout) < 100:
+            logger.error("ffmpeg returned empty or too small frame from USB camera")
+            return None
+
+        return stdout
+
+    except TimeoutError:
+        logger.warning(f"USB frame capture timed out after {timeout}s")
+        if process:
+            process.kill()
+        return None
+    except Exception as e:
+        logger.error(f"USB frame capture failed: {e}")
+        return None
+
+
+async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
+    """Extract single frame from MJPEG stream."""
+    try:
+        async with (
+            aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
+            session.get(url) as response,
+        ):
+            if response.status != 200:
+                logger.error(f"MJPEG stream returned status {response.status}")
+                return None
+
+            # Read chunks until we find a complete JPEG frame
+            buffer = b""
+            jpeg_start = b"\xff\xd8"
+            jpeg_end = b"\xff\xd9"
+
+            async for chunk in response.content.iter_chunked(8192):
+                buffer += chunk
+
+                # Look for complete JPEG frame
+                start_idx = buffer.find(jpeg_start)
+                if start_idx == -1:
+                    continue
+
+                end_idx = buffer.find(jpeg_end, start_idx + 2)
+                if end_idx != -1:
+                    # Found complete frame
+                    frame = buffer[start_idx : end_idx + 2]
+                    return frame
+
+                # Keep searching, but limit buffer size
+                if len(buffer) > 5 * 1024 * 1024:  # 5MB limit
+                    logger.warning("MJPEG buffer exceeded 5MB without finding frame")
+                    return None
+
+    except TimeoutError:
+        logger.warning(f"MJPEG frame capture timed out after {timeout}s")
+        return None
+    except Exception as e:
+        logger.error(f"MJPEG frame capture failed: {e}")
+        return None
+
+    return None
+
+
+async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
+    """Capture frame from RTSP using ffmpeg."""
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - required for RTSP capture")
+        return None
+
+    # Use ffmpeg to grab a single frame from RTSP stream
+    # ffmpeg handles both rtsp:// and rtsps:// URLs automatically
+    cmd = [
+        ffmpeg,
+        "-rtsp_transport",
+        "tcp",
+        "-i",
+        url,
+        "-frames:v",
+        "1",
+        "-f",
+        "image2pipe",
+        "-vcodec",
+        "mjpeg",
+        "-q:v",
+        "2",
+        "-",
+    ]
+
+    try:
+        print(f"[EXT-CAM] Running ffmpeg command: {' '.join(cmd[:6])}...")
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+        print(
+            f"[EXT-CAM] ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
+        )
+
+        if process.returncode != 0:
+            logger.error(f"ffmpeg RTSP capture failed: {stderr.decode()[:200]}")
+            print(f"[EXT-CAM] ffmpeg error: {stderr.decode()[:300]}")
+            return None
+
+        if not stdout or len(stdout) < 100:
+            logger.error("ffmpeg returned empty or too small frame")
+            return None
+
+        return stdout
+
+    except TimeoutError:
+        logger.warning(f"RTSP frame capture timed out after {timeout}s")
+        if process:
+            process.kill()
+        return None
+    except Exception as e:
+        logger.error(f"RTSP frame capture failed: {e}")
+        return None
+
+
+async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
+    """Fetch snapshot from HTTP URL."""
+    try:
+        async with (
+            aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
+            session.get(url) as response,
+        ):
+            if response.status != 200:
+                logger.error(f"Snapshot URL returned status {response.status}")
+                return None
+
+            data = await response.read()
+
+            # Validate it looks like JPEG
+            if not data.startswith(b"\xff\xd8"):
+                logger.warning("Snapshot does not appear to be JPEG")
+                # Still return it - might be valid with different header
+
+            return data
+
+    except TimeoutError:
+        logger.warning(f"Snapshot capture timed out after {timeout}s")
+        return None
+    except Exception as e:
+        logger.error(f"Snapshot capture failed: {e}")
+        return None
+
+
+async def test_connection(url: str, camera_type: str) -> dict:
+    """Test camera connection.
+
+    Returns:
+        Dict with {success: bool, error?: str, resolution?: str}
+    """
+    print(f"[EXT-CAM] Testing camera connection: type={camera_type}, url={url[:50]}...")
+    logger.info(f"Testing camera connection: type={camera_type}, url={url[:50]}...")
+    try:
+        frame = await capture_frame(url, camera_type, timeout=10)
+        print(f"[EXT-CAM] Capture result: {len(frame) if frame else 0} bytes")
+        logger.info(f"Capture result: {len(frame) if frame else 0} bytes")
+
+        if frame:
+            # Try to get resolution from JPEG header
+            resolution = None
+            try:
+                # Simple JPEG dimension extraction
+                # SOF0 marker is FF C0, followed by length, precision, height, width
+                sof_markers = [b"\xff\xc0", b"\xff\xc1", b"\xff\xc2"]
+                for marker in sof_markers:
+                    idx = frame.find(marker)
+                    if idx != -1 and idx + 9 <= len(frame):
+                        height = (frame[idx + 5] << 8) | frame[idx + 6]
+                        width = (frame[idx + 7] << 8) | frame[idx + 8]
+                        resolution = f"{width}x{height}"
+                        break
+            except Exception:
+                pass
+
+            return {"success": True, "resolution": resolution}
+        else:
+            return {"success": False, "error": "Failed to capture frame from camera"}
+
+    except Exception as e:
+        return {"success": False, "error": str(e)}
+
+
+async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
+    """Generator yielding MJPEG frames for streaming.
+
+    Args:
+        url: Camera URL or USB device path
+        camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
+        fps: Target frames per second
+
+    Yields:
+        MJPEG frame data with HTTP multipart boundaries
+    """
+    frame_interval = 1.0 / max(fps, 1)
+    last_frame_time = 0.0
+
+    if camera_type == "mjpeg":
+        # Proxy MJPEG stream directly
+        async for frame in _stream_mjpeg(url):
+            current_time = asyncio.get_event_loop().time()
+            if current_time - last_frame_time >= frame_interval:
+                last_frame_time = current_time
+                yield _format_mjpeg_frame(frame)
+
+    elif camera_type == "rtsp":
+        # Use ffmpeg to convert RTSP to MJPEG
+        async for frame in _stream_rtsp(url, fps):
+            yield _format_mjpeg_frame(frame)
+
+    elif camera_type == "usb":
+        # Use ffmpeg to stream from USB camera
+        async for frame in _stream_usb(url, fps):
+            yield _format_mjpeg_frame(frame)
+
+    elif camera_type == "snapshot":
+        # Poll snapshot URL at interval
+        while True:
+            try:
+                frame = await _capture_snapshot(url, timeout=10)
+                if frame:
+                    yield _format_mjpeg_frame(frame)
+                await asyncio.sleep(frame_interval)
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.warning(f"Snapshot poll failed: {e}")
+                await asyncio.sleep(frame_interval)
+
+
+def _format_mjpeg_frame(frame: bytes) -> bytes:
+    """Format frame for MJPEG HTTP response."""
+    return (
+        b"--frame\r\n"
+        b"Content-Type: image/jpeg\r\n"
+        b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
+        b"\r\n" + frame + b"\r\n"
+    )
+
+
+async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
+    """Stream frames from MJPEG URL."""
+    try:
+        timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
+        async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
+            if response.status != 200:
+                logger.error(f"MJPEG stream returned status {response.status}")
+                return
+
+            buffer = b""
+            jpeg_start = b"\xff\xd8"
+            jpeg_end = b"\xff\xd9"
+
+            async for chunk in response.content.iter_chunked(8192):
+                buffer += chunk
+
+                # Extract complete frames from buffer
+                while True:
+                    start_idx = buffer.find(jpeg_start)
+                    if start_idx == -1:
+                        buffer = buffer[-2:] if len(buffer) > 2 else buffer
+                        break
+
+                    if start_idx > 0:
+                        buffer = buffer[start_idx:]
+
+                    end_idx = buffer.find(jpeg_end, 2)
+                    if end_idx == -1:
+                        break
+
+                    frame = buffer[: end_idx + 2]
+                    buffer = buffer[end_idx + 2 :]
+                    yield frame
+
+    except asyncio.CancelledError:
+        logger.info("MJPEG stream cancelled")
+    except Exception as e:
+        logger.error(f"MJPEG stream error: {e}")
+
+
+async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
+    """Stream frames from RTSP URL via ffmpeg."""
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - required for RTSP streaming")
+        return
+
+    # ffmpeg handles both rtsp:// and rtsps:// URLs automatically
+    cmd = [
+        ffmpeg,
+        "-rtsp_transport",
+        "tcp",
+        "-rtsp_flags",
+        "prefer_tcp",
+        "-timeout",
+        "30000000",
+        "-buffer_size",
+        "1024000",
+        "-max_delay",
+        "500000",
+        "-i",
+        url,
+        "-f",
+        "mjpeg",
+        "-q:v",
+        "5",
+        "-r",
+        str(fps),
+        "-an",
+        "-",
+    ]
+
+    process = None
+    try:
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        # Give ffmpeg a moment to start and check for immediate failures
+        await asyncio.sleep(0.5)
+        if process.returncode is not None:
+            stderr = await process.stderr.read()
+            logger.error(f"ffmpeg RTSP stream failed immediately: {stderr.decode()[:300]}")
+            return
+
+        buffer = b""
+        jpeg_start = b"\xff\xd8"
+        jpeg_end = b"\xff\xd9"
+
+        while True:
+            try:
+                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
+
+                if not chunk:
+                    break
+
+                buffer += chunk
+
+                # Extract complete frames
+                while True:
+                    start_idx = buffer.find(jpeg_start)
+                    if start_idx == -1:
+                        buffer = buffer[-2:] if len(buffer) > 2 else buffer
+                        break
+
+                    if start_idx > 0:
+                        buffer = buffer[start_idx:]
+
+                    end_idx = buffer.find(jpeg_end, 2)
+                    if end_idx == -1:
+                        break
+
+                    frame = buffer[: end_idx + 2]
+                    buffer = buffer[end_idx + 2 :]
+                    yield frame
+
+            except TimeoutError:
+                logger.warning("RTSP stream read timeout")
+                break
+
+    except asyncio.CancelledError:
+        logger.info("RTSP stream cancelled")
+    except Exception as e:
+        logger.error(f"RTSP stream error: {e}")
+    finally:
+        if process and process.returncode is None:
+            process.terminate()
+            try:
+                await asyncio.wait_for(process.wait(), timeout=2.0)
+            except TimeoutError:
+                process.kill()
+                await process.wait()
+
+
+async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
+    """Stream frames from USB camera via ffmpeg."""
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - required for USB camera streaming")
+        return
+
+    # Validate device path
+    if not device.startswith("/dev/video"):
+        logger.error(f"Invalid USB device path: {device}")
+        return
+
+    if not Path(device).exists():
+        logger.error(f"USB device does not exist: {device}")
+        return
+
+    # ffmpeg command to stream from USB camera (v4l2)
+    cmd = [
+        ffmpeg,
+        "-f",
+        "v4l2",
+        "-framerate",
+        str(fps),
+        "-i",
+        device,
+        "-f",
+        "mjpeg",
+        "-q:v",
+        "5",
+        "-r",
+        str(fps),
+        "-",
+    ]
+
+    process = None
+    try:
+        logger.info(f"Starting USB camera stream from {device} at {fps} fps")
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        # Give ffmpeg a moment to start and check for immediate failures
+        await asyncio.sleep(0.5)
+        if process.returncode is not None:
+            stderr = await process.stderr.read()
+            logger.error(f"ffmpeg USB stream failed immediately: {stderr.decode()[:300]}")
+            return
+
+        buffer = b""
+        jpeg_start = b"\xff\xd8"
+        jpeg_end = b"\xff\xd9"
+
+        while True:
+            try:
+                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
+
+                if not chunk:
+                    break
+
+                buffer += chunk
+
+                # Extract complete frames
+                while True:
+                    start_idx = buffer.find(jpeg_start)
+                    if start_idx == -1:
+                        buffer = buffer[-2:] if len(buffer) > 2 else buffer
+                        break
+
+                    if start_idx > 0:
+                        buffer = buffer[start_idx:]
+
+                    end_idx = buffer.find(jpeg_end, 2)
+                    if end_idx == -1:
+                        break
+
+                    frame = buffer[: end_idx + 2]
+                    buffer = buffer[end_idx + 2 :]
+                    yield frame
+
+            except TimeoutError:
+                logger.warning("USB stream read timeout")
+                break
+
+    except asyncio.CancelledError:
+        logger.info("USB stream cancelled")
+    except Exception as e:
+        logger.error(f"USB stream error: {e}")
+    finally:
+        if process and process.returncode is None:
+            process.terminate()
+            try:
+                await asyncio.wait_for(process.wait(), timeout=2.0)
+            except TimeoutError:
+                process.kill()
+                await process.wait()

+ 731 - 0
backend/app/services/github_backup.py

@@ -0,0 +1,731 @@
+"""GitHub backup service for printer profiles.
+
+Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.
+"""
+
+import asyncio
+import base64
+import hashlib
+import json
+import logging
+import re
+from datetime import UTC, datetime, timedelta
+
+import httpx
+from sqlalchemy import desc, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import async_session
+from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.services.bambu_cloud import get_cloud_service
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+# Schedule intervals in seconds
+SCHEDULE_INTERVALS = {
+    "hourly": 3600,
+    "daily": 86400,
+    "weekly": 604800,
+}
+
+
+class GitHubBackupService:
+    """Service for backing up profiles to GitHub."""
+
+    def __init__(self):
+        self._scheduler_task: asyncio.Task | None = None
+        self._check_interval = 60  # Check every minute for scheduled runs
+        self._running_backup: bool = False
+        self._backup_progress: str | None = None
+        self._http_client: httpx.AsyncClient | None = None
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        """Get or create HTTP client."""
+        if self._http_client is None or self._http_client.is_closed:
+            self._http_client = httpx.AsyncClient(timeout=60.0)
+        return self._http_client
+
+    async def start_scheduler(self):
+        """Start the background scheduler loop."""
+        if self._scheduler_task is not None:
+            return
+        logger.info("Starting GitHub backup scheduler")
+        self._scheduler_task = asyncio.create_task(self._scheduler_loop())
+
+    def stop_scheduler(self):
+        """Stop the scheduler."""
+        if self._scheduler_task:
+            self._scheduler_task.cancel()
+            self._scheduler_task = None
+            logger.info("Stopped GitHub backup scheduler")
+
+    async def _scheduler_loop(self):
+        """Main scheduler loop - checks for due backups."""
+        while True:
+            try:
+                await asyncio.sleep(self._check_interval)
+                await self._check_scheduled_backups()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.error(f"Error in GitHub backup scheduler: {e}")
+                await asyncio.sleep(60)
+
+    async def _check_scheduled_backups(self):
+        """Check if any scheduled backups are due."""
+        async with async_session() as db:
+            result = await db.execute(
+                select(GitHubBackupConfig).where(
+                    GitHubBackupConfig.enabled == True,  # noqa: E712
+                    GitHubBackupConfig.schedule_enabled == True,  # noqa: E712
+                )
+            )
+            configs = result.scalars().all()
+
+            now = datetime.now(UTC)
+            for config in configs:
+                # Handle both naive (from DB) and aware datetimes
+                next_run = config.next_scheduled_run
+                if next_run and next_run.tzinfo is None:
+                    next_run = next_run.replace(tzinfo=UTC)
+                if next_run and next_run <= now:
+                    logger.info(f"Running scheduled backup for config {config.id}")
+                    await self.run_backup(config.id, trigger="scheduled")
+
+    def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
+        """Calculate the next scheduled run time."""
+        now = from_time or datetime.now(UTC)
+        interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
+        return now + timedelta(seconds=interval)
+
+    async def test_connection(self, repo_url: str, token: str) -> dict:
+        """Test GitHub connection and permissions.
+
+        Args:
+            repo_url: GitHub repository URL
+            token: Personal Access Token
+
+        Returns:
+            dict with success, message, repo_name, permissions
+        """
+        try:
+            owner, repo = self._parse_repo_url(repo_url)
+            client = await self._get_client()
+
+            # Test API access
+            response = await client.get(
+                f"https://api.github.com/repos/{owner}/{repo}",
+                headers={
+                    "Authorization": f"token {token}",
+                    "Accept": "application/vnd.github.v3+json",
+                    "User-Agent": "Bambuddy-Backup",
+                },
+            )
+
+            if response.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": "Repository not found. Check URL and token permissions.",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            if response.status_code != 200:
+                return {
+                    "success": False,
+                    "message": f"GitHub API error: {response.status_code}",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            data = response.json()
+            permissions = data.get("permissions", {})
+
+            # Check for push permission
+            if not permissions.get("push", False):
+                return {
+                    "success": False,
+                    "message": "Token does not have push permission to this repository",
+                    "repo_name": data.get("full_name"),
+                    "permissions": permissions,
+                }
+
+            return {
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": data.get("full_name"),
+                "permissions": permissions,
+            }
+
+        except Exception as e:
+            logger.error(f"GitHub connection test failed: {e}")
+            return {"success": False, "message": str(e), "repo_name": None, "permissions": None}
+
+    def _parse_repo_url(self, url: str) -> tuple[str, str]:
+        """Parse owner and repo from GitHub URL."""
+        # Handle HTTPS URLs
+        match = re.match(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url)
+        if match:
+            return match.group(1), match.group(2)
+
+        # Handle SSH URLs
+        match = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
+        if match:
+            return match.group(1), match.group(2)
+
+        raise ValueError(f"Invalid GitHub URL: {url}")
+
+    async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
+        """Run a backup operation.
+
+        Args:
+            config_id: ID of the backup configuration
+            trigger: "manual" or "scheduled"
+
+        Returns:
+            dict with success, message, log_id, commit_sha, files_changed
+        """
+        if self._running_backup:
+            return {"success": False, "message": "A backup is already running", "log_id": None}
+
+        self._running_backup = True
+        log_id = None
+
+        try:
+            async with async_session() as db:
+                # Get config
+                result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
+                config = result.scalar_one_or_none()
+
+                if not config:
+                    return {"success": False, "message": "Configuration not found", "log_id": None}
+
+                if not config.enabled:
+                    return {"success": False, "message": "Backup is disabled", "log_id": None}
+
+                # Create log entry
+                log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
+                db.add(log)
+                await db.commit()
+                await db.refresh(log)
+                log_id = log.id
+
+                try:
+                    # Collect backup data
+                    self._backup_progress = "Collecting profiles..."
+                    backup_data = await self._collect_backup_data(db, config)
+
+                    if not backup_data:
+                        # No data to backup
+                        log.status = "skipped"
+                        log.completed_at = datetime.now(UTC)
+                        log.error_message = "No data to backup"
+                        config.last_backup_at = datetime.now(UTC)
+                        config.last_backup_status = "skipped"
+                        config.last_backup_message = "No data to backup"
+                        if config.schedule_enabled:
+                            config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
+                        await db.commit()
+                        return {
+                            "success": True,
+                            "message": "No data to backup",
+                            "log_id": log_id,
+                            "commit_sha": None,
+                            "files_changed": 0,
+                        }
+
+                    # Push to GitHub
+                    self._backup_progress = "Pushing to GitHub..."
+                    push_result = await self._push_to_github(config, backup_data)
+
+                    # Update log and config
+                    log.status = push_result["status"]
+                    log.completed_at = datetime.now(UTC)
+                    log.commit_sha = push_result.get("commit_sha")
+                    log.files_changed = push_result.get("files_changed", 0)
+                    log.error_message = push_result.get("error")
+
+                    config.last_backup_at = datetime.now(UTC)
+                    config.last_backup_status = push_result["status"]
+                    config.last_backup_message = push_result.get("message", "")
+                    config.last_backup_commit_sha = push_result.get("commit_sha")
+
+                    if config.schedule_enabled:
+                        config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
+
+                    await db.commit()
+
+                    return {
+                        "success": push_result["status"] in ("success", "skipped"),
+                        "message": push_result.get("message", "Backup completed"),
+                        "log_id": log_id,
+                        "commit_sha": push_result.get("commit_sha"),
+                        "files_changed": push_result.get("files_changed", 0),
+                    }
+
+                except Exception as e:
+                    logger.error(f"Backup failed: {e}")
+                    log.status = "failed"
+                    log.completed_at = datetime.now(UTC)
+                    log.error_message = str(e)
+
+                    config.last_backup_at = datetime.now(UTC)
+                    config.last_backup_status = "failed"
+                    config.last_backup_message = str(e)
+
+                    if config.schedule_enabled:
+                        config.next_scheduled_run = self._calculate_next_run(config.schedule_type)
+
+                    await db.commit()
+                    return {
+                        "success": False,
+                        "message": str(e),
+                        "log_id": log_id,
+                        "commit_sha": None,
+                        "files_changed": 0,
+                    }
+
+        finally:
+            self._running_backup = False
+            self._backup_progress = None
+
+    async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:
+        """Collect data to backup based on config settings.
+
+        Returns dict with structure:
+        {
+            "backup_metadata.json": {...},
+            "kprofiles/{serial}/{nozzle}.json": {...},
+            "cloud_profiles/filament.json": [...],
+            "cloud_profiles/printer.json": [...],
+            "cloud_profiles/process.json": [...],
+            "settings/app_settings.json": {...},
+        }
+        """
+        files: dict[str, dict | list] = {}
+
+        # Metadata file (no timestamps - git tracks file history)
+        metadata = {
+            "version": "1.0",
+            "backup_type": "bambuddy_profiles",
+            "contents": {
+                "kprofiles": config.backup_kprofiles,
+                "cloud_profiles": config.backup_cloud_profiles,
+                "settings": config.backup_settings,
+            },
+        }
+        files["backup_metadata.json"] = metadata
+
+        # Collect K-profiles from all connected printers
+        if config.backup_kprofiles:
+            self._backup_progress = "Collecting K-profiles from printers..."
+            await self._collect_kprofiles(db, files)
+
+        # Collect cloud profiles
+        if config.backup_cloud_profiles:
+            self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
+            await self._collect_cloud_profiles(db, files)
+
+        # Collect app settings
+        if config.backup_settings:
+            self._backup_progress = "Collecting app settings..."
+            await self._collect_settings(db, files)
+
+        return files
+
+    async def _collect_kprofiles(self, db: AsyncSession, files: dict):
+        """Collect K-profiles from all connected printers."""
+        result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
+        printers = result.scalars().all()
+
+        nozzle_diameters = ["0.2", "0.4", "0.6", "0.8"]
+
+        for printer in printers:
+            client = printer_manager.get_client(printer.id)
+            if not client or not client.state.connected:
+                continue
+
+            serial = printer.serial_number
+            printer_profiles = {}
+
+            for nozzle in nozzle_diameters:
+                try:
+                    profiles = await client.get_kprofiles(nozzle_diameter=nozzle)
+                    if profiles:
+                        profile_data = {
+                            "version": "1.0",
+                            "printer_name": printer.name,
+                            "printer_serial": serial,
+                            "nozzle_diameter": nozzle,
+                            "profiles": [
+                                {
+                                    "slot_id": p.slot_id,
+                                    "name": p.name,
+                                    "k_value": p.k_value,
+                                    "filament_id": p.filament_id,
+                                    "nozzle_id": p.nozzle_id,
+                                    "extruder_id": p.extruder_id,
+                                    "setting_id": p.setting_id,
+                                    "n_coef": p.n_coef,
+                                }
+                                for p in profiles
+                            ],
+                        }
+                        files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
+                        printer_profiles[nozzle] = len(profiles)
+                except Exception as e:
+                    logger.warning(f"Failed to get K-profiles for printer {serial} nozzle {nozzle}: {e}")
+
+            if printer_profiles:
+                logger.info(f"Collected K-profiles for {serial}: {printer_profiles}")
+
+    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
+        """Collect Bambu Cloud profiles if authenticated."""
+        # Check if cloud is authenticated
+        cloud = get_cloud_service()
+
+        # Try to restore token from DB
+        result = await db.execute(select(Settings).where(Settings.key == "bambu_cloud_token"))
+        setting = result.scalar_one_or_none()
+        if setting and setting.value:
+            cloud.set_token(setting.value)
+
+        if not cloud.is_authenticated:
+            logger.info("Cloud not authenticated, skipping cloud profiles")
+            return
+
+        try:
+            settings = await cloud.get_slicer_settings()
+            if not settings:
+                return
+
+            # Separate by type
+            filament_settings = []
+            printer_settings = []
+            process_settings = []
+
+            for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
+                setting_type = setting.get("type", "")
+                if setting_type == "filament":
+                    filament_settings.append(setting)
+                elif setting_type == "printer":
+                    printer_settings.append(setting)
+                elif setting_type == "process":
+                    process_settings.append(setting)
+
+            if filament_settings:
+                files["cloud_profiles/filament.json"] = {
+                    "version": "1.0",
+                    "profiles": filament_settings,
+                }
+
+            if printer_settings:
+                files["cloud_profiles/printer.json"] = {
+                    "version": "1.0",
+                    "profiles": printer_settings,
+                }
+
+            if process_settings:
+                files["cloud_profiles/process.json"] = {
+                    "version": "1.0",
+                    "profiles": process_settings,
+                }
+
+            logger.info(
+                f"Collected cloud profiles: {len(filament_settings)} filament, "
+                f"{len(printer_settings)} printer, {len(process_settings)} process"
+            )
+
+        except Exception as e:
+            logger.warning(f"Failed to collect cloud profiles: {e}")
+
+    async def _collect_settings(self, db: AsyncSession, files: dict):
+        """Collect app settings."""
+        result = await db.execute(select(Settings))
+        settings = result.scalars().all()
+
+        # Filter out sensitive settings
+        sensitive_keys = {"bambu_cloud_token", "auth_secret_key"}
+        settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
+
+        files["settings/app_settings.json"] = {
+            "version": "1.0",
+            "settings": settings_data,
+        }
+
+    async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
+        """Push files to GitHub using the GitHub API.
+
+        Uses the Git Data API to create blobs, tree, and commit.
+
+        Returns:
+            dict with status, message, commit_sha, files_changed
+        """
+        try:
+            owner, repo = self._parse_repo_url(config.repository_url)
+            branch = config.branch
+            client = await self._get_client()
+            headers = {
+                "Authorization": f"token {config.access_token}",
+                "Accept": "application/vnd.github.v3+json",
+                "User-Agent": "Bambuddy-Backup",
+            }
+
+            # Get current branch reference
+            ref_response = await client.get(
+                f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers
+            )
+
+            if ref_response.status_code == 404:
+                # Branch doesn't exist, need to create it from default branch
+                return await self._create_branch_and_push(client, headers, owner, repo, branch, files)
+
+            if ref_response.status_code != 200:
+                return {
+                    "status": "failed",
+                    "message": f"Failed to get branch ref: {ref_response.status_code}",
+                    "error": ref_response.text,
+                }
+
+            ref_data = ref_response.json()
+            current_commit_sha = ref_data["object"]["sha"]
+
+            # Get the current tree
+            commit_response = await client.get(
+                f"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
+            )
+            if commit_response.status_code != 200:
+                return {"status": "failed", "message": "Failed to get current commit"}
+
+            current_tree_sha = commit_response.json()["tree"]["sha"]
+
+            # Get existing files to check for changes
+            tree_response = await client.get(
+                f"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
+            )
+            existing_files = {}
+            if tree_response.status_code == 200:
+                for item in tree_response.json().get("tree", []):
+                    if item["type"] == "blob":
+                        existing_files[item["path"]] = item["sha"]
+
+            # Create blobs for changed files
+            tree_items = []
+            files_changed = 0
+
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                content_bytes = content_str.encode("utf-8")
+                content_sha = hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes).hexdigest()
+
+                # Skip if file hasn't changed
+                if path in existing_files and existing_files[path] == content_sha:
+                    continue
+
+                # Create blob
+                blob_response = await client.post(
+                    f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
+                    headers=headers,
+                    json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
+                )
+
+                if blob_response.status_code != 201:
+                    logger.error(f"Failed to create blob for {path}: {blob_response.text}")
+                    continue
+
+                blob_sha = blob_response.json()["sha"]
+                tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
+                files_changed += 1
+
+            if not tree_items:
+                return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
+
+            # Create new tree
+            tree_response = await client.post(
+                f"https://api.github.com/repos/{owner}/{repo}/git/trees",
+                headers=headers,
+                json={"base_tree": current_tree_sha, "tree": tree_items},
+            )
+
+            if tree_response.status_code != 201:
+                return {"status": "failed", "message": f"Failed to create tree: {tree_response.text}"}
+
+            new_tree_sha = tree_response.json()["sha"]
+
+            # Create commit
+            commit_message = f"Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            commit_response = await client.post(
+                f"https://api.github.com/repos/{owner}/{repo}/git/commits",
+                headers=headers,
+                json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
+            )
+
+            if commit_response.status_code != 201:
+                return {"status": "failed", "message": f"Failed to create commit: {commit_response.text}"}
+
+            new_commit_sha = commit_response.json()["sha"]
+
+            # Update branch reference
+            ref_update = await client.patch(
+                f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
+                headers=headers,
+                json={"sha": new_commit_sha},
+            )
+
+            if ref_update.status_code != 200:
+                return {"status": "failed", "message": f"Failed to update branch: {ref_update.text}"}
+
+            return {
+                "status": "success",
+                "message": f"Backup successful - {files_changed} files updated",
+                "commit_sha": new_commit_sha,
+                "files_changed": files_changed,
+            }
+
+        except Exception as e:
+            logger.error(f"Push to GitHub failed: {e}")
+            return {"status": "failed", "message": str(e), "error": str(e)}
+
+    async def _create_branch_and_push(
+        self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
+    ) -> dict:
+        """Create a new branch and push files when branch doesn't exist."""
+        try:
+            # Get default branch
+            repo_response = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
+            if repo_response.status_code != 200:
+                return {"status": "failed", "message": "Failed to get repo info"}
+
+            default_branch = repo_response.json().get("default_branch", "main")
+
+            # Get default branch ref
+            ref_response = await client.get(
+                f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
+            )
+            if ref_response.status_code != 200:
+                # Empty repo - create initial commit
+                return await self._create_initial_commit(client, headers, owner, repo, branch, files)
+
+            base_sha = ref_response.json()["object"]["sha"]
+
+            # Create new branch
+            create_ref = await client.post(
+                f"https://api.github.com/repos/{owner}/{repo}/git/refs",
+                headers=headers,
+                json={"ref": f"refs/heads/{branch}", "sha": base_sha},
+            )
+
+            if create_ref.status_code != 201:
+                return {"status": "failed", "message": f"Failed to create branch: {create_ref.text}"}
+
+            # Now push to the new branch (recursive call will find the branch)
+            return await self._push_to_github(
+                type(
+                    "Config",
+                    (),
+                    {
+                        "repository_url": f"https://github.com/{owner}/{repo}",
+                        "access_token": headers["Authorization"].replace("token ", ""),
+                        "branch": branch,
+                    },
+                )(),
+                files,
+            )
+
+        except Exception as e:
+            return {"status": "failed", "message": str(e)}
+
+    async def _create_initial_commit(
+        self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
+    ) -> dict:
+        """Create initial commit in an empty repository."""
+        try:
+            # Create blobs
+            tree_items = []
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                blob_response = await client.post(
+                    f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
+                    headers=headers,
+                    json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
+                )
+                if blob_response.status_code == 201:
+                    tree_items.append(
+                        {"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
+                    )
+
+            # Create tree
+            tree_response = await client.post(
+                f"https://api.github.com/repos/{owner}/{repo}/git/trees",
+                headers=headers,
+                json={"tree": tree_items},
+            )
+            if tree_response.status_code != 201:
+                return {"status": "failed", "message": "Failed to create tree"}
+
+            tree_sha = tree_response.json()["sha"]
+
+            # Create commit (no parents for initial)
+            commit_response = await client.post(
+                f"https://api.github.com/repos/{owner}/{repo}/git/commits",
+                headers=headers,
+                json={
+                    "message": f"Initial Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}",
+                    "tree": tree_sha,
+                },
+            )
+            if commit_response.status_code != 201:
+                return {"status": "failed", "message": "Failed to create commit"}
+
+            commit_sha = commit_response.json()["sha"]
+
+            # Create branch ref
+            ref_response = await client.post(
+                f"https://api.github.com/repos/{owner}/{repo}/git/refs",
+                headers=headers,
+                json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
+            )
+            if ref_response.status_code != 201:
+                return {"status": "failed", "message": "Failed to create branch ref"}
+
+            return {
+                "status": "success",
+                "message": f"Initial backup created - {len(files)} files",
+                "commit_sha": commit_sha,
+                "files_changed": len(files),
+            }
+
+        except Exception as e:
+            return {"status": "failed", "message": str(e)}
+
+    @property
+    def is_running(self) -> bool:
+        """Check if a backup is currently running."""
+        return self._running_backup
+
+    @property
+    def progress(self) -> str | None:
+        """Get current backup progress message."""
+        return self._backup_progress
+
+    async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:
+        """Get backup logs for a configuration."""
+        async with async_session() as db:
+            result = await db.execute(
+                select(GitHubBackupLog)
+                .where(GitHubBackupLog.config_id == config_id)
+                .order_by(desc(GitHubBackupLog.started_at))
+                .offset(offset)
+                .limit(limit)
+            )
+            return list(result.scalars().all())
+
+
+# Singleton instance
+github_backup_service = GitHubBackupService()

+ 875 - 0
backend/app/services/hms_errors.py

@@ -0,0 +1,875 @@
+"""HMS Error Code Descriptions.
+
+Auto-generated from frontend/src/components/HMSErrorModal.tsx
+Source: https://github.com/greghesp/ha-bambulab
+"""
+
+# HMS error code to human-readable description mapping
+# Format: "XXXX_YYYY" where XXXX is module code, YYYY is error code
+HMS_ERROR_DESCRIPTIONS: dict[str, str] = {
+    "0300_4000": "Z axis homing failed; the task has been stopped.",
+    "0300_4001": "The printer timed out waiting for the nozzle to cool down before homing.",
+    "0300_4002": "Auto Bed Leveling failed; the task has been stopped.",
+    "0300_4005": "The hotend cooling fan speed is abnormal.",
+    "0300_4006": "The nozzle is clogged.",
+    "0300_4008": "The AMS failed to change filament.",
+    "0300_4009": "Homing XY axis failed.",
+    "0300_400A": "Mechanical resonance frequency identification failed.",
+    "0300_400B": "Internal communication exception",
+    "0300_400C": "The task was canceled.",
+    "0300_400D": "Resume failed after power loss.",
+    "0300_400E": "The motor self-check failed.",
+    "0300_400F": "The power supply voltage does not match the printer.",
+    "0300_4010": "Nozzle offset calibration failed.",
+    "0300_4011": "Flow Dynamics Calibration failed; please reinitiate printing or calibration.",
+    "0300_4013": "Printing cannot be initiated while AMS is drying.",
+    "0300_4014": "Homing Z axis failed: temperature control abnormality.",
+    "0300_4015": "Nozzle clumping detection calibration failed. Please go to 'Assistant' for troubleshooting.",
+    "0300_4016": "Nozzle cleaning failed. Please click the Assistant for troubleshooting.",
+    "0300_401F": "The hotend is not installed, and the toolhead cannot perform homing. Please install the hotend and then continue.",
+    "0300_4020": "The nozzle presence detection failed. Please check the Assistant for details.",
+    "0300_4021": "Nozzle offset calibration sensor signal abnormality detected. Please check the sensor and retry.",
+    "0300_4042": "The Laser Safety Window is not properly installed. The task has been stopped.",
+    "0300_4044": "The Flame Sensor is abnormal. The sensor may be short-circuited. Please troubleshoot the issue before starting a print job.",
+    "0300_404B": "Task aborted because the front door or top cover is open.",
+    "0300_404D": "The current temperature of the hotend, heatbed, or chamber is too high. Please wait for it to cool down to room temperature before restarting the task.",
+    "0300_4050": "Liveview Camera calibration timeout; please restart the printer.",
+    "0300_4052": "Blade Z-axis homing failed",
+    "0300_4057": "Z-axis step loss detected. The task has stopped. Please check if there are any obstructions beneath the heatbed.",
+    "0300_4066": "Calibration of motion precision failed.",
+    "0300_4067": "Calibration result is over the threshold.",
+    "0300_4068": "Step loss occurred during the motion accuracy enhancement process. Please try again.",
+    "0300_8000": "Printing was paused for unknown reason. You can select 'Resume' to resume the print job.",
+    "0300_8001": "Printing was paused by the user. You can select 'Resume' to continue printing.",
+    "0300_8002": "First layer defects were detected by the Micro Lidar. Please check the quality of the printed model before continuing your print.",
+    "0300_8003": "Spaghetti defects were detected by the AI Print Monitoring. Please check the quality of the printed model before continuing your print.",
+    "0300_8004": "Filament ran out. Please load new filament.",
+    "0300_8005": "Toolhead front cover fell off. Please remount the front cover and check to make sure your print is going okay.",
+    "0300_8006": "The build plate marker was not detected. Please confirm the build plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible.",
+    "0300_8007": "There was an unfinished print job when the printer lost power. If the model is still adhered to the build plate, you can try resuming the print job.",
+    "0300_8008": "Nozzle temperature malfunction",
+    "0300_8009": "Heatbed temperature malfunction",
+    "0300_800A": "A Filament pile-up was detected by AI Print Monitoring. Please clean filament from the waste chute.",
+    "0300_800B": "The cutter is stuck. Please make sure the cutter handle is out and check the filament sensor cable connection.",
+    "0300_800C": "Skipped step detected: auto-recover complete; please resume print and check if there are any layer shift problems.",
+    "0300_800D": "Detected that the extruder is not extruding normally. If the defects are acceptable, select 'Resume' to resume the print job.",
+    "0300_800E": "The print file is not available. Please check to see if the storage media has been removed.",
+    "0300_800F": "The door seems to be open, so printing was paused.",
+    "0300_8010": "The hotend cooling fan speed is abnormal.",
+    "0300_8011": "Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.",
+    "0300_8013": "Printing paused due to the pause command added to the printing file.",
+    "0300_8014": "The nozzle is covered with filament, or the build plate is installed incorrectly. Please cancel this print and clean the nozzle or adjust the build plate according to the actual status. You can als...",
+    "0300_8015": "The filament on external spool has run out; please load new filament. If the filament is loaded, please select 'Resume'.",
+    "0300_8016": "The nozzle is clogged with filament. Please cancel this print and clean the nozzle or select 'Resume' to resume the print job.",
+    "0300_8017": "Foreign objects detected on heatbed. Please check and clean the heatbed. Then, select 'Resume' to resume the print job.",
+    "0300_8018": "Chamber temperature malfunction.",
+    "0300_8019": "No build plate is placed.",
+    "0300_801A": "Filament extrusion error; please check the assistant for troubleshooting. After resolving the issue, decide whether to cancel or resume the print job based on the actual print status.",
+    "0300_801B": "Nozzle temperature problem detected. Refer to Assistant to re-connect the hotend connector. POWER OFF the printer before this operation to avoid short circuits.",
+    "0300_801C": "The extrusion resistance is abnormal. The extruder may be clogged; please refer to the assistant. After trouble shooting, you can select 'Resume' to resume the print job.",
+    "0300_801D": "The extruder servo motor position sensor is malfunctioning. Please power off the printer first and check if the connection cable is loose.",
+    "0300_801E": "The extrusion motor is overloaded, please check the Assistant for details.",
+    "0300_8021": "The nozzle may not be installed or not properly installed. Please ensure the nozzle is correctly installed before proceeding.",
+    "0300_8022": "The heatbed may be obstructed while moving downward. Please clear any objects beneath the heatbed and check for any resistance or jamming during its movement.",
+    "0300_8028": "Nozzle offset calibration sensor error. If using a single hotend or the calibration function is disabled, you may ignore this and continue printing; otherwise, it is recommended to check the sensor...",
+    "0300_8041": "Platform detection timeout: please restart the printer.",
+    "0300_8042": "Task paused because the door is open.",
+    "0300_8043": "The laser module is abnormal.",
+    "0300_8044": "Fire was detected inside the chamber.",
+    "0300_8045": "Material detection timeout: please restart the printer.",
+    "0300_8046": "Foreign object detect timeout: please restart the printer.",
+    "0300_8047": "Quick-release lever detection time out: please restart the printer.",
+    "0300_8048": "Laser Module unlock has timed out, and the task cannot proceed. Please restart the printer and try again.",
+    "0300_8049": "The current plate is invalid.",
+    "0300_804A": "Emergency stop button improperly installed. Please reinstall according to the Wiki before proceeding.",
+    "0300_804B": "Task paused. The Laser Safety Window is open.",
+    "0300_804E": "This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.",
+    "0300_804F": "The loading/unloading process is currently ongoing. Please stop the process or remove the laser/cutting module.",
+    "0300_8050": "This device does not support the 40W Laser Module. Please remove it or replace it with a 10W Laser Module.",
+    "0300_8051": "The cutting module has dropped or the cutting module cable is disconnected; please check the module.",
+    "0300_8053": "Laser module detected. Please install the right nozzle correctly to ensure proper Laser Module Mounting Calibration.",
+    "0300_8054": "Please place the paper required for Print Then Cut.",
+    "0300_8055": "The module mounted on the toolhead does not match the task. Please install the correct module.",
+    "0300_8057": "The rotary attachment is disconnected. Please ensure it is properly installed and the cable is securely plugged in.",
+    "0300_8058": "The rotary attachment is detected. Please remove it before continuing.",
+    "0300_8061": "The mode of Airflow System failed to activate; check the air door condition.",
+    "0300_8062": "The chamber temperature is too high. It may be due to high environmental temperature.",
+    "0300_8063": "The chamber temperature is too high. Please open the top cover and front door to cool down.",
+    "0300_8064": "The chamber temperature is too high. Please open the top cover and front door to cool down. (Open door detection for this print job will be set to 'Notification' level)",
+    "0300_8065": "The temperature of the MC module is too high. Please check the Wiki for possible explanations.",
+    "0300_8071": "The Toolhead Enhanced Cooling Fan module is malfunctioning.",
+    "0300_807D": "Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.",
+    "0300_807E": "Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.",
+    "0300_807F": "Fire Extinguisher is malfunctioning.",
+    "0300_8080": "Fire extinguisher motor reset failed.",
+    "0300_8081": "Fire extinguisher cylinder not installed. Please confirm on the extinguisher page.",
+    "0300_8082": "The Fire Extinguisher Gas Cylinder is empty.",
+    "0300_C012": "Please heat the nozzle to above 170°C.",
+    "0300_C056": "A minor fire was detected inside the chamber, and the Auto Fire Extinguishing process has been aborted.",
+    "0300_C070": "The fire extinguisher has been detected and is ready for use after the laser module is connected.",
+    "0500_4001": "Failed to connect to Bambu Cloud. Please check your network connection.",
+    "0500_4002": "Unsupported print file path or name. Please resend the print job.",
+    "0500_4003": "Printing stopped because the printer was unable to parse the file. Please resend your print job.",
+    "0500_4004": "Device is busy and cannot start new task. Please wait for current task to complete before sending new task.",
+    "0500_4005": "Print jobs are not allowed to be sent while updating firmware.",
+    "0500_4006": "There is not enough free storage space for the print job. Restoring to factory settings can free up available space.",
+    "0500_4007": "The device requires a repair upgrade, and printing is currently unavailable.",
+    "0500_4008": "Starting printing failed; please power cycle the printer and resend the print job.",
+    "0500_4009": "Print jobs are not allowed to be sent while updating logs.",
+    "0500_400A": "The file name is not supported. Please rename and restart the print job.",
+    "0500_400B": "There was a problem downloading a file. Please check your network connection and resend the print job.",
+    "0500_400C": "Please insert a MicroSD card and restart the print job.",
+    "0500_400D": "Please run a self-test and restart the print job.",
+    "0500_400E": "Printing was cancelled.",
+    "0500_400F": "AMS is initializing and cannot be upgraded at the moment. Please try again later.",
+    "0500_4010": "AMS is drying and cannot be upgraded at the moment. Please try again later.",
+    "0500_4011": "The printer is loading or unloading filament and cannot be upgraded at the moment. Please try again later.",
+    "0500_4012": "The device is printing and cannot be upgraded at the moment. Please try again later.",
+    "0500_4013": "AMS is in operation and cannot be upgraded at the moment. Please try again when it is idle.",
+    "0500_4014": "Slicing for the print job failed. Please check your settings and restart the print job.",
+    "0500_4015": "There is not enough free storage space for the print job. Please format or clear files from the MicroSD card to free up space.",
+    "0500_4016": "The MicroSD Card is write-protected. Please replace the MicroSD Card.",
+    "0500_4017": "Binding failed. Please retry or restart the printer and retry.",
+    "0500_4018": "Binding configuration information parsing failed; please try again.",
+    "0500_4019": "The printer has already been bound. Please unbind it and try again.",
+    "0500_401A": "Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...",
+    "0500_401B": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_401C": "Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_401D": "Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.",
+    "0500_401E": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_401F": "Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.",
+    "0500_4020": "Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_4021": "Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.",
+    "0500_4022": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_4023": "Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_4024": "Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...",
+    "0500_4025": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_4026": "Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_4027": "Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.",
+    "0500_4028": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_4029": "Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0500_402A": "Failed to connect to the router, which may be caused by wireless interference or being too far away from the router. Please try again or move the printer closer to the router and try again.",
+    "0500_402B": "Router connection failed due to incorrect password. Please check the password and try again.",
+    "0500_402C": "Failed to obtain IP address, which may be caused by wireless interference resulting in data transmission failure or the DHCP address pool of the router being full. Please move the printer closer to...",
+    "0500_402D": "System exception",
+    "0500_402E": "System does not support the file system currently used by the USB flash drive. Please replace or format the USB flash drive to FAT32.",
+    "0500_402F": "The MicroSD card sector data is damaged. Please use the SD card repair tool to repair or format it. If it still cannot be identified, please replace the MicroSD card.",
+    "0500_4030": "The device is currently upgrading. Please try again when it is idle.",
+    "0500_4031": "The accessory firmware does not match the printer. Please update it on the 'Firmware' page.",
+    "0500_4033": "The AMS firmware does not match the printer. Please update it on the 'Firmware' page.",
+    "0500_4034": "The Laser Module firmware does not match the printer. Please update it on the 'Firmware' page.",
+    "0500_4035": "The BirdsEye Camera is malfunctioning. Please try restarting the device. If the issue persists after multiple restarts, check the camera connection status or contact customer support.",
+    "0500_4037": "Your sliced file is not compatible with current printer model. This file can't be printed on this printer.",
+    "0500_4038": "The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.",
+    "0500_4039": "The current task does not allow the installation of the laser/cutting module, and the task has been halted.",
+    "0500_403A": "The current temperature is too low. In order to protect you and your printer, printing tasks, moving an axis and other operations are disabled. Please move the printer to an environment above 10 de...",
+    "0500_403B": "Laser/cutting tasks cannot be initiated on the machine at the moment. Please use the computer software to start the task.",
+    "0500_403C": "The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.",
+    "0500_403D": "The toolhead module is not set up. Please set it up before initiating the task.",
+    "0500_403E": "The current tool head does not support initialization.",
+    "0500_403F": "Failed to download print job; please check your network connection.",
+    "0500_4040": "The printer has reached its power limit. Please connect a dedicated power adapter to this AMS to enable drying.",
+    "0500_4041": "The AMS drying cannot be started during printing.",
+    "0500_4042": "Due to power limitations, starting AMS drying will pause current operations such as nozzle heating and fan running. Do you want to proceed with drying?",
+    "0500_4043": "Due to power limitations, only one AMS is allowed to use the device's power for drying.",
+    "0500_4044": "BirdsEye Camera malfunction: please contact customer support.",
+    "0500_4045": "Hotend check in progress. This operation is temporarily unavailable. Please wait.",
+    "0500_4050": "Error detected on the print board.",
+    "0500_4052": "Error detected on the hot end.",
+    "0500_4054": "Error detected on the mat.",
+    "0500_405D": "Laser module Serial Number error: unable to calibrate or make project.",
+    "0500_4065": "The task requires a Laser Platform, but the current one is a Cutting Platform. Please replace it, measure the material thickness in the software, and then restart the task.",
+    "0500_4070": "The laser or cutter module is connected, so the device cannot initiate a 3D printing task.",
+    "0500_4075": "No Laser Platform was detected, which may affect thickness measurement accuracy. Please place the laser platform correctly and ensure the rear markers are not blocked, then restart the thickness me...",
+    "0500_4076": "Please place the Laser Platform correctly and ensure the rear markers are not blocked, then restart the thickness measurement in the software before initiating the task.",
+    "0500_4097": "The device cannot detect the Laser Module. Please reconnect the module cable or restart the printer.",
+    "0500_4098": "The device cannot detect AMS A. Please reconnect the AMS cable or restart the printer.",
+    "0500_4099": "The firmware of Cutting Module does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0500_409A": "The firmware of the Air Pump does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0500_409B": "The firmware of the Laser Module does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0500_409D": "The firmware of AMS A does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.",
+    "0500_409E": "The device cannot detect the Cutting Module. Please reconnect the module cable or restart the printer.",
+    "0500_409F": "The device cannot detect the Air Pump.  Please reconnect the module cable or restart the printer.",
+    "0500_40A0": "The Rotary Attachment module is not detected. Please reconnect the cable or restart the printer.",
+    "0500_40A1": "The Auto Fire Extinguishing System is not detected.  Please reconnect the module cable or restart the printer.",
+    "0500_40A3": "AMS(or AMS lite) A communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0500_40A4": "The current firmware only supports 1 AMS Lite. Please remove all AMS units before reconnecting the supported AMS Lite device.",
+    "0500_40A5": "The current firmware only supports AMS/AMS 2 Pro/AMS HT, with a maximum of 4 units. Please remove all AMS units before reconnecting the supported one.",
+    "0500_8013": "The print file is not available. Please check to see if the storage media has been removed.",
+    "0500_8036": "Your sliced file is not consistent with the current printer model. Continue?",
+    "0500_803C": "The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.",
+    "0500_8040": "Toolhead front cover is detached. Moving the toolhead may damage the printer. Do you want to continue?",
+    "0500_8041": "The filament in hotend is too cold. Extrusion may damage the extruder. Still feeding in/out the filament?",
+    "0500_8048": "The module on the toolhead is not calibrated. Please cancel the task to perform calibration or switch to a calibrated module.",
+    "0500_8051": "Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.",
+    "0500_8053": "Nozzle mismatch was detected during printing. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.",
+    "0500_8055": "Laser module is installed, but a Cutting Platform is detected. Please place a Laser Platform and perform laser calibration.",
+    "0500_8056": "Cutting module is installed, but the laser platform is detected. Please place the cutting platform for calibration.",
+    "0500_8058": "Please place the light grip cutting mat correctly and ensure the marker is exposed.",
+    "0500_8059": "Cutting platform base is not correctly aligned. Please ensure that the four corners of the platform are aligned with the heatbed.",
+    "0500_805A": "Please place the cutting mat on cutting protection base.",
+    "0500_805B": "The cutting mat type is unknown; please replace it with the correct cutting mat.",
+    "0500_805C": "The grip cutting mat type does not match; please place a LightGrip cutting mat.",
+    "0500_805E": "Cutting module Serial Number error: unable to calibrate or make project.",
+    "0500_8060": "The current module on toolhead does not meet requirements. Please replace the module as per the on-screen instructions.",
+    "0500_8061": "No print plate detected. Please make sure it is placed correctly.",
+    "0500_8062": "The print plate marker was not detected. Please confirm the print plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible. If strong light is shining o...",
+    "0500_8063": "The platform is not detected during calibration; please make sure the Laser Platform is properly placed.",
+    "0500_8064": "Please place the Laser Platform correctly and ensure the rear markers are not blocked for laser calibration.",
+    "0500_8066": "The task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + LightGrip cutting mat).",
+    "0500_8067": "Please place a LightGrip cutting mat on the cutting protection base.",
+    "0500_8068": "Please place the strong grip cutting mat correctly and ensure the marker is exposed.",
+    "0500_8069": "Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please manually set the hotend types.",
+    "0500_806A": "Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please set hotend types on printer screen before next print.",
+    "0500_806B": "Quick-release Lever is not locked. Please press down the external toolhead module to ensure it is properly seated, then push down the level to lock it in place.",
+    "0500_806C": "Please place the cutting platform correctly and ensure the marker is exposed.",
+    "0500_806D": "Material not detected. Please confirm placement and continue.",
+    "0500_806E": "Foreign objects detected on heatbed; please check and clean up the heatbed.",
+    "0500_806F": "The grip cutting mat type does not match; please place a StrongGrip cutting mat.",
+    "0500_8071": "No cutting platform was detected. Please confirm that it has been correctly placed.",
+    "0500_8072": "Live View camera is blocked",
+    "0500_8073": "Heatbed limit block is obstructed or contaminated. Please clean and ensure the limit block is visible, otherwise platform position offset detection may be inaccurate.",
+    "0500_8074": "The Laser Platform is offset. Please ensure that the four corners of the platform are aligned with the heatbed, and the marker is not obstructed.",
+    "0500_8077": "The visual marker was not detected. Please ensure the paper is properly placed.",
+    "0500_8078": "Current material does not match the sliced file settings. Please load the correct material and ensure the QR code on the material is not damaged or dirty.",
+    "0500_8079": "Please place the Laser Test Material (350g paperboard) and position support strips underneath to prevent material warping.",
+    "0500_807A": "The foreign object detection function is not working. You can continue the task or check the assistant for troubleshooting.",
+    "0500_807B": "Please place the cutting platform (cutting protection base + LightGrip cutting mat).",
+    "0500_807C": "Please place the cutting platform (cutting protection base + StrongGrip cutting mat).",
+    "0500_807D": "This task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + StrongGrip Cutting Mat).",
+    "0500_807E": "Please place a StrongGrip cutting mat on the cutting protection base.",
+    "0500_8080": "The left and right hotends are not installed.",
+    "0500_8081": "The left and right hotends are not installed.",
+    "0500_8082": "Please remove the protective film on the Opaque Glossy Acrylic before processing",
+    "0500_8083": "Material is not allowed in Mounting Calibration. Please remove the material from the platform.",
+    "0500_8084": "The Live View Camera is dirty; please clean it and continue.",
+    "0500_8085": "Toolhead camera is obstructed",
+    "0500_8086": "Toolhead Camera is dirty, which affects the AI function; please clean the lens surface.",
+    "0500_8087": "BirdsEye camera is obstructed",
+    "0500_8088": "The Birdseye Camera is dirty",
+    "0500_8089": "Task paused due to Presence Check failed. Please check the printer to continue.",
+    "0500_808A": "The BirdsEye Camera is installed offset. Please refer to the assistant to reinstall it.",
+    "0500_808B": "The BirdsEye Camera setup failed. Please remove all objects and the mat on the heatbed to ensure the heatbed markers are visible. Meanwhile, please ensure the BirdsEye Camera is installed correctly...",
+    "0500_808C": "Detected build plate offset. Please align the build plate with the heatbed, and then continue.",
+    "0500_808D": "The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the cutting material is properly positioned and check whether the cutting blade tip is worn.",
+    "0500_808E": "BirdsEye Camera initialization failed. The toolhead camera did not detect the Heatbed features. Please clean the Heatbed, remove all objects and pads, and ensure the bed markings are visible. Check...",
+    "0500_808F": "Nozzle camera lens is dirty, affecting AI monitoring. Clean the lens with a non-woven cloth and a small amount of alcohol. Beware of hotend heat; wait for it to cool before handling.",
+    "0500_8090": "Please attach the 80g White Printing Paper to the center area of the platform.",
+    "0500_8091": "The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the 80g white printer paper(letter paper thickness) is properly positioned and check whether the cut...",
+    "0500_8092": "Toolhead Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.",
+    "0500_8093": "The nozzle silicone sleeve is not installed; there is a risk of temperature control failure. Please install it correctly and try again.",
+    "0500_80A0": "The visual encoder board was not detected. Please check if the board is properly placed and aligned at all four corners, and ensure the positioning markings are clear and free from wear.",
+    "0500_C010": "MicroSD Card read/write exception: please reinsert or replace the MicroSD Card.",
+    "0500_C032": "Laser/Cutting module connected to the toolhead. The drying process has been automatically stopped.",
+    "0500_C036": "This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.",
+    "0500_C07F": "Device is busy and cannot perform this operation. To proceed, please pause or stop the current task.",
+    "0501_4017": "Binding failed. Please retry or restart the printer and retry.",
+    "0501_4018": "Binding configuration information parsing failed; please try again.",
+    "0501_4019": "The printer has already been bound. Please unbind it and try again.",
+    "0501_401A": "Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...",
+    "0501_401B": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_401C": "Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_401D": "Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.",
+    "0501_401E": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_401F": "Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.",
+    "0501_4020": "Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_4021": "Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.",
+    "0501_4022": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_4023": "Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_4024": "Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...",
+    "0501_4025": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_4026": "Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_4027": "Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.",
+    "0501_4028": "Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_4029": "Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.",
+    "0501_4031": "Device discovery binding is in progress, and the QR code cannot be displayed on the screen. You can wait for the binding to finish or abort the device discovery binding process in the APP/Studio an...",
+    "0501_4032": "QR code binding is in progress, so device discovery binding cannot be performed. You can scan the QR code on the screen for binding or exit the QR code display page on screen and try device discove...",
+    "0501_4033": "Your APP region does not match with your printer; please download the APP in the corresponding region and register your account again.",
+    "0501_4034": "The slicing progress has not been updated for a long time, and the printing task has exited. Please confirm the parameters and reinitiate printing.",
+    "0501_4035": "The device is in the process of binding and cannot respond to new binding requests.",
+    "0501_4038": "The regional settings do not match the printer; please check the printer's regional settings.",
+    "0501_4039": "Device login has expired; please try to bind again.",
+    "0501_4098": "The device cannot detect AMS B. Please reconnect the AMS cable or restart the printer.",
+    "0501_409D": "The firmware of AMS B does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0501_40A3": "AMS(or AMS lite) B communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0502_4001": "Current filament will be used in this print job. Settings cannot be changed.",
+    "0502_4002": "Please go to “Settings > Calibration” to run the Motion Accuracy Enhancement Calibration before turning on Motion Accuracy Enhancement mode.",
+    "0502_4003": "The printer is currently printing and the motion accuracy enhancement feature cannot be turned on or off.",
+    "0502_4004": "Some features are not supported by the current device. Please check the Studio feature settings or update the firmware to the latest version.",
+    "0502_4005": "The AMS has not been calibrated yet, so printing cannot be initiated.",
+    "0502_4006": "Unknown module detected; please try updating the firmware to the latest version.",
+    "0502_400D": "Failed to start a new task: filament loading/unloading not completed.",
+    "0502_400E": "Failed to start a new task: The nozzle cold pull was not completed.",
+    "0502_4013": "This device is not compatible with the 40W laser module. Please replace it with a 10W laser module or remove it.",
+    "0502_4098": "The device cannot detect AMS C. Please reconnect the AMS cable or restart the printer.",
+    "0502_409D": "The firmware of AMS C does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.",
+    "0502_40A3": "AMS(or AMS lite) C communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0502_C00F": "The device is busy and cannot perform nozzle identification.",
+    "0502_C010": "Due to printer power limitations, printing, calibration, controls and other actions cannot be performed during AMS drying. Please stop the drying process before proceeding with any other operation.",
+    "0502_C011": "Currently in 2D production mode. Please continue the operation on the printer",
+    "0502_C012": "The task cannot be paused.",
+    "0502_C014": "The AMS Remaining Filament Estimation is enabled by default and cannot be disabled.",
+    "0502_C024": "The flow dynamic calibration records have exceeded the storage limit. Please delete some historical records in the slicer software before adding new calibration data.",
+    "0503_4098": "The device cannot detect AMS D. Please reconnect the AMS cable or restart the printer.",
+    "0503_409D": "The firmware of AMS D does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0503_40A3": "AMS(or AMS lite) D communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0580_4096": "The device cannot detect AMS-HT A. Please reconnect the AMS-HT cable or restart the printer.",
+    "0580_409C": "The firmware of AMS-HT A does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0580_40A2": "AMS-HT A communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0581_4096": "The device cannot detect AMS-HT B. Please reconnect the AMS-HT cable or restart the printer.",
+    "0581_409C": "The firmware of AMS-HT B does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0581_40A2": "AMS-HT B communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0582_4096": "The device cannot detect AMS-HT C. Please reconnect the AMS-HT cable or restart the printer.",
+    "0582_409C": "The firmware of AMS-HT C does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0582_40A2": "AMS-HT C communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0583_4096": "The device cannot detect AMS-HT D. Please reconnect the AMS-HT cable or restart the printer.",
+    "0583_409C": "The firmware of AMS-HT D does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0583_40A2": "AMS-HT D communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0584_4096": "The device cannot detect AMS-HT F. Please reconnect the AMS-HT cable or restart the printer.",
+    "0584_409C": "The firmware of AMS-HT E does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0584_40A2": "AMS-HT E communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0585_4096": "The device cannot detect AMS-HT E. Please reconnect the AMS-HT cable or restart the printer.",
+    "0585_409C": "The firmware of AMS-HT F does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0585_40A2": "AMS-HT F communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0586_4096": "The device cannot detect AMS-HT G. Please reconnect the AMS-HT cable or restart the printer.",
+    "0586_409C": "The firmware of AMS-HT G does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.",
+    "0586_40A2": "AMS-HT G communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "0587_4096": "The device cannot detect AMS-HT H. Please reconnect the AMS-HT cable or restart the printer.",
+    "0587_409C": "The firmware of AMS-HT H does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.",
+    "0587_40A2": "AMS-HT H communication is abnormal. Please reconnect the module cable or restart the printer.",
+    "05FE_8053": "The left nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.",
+    "05FE_8069": "Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.",
+    "05FE_806A": "Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.",
+    "05FE_8080": "The left hotend is not installed.",
+    "05FE_8081": "The left hotend is not installed.",
+    "05FF_8053": "The right nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.",
+    "05FF_8069": "Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.",
+    "05FF_806A": "Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.",
+    "05FF_8080": "The right hotend is not installed.",
+    "05FF_8081": "The right hotend is not installed.",
+    "0700_4001": "The AMS has been disabled for a print, but it still has filament loaded. Please unload the AMS filament and switch to the spool holder filament for printing.",
+    "0700_4025": "Failed to read the filament information.",
+    "0700_8001": "Failed to cut the filament. Please check the cutter.",
+    "0700_8002": "The cutter is stuck. Please make sure the cutter handle is out.",
+    "0700_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0700_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0700_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0700_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0700_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0700_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS A to the extruder is properly connected.",
+    "0700_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0700_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0700_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0700_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0700_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0700_8017": "AMS A is drying. Please stop drying process before loading/unloading material.",
+    "0700_8021": "AMS setup failed; please refer to the assistant.",
+    "0700_8023": "AMS A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "0700_C069": "An error occurred during AMS A drying. Please go to Assistant for more details.",
+    "0700_C06A": "AMS A is reading RFID. Unable to start drying. Please try again later.",
+    "0700_C06B": "AMS A is changing filament. Unable to start drying. Please try again later.",
+    "0700_C06C": "AMS A is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "0700_C06D": "AMS A is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "0700_C06E": "AMS A motor is performing self-test. Unable to start drying. Please try again later.",
+    "0701_4001": "Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.",
+    "0701_4025": "Failed to read the filament information.",
+    "0701_8001": "Failed to cut the filament. Please check the cutter.",
+    "0701_8002": "The cutter is stuck. Please make sure the cutter handle is out.",
+    "0701_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0701_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0701_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0701_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0701_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0701_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS B to the extruder is properly connected.",
+    "0701_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0701_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0701_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0701_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0701_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0701_8017": "AMS B is drying. Please stop drying process before loading/unloading material.",
+    "0701_8021": "AMS setup failed; please refer to the assistant.",
+    "0701_8023": "AMS B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "0701_C069": "An error occurred during AMS B drying. Please go to Assistant for more details.",
+    "0701_C06A": "AMS B is reading RFID. Unable to start drying. Please try again later.",
+    "0701_C06B": "AMS B is changing filament. Unable to start drying. Please try again later.",
+    "0701_C06C": "AMS B is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "0701_C06D": "AMS B is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "0701_C06E": "AMS B motor is performing self-test. Unable to start drying. Please try again later.",
+    "0702_4001": "Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.",
+    "0702_4025": "Failed to read the filament information.",
+    "0702_8001": "Failed to cut the filament. Please check the cutter.",
+    "0702_8002": "The cutter is stuck. Please make sure the cutter handle is out.",
+    "0702_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0702_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0702_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0702_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0702_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0702_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS C to the extruder is properly connected.",
+    "0702_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0702_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0702_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0702_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0702_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0702_8017": "AMS C is drying. Please stop drying process before loading/unloading material.",
+    "0702_8021": "AMS setup failed; please refer to the assistant.",
+    "0702_8023": "AMS C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "0702_C069": "An error occurred during AMS C drying. Please go to Assistant for more details.",
+    "0702_C06A": "AMS C is reading RFID. Unable to start drying. Please try again later.",
+    "0702_C06B": "AMS C is changing filament. Unable to start drying. Please try again later.",
+    "0702_C06C": "AMS C is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "0702_C06D": "AMS C is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "0702_C06E": "AMS C motor is performing self-test. Unable to start drying. Please try again later.",
+    "0703_4001": "Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.",
+    "0703_4025": "Failed to read the filament information.",
+    "0703_8001": "Failed to cut the filament. Please check the cutter.",
+    "0703_8002": "The cutter is stuck. Please make sure the cutter handle is out.",
+    "0703_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0703_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0703_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0703_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0703_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0703_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS D to the extruder is properly connected.",
+    "0703_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0703_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0703_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0703_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0703_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0703_8017": "AMS D is drying. Please stop drying process before loading/unloading material.",
+    "0703_8021": "AMS setup failed; please refer to the assistant.",
+    "0703_8023": "AMS D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "0703_C069": "An error occurred during AMS D drying. Please go to Assistant for more details.",
+    "0703_C06A": "AMS D is reading RFID. Unable to start drying. Please try again later.",
+    "0703_C06B": "AMS D is changing filament. Unable to start drying. Please try again later.",
+    "0703_C06C": "AMS D is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "0703_C06D": "AMS D is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "0703_C06E": "AMS D motor is performing self-test. Unable to start drying. Please try again later.",
+    "0704_4025": "Failed to read the filament information.",
+    "0704_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0704_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0704_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0704_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0704_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0704_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS E to the extruder is properly connected.",
+    "0704_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0704_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0704_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0704_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0704_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0704_8021": "AMS setup failed; please refer to the assistant.",
+    "0704_8023": "AMS E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "0705_4025": "Failed to read the filament information.",
+    "0705_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0705_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0705_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0705_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0705_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0705_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS F to the extruder is properly connected.",
+    "0705_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0705_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0705_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0705_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0705_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0705_8021": "AMS setup failed; please refer to the assistant.",
+    "0705_8023": "AMS F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "0706_4025": "Failed to read the filament information.",
+    "0706_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0706_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0706_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0706_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0706_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0706_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS G to the extruder is properly connected.",
+    "0706_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0706_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0706_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0706_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0706_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0706_8021": "AMS setup failed; please refer to the assistant.",
+    "0706_8023": "AMS G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "0707_4025": "Failed to read the filament information.",
+    "0707_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "0707_8004": "AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "0707_8005": "The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "0707_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "0707_8007": "Extruding filament failed. The extruder might be clogged.",
+    "0707_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS H to the extruder is properly connected.",
+    "0707_8010": "The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "0707_8011": "AMS filament ran out. Please insert a new filament into the same AMS slot.",
+    "0707_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "0707_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "0707_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "0707_8021": "AMS setup failed; please refer to the assistant.",
+    "0707_8023": "AMS H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "07FE_8001": "Failed to cut the filament of the left extruder. Please check the cutter.",
+    "07FE_8002": "The cutter of the left extruder is stuck. Please pull out the cutter handle.",
+    "07FE_8003": "Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...",
+    "07FE_8004": "Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.",
+    "07FE_8005": "Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.",
+    "07FE_8006": "Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.",
+    "07FE_8007": "Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.",
+    "07FE_8010": "Check if the left external filament spool or filament is stuck.",
+    "07FE_8011": "The external filament connected to the left extruder has run out; please load a new filament.",
+    "07FE_8012": "Failed to get mapping table; please select 'Resume' to retry.",
+    "07FE_8013": "Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.",
+    "07FE_8020": "Extruder change failed; please refer to the assistant.",
+    "07FE_8021": "AMS setup failed; please refer to the assistant.",
+    "07FE_8024": "Extruder position calibration failed; please refer to the assistant.",
+    "07FE_8025": "Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.",
+    "07FE_8030": "The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.",
+    "07FE_C003": "Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...",
+    "07FE_C006": "Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.",
+    "07FE_C008": "Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...",
+    "07FE_C009": "Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.",
+    "07FE_C00A": "Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.",
+    "07FE_C010": "Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.",
+    "07FE_C011": "Please manually and slowly pull out the filament from the extruder. Then click “Continue”.",
+    "07FE_C012": "Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click 'Continue.'",
+    "07FF_4001": "Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.",
+    "07FF_8001": "Failed to cut the filament of the right extruder. Please check the cutter.",
+    "07FF_8002": "The cutter is stuck. Please make sure the cutter handle is out.",
+    "07FF_8003": "Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...",
+    "07FF_8004": "Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.",
+    "07FF_8005": "Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.",
+    "07FF_8006": "Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.",
+    "07FF_8007": "Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.",
+    "07FF_8010": "Check if the external filament spool or filament is stuck.",
+    "07FF_8011": "External filament has run out; please load a new filament.",
+    "07FF_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "07FF_8013": "Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.",
+    "07FF_8020": "Extruder change failed; please refer to the assistant.",
+    "07FF_8021": "AMS setup failed; please refer to the assistant.",
+    "07FF_8024": "Extruder position calibration failed; please refer to the assistant.",
+    "07FF_8025": "Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.",
+    "07FF_8030": "The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.",
+    "07FF_C003": "Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...",
+    "07FF_C006": "Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.",
+    "07FF_C008": "Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...",
+    "07FF_C009": "Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.",
+    "07FF_C00A": "Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.",
+    "07FF_C010": "Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.",
+    "07FF_C011": "Hold the driven wheel bracket, slowly pull the filament from the extruder, then press 'Continue'.",
+    "07FF_C012": "Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click 'Continue.'",
+    "0C00_4020": "The setup of BirdsEye Camera failed. Please clear all objects and remove the mat. Make sure the marker is not obstructed. Meanwhile, clean both the BirdsEye Camera and Toolhead Camera, and remove a...",
+    "0C00_4021": "The setup of BirdsEye Camera failed; please reboot the printer.",
+    "0C00_4022": "The setup of BirdsEye Camera failed.  Please check if the laser module is working properly.",
+    "0C00_4024": "The Birdseye Camera is installed offset. Please refer to the assistant to reinstall it.",
+    "0C00_4025": "The Birdseye Camera is dirty. Please clean it and restart the process.",
+    "0C00_4026": "The Live View Camera initialization failed; please reboot the printer.",
+    "0C00_4027": "The Live View Camera calibration failed. Please refer to the assistant for details and recalibrate the camera after processing.",
+    "0C00_4029": "Material not detected. Please confirm placement and continue.",
+    "0C00_402A": "The visual marker was not detected. Please re-paste the paper in the correct position.",
+    "0C00_402C": "Device data link error. Please reboot the printer",
+    "0C00_402D": "The toolhead camera is not working properly; please reboot the device.",
+    "0C00_403D": "The vision encoder plate was not detected. Please confirm it is correctly positioned on the heatbed.",
+    "0C00_403E": "The high-precision nozzle offset calibration has failed, possibly due to a damaged pattern or the similarity of the colors of the two selected filaments. Please clear the printed pattern and replac...",
+    "0C00_4041": "Toolhead camera calibration failed. Please ensure the Calibration Marker on the heatbed or Height Calibration Marker on the homing area is clean and undamaged, then re-run the calibration process.",
+    "0C00_8001": "First layer defects were detected. If the defects are acceptable, select 'Resume' to resume the print job.",
+    "0C00_8005": "Purged filament has piled up in the waste chute, which may cause a tool head collision.",
+    "0C00_8009": "Build plate localization marker was not found.",
+    "0C00_800B": "The heatbed marker was not detected. Please clear all objects and remove the mat. Make sure the marker is not obstructed.",
+    "0C00_8015": "Objects detected on the platform; please clean them up in a timely manner.",
+    "0C00_8016": "The foreign object detection function is not working. You can continue the task or check assistant for solutions.",
+    "0C00_8017": "Foreign objects detected on the platform; please clean them up on time.",
+    "0C00_8018": "The foreign object detection function is not working. You can continue the task or view the assistant for troubleshooting.",
+    "0C00_8033": "Quick-release Lever is not locked. Please push it down to secure.",
+    "0C00_8034": "Liveview Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.",
+    "0C00_803F": "AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.",
+    "0C00_8040": "AI detected air-printing defect. Please check the hotend extrusion status. Refer to assistant for solutions.",
+    "0C00_8042": "The AI print monitor has detected a spaghetti defect. Please check the print and take the necessary action.",
+    "0C00_8043": "AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.",
+    "0C00_C003": "Possible defects were detected in the first layer.",
+    "0C00_C004": "Possible spaghetti failure was detected.",
+    "0C00_C006": "Purged filament may have piled up in the waste chute.",
+    "1000_C001": "High bed temperature may lead to filament clogging in the nozzle. You may open the chamber door.",
+    "1000_C002": "Printing CF material with stainless steel may cause nozzle damage.",
+    "1000_C003": "Enabling Timelapse in traditional mode may cause defects; please activate this feature as needed.",
+    "1001_4001": "Timelapse is not supported as Spiral Vase mode is enabled in slicing presets.",
+    "1001_4002": "Timelapse is not supported as the Print sequence is set to 'By object'.",
+    "1001_8003": "The time-lapse mode is set to Traditional in the slicing file. This may cause surface defects. Would you like to enable it?",
+    "1001_8004": "Prime Tower is not enabled and time-lapse mode is set to Smooth in slicing file. This may cause surface defects. Would you like to enable it?",
+    "1200_4001": "Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.",
+    "1200_8001": "Cutting the filament failed. Please check to see if the cutter is stuck. Refer to the Assistant for solutions.",
+    "1200_8002": "The cutter is stuck. Please pull out the cutter handle.",
+    "1200_8003": "Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.",
+    "1200_8004": "Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.",
+    "1200_8005": "The filament is not inserted. Please insert the filament.",
+    "1200_8006": "Unable to feed filament into the extruder. This could be due to tangled filament or a stuck spool. If not, please check if the AMS PTFE tube is connected.",
+    "1200_8007": "Failed to extrude the filament. This might be caused by clogged extruder or stuck filament. Refer to the Assistant for solutions.",
+    "1200_8010": "Filament or spool may be stuck.",
+    "1200_8011": "AMS filament has run out. Please insert a new filament into the same AMS slot.",
+    "1200_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1200_8013": "Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.",
+    "1200_8014": "The filament location in the toolhead was not found. Refer to the Assistant for solutions.",
+    "1200_8015": "Failed to pull out the filament from the toolhead. Please check if the filament is stuck, or if it is broken inside the extruder or PTFE tube.",
+    "1200_8016": "The extruder is not extruding normally. Refer to the Assistant for troubleshooting. There may be defects in this layer, but you may resume if the defects are acceptable.",
+    "1201_4001": "Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.",
+    "1201_8001": "Failed to cut the filament. Please check the cutter.",
+    "1201_8002": "The cutter is stuck. Please pull out the cutter handle.",
+    "1201_8003": "Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.",
+    "1201_8004": "Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.",
+    "1201_8005": "Failed to feed the filament. Please load the filament and then select 'Retry'.",
+    "1201_8006": "Failed to feed the filament into the toolhead. Please check whether the filament is stuck.",
+    "1201_8007": "Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.",
+    "1201_8010": "Please check if the spool or filament is stuck.",
+    "1201_8011": "AMS filament has run out. Please insert a new filament into the same AMS slot.",
+    "1201_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1201_8013": "Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.",
+    "1201_8014": "Failed to check the filament location in the tool head; please refer to the HMS.",
+    "1201_8015": "Failed to pull back the filament from the toolhead. Please check if the filament is stuck or the filament is broken inside the extruder.",
+    "1201_8016": "The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.",
+    "1202_4001": "Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.",
+    "1202_8001": "Failed to cut the filament. Please check the cutter.",
+    "1202_8002": "The cutter is stuck. Please pull out the cutter handle.",
+    "1202_8003": "Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.",
+    "1202_8004": "Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.",
+    "1202_8005": "The filament is not inserted. Please insert the filament.",
+    "1202_8006": "Failed to feed the filament into the toolhead. Please check whether the filament is stuck.",
+    "1202_8007": "Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.",
+    "1202_8010": "Please check if the spool or filament is stuck.",
+    "1202_8011": "AMS filament has run out. Please insert a new filament into the same AMS slot.",
+    "1202_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1202_8013": "Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.",
+    "1202_8014": "Failed to check the filament location in the tool head; please refer to the HMS.",
+    "1202_8015": "Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.",
+    "1202_8016": "The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.",
+    "1203_4001": "Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.",
+    "1203_8001": "Failed to cut the filament. Please check the cutter.",
+    "1203_8002": "The cutter is stuck. Please pull out the cutter handle.",
+    "1203_8003": "Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.",
+    "1203_8004": "Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.",
+    "1203_8005": "The filament is not inserted. Please insert the filament.",
+    "1203_8006": "Failed to feed the filament into the toolhead. Please check whether the filament is stuck.",
+    "1203_8007": "Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.",
+    "1203_8010": "Please check if the spool or filament is stuck.",
+    "1203_8011": "AMS filament has run out. Please insert a new filament into the same AMS slot.",
+    "1203_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1203_8013": "Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.",
+    "1203_8014": "Failed to check the filament location in the tool head; please refer to the HMS.",
+    "1203_8015": "Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.",
+    "1203_8016": "The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.",
+    "12FF_4001": "Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.",
+    "12FF_8001": "Failed to cut the filament. Please check the cutter.",
+    "12FF_8002": "The cutter is stuck. Please pull out the cutter handle.",
+    "12FF_8003": "Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube if you are about to us...",
+    "12FF_8004": "Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.",
+    "12FF_8005": "The filament is not inserted. Please insert the filament.",
+    "12FF_8006": "Please feed filament into the PTFE tube until it can not be pushed any farther.",
+    "12FF_8007": "Check nozzle. Select 'Done' if filament was extruded, otherwise push filament forward slightly and select 'Retry.'",
+    "12FF_8010": "Please check if the filament or the spool is stuck.",
+    "12FF_8011": "AMS filament has run out. Please insert a new filament into the same AMS slot.",
+    "12FF_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "12FF_8013": "Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.",
+    "12FF_C003": "Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE Tube. (Connect a PTFE tube if you are about to us...",
+    "12FF_C006": "Please feed filament into the PTFE tube until it can not be pushed any farther.",
+    "1800_4025": "Failed to read the filament information.",
+    "1800_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1800_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1800_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1800_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1800_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1800_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT A to the extruder is properly connected.",
+    "1800_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1800_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1800_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1800_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1800_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1800_8017": "AMS-HT A is drying. Please stop drying process before loading/unloading material.",
+    "1800_8021": "AMS setup failed; please refer to the assistant.",
+    "1800_8023": "AMS-HT A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1800_C069": "An error occurred during AMS-HT A drying. Please go to Assistant for more details.",
+    "1800_C06A": "AMS-HT A is reading RFID. Unable to start drying. Please try again later.",
+    "1800_C06B": "AMS-HT A is changing filament. Unable to start drying. Please try again later.",
+    "1800_C06C": "AMS-HT A is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1800_C06D": "AMS-HT A is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1800_C06E": "AMS-HT A motor is performing self-test. Unable to start drying. Please try again later.",
+    "1801_4025": "Failed to read the filament information.",
+    "1801_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1801_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1801_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1801_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1801_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1801_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT B to the extruder is properly connected.",
+    "1801_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1801_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1801_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1801_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1801_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1801_8017": "AMS-HT B is drying. Please stop drying process before loading/unloading material.",
+    "1801_8021": "AMS setup failed; please refer to the assistant.",
+    "1801_8023": "AMS-HT B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1801_C069": "An error occurred during AMS-HT B drying. Please go to Assistant for more details.",
+    "1801_C06A": "AMS-HT B is reading RFID. Unable to start drying. Please try again later.",
+    "1801_C06B": "AMS-HT B is changing filament. Unable to start drying. Please try again later.",
+    "1801_C06C": "AMS-HT B is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1801_C06D": "AMS-HT B is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1801_C06E": "AMS-HT B motor is performing self-test. Unable to start drying. Please try again later.",
+    "1802_4025": "Failed to read the filament information.",
+    "1802_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1802_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1802_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1802_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1802_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1802_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT C to the extruder is properly connected.",
+    "1802_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1802_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1802_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1802_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1802_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1802_8017": "AMS-HT C is drying. Please stop drying process before loading/unloading material.",
+    "1802_8021": "AMS setup failed; please refer to the assistant.",
+    "1802_8023": "AMS-HT C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1802_C069": "An error occurred during AMS-HT C drying. Please go to Assistant for more details.",
+    "1802_C06A": "AMS-HT C is reading RFID. Unable to start drying. Please try again later.",
+    "1802_C06B": "AMS-HT C is changing filament. Unable to start drying. Please try again later.",
+    "1802_C06C": "AMS-HT C is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1802_C06D": "AMS-HT C is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1802_C06E": "AMS-HT C motor is performing self-test. Unable to start drying. Please try again later.",
+    "1803_4025": "Failed to read the filament information.",
+    "1803_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1803_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1803_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1803_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1803_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1803_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT D to the extruder is properly connected.",
+    "1803_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1803_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1803_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1803_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1803_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1803_8017": "AMS-HT D is drying. Please stop drying process before loading/unloading material.",
+    "1803_8021": "AMS setup failed; please refer to the assistant.",
+    "1803_8023": "AMS-HT D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1803_C069": "An error occurred during AMS-HT D drying. Please go to Assistant for more details.",
+    "1803_C06A": "AMS-HT D is reading RFID. Unable to start drying. Please try again later.",
+    "1803_C06B": "AMS-HT D is changing filament. Unable to start drying. Please try again later.",
+    "1803_C06C": "AMS-HT D is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1803_C06D": "AMS-HT D is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1803_C06E": "AMS-HT D motor is performing self-test. Unable to start drying. Please try again later.",
+    "1804_4025": "Failed to read the filament information.",
+    "1804_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1804_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1804_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1804_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1804_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1804_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT E to the extruder is properly connected.",
+    "1804_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1804_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1804_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1804_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1804_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1804_8021": "AMS setup failed; please refer to the assistant.",
+    "1804_8023": "AMS-HT E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1804_C069": "An error occurred during AMS-HT E drying. Please go to Assistant for more details.",
+    "1804_C06A": "AMS-HT E is reading RFID. Unable to start drying. Please try again later.",
+    "1804_C06B": "AMS-HT E is changing filament. Unable to start drying. Please try again later.",
+    "1804_C06C": "AMS-HT E is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1804_C06D": "AMS-HT E is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1804_C06E": "AMS-HT E motor is performing self-test. Unable to start drying. Please try again later.",
+    "1805_4025": "Failed to read the filament information.",
+    "1805_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1805_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1805_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1805_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1805_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1805_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT F to the extruder is properly connected.",
+    "1805_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1805_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1805_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1805_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1805_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1805_8021": "AMS setup failed; please refer to the assistant.",
+    "1805_8023": "AMS-HT F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1805_C069": "An error occurred during AMS-HT F drying. Please go to Assistant for more details.",
+    "1805_C06A": "AMS-HT F is reading RFID. Unable to start drying. Please try again later.",
+    "1805_C06B": "AMS-HT F is changing filament. Unable to start drying. Please try again later.",
+    "1805_C06C": "AMS-HT F is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1805_C06D": "AMS-HT F is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1805_C06E": "AMS-HT F motor is performing self-test. Unable to start drying. Please try again later.",
+    "1806_4025": "Failed to read the filament information.",
+    "1806_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1806_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1806_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1806_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1806_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1806_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT G to the extruder is properly connected.",
+    "1806_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1806_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1806_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1806_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1806_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1806_8021": "AMS setup failed; please refer to the assistant.",
+    "1806_8023": "AMS-HT G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1806_C069": "An error occurred during AMS-HT G drying. Please go to Assistant for more details.",
+    "1806_C06A": "AMS-HT G is reading RFID. Unable to start drying. Please try again later.",
+    "1806_C06B": "AMS-HT G is changing filament. Unable to start drying. Please try again later.",
+    "1806_C06C": "AMS-HT G is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1806_C06D": "AMS-HT G is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1806_C06E": "AMS-HT G motor is performing self-test. Unable to start drying. Please try again later.",
+    "1807_4025": "Failed to read the filament information.",
+    "1807_8003": "Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.",
+    "1807_8004": "AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.",
+    "1807_8005": "The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.",
+    "1807_8006": "Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...",
+    "1807_8007": "Extruding filament failed. The extruder might be clogged.",
+    "1807_800A": "PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT H to the extruder is properly connected.",
+    "1807_8010": "The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.",
+    "1807_8011": "AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.",
+    "1807_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "1807_8013": "Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.",
+    "1807_8016": "The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.",
+    "1807_8021": "AMS setup failed; please refer to the assistant.",
+    "1807_8023": "AMS-HT H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.",
+    "1807_C069": "An error occurred during AMS-HT H drying. Please go to Assistant for more details.",
+    "1807_C06A": "AMS-HT H is reading RFID. Unable to start drying. Please try again later.",
+    "1807_C06B": "AMS-HT H is changing filament. Unable to start drying. Please try again later.",
+    "1807_C06C": "AMS-HT H is in Feed Assist Mode. Unable to start drying. Please try again later.",
+    "1807_C06D": "AMS-HT H is assisting in filament insertion. Unable to start drying. Please try again later.",
+    "1807_C06E": "AMS-HT H motor is performing self-test. Unable to start drying. Please try again later.",
+    "18FE_8001": "Failed to cut the filament of the left extruder. Please check the cutter.",
+    "18FE_8002": "The cutter of the left extruder is stuck. Please pull out the cutter handle.",
+    "18FE_8003": "Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...",
+    "18FE_8004": "Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.",
+    "18FE_8005": "Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.",
+    "18FE_8006": "Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.",
+    "18FE_8007": "Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.",
+    "18FE_8011": "The external filament connected to the left extruder has run out; please load a new filament.",
+    "18FE_8012": "Failed to get mapping table; please select 'Resume' to retry.",
+    "18FE_8013": "Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.",
+    "18FE_8020": "Extruder change failed; please refer to the assistant.",
+    "18FE_8021": "AMS setup failed; please refer to the assistant.",
+    "18FE_8024": "Extruder position calibration failed; please refer to the assistant.",
+    "18FE_C003": "Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...",
+    "18FE_C006": "Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.",
+    "18FE_C008": "Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...",
+    "18FE_C009": "Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.",
+    "18FE_C00A": "Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.",
+    "18FF_8001": "Failed to cut the filament of the right extruder. Please check the cutter.",
+    "18FF_8002": "The cutter of the right extruder is stuck. Please pull out the cutter handle.",
+    "18FF_8003": "Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...",
+    "18FF_8004": "Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.",
+    "18FF_8005": "Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.",
+    "18FF_8006": "Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.",
+    "18FF_8007": "Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.",
+    "18FF_8011": "The external filament connected to the right extruder has run out; please load a new filament.",
+    "18FF_8012": "Failed to get AMS mapping table; please select 'Resume' to retry.",
+    "18FF_8013": "Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.",
+    "18FF_8020": "Extruder change failed; please refer to the assistant.",
+    "18FF_8021": "AMS setup failed; please refer to the assistant.",
+    "18FF_8024": "Extruder position calibration failed; please refer to the assistant.",
+    "18FF_C003": "Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...",
+    "18FF_C006": "Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.",
+    "18FF_C008": "Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...",
+    "18FF_C009": "Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.",
+    "18FF_C00A": "Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.",
+}
+
+
+def get_error_description(error_code: str) -> str | None:
+    """Get human-readable description for an HMS error code.
+
+    Args:
+        error_code: Error code in format "XXXX_YYYY" (e.g., "0300_400C")
+
+    Returns:
+        Human-readable description or None if not found
+    """
+    return HMS_ERROR_DESCRIPTIONS.get(error_code.upper())

+ 5 - 5
backend/app/services/homeassistant.py

@@ -282,9 +282,9 @@ class HomeAssistantService:
                 )
                 response.raise_for_status()
 
-                # Valid units for energy monitoring sensors
-                power_units = {"W", "kW", "mW"}
-                energy_units = {"kWh", "Wh", "MWh"}
+                # Valid units for energy monitoring sensors (lowercase for case-insensitive matching)
+                power_units = {"w", "kw", "mw"}
+                energy_units = {"kwh", "wh", "mwh"}
                 valid_units = power_units | energy_units
 
                 entities = []
@@ -299,8 +299,8 @@ class HomeAssistantService:
                     attrs = entity.get("attributes", {})
                     unit = attrs.get("unit_of_measurement", "")
 
-                    # Only include sensors with power/energy units
-                    if unit in valid_units:
+                    # Only include sensors with power/energy units (case-insensitive)
+                    if unit.lower() in valid_units:
                         entities.append(
                             {
                                 "entity_id": entity_id,

+ 274 - 0
backend/app/services/layer_timelapse.py

@@ -0,0 +1,274 @@
+"""Layer-based timelapse for external cameras.
+
+Captures a frame on each layer change and stitches them into a video on print completion.
+"""
+
+import asyncio
+import logging
+import shutil
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+
+from backend.app.core.config import settings
+from backend.app.services.external_camera import capture_frame
+
+logger = logging.getLogger(__name__)
+
+# Active timelapse sessions: {printer_id: TimelapseSession}
+_active_sessions: dict[int, "TimelapseSession"] = {}
+
+
+def get_ffmpeg_path() -> str | None:
+    """Get the path to ffmpeg executable."""
+    # Try shutil.which first
+    path = shutil.which("ffmpeg")
+    if path:
+        return path
+    # Check common locations (systemd services may have limited PATH)
+    for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
+        if Path(common_path).exists():
+            return common_path
+    return None
+
+
+@dataclass
+class TimelapseSession:
+    """Active timelapse recording session."""
+
+    printer_id: int
+    archive_id: int | None
+    camera_url: str
+    camera_type: str
+    last_layer: int = -1
+    frame_count: int = 0
+    session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
+    frames_dir: Path = field(init=False)
+
+    def __post_init__(self):
+        self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
+        self.frames_dir.mkdir(parents=True, exist_ok=True)
+        logger.info(f"Created timelapse session {self.session_id} for printer {self.printer_id}")
+
+    async def capture_layer(self, layer_num: int) -> bool:
+        """Capture frame if layer changed.
+
+        Args:
+            layer_num: Current layer number from printer
+
+        Returns:
+            True if frame was captured, False otherwise
+        """
+        # Only capture if layer increased
+        if layer_num <= self.last_layer:
+            return False
+
+        self.last_layer = layer_num
+
+        try:
+            frame_data = await capture_frame(self.camera_url, self.camera_type)
+            if frame_data:
+                frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
+                await asyncio.to_thread(frame_path.write_bytes, frame_data)
+                self.frame_count += 1
+                logger.debug(f"Captured layer {layer_num} for printer {self.printer_id} (frame {self.frame_count})")
+                return True
+            else:
+                logger.warning(f"Failed to capture frame for layer {layer_num}")
+                return False
+        except Exception as e:
+            logger.error(f"Error capturing timelapse frame: {e}")
+            return False
+
+    async def stitch(self, output_path: Path, fps: int = 30) -> bool:
+        """Create MP4 from captured frames using ffmpeg.
+
+        Args:
+            output_path: Path for output video file
+            fps: Frames per second for output video
+
+        Returns:
+            True if stitching succeeded, False otherwise
+        """
+        if self.frame_count == 0:
+            logger.warning("No frames to stitch")
+            return False
+
+        ffmpeg = get_ffmpeg_path()
+        if not ffmpeg:
+            logger.error("ffmpeg not found - required for timelapse stitching")
+            return False
+
+        # Find all frame files and create a sequential list
+        # This handles gaps in layer numbers (e.g., if some captures failed)
+        frame_files = sorted(self.frames_dir.glob("layer_*.jpg"))
+        if not frame_files:
+            logger.warning("No frame files found in timelapse directory")
+            return False
+
+        # Create a concat file listing all frames
+        concat_file = self.frames_dir / "frames.txt"
+        try:
+            with open(concat_file, "w") as f:
+                for frame in frame_files:
+                    # Each frame shown for 1/fps duration
+                    f.write(f"file '{frame.name}'\n")
+                    f.write(f"duration {1.0 / fps}\n")
+                # Add last frame again (required by concat demuxer)
+                if frame_files:
+                    f.write(f"file '{frame_files[-1].name}'\n")
+        except Exception as e:
+            logger.error(f"Failed to create concat file: {e}")
+            return False
+
+        # Use ffmpeg concat demuxer for variable-gap frame sequences
+        cmd = [
+            ffmpeg,
+            "-y",  # Overwrite output
+            "-f",
+            "concat",
+            "-safe",
+            "0",
+            "-i",
+            str(concat_file),
+            "-c:v",
+            "libx264",
+            "-pix_fmt",
+            "yuv420p",
+            "-preset",
+            "medium",
+            "-crf",
+            "23",
+            str(output_path),
+        ]
+
+        try:
+            process = await asyncio.create_subprocess_exec(
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+                cwd=str(self.frames_dir),  # Run in frames dir so relative paths work
+            )
+
+            stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
+
+            if process.returncode != 0:
+                logger.error(f"ffmpeg timelapse stitch failed: {stderr.decode()[:500]}")
+                return False
+
+            logger.info(f"Created timelapse video: {output_path} ({self.frame_count} frames)")
+            return True
+
+        except TimeoutError:
+            logger.error("Timelapse stitching timed out")
+            if process:
+                process.kill()
+            return False
+        except Exception as e:
+            logger.error(f"Timelapse stitch failed: {e}")
+            return False
+
+    def cleanup(self):
+        """Remove temporary frames directory."""
+        try:
+            if self.frames_dir.exists():
+                shutil.rmtree(self.frames_dir, ignore_errors=True)
+                logger.info(f"Cleaned up timelapse frames for session {self.session_id}")
+        except Exception as e:
+            logger.warning(f"Failed to cleanup timelapse frames: {e}")
+
+
+def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: str) -> TimelapseSession:
+    """Start new timelapse session for a printer.
+
+    Args:
+        printer_id: The printer ID
+        archive_id: Associated print archive ID (optional)
+        url: External camera URL
+        cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
+
+    Returns:
+        The new TimelapseSession
+    """
+    # Cancel any existing session
+    cancel_session(printer_id)
+
+    session = TimelapseSession(
+        printer_id=printer_id,
+        archive_id=archive_id,
+        camera_url=url,
+        camera_type=cam_type,
+    )
+    _active_sessions[printer_id] = session
+    logger.info(f"Started timelapse session for printer {printer_id}")
+    return session
+
+
+def get_session(printer_id: int) -> TimelapseSession | None:
+    """Get active timelapse session for a printer."""
+    return _active_sessions.get(printer_id)
+
+
+async def on_layer_change(printer_id: int, layer_num: int):
+    """Called on layer change - captures frame if session active.
+
+    Args:
+        printer_id: The printer ID
+        layer_num: Current layer number
+    """
+    session = get_session(printer_id)
+    if session:
+        await session.capture_layer(layer_num)
+
+
+async def on_print_complete(printer_id: int) -> Path | None:
+    """Stitch timelapse and return path. Cleans up session.
+
+    Args:
+        printer_id: The printer ID
+
+    Returns:
+        Path to stitched video, or None if no session or stitching failed
+    """
+    session = _active_sessions.pop(printer_id, None)
+    if not session:
+        return None
+
+    if session.frame_count == 0:
+        logger.info(f"No timelapse frames captured for printer {printer_id}")
+        session.cleanup()
+        return None
+
+    # Create output path in parent of frames dir
+    output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
+
+    try:
+        success = await session.stitch(output_path)
+        if success:
+            # Cleanup frames after successful stitch
+            session.cleanup()
+            return output_path
+        else:
+            session.cleanup()
+            return None
+    except Exception as e:
+        logger.error(f"Timelapse completion failed: {e}")
+        session.cleanup()
+        return None
+
+
+def cancel_session(printer_id: int):
+    """Cancel and cleanup timelapse session (on print fail/cancel).
+
+    Args:
+        printer_id: The printer ID
+    """
+    session = _active_sessions.pop(printer_id, None)
+    if session:
+        session.cleanup()
+        logger.info(f"Cancelled timelapse session for printer {printer_id}")
+
+
+def get_active_sessions() -> dict[int, TimelapseSession]:
+    """Get all active timelapse sessions."""
+    return _active_sessions.copy()

+ 28 - 3
backend/app/services/notification_service.py

@@ -252,15 +252,18 @@ class NotificationService:
 
         url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
 
-        # Check if message contains URLs (which have underscores that break Markdown)
-        # If so, don't use parse_mode to avoid parsing errors
+        # Check if message contains characters that break Markdown parsing
+        # URLs and error codes with underscores cause issues
         has_url = "http://" in message or "https://" in message
+        # Check for underscores outside of the bold title (odd number of _ breaks markdown)
+        body_part = message.split("\n", 1)[1] if "\n" in message else ""
+        has_problematic_underscore = "_" in body_part
 
         data = {
             "chat_id": chat_id,
             "text": message,
         }
-        if not has_url:
+        if not has_url and not has_problematic_underscore:
             data["parse_mode"] = "Markdown"
 
         client = await self._get_client()
@@ -750,6 +753,28 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "printer_error", variables)
         await self._send_to_providers(providers, title, message, db, "printer_error", printer_id, printer_name)
 
+    async def on_plate_not_empty(
+        self,
+        printer_id: int,
+        printer_name: str,
+        db: AsyncSession,
+        difference_percent: float | None = None,
+    ):
+        """Handle plate not empty event - objects detected on build plate before print."""
+        providers = await self._get_providers_for_event(db, "on_plate_not_empty", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "difference_percent": f"{difference_percent:.1f}" if difference_percent else "N/A",
+        }
+
+        title, message = await self._build_message_from_template(db, "plate_not_empty", variables)
+        await self._send_to_providers(
+            providers, title, message, db, "plate_not_empty", printer_id, printer_name, force_immediate=True
+        )
+
     async def on_filament_low(
         self,
         printer_id: int,

+ 797 - 0
backend/app/services/plate_detection.py

@@ -0,0 +1,797 @@
+"""Build plate empty detection using OpenCV.
+
+Analyzes camera frames to detect if there are objects on the build plate.
+Uses calibration-based difference detection - compares current frame to
+a reference image of the empty plate.
+"""
+
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Optional OpenCV import - feature disabled if not available
+try:
+    import cv2
+    import numpy as np
+
+    OPENCV_AVAILABLE = True
+except ImportError:
+    OPENCV_AVAILABLE = False
+    logger.info("OpenCV not available - plate detection feature disabled")
+
+
+def _get_calibration_dir() -> Path:
+    """Get the calibration directory from settings (ensures persistence in Docker)."""
+    from backend.app.core.config import settings
+
+    return settings.plate_calibration_dir
+
+
+class PlateDetectionResult:
+    """Result of plate detection analysis."""
+
+    def __init__(
+        self,
+        is_empty: bool,
+        confidence: float,
+        difference_percent: float,
+        message: str,
+        debug_image: bytes | None = None,
+        needs_calibration: bool = False,
+    ):
+        self.is_empty = is_empty
+        self.confidence = confidence  # 0.0 to 1.0
+        self.difference_percent = difference_percent  # How different from reference
+        self.message = message
+        self.debug_image = debug_image  # Optional annotated image for debugging
+        self.needs_calibration = needs_calibration  # True if no reference image exists
+
+    def to_dict(self) -> dict:
+        return {
+            "is_empty": bool(self.is_empty),
+            "confidence": float(round(self.confidence, 2)),
+            "difference_percent": float(round(self.difference_percent, 2)),
+            "message": self.message,
+            "has_debug_image": self.debug_image is not None,
+            "needs_calibration": bool(self.needs_calibration),
+        }
+
+
+class PlateDetector:
+    """Detects if the build plate is empty using calibration-based difference detection."""
+
+    # Default region of interest (ROI) as percentage of image dimensions
+    # These define where the build plate typically appears in the camera view
+    # Format: (x_start%, y_start%, width%, height%)
+    DEFAULT_ROI = (0.15, 0.35, 0.70, 0.55)  # Center-lower portion of frame
+
+    # Detection thresholds for difference detection
+    # Using mean pixel difference (0-100% scale)
+    # Small objects may only cause 1-2% mean difference
+    DEFAULT_DIFFERENCE_THRESHOLD = 1.0
+    DEFAULT_BLUR_SIZE = 21  # Gaussian blur kernel size (must be odd) - unused with edge detection
+
+    def __init__(
+        self,
+        roi: tuple[float, float, float, float] | None = None,
+        difference_threshold: float = DEFAULT_DIFFERENCE_THRESHOLD,
+        blur_size: int = DEFAULT_BLUR_SIZE,
+    ):
+        """Initialize the plate detector.
+
+        Args:
+            roi: Region of interest as (x%, y%, w%, h%) - percentages of image size
+            difference_threshold: Percentage of pixels that must differ to trigger "not empty"
+            blur_size: Gaussian blur kernel size for noise reduction
+        """
+        if not OPENCV_AVAILABLE:
+            raise RuntimeError("OpenCV is not installed. Install with: pip install opencv-python-headless")
+
+        self.roi = roi or self.DEFAULT_ROI
+        self.difference_threshold = difference_threshold
+        self.blur_size = blur_size if blur_size % 2 == 1 else blur_size + 1  # Must be odd
+
+    # Maximum number of reference images to store per printer
+    MAX_REFERENCES = 5
+
+    def _get_metadata_path(self, printer_id: int) -> Path:
+        """Get the path to the metadata JSON file for a printer."""
+        _get_calibration_dir().mkdir(parents=True, exist_ok=True)
+        return _get_calibration_dir() / f"printer_{printer_id}_metadata.json"
+
+    def _load_metadata(self, printer_id: int) -> dict:
+        """Load metadata for a printer's references."""
+        import json
+
+        meta_path = self._get_metadata_path(printer_id)
+        if meta_path.exists():
+            try:
+                with open(meta_path) as f:
+                    return json.load(f)
+            except Exception:
+                pass
+        return {"references": {}}
+
+    def _save_metadata(self, printer_id: int, metadata: dict) -> None:
+        """Save metadata for a printer's references."""
+        import json
+
+        meta_path = self._get_metadata_path(printer_id)
+        with open(meta_path, "w") as f:
+            json.dump(metadata, f, indent=2)
+
+    def _get_reference_paths(self, printer_id: int) -> list[Path]:
+        """Get all existing reference image paths for a printer."""
+        _get_calibration_dir().mkdir(parents=True, exist_ok=True)
+        paths = []
+        for i in range(self.MAX_REFERENCES):
+            path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
+            if path.exists():
+                paths.append(path)
+        return paths
+
+    def _get_next_reference_slot(self, printer_id: int) -> Path:
+        """Get the path for the next reference image slot (cycles through slots)."""
+        _get_calibration_dir().mkdir(parents=True, exist_ok=True)
+        # Find first empty slot, or use oldest (slot 0) and shift others
+        for i in range(self.MAX_REFERENCES):
+            path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
+            if not path.exists():
+                return path
+        # All slots full - return slot 0 (will be overwritten, but we rotate first)
+        return _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
+
+    def _rotate_references(self, printer_id: int) -> None:
+        """Rotate references: delete oldest (0), shift others down."""
+        # Delete slot 0
+        slot0 = _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
+        if slot0.exists():
+            logger.info(f"Rotating references: removing oldest {slot0}")
+            slot0.unlink()
+        # Shift others down
+        for i in range(1, self.MAX_REFERENCES):
+            old_path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
+            new_path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i - 1}.jpg"
+            if old_path.exists():
+                old_path.rename(new_path)
+
+        # Also rotate metadata
+        metadata = self._load_metadata(printer_id)
+        refs = metadata.get("references", {})
+        new_refs = {}
+        for i in range(1, self.MAX_REFERENCES):
+            if str(i) in refs:
+                new_refs[str(i - 1)] = refs[str(i)]
+        metadata["references"] = new_refs
+        self._save_metadata(printer_id, metadata)
+
+    def get_references(self, printer_id: int) -> list[dict]:
+        """Get all references with metadata for a printer.
+
+        Returns list of dicts with: index, label, timestamp, has_image
+        """
+
+        metadata = self._load_metadata(printer_id)
+        refs = metadata.get("references", {})
+        result = []
+
+        for i in range(self.MAX_REFERENCES):
+            path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
+            if path.exists():
+                ref_meta = refs.get(str(i), {})
+                result.append(
+                    {
+                        "index": i,
+                        "label": ref_meta.get("label", ""),
+                        "timestamp": ref_meta.get("timestamp", ""),
+                        "has_image": True,
+                    }
+                )
+
+        return result
+
+    def update_reference_label(self, printer_id: int, index: int, label: str) -> bool:
+        """Update the label for a reference."""
+        if index < 0 or index >= self.MAX_REFERENCES:
+            return False
+
+        path = _get_calibration_dir() / f"printer_{printer_id}_ref_{index}.jpg"
+        if not path.exists():
+            return False
+
+        metadata = self._load_metadata(printer_id)
+        if "references" not in metadata:
+            metadata["references"] = {}
+        if str(index) not in metadata["references"]:
+            metadata["references"][str(index)] = {}
+
+        metadata["references"][str(index)]["label"] = label
+        self._save_metadata(printer_id, metadata)
+        return True
+
+    def delete_reference(self, printer_id: int, index: int) -> bool:
+        """Delete a specific reference by index."""
+        if index < 0 or index >= self.MAX_REFERENCES:
+            return False
+
+        path = _get_calibration_dir() / f"printer_{printer_id}_ref_{index}.jpg"
+        if not path.exists():
+            return False
+
+        # Delete image
+        logger.info(f"Deleting reference {index} for printer {printer_id}: {path}")
+        path.unlink()
+
+        # Remove from metadata
+        metadata = self._load_metadata(printer_id)
+        refs = metadata.get("references", {})
+        if str(index) in refs:
+            del refs[str(index)]
+        metadata["references"] = refs
+        self._save_metadata(printer_id, metadata)
+
+        # Shift remaining references down to fill the gap
+        for i in range(index + 1, self.MAX_REFERENCES):
+            old_img = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
+            new_img = _get_calibration_dir() / f"printer_{printer_id}_ref_{i - 1}.jpg"
+            if old_img.exists():
+                old_img.rename(new_img)
+                # Also shift metadata
+                if str(i) in refs:
+                    refs[str(i - 1)] = refs[str(i)]
+                    del refs[str(i)]
+
+        metadata["references"] = refs
+        self._save_metadata(printer_id, metadata)
+        return True
+
+    def get_reference_thumbnail(self, printer_id: int, index: int, max_size: int = 150) -> bytes | None:
+        """Get a thumbnail of a reference image.
+
+        Returns JPEG bytes or None if not found.
+        """
+        path = _get_calibration_dir() / f"printer_{printer_id}_ref_{index}.jpg"
+        if not path.exists():
+            return None
+
+        try:
+            img = cv2.imread(str(path))
+            if img is None:
+                return None
+
+            # Calculate thumbnail size maintaining aspect ratio
+            h, w = img.shape[:2]
+            if w > h:
+                new_w = max_size
+                new_h = int(h * max_size / w)
+            else:
+                new_h = max_size
+                new_w = int(w * max_size / h)
+
+            thumb = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
+            _, buffer = cv2.imencode(".jpg", thumb, [cv2.IMWRITE_JPEG_QUALITY, 80])
+            return buffer.tobytes()
+        except Exception as e:
+            logger.error(f"Error creating thumbnail: {e}")
+            return None
+
+    def _extract_roi(self, frame: np.ndarray) -> tuple[np.ndarray, int, int, int, int]:
+        """Extract the region of interest from a frame.
+
+        Returns:
+            Tuple of (roi_frame, x_start, y_start, roi_width, roi_height)
+        """
+        height, width = frame.shape[:2]
+        x_start = int(width * self.roi[0])
+        y_start = int(height * self.roi[1])
+        roi_width = int(width * self.roi[2])
+        roi_height = int(height * self.roi[3])
+        roi_frame = frame[y_start : y_start + roi_height, x_start : x_start + roi_width]
+        return roi_frame, x_start, y_start, roi_width, roi_height
+
+    def _preprocess_for_comparison(self, frame: np.ndarray) -> np.ndarray:
+        """Preprocess a frame for comparison.
+
+        Uses heavy blur to create "blob" representation - smooths out texture
+        and noise while preserving large objects. Then normalizes brightness
+        to reduce lighting sensitivity.
+        """
+        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
+        # Very heavy blur to smooth texture, keep only large shapes
+        blurred = cv2.GaussianBlur(gray, (51, 51), 0)
+        # Normalize to 0-255 range to reduce brightness sensitivity
+        normalized = cv2.normalize(blurred, None, 0, 255, cv2.NORM_MINMAX)
+        return normalized
+
+    def calibrate(self, image_data: bytes, printer_id: int, label: str | None = None) -> tuple[bool, str, int]:
+        """Calibrate by saving a reference image of the empty plate.
+
+        Stores up to MAX_REFERENCES (5) images per printer. When all slots are full,
+        the oldest reference is removed and others are shifted.
+
+        Args:
+            image_data: JPEG image data as bytes
+            printer_id: Printer database ID
+            label: Optional label for this reference (e.g., "High Temp Plate")
+
+        Returns:
+            Tuple of (success, message, index) where index is the slot used
+        """
+        from datetime import datetime
+
+        try:
+            # Decode image
+            nparr = np.frombuffer(image_data, np.uint8)
+            frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
+
+            if frame is None:
+                return False, "Failed to decode image", -1
+
+            # Get existing references count
+            existing_refs = self._get_reference_paths(printer_id)
+            num_existing = len(existing_refs)
+
+            # If all slots are full, rotate (remove oldest)
+            if num_existing >= self.MAX_REFERENCES:
+                self._rotate_references(printer_id)
+                num_existing = self.MAX_REFERENCES - 1
+
+            # Save to next available slot
+            slot_index = num_existing
+            reference_path = _get_calibration_dir() / f"printer_{printer_id}_ref_{slot_index}.jpg"
+            write_success = cv2.imwrite(str(reference_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 95])
+
+            if not write_success:
+                logger.error(f"cv2.imwrite failed for {reference_path}")
+                return False, "Failed to save reference image", -1
+
+            # Verify the file actually exists and has content
+            if not reference_path.exists():
+                logger.error(f"Reference image not found after save: {reference_path}")
+                return False, "Reference image not found after save", -1
+
+            file_size = reference_path.stat().st_size
+            if file_size < 1000:  # JPEG should be at least 1KB
+                logger.error(f"Reference image too small ({file_size} bytes): {reference_path}")
+                reference_path.unlink()  # Clean up invalid file
+                return False, f"Reference image corrupted (only {file_size} bytes)", -1
+
+            logger.info(f"Saved reference image: {reference_path} ({file_size} bytes)")
+
+            # Save metadata
+            metadata = self._load_metadata(printer_id)
+            if "references" not in metadata:
+                metadata["references"] = {}
+            metadata["references"][str(slot_index)] = {
+                "label": label or "",
+                "timestamp": datetime.now().isoformat(),
+            }
+            self._save_metadata(printer_id, metadata)
+
+            logger.info(
+                f"Saved plate calibration reference {slot_index + 1}/{self.MAX_REFERENCES} for printer {printer_id}"
+            )
+            return True, f"Calibration saved ({slot_index + 1}/{self.MAX_REFERENCES} references)", slot_index
+
+        except Exception as e:
+            logger.exception("Error during plate calibration")
+            return False, f"Calibration error: {e!s}", -1
+
+    def get_calibration_count(self, printer_id: int) -> int:
+        """Get the number of calibration references for a printer."""
+        return len(self._get_reference_paths(printer_id))
+
+    def has_calibration(self, printer_id: int, plate_type: str | None = None) -> bool:
+        """Check if a printer has any calibration reference images."""
+        return len(self._get_reference_paths(printer_id)) > 0
+
+    def delete_calibration(self, printer_id: int, plate_type: str | None = None) -> bool:
+        """Delete all calibration reference images for a printer."""
+        paths = self._get_reference_paths(printer_id)
+        if not paths:
+            return False
+        for path in paths:
+            path.unlink()
+        logger.info(f"Deleted {len(paths)} plate calibration reference(s) for printer {printer_id}")
+        return True
+
+    def analyze_frame(
+        self, image_data: bytes, printer_id: int, plate_type: str | None = None, include_debug_image: bool = False
+    ) -> PlateDetectionResult:
+        """Analyze a camera frame to detect if the plate is empty.
+
+        Compares the current frame to all calibration reference images and uses
+        the best match (lowest difference) for the final result.
+
+        Args:
+            image_data: JPEG image data as bytes
+            printer_id: Printer database ID (for reference lookup)
+            plate_type: Unused - kept for API compatibility
+            include_debug_image: If True, include annotated image in result
+
+        Returns:
+            PlateDetectionResult with analysis results
+        """
+        try:
+            # Check for calibration
+            reference_paths = self._get_reference_paths(printer_id)
+            if not reference_paths:
+                return PlateDetectionResult(
+                    is_empty=True,  # Default to empty when not calibrated
+                    confidence=0.0,
+                    difference_percent=0.0,
+                    message="No calibration - please calibrate with empty plate first",
+                    needs_calibration=True,
+                )
+
+            # Decode current image
+            nparr = np.frombuffer(image_data, np.uint8)
+            current_frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
+
+            if current_frame is None:
+                return PlateDetectionResult(
+                    is_empty=True,
+                    confidence=0.0,
+                    difference_percent=0.0,
+                    message="Failed to decode current image",
+                )
+
+            # Extract ROI from current frame
+            current_roi, x_start, y_start, roi_width, roi_height = self._extract_roi(current_frame)
+            current_processed = self._preprocess_for_comparison(current_roi)
+
+            # Compare against all references, find best match (lowest difference)
+            best_difference_percent = float("inf")
+            best_ref_idx = -1
+            best_diff = None
+
+            for idx, ref_path in enumerate(reference_paths):
+                # Load reference image
+                reference_frame = cv2.imread(str(ref_path), cv2.IMREAD_COLOR)
+                if reference_frame is None:
+                    continue
+
+                # Ensure same dimensions
+                if current_frame.shape != reference_frame.shape:
+                    reference_frame = cv2.resize(reference_frame, (current_frame.shape[1], current_frame.shape[0]))
+
+                # Extract ROI and preprocess
+                reference_roi, _, _, _, _ = self._extract_roi(reference_frame)
+                reference_processed = self._preprocess_for_comparison(reference_roi)
+
+                # Calculate absolute difference
+                diff = cv2.absdiff(current_processed, reference_processed)
+
+                # Calculate mean difference as percentage
+                mean_diff = np.mean(diff)
+                difference_percent = (mean_diff / 255.0) * 100
+
+                if difference_percent < best_difference_percent:
+                    best_difference_percent = difference_percent
+                    best_ref_idx = idx
+                    best_diff = diff
+
+            if best_ref_idx == -1:
+                return PlateDetectionResult(
+                    is_empty=True,
+                    confidence=0.0,
+                    difference_percent=0.0,
+                    message="Failed to load any reference images - please recalibrate",
+                    needs_calibration=True,
+                )
+
+            difference_percent = best_difference_percent
+
+            # Determine if plate is empty (use best match)
+            is_empty = difference_percent < self.difference_threshold
+
+            # Calculate confidence
+            if is_empty:
+                # Higher confidence when very little difference
+                confidence = 1.0 - min(1.0, difference_percent / self.difference_threshold)
+            else:
+                # Higher confidence when clearly different
+                confidence = min(1.0, difference_percent / (self.difference_threshold * 2))
+
+            # Generate message
+            num_refs = len(reference_paths)
+            if is_empty:
+                message = (
+                    f"Plate appears empty (difference: {difference_percent:.1f}%, ref {best_ref_idx + 1}/{num_refs})"
+                )
+            else:
+                message = f"Objects detected on plate (difference: {difference_percent:.1f}%, best ref {best_ref_idx + 1}/{num_refs})"
+
+            # Generate debug image if requested
+            debug_image = None
+            if include_debug_image and best_diff is not None:
+                debug_frame = current_frame.copy()
+
+                # Draw ROI rectangle
+                cv2.rectangle(
+                    debug_frame,
+                    (x_start, y_start),
+                    (x_start + roi_width, y_start + roi_height),
+                    (0, 255, 0),
+                    2,
+                )
+
+                # Create colored difference overlay
+                # Red = areas that are different from reference
+                # Amplify diff for visibility (multiply by 3, cap at 255)
+                diff_amplified = np.minimum(best_diff * 3, 255).astype(np.uint8)
+                diff_colored = cv2.cvtColor(diff_amplified, cv2.COLOR_GRAY2BGR)
+                diff_colored[:, :, 0] = 0  # Remove blue
+                diff_colored[:, :, 1] = 0  # Remove green
+                # Red channel has the diff
+
+                # Overlay difference on ROI
+                roi_overlay = debug_frame[y_start : y_start + roi_height, x_start : x_start + roi_width]
+                cv2.addWeighted(diff_colored, 0.5, roi_overlay, 0.5, 0, roi_overlay)
+
+                # Add status text
+                status_text = "EMPTY" if is_empty else "OBJECTS DETECTED"
+                color = (0, 255, 0) if is_empty else (0, 0, 255)
+                cv2.putText(debug_frame, status_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, color, 2)
+                cv2.putText(
+                    debug_frame,
+                    f"Diff: {difference_percent:.1f}% (ref {best_ref_idx + 1}/{num_refs})",
+                    (10, 60),
+                    cv2.FONT_HERSHEY_SIMPLEX,
+                    0.7,
+                    color,
+                    2,
+                )
+                cv2.putText(
+                    debug_frame,
+                    f"Confidence: {confidence:.0%}",
+                    (10, 90),
+                    cv2.FONT_HERSHEY_SIMPLEX,
+                    0.7,
+                    color,
+                    2,
+                )
+
+                # Encode debug image as JPEG
+                _, buffer = cv2.imencode(".jpg", debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
+                debug_image = buffer.tobytes()
+
+            return PlateDetectionResult(
+                is_empty=is_empty,
+                confidence=confidence,
+                difference_percent=difference_percent,
+                message=message,
+                debug_image=debug_image,
+            )
+
+        except Exception as e:
+            logger.exception("Error analyzing frame for plate detection")
+            return PlateDetectionResult(
+                is_empty=True,  # Default to empty on error (don't block prints)
+                confidence=0.0,
+                difference_percent=0.0,
+                message=f"Analysis error: {e!s}",
+            )
+
+
+async def capture_camera_image(
+    printer_id: int,
+    ip_address: str,
+    access_code: str,
+    model: str,
+    external_camera_url: str | None = None,
+    external_camera_type: str | None = None,
+    use_external: bool = False,
+) -> tuple[bytes | None, str]:
+    """Capture an image from the printer camera.
+
+    If there's an active camera stream, uses the buffered frame instead of
+    creating a new connection (which would fail while stream is active).
+
+    Returns:
+        Tuple of (image_data, camera_source) or (None, error_message)
+    """
+    image_data: bytes | None = None
+    camera_source = "unknown"
+
+    # Try external camera first if requested and available
+    if use_external and external_camera_url and external_camera_type:
+        try:
+            from backend.app.services.external_camera import capture_frame
+
+            image_data = await capture_frame(external_camera_url, external_camera_type)
+            if image_data:
+                camera_source = "external"
+                logger.debug(f"Captured frame from external camera for printer {printer_id}")
+        except Exception as e:
+            logger.warning(f"Failed to capture from external camera: {e}")
+
+    # Fall back to built-in camera
+    if image_data is None:
+        # First, check if there's an active stream with a buffered frame
+        # This avoids blocking when camera viewer is open
+        try:
+            from backend.app.api.routes.camera import get_buffered_frame
+
+            buffered = get_buffered_frame(printer_id)
+            if buffered:
+                image_data = buffered
+                camera_source = "built-in (buffered)"
+                logger.debug(f"Using buffered frame from active stream for printer {printer_id}")
+        except Exception as e:
+            logger.debug(f"Could not get buffered frame: {e}")
+
+        # If no buffered frame, try to capture a new one
+        if image_data is None:
+            import tempfile
+
+            from backend.app.services.camera import capture_camera_frame
+
+            with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
+                tmp_path = Path(tmp.name)
+
+            try:
+                success = await capture_camera_frame(ip_address, access_code, model, tmp_path, timeout=10)
+                if success:
+                    with open(tmp_path, "rb") as f:
+                        image_data = f.read()
+                    camera_source = "built-in"
+                    logger.debug(f"Captured frame from built-in camera for printer {printer_id}")
+            finally:
+                try:
+                    tmp_path.unlink()
+                except Exception:
+                    pass
+
+    return image_data, camera_source
+
+
+async def check_plate_empty(
+    printer_id: int,
+    ip_address: str,
+    access_code: str,
+    model: str,
+    plate_type: str | None = None,
+    include_debug_image: bool = False,
+    external_camera_url: str | None = None,
+    external_camera_type: str | None = None,
+    use_external: bool = False,
+    roi: tuple[float, float, float, float] | None = None,
+) -> PlateDetectionResult:
+    """Check if the build plate is empty for a printer.
+
+    Args:
+        printer_id: Printer database ID
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model string
+        plate_type: Type of build plate for calibration lookup
+        include_debug_image: If True, include annotated image in result
+        external_camera_url: URL of external camera (if configured)
+        external_camera_type: Type of external camera (mjpeg, rtsp, snapshot)
+        use_external: If True, prefer external camera over built-in
+        roi: Region of interest as (x%, y%, w%, h%) - percentages of image size
+
+    Returns:
+        PlateDetectionResult with analysis results
+    """
+    if not OPENCV_AVAILABLE:
+        return PlateDetectionResult(
+            is_empty=True,
+            confidence=0.0,
+            difference_percent=0.0,
+            message="OpenCV not available - plate detection disabled",
+        )
+
+    image_data, camera_source = await capture_camera_image(
+        printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external
+    )
+
+    if image_data is None:
+        return PlateDetectionResult(
+            is_empty=True,  # Default to empty on error
+            confidence=0.0,
+            difference_percent=0.0,
+            message="Failed to capture camera frame from any source",
+        )
+
+    # Analyze the captured frame
+    detector = PlateDetector(roi=roi)
+    result = detector.analyze_frame(image_data, printer_id, plate_type, include_debug_image)
+
+    # Add camera source to message
+    result.message = f"[{camera_source}] {result.message}"
+
+    return result
+
+
+async def calibrate_plate(
+    printer_id: int,
+    ip_address: str,
+    access_code: str,
+    model: str,
+    label: str | None = None,
+    external_camera_url: str | None = None,
+    external_camera_type: str | None = None,
+    use_external: bool = False,
+) -> tuple[bool, str, int]:
+    """Calibrate plate detection by capturing a reference image of the empty plate.
+
+    Args:
+        printer_id: Printer database ID
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model string
+        label: Optional label for this reference (e.g., "High Temp Plate")
+        external_camera_url: URL of external camera (if configured)
+        external_camera_type: Type of external camera (mjpeg, rtsp, snapshot)
+        use_external: If True, prefer external camera over built-in
+
+    Returns:
+        Tuple of (success, message, index)
+    """
+    if not OPENCV_AVAILABLE:
+        return False, "OpenCV not available - plate detection disabled", -1
+
+    image_data, camera_source = await capture_camera_image(
+        printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external
+    )
+
+    if image_data is None:
+        return False, "Failed to capture camera frame for calibration", -1
+
+    detector = PlateDetector()
+    success, message, index = detector.calibrate(image_data, printer_id, label)
+
+    if success:
+        message = f"[{camera_source}] {message}"
+
+    return success, message, index
+
+
+def get_calibration_status(printer_id: int, plate_type: str | None = None) -> dict:
+    """Get calibration status for a printer.
+
+    Returns:
+        Dict with calibration info including reference count
+    """
+    if not OPENCV_AVAILABLE:
+        return {
+            "available": False,
+            "calibrated": False,
+            "reference_count": 0,
+            "max_references": 5,
+            "message": "OpenCV not available",
+        }
+
+    detector = PlateDetector()
+    calibrated = detector.has_calibration(printer_id)
+    ref_count = detector.get_calibration_count(printer_id)
+
+    if calibrated:
+        message = f"Calibrated with {ref_count}/{detector.MAX_REFERENCES} reference(s)"
+    else:
+        message = "Not calibrated - please calibrate with empty plate"
+
+    return {
+        "available": True,
+        "calibrated": calibrated,
+        "reference_count": ref_count,
+        "max_references": detector.MAX_REFERENCES,
+        "message": message,
+    }
+
+
+def delete_calibration(printer_id: int, plate_type: str | None = None) -> bool:
+    """Delete calibration for a printer and plate type."""
+    if not OPENCV_AVAILABLE:
+        return False
+
+    detector = PlateDetector()
+    return detector.delete_calibration(printer_id, plate_type)
+
+
+def is_plate_detection_available() -> bool:
+    """Check if plate detection feature is available (OpenCV installed)."""
+    return OPENCV_AVAILABLE

+ 70 - 24
backend/app/services/print_scheduler.py

@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.config import settings
 from backend.app.core.database import async_session
 from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
@@ -126,8 +127,9 @@ class PrintScheduler:
         if not state:
             return False
 
-        # Printer is idle if state is IDLE or FINISH
-        return state.state in ("IDLE", "FINISH", "unknown")
+        # Printer is idle if state is IDLE, FINISH, FAILED, or unknown
+        # FAILED means previous print failed, printer is ready for new print
+        return state.state in ("IDLE", "FINISH", "FAILED", "unknown")
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""
@@ -219,22 +221,15 @@ class PrintScheduler:
             await tasmota_service.turn_off(plug)
 
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
-        """Upload file and start print for a queue item."""
-        logger.info(f"Starting queue item {item.id}")
+        """Upload file and start print for a queue item.
 
-        # Get archive
-        result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
-        archive = result.scalar_one_or_none()
-        if not archive:
-            item.status = "failed"
-            item.error_message = "Archive not found"
-            item.completed_at = datetime.utcnow()
-            await db.commit()
-            logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
-            await self._power_off_if_needed(db, item)
-            return
+        Supports two sources:
+        - archive_id: Print from an existing archive
+        - library_file_id: Print from a library file (file manager)
+        """
+        logger.info(f"Starting queue item {item.id}")
 
-        # Get printer
+        # Get printer first (needed for both paths)
         result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         printer = result.scalar_one_or_none()
         if not printer:
@@ -256,11 +251,60 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             return
 
-        # Get file path
-        file_path = settings.base_dir / archive.file_path
+        # Determine source: archive or library file
+        archive = None
+        library_file = None
+        file_path = None
+        filename = None
+
+        if item.archive_id:
+            # Print from archive
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
+            archive = result.scalar_one_or_none()
+            if not archive:
+                item.status = "failed"
+                item.error_message = "Archive not found"
+                item.completed_at = datetime.utcnow()
+                await db.commit()
+                logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
+                await self._power_off_if_needed(db, item)
+                return
+            file_path = settings.base_dir / archive.file_path
+            filename = archive.filename
+
+        elif item.library_file_id:
+            # Print from library file (file manager)
+            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            library_file = result.scalar_one_or_none()
+            if not library_file:
+                item.status = "failed"
+                item.error_message = "Library file not found"
+                item.completed_at = datetime.utcnow()
+                await db.commit()
+                logger.error(f"Queue item {item.id}: Library file {item.library_file_id} not found")
+                await self._power_off_if_needed(db, item)
+                return
+            # Library files store absolute paths
+            from pathlib import Path
+
+            lib_path = Path(library_file.file_path)
+            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+            filename = library_file.filename
+
+        else:
+            # Neither archive nor library file specified
+            item.status = "failed"
+            item.error_message = "No source file specified"
+            item.completed_at = datetime.utcnow()
+            await db.commit()
+            logger.error(f"Queue item {item.id}: No archive_id or library_file_id specified")
+            await self._power_off_if_needed(db, item)
+            return
+
+        # Check file exists on disk
         if not file_path.exists():
             item.status = "failed"
-            item.error_message = "Archive file not found on disk"
+            item.error_message = "Source file not found on disk"
             item.completed_at = datetime.utcnow()
             await db.commit()
             logger.error(f"Queue item {item.id}: File not found: {file_path}")
@@ -269,7 +313,7 @@ class PrintScheduler:
 
         # Upload file to printer via FTP
         # Use a clean filename to avoid issues with double extensions like .gcode.3mf
-        base_name = archive.filename
+        base_name = filename
         if base_name.endswith(".gcode.3mf"):
             base_name = base_name[:-10]  # Remove .gcode.3mf
         elif base_name.endswith(".3mf"):
@@ -331,9 +375,11 @@ class PrintScheduler:
             return
 
         # Register as expected print so we don't create a duplicate archive
-        from backend.app.main import register_expected_print
+        # Only applicable for archive-based prints
+        if archive:
+            from backend.app.main import register_expected_print
 
-        register_expected_print(item.printer_id, remote_filename, archive.id)
+            register_expected_print(item.printer_id, remote_filename, archive.id)
 
         # Parse AMS mapping if stored
         ams_mapping = None
@@ -363,7 +409,7 @@ class PrintScheduler:
             item.status = "printing"
             item.started_at = datetime.utcnow()
             await db.commit()
-            logger.info(f"Queue item {item.id}: Print started - {archive.filename}")
+            logger.info(f"Queue item {item.id}: Print started - {filename}")
 
             # MQTT relay - publish queue job started
             try:
@@ -371,7 +417,7 @@ class PrintScheduler:
 
                 await mqtt_relay.on_queue_job_started(
                     job_id=item.id,
-                    filename=archive.filename,
+                    filename=filename,
                     printer_id=printer.id,
                     printer_name=printer.name,
                     printer_serial=printer.serial_number,

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

@@ -33,6 +33,22 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
     ]
 )
 
+# Models that may incorrectly report stg_cur=0 when idle (firmware bug)
+# Based on Home Assistant Bambu Lab integration observations
+# See: https://github.com/greghesp/ha-bambulab/blob/main/custom_components/bambu_lab/pybambu/models.py
+A1_MODELS = frozenset(
+    [
+        # Display names
+        "A1",
+        "A1 MINI",
+        "A1-MINI",
+        "A1MINI",
+        # Internal codes (from MQTT/SSDP)
+        "N1",  # A1 Mini
+        "N2S",  # A1
+    ]
+)
+
 
 def supports_chamber_temp(model: str | None) -> bool:
     """Check if a printer model has a real chamber temperature sensor.
@@ -47,6 +63,19 @@ def supports_chamber_temp(model: str | None) -> bool:
     return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
 
 
+def has_stg_cur_idle_bug(model: str | None) -> bool:
+    """Check if a printer model may incorrectly report stg_cur=0 when idle.
+
+    Some A1/A1 Mini firmware versions report stg_cur=0 (which maps to "Printing")
+    even when the printer is idle. This is a known firmware bug that was observed
+    in the Home Assistant Bambu Lab integration.
+    """
+    if not model:
+        return False
+    model_upper = model.strip().upper()
+    return model_upper in A1_MODELS
+
+
 class PrinterInfo:
     """Basic printer info for callbacks."""
 
@@ -66,6 +95,7 @@ class PrinterManager:
         self._on_print_complete: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
+        self._on_layer_change: Callable[[int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
@@ -92,6 +122,10 @@ class PrinterManager:
         """Set callback for AMS data change events."""
         self._on_ams_change = callback
 
+    def set_layer_change_callback(self, callback: Callable[[int, int], None]):
+        """Set callback for layer change events. Receives (printer_id, layer_num)."""
+        self._on_layer_change = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
 
@@ -135,6 +169,10 @@ class PrinterManager:
             if self._on_ams_change:
                 self._schedule_async(self._on_ams_change(printer_id, ams_data))
 
+        def on_layer_change(layer_num: int):
+            if self._on_layer_change:
+                self._schedule_async(self._on_layer_change(printer_id, layer_num))
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -143,6 +181,7 @@ class PrinterManager:
             on_print_start=on_print_start,
             on_print_complete=on_print_complete,
             on_ams_change=on_ams_change,
+            on_layer_change=on_layer_change,
         )
 
         client.connect()
@@ -363,13 +402,22 @@ class PrinterManager:
         return result
 
 
-def get_derived_status_name(state: PrinterState) -> str | None:
+def get_derived_status_name(state: PrinterState, model: str | None = None) -> str | None:
     """
     Compute a human-readable status name based on printer state.
 
     Uses stg_cur when available, otherwise derives status from temperature data
     when the printer is heating before a print starts.
+
+    Args:
+        state: The printer state to analyze
+        model: Optional printer model for model-specific workarounds
     """
+    # A1/A1 Mini firmware bug: some versions report stg_cur=0 when idle
+    # Only correct this specific case (IDLE + stg_cur=0) for affected models
+    if state.state == "IDLE" and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
+        return None
+
     # If we have a valid calibration stage, use it
     # X1 models use -1 for idle, A1/P1 models use 255 for idle
     # Valid stage numbers are 0-254
@@ -571,7 +619,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "wifi_signal": state.wifi_signal,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
-        "stg_cur_name": get_derived_status_name(state),
+        "stg_cur_name": get_derived_status_name(state, model),
         # Printable objects count for skip objects feature
         "printable_objects_count": len(state.printable_objects),
         # Fan speeds (0-100 percentage, None if not available)

+ 0 - 133
backend/app/services/telemetry.py

@@ -1,133 +0,0 @@
-"""Anonymous telemetry service for BamBuddy."""
-
-import asyncio
-import logging
-import uuid
-from datetime import datetime, timedelta
-
-import httpx
-from sqlalchemy import func, select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from backend.app.core.config import APP_VERSION
-from backend.app.models.printer import Printer
-from backend.app.models.settings import Settings
-
-logger = logging.getLogger(__name__)
-
-# Default telemetry server URL (can be overridden via settings)
-DEFAULT_TELEMETRY_URL = "https://telemetry.bambuddy.cool"
-
-# How often to send heartbeats (once per day)
-HEARTBEAT_INTERVAL = timedelta(hours=24)
-
-_last_heartbeat: datetime | None = None
-
-
-async def get_or_create_installation_id(db: AsyncSession) -> str:
-    """Get existing installation ID or create a new one."""
-    result = await db.execute(select(Settings).where(Settings.key == "installation_id"))
-    setting = result.scalar_one_or_none()
-
-    if setting:
-        return setting.value
-
-    # Generate new UUID
-    installation_id = str(uuid.uuid4())
-
-    # Save to database
-    new_setting = Settings(key="installation_id", value=installation_id)
-    db.add(new_setting)
-    await db.commit()
-
-    logger.info(f"Generated new installation ID: {installation_id[:8]}...")
-    return installation_id
-
-
-async def is_telemetry_enabled(db: AsyncSession) -> bool:
-    """Check if telemetry is enabled (opt-out model)."""
-    result = await db.execute(select(Settings).where(Settings.key == "telemetry_enabled"))
-    setting = result.scalar_one_or_none()
-
-    # Default to enabled (opt-out model)
-    if not setting:
-        return True
-
-    return setting.value.lower() == "true"
-
-
-async def get_telemetry_url(db: AsyncSession) -> str:
-    """Get telemetry server URL from settings."""
-    result = await db.execute(select(Settings).where(Settings.key == "telemetry_url"))
-    setting = result.scalar_one_or_none()
-
-    return setting.value if setting else DEFAULT_TELEMETRY_URL
-
-
-async def get_printer_model_counts(db: AsyncSession) -> dict[str, int]:
-    """Get count of each printer model configured in BamBuddy."""
-    result = await db.execute(select(Printer.model, func.count(Printer.id)).group_by(Printer.model))
-    counts = {}
-    for model, count in result.all():
-        # Normalize model name (handle None/empty)
-        model_name = model if model else "Unknown"
-        counts[model_name] = count
-    return counts
-
-
-async def send_heartbeat(db: AsyncSession) -> bool:
-    """Send anonymous heartbeat to telemetry server."""
-    global _last_heartbeat
-
-    try:
-        # Check if telemetry is enabled
-        if not await is_telemetry_enabled(db):
-            logger.debug("Telemetry disabled, skipping heartbeat")
-            return False
-
-        # Rate limit: only send once per day
-        if _last_heartbeat and datetime.now() - _last_heartbeat < HEARTBEAT_INTERVAL:
-            logger.debug("Heartbeat already sent recently, skipping")
-            return True
-
-        installation_id = await get_or_create_installation_id(db)
-        telemetry_url = await get_telemetry_url(db)
-        printer_models = await get_printer_model_counts(db)
-
-        async with httpx.AsyncClient(timeout=10.0) as client:
-            response = await client.post(
-                f"{telemetry_url}/heartbeat",
-                json={
-                    "installation_id": installation_id,
-                    "version": APP_VERSION,
-                    "printer_models": printer_models,
-                },
-            )
-            response.raise_for_status()
-
-        _last_heartbeat = datetime.now()
-        logger.info(f"Telemetry heartbeat sent to {telemetry_url}")
-        return True
-
-    except httpx.HTTPError as e:
-        logger.debug(f"Telemetry heartbeat failed (network): {e}")
-        return False
-    except Exception as e:
-        logger.debug(f"Telemetry heartbeat failed: {e}")
-        return False
-
-
-async def start_telemetry_loop(get_session):
-    """Background task to send periodic heartbeats."""
-    # Wait a bit before first heartbeat to let app initialize
-    await asyncio.sleep(30)
-
-    while True:
-        try:
-            async with get_session() as db:
-                await send_heartbeat(db)
-        except Exception as e:
-            logger.debug(f"Telemetry loop error: {e}")
-
-        # Check daily
-        await asyncio.sleep(HEARTBEAT_INTERVAL.total_seconds())

+ 17 - 0
backend/tests/conftest.py

@@ -1,12 +1,16 @@
 """Shared test fixtures for BamBuddy backend tests."""
 
 import asyncio
+import atexit
 import json
 import logging
 import os
+import shutil
 import sys
+import tempfile
 from collections.abc import AsyncGenerator
 from datetime import datetime
+from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -24,6 +28,19 @@ from backend.app.core.config import settings  # noqa: E402
 
 settings.log_to_file = False
 
+# Use a temp directory for plate calibration to avoid deleting real calibration files
+_test_plate_cal_dir = Path(tempfile.mkdtemp(prefix="bambuddy_test_plate_cal_"))
+settings.plate_calibration_dir = _test_plate_cal_dir
+
+
+# Clean up temp directory when tests finish
+def _cleanup_test_plate_cal_dir():
+    if _test_plate_cal_dir.exists():
+        shutil.rmtree(_test_plate_cal_dir, ignore_errors=True)
+
+
+atexit.register(_cleanup_test_plate_cal_dir)
+
 from backend.app.core.database import Base  # noqa: E402
 
 # Use in-memory SQLite for tests

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

@@ -146,6 +146,28 @@ class TestArchivesAPI:
         assert response.status_code == 200
         assert response.json()["is_favorite"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_external_url(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive external_url can be updated."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}", json={"external_url": "https://printables.com/model/12345"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["external_url"] == "https://printables.com/model/12345"
+
+        # Verify it can be cleared
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"external_url": None})
+
+        assert response.status_code == 200
+        assert response.json()["external_url"] is None
+
     # ========================================================================
     # Delete endpoints
     # ========================================================================

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

@@ -223,3 +223,236 @@ class TestCameraAPI:
             )
             # Response will be a streaming response with error
             assert response.status_code == 200
+
+    # ========================================================================
+    # Plate Detection Endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plate_detection_status_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when checking plate detection status for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/status")
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plate_detection_status_opencv_not_available(self, async_client: AsyncClient, printer_factory):
+        """Verify plate detection status returns unavailable when OpenCV not installed."""
+        printer = await printer_factory()
+
+        with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["available"] is False
+        assert result["calibrated"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_plate_detection_status_success(self, async_client: AsyncClient, printer_factory):
+        """Verify plate detection status returns correctly when OpenCV available."""
+        printer = await printer_factory()
+
+        # OpenCV is available in test environment, just check the response structure
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "available" in result
+        assert "calibrated" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_plate_empty_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when checking plate for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/camera/check-plate")
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_plate_empty_success_structure(self, async_client: AsyncClient, printer_factory):
+        """Verify check plate returns proper structure when OpenCV available."""
+        printer = await printer_factory()
+
+        # Mock PlateDetectionResult to avoid camera timeout
+        mock_result = MagicMock()
+        mock_result.is_empty = True
+        mock_result.confidence = 0.95
+        mock_result.difference_percent = 0.5
+        mock_result.message = "Plate appears empty"
+        mock_result.needs_calibration = False
+        mock_result.to_dict.return_value = {
+            "is_empty": True,
+            "confidence": 0.95,
+            "difference_percent": 0.5,
+            "message": "Plate appears empty",
+            "has_debug_image": False,
+            "needs_calibration": False,
+        }
+
+        with patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check:
+            mock_check.return_value = mock_result
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "is_empty" in result
+        assert "confidence" in result
+        assert "message" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calibrate_plate_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when calibrating plate for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/camera/plate-detection/calibrate")
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calibrate_plate_success_structure(self, async_client: AsyncClient, printer_factory):
+        """Verify calibrate endpoint responds with proper structure."""
+        printer = await printer_factory()
+
+        # Mock calibrate_plate at the source module to avoid camera timeout
+        with patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate:
+            mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is True
+        assert "index" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when deleting calibration for non-existent printer."""
+        response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/calibrate")
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_calibration_success(self, async_client: AsyncClient, printer_factory):
+        """Verify delete calibration returns proper structure."""
+        printer = await printer_factory()
+
+        response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "success" in result
+        assert "message" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_references_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when getting references for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references")
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_references_opencv_not_available(self, async_client: AsyncClient, printer_factory):
+        """Verify get references returns unavailable when OpenCV not installed."""
+        printer = await printer_factory()
+
+        with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
+
+        assert response.status_code == 503
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_references_success(self, async_client: AsyncClient, printer_factory):
+        """Verify get references returns proper structure."""
+        printer = await printer_factory()
+
+        # OpenCV is available in test environment, just check the response structure
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "references" in result
+        assert "max_references" in result
+        assert isinstance(result["references"], list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_reference_label_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when updating reference label for non-existent printer."""
+        response = await async_client.put(
+            "/api/v1/printers/99999/camera/plate-detection/references/0", params={"label": "New Label"}
+        )
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_reference_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when deleting reference for non-existent printer."""
+        response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/references/0")
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_reference_thumbnail_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 when getting reference thumbnail for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references/0/thumbnail")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # USB Camera Endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_usb_cameras_returns_list(self, async_client: AsyncClient):
+        """Verify USB cameras endpoint returns a list of cameras."""
+        response = await async_client.get("/api/v1/printers/usb-cameras")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "cameras" in result
+        assert isinstance(result["cameras"], list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_usb_cameras_structure(self, async_client: AsyncClient):
+        """Verify USB cameras endpoint returns proper structure for each camera."""
+        with patch("backend.app.services.external_camera.list_usb_cameras") as mock_list:
+            mock_list.return_value = [
+                {"device": "/dev/video0", "name": "Logitech Webcam C920", "index": 0},
+                {"device": "/dev/video2", "name": "USB Camera", "index": 2},
+            ]
+
+            response = await async_client.get("/api/v1/printers/usb-cameras")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["cameras"]) == 2
+        assert result["cameras"][0]["device"] == "/dev/video0"
+        assert result["cameras"][0]["name"] == "Logitech Webcam C920"
+        assert result["cameras"][1]["device"] == "/dev/video2"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_usb_cameras_empty_on_non_linux(self, async_client: AsyncClient):
+        """Verify USB cameras endpoint returns empty list on non-Linux systems."""
+        with patch("backend.app.services.external_camera.list_usb_cameras") as mock_list:
+            # Simulate non-Linux system (no /dev/video* devices)
+            mock_list.return_value = []
+
+            response = await async_client.get("/api/v1/printers/usb-cameras")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["cameras"] == []

+ 255 - 0
backend/tests/integration/test_github_backup_api.py

@@ -0,0 +1,255 @@
+"""Integration tests for GitHub Backup API endpoints."""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestGitHubBackupConfigAPI:
+    """Integration tests for /api/v1/github-backup endpoints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_config_no_config(self, async_client: AsyncClient):
+        """Verify getting config when none exists returns null."""
+        response = await async_client.get("/api/v1/github-backup/config")
+        assert response.status_code == 200
+        assert response.json() is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_config(self, async_client: AsyncClient):
+        """Verify GitHub backup config can be created."""
+        data = {
+            "repository_url": "https://github.com/test/repo",
+            "access_token": "ghp_testtoken123",
+            "branch": "main",
+            "schedule_enabled": False,
+            "schedule_type": "daily",
+            "backup_kprofiles": True,
+            "backup_cloud_profiles": True,
+            "backup_settings": False,
+            "enabled": True,
+        }
+        response = await async_client.post("/api/v1/github-backup/config", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["repository_url"] == "https://github.com/test/repo"
+        assert result["branch"] == "main"
+        assert result["has_token"] is True
+        assert result["enabled"] is True
+        # Token should not be exposed in response
+        assert "access_token" not in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_config_after_create(self, async_client: AsyncClient):
+        """Verify getting config after creation returns the config."""
+        # Create config first
+        data = {
+            "repository_url": "https://github.com/test/getrepo",
+            "access_token": "ghp_testtoken456",
+            "branch": "develop",
+            "schedule_enabled": True,
+            "schedule_type": "weekly",
+            "backup_kprofiles": True,
+            "backup_cloud_profiles": False,
+            "backup_settings": True,
+            "enabled": True,
+        }
+        await async_client.post("/api/v1/github-backup/config", json=data)
+
+        # Get config
+        response = await async_client.get("/api/v1/github-backup/config")
+        assert response.status_code == 200
+        result = response.json()
+        assert result is not None
+        assert result["repository_url"] == "https://github.com/test/getrepo"
+        assert result["branch"] == "develop"
+        assert result["schedule_type"] == "weekly"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_config_partial(self, async_client: AsyncClient):
+        """Verify partial update of GitHub backup config."""
+        # Create config first
+        create_data = {
+            "repository_url": "https://github.com/test/update",
+            "access_token": "ghp_token",
+            "branch": "main",
+            "schedule_enabled": False,
+            "schedule_type": "daily",
+            "backup_kprofiles": True,
+            "backup_cloud_profiles": True,
+            "backup_settings": False,
+            "enabled": True,
+        }
+        await async_client.post("/api/v1/github-backup/config", json=create_data)
+
+        # Partial update
+        update_data = {
+            "branch": "develop",
+            "schedule_enabled": True,
+        }
+        response = await async_client.patch("/api/v1/github-backup/config", json=update_data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["branch"] == "develop"
+        assert result["schedule_enabled"] is True
+        # Original values should be preserved
+        assert result["repository_url"] == "https://github.com/test/update"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_config(self, async_client: AsyncClient):
+        """Verify GitHub backup config can be deleted."""
+        # Create config first
+        create_data = {
+            "repository_url": "https://github.com/test/delete",
+            "access_token": "ghp_deletetoken",
+            "branch": "main",
+            "schedule_enabled": False,
+            "schedule_type": "daily",
+            "backup_kprofiles": True,
+            "backup_cloud_profiles": True,
+            "backup_settings": False,
+            "enabled": True,
+        }
+        await async_client.post("/api/v1/github-backup/config", json=create_data)
+
+        # Delete
+        response = await async_client.delete("/api/v1/github-backup/config")
+        assert response.status_code == 200
+
+        # Verify it's deleted
+        get_response = await async_client.get("/api/v1/github-backup/config")
+        assert get_response.status_code == 200
+        assert get_response.json() is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_config_not_found(self, async_client: AsyncClient):
+        """Verify deleting non-existent config returns 404."""
+        # Make sure no config exists
+        await async_client.delete("/api/v1/github-backup/config")
+
+        # Try to delete again
+        response = await async_client.delete("/api/v1/github-backup/config")
+        assert response.status_code == 404
+
+
+class TestGitHubBackupStatusAPI:
+    """Integration tests for /api/v1/github-backup/status endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_no_config(self, async_client: AsyncClient):
+        """Verify status when no config exists."""
+        # Ensure no config
+        await async_client.delete("/api/v1/github-backup/config")
+
+        response = await async_client.get("/api/v1/github-backup/status")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["configured"] is False
+        assert result["enabled"] is False
+        assert result["is_running"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_with_config(self, async_client: AsyncClient):
+        """Verify status when config exists."""
+        # Create config
+        create_data = {
+            "repository_url": "https://github.com/test/status",
+            "access_token": "ghp_statustoken",
+            "branch": "main",
+            "schedule_enabled": True,
+            "schedule_type": "hourly",
+            "backup_kprofiles": True,
+            "backup_cloud_profiles": True,
+            "backup_settings": False,
+            "enabled": True,
+        }
+        await async_client.post("/api/v1/github-backup/config", json=create_data)
+
+        response = await async_client.get("/api/v1/github-backup/status")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["configured"] is True
+        assert result["enabled"] is True
+        assert result["is_running"] is False
+        assert result["next_scheduled_run"] is not None
+
+
+class TestGitHubBackupLogsAPI:
+    """Integration tests for /api/v1/github-backup/logs endpoints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_logs_no_config(self, async_client: AsyncClient):
+        """Verify getting logs when no config exists returns empty list."""
+        # Ensure no config
+        await async_client.delete("/api/v1/github-backup/config")
+
+        response = await async_client.get("/api/v1/github-backup/logs")
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_logs_with_config(self, async_client: AsyncClient):
+        """Verify getting logs with config."""
+        # Create config
+        create_data = {
+            "repository_url": "https://github.com/test/logs",
+            "access_token": "ghp_logstoken",
+            "branch": "main",
+            "schedule_enabled": False,
+            "schedule_type": "daily",
+            "backup_kprofiles": True,
+            "backup_cloud_profiles": True,
+            "backup_settings": False,
+            "enabled": True,
+        }
+        await async_client.post("/api/v1/github-backup/config", json=create_data)
+
+        response = await async_client.get("/api/v1/github-backup/logs")
+        assert response.status_code == 200
+        # No backups run yet, so empty list
+        assert response.json() == []
+
+
+class TestGitHubBackupTriggerAPI:
+    """Integration tests for /api/v1/github-backup/run endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_no_config(self, async_client: AsyncClient):
+        """Verify triggering backup without config returns 404."""
+        # Ensure no config
+        await async_client.delete("/api/v1/github-backup/config")
+
+        response = await async_client.post("/api/v1/github-backup/run")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_disabled_config(self, async_client: AsyncClient):
+        """Verify triggering backup with disabled config returns 400."""
+        # Create disabled config
+        create_data = {
+            "repository_url": "https://github.com/test/trigger",
+            "access_token": "ghp_triggertoken",
+            "branch": "main",
+            "schedule_enabled": False,
+            "schedule_type": "daily",
+            "backup_kprofiles": True,
+            "backup_cloud_profiles": True,
+            "backup_settings": False,
+            "enabled": False,  # Disabled
+        }
+        await async_client.post("/api/v1/github-backup/config", json=create_data)
+
+        response = await async_client.post("/api/v1/github-backup/run")
+        assert response.status_code == 400
+        assert "disabled" in response.json()["detail"].lower()

+ 27 - 116
backend/tests/integration/test_library_api.py

@@ -446,125 +446,36 @@ class TestLibraryZipExtractAPI:
         assert result["extracted"] == 1  # Only real_file.txt
         assert result["files"][0]["filename"] == "real_file.txt"
 
-
-class TestSTLThumbnailAPI:
-    """Integration tests for STL thumbnail generation endpoints."""
-
-    @pytest.fixture
-    async def stl_file_factory(self, db_session):
-        """Factory to create test STL files."""
-        _counter = [0]
-
-        async def _create_stl_file(**kwargs):
-            from backend.app.models.library import LibraryFile
-
-            _counter[0] += 1
-            counter = _counter[0]
-
-            defaults = {
-                "filename": f"test_model_{counter}.stl",
-                "file_path": f"/test/path/test_model_{counter}.stl",
-                "file_size": 1024,
-                "file_type": "stl",
-            }
-            defaults.update(kwargs)
-
-            lib_file = LibraryFile(**defaults)
-            db_session.add(lib_file)
-            await db_session.commit()
-            await db_session.refresh(lib_file)
-            return lib_file
-
-        return _create_stl_file
-
-    @pytest.fixture
-    async def folder_factory(self, db_session):
-        """Factory to create test folders."""
-        _counter = [0]
-
-        async def _create_folder(**kwargs):
-            from backend.app.models.library import LibraryFolder
-
-            _counter[0] += 1
-            counter = _counter[0]
-
-            defaults = {"name": f"Test Folder {counter}"}
-            defaults.update(kwargs)
-
-            folder = LibraryFolder(**defaults)
-            db_session.add(folder)
-            await db_session.commit()
-            await db_session.refresh(folder)
-            return folder
-
-        return _create_folder
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_regenerate_thumbnail_file_not_found(self, async_client: AsyncClient, db_session):
-        """Verify 404 for non-existent file."""
-        response = await async_client.post("/api/v1/library/files/9999/regenerate-thumbnail")
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_regenerate_thumbnail_file_missing_on_disk(
-        self, async_client: AsyncClient, stl_file_factory, db_session
-    ):
-        """Verify error when file exists in DB but not on disk."""
-        stl_file = await stl_file_factory()
-        response = await async_client.post(f"/api/v1/library/files/{stl_file.id}/regenerate-thumbnail")
-        assert response.status_code == 404
-        assert "not found on disk" in response.json()["detail"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_batch_generate_no_filter(self, async_client: AsyncClient, db_session):
-        """Verify error when no filter is specified."""
-        data = {}
-        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
-        assert response.status_code == 400
-        assert "Must specify" in response.json()["detail"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_batch_generate_all_missing_empty(self, async_client: AsyncClient, db_session):
-        """Verify batch generation with no matching files."""
-        data = {"all_missing": True}
-        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
-        assert response.status_code == 200
-        result = response.json()
-        assert result["processed"] == 0
-        assert result["succeeded"] == 0
-        assert result["failed"] == 0
-
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_batch_generate_specific_files(self, async_client: AsyncClient, stl_file_factory, db_session):
-        """Verify batch generation with specific file IDs."""
-        stl_file = await stl_file_factory()
-        data = {"file_ids": [stl_file.id]}
-        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
-        assert response.status_code == 200
-        result = response.json()
-        assert result["processed"] == 1
-        # Will fail because file doesn't exist on disk
-        assert result["failed"] == 1
-        assert result["results"][0]["error"] == "File not found on disk"
+    async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction creates a folder from the ZIP filename."""
+        import io
+        import zipfile
 
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_batch_generate_by_folder(
-        self, async_client: AsyncClient, stl_file_factory, folder_factory, db_session
-    ):
-        """Verify batch generation by folder ID."""
-        folder = await folder_factory()
-        await stl_file_factory(folder_id=folder.id)
-        await stl_file_factory(folder_id=folder.id)
-        await stl_file_factory()  # File in root, should not be processed
+        # Create a ZIP file with some files
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("file1.txt", "Content 1")
+            zf.writestr("file2.txt", "Content 2")
+        zip_buffer.seek(0)
 
-        data = {"folder_id": folder.id}
-        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        files = {"file": ("MyProject.zip", zip_buffer.read(), "application/zip")}
+        params = {"create_folder_from_zip": "true", "preserve_structure": "false"}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
         assert response.status_code == 200
         result = response.json()
-        assert result["processed"] == 2  # Only files in the folder
+        assert result["extracted"] == 2
+        assert result["folders_created"] == 1  # MyProject folder created
+
+        # Verify the files are in a folder
+        assert result["files"][0]["folder_id"] is not None
+        assert result["files"][1]["folder_id"] is not None
+        # Both files should be in the same folder
+        assert result["files"][0]["folder_id"] == result["files"][1]["folder_id"]
+
+        # Verify the folder was created with the right name
+        folder_response = await async_client.get(f"/api/v1/library/folders/{result['files'][0]['folder_id']}")
+        assert folder_response.status_code == 200
+        folder = folder_response.json()
+        assert folder["name"] == "MyProject"

+ 139 - 0
backend/tests/integration/test_metrics_api.py

@@ -0,0 +1,139 @@
+"""Integration tests for Prometheus Metrics API endpoint.
+
+Tests the /api/v1/metrics endpoint for Prometheus scraping.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestMetricsAPI:
+    """Integration tests for /api/v1/metrics endpoint."""
+
+    # ========================================================================
+    # Metrics endpoint access control
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_disabled_returns_404(self, async_client: AsyncClient):
+        """Verify metrics endpoint returns 404 when disabled."""
+        # Ensure prometheus is disabled
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": False})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 404
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_enabled_without_token(self, async_client: AsyncClient):
+        """Verify metrics endpoint works when enabled without token."""
+        # Enable prometheus without token
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        assert response.headers["content-type"].startswith("text/plain")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_with_token_requires_auth(self, async_client: AsyncClient):
+        """Verify metrics endpoint requires auth when token is set."""
+        # Enable prometheus with token
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": "secret123"})
+
+        # Request without auth
+        response = await async_client.get("/api/v1/metrics")
+        assert response.status_code == 401
+
+        # Request with wrong token
+        response = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer wrongtoken"})
+        assert response.status_code == 401
+
+        # Request with correct token
+        response = await async_client.get("/api/v1/metrics", headers={"Authorization": "Bearer secret123"})
+        assert response.status_code == 200
+
+    # ========================================================================
+    # Metrics content validation
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_format(self, async_client: AsyncClient):
+        """Verify metrics are in Prometheus text format."""
+        # Enable prometheus
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        content = response.text
+
+        # Check for Prometheus format markers
+        assert "# HELP" in content
+        assert "# TYPE" in content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_contains_expected_metrics(self, async_client: AsyncClient):
+        """Verify expected metrics are present."""
+        # Enable prometheus
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        content = response.text
+
+        # Check for key metrics
+        assert "bambuddy_printers_connected" in content
+        assert "bambuddy_printers_total" in content
+        assert "bambuddy_prints_total" in content
+        assert "bambuddy_filament_used_grams" in content
+        assert "bambuddy_print_time_seconds" in content
+        assert "bambuddy_queue_pending" in content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_metrics_printer_metrics_when_no_printers(self, async_client: AsyncClient):
+        """Verify printer metrics work when no printers configured."""
+        # Enable prometheus
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": ""})
+
+        response = await async_client.get("/api/v1/metrics")
+
+        assert response.status_code == 200
+        content = response.text
+
+        # Should still have system metrics
+        assert "bambuddy_printers_total" in content
+        assert "bambuddy_printers_connected" in content
+
+    # ========================================================================
+    # Settings persistence
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_prometheus_settings_persist(self, async_client: AsyncClient):
+        """Verify prometheus settings are saved correctly."""
+        # Update settings
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": True, "prometheus_token": "mytoken"})
+
+        # Read back settings
+        response = await async_client.get("/api/v1/settings/")
+        settings = response.json()
+
+        assert settings["prometheus_enabled"] is True
+        assert settings["prometheus_token"] == "mytoken"
+
+        # Disable and verify
+        await async_client.put("/api/v1/settings/", json={"prometheus_enabled": False})
+        response = await async_client.get("/api/v1/settings/")
+        settings = response.json()
+
+        assert settings["prometheus_enabled"] is False

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

@@ -734,3 +734,232 @@ class TestQueueLibraryFileSupport:
         assert our_item is not None
         assert our_item["library_file_name"] == "Custom Print Name"
         assert our_item["print_time_seconds"] == 7200
+
+
+class TestBulkUpdateEndpoint:
+    """Tests for the /queue/bulk endpoint."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Bulk Test Printer {counter}",
+                "ip_address": f"192.168.1.{150 + counter}",
+                "serial_number": f"TESTBULK{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"bulk_test_{counter}.3mf",
+                "print_name": f"Bulk Test Print {counter}",
+                "file_path": f"/tmp/bulk_test_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"bulkhash{counter:04d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+
+        async def _create_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": 1,
+                "bed_levelling": True,
+                "flow_cali": False,
+                "vibration_cali": True,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify bulk update can change a single field on multiple items."""
+        item1 = await queue_item_factory(bed_levelling=True)
+        item2 = await queue_item_factory(bed_levelling=True)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item1.id, item2.id], "bed_levelling": False},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["updated_count"] == 2
+        assert result["skipped_count"] == 0
+
+        # Verify items were updated
+        await db_session.refresh(item1)
+        await db_session.refresh(item2)
+        assert item1.bed_levelling is False
+        assert item2.bed_levelling is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify bulk update can change multiple fields at once."""
+        item1 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
+        item2 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={
+                "item_ids": [item1.id, item2.id],
+                "bed_levelling": False,
+                "flow_cali": True,
+                "manual_start": True,
+            },
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["updated_count"] == 2
+
+        await db_session.refresh(item1)
+        assert item1.bed_levelling is False
+        assert item1.flow_cali is True
+        assert item1.manual_start is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify bulk update skips non-pending items."""
+        pending_item = await queue_item_factory(status="pending", bed_levelling=True)
+        printing_item = await queue_item_factory(status="printing", bed_levelling=True)
+        completed_item = await queue_item_factory(status="completed", bed_levelling=True)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={
+                "item_ids": [pending_item.id, printing_item.id, completed_item.id],
+                "bed_levelling": False,
+            },
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["updated_count"] == 1
+        assert result["skipped_count"] == 2
+
+        # Only pending item should be updated
+        await db_session.refresh(pending_item)
+        await db_session.refresh(printing_item)
+        await db_session.refresh(completed_item)
+        assert pending_item.bed_levelling is False
+        assert printing_item.bed_levelling is True
+        assert completed_item.bed_levelling is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_change_printer(
+        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
+    ):
+        """Verify bulk update can reassign items to a different printer."""
+        new_printer = await printer_factory(name="New Target Printer")
+        item1 = await queue_item_factory()
+        item2 = await queue_item_factory()
+
+        original_printer_id = item1.printer_id
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item1.id, item2.id], "printer_id": new_printer.id},
+        )
+        assert response.status_code == 200
+
+        await db_session.refresh(item1)
+        await db_session.refresh(item2)
+        assert item1.printer_id == new_printer.id
+        assert item2.printer_id == new_printer.id
+        assert item1.printer_id != original_printer_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_empty_item_ids(self, async_client: AsyncClient):
+        """Verify 400 error when item_ids is empty."""
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [], "bed_levelling": False},
+        )
+        assert response.status_code == 400
+        assert "no item" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_no_fields(self, async_client: AsyncClient, queue_item_factory):
+        """Verify 400 error when no fields to update."""
+        item = await queue_item_factory()
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item.id]},
+        )
+        assert response.status_code == 400
+        assert "no fields" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_invalid_printer(self, async_client: AsyncClient, queue_item_factory):
+        """Verify 400 error when printer_id doesn't exist."""
+        item = await queue_item_factory()
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item.id], "printer_id": 99999},
+        )
+        assert response.status_code == 400
+        assert "printer not found" in response.json()["detail"].lower()

+ 327 - 0
backend/tests/integration/test_projects_api.py

@@ -297,3 +297,330 @@ class TestProjectArchivesAPI:
         # Project should have an archive count (may be 0)
         data = response.json()
         assert "name" in data
+
+
+class TestProjectExportImport:
+    """Tests for project export/import functionality."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+        _counter = [0]
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Export Test Project {counter}",
+                "description": "Test project for export",
+                "color": "#00FF00",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def bom_item_factory(self, db_session):
+        """Factory to create test BOM items."""
+
+        async def _create_bom_item(project_id: int, **kwargs):
+            from backend.app.models.project_bom import ProjectBOMItem
+
+            defaults = {
+                "project_id": project_id,
+                "name": "Test Part",
+                "quantity_needed": 1,
+                "quantity_acquired": 0,
+                "sort_order": 0,
+            }
+            defaults.update(kwargs)
+
+            item = ProjectBOMItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_bom_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
+        """Verify project export includes BOM items."""
+        project = await project_factory(
+            name="Export Me",
+            description="A test project",
+            target_count=10,
+            target_parts_count=50,
+            budget=100.0,
+        )
+
+        # Add BOM items
+        await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
+        await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
+
+        # Test JSON format export
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Export Me"
+        assert data["description"] == "A test project"
+        assert data["target_count"] == 10
+        assert data["target_parts_count"] == 50
+        assert data["budget"] == 100.0
+        assert len(data["bom_items"]) == 2
+
+        # Check BOM items
+        bom_names = [item["name"] for item in data["bom_items"]]
+        assert "M3x8 Screws" in bom_names
+        assert "Heat Inserts" in bom_names
+
+        # Test ZIP format export (default)
+        zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
+        assert zip_response.status_code == 200
+        assert zip_response.headers["content-type"] == "application/zip"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project(self, async_client: AsyncClient):
+        """Verify project can be imported with BOM items."""
+        import_data = {
+            "name": "Imported Project",
+            "description": "Imported from JSON",
+            "color": "#FF00FF",
+            "target_count": 5,
+            "target_parts_count": 25,
+            "budget": 50.0,
+            "bom_items": [
+                {
+                    "name": "PTFE Tubes",
+                    "quantity_needed": 4,
+                    "quantity_acquired": 0,
+                    "unit_price": 2.50,
+                    "sourcing_url": "https://example.com",
+                    "stl_filename": None,
+                    "remarks": "Need 4mm ID",
+                },
+            ],
+        }
+
+        response = await async_client.post("/api/v1/projects/import", json=import_data)
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Imported Project"
+        assert data["description"] == "Imported from JSON"
+        assert data["target_count"] == 5
+        assert data["target_parts_count"] == 25
+        assert data["budget"] == 50.0
+        assert data["id"] > 0  # Has a valid ID
+        # BOM stats should show 1 item imported
+        assert data["stats"]["bom_total_items"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify project export includes linked folders."""
+        from backend.app.models.library import LibraryFolder
+
+        project = await project_factory(name="Project With Folder")
+
+        # Create a linked folder
+        folder = LibraryFolder(name="Project Files", project_id=project.id)
+        db_session.add(folder)
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Project With Folder"
+        assert len(data["linked_folders"]) == 1
+        assert data["linked_folders"][0]["name"] == "Project Files"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
+        """Verify project import accepts linked folders data."""
+        import_data = {
+            "name": "Imported With Folders",
+            "linked_folders": [
+                {"name": "STL Files"},
+                {"name": "Documentation"},
+            ],
+        }
+
+        # Import should succeed with linked_folders
+        response = await async_client.post("/api/v1/projects/import", json=import_data)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "Imported With Folders"
+        assert data["id"] > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_from_json_file(self, async_client: AsyncClient):
+        """Verify project can be imported from JSON file upload."""
+        import io
+        import json
+
+        project_data = {
+            "name": "File Uploaded Project",
+            "description": "Imported from JSON file",
+            "color": "#123456",
+        }
+
+        # Create a file-like object
+        file_content = json.dumps(project_data).encode()
+        files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
+
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "File Uploaded Project"
+        assert data["description"] == "Imported from JSON file"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_from_zip_file(self, async_client: AsyncClient):
+        """Verify project can be imported from ZIP file with files."""
+        import io
+        import json
+        import zipfile
+
+        project_data = {
+            "name": "ZIP Imported Project",
+            "description": "Imported from ZIP",
+            "linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
+        }
+
+        # Create a ZIP file in memory
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("project.json", json.dumps(project_data))
+            zf.writestr("files/TestFolder/test.txt", "Hello World")
+
+        zip_buffer.seek(0)
+        files = {"file": ("project.zip", zip_buffer, "application/zip")}
+
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "ZIP Imported Project"
+        assert data["description"] == "Imported from ZIP"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify ZIP export contains actual files from linked folders."""
+        import io
+        import json
+        import zipfile
+        from pathlib import Path
+
+        from backend.app.api.routes.library import get_library_dir
+        from backend.app.models.library import LibraryFile, LibraryFolder
+
+        project = await project_factory(name="Project With Files")
+
+        # Create a linked folder with is_external fields
+        folder = LibraryFolder(
+            name="TestExportFolder",
+            project_id=project.id,
+            is_external=False,
+            external_readonly=False,
+            external_show_hidden=False,
+        )
+        db_session.add(folder)
+        await db_session.flush()
+
+        # Create a test file on disk
+        library_dir = get_library_dir()
+        folder_path = library_dir / "TestExportFolder"
+        folder_path.mkdir(parents=True, exist_ok=True)
+        test_file_path = folder_path / "test_export.txt"
+        test_file_path.write_text("Export test content")
+
+        # Create library file record
+        lib_file = LibraryFile(
+            folder_id=folder.id,
+            filename="test_export.txt",
+            file_path="TestExportFolder/test_export.txt",
+            file_type="other",
+            file_size=19,
+            is_external=False,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+
+        # Export as ZIP
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export")
+        assert response.status_code == 200
+        assert response.headers["content-type"] == "application/zip"
+
+        # Verify ZIP contents
+        zip_buffer = io.BytesIO(response.content)
+        with zipfile.ZipFile(zip_buffer, "r") as zf:
+            assert "project.json" in zf.namelist()
+            assert "files/TestExportFolder/test_export.txt" in zf.namelist()
+
+            # Verify file content
+            file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
+            assert file_content == "Export test content"
+
+            # Verify project.json
+            project_data = json.loads(zf.read("project.json"))
+            assert project_data["name"] == "Project With Files"
+
+        # Cleanup
+        test_file_path.unlink(missing_ok=True)
+        folder_path.rmdir()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_invalid_file_type(self, async_client: AsyncClient):
+        """Verify import rejects invalid file types."""
+        import io
+
+        files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "must be .zip or .json" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
+        """Verify import rejects ZIP without project.json."""
+        import io
+        import zipfile
+
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w") as zf:
+            zf.writestr("other.txt", "no project.json here")
+
+        zip_buffer.seek(0)
+        files = {"file": ("project.zip", zip_buffer, "application/zip")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "project.json" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_invalid_json(self, async_client: AsyncClient):
+        """Verify import rejects invalid JSON content."""
+        import io
+
+        files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "Invalid JSON" in response.json()["detail"]

+ 105 - 0
backend/tests/integration/test_settings_api.py

@@ -215,6 +215,28 @@ class TestSettingsAPI:
         assert result["currency"] == "JPY"
         assert result["check_updates"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_printer_firmware(self, async_client: AsyncClient):
+        """Verify check_printer_firmware can be updated."""
+        # Default should be True
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["check_printer_firmware"] is True
+
+        # Update to False
+        response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": False})
+        assert response.status_code == 200
+        assert response.json()["check_printer_firmware"] is False
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["check_printer_firmware"] is False
+
+        # Update back to True
+        response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": True})
+        assert response.status_code == 200
+        assert response.json()["check_printer_firmware"] is True
+
     # ========================================================================
     # MQTT settings tests
     # ========================================================================
@@ -370,3 +392,86 @@ class TestSettingsAPI:
         assert "per_printer_mapping_expanded" in result
         # Default is False as defined in schema
         assert isinstance(result["per_printer_mapping_expanded"], bool)
+
+    # ========================================================================
+    # Backup/Restore tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_backup_includes_external_camera_settings(self, async_client: AsyncClient, printer_factory):
+        """Verify backup includes external camera settings for printers."""
+        # Create a printer with external camera settings
+        _printer = await printer_factory(
+            name="Camera Test Printer",
+            external_camera_url="/dev/video0",
+            external_camera_type="usb",
+            external_camera_enabled=True,
+        )
+
+        # Request backup with printers
+        response = await async_client.get("/api/v1/settings/backup?include_printers=true")
+
+        assert response.status_code == 200
+        backup = response.json()
+
+        # Find the printer in the backup
+        assert "printers" in backup
+        printer_data = next((p for p in backup["printers"] if p["name"] == "Camera Test Printer"), None)
+        assert printer_data is not None
+
+        # Verify external camera fields are included
+        assert "external_camera_url" in printer_data
+        assert "external_camera_type" in printer_data
+        assert "external_camera_enabled" in printer_data
+        assert printer_data["external_camera_url"] == "/dev/video0"
+        assert printer_data["external_camera_type"] == "usb"
+        assert printer_data["external_camera_enabled"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restore_external_camera_settings_overwrite(self, async_client: AsyncClient, printer_factory):
+        """Verify restore with overwrite updates external camera settings."""
+        import io
+
+        # Create a printer without camera settings
+        printer = await printer_factory(
+            name="Restore Test",
+            external_camera_url=None,
+            external_camera_type=None,
+            external_camera_enabled=False,
+        )
+
+        # Create backup data with camera settings
+        backup_data = {
+            "version": "1.0",
+            "included": ["printers"],
+            "printers": [
+                {
+                    "name": "Restore Test",
+                    "serial_number": printer.serial_number,
+                    "ip_address": printer.ip_address,
+                    "external_camera_url": "/dev/video1",
+                    "external_camera_type": "usb",
+                    "external_camera_enabled": True,
+                }
+            ],
+        }
+
+        # Restore with overwrite
+        import json
+
+        files = {"file": ("backup.json", io.BytesIO(json.dumps(backup_data).encode()), "application/json")}
+        response = await async_client.post("/api/v1/settings/restore?overwrite=true", files=files)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is True
+
+        # Verify the printer was updated
+        response = await async_client.get(f"/api/v1/printers/{printer.id}")
+        assert response.status_code == 200
+        updated_printer = response.json()
+        assert updated_printer["external_camera_url"] == "/dev/video1"
+        assert updated_printer["external_camera_type"] == "usb"
+        assert updated_printer["external_camera_enabled"] is True

+ 51 - 0
backend/tests/unit/services/test_archive_service.py

@@ -611,3 +611,54 @@ class TestMultiPlate3MFParsing:
 
         is_multi_plate = len(plate_indices) > 1
         assert is_multi_plate is False
+
+
+class TestReprintCostCalculation:
+    """Tests for reprint cost calculation."""
+
+    def test_cost_addition_logic(self):
+        """Test that reprint costs are added correctly."""
+        # Simulate the cost addition logic
+        existing_cost = 5.25  # Original print cost
+        filament_grams = 100.0
+        cost_per_kg = 25.0  # Default cost
+
+        # Calculate additional cost for reprint
+        additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
+        assert additional_cost == 2.50
+
+        # Add to existing cost
+        new_total = round(existing_cost + additional_cost, 2)
+        assert new_total == 7.75
+
+    def test_cost_addition_with_none_existing(self):
+        """Test cost addition when existing cost is None."""
+        existing_cost = None
+        filament_grams = 200.0
+        cost_per_kg = 15.0
+
+        additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)
+        assert additional_cost == 3.0
+
+        # When existing is None, just use additional
+        new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)
+        assert new_total == 3.0
+
+    def test_cost_with_custom_filament_price(self):
+        """Test cost calculation with custom filament price."""
+        filament_grams = 150.0
+        custom_cost_per_kg = 35.0  # More expensive filament
+
+        cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)
+        assert cost == 5.25
+
+    def test_multiple_reprints_accumulate(self):
+        """Test that multiple reprints accumulate costs correctly."""
+        filament_grams = 100.0
+        cost_per_kg = 20.0
+        single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)
+        assert single_print_cost == 2.0
+
+        # After 3 prints (1 original + 2 reprints)
+        total_after_3_prints = round(single_print_cost * 3, 2)
+        assert total_after_3_prints == 6.0

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

@@ -424,3 +424,184 @@ class TestRealisticMessageFlow:
 
         assert complete_data["timelapse_was_active"] is True
         assert complete_data["status"] == "failed"
+
+
+class TestAMSDataMerging:
+    """Tests for AMS data merging, particularly handling empty slots."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_empty_slot_clears_tray_type(self, mqtt_client):
+        """Test that empty slot update clears tray_type (Issue #147).
+
+        When a spool is removed from an old AMS, the printer sends empty values.
+        These must overwrite the previous values to show the slot as empty.
+        """
+        # Initial state: AMS unit with a loaded spool
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_sub_brands": "Bambu PLA Basic",
+                            "tray_color": "FF0000",
+                            "tag_uid": "1234567890ABCDEF",
+                            "remain": 80,
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Verify initial state
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        assert len(ams_data) == 1
+        tray = ams_data[0]["tray"][0]
+        assert tray["tray_type"] == "PLA"
+        assert tray["tray_color"] == "FF0000"
+
+        # Now simulate spool removal - printer sends empty values
+        empty_update = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "",  # Empty = slot is empty
+                            "tray_sub_brands": "",
+                            "tray_color": "",
+                            "tag_uid": "0000000000000000",  # Zero UID
+                            "remain": 0,
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(empty_update)
+
+        # Verify empty values were applied (not ignored by merge logic)
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        tray = ams_data[0]["tray"][0]
+        assert tray["tray_type"] == "", "tray_type should be cleared when slot is empty"
+        assert tray["tray_color"] == "", "tray_color should be cleared when slot is empty"
+        assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
+        assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
+
+    def test_partial_update_preserves_other_fields(self, mqtt_client):
+        """Test that partial updates still preserve non-slot-status fields."""
+        # Initial state with full data
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "humidity": "3",
+                    "temp": "25.5",
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "00FF00",
+                            "remain": 90,
+                            "k": 0.02,
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Partial update - only remain changes
+        partial_update = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "remain": 85,  # Only this changed
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(partial_update)
+
+        # Verify remain was updated but other fields preserved
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        tray = ams_data[0]["tray"][0]
+        assert tray["remain"] == 85, "remain should be updated"
+        assert tray["tray_type"] == "PLA", "tray_type should be preserved"
+        assert tray["tray_color"] == "00FF00", "tray_color should be preserved"
+        assert tray["k"] == 0.02, "k should be preserved"
+
+    def test_tray_exist_bits_clears_empty_slots(self, mqtt_client):
+        """Test that tray_exist_bits clears slots marked as empty (Issue #147).
+
+        New AMS models (AMS 2 Pro) don't send empty tray data when a spool is removed.
+        Instead, they update tray_exist_bits to indicate which slots have spools.
+        """
+        # Initial state: AMS 0 and AMS 1 with loaded spools
+        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},
+                        {"id": 2, "tray_type": "ABS", "tray_color": "0000FF", "remain": 40},
+                        {"id": 3, "tray_type": "TPU", "tray_color": "FFFF00", "remain": 20},
+                    ],
+                },
+                {
+                    "id": 1,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFF", "remain": 90},
+                        {"id": 1, "tray_type": "PLA", "tray_color": "000000", "remain": 70},
+                        {"id": 2, "tray_type": "PLA", "tray_color": "FF00FF", "remain": 50},
+                        {"id": 3, "tray_type": "PLA", "tray_color": "00FFFF", "remain": 30},
+                    ],
+                },
+            ],
+            "tray_exist_bits": "ff",  # All 8 slots have spools (0xFF = 11111111)
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Verify initial state
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        assert ams_data[1]["tray"][3]["tray_type"] == "PLA"  # AMS 1 slot 3 (B4) has spool
+
+        # Now simulate spool removal from AMS 1 slot 3 (B4)
+        # tray_exist_bits: 0x7f = 01111111 (bit 7 = 0 means AMS 1 slot 3 is empty)
+        update_ams = {
+            "ams": [
+                {"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
+                {"id": 1, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
+            ],
+            "tray_exist_bits": "7f",  # Bit 7 = 0 -> AMS 1 slot 3 is empty
+        }
+        mqtt_client._handle_ams_data(update_ams)
+
+        # Verify AMS 1 slot 3 was cleared
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        b4_tray = ams_data[1]["tray"][3]
+        assert b4_tray["tray_type"] == "", "tray_type should be cleared for empty slot"
+        assert b4_tray["remain"] == 0, "remain should be 0 for empty slot"
+
+        # Verify other slots are preserved
+        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"

+ 259 - 0
backend/tests/unit/services/test_external_camera.py

@@ -0,0 +1,259 @@
+"""
+Tests for the external camera service.
+
+These tests cover pure functions and frame parsing logic.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+class TestFormatMjpegFrame:
+    """Tests for MJPEG frame formatting."""
+
+    def test_format_mjpeg_frame_basic(self):
+        """Verify MJPEG frame is formatted correctly with boundary and headers."""
+        from backend.app.services.external_camera import _format_mjpeg_frame
+
+        # Minimal JPEG data (just SOI and EOI markers)
+        jpeg_data = b"\xff\xd8\xff\xd9"
+
+        result = _format_mjpeg_frame(jpeg_data)
+
+        # Check boundary
+        assert result.startswith(b"--frame\r\n")
+        # Check content type
+        assert b"Content-Type: image/jpeg\r\n" in result
+        # Check content length
+        assert b"Content-Length: 4\r\n" in result
+        # Check frame data is included
+        assert jpeg_data in result
+        # Check ends with CRLF
+        assert result.endswith(b"\r\n")
+
+    def test_format_mjpeg_frame_larger_data(self):
+        """Verify content length is correct for larger frames."""
+        from backend.app.services.external_camera import _format_mjpeg_frame
+
+        # Simulate a larger JPEG (1000 bytes)
+        jpeg_data = b"\xff\xd8" + b"\x00" * 996 + b"\xff\xd9"
+
+        result = _format_mjpeg_frame(jpeg_data)
+
+        assert b"Content-Length: 1000\r\n" in result
+
+
+class TestGetFfmpegPath:
+    """Tests for ffmpeg path detection."""
+
+    def test_get_ffmpeg_path_from_shutil_which(self):
+        """Verify ffmpeg found via shutil.which is returned."""
+        from backend.app.services.external_camera import get_ffmpeg_path
+
+        with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
+            result = get_ffmpeg_path()
+            assert result == "/usr/bin/ffmpeg"
+
+    def test_get_ffmpeg_path_fallback_to_common_paths(self):
+        """Verify common paths are checked when shutil.which fails."""
+        from backend.app.services.external_camera import get_ffmpeg_path
+
+        with patch("shutil.which", return_value=None), patch("pathlib.Path.exists") as mock_exists:
+            # First common path exists
+            mock_exists.return_value = True
+            result = get_ffmpeg_path()
+            assert result in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
+
+    def test_get_ffmpeg_path_returns_none_when_not_found(self):
+        """Verify None is returned when ffmpeg not found anywhere."""
+        from backend.app.services.external_camera import get_ffmpeg_path
+
+        with patch("shutil.which", return_value=None), patch("pathlib.Path.exists", return_value=False):
+            result = get_ffmpeg_path()
+            assert result is None
+
+
+class TestJpegFrameExtraction:
+    """Tests for JPEG frame extraction from buffer."""
+
+    def test_extract_single_frame_from_buffer(self):
+        """Test extracting a complete JPEG frame from buffer."""
+        # JPEG markers
+        jpeg_start = b"\xff\xd8"
+        jpeg_end = b"\xff\xd9"
+
+        # Create a buffer with one complete frame
+        frame_content = b"\x00" * 100
+        buffer = jpeg_start + frame_content + jpeg_end
+
+        # Find frame boundaries
+        start_idx = buffer.find(jpeg_start)
+        end_idx = buffer.find(jpeg_end, start_idx + 2)
+
+        assert start_idx == 0
+        assert end_idx == 102
+
+        # Extract frame
+        frame = buffer[start_idx : end_idx + 2]
+        assert frame == buffer
+        assert len(frame) == 104
+
+    def test_extract_frame_with_leading_garbage(self):
+        """Test extracting frame when buffer has leading garbage data."""
+        jpeg_start = b"\xff\xd8"
+        jpeg_end = b"\xff\xd9"
+
+        # Buffer with garbage before the JPEG
+        garbage = b"\x00\x01\x02\x03"
+        frame_content = b"\xff" * 50
+        buffer = garbage + jpeg_start + frame_content + jpeg_end
+
+        start_idx = buffer.find(jpeg_start)
+        assert start_idx == 4  # After garbage
+
+        end_idx = buffer.find(jpeg_end, start_idx + 2)
+        frame = buffer[start_idx : end_idx + 2]
+
+        assert frame.startswith(jpeg_start)
+        assert frame.endswith(jpeg_end)
+        assert len(frame) == 54  # 2 + 50 + 2
+
+    def test_incomplete_frame_detection(self):
+        """Test detection of incomplete frame (no end marker)."""
+        jpeg_start = b"\xff\xd8"
+
+        # Incomplete buffer - no end marker
+        buffer = jpeg_start + b"\x00" * 100
+
+        start_idx = buffer.find(jpeg_start)
+        end_idx = buffer.find(b"\xff\xd9", start_idx + 2)
+
+        assert start_idx == 0
+        assert end_idx == -1  # Not found
+
+    def test_multiple_frames_in_buffer(self):
+        """Test extracting first frame when buffer contains multiple frames."""
+        jpeg_start = b"\xff\xd8"
+        jpeg_end = b"\xff\xd9"
+
+        # Two complete frames
+        frame1 = jpeg_start + b"\x01" * 10 + jpeg_end
+        frame2 = jpeg_start + b"\x02" * 20 + jpeg_end
+        buffer = frame1 + frame2
+
+        # Extract first frame
+        start_idx = buffer.find(jpeg_start)
+        end_idx = buffer.find(jpeg_end, start_idx + 2)
+        first_frame = buffer[start_idx : end_idx + 2]
+
+        assert first_frame == frame1
+        assert len(first_frame) == 14
+
+        # Remaining buffer should contain second frame
+        remaining = buffer[end_idx + 2 :]
+        assert remaining == frame2
+
+
+class TestCameraTypeValidation:
+    """Tests for camera type handling."""
+
+    @pytest.mark.asyncio
+    async def test_capture_frame_unknown_type_returns_none(self):
+        """Verify unknown camera type returns None."""
+        from backend.app.services.external_camera import capture_frame
+
+        result = await capture_frame("http://example.com", "unknown_type")
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_capture_frame_valid_types(self):
+        """Verify valid camera types are accepted (they may fail but shouldn't error on type)."""
+        from backend.app.services.external_camera import capture_frame
+
+        # These will fail to connect but shouldn't raise type errors
+        for camera_type in ["mjpeg", "rtsp", "snapshot"]:
+            # Use a non-routable IP to fail fast
+            result = await capture_frame("http://192.0.2.1/test", camera_type, timeout=1)
+            # Should return None (failed connection) not raise exception
+            assert result is None
+
+
+class TestRtspUrlHandling:
+    """Tests for RTSP/RTSPS URL handling."""
+
+    def test_rtsps_url_detection(self):
+        """Verify rtsps:// and rtsp:// URL schemes are distinct."""
+        url_rtsps = "rtsps://user:pass@192.168.1.1:554/stream"
+        url_rtsp = "rtsp://user:pass@192.168.1.1:554/stream"
+
+        assert url_rtsps.startswith("rtsps://")
+        assert not url_rtsp.startswith("rtsps://")
+        assert url_rtsp.startswith("rtsp://")
+
+    def test_ffmpeg_handles_both_rtsp_and_rtsps(self):
+        """Verify ffmpeg command structure handles both URL schemes identically.
+
+        ffmpeg automatically handles TLS for rtsps:// URLs, so no special
+        flags are needed - both URL schemes use the same command structure.
+        """
+        # Both URL types should use the same basic ffmpeg options
+        base_cmd = [
+            "ffmpeg",
+            "-rtsp_transport",
+            "tcp",
+            "-i",
+        ]
+
+        rtsp_url = "rtsp://user:pass@192.168.1.1:554/stream"
+        rtsps_url = "rtsps://user:pass@192.168.1.1:554/stream"
+
+        # Command structure is identical for both
+        cmd_rtsp = base_cmd + [rtsp_url]
+        cmd_rtsps = base_cmd + [rtsps_url]
+
+        # Only the URL differs
+        assert cmd_rtsp[:-1] == cmd_rtsps[:-1]
+        assert cmd_rtsp[-1] != cmd_rtsps[-1]
+
+
+class TestUsbCameraHandling:
+    """Tests for USB camera support."""
+
+    def test_list_usb_cameras_returns_list(self):
+        """Verify list_usb_cameras returns a list (may be empty if no cameras)."""
+        from backend.app.services.external_camera import list_usb_cameras
+
+        result = list_usb_cameras()
+        assert isinstance(result, list)
+
+    def test_list_usb_cameras_dict_structure(self):
+        """Verify each camera entry has expected fields."""
+        from backend.app.services.external_camera import list_usb_cameras
+
+        result = list_usb_cameras()
+        for camera in result:
+            assert "device" in camera
+            assert "name" in camera
+            assert camera["device"].startswith("/dev/video")
+
+    @pytest.mark.asyncio
+    async def test_capture_frame_usb_type_accepted(self):
+        """Verify 'usb' camera type is accepted."""
+        from backend.app.services.external_camera import capture_frame
+
+        # Non-existent device should fail gracefully
+        result = await capture_frame("/dev/video999", "usb", timeout=1)
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_capture_frame_usb_invalid_device_path(self):
+        """Verify invalid USB device paths are rejected."""
+        from backend.app.services.external_camera import capture_frame
+
+        # Invalid device path (not /dev/video*)
+        result = await capture_frame("/dev/sda1", "usb", timeout=1)
+        assert result is None
+
+        result = await capture_frame("http://example.com", "usb", timeout=1)
+        assert result is None

+ 76 - 0
backend/tests/unit/services/test_hms_errors.py

@@ -0,0 +1,76 @@
+"""Tests for HMS error code translations."""
+
+import pytest
+
+from backend.app.services.hms_errors import HMS_ERROR_DESCRIPTIONS, get_error_description
+
+
+class TestHMSErrorDescriptions:
+    """Tests for the HMS error descriptions dictionary."""
+
+    def test_dictionary_is_not_empty(self):
+        """Verify the error descriptions dictionary has entries."""
+        assert len(HMS_ERROR_DESCRIPTIONS) > 0
+
+    def test_dictionary_has_expected_count(self):
+        """Verify we have the expected number of error codes."""
+        # Should have 853 error codes from the frontend
+        assert len(HMS_ERROR_DESCRIPTIONS) == 853
+
+    def test_all_keys_are_valid_format(self):
+        """Verify all keys follow the XXXX_YYYY format."""
+        import re
+
+        pattern = re.compile(r"^[0-9A-F]{4}_[0-9A-F]{4}$")
+        for code in HMS_ERROR_DESCRIPTIONS:
+            assert pattern.match(code), f"Invalid error code format: {code}"
+
+    def test_all_values_are_non_empty_strings(self):
+        """Verify all descriptions are non-empty strings."""
+        for code, description in HMS_ERROR_DESCRIPTIONS.items():
+            assert isinstance(description, str), f"Description for {code} is not a string"
+            assert len(description) > 0, f"Description for {code} is empty"
+
+
+class TestGetErrorDescription:
+    """Tests for the get_error_description function."""
+
+    def test_returns_description_for_known_code(self):
+        """Verify known error codes return their descriptions."""
+        # 0300_400C = "The task was canceled."
+        result = get_error_description("0300_400C")
+        assert result == "The task was canceled."
+
+    def test_returns_description_for_ams_error(self):
+        """Verify AMS error codes return their descriptions."""
+        # 0700_8010 = AMS assist motor overloaded
+        result = get_error_description("0700_8010")
+        assert "AMS assist motor" in result
+
+    def test_returns_none_for_unknown_code(self):
+        """Verify unknown error codes return None."""
+        result = get_error_description("XXXX_YYYY")
+        assert result is None
+
+    def test_handles_lowercase_input(self):
+        """Verify function handles lowercase input."""
+        result = get_error_description("0300_400c")
+        assert result == "The task was canceled."
+
+    def test_handles_mixed_case_input(self):
+        """Verify function handles mixed case input."""
+        result = get_error_description("0300_400C")
+        assert result == "The task was canceled."
+
+    def test_common_error_codes_have_descriptions(self):
+        """Verify common error codes have descriptions."""
+        common_codes = [
+            "0300_4000",  # Z axis homing failed
+            "0300_4006",  # Nozzle clogged
+            "0300_8004",  # Filament ran out
+            "0500_4001",  # Failed to connect to Bambu Cloud
+            "0700_8010",  # AMS assist motor overloaded
+        ]
+        for code in common_codes:
+            result = get_error_description(code)
+            assert result is not None, f"Missing description for common code: {code}"

+ 320 - 0
backend/tests/unit/services/test_layer_timelapse.py

@@ -0,0 +1,320 @@
+"""
+Tests for the layer timelapse service.
+
+These tests cover session management and pure logic functions.
+"""
+
+from datetime import datetime
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestTimelapseSessionManagement:
+    """Tests for timelapse session lifecycle."""
+
+    def test_start_session_creates_new_session(self):
+        """Verify start_session creates and registers a new session."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cancel_session,
+            get_session,
+            start_session,
+        )
+
+        # Clear any existing sessions
+        _active_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+
+            session = start_session(
+                printer_id=1,
+                archive_id=100,
+                url="http://camera.local/mjpeg",
+                cam_type="mjpeg",
+            )
+
+            assert session is not None
+            assert session.printer_id == 1
+            assert session.archive_id == 100
+            assert session.camera_url == "http://camera.local/mjpeg"
+            assert session.camera_type == "mjpeg"
+            assert session.last_layer == -1
+            assert session.frame_count == 0
+
+            # Session should be retrievable
+            retrieved = get_session(1)
+            assert retrieved is session
+
+            # Cleanup
+            cancel_session(1)
+
+    def test_start_session_cancels_existing(self):
+        """Verify starting a new session cancels any existing session."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cancel_session,
+            get_session,
+            start_session,
+        )
+
+        _active_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+
+            # Start first session
+            session1 = start_session(1, 100, "http://cam1/", "mjpeg")
+
+            # Mock cleanup to track if it was called
+            session1.cleanup = MagicMock()
+
+            # Start second session for same printer
+            session2 = start_session(1, 101, "http://cam2/", "rtsp")
+
+            # First session should be replaced
+            current = get_session(1)
+            assert current is session2
+            assert current.archive_id == 101  # Verify it's the new session
+            assert current.camera_url == "http://cam2/"
+
+            # First session's cleanup should have been called
+            session1.cleanup.assert_called_once()
+
+            # Cleanup
+            cancel_session(1)
+
+    def test_get_session_returns_none_for_unknown(self):
+        """Verify get_session returns None for unknown printer."""
+        from backend.app.services.layer_timelapse import _active_sessions, get_session
+
+        _active_sessions.clear()
+
+        result = get_session(999)
+        assert result is None
+
+    def test_cancel_session_removes_and_cleans_up(self):
+        """Verify cancel_session removes session and cleans up."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cancel_session,
+            get_session,
+            start_session,
+        )
+
+        _active_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+
+            session = start_session(1, 100, "http://cam/", "mjpeg")
+
+            # Mock cleanup to avoid filesystem operations
+            session.cleanup = MagicMock()
+
+            cancel_session(1)
+
+            # Session should be removed
+            assert get_session(1) is None
+            # Cleanup should have been called
+            session.cleanup.assert_called_once()
+
+    def test_cancel_nonexistent_session_is_safe(self):
+        """Verify canceling a non-existent session doesn't error."""
+        from backend.app.services.layer_timelapse import _active_sessions, cancel_session
+
+        _active_sessions.clear()
+
+        # Should not raise
+        cancel_session(999)
+
+
+class TestTimelapseSession:
+    """Tests for TimelapseSession class."""
+
+    def test_session_id_format(self):
+        """Verify session ID follows expected datetime format."""
+        from backend.app.services.layer_timelapse import TimelapseSession, _active_sessions
+
+        _active_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test_bambuddy")
+
+            session = TimelapseSession(
+                printer_id=1,
+                archive_id=100,
+                camera_url="http://test/",
+                camera_type="mjpeg",
+            )
+
+            # Session ID should be timestamp format YYYYMMDD_HHMMSS
+            assert len(session.session_id) == 15
+            assert session.session_id[8] == "_"
+
+            # Should be parseable as datetime
+            try:
+                datetime.strptime(session.session_id, "%Y%m%d_%H%M%S")
+            except ValueError:
+                pytest.fail("Session ID is not valid datetime format")
+
+    def test_frames_dir_path_structure(self):
+        """Verify frames directory path is structured correctly."""
+        from backend.app.services.layer_timelapse import TimelapseSession
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/data/bambuddy")
+
+            with patch.object(Path, "mkdir"):  # Avoid creating real directories
+                session = TimelapseSession(
+                    printer_id=42,
+                    archive_id=100,
+                    camera_url="http://test/",
+                    camera_type="mjpeg",
+                )
+
+                expected_path = Path("/data/bambuddy/timelapse_frames/42") / session.session_id
+                assert session.frames_dir == expected_path
+
+
+class TestLayerChangeLogic:
+    """Tests for layer change capture logic."""
+
+    @pytest.mark.asyncio
+    async def test_capture_layer_only_on_increase(self):
+        """Verify frames are only captured when layer increases."""
+        from backend.app.services.layer_timelapse import TimelapseSession
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test")
+
+            with patch.object(Path, "mkdir"):
+                session = TimelapseSession(1, 100, "http://test/", "mjpeg")
+
+                # Mock capture_frame to return data
+                with patch(
+                    "backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
+                ) as mock_capture:
+                    mock_capture.return_value = b"\xff\xd8test\xff\xd9"
+
+                    with patch.object(Path, "write_bytes"):
+                        # First layer should capture
+                        result = await session.capture_layer(1)
+                        assert result is True
+                        assert session.last_layer == 1
+                        assert session.frame_count == 1
+
+                        # Same layer should NOT capture
+                        result = await session.capture_layer(1)
+                        assert result is False
+                        assert session.frame_count == 1
+
+                        # Lower layer should NOT capture
+                        result = await session.capture_layer(0)
+                        assert result is False
+                        assert session.frame_count == 1
+
+                        # Higher layer should capture
+                        result = await session.capture_layer(5)
+                        assert result is True
+                        assert session.last_layer == 5
+                        assert session.frame_count == 2
+
+    @pytest.mark.asyncio
+    async def test_capture_layer_handles_failed_capture(self):
+        """Verify failed capture returns False but updates layer."""
+        from backend.app.services.layer_timelapse import TimelapseSession
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test")
+
+            with patch.object(Path, "mkdir"):
+                session = TimelapseSession(1, 100, "http://test/", "mjpeg")
+
+                # Mock capture_frame to return None (failure)
+                with patch(
+                    "backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
+                ) as mock_capture:
+                    mock_capture.return_value = None
+
+                    result = await session.capture_layer(1)
+
+                    assert result is False
+                    assert session.last_layer == 1  # Layer is still updated
+                    assert session.frame_count == 0  # But frame count not incremented
+
+
+class TestOnLayerChange:
+    """Tests for the on_layer_change callback."""
+
+    @pytest.mark.asyncio
+    async def test_on_layer_change_captures_when_session_exists(self):
+        """Verify on_layer_change triggers capture when session exists."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cancel_session,
+            on_layer_change,
+            start_session,
+        )
+
+        _active_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test")
+
+            with patch.object(Path, "mkdir"):
+                session = start_session(1, 100, "http://test/", "mjpeg")
+
+                with patch.object(session, "capture_layer", new_callable=AsyncMock) as mock_capture:
+                    mock_capture.return_value = True
+
+                    await on_layer_change(1, 5)
+
+                    mock_capture.assert_called_once_with(5)
+
+                cancel_session(1)
+
+    @pytest.mark.asyncio
+    async def test_on_layer_change_does_nothing_without_session(self):
+        """Verify on_layer_change is safe when no session exists."""
+        from backend.app.services.layer_timelapse import _active_sessions, on_layer_change
+
+        _active_sessions.clear()
+
+        # Should not raise
+        await on_layer_change(999, 10)
+
+
+class TestGetActiveSessions:
+    """Tests for get_active_sessions."""
+
+    def test_get_active_sessions_returns_copy(self):
+        """Verify get_active_sessions returns a copy, not the original dict."""
+        from backend.app.services.layer_timelapse import (
+            _active_sessions,
+            cancel_session,
+            get_active_sessions,
+            start_session,
+        )
+
+        _active_sessions.clear()
+
+        with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
+            mock_settings.base_dir = Path("/tmp/test")
+
+            with patch.object(Path, "mkdir"):
+                start_session(1, 100, "http://test/", "mjpeg")
+
+                sessions = get_active_sessions()
+
+                # Should be a copy
+                assert sessions is not _active_sessions
+                assert 1 in sessions
+
+                # Modifying copy shouldn't affect original
+                sessions.clear()
+                assert 1 in _active_sessions
+
+                cancel_session(1)

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

@@ -965,3 +965,230 @@ class TestNotificationTemplates:
             assert "Test" in result
         except KeyError:
             pytest.fail("Template should handle missing variables gracefully")
+
+
+class TestPrinterErrorNotifications:
+    """Tests for HMS error (printer error) notifications."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        """Create a mock notification provider with error notifications enabled."""
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_printer_error = True  # Enable error notifications
+        provider.quiet_hours_enabled = False
+        provider.daily_digest_enabled = False
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        """Create a mock database session."""
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    @pytest.mark.asyncio
+    async def test_on_printer_error_sends_notification(self, service, mock_provider, mock_db):
+        """Verify HMS error notification is sent when triggered."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Printer Error", "AMS/Filament Error: 0700_8010")
+
+            await service.on_printer_error(
+                printer_id=1,
+                printer_name="Test Printer",
+                error_type="AMS/Filament Error",
+                db=mock_db,
+                error_detail="Error code: 0700_8010",
+            )
+
+            mock_get.assert_called_once()
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_on_printer_error_skipped_when_disabled(self, service, mock_provider, mock_db):
+        """CRITICAL: Verify error notifications respect toggle setting."""
+        mock_provider.on_printer_error = False
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
+            # Provider with toggle disabled won't be returned
+            mock_get.return_value = []
+
+            await service.on_printer_error(
+                printer_id=1,
+                printer_name="Test",
+                error_type="AMS Error",
+                db=mock_db,
+                error_detail="Test error",
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_printer_error_includes_error_detail(self, service, mock_provider, mock_db):
+        """Verify error details are passed to template variables."""
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_printer_error(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                error_type="AMS/Filament Error",
+                db=mock_db,
+                error_detail="Error code: 0700_8010",
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["error_type"] == "AMS/Filament Error"
+            assert captured_variables["error_detail"] == "Error code: 0700_8010"
+
+    @pytest.mark.asyncio
+    async def test_on_printer_error_fallback_when_no_detail(self, service, mock_provider, mock_db):
+        """Verify fallback message when error_detail is None."""
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_printer_error(
+                printer_id=1,
+                printer_name="Test Printer",
+                error_type="Unknown Error",
+                db=mock_db,
+                error_detail=None,  # No detail provided
+            )
+
+            assert captured_variables["error_detail"] == "No details available"
+
+
+class TestPlateNotEmptyNotifications:
+    """Tests for plate not empty (build plate detection) notifications."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        """Create a mock notification provider with plate detection enabled."""
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_plate_not_empty = True
+        provider.quiet_hours_enabled = False
+        provider.daily_digest_enabled = False
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        """Create a mock database session."""
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    @pytest.mark.asyncio
+    async def test_on_plate_not_empty_sends_notification(self, service, mock_provider, mock_db):
+        """Verify plate not empty notification is sent when triggered."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Plate Not Empty", "Objects detected on build plate")
+
+            await service.on_plate_not_empty(
+                printer_id=1,
+                printer_name="Test Printer",
+                db=mock_db,
+                difference_percent=5.2,
+            )
+
+            mock_get.assert_called_once()
+            mock_send.assert_called_once()
+            # Verify force_immediate is True (critical alert)
+            call_kwargs = mock_send.call_args[1]
+            assert call_kwargs.get("force_immediate") is True
+
+    @pytest.mark.asyncio
+    async def test_on_plate_not_empty_skipped_when_disabled(self, service, mock_provider, mock_db):
+        """Verify notification is skipped when toggle is disabled."""
+        mock_provider.on_plate_not_empty = False
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
+            mock_get.return_value = []
+
+            await service.on_plate_not_empty(
+                printer_id=1,
+                printer_name="Test",
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_plate_not_empty_includes_difference_percent(self, service, mock_provider, mock_db):
+        """Verify difference percentage is passed to template variables."""
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_plate_not_empty(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                db=mock_db,
+                difference_percent=3.5,
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["difference_percent"] == "3.5"

+ 185 - 0
backend/tests/unit/services/test_plate_detection.py

@@ -0,0 +1,185 @@
+"""Unit tests for plate detection service."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# Mock cv2 and numpy before importing the module
+cv2_mock = MagicMock()
+np_mock = MagicMock()
+
+
+class TestPlateDetectionResult:
+    """Tests for PlateDetectionResult class."""
+
+    def test_result_to_dict(self):
+        """Verify PlateDetectionResult.to_dict() returns correct structure."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            from backend.app.services.plate_detection import PlateDetectionResult
+
+            result = PlateDetectionResult(
+                is_empty=True,
+                confidence=0.95,
+                difference_percent=0.5,
+                message="Test message",
+                debug_image=None,
+                needs_calibration=False,
+            )
+
+            d = result.to_dict()
+
+            assert d["is_empty"] is True
+            assert d["confidence"] == 0.95
+            assert d["difference_percent"] == 0.5
+            assert d["message"] == "Test message"
+            assert d["has_debug_image"] is False
+            assert d["needs_calibration"] is False
+
+    def test_result_with_debug_image(self):
+        """Verify has_debug_image is True when debug_image is provided."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            from backend.app.services.plate_detection import PlateDetectionResult
+
+            result = PlateDetectionResult(
+                is_empty=False,
+                confidence=0.8,
+                difference_percent=5.0,
+                message="Objects detected",
+                debug_image=b"fake_image_data",
+                needs_calibration=False,
+            )
+
+            d = result.to_dict()
+            assert d["has_debug_image"] is True
+
+    def test_result_needs_calibration(self):
+        """Verify needs_calibration flag is preserved."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            from backend.app.services.plate_detection import PlateDetectionResult
+
+            result = PlateDetectionResult(
+                is_empty=True,
+                confidence=0.0,
+                difference_percent=0.0,
+                message="No calibration",
+                needs_calibration=True,
+            )
+
+            d = result.to_dict()
+            assert d["needs_calibration"] is True
+
+
+class TestPlateDetector:
+    """Tests for PlateDetector class."""
+
+    def test_detector_initialization(self):
+        """Verify PlateDetector initializes with default values."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            # Re-import to get fresh module
+            import importlib
+
+            import backend.app.services.plate_detection as pd_module
+
+            importlib.reload(pd_module)
+
+            # Mock OPENCV_AVAILABLE
+            pd_module.OPENCV_AVAILABLE = True
+
+            detector = pd_module.PlateDetector()
+            assert detector.roi == (0.15, 0.35, 0.70, 0.55)
+            assert detector.difference_threshold == 1.0
+
+    def test_detector_custom_roi(self):
+        """Verify PlateDetector accepts custom ROI."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            import importlib
+
+            import backend.app.services.plate_detection as pd_module
+
+            importlib.reload(pd_module)
+
+            pd_module.OPENCV_AVAILABLE = True
+
+            custom_roi = (0.1, 0.2, 0.8, 0.6)
+            detector = pd_module.PlateDetector(roi=custom_roi)
+            assert detector.roi == custom_roi
+
+    def test_detector_raises_without_opencv(self):
+        """Verify PlateDetector raises when OpenCV not available."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            import importlib
+
+            import backend.app.services.plate_detection as pd_module
+
+            importlib.reload(pd_module)
+
+            pd_module.OPENCV_AVAILABLE = False
+
+            with pytest.raises(RuntimeError, match="OpenCV is not installed"):
+                pd_module.PlateDetector()
+
+
+class TestCalibrationStatus:
+    """Tests for calibration status functions."""
+
+    def test_get_calibration_status_no_opencv(self):
+        """Verify calibration status when OpenCV not available."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            import importlib
+
+            import backend.app.services.plate_detection as pd_module
+
+            importlib.reload(pd_module)
+
+            pd_module.OPENCV_AVAILABLE = False
+
+            status = pd_module.get_calibration_status(1)
+
+            assert status["available"] is False
+            assert status["calibrated"] is False
+            assert status["reference_count"] == 0
+            assert "OpenCV not available" in status["message"]
+
+    def test_is_plate_detection_available_true(self):
+        """Verify is_plate_detection_available returns True when OpenCV available."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            import importlib
+
+            import backend.app.services.plate_detection as pd_module
+
+            importlib.reload(pd_module)
+
+            pd_module.OPENCV_AVAILABLE = True
+            assert pd_module.is_plate_detection_available() is True
+
+    def test_is_plate_detection_available_false(self):
+        """Verify is_plate_detection_available returns False when OpenCV not available."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            import importlib
+
+            import backend.app.services.plate_detection as pd_module
+
+            importlib.reload(pd_module)
+
+            pd_module.OPENCV_AVAILABLE = False
+            assert pd_module.is_plate_detection_available() is False
+
+
+class TestDeleteCalibration:
+    """Tests for delete_calibration function."""
+
+    def test_delete_calibration_no_opencv(self):
+        """Verify delete_calibration returns False when OpenCV not available."""
+        with patch.dict("sys.modules", {"cv2": cv2_mock, "numpy": np_mock}):
+            import importlib
+
+            import backend.app.services.plate_detection as pd_module
+
+            importlib.reload(pd_module)
+
+            pd_module.OPENCV_AVAILABLE = False
+
+            result = pd_module.delete_calibration(1)
+            assert result is False

+ 68 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -12,6 +12,7 @@ import pytest
 from backend.app.services.printer_manager import (
     PrinterManager,
     get_derived_status_name,
+    has_stg_cur_idle_bug,
     init_printer_connections,
     printer_state_to_dict,
     supports_chamber_temp,
@@ -901,7 +902,7 @@ class TestGetDerivedStatusName:
         assert result == "Auto bed leveling"
 
     def test_stg_cur_zero_returns_printing(self):
-        """Verify stg_cur=0 returns 'Printing'."""
+        """Verify stg_cur=0 returns 'Printing' when no model specified."""
         state = MagicMock()
         state.stg_cur = 0
 
@@ -909,6 +910,72 @@ class TestGetDerivedStatusName:
 
         assert result == "Printing"
 
+    def test_a1_idle_with_stg_cur_zero_returns_none(self):
+        """Verify A1 with IDLE state and stg_cur=0 returns None (bug workaround)."""
+        state = MagicMock()
+        state.stg_cur = 0
+        state.state = "IDLE"
+
+        # Test various A1 model names
+        for model in ["A1", "A1 Mini", "A1-Mini", "A1MINI", "N1", "N2S"]:
+            result = get_derived_status_name(state, model)
+            assert result is None, f"Expected None for model {model}"
+
+    def test_a1_running_with_stg_cur_zero_returns_printing(self):
+        """Verify A1 with RUNNING state and stg_cur=0 still returns 'Printing'."""
+        state = MagicMock()
+        state.stg_cur = 0
+        state.state = "RUNNING"
+
+        result = get_derived_status_name(state, "A1")
+
+        assert result == "Printing"
+
+    def test_non_a1_idle_with_stg_cur_zero_returns_printing(self):
+        """Verify non-A1 models with IDLE and stg_cur=0 still return 'Printing'."""
+        state = MagicMock()
+        state.stg_cur = 0
+        state.state = "IDLE"
+
+        # X1C should not get the workaround
+        result = get_derived_status_name(state, "X1C")
+
+        assert result == "Printing"
+
+
+class TestHasStgCurIdleBug:
+    """Tests for has_stg_cur_idle_bug function."""
+
+    def test_a1_models_return_true(self):
+        """Verify A1 model variants return True."""
+        assert has_stg_cur_idle_bug("A1") is True
+        assert has_stg_cur_idle_bug("A1 Mini") is True
+        assert has_stg_cur_idle_bug("A1-Mini") is True
+        assert has_stg_cur_idle_bug("A1MINI") is True
+        assert has_stg_cur_idle_bug("a1") is True  # case insensitive
+        assert has_stg_cur_idle_bug("a1 mini") is True
+
+    def test_a1_internal_codes_return_true(self):
+        """Verify A1 internal model codes return True."""
+        assert has_stg_cur_idle_bug("N1") is True  # A1 Mini
+        assert has_stg_cur_idle_bug("N2S") is True  # A1
+
+    def test_non_a1_models_return_false(self):
+        """Verify non-A1 models return False."""
+        assert has_stg_cur_idle_bug("X1C") is False
+        assert has_stg_cur_idle_bug("X1") is False
+        assert has_stg_cur_idle_bug("P1P") is False
+        assert has_stg_cur_idle_bug("P1S") is False
+        assert has_stg_cur_idle_bug("H2D") is False
+
+    def test_none_model_returns_false(self):
+        """Verify None model returns False."""
+        assert has_stg_cur_idle_bug(None) is False
+
+    def test_empty_model_returns_false(self):
+        """Verify empty model returns False."""
+        assert has_stg_cur_idle_bug("") is False
+
 
 class TestInitPrinterConnections:
     """Tests for init_printer_connections function."""

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

@@ -1,229 +0,0 @@
-"""Unit tests for Telemetry service.
-
-Tests the anonymous telemetry/stats collection functionality.
-"""
-
-from datetime import datetime, timedelta
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-from backend.app.models.settings import Settings
-from backend.app.services.telemetry import (
-    DEFAULT_TELEMETRY_URL,
-    HEARTBEAT_INTERVAL,
-    _last_heartbeat,
-    get_or_create_installation_id,
-    get_telemetry_url,
-    is_telemetry_enabled,
-    send_heartbeat,
-)
-
-
-class TestTelemetryService:
-    """Tests for telemetry service functions."""
-
-    # ========================================================================
-    # Installation ID Tests
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_get_or_create_installation_id_creates_new(self, db_session):
-        """Verify new installation ID is created when none exists."""
-        installation_id = await get_or_create_installation_id(db_session)
-
-        assert installation_id is not None
-        assert len(installation_id) == 36  # UUID format
-        assert "-" in installation_id
-
-    @pytest.mark.asyncio
-    async def test_get_or_create_installation_id_returns_existing(self, db_session):
-        """Verify existing installation ID is returned."""
-        # Create an existing installation ID
-        existing_id = "test-uuid-1234-5678-abcd"
-        setting = Settings(key="installation_id", value=existing_id)
-        db_session.add(setting)
-        await db_session.commit()
-
-        result = await get_or_create_installation_id(db_session)
-
-        assert result == existing_id
-
-    @pytest.mark.asyncio
-    async def test_get_or_create_installation_id_persists(self, db_session):
-        """Verify created installation ID persists in database."""
-        first_id = await get_or_create_installation_id(db_session)
-        second_id = await get_or_create_installation_id(db_session)
-
-        assert first_id == second_id
-
-    # ========================================================================
-    # Telemetry Enabled Tests
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_is_telemetry_enabled_default_true(self, db_session):
-        """Verify telemetry is enabled by default (opt-out model)."""
-        result = await is_telemetry_enabled(db_session)
-
-        assert result is True
-
-    @pytest.mark.asyncio
-    async def test_is_telemetry_enabled_explicit_true(self, db_session):
-        """Verify telemetry enabled when explicitly set to true."""
-        setting = Settings(key="telemetry_enabled", value="true")
-        db_session.add(setting)
-        await db_session.commit()
-
-        result = await is_telemetry_enabled(db_session)
-
-        assert result is True
-
-    @pytest.mark.asyncio
-    async def test_is_telemetry_enabled_explicit_false(self, db_session):
-        """Verify telemetry disabled when set to false."""
-        setting = Settings(key="telemetry_enabled", value="false")
-        db_session.add(setting)
-        await db_session.commit()
-
-        result = await is_telemetry_enabled(db_session)
-
-        assert result is False
-
-    @pytest.mark.asyncio
-    async def test_is_telemetry_enabled_case_insensitive(self, db_session):
-        """Verify telemetry enabled check is case insensitive."""
-        setting = Settings(key="telemetry_enabled", value="TRUE")
-        db_session.add(setting)
-        await db_session.commit()
-
-        result = await is_telemetry_enabled(db_session)
-
-        assert result is True
-
-    # ========================================================================
-    # Telemetry URL Tests
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_get_telemetry_url_default(self, db_session):
-        """Verify default telemetry URL is returned when not configured."""
-        result = await get_telemetry_url(db_session)
-
-        assert result == DEFAULT_TELEMETRY_URL
-
-    @pytest.mark.asyncio
-    async def test_get_telemetry_url_custom(self, db_session):
-        """Verify custom telemetry URL is returned when configured."""
-        custom_url = "https://custom.telemetry.example.com"
-        setting = Settings(key="telemetry_url", value=custom_url)
-        db_session.add(setting)
-        await db_session.commit()
-
-        result = await get_telemetry_url(db_session)
-
-        assert result == custom_url
-
-    # ========================================================================
-    # Send Heartbeat Tests
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_send_heartbeat_when_disabled(self, db_session):
-        """Verify heartbeat is not sent when telemetry is disabled."""
-        setting = Settings(key="telemetry_enabled", value="false")
-        db_session.add(setting)
-        await db_session.commit()
-
-        with patch("httpx.AsyncClient") as mock_client:
-            result = await send_heartbeat(db_session)
-
-        assert result is False
-        mock_client.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_send_heartbeat_success(self, db_session, mock_httpx_client):
-        """Verify heartbeat is sent successfully when enabled."""
-        # Reset the last heartbeat to allow sending
-        import backend.app.services.telemetry as telemetry_module
-
-        telemetry_module._last_heartbeat = None
-
-        result = await send_heartbeat(db_session)
-
-        assert result is True
-
-    @pytest.mark.asyncio
-    async def test_send_heartbeat_rate_limited(self, db_session):
-        """Verify heartbeat is rate limited to once per day."""
-        import backend.app.services.telemetry as telemetry_module
-
-        # Set last heartbeat to recent time
-        telemetry_module._last_heartbeat = datetime.now()
-
-        with patch("httpx.AsyncClient") as mock_client:
-            result = await send_heartbeat(db_session)
-
-        # Should return True (already sent) without making HTTP request
-        assert result is True
-        mock_client.assert_not_called()
-
-    @pytest.mark.asyncio
-    async def test_send_heartbeat_handles_exceptions(self, db_session):
-        """Verify heartbeat returns False on general exceptions."""
-        import backend.app.services.telemetry as telemetry_module
-
-        telemetry_module._last_heartbeat = None
-
-        # Test that the function handles exceptions gracefully by checking
-        # the code path - the actual telemetry URL may or may not be reachable
-        # The function should not raise exceptions to the caller
-        try:
-            result = await send_heartbeat(db_session)
-            # Result can be True (success) or False (failure) but should not raise
-            assert isinstance(result, bool)
-        except Exception as e:
-            pytest.fail(f"send_heartbeat should not raise exceptions: {e}")
-
-    @pytest.mark.asyncio
-    async def test_send_heartbeat_sends_correct_data(self, db_session):
-        """Verify heartbeat sends correct payload."""
-        import backend.app.services.telemetry as telemetry_module
-        from backend.app.core.config import APP_VERSION
-
-        telemetry_module._last_heartbeat = None
-
-        captured_data = {}
-
-        with patch("httpx.AsyncClient") as mock_class:
-            mock_instance = AsyncMock()
-            mock_response = MagicMock()
-            mock_response.raise_for_status = MagicMock()
-
-            async def capture_post(url, json=None):
-                captured_data["url"] = url
-                captured_data["json"] = json
-                return mock_response
-
-            mock_instance.post = capture_post
-            mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
-            mock_instance.__aexit__ = AsyncMock()
-            mock_class.return_value = mock_instance
-
-            await send_heartbeat(db_session)
-
-        assert "heartbeat" in captured_data["url"]
-        assert "installation_id" in captured_data["json"]
-        assert captured_data["json"]["version"] == APP_VERSION
-
-
-class TestHeartbeatInterval:
-    """Tests for heartbeat interval configuration."""
-
-    def test_heartbeat_interval_is_24_hours(self):
-        """Verify heartbeat interval is set to 24 hours."""
-        assert timedelta(hours=24) == HEARTBEAT_INTERVAL
-
-    def test_default_telemetry_url(self):
-        """Verify default telemetry URL is correct."""
-        assert DEFAULT_TELEMETRY_URL == "https://telemetry.bambuddy.cool"

+ 0 - 83
bambuddy-issue-notes.txt

@@ -1,83 +0,0 @@
-=== BAMBUDDY FILE DELETION ISSUE - Jan 8, 2026 ===
-=== ROOT CAUSE IDENTIFIED ===
-
-WHAT HAPPENED:
-- /opt was COMPLETELY DELETED on TWO containers:
-  - Container 109 (claude): ~11:22 and ~12:22
-  - Container 107 (3dp): ~13:28
-- Container 107 was "untouched" (no SSH, no Claude Code) - just running BamBuddy
-
-ROOT CAUSE FOUND:
-Bug in backend/app/services/archive.py delete_archive() function (lines 914-929):
-
-    file_path = settings.base_dir / archive.file_path
-    if file_path.exists():
-        archive_dir = file_path.parent
-        shutil.rmtree(archive_dir, ignore_errors=True)  # <-- THE BUG
-
-If archive.file_path is EMPTY or MALFORMED:
-- file_path = /opt/bambuddy / "" = /opt/bambuddy
-- archive_dir = file_path.parent = /opt
-- shutil.rmtree("/opt") --> DELETES ENTIRE /opt DIRECTORY!
-
-TRIGGER:
-- User was deleting archives via BamBuddy web UI on container 107 (3dp)
-- One archive had corrupted/empty file_path in database
-- Deleting that archive triggered shutil.rmtree("/opt")
-- This deleted the entire /opt directory including BamBuddy itself
-
-TIMELINE FOR CONTAINER 107 (3dp):
-- 13:28:19 - Normal operation (WebSocket disconnect)
-- 13:28:44 - DELETE /api/v1/archives/* requests failing with 500
-            (database already gone because /opt was deleted)
-- ls -la / shows root directory modified at 13:28
-
-FIX APPLIED (on container 109):
-Safety checks added to delete_archive() in archive.py:
-1. Check if file_path is not empty
-2. Verify archive_dir is inside settings.archive_dir
-3. Ensure archive_dir is at least 2 levels deep
-4. Log error and refuse to delete if checks fail
-
-TO INVESTIGATE AFTER ROLLBACK:
-On container 107, after rolling back to autodaily260108003006:
-
-    # Find corrupted archive records
-    sqlite3 /opt/bambuddy/data/bambuddy.db \
-      "SELECT id, filename, file_path FROM print_archives
-       WHERE file_path = '' OR file_path IS NULL
-       OR file_path NOT LIKE 'archive/%';"
-
-    # Check all file_path values
-    sqlite3 /opt/bambuddy/data/bambuddy.db \
-      "SELECT id, file_path FROM print_archives ORDER BY id;"
-
-CONTAINER 109 (this host):
-- Were you also deleting archives around 11:22 and 12:22?
-- Same bug could have been triggered here too
-
-PROXMOX COMMANDS FOR ROLLBACK:
-    # Container 107 (3dp)
-    pct rollback 107 autodaily260108003006
-    pct start 107
-
-    # Container 109 (claude) - already done via UI
-    # Current snapshot: autodaily260108003004
-
-WHAT TO DO NEXT:
-1. Rollback container 107 to morning snapshot
-2. Run the SQL query above to find corrupted archive
-3. Apply the fix from container 109 to container 107
-4. Understand how the file_path got corrupted in the first place
-
-THE FIX (apply to both containers):
-In backend/app/services/archive.py, the delete_archive function now has:
-- Empty file_path check
-- Path traversal protection (relative_to check)
-- Minimum depth check (must be 2+ levels inside archive dir)
-- Error logging for refused deletions
-
-NOT CLAUDE CODE'S FAULT:
-This was a bug in BamBuddy's own code that was triggered by:
-1. Corrupted database record (unknown how it got corrupted)
-2. User action (deleting archives via web UI)

+ 196 - 1062
frontend/package-lock.json

@@ -44,8 +44,9 @@
         "@types/react": "^19.2.5",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^5.1.1",
-        "@vitest/coverage-v8": "^2.1.0",
+        "@vitest/coverage-v8": "^3.2.4",
         "autoprefixer": "^10.4.22",
+        "baseline-browser-mapping": "^2.9.18",
         "eslint": "^9.39.1",
         "eslint-plugin-react-hooks": "^7.0.1",
         "eslint-plugin-react-refresh": "^0.4.24",
@@ -57,7 +58,7 @@
         "typescript": "~5.9.3",
         "typescript-eslint": "^8.46.4",
         "vite": "^7.2.4",
-        "vitest": "^2.1.0"
+        "vitest": "^3.2.4"
       }
     },
     "node_modules/@adobe/css-tools": {
@@ -403,10 +404,13 @@
       }
     },
     "node_modules/@bcoe/v8-coverage": {
-      "version": "0.2.3",
-      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
-      "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
-      "dev": true
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+      "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+      "dev": true,
+      "engines": {
+        "node": ">=18"
+      }
     },
     "node_modules/@csstools/color-helpers": {
       "version": "5.1.0",
@@ -2847,6 +2851,16 @@
         "@babel/types": "^7.28.2"
       }
     },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
     "node_modules/@types/d3-array": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -2910,6 +2924,12 @@
       "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
       "license": "MIT"
     },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true
+    },
     "node_modules/@types/estree": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3305,30 +3325,31 @@
       }
     },
     "node_modules/@vitest/coverage-v8": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz",
-      "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+      "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.3.0",
-        "@bcoe/v8-coverage": "^0.2.3",
-        "debug": "^4.3.7",
+        "@bcoe/v8-coverage": "^1.0.2",
+        "ast-v8-to-istanbul": "^0.3.3",
+        "debug": "^4.4.1",
         "istanbul-lib-coverage": "^3.2.2",
         "istanbul-lib-report": "^3.0.1",
         "istanbul-lib-source-maps": "^5.0.6",
         "istanbul-reports": "^3.1.7",
-        "magic-string": "^0.30.12",
+        "magic-string": "^0.30.17",
         "magicast": "^0.3.5",
-        "std-env": "^3.8.0",
+        "std-env": "^3.9.0",
         "test-exclude": "^7.0.1",
-        "tinyrainbow": "^1.2.0"
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
-        "@vitest/browser": "2.1.9",
-        "vitest": "2.1.9"
+        "@vitest/browser": "3.2.4",
+        "vitest": "3.2.4"
       },
       "peerDependenciesMeta": {
         "@vitest/browser": {
@@ -3337,80 +3358,108 @@
       }
     },
     "node_modules/@vitest/expect": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
-      "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+      "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+      "dev": true,
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+      "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
       "dev": true,
       "dependencies": {
-        "@vitest/spy": "2.1.9",
-        "@vitest/utils": "2.1.9",
-        "chai": "^5.1.2",
-        "tinyrainbow": "^1.2.0"
+        "@vitest/spy": "3.2.4",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.17"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
       }
     },
     "node_modules/@vitest/pretty-format": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
-      "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+      "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
       "dev": true,
       "dependencies": {
-        "tinyrainbow": "^1.2.0"
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/runner": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
-      "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+      "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
       "dev": true,
       "dependencies": {
-        "@vitest/utils": "2.1.9",
-        "pathe": "^1.1.2"
+        "@vitest/utils": "3.2.4",
+        "pathe": "^2.0.3",
+        "strip-literal": "^3.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/snapshot": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
-      "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+      "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
       "dev": true,
       "dependencies": {
-        "@vitest/pretty-format": "2.1.9",
-        "magic-string": "^0.30.12",
-        "pathe": "^1.1.2"
+        "@vitest/pretty-format": "3.2.4",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/spy": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
-      "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+      "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
       "dev": true,
       "dependencies": {
-        "tinyspy": "^3.0.2"
+        "tinyspy": "^4.0.3"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
-      "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+      "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
       "dev": true,
       "dependencies": {
-        "@vitest/pretty-format": "2.1.9",
-        "loupe": "^3.1.2",
-        "tinyrainbow": "^1.2.0"
+        "@vitest/pretty-format": "3.2.4",
+        "loupe": "^3.1.4",
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
@@ -3520,6 +3569,23 @@
         "node": ">=12"
       }
     },
+    "node_modules/ast-v8-to-istanbul": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+      "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.31",
+        "estree-walker": "^3.0.3",
+        "js-tokens": "^9.0.1"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3572,11 +3638,10 @@
       "license": "MIT"
     },
     "node_modules/baseline-browser-mapping": {
-      "version": "2.8.31",
-      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
-      "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
+      "version": "2.9.18",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
+      "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
       "dev": true,
-      "license": "Apache-2.0",
       "bin": {
         "baseline-browser-mapping": "dist/cli.js"
       }
@@ -3713,9 +3778,9 @@
       }
     },
     "node_modules/check-error": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
-      "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
       "dev": true,
       "engines": {
         "node": ">= 16"
@@ -6288,9 +6353,9 @@
       "dev": true
     },
     "node_modules/pathe": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
-      "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
       "dev": true
     },
     "node_modules/pathval": {
@@ -7161,6 +7226,24 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/strip-literal": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+      "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+      "dev": true,
+      "dependencies": {
+        "js-tokens": "^9.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/strip-literal/node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true
+    },
     "node_modules/supports-color": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -7302,18 +7385,18 @@
       }
     },
     "node_modules/tinyrainbow": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
-      "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
       "dev": true,
       "engines": {
         "node": ">=14.0.0"
       }
     },
     "node_modules/tinyspy": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
-      "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+      "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
       "dev": true,
       "engines": {
         "node": ">=14.0.0"
@@ -7616,533 +7699,72 @@
       }
     },
     "node_modules/vite-node": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
-      "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+      "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
       "dev": true,
       "dependencies": {
         "cac": "^6.7.14",
-        "debug": "^4.3.7",
-        "es-module-lexer": "^1.5.4",
-        "pathe": "^1.1.2",
-        "vite": "^5.0.0"
+        "debug": "^4.4.1",
+        "es-module-lexer": "^1.7.0",
+        "pathe": "^2.0.3",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
       },
       "bin": {
         "vite-node": "vite-node.mjs"
       },
       "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/android-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
-      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/android-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
-      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/android-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
-      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
-      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/darwin-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
-      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
-      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
-      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
-      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
-      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
-      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-loong64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
-      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
-      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
-      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
+    "node_modules/vitest": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+      "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+      "dev": true,
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/expect": "3.2.4",
+        "@vitest/mocker": "3.2.4",
+        "@vitest/pretty-format": "^3.2.4",
+        "@vitest/runner": "3.2.4",
+        "@vitest/snapshot": "3.2.4",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "debug": "^4.4.1",
+        "expect-type": "^1.2.1",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.2",
+        "std-env": "^3.9.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.2",
+        "tinyglobby": "^0.2.14",
+        "tinypool": "^1.1.1",
+        "tinyrainbow": "^2.0.0",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+        "vite-node": "3.2.4",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
       "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-s390x": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
-      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
-      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/sunos-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
-      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/win32-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
-      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/win32-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
-      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/win32-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
-      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/esbuild": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
-      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
-      "dev": true,
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.21.5",
-        "@esbuild/android-arm": "0.21.5",
-        "@esbuild/android-arm64": "0.21.5",
-        "@esbuild/android-x64": "0.21.5",
-        "@esbuild/darwin-arm64": "0.21.5",
-        "@esbuild/darwin-x64": "0.21.5",
-        "@esbuild/freebsd-arm64": "0.21.5",
-        "@esbuild/freebsd-x64": "0.21.5",
-        "@esbuild/linux-arm": "0.21.5",
-        "@esbuild/linux-arm64": "0.21.5",
-        "@esbuild/linux-ia32": "0.21.5",
-        "@esbuild/linux-loong64": "0.21.5",
-        "@esbuild/linux-mips64el": "0.21.5",
-        "@esbuild/linux-ppc64": "0.21.5",
-        "@esbuild/linux-riscv64": "0.21.5",
-        "@esbuild/linux-s390x": "0.21.5",
-        "@esbuild/linux-x64": "0.21.5",
-        "@esbuild/netbsd-x64": "0.21.5",
-        "@esbuild/openbsd-x64": "0.21.5",
-        "@esbuild/sunos-x64": "0.21.5",
-        "@esbuild/win32-arm64": "0.21.5",
-        "@esbuild/win32-ia32": "0.21.5",
-        "@esbuild/win32-x64": "0.21.5"
-      }
-    },
-    "node_modules/vite-node/node_modules/vite": {
-      "version": "5.4.21",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
-      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
-      "dev": true,
-      "dependencies": {
-        "esbuild": "^0.21.3",
-        "postcss": "^8.4.43",
-        "rollup": "^4.20.0"
-      },
-      "bin": {
-        "vite": "bin/vite.js"
-      },
-      "engines": {
-        "node": "^18.0.0 || >=20.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/vitejs/vite?sponsor=1"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      },
-      "peerDependencies": {
-        "@types/node": "^18.0.0 || >=20.0.0",
-        "less": "*",
-        "lightningcss": "^1.21.0",
-        "sass": "*",
-        "sass-embedded": "*",
-        "stylus": "*",
-        "sugarss": "*",
-        "terser": "^5.4.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "less": {
-          "optional": true
-        },
-        "lightningcss": {
-          "optional": true
-        },
-        "sass": {
-          "optional": true
-        },
-        "sass-embedded": {
-          "optional": true
-        },
-        "stylus": {
-          "optional": true
-        },
-        "sugarss": {
-          "optional": true
-        },
-        "terser": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/vitest": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
-      "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
-      "dev": true,
-      "dependencies": {
-        "@vitest/expect": "2.1.9",
-        "@vitest/mocker": "2.1.9",
-        "@vitest/pretty-format": "^2.1.9",
-        "@vitest/runner": "2.1.9",
-        "@vitest/snapshot": "2.1.9",
-        "@vitest/spy": "2.1.9",
-        "@vitest/utils": "2.1.9",
-        "chai": "^5.1.2",
-        "debug": "^4.3.7",
-        "expect-type": "^1.1.0",
-        "magic-string": "^0.30.12",
-        "pathe": "^1.1.2",
-        "std-env": "^3.8.0",
-        "tinybench": "^2.9.0",
-        "tinyexec": "^0.3.1",
-        "tinypool": "^1.0.1",
-        "tinyrainbow": "^1.2.0",
-        "vite": "^5.0.0",
-        "vite-node": "2.1.9",
-        "why-is-node-running": "^2.3.0"
-      },
-      "bin": {
-        "vitest": "vitest.mjs"
-      },
-      "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
         "@edge-runtime/vm": "*",
-        "@types/node": "^18.0.0 || >=20.0.0",
-        "@vitest/browser": "2.1.9",
-        "@vitest/ui": "2.1.9",
+        "@types/debug": "^4.1.12",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "@vitest/browser": "3.2.4",
+        "@vitest/ui": "3.2.4",
         "happy-dom": "*",
         "jsdom": "*"
       },
@@ -8150,6 +7772,9 @@
         "@edge-runtime/vm": {
           "optional": true
         },
+        "@types/debug": {
+          "optional": true
+        },
         "@types/node": {
           "optional": true
         },
@@ -8167,497 +7792,6 @@
         }
       }
     },
-    "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/android-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
-      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/android-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
-      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/android-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
-      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
-      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
-      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
-      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
-      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
-      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
-      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
-      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
-      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
-      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
-      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
-      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
-      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
-      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
-      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
-      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/win32-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
-      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@vitest/mocker": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
-      "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
-      "dev": true,
-      "dependencies": {
-        "@vitest/spy": "2.1.9",
-        "estree-walker": "^3.0.3",
-        "magic-string": "^0.30.12"
-      },
-      "funding": {
-        "url": "https://opencollective.com/vitest"
-      },
-      "peerDependencies": {
-        "msw": "^2.4.9",
-        "vite": "^5.0.0"
-      },
-      "peerDependenciesMeta": {
-        "msw": {
-          "optional": true
-        },
-        "vite": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/vitest/node_modules/esbuild": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
-      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
-      "dev": true,
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.21.5",
-        "@esbuild/android-arm": "0.21.5",
-        "@esbuild/android-arm64": "0.21.5",
-        "@esbuild/android-x64": "0.21.5",
-        "@esbuild/darwin-arm64": "0.21.5",
-        "@esbuild/darwin-x64": "0.21.5",
-        "@esbuild/freebsd-arm64": "0.21.5",
-        "@esbuild/freebsd-x64": "0.21.5",
-        "@esbuild/linux-arm": "0.21.5",
-        "@esbuild/linux-arm64": "0.21.5",
-        "@esbuild/linux-ia32": "0.21.5",
-        "@esbuild/linux-loong64": "0.21.5",
-        "@esbuild/linux-mips64el": "0.21.5",
-        "@esbuild/linux-ppc64": "0.21.5",
-        "@esbuild/linux-riscv64": "0.21.5",
-        "@esbuild/linux-s390x": "0.21.5",
-        "@esbuild/linux-x64": "0.21.5",
-        "@esbuild/netbsd-x64": "0.21.5",
-        "@esbuild/openbsd-x64": "0.21.5",
-        "@esbuild/sunos-x64": "0.21.5",
-        "@esbuild/win32-arm64": "0.21.5",
-        "@esbuild/win32-ia32": "0.21.5",
-        "@esbuild/win32-x64": "0.21.5"
-      }
-    },
-    "node_modules/vitest/node_modules/vite": {
-      "version": "5.4.21",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
-      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
-      "dev": true,
-      "dependencies": {
-        "esbuild": "^0.21.3",
-        "postcss": "^8.4.43",
-        "rollup": "^4.20.0"
-      },
-      "bin": {
-        "vite": "bin/vite.js"
-      },
-      "engines": {
-        "node": "^18.0.0 || >=20.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/vitejs/vite?sponsor=1"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      },
-      "peerDependencies": {
-        "@types/node": "^18.0.0 || >=20.0.0",
-        "less": "*",
-        "lightningcss": "^1.21.0",
-        "sass": "*",
-        "sass-embedded": "*",
-        "stylus": "*",
-        "sugarss": "*",
-        "terser": "^5.4.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "less": {
-          "optional": true
-        },
-        "lightningcss": {
-          "optional": true
-        },
-        "sass": {
-          "optional": true
-        },
-        "sass-embedded": {
-          "optional": true
-        },
-        "stylus": {
-          "optional": true
-        },
-        "sugarss": {
-          "optional": true
-        },
-        "terser": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/void-elements": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",

+ 3 - 2
frontend/package.json

@@ -50,8 +50,9 @@
     "@types/react": "^19.2.5",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.1.1",
-    "@vitest/coverage-v8": "^2.1.0",
+    "@vitest/coverage-v8": "^3.2.4",
     "autoprefixer": "^10.4.22",
+    "baseline-browser-mapping": "^2.9.18",
     "eslint": "^9.39.1",
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-refresh": "^0.4.24",
@@ -63,6 +64,6 @@
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
     "vite": "^7.2.4",
-    "vitest": "^2.1.0"
+    "vitest": "^3.2.4"
   }
 }

+ 283 - 0
frontend/src/__tests__/api/githubBackupApi.test.ts

@@ -0,0 +1,283 @@
+/**
+ * Tests for the GitHub Backup API client functions.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import type {
+  GitHubBackupConfig,
+  GitHubBackupStatus,
+  GitHubBackupLog,
+} from '../../api/client';
+
+// Mock API base URL
+const API_BASE = 'http://localhost:5000/api/v1';
+
+// Create MSW server
+const server = setupServer();
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+describe('GitHub Backup API Types', () => {
+  it('GitHubBackupConfig has correct shape', () => {
+    const config: GitHubBackupConfig = {
+      id: 1,
+      repository_url: 'https://github.com/test/repo',
+      has_token: true,
+      branch: 'main',
+      schedule_enabled: true,
+      schedule_type: 'daily',
+      backup_kprofiles: true,
+      backup_cloud_profiles: true,
+      backup_settings: false,
+      enabled: true,
+      last_backup_at: '2026-01-27T10:00:00Z',
+      last_backup_status: 'success',
+      last_backup_message: null,
+      last_backup_commit_sha: 'abc123',
+      next_scheduled_run: '2026-01-28T00:00:00Z',
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-27T10:00:00Z',
+    };
+
+    expect(config.id).toBe(1);
+    expect(config.has_token).toBe(true);
+    expect(config.schedule_type).toBe('daily');
+  });
+
+  it('GitHubBackupStatus has correct shape', () => {
+    const status: GitHubBackupStatus = {
+      configured: true,
+      enabled: true,
+      is_running: false,
+      progress: null,
+      last_backup_at: '2026-01-27T10:00:00Z',
+      last_backup_status: 'success',
+      next_scheduled_run: '2026-01-28T00:00:00Z',
+    };
+
+    expect(status.configured).toBe(true);
+    expect(status.is_running).toBe(false);
+  });
+
+  it('GitHubBackupStatus can have progress', () => {
+    const status: GitHubBackupStatus = {
+      configured: true,
+      enabled: true,
+      is_running: true,
+      progress: 'Pushing to GitHub...',
+      last_backup_at: null,
+      last_backup_status: null,
+      next_scheduled_run: null,
+    };
+
+    expect(status.is_running).toBe(true);
+    expect(status.progress).toBe('Pushing to GitHub...');
+  });
+
+  it('GitHubBackupLog has correct shape', () => {
+    const log: GitHubBackupLog = {
+      id: 1,
+      config_id: 1,
+      started_at: '2026-01-27T10:00:00Z',
+      completed_at: '2026-01-27T10:01:00Z',
+      status: 'success',
+      trigger: 'manual',
+      commit_sha: 'abc123',
+      files_changed: 5,
+      error_message: null,
+    };
+
+    expect(log.status).toBe('success');
+    expect(log.trigger).toBe('manual');
+    expect(log.files_changed).toBe(5);
+  });
+
+  it('GitHubBackupLog can have error', () => {
+    const log: GitHubBackupLog = {
+      id: 2,
+      config_id: 1,
+      started_at: '2026-01-27T10:00:00Z',
+      completed_at: '2026-01-27T10:00:30Z',
+      status: 'failed',
+      trigger: 'scheduled',
+      commit_sha: null,
+      files_changed: 0,
+      error_message: 'Authentication failed',
+    };
+
+    expect(log.status).toBe('failed');
+    expect(log.error_message).toBe('Authentication failed');
+    expect(log.commit_sha).toBeNull();
+  });
+});
+
+describe('GitHub Backup API Endpoints', () => {
+  it('GET /github-backup/config returns null when not configured', async () => {
+    server.use(
+      http.get(`${API_BASE}/github-backup/config`, () => {
+        return HttpResponse.json(null);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/config`);
+    const data = await response.json();
+    expect(data).toBeNull();
+  });
+
+  it('GET /github-backup/config returns config when exists', async () => {
+    const mockConfig: GitHubBackupConfig = {
+      id: 1,
+      repository_url: 'https://github.com/test/repo',
+      has_token: true,
+      branch: 'main',
+      schedule_enabled: false,
+      schedule_type: 'daily',
+      backup_kprofiles: true,
+      backup_cloud_profiles: true,
+      backup_settings: false,
+      enabled: true,
+      last_backup_at: null,
+      last_backup_status: null,
+      last_backup_message: null,
+      last_backup_commit_sha: null,
+      next_scheduled_run: null,
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    };
+
+    server.use(
+      http.get(`${API_BASE}/github-backup/config`, () => {
+        return HttpResponse.json(mockConfig);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/config`);
+    const data = await response.json();
+    expect(data.repository_url).toBe('https://github.com/test/repo');
+    expect(data.has_token).toBe(true);
+  });
+
+  it('GET /github-backup/status returns not configured status', async () => {
+    const mockStatus: GitHubBackupStatus = {
+      configured: false,
+      enabled: false,
+      is_running: false,
+      progress: null,
+      last_backup_at: null,
+      last_backup_status: null,
+      next_scheduled_run: null,
+    };
+
+    server.use(
+      http.get(`${API_BASE}/github-backup/status`, () => {
+        return HttpResponse.json(mockStatus);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/status`);
+    const data = await response.json();
+    expect(data.configured).toBe(false);
+    expect(data.enabled).toBe(false);
+  });
+
+  it('GET /github-backup/logs returns empty list when no logs', async () => {
+    server.use(
+      http.get(`${API_BASE}/github-backup/logs`, () => {
+        return HttpResponse.json([]);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/logs`);
+    const data = await response.json();
+    expect(data).toEqual([]);
+  });
+
+  it('GET /github-backup/logs returns log entries', async () => {
+    const mockLogs: GitHubBackupLog[] = [
+      {
+        id: 1,
+        config_id: 1,
+        started_at: '2026-01-27T10:00:00Z',
+        completed_at: '2026-01-27T10:01:00Z',
+        status: 'success',
+        trigger: 'manual',
+        commit_sha: 'abc123',
+        files_changed: 5,
+        error_message: null,
+      },
+    ];
+
+    server.use(
+      http.get(`${API_BASE}/github-backup/logs`, () => {
+        return HttpResponse.json(mockLogs);
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/logs`);
+    const data = await response.json();
+    expect(data.length).toBe(1);
+    expect(data[0].status).toBe('success');
+  });
+
+  it('POST /github-backup/run returns 404 when not configured', async () => {
+    server.use(
+      http.post(`${API_BASE}/github-backup/run`, () => {
+        return HttpResponse.json(
+          { detail: 'No configuration found' },
+          { status: 404 }
+        );
+      })
+    );
+
+    const response = await fetch(`${API_BASE}/github-backup/run`, {
+      method: 'POST',
+    });
+    expect(response.status).toBe(404);
+  });
+
+  it('POST /github-backup/test returns success on valid credentials', async () => {
+    server.use(
+      http.post(`${API_BASE}/github-backup/test`, () => {
+        return HttpResponse.json({
+          success: true,
+          message: 'Connection successful',
+          repo_name: 'test/repo',
+          default_branch: 'main',
+        });
+      })
+    );
+
+    const response = await fetch(
+      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=ghp_test`,
+      { method: 'POST' }
+    );
+    const data = await response.json();
+    expect(data.success).toBe(true);
+    expect(data.repo_name).toBe('test/repo');
+  });
+
+  it('POST /github-backup/test returns failure on invalid credentials', async () => {
+    server.use(
+      http.post(`${API_BASE}/github-backup/test`, () => {
+        return HttpResponse.json({
+          success: false,
+          message: 'Authentication failed',
+          repo_name: null,
+          default_branch: null,
+        });
+      })
+    );
+
+    const response = await fetch(
+      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=invalid`,
+      { method: 'POST' }
+    );
+    const data = await response.json();
+    expect(data.success).toBe(false);
+    expect(data.message).toBe('Authentication failed');
+  });
+});

+ 1 - 1
frontend/src/__tests__/components/BackupModal.test.tsx

@@ -59,7 +59,7 @@ describe('BackupModal', () => {
     it('has checkbox for printers', () => {
       render(<BackupModal onClose={mockOnClose} />);
 
-      expect(screen.getByText(/printers/i)).toBeInTheDocument();
+      expect(screen.getByText('Printers')).toBeInTheDocument();
     });
 
     it('has checkbox for archives', () => {

+ 256 - 14
frontend/src/__tests__/components/Dashboard.test.tsx

@@ -1,24 +1,266 @@
 /**
  * Tests for the Dashboard component.
- * Note: Dashboard component may be named differently or have different structure.
- * These tests verify basic rendering if the component exists.
+ * Tests drag-and-drop widget management, visibility toggles, and layout persistence.
  */
 
-import { describe, it, expect, beforeEach } from 'vitest';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { Dashboard, type DashboardWidget } from '../../components/Dashboard';
 
-// Skip these tests as Dashboard component structure may differ
-describe.skip('Dashboard', () => {
+const mockWidgets: DashboardWidget[] = [
+  {
+    id: 'widget-1',
+    title: 'Widget One',
+    component: <div>Widget One Content</div>,
+    defaultVisible: true,
+    defaultSize: 2,
+  },
+  {
+    id: 'widget-2',
+    title: 'Widget Two',
+    component: <div>Widget Two Content</div>,
+    defaultVisible: true,
+    defaultSize: 4,
+  },
+  {
+    id: 'widget-3',
+    title: 'Widget Three',
+    component: <div>Widget Three Content</div>,
+    defaultVisible: false, // Hidden by default
+    defaultSize: 1,
+  },
+];
+
+// Create a working localStorage mock for these tests
+const localStorageData: Record<string, string> = {};
+const localStorageMock = {
+  getItem: vi.fn((key: string) => localStorageData[key] || null),
+  setItem: vi.fn((key: string, value: string) => {
+    localStorageData[key] = value;
+  }),
+  removeItem: vi.fn((key: string) => {
+    delete localStorageData[key];
+  }),
+  clear: vi.fn(() => {
+    Object.keys(localStorageData).forEach((key) => delete localStorageData[key]);
+  }),
+};
+
+describe('Dashboard', () => {
   beforeEach(() => {
-    server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json([]);
-      })
-    );
+    // Clear localStorage data and mocks before each test
+    Object.keys(localStorageData).forEach((key) => delete localStorageData[key]);
+    vi.clearAllMocks();
+    Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true });
+  });
+
+  describe('rendering', () => {
+    it('renders visible widgets', () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      expect(screen.getByText('Widget One')).toBeInTheDocument();
+      expect(screen.getByText('Widget Two')).toBeInTheDocument();
+      expect(screen.getByText('Widget One Content')).toBeInTheDocument();
+      expect(screen.getByText('Widget Two Content')).toBeInTheDocument();
+    });
+
+    it('does not render hidden widgets', () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      // Widget Three is hidden by default
+      expect(screen.queryByText('Widget Three')).not.toBeInTheDocument();
+      expect(screen.queryByText('Widget Three Content')).not.toBeInTheDocument();
+    });
+
+    it('renders Reset Layout button when controls are shown', () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      expect(screen.getByText('Reset Layout')).toBeInTheDocument();
+    });
+
+    it('hides controls when hideControls is true', () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" hideControls />);
+
+      expect(screen.queryByText('Reset Layout')).not.toBeInTheDocument();
+    });
+
+    it('shows hidden count button when widgets are hidden', () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      // Widget Three is hidden by default
+      expect(screen.getByText('1 Hidden')).toBeInTheDocument();
+    });
+  });
+
+  describe('visibility toggle', () => {
+    it('hides a widget when hide button is clicked', async () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      // Find and click the hide button for Widget One
+      const hideButtons = screen.getAllByTitle('Hide widget');
+      fireEvent.click(hideButtons[0]);
+
+      await waitFor(() => {
+        expect(screen.queryByText('Widget One Content')).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows hidden widgets panel when clicking hidden count button', async () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      const hiddenButton = screen.getByText('1 Hidden');
+      fireEvent.click(hiddenButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('Hidden widgets (click to show):')).toBeInTheDocument();
+        expect(screen.getByText('Widget Three')).toBeInTheDocument();
+      });
+    });
+
+    it('shows a hidden widget when clicked in the hidden panel', async () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      // Open hidden panel
+      const hiddenButton = screen.getByText('1 Hidden');
+      fireEvent.click(hiddenButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('Widget Three')).toBeInTheDocument();
+      });
+
+      // Click to show Widget Three
+      const showWidgetButton = screen.getByRole('button', { name: /Widget Three/i });
+      fireEvent.click(showWidgetButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('Widget Three Content')).toBeInTheDocument();
+      });
+    });
   });
 
-  it('placeholder test', () => {
-    expect(true).toBe(true);
+  describe('reset layout', () => {
+    it('resets layout to default when Reset Layout is clicked', async () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      // Hide Widget One
+      const hideButtons = screen.getAllByTitle('Hide widget');
+      fireEvent.click(hideButtons[0]);
+
+      await waitFor(() => {
+        expect(screen.queryByText('Widget One Content')).not.toBeInTheDocument();
+      });
+
+      // Reset layout
+      const resetButton = screen.getByText('Reset Layout');
+      fireEvent.click(resetButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('Widget One Content')).toBeInTheDocument();
+      });
+    });
+
+    it('calls onResetLayout callback when reset', async () => {
+      const onResetLayout = vi.fn();
+      render(
+        <Dashboard
+          widgets={mockWidgets}
+          storageKey="test-dashboard"
+          onResetLayout={onResetLayout}
+        />
+      );
+
+      const resetButton = screen.getByText('Reset Layout');
+      fireEvent.click(resetButton);
+
+      expect(onResetLayout).toHaveBeenCalled();
+    });
+  });
+
+  describe('size toggle', () => {
+    it('cycles widget size when size button is clicked', async () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard" />);
+
+      // Widget One starts at size 2, should cycle to 4
+      const sizeButtons = screen.getAllByTitle(/Size:/);
+      fireEvent.click(sizeButtons[0]);
+
+      // After click, size should change (verify by checking title updates)
+      await waitFor(() => {
+        // The button title should now show a different size
+        expect(screen.getAllByTitle(/Size:/)[0]).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('localStorage persistence', () => {
+    it('saves layout to localStorage when widget is hidden', async () => {
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard-persist" />);
+
+      // Hide a widget to trigger a layout change
+      const hideButtons = screen.getAllByTitle('Hide widget');
+      fireEvent.click(hideButtons[0]);
+
+      await waitFor(() => {
+        // Verify setItem was called with the storage key
+        expect(localStorageMock.setItem).toHaveBeenCalled();
+        const calls = localStorageMock.setItem.mock.calls;
+        const lastCall = calls[calls.length - 1];
+        expect(lastCall[0]).toBe('test-dashboard-persist');
+        const parsed = JSON.parse(lastCall[1]);
+        expect(parsed.hidden).toContain('widget-1');
+      });
+    });
+
+    it('loads saved layout from localStorage', () => {
+      // Pre-set a layout in localStorage
+      localStorageData['test-dashboard-load'] = JSON.stringify({
+        order: ['widget-2', 'widget-1', 'widget-3'],
+        hidden: ['widget-2'],
+        sizes: { 'widget-1': 4, 'widget-2': 2, 'widget-3': 1 },
+      });
+
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard-load" />);
+
+      // Widget 2 should be hidden
+      expect(screen.queryByText('Widget Two Content')).not.toBeInTheDocument();
+      // Widget 1 should be visible
+      expect(screen.getByText('Widget One Content')).toBeInTheDocument();
+    });
+  });
+
+  describe('empty state', () => {
+    it('shows empty message when all widgets are hidden', async () => {
+      // Pre-set all widgets as hidden
+      localStorageData['test-dashboard-empty'] = JSON.stringify({
+        order: ['widget-1', 'widget-2', 'widget-3'],
+        hidden: ['widget-1', 'widget-2', 'widget-3'],
+        sizes: {},
+      });
+
+      render(<Dashboard widgets={mockWidgets} storageKey="test-dashboard-empty" />);
+
+      expect(screen.getByText('All widgets are hidden.')).toBeInTheDocument();
+      // There are multiple Reset Layout buttons (one in controls, one in empty state)
+      const resetButtons = screen.getAllByRole('button', { name: 'Reset Layout' });
+      expect(resetButtons.length).toBeGreaterThan(0);
+    });
+  });
+
+  describe('custom render controls', () => {
+    it('renders custom controls when renderControls is provided', () => {
+      render(
+        <Dashboard
+          widgets={mockWidgets}
+          storageKey="test-dashboard"
+          renderControls={({ hiddenCount }) => (
+            <div data-testid="custom-controls">Hidden: {hiddenCount}</div>
+          )}
+        />
+      );
+
+      expect(screen.getByTestId('custom-controls')).toBeInTheDocument();
+      expect(screen.getByText('Hidden: 1')).toBeInTheDocument();
+    });
   });
 });

+ 398 - 11
frontend/src/__tests__/components/FileManagerModal.test.tsx

@@ -1,30 +1,417 @@
 /**
  * Tests for the FileManagerModal component.
- * Note: This component may have a different structure or name.
+ * Tests file browsing, selection, navigation, and file operations.
  */
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { FileManagerModal } from '../../components/FileManagerModal';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
-// Skip these tests as FileManagerModal component structure may differ
-describe.skip('FileManagerModal', () => {
-  const _mockOnClose = vi.fn();
-  const _mockOnSelect = vi.fn();
+const mockFiles = [
+  {
+    name: 'cache',
+    path: '/cache',
+    size: 0,
+    is_directory: true,
+    mtime: '2024-01-15T10:00:00Z',
+  },
+  {
+    name: 'model',
+    path: '/model',
+    size: 0,
+    is_directory: true,
+    mtime: '2024-01-15T10:00:00Z',
+  },
+  {
+    name: 'benchy.3mf',
+    path: '/benchy.3mf',
+    size: 1024000,
+    is_directory: false,
+    mtime: '2024-01-15T10:00:00Z',
+  },
+  {
+    name: 'print_job.gcode',
+    path: '/print_job.gcode',
+    size: 2048000,
+    is_directory: false,
+    mtime: '2024-01-14T10:00:00Z',
+  },
+];
+
+const mockStorage = {
+  used_bytes: 1073741824, // 1 GB
+  free_bytes: 3221225472, // 3 GB
+};
+
+describe('FileManagerModal', () => {
+  const mockOnClose = vi.fn();
 
   beforeEach(() => {
     vi.clearAllMocks();
     server.use(
-      http.get('/api/v1/library/folders', () => {
-        return HttpResponse.json([]);
+      http.get('/api/v1/printers/:id/files', () => {
+        return HttpResponse.json({ files: mockFiles });
       }),
-      http.get('/api/v1/library/files', () => {
-        return HttpResponse.json([]);
+      http.get('/api/v1/printers/:id/storage', () => {
+        return HttpResponse.json(mockStorage);
+      }),
+      http.delete('/api/v1/printers/:id/files', () => {
+        return HttpResponse.json({ success: true });
       })
     );
   });
 
-  it('placeholder test', () => {
-    expect(true).toBe(true);
+  describe('rendering', () => {
+    it('renders the modal with header', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('File Manager')).toBeInTheDocument();
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+
+    it('renders storage info', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText(/Used:/)).toBeInTheDocument();
+        expect(screen.getByText(/Free:/)).toBeInTheDocument();
+      });
+    });
+
+    it('renders quick navigation buttons', () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Root')).toBeInTheDocument();
+      expect(screen.getByText('Cache')).toBeInTheDocument();
+      expect(screen.getByText('Models')).toBeInTheDocument();
+      expect(screen.getByText('Timelapse')).toBeInTheDocument();
+    });
+
+    it('renders file list', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('cache')).toBeInTheDocument();
+        expect(screen.getByText('model')).toBeInTheDocument();
+        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
+        expect(screen.getByText('print_job.gcode')).toBeInTheDocument();
+      });
+    });
+
+    it('shows file sizes for files', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        // 1024000 bytes = 1000 KB = ~1.0 MB
+        expect(screen.getByText('1000 KB')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('navigation', () => {
+    it('navigates into a folder when clicked', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/files', ({ request }) => {
+          const url = new URL(request.url);
+          const path = url.searchParams.get('path');
+          if (path === '/cache') {
+            return HttpResponse.json({
+              files: [
+                { name: 'temp.dat', path: '/cache/temp.dat', size: 512, is_directory: false },
+              ],
+            });
+          }
+          return HttpResponse.json({ files: mockFiles });
+        })
+      );
+
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('cache')).toBeInTheDocument();
+      });
+
+      // Click on cache folder
+      fireEvent.click(screen.getByText('cache'));
+
+      await waitFor(() => {
+        expect(screen.getByText('temp.dat')).toBeInTheDocument();
+      });
+    });
+
+    it('shows current path', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('/')).toBeInTheDocument();
+    });
+  });
+
+  describe('file selection', () => {
+    it('selects a file when checkbox is clicked', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
+      });
+
+      // Find and click a checkbox (files have checkboxes, directories don't)
+      const checkboxes = screen.getAllByRole('button').filter(btn =>
+        btn.querySelector('svg')?.classList.contains('lucide-square')
+      );
+
+      if (checkboxes.length > 0) {
+        fireEvent.click(checkboxes[0]);
+
+        await waitFor(() => {
+          expect(screen.getByText('1 selected')).toBeInTheDocument();
+        });
+      }
+    });
+
+    it('enables download button when files are selected', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
+      });
+
+      // Download button should be disabled initially
+      const downloadButton = screen.getByRole('button', { name: /Download/i });
+      expect(downloadButton).toBeDisabled();
+    });
+
+    it('shows Select All button when files exist', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select All')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('search and filter', () => {
+    it('renders search input', () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByPlaceholderText('Filter files...')).toBeInTheDocument();
+    });
+
+    it('filters files based on search query', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
+      });
+
+      const searchInput = screen.getByPlaceholderText('Filter files...');
+      fireEvent.change(searchInput, { target: { value: 'benchy' } });
+
+      await waitFor(() => {
+        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
+        expect(screen.queryByText('print_job.gcode')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('sorting', () => {
+    it('renders sort dropdown', () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('combobox')).toBeInTheDocument();
+    });
+
+    it('has sort options available', () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      const sortSelect = screen.getByRole('combobox');
+      expect(sortSelect).toBeInTheDocument();
+
+      // Check that options exist
+      expect(screen.getByText('Name (A-Z)')).toBeInTheDocument();
+    });
+  });
+
+  describe('close behavior', () => {
+    it('calls onClose when X button is clicked', async () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      const closeButton = screen.getAllByRole('button').find(btn =>
+        btn.querySelector('.lucide-x')
+      );
+
+      if (closeButton) {
+        fireEvent.click(closeButton);
+        expect(mockOnClose).toHaveBeenCalled();
+      }
+    });
+
+    it('calls onClose when clicking outside the modal', () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      // Click on the backdrop
+      const backdrop = document.querySelector('.fixed.inset-0');
+      if (backdrop) {
+        fireEvent.click(backdrop);
+        expect(mockOnClose).toHaveBeenCalled();
+      }
+    });
+
+    it('calls onClose when Escape key is pressed', () => {
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+  });
+
+  describe('empty state', () => {
+    it('shows empty message when directory has no files', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/files', () => {
+          return HttpResponse.json({ files: [] });
+        })
+      );
+
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('No files in this directory')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('loading state', () => {
+    it('shows loading spinner while fetching files', () => {
+      // Delay the response to see loading state
+      server.use(
+        http.get('/api/v1/printers/:id/files', async () => {
+          await new Promise((r) => setTimeout(r, 100));
+          return HttpResponse.json({ files: mockFiles });
+        })
+      );
+
+      render(
+        <FileManagerModal
+          printerId={1}
+          printerName="X1 Carbon"
+          onClose={mockOnClose}
+        />
+      );
+
+      // The loader should be present initially
+      const loader = document.querySelector('.animate-spin');
+      expect(loader).toBeInTheDocument();
+    });
   });
 });

+ 62 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -29,6 +29,7 @@ describe('Layout', () => {
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json({
           check_updates: false,
+          check_printer_firmware: false,
           auto_archive: true,
         });
       }),
@@ -122,4 +123,65 @@ describe('Layout', () => {
       });
     });
   });
+
+  describe('plate detection alert modal', () => {
+    it('shows modal when plate-not-empty event is dispatched', async () => {
+      render(<Layout />);
+
+      // Dispatch the plate-not-empty event
+      window.dispatchEvent(
+        new CustomEvent('plate-not-empty', {
+          detail: {
+            printer_id: 1,
+            printer_name: 'Test Printer',
+            message: 'Objects detected on build plate',
+          },
+        })
+      );
+
+      await waitFor(() => {
+        // Modal should appear with "Print Paused!" text
+        expect(document.body.textContent).toContain('Print Paused!');
+        expect(document.body.textContent).toContain('Test Printer');
+      });
+    });
+
+    it('closes modal when I Understand button is clicked', async () => {
+      render(<Layout />);
+
+      // Dispatch the plate-not-empty event
+      window.dispatchEvent(
+        new CustomEvent('plate-not-empty', {
+          detail: {
+            printer_id: 1,
+            printer_name: 'Test Printer',
+            message: 'Objects detected on build plate',
+          },
+        })
+      );
+
+      await waitFor(() => {
+        expect(document.body.textContent).toContain('Print Paused!');
+      });
+
+      // Click the "I Understand" button
+      const button = document.querySelector('button');
+      if (button && button.textContent?.includes('I Understand')) {
+        button.click();
+      }
+
+      // Find and click the "I Understand" button by searching all buttons
+      const buttons = document.querySelectorAll('button');
+      buttons.forEach((btn) => {
+        if (btn.textContent?.includes('I Understand')) {
+          btn.click();
+        }
+      });
+
+      await waitFor(() => {
+        // Modal should be closed
+        expect(document.body.textContent).not.toContain('Print Paused!');
+      });
+    });
+  });
 });

+ 290 - 7
frontend/src/__tests__/components/UploadModal.test.tsx

@@ -1,13 +1,296 @@
 /**
- * Tests for upload modal functionality.
- * Note: UploadModal may be integrated into other components.
+ * Tests for the UploadModal component.
+ * Tests file upload functionality with drag-and-drop support.
  */
 
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { UploadModal } from '../../components/UploadModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
 
-// Skip these tests as UploadModal may be integrated into FileManagerPage
-describe.skip('UploadModal', () => {
-  it('placeholder test', () => {
-    expect(true).toBe(true);
+const mockPrinters = [
+  { id: 1, name: 'X1 Carbon', model: 'X1C', serial_number: '123' },
+  { id: 2, name: 'P1S', model: 'P1S', serial_number: '456' },
+];
+
+describe('UploadModal', () => {
+  const mockOnClose = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.post('/api/v1/archives/upload-bulk', async () => {
+        return HttpResponse.json({
+          uploaded: 1,
+          failed: 0,
+          results: [{ id: 1, filename: 'test.3mf' }],
+          errors: [],
+        });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal with title', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      expect(screen.getByText('Upload 3MF Files')).toBeInTheDocument();
+    });
+
+    it('renders drag and drop zone', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      expect(screen.getByText('Drag & drop .3mf files here')).toBeInTheDocument();
+    });
+
+    it('renders Browse Files button', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
+    });
+
+    it('renders printer selection dropdown', async () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Associate with printer (optional)')).toBeInTheDocument();
+      });
+
+      const select = screen.getByRole('combobox');
+      expect(select).toBeInTheDocument();
+    });
+
+    it('renders Cancel button', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    });
+
+    it('renders Upload button (disabled initially)', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload/i });
+      expect(uploadButton).toBeDisabled();
+    });
+  });
+
+  describe('printer selection', () => {
+    it('shows available printers in dropdown', async () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        // Check for printer options in the select
+        expect(screen.getByRole('option', { name: 'No printer' })).toBeInTheDocument();
+        expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
+        expect(screen.getByRole('option', { name: 'P1S' })).toBeInTheDocument();
+      });
+    });
+
+    it('allows selecting a printer', async () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
+      });
+
+      const select = screen.getByRole('combobox');
+      fireEvent.change(select, { target: { value: '1' } });
+
+      expect(select).toHaveValue('1');
+    });
+  });
+
+  describe('file handling with initialFiles', () => {
+    it('shows initial files when provided', () => {
+      const initialFiles = [
+        new File(['content'], 'model.3mf', { type: 'application/3mf' }),
+      ];
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      expect(screen.getByText('model.3mf')).toBeInTheDocument();
+    });
+
+    it('enables Upload button when files are present', () => {
+      const initialFiles = [
+        new File(['content'], 'model.3mf', { type: 'application/3mf' }),
+      ];
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload/i });
+      expect(uploadButton).not.toBeDisabled();
+    });
+
+    it('shows file count in Upload button', () => {
+      const initialFiles = [
+        new File(['content'], 'model1.3mf', { type: 'application/3mf' }),
+        new File(['content'], 'model2.3mf', { type: 'application/3mf' }),
+      ];
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      expect(screen.getByRole('button', { name: /Upload \(2\)/i })).toBeInTheDocument();
+    });
+
+    it('filters out non-3mf files from initialFiles', () => {
+      const initialFiles = [
+        new File(['content'], 'model.3mf', { type: 'application/3mf' }),
+        new File(['content'], 'image.png', { type: 'image/png' }),
+        new File(['content'], 'doc.txt', { type: 'text/plain' }),
+      ];
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      expect(screen.getByText('model.3mf')).toBeInTheDocument();
+      expect(screen.queryByText('image.png')).not.toBeInTheDocument();
+      expect(screen.queryByText('doc.txt')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('file removal', () => {
+    it('allows removing a file before upload', async () => {
+      const initialFiles = [
+        new File(['content'], 'model.3mf', { type: 'application/3mf' }),
+      ];
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      expect(screen.getByText('model.3mf')).toBeInTheDocument();
+
+      // Find and click the remove button (X icon next to file)
+      const fileItem = screen.getByText('model.3mf').closest('.flex');
+      const removeButton = fileItem?.querySelector('button');
+
+      if (removeButton) {
+        fireEvent.click(removeButton);
+
+        await waitFor(() => {
+          expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
+        });
+      }
+    });
+  });
+
+  describe('upload button behavior', () => {
+    it('Upload button triggers upload mutation when clicked', async () => {
+      const initialFiles = [
+        new File(['content'], 'test.3mf', { type: 'application/3mf' }),
+      ];
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload/i });
+      expect(uploadButton).not.toBeDisabled();
+
+      // Click should trigger upload (button text will change)
+      fireEvent.click(uploadButton);
+
+      // The button should show uploading state or become disabled
+      await waitFor(() => {
+        // Either showing "Uploading..." or a spinner is present
+        const hasUploadingText = screen.queryByText(/Uploading/i) !== null;
+        const hasSpinner = document.querySelector('.animate-spin') !== null;
+        expect(hasUploadingText || hasSpinner).toBe(true);
+      });
+    });
+
+    it('Upload button is disabled when no files are pending', async () => {
+      render(<UploadModal onClose={mockOnClose} initialFiles={[]} />);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload/i });
+      expect(uploadButton).toBeDisabled();
+    });
+
+    it('shows correct file count in Upload button', () => {
+      const initialFiles = [
+        new File(['content'], 'file1.3mf', { type: 'application/3mf' }),
+        new File(['content'], 'file2.3mf', { type: 'application/3mf' }),
+        new File(['content'], 'file3.3mf', { type: 'application/3mf' }),
+      ];
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      expect(screen.getByRole('button', { name: /Upload \(3\)/i })).toBeInTheDocument();
+    });
+  });
+
+  describe('close behavior', () => {
+    it('calls onClose when Cancel button is clicked', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+      fireEvent.click(cancelButton);
+
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when X button is clicked', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      // Find the X button in the header
+      const buttons = screen.getAllByRole('button');
+      const closeButton = buttons.find(btn =>
+        btn.querySelector('.lucide-x')
+      );
+
+      if (closeButton) {
+        fireEvent.click(closeButton);
+        expect(mockOnClose).toHaveBeenCalled();
+      }
+    });
+
+    it('calls onClose when Escape key is pressed', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+  });
+
+  describe('drag and drop', () => {
+    it('highlights drop zone on drag over', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      const dropZone = screen.getByText('Drag & drop .3mf files here').closest('div');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, {
+          dataTransfer: { files: [] },
+        });
+
+        // The drop zone should have the highlight class
+        expect(dropZone.className).toContain('border-bambu-green');
+      }
+    });
+
+    it('removes highlight on drag leave', () => {
+      render(<UploadModal onClose={mockOnClose} />);
+
+      const dropZone = screen.getByText('Drag & drop .3mf files here').closest('div');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
+        fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });
+
+        // The drop zone should not have the highlight class
+        expect(dropZone.className).not.toContain('bg-bambu-green');
+      }
+    });
+  });
+
+  describe('file size display', () => {
+    it('shows file size in MB', () => {
+      const file = new File(['x'.repeat(1048576)], 'large.3mf', { type: 'application/3mf' }); // 1 MB
+
+      render(<UploadModal onClose={mockOnClose} initialFiles={[file]} />);
+
+      expect(screen.getByText('1.0 MB')).toBeInTheDocument();
+    });
   });
 });

+ 11 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -269,6 +269,17 @@ export const handlers = [
     return HttpResponse.json(body);
   }),
 
+  // ========================================================================
+  // Auth
+  // ========================================================================
+
+  http.get('/api/v1/auth/status', () => {
+    return HttpResponse.json({
+      auth_enabled: false,
+      requires_setup: false,
+    });
+  }),
+
   // ========================================================================
   // Version / Health
   // ========================================================================

+ 40 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -261,4 +261,44 @@ describe('ArchivesPage', () => {
       });
     });
   });
+
+  describe('plate navigation', () => {
+    it('renders archive cards with thumbnails', async () => {
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        // Archive cards should render with their thumbnails
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+        // Thumbnail images should be present (archive cards have img elements)
+        const images = document.querySelectorAll('img[alt="Benchy"]');
+        expect(images.length).toBeGreaterThanOrEqual(0);
+      });
+    });
+
+    it('fetches plate data for multi-plate archives on hover', async () => {
+      // Setup handler for plates endpoint
+      server.use(
+        http.get('/api/v1/archives/:id/plates', ({ params }) => {
+          return HttpResponse.json({
+            archive_id: Number(params.id),
+            filename: 'test.3mf',
+            plates: [
+              { index: 0, name: 'Plate 1', objects: ['Object A'], has_thumbnail: true, thumbnail_url: '/thumb1.png', print_time_seconds: 3600, filament_used_grams: 10, filaments: [] },
+              { index: 1, name: 'Plate 2', objects: ['Object B'], has_thumbnail: true, thumbnail_url: '/thumb2.png', print_time_seconds: 1800, filament_used_grams: 5, filaments: [] },
+            ],
+            is_multi_plate: true,
+          });
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Archives with multi-plate support will show navigation on hover
+      // The plates API is called lazily when hovering
+    });
+  });
 });

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

@@ -105,6 +105,7 @@ describe('FileManagerPage', () => {
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json({
           check_updates: false,
+          check_printer_firmware: false,
           library_disk_warning_gb: 5,
         });
       }),

+ 21 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -215,6 +215,27 @@ describe('QueuePage', () => {
         expect(printerElements.length).toBeGreaterThan(0);
       });
     });
+
+    it('renders queue items with plate_id correctly', async () => {
+      // Override with queue items that have plate_id set
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              plate_id: 2,
+              archive_name: 'Multi-plate Print',
+            },
+          ]);
+        })
+      );
+
+      render(<QueuePage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Multi-plate Print')).toBeInTheDocument();
+      });
+    });
   });
 
   describe('empty state', () => {

+ 11 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -31,6 +31,7 @@ const mockSettings = {
   ha_url: '',
   ha_token: '',
   check_updates: false,
+  check_printer_firmware: false,
 };
 
 describe('SettingsPage', () => {
@@ -124,6 +125,16 @@ describe('SettingsPage', () => {
         expect(screen.getByText('Appearance')).toBeInTheDocument();
       });
     });
+
+    it('shows updates section with firmware toggle', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Updates')).toBeInTheDocument();
+        expect(screen.getByText('Check for updates')).toBeInTheDocument();
+        expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
+      });
+    });
   });
 
   describe('tabs navigation', () => {

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

@@ -48,6 +48,7 @@ const mockArchives = [
 const mockSettings = {
   currency: '$',
   check_updates: false,
+  check_printer_firmware: false,
 };
 
 const mockFailureAnalysis = {
@@ -196,4 +197,14 @@ describe('StatsPage', () => {
       });
     });
   });
+
+  describe('recalculate costs', () => {
+    it('has recalculate costs button', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Recalculate Costs')).toBeInTheDocument();
+      });
+    });
+  });
 });

+ 336 - 14
frontend/src/api/client.ts

@@ -65,6 +65,11 @@ export interface Printer {
   nozzle_count: number;  // 1 or 2, auto-detected from MQTT
   is_active: boolean;
   auto_archive: boolean;
+  external_camera_url: string | null;
+  external_camera_type: string | null;  // "mjpeg", "rtsp", "snapshot"
+  external_camera_enabled: boolean;
+  plate_detection_enabled: boolean;  // Check plate before print
+  plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   created_at: string;
   updated_at: string;
 }
@@ -209,6 +214,54 @@ export interface PrinterCreate {
   model?: string;
   location?: string;
   auto_archive?: boolean;
+  external_camera_url?: string | null;
+  external_camera_type?: string | null;
+  external_camera_enabled?: boolean;
+  plate_detection_enabled?: boolean;
+  plate_detection_roi?: PlateDetectionROI;
+}
+
+// Plate Detection
+export interface PlateDetectionROI {
+  x: number;  // X start % (0.0-1.0)
+  y: number;  // Y start % (0.0-1.0)
+  w: number;  // Width % (0.0-1.0)
+  h: number;  // Height % (0.0-1.0)
+}
+
+export interface PlateDetectionResult {
+  is_empty: boolean;
+  confidence: number;
+  difference_percent: number;
+  message: string;
+  has_debug_image: boolean;
+  debug_image_url?: string;
+  needs_calibration: boolean;
+  light_warning?: boolean;
+  reference_count?: number;
+  max_references?: number;
+  roi?: PlateDetectionROI;
+}
+
+export interface PlateDetectionStatus {
+  available: boolean;
+  calibrated: boolean;
+  reference_count: number;
+  max_references: number;
+  message: string;
+}
+
+export interface CalibrationResult {
+  success: boolean;
+  message: string;
+}
+
+export interface PlateReference {
+  index: number;
+  label: string;
+  timestamp: string;
+  has_image: boolean;
+  thumbnail_url: string;
 }
 
 // Archive types
@@ -253,6 +306,7 @@ export interface Archive {
   extra_data: Record<string, unknown> | null;
   makerworld_url: string | null;
   designer: string | null;
+  external_url: string | null;
   is_favorite: boolean;
   tags: string | null;
   notes: string | null;
@@ -523,6 +577,53 @@ export interface BOMItemUpdate {
   remarks?: string;
 }
 
+// Project Export/Import Types
+export interface BOMItemExport {
+  name: string;
+  quantity_needed: number;
+  quantity_acquired: number;
+  unit_price: number | null;
+  sourcing_url: string | null;
+  stl_filename: string | null;
+  remarks: string | null;
+}
+
+export interface LinkedFolderExport {
+  name: string;
+}
+
+export interface ProjectExport {
+  name: string;
+  description: string | null;
+  color: string | null;
+  status: string;
+  target_count: number | null;
+  target_parts_count: number | null;
+  notes: string | null;
+  tags: string | null;
+  due_date: string | null;
+  priority: string;
+  budget: number | null;
+  bom_items: BOMItemExport[];
+  linked_folders: LinkedFolderExport[];
+}
+
+export interface ProjectImport {
+  name: string;
+  description?: string;
+  color?: string;
+  status?: string;
+  target_count?: number;
+  target_parts_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  bom_items?: BOMItemExport[];
+  linked_folders?: LinkedFolderExport[];
+}
+
 // Timeline Types
 export interface TimelineEvent {
   event_type: string;
@@ -580,6 +681,7 @@ export interface AppSettings {
   energy_cost_per_kwh: number;
   energy_tracking_mode: 'print' | 'total';
   check_updates: boolean;
+  check_printer_firmware: boolean;
   notification_language: string;
   // AMS threshold settings
   ams_humidity_good: number;  // <= this is green
@@ -594,8 +696,6 @@ export interface AppSettings {
   time_format: 'system' | '12h' | '24h';
   // Default printer
   default_printer_id: number | null;
-  // Telemetry
-  telemetry_enabled: boolean;
   // Dark mode theme settings
   dark_style: 'classic' | 'glow' | 'vibrant';
   dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
@@ -628,6 +728,9 @@ export interface AppSettings {
   library_disk_warning_gb: number;
   // Camera view settings
   camera_view_mode: 'window' | 'embedded';
+  // Prometheus metrics
+  prometheus_enabled: boolean;
+  prometheus_token: string;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -966,6 +1069,28 @@ export interface PrintQueueItemUpdate {
   use_ams?: boolean;
 }
 
+export interface PrintQueueBulkUpdate {
+  item_ids: number[];
+  printer_id?: number | null;
+  scheduled_time?: string | null;
+  require_previous_success?: boolean;
+  auto_off_after?: boolean;
+  manual_start?: boolean;
+  // Print options
+  bed_levelling?: boolean;
+  flow_cali?: boolean;
+  vibration_cali?: boolean;
+  layer_inspect?: boolean;
+  timelapse?: boolean;
+  use_ams?: boolean;
+}
+
+export interface PrintQueueBulkUpdateResponse {
+  updated_count: number;
+  skipped_count: number;
+  message: string;
+}
+
 // MQTT Logging types
 export interface MQTTLogEntry {
   timestamp: string;
@@ -1085,6 +1210,8 @@ export interface NotificationProvider {
   // AMS-HT environmental alarms
   on_ams_ht_humidity_high: boolean;
   on_ams_ht_temperature_high: boolean;
+  // Build plate detection
+  on_plate_not_empty: boolean;
   // Quiet hours
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
@@ -1125,6 +1252,8 @@ export interface NotificationProviderCreate {
   // AMS-HT environmental alarms
   on_ams_ht_humidity_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
+  // Build plate detection
+  on_plate_not_empty?: boolean;
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
@@ -1158,6 +1287,8 @@ export interface NotificationProviderUpdate {
   // AMS-HT environmental alarms
   on_ams_ht_humidity_high?: boolean;
   on_ams_ht_temperature_high?: boolean;
+  // Build plate detection
+  on_plate_not_empty?: boolean;
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
@@ -1169,6 +1300,78 @@ export interface NotificationProviderUpdate {
   printer_id?: number | null;
 }
 
+// GitHub Backup types
+export type ScheduleType = 'hourly' | 'daily' | 'weekly';
+
+export interface GitHubBackupConfig {
+  id: number;
+  repository_url: string;
+  has_token: boolean;
+  branch: string;
+  schedule_enabled: boolean;
+  schedule_type: ScheduleType;
+  backup_kprofiles: boolean;
+  backup_cloud_profiles: boolean;
+  backup_settings: boolean;
+  enabled: boolean;
+  last_backup_at: string | null;
+  last_backup_status: string | null;
+  last_backup_message: string | null;
+  last_backup_commit_sha: string | null;
+  next_scheduled_run: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface GitHubBackupConfigCreate {
+  repository_url: string;
+  access_token: string;
+  branch?: string;
+  schedule_enabled?: boolean;
+  schedule_type?: ScheduleType;
+  backup_kprofiles?: boolean;
+  backup_cloud_profiles?: boolean;
+  backup_settings?: boolean;
+  enabled?: boolean;
+}
+
+export interface GitHubBackupLog {
+  id: number;
+  config_id: number;
+  started_at: string;
+  completed_at: string | null;
+  status: string;
+  trigger: string;
+  commit_sha: string | null;
+  files_changed: number;
+  error_message: string | null;
+}
+
+export interface GitHubBackupStatus {
+  configured: boolean;
+  enabled: boolean;
+  is_running: boolean;
+  progress: string | null;
+  last_backup_at: string | null;
+  last_backup_status: string | null;
+  next_scheduled_run: string | null;
+}
+
+export interface GitHubTestConnectionResponse {
+  success: boolean;
+  message: string;
+  repo_name: string | null;
+  permissions: Record<string, boolean> | null;
+}
+
+export interface GitHubBackupTriggerResponse {
+  success: boolean;
+  message: string;
+  log_id: number | null;
+  commit_sha: string | null;
+  files_changed: number;
+}
+
 export interface NotificationTestRequest {
   provider_type: ProviderType;
   config: Record<string, unknown>;
@@ -1548,6 +1751,11 @@ export const api = {
     request<{ connected: boolean }>(`/printers/${id}/disconnect`, {
       method: 'POST',
     }),
+  testExternalCamera: (printerId: number, url: string, cameraType: string) =>
+    request<{ success: boolean; error?: string; resolution?: string }>(
+      `/printers/${printerId}/camera/external/test?url=${encodeURIComponent(url)}&camera_type=${encodeURIComponent(cameraType)}`,
+      { method: 'POST' }
+    ),
 
   // Print Control
   stopPrint: (printerId: number) =>
@@ -1625,6 +1833,18 @@ export const api = {
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
+    const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ paths }),
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.blob();
+  },
   deletePrinterFile: (printerId: number, path: string) =>
     request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
       method: 'DELETE',
@@ -1670,6 +1890,7 @@ export const api = {
     failure_reason?: string | null;
     status?: string;
     quantity?: number;
+    external_url?: string | null;
   }) =>
     request<Archive>(`/archives/${id}`, {
       method: 'PATCH',
@@ -1680,6 +1901,8 @@ export const api = {
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  recalculateCosts: () =>
+    request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
     const params = new URLSearchParams();
     if (options?.days) params.set('days', String(options.days));
@@ -1762,6 +1985,8 @@ export const api = {
       method: 'POST',
     }),
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
+  getArchivePlateThumbnail: (id: number, plateIndex: number) =>
+    `${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
@@ -2067,6 +2292,7 @@ export const api = {
       if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
       if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links));
       if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
+      if (categories.plate_calibration !== undefined) params.set('include_plate_calibration', String(categories.plate_calibration));
       if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
       if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
       if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
@@ -2249,6 +2475,11 @@ export const api = {
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
   startQueueItem: (id: number) =>
     request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
+  bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
+    request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
 
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
@@ -2524,6 +2755,59 @@ export const api = {
   testCameraConnection: (printerId: number) =>
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
 
+  // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
+  checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
+    const params = new URLSearchParams();
+    params.set('use_external', String(options?.useExternal ?? false));
+    params.set('include_debug_image', String(options?.includeDebugImage ?? false));
+    return request<PlateDetectionResult>(
+      `/printers/${printerId}/camera/check-plate?${params.toString()}`
+    );
+  },
+  getPlateDetectionStatus: (printerId: number) => {
+    return request<PlateDetectionStatus & { chamber_light?: boolean }>(
+      `/printers/${printerId}/camera/plate-detection/status`
+    );
+  },
+  calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => {
+    const params = new URLSearchParams();
+    if (options?.label) params.set('label', options.label);
+    params.set('use_external', String(options?.useExternal ?? false));
+    return request<CalibrationResult & { index: number }>(
+      `/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`,
+      { method: 'POST' }
+    );
+  },
+  deletePlateCalibration: (printerId: number) => {
+    return request<CalibrationResult>(
+      `/printers/${printerId}/camera/plate-detection/calibrate`,
+      { method: 'DELETE' }
+    );
+  },
+  getPlateReferences: (printerId: number) => {
+    return request<{
+      references: PlateReference[];
+      max_references: number;
+    }>(`/printers/${printerId}/camera/plate-detection/references`);
+  },
+  getPlateReferenceThumbnailUrl: (printerId: number, index: number) => {
+    return `${API_BASE}/printers/${printerId}/camera/plate-detection/references/${index}/thumbnail`;
+  },
+  updatePlateReferenceLabel: (printerId: number, index: number, label: string) => {
+    const params = new URLSearchParams();
+    params.set('label', label);
+    return request<{ success: boolean; index: number; label: string }>(
+      `/printers/${printerId}/camera/plate-detection/references/${index}?${params.toString()}`,
+      { method: 'PUT' }
+    );
+  },
+  deletePlateReference: (printerId: number, index: number) => {
+    return request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/camera/plate-detection/references/${index}`,
+      { method: 'DELETE' }
+    );
+  },
+
   // External Links
   getExternalLinks: () => request<ExternalLink[]>('/external-links/'),
   getExternalLink: (id: number) => request<ExternalLink>(`/external-links/${id}`),
@@ -2656,6 +2940,15 @@ export const api = {
   getProjectTimeline: (projectId: number, limit = 50) =>
     request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
 
+  // Project Export/Import
+  exportProjectJson: (projectId: number) =>
+    request<ProjectExport>(`/projects/${projectId}/export?format=json`),
+  importProject: (data: ProjectImport) =>
+    request<Project>('/projects/import', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
   // API Keys
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   createAPIKey: (data: APIKeyCreate) =>
@@ -2723,13 +3016,15 @@ export const api = {
   extractZipFile: async (
     file: File,
     folderId?: number | null,
-    preserveStructure: boolean = true
+    preserveStructure: boolean = true,
+    createFolderFromZip: boolean = false
   ): Promise<ZipExtractResponse> => {
     const formData = new FormData();
     formData.append('file', file);
     const params = new URLSearchParams();
     if (folderId) params.set('folder_id', String(folderId));
     params.set('preserve_structure', String(preserveStructure));
+    params.set('create_folder_from_zip', String(createFolderFromZip));
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
       method: 'POST',
       body: formData,
@@ -2749,6 +3044,8 @@ export const api = {
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
   getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`,
+  getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
+    `${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`,
   getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
   moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
     request<{ status: string; moved: number }>('/library/files/move', {
@@ -2822,20 +3119,45 @@ export const api = {
       }>;
     }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
 
-  // STL Thumbnail Generation
-  regenerateFileThumbnail: (fileId: number) =>
-    request<LibraryFile>(`/library/files/${fileId}/regenerate-thumbnail`, {
+  // GitHub Backup
+  getGitHubBackupConfig: () =>
+    request<GitHubBackupConfig | null>('/github-backup/config'),
+
+  saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
+    request<GitHubBackupConfig>('/github-backup/config', {
       method: 'POST',
+      body: JSON.stringify(config),
     }),
-  batchGenerateStlThumbnails: (options: {
-    file_ids?: number[];
-    folder_id?: number;
-    all_missing?: boolean;
-  }) =>
-    request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {
-      method: 'POST',
-      body: JSON.stringify(options),
+
+  updateGitHubBackupConfig: (config: Partial<GitHubBackupConfigCreate>) =>
+    request<GitHubBackupConfig>('/github-backup/config', {
+      method: 'PATCH',
+      body: JSON.stringify(config),
     }),
+
+  deleteGitHubBackupConfig: () =>
+    request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),
+
+  testGitHubConnection: (repoUrl: string, token: string) =>
+    request<GitHubTestConnectionResponse>(
+      `/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}`,
+      { method: 'POST' }
+    ),
+
+  testGitHubStoredConnection: () =>
+    request<GitHubTestConnectionResponse>('/github-backup/test-stored', { method: 'POST' }),
+
+  triggerGitHubBackup: () =>
+    request<GitHubBackupTriggerResponse>('/github-backup/run', { method: 'POST' }),
+
+  getGitHubBackupStatus: () =>
+    request<GitHubBackupStatus>('/github-backup/status'),
+
+  getGitHubBackupLogs: (limit: number = 50) =>
+    request<GitHubBackupLog[]>(`/github-backup/logs?limit=${limit}`),
+
+  clearGitHubBackupLogs: (keepLast: number = 10) =>
+    request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
 };
 
 // AMS History types

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

@@ -344,8 +344,8 @@ export function AMSHistoryModal({
                       hour: '2-digit',
                       minute: '2-digit',
                     }, timeFormat))}
-                    formatter={(value: number) => [
-                      mode === 'humidity' ? `${value}%` : `${value}°C`,
+                    formatter={(value) => [
+                      mode === 'humidity' ? `${value ?? 0}%` : `${value ?? 0}°C`,
                       mode === 'humidity' ? 'Humidity' : 'Temperature'
                     ]}
                   />

+ 28 - 9
frontend/src/components/BackupModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react';
-import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban, Upload } from 'lucide-react';
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban, Upload, Camera } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -12,6 +12,7 @@ interface BackupCategory {
   icon: React.ReactNode;
   default: boolean;
   description: string;
+  requiresPrinters?: boolean;
 }
 
 const BACKUP_CATEGORIES: BackupCategory[] = [
@@ -63,6 +64,15 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: false,
     description: 'Printer info (access codes excluded)',
   },
+  {
+    id: 'plate_calibration',
+    labelKey: 'backup.categories.plateCalibration',
+    defaultLabel: 'Plate Detection',
+    icon: <Camera className="w-4 h-4" />,
+    default: false,
+    description: 'Empty plate reference images',
+    requiresPrinters: true,
+  },
   {
     id: 'filaments',
     labelKey: 'backup.categories.filaments',
@@ -221,33 +231,42 @@ export function BackupModal({ onClose, onExport }: BackupModalProps) {
 
           {/* Categories */}
           <div className={`p-4 space-y-2 max-h-[400px] overflow-y-auto ${isExporting ? 'opacity-50 pointer-events-none' : ''}`}>
-            {BACKUP_CATEGORIES.map((category) => (
+            {BACKUP_CATEGORIES.map((category) => {
+              const isDisabled = isExporting || (category.requiresPrinters && !selected.printers);
+              return (
               <label
                 key={category.id}
-                className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
-                  selected[category.id]
+                className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
+                  isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
+                } ${
+                  selected[category.id] && !isDisabled
                     ? 'bg-bambu-green/10 border border-bambu-green/30'
                     : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
                 }`}
               >
                 <input
                   type="checkbox"
-                  checked={selected[category.id]}
+                  checked={selected[category.id] && !isDisabled}
                   onChange={() => toggleCategory(category.id)}
-                  disabled={isExporting}
+                  disabled={isDisabled}
                   className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
                 />
-                <div className={`${selected[category.id] ? 'text-bambu-green' : 'text-bambu-gray'}`}>
+                <div className={`${selected[category.id] && !isDisabled ? 'text-bambu-green' : 'text-bambu-gray'}`}>
                   {category.icon}
                 </div>
                 <div className="flex-1">
                   <div className="text-white text-sm font-medium">
                     {t(category.labelKey, { defaultValue: category.defaultLabel })}
                   </div>
-                  <div className="text-xs text-bambu-gray">{category.description}</div>
+                  <div className="text-xs text-bambu-gray">
+                    {category.requiresPrinters && !selected.printers
+                      ? 'Requires Printers to be selected'
+                      : category.description}
+                  </div>
                 </div>
               </label>
-            ))}
+              );
+            })}
           </div>
 
           {/* Archive warning */}

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

@@ -91,8 +91,8 @@ function ComparisonContent({ comparison }: { comparison: ArchiveComparison }) {
                     {archive.print_name}
                   </div>
                   <div className={`text-xs ${
-                    archive.status === 'completed' ? 'text-bambu-green' :
-                    archive.status === 'failed' ? 'text-red-400' : 'text-bambu-gray'
+                    archive.status === 'completed' ? 'text-status-ok' :
+                    archive.status === 'failed' ? 'text-status-error' : 'text-bambu-gray'
                   }`}>
                     {archive.status}
                   </div>

+ 21 - 1
frontend/src/components/EditArchiveModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash } from 'lucide-react';
+import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
@@ -51,6 +51,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [status, setStatus] = useState(archive.status);
   const [quantity, setQuantity] = useState(archive.quantity ?? 1);
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
+  const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
   const [uploadingPhoto, setUploadingPhoto] = useState(false);
   const [showTagSuggestions, setShowTagSuggestions] = useState(false);
   const tagInputRef = useRef<HTMLInputElement>(null);
@@ -155,6 +156,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       notes: notes || undefined,
       tags: tags || undefined,
       quantity: quantity,
+      external_url: externalUrl || null,
     };
 
     // Only include status if changed
@@ -275,6 +277,24 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
             />
           </div>
 
+          {/* External Link */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <Link className="w-4 h-4 inline mr-1" />
+              External Link
+            </label>
+            <input
+              type="url"
+              value={externalUrl}
+              onChange={(e) => setExternalUrl(e.target.value)}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              placeholder="https://printables.com/model/..."
+            />
+            <p className="text-xs text-bambu-gray mt-1">
+              Link to Printables, Thingiverse, or other source
+            </p>
+          </div>
+
           {/* Tags */}
           <div>
             <label className="block text-sm text-bambu-gray mb-1">Tags</label>

+ 106 - 4
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -70,6 +70,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [isPanning, setIsPanning] = useState(false);
   const [panStart, setPanStart] = useState({ x: 0, y: 0 });
+  const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
+  const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
 
   // Stream state
   const [streamError, setStreamError] = useState(false);
@@ -260,15 +262,26 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     }
   };
 
+  // Calculate max pan based on container size and zoom level
+  const getMaxPan = useCallback(() => {
+    if (!containerRef.current || !imgRef.current) {
+      return { x: 200, y: 150 };
+    }
+    const container = containerRef.current.getBoundingClientRect();
+    // Allow panning up to half the zoomed overflow in each direction
+    const maxX = (container.width * (zoomLevel - 1)) / 2;
+    const maxY = (container.height * (zoomLevel - 1)) / 2;
+    return { x: Math.max(50, maxX), y: Math.max(50, maxY) };
+  }, [zoomLevel]);
+
   const handleImageMouseMove = (e: React.MouseEvent) => {
     if (isPanning && zoomLevel > 1) {
       const newX = e.clientX - panStart.x;
       const newY = e.clientY - panStart.y;
-      // Limit panning based on zoom level
-      const maxPan = (zoomLevel - 1) * 150;
+      const maxPan = getMaxPan();
       setPanOffset({
-        x: Math.max(-maxPan, Math.min(maxPan, newX)),
-        y: Math.max(-maxPan, Math.min(maxPan, newY)),
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
       });
     }
   };
@@ -277,6 +290,91 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     setIsPanning(false);
   };
 
+  // Touch event handlers for mobile
+  const getTouchDistance = (touches: React.TouchList) => {
+    if (touches.length < 2) return 0;
+    const dx = touches[0].clientX - touches[1].clientX;
+    const dy = touches[0].clientY - touches[1].clientY;
+    return Math.sqrt(dx * dx + dy * dy);
+  };
+
+  const getTouchCenter = (touches: React.TouchList) => {
+    if (touches.length < 2) {
+      return { x: touches[0].clientX, y: touches[0].clientY };
+    }
+    return {
+      x: (touches[0].clientX + touches[1].clientX) / 2,
+      y: (touches[0].clientY + touches[1].clientY) / 2,
+    };
+  };
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    if (e.touches.length === 2) {
+      // Pinch gesture start
+      e.preventDefault();
+      setLastTouchDistance(getTouchDistance(e.touches));
+      setLastTouchCenter(getTouchCenter(e.touches));
+    } else if (e.touches.length === 1 && zoomLevel > 1) {
+      // Single touch pan start
+      e.preventDefault();
+      setIsPanning(true);
+      setPanStart({
+        x: e.touches[0].clientX - panOffset.x,
+        y: e.touches[0].clientY - panOffset.y,
+      });
+    }
+  };
+
+  const handleTouchMove = (e: React.TouchEvent) => {
+    if (e.touches.length === 2 && lastTouchDistance !== null) {
+      // Pinch gesture
+      e.preventDefault();
+      const newDistance = getTouchDistance(e.touches);
+      const scale = newDistance / lastTouchDistance;
+
+      setZoomLevel(prev => {
+        const newZoom = Math.max(1, Math.min(4, prev * scale));
+        if (newZoom === 1) {
+          setPanOffset({ x: 0, y: 0 });
+        }
+        return newZoom;
+      });
+
+      setLastTouchDistance(newDistance);
+
+      // Also handle pan during pinch
+      const newCenter = getTouchCenter(e.touches);
+      if (lastTouchCenter) {
+        const maxPan = getMaxPan();
+        setPanOffset(prev => ({
+          x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),
+          y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),
+        }));
+      }
+      setLastTouchCenter(newCenter);
+    } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {
+      // Single touch pan
+      e.preventDefault();
+      const newX = e.touches[0].clientX - panStart.x;
+      const newY = e.touches[0].clientY - panStart.y;
+      const maxPan = getMaxPan();
+      setPanOffset({
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
+      });
+    }
+  };
+
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (e.touches.length < 2) {
+      setLastTouchDistance(null);
+      setLastTouchCenter(null);
+    }
+    if (e.touches.length === 0) {
+      setIsPanning(false);
+    }
+  };
+
   const resetZoom = () => {
     setZoomLevel(1);
     setPanOffset({ x: 0, y: 0 });
@@ -435,6 +533,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           onMouseMove={handleImageMouseMove}
           onMouseUp={handleImageMouseUp}
           onMouseLeave={handleImageMouseUp}
+          onTouchStart={handleTouchStart}
+          onTouchMove={handleTouchMove}
+          onTouchEnd={handleTouchEnd}
+          style={{ touchAction: 'none' }}
         >
           {streamLoading && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">

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

@@ -231,7 +231,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                   borderRadius: '8px',
                 }}
                 labelStyle={{ color: '#fff' }}
-                formatter={(value: number) => [`${value.toFixed(0)}g`, 'Filament']}
+                formatter={(value) => [`${Number(value ?? 0).toFixed(0)}g`, 'Filament']}
               />
               <Area
                 type="monotone"
@@ -278,7 +278,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                       border: '1px solid #3d3d3d',
                       borderRadius: '8px',
                     }}
-                    formatter={(value: number) => [`${value}g`, 'Usage']}
+                    formatter={(value) => [`${value ?? 0}g`, 'Usage']}
                   />
                 </PieChart>
               </ResponsiveContainer>
@@ -320,8 +320,8 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
                   border: '1px solid #3d3d3d',
                   borderRadius: '8px',
                 }}
-                formatter={(value: number, name: string) => [
-                  name === 'filament' ? `${value}g` : name === 'cost' ? `${currency}${value.toFixed(2)}` : value,
+                formatter={(value, name) => [
+                  name === 'filament' ? `${value ?? 0}g` : name === 'cost' ? `${currency}${Number(value ?? 0).toFixed(2)}` : value ?? 0,
                   name === 'filament' ? 'Filament' : name === 'cost' ? 'Cost' : 'Prints'
                 ]}
               />

+ 147 - 33
frontend/src/components/FileManagerModal.tsx

@@ -16,6 +16,9 @@ import {
   Image,
   Search,
   ArrowUpDown,
+  CheckSquare,
+  Square,
+  MinusSquare,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { Button } from './Button';
@@ -82,10 +85,11 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const [currentPath, setCurrentPath] = useState('/');
-  const [selectedFile, setSelectedFile] = useState<string | null>(null);
+  const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
   const [searchQuery, setSearchQuery] = useState('');
-  const [fileToDelete, setFileToDelete] = useState<string | null>(null);
+  const [filesToDelete, setFilesToDelete] = useState<string[]>([]);
   const [sortBy, setSortBy] = useState<SortOption>('name-asc');
+  const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
 
   // Close on Escape key
   useEffect(() => {
@@ -108,11 +112,17 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   });
 
   const deleteMutation = useMutation({
-    mutationFn: (path: string) => api.deletePrinterFile(printerId, path),
-    onSuccess: (_, path) => {
-      showToast(`Deleted: ${path.split('/').pop()}`);
+    mutationFn: async (paths: string[]) => {
+      // Delete files one by one
+      for (const path of paths) {
+        await api.deletePrinterFile(printerId, path);
+      }
+    },
+    onSuccess: () => {
+      showToast(`Deleted ${filesToDelete.length} file${filesToDelete.length > 1 ? 's' : ''}`);
       queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
-      setSelectedFile(null);
+      setSelectedFiles(new Set());
+      setFilesToDelete([]);
     },
     onError: (error: Error) => {
       showToast(`Delete failed: ${error.message}`, 'error');
@@ -121,7 +131,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
 
   const navigateToFolder = (path: string) => {
     setCurrentPath(path);
-    setSelectedFile(null);
+    setSelectedFiles(new Set());
   };
 
   const navigateUp = () => {
@@ -129,15 +139,70 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
     const parts = currentPath.split('/').filter(Boolean);
     parts.pop();
     setCurrentPath(parts.length ? '/' + parts.join('/') : '/');
-    setSelectedFile(null);
+    setSelectedFiles(new Set());
+  };
+
+  const toggleFileSelection = (path: string, e: React.MouseEvent) => {
+    e.stopPropagation();
+    setSelectedFiles(prev => {
+      const next = new Set(prev);
+      if (next.has(path)) {
+        next.delete(path);
+      } else {
+        next.add(path);
+      }
+      return next;
+    });
+  };
+
+  const selectAllFiles = () => {
+    if (!data?.files) return;
+    const filePaths = data.files
+      .filter(f => !f.is_directory && (!searchQuery || f.name.toLowerCase().includes(searchQuery.toLowerCase())))
+      .map(f => f.path);
+    setSelectedFiles(new Set(filePaths));
   };
 
-  const handleDownload = (path: string) => {
-    window.open(api.getPrinterFileDownloadUrl(printerId, path), '_blank');
+  const deselectAllFiles = () => {
+    setSelectedFiles(new Set());
   };
 
-  const handleDelete = (path: string) => {
-    setFileToDelete(path);
+  const handleDownload = async () => {
+    if (selectedFiles.size === 0) return;
+
+    const paths = Array.from(selectedFiles);
+
+    if (paths.length === 1) {
+      // Single file - direct download
+      window.open(api.getPrinterFileDownloadUrl(printerId, paths[0]), '_blank');
+      setSelectedFiles(new Set());
+      return;
+    }
+
+    // Multiple files - download as ZIP
+    setDownloadProgress({ current: 0, total: paths.length });
+    try {
+      const blob = await api.downloadPrinterFilesAsZip(printerId, paths);
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = `${printerName.replace(/[^a-zA-Z0-9]/g, '_')}-files.zip`;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+      URL.revokeObjectURL(url);
+      showToast(`Downloaded ${paths.length} files as ZIP`);
+      setSelectedFiles(new Set());
+    } catch (error) {
+      showToast(`Download failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
+    } finally {
+      setDownloadProgress(null);
+    }
+  };
+
+  const handleDelete = () => {
+    if (selectedFiles.size === 0) return;
+    setFilesToDelete(Array.from(selectedFiles));
   };
 
   // Quick navigation buttons for common directories
@@ -303,7 +368,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                   })
                   .map((file) => {
                     const FileIcon = getFileIcon(file.name, file.is_directory);
-                    const isSelected = selectedFile === file.path;
+                    const isSelected = selectedFiles.has(file.path);
 
                     return (
                       <div
@@ -316,11 +381,22 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                         onClick={() => {
                           if (file.is_directory) {
                             navigateToFolder(file.path);
-                          } else {
-                            setSelectedFile(isSelected ? null : file.path);
                           }
                         }}
                       >
+                        {/* Checkbox for files only */}
+                        {!file.is_directory ? (
+                          <button
+                            onClick={(e) => toggleFileSelection(file.path, e)}
+                            className="flex-shrink-0 text-bambu-gray hover:text-white"
+                          >
+                            {isSelected ? (
+                              <CheckSquare className="w-5 h-5 text-bambu-green" />
+                            ) : (
+                              <Square className="w-5 h-5" />
+                            )}
+                          </button>
+                        ) : null}
                         <FileIcon
                           className={`w-5 h-5 flex-shrink-0 ${
                             file.is_directory ? 'text-bambu-green' : 'text-bambu-gray'
@@ -344,25 +420,60 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
 
         {/* Action bar */}
         <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
-          <div className="text-sm text-bambu-gray">
-            {searchQuery
-              ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`
-              : `${data?.files?.length || 0} items`
-            }
+          <div className="flex items-center gap-4">
+            <div className="text-sm text-bambu-gray">
+              {selectedFiles.size > 0
+                ? `${selectedFiles.size} selected`
+                : searchQuery
+                  ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`
+                  : `${data?.files?.length || 0} items`
+              }
+            </div>
+            {/* Select All / Deselect All */}
+            {data?.files?.some(f => !f.is_directory) && (
+              <div className="flex items-center gap-2">
+                {selectedFiles.size > 0 ? (
+                  <button
+                    onClick={deselectAllFiles}
+                    className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
+                  >
+                    <MinusSquare className="w-4 h-4" />
+                    Deselect All
+                  </button>
+                ) : (
+                  <button
+                    onClick={selectAllFiles}
+                    className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
+                  >
+                    <CheckSquare className="w-4 h-4" />
+                    Select All
+                  </button>
+                )}
+              </div>
+            )}
           </div>
           <div className="flex gap-2">
             <Button
               variant="secondary"
-              disabled={!selectedFile}
-              onClick={() => selectedFile && handleDownload(selectedFile)}
+              disabled={selectedFiles.size === 0 || downloadProgress !== null}
+              onClick={handleDownload}
             >
-              <Download className="w-4 h-4" />
-              Download
+              {downloadProgress ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {downloadProgress.current}/{downloadProgress.total}
+                </>
+              ) : (
+                <>
+                  <Download className="w-4 h-4" />
+                  Download{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
+                </>
+              )}
             </Button>
             <Button
               variant="secondary"
-              disabled={!selectedFile || deleteMutation.isPending}
-              onClick={() => selectedFile && handleDelete(selectedFile)}
+              disabled={selectedFiles.size === 0 || deleteMutation.isPending}
+              onClick={handleDelete}
               className="text-red-400 hover:text-red-300"
             >
               {deleteMutation.isPending ? (
@@ -370,24 +481,27 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               ) : (
                 <Trash2 className="w-4 h-4" />
               )}
-              Delete
+              Delete{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
             </Button>
           </div>
         </div>
       </div>
 
       {/* Delete Confirmation Modal */}
-      {fileToDelete && (
+      {filesToDelete.length > 0 && (
         <ConfirmModal
-          title="Delete File"
-          message={`Delete "${fileToDelete.split('/').pop()}"? This cannot be undone.`}
+          title={filesToDelete.length > 1 ? `Delete ${filesToDelete.length} Files` : 'Delete File'}
+          message={
+            filesToDelete.length > 1
+              ? `Delete ${filesToDelete.length} selected files? This cannot be undone.`
+              : `Delete "${filesToDelete[0].split('/').pop()}"? This cannot be undone.`
+          }
           confirmText="Delete"
           variant="danger"
           onConfirm={() => {
-            deleteMutation.mutate(fileToDelete);
-            setFileToDelete(null);
+            deleteMutation.mutate(filesToDelete);
           }}
-          onCancel={() => setFileToDelete(null)}
+          onCancel={() => setFilesToDelete([])}
         />
       )}
     </div>

+ 776 - 0
frontend/src/components/GitHubBackupSettings.tsx

@@ -0,0 +1,776 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  Github,
+  Play,
+  Clock,
+  CheckCircle,
+  XCircle,
+  Loader2,
+  ExternalLink,
+  RefreshCw,
+  Download,
+  Upload,
+  Database,
+  History,
+  SkipForward,
+  AlertTriangle,
+  Trash2,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type {
+  GitHubBackupConfig,
+  GitHubBackupConfigCreate,
+  GitHubBackupLog,
+  GitHubBackupStatus,
+  GitHubBackupTriggerResponse,
+  ScheduleType,
+  CloudAuthStatus,
+  Printer,
+} from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { BackupModal } from './BackupModal';
+import { RestoreModal } from './RestoreModal';
+import { useToast } from '../contexts/ToastContext';
+
+interface StatusBadgeProps {
+  status: string | null;
+}
+
+function StatusBadge({ status }: StatusBadgeProps) {
+  if (!status) return null;
+
+  const styles: Record<string, string> = {
+    success: 'bg-green-500/20 text-green-400',
+    failed: 'bg-red-500/20 text-red-400',
+    skipped: 'bg-yellow-500/20 text-yellow-400',
+    running: 'bg-blue-500/20 text-blue-400',
+  };
+
+  const icons: Record<string, React.ReactNode> = {
+    success: <CheckCircle className="w-3 h-3" />,
+    failed: <XCircle className="w-3 h-3" />,
+    skipped: <SkipForward className="w-3 h-3" />,
+    running: <Loader2 className="w-3 h-3 animate-spin" />,
+  };
+
+  return (
+    <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${styles[status] || 'bg-gray-500/20 text-gray-400'}`}>
+      {icons[status]}
+      {status.charAt(0).toUpperCase() + status.slice(1)}
+    </span>
+  );
+}
+
+function formatDateTime(dateStr: string | null): string {
+  if (!dateStr) return '-';
+  const date = new Date(dateStr);
+  return date.toLocaleString();
+}
+
+function formatRelativeTime(dateStr: string | null): string {
+  if (!dateStr) return '-';
+  const date = new Date(dateStr);
+  const now = new Date();
+  const diffMs = date.getTime() - now.getTime();
+  const diffMins = Math.round(diffMs / 60000);
+
+  if (diffMins < 0) {
+    const absMins = Math.abs(diffMins);
+    if (absMins < 60) return `${absMins}m ago`;
+    const hours = Math.floor(absMins / 60);
+    if (hours < 24) return `${hours}h ago`;
+    const days = Math.floor(hours / 24);
+    return `${days}d ago`;
+  } else {
+    if (diffMins < 60) return `in ${diffMins}m`;
+    const hours = Math.floor(diffMins / 60);
+    if (hours < 24) return `in ${hours}h`;
+    const days = Math.floor(hours / 24);
+    return `in ${days}d`;
+  }
+}
+
+export function GitHubBackupSettings() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  // Local state for form
+  const [repoUrl, setRepoUrl] = useState('');
+  const [accessToken, setAccessToken] = useState('');
+  const [branch, setBranch] = useState('main');
+  const [scheduleEnabled, setScheduleEnabled] = useState(false);
+  const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
+  const [backupKProfiles, setBackupKProfiles] = useState(true);
+  const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);
+  const [backupSettings, setBackupSettings] = useState(false);
+  const [enabled, setEnabled] = useState(true);
+
+  // Local backup modals
+  const [showBackupModal, setShowBackupModal] = useState(false);
+  const [showRestoreModal, setShowRestoreModal] = useState(false);
+
+  // Test connection state
+  const [testLoading, setTestLoading] = useState(false);
+  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+
+  // Auto-save debounce
+  const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const isInitializedRef = useRef(false);
+
+  // Queries
+  const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
+    queryKey: ['github-backup-config'],
+    queryFn: api.getGitHubBackupConfig,
+  });
+
+  const { data: status } = useQuery<GitHubBackupStatus>({
+    queryKey: ['github-backup-status'],
+    queryFn: api.getGitHubBackupStatus,
+    refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup
+  });
+
+  const { data: logs } = useQuery<GitHubBackupLog[]>({
+    queryKey: ['github-backup-logs'],
+    queryFn: () => api.getGitHubBackupLogs(20),
+  });
+
+  const { data: cloudStatus } = useQuery<CloudAuthStatus>({
+    queryKey: ['cloud-status'],
+    queryFn: api.getCloudStatus,
+  });
+
+  // Fetch printers and their statuses for K-profile availability
+  const { data: printers } = useQuery<Printer[]>({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Fetch printer statuses from API (not just cache) to get accurate connection status
+  const printerStatusQueries = useQueries({
+    queries: (printers ?? []).map(printer => ({
+      queryKey: ['printerStatus', printer.id],
+      queryFn: () => api.getPrinterStatus(printer.id),
+      staleTime: 10000, // Consider stale after 10s
+      refetchInterval: 30000, // Refresh every 30s
+    })),
+  });
+
+  const printerStatuses = (printers ?? []).map((printer, index) => ({
+    printer,
+    connected: printerStatusQueries[index]?.data?.connected ?? false,
+  }));
+
+  const totalPrinters = printerStatuses.length;
+  const connectedPrinters = printerStatuses.filter(p => p.connected).length;
+  const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0;
+  const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters;
+
+  // Initialize form from config
+  useEffect(() => {
+    if (config) {
+      setRepoUrl(config.repository_url);
+      setBranch(config.branch);
+      setScheduleEnabled(config.schedule_enabled);
+      setScheduleType(config.schedule_type);
+      setBackupKProfiles(config.backup_kprofiles);
+      setBackupCloudProfiles(config.backup_cloud_profiles);
+      setBackupSettings(config.backup_settings);
+      setEnabled(config.enabled);
+      setAccessToken(''); // Don't show stored token
+      // Mark as initialized after a tick to avoid auto-save on initial load
+      setTimeout(() => { isInitializedRef.current = true; }, 100);
+    }
+  }, [config]);
+
+  // Auto-save function for existing configs
+  const autoSave = useCallback(async (includeToken: boolean = false) => {
+    if (!config?.has_token) return; // Only auto-save if config already exists
+
+    try {
+      if (includeToken && accessToken) {
+        // Full save with new token
+        await api.saveGitHubBackupConfig({
+          repository_url: repoUrl,
+          access_token: accessToken,
+          branch,
+          schedule_enabled: scheduleEnabled,
+          schedule_type: scheduleType,
+          backup_kprofiles: backupKProfiles,
+          backup_cloud_profiles: backupCloudProfiles,
+          backup_settings: backupSettings,
+          enabled,
+        });
+        setAccessToken(''); // Clear after save
+        showToast('Token updated');
+      } else {
+        // Update without token
+        await api.updateGitHubBackupConfig({
+          repository_url: repoUrl,
+          branch,
+          schedule_enabled: scheduleEnabled,
+          schedule_type: scheduleType,
+          backup_kprofiles: backupKProfiles,
+          backup_cloud_profiles: backupCloudProfiles,
+          backup_settings: backupSettings,
+          enabled,
+        });
+        showToast('Settings saved');
+      }
+      queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
+      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
+    } catch (error) {
+      showToast(`Failed to save: ${(error as Error).message}`, 'error');
+    }
+  }, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, queryClient, showToast]);
+
+  // Auto-save effect for existing configs (debounced)
+  useEffect(() => {
+    if (!isInitializedRef.current || !config?.has_token) return;
+
+    if (autoSaveTimerRef.current) {
+      clearTimeout(autoSaveTimerRef.current);
+    }
+
+    autoSaveTimerRef.current = setTimeout(() => {
+      autoSave(false);
+    }, 500);
+
+    return () => {
+      if (autoSaveTimerRef.current) {
+        clearTimeout(autoSaveTimerRef.current);
+      }
+    };
+  }, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, autoSave, config?.has_token]);
+
+  // Auto-save token when it changes (with longer debounce)
+  useEffect(() => {
+    if (!isInitializedRef.current || !config?.has_token || !accessToken) return;
+
+    if (autoSaveTimerRef.current) {
+      clearTimeout(autoSaveTimerRef.current);
+    }
+
+    autoSaveTimerRef.current = setTimeout(() => {
+      autoSave(true);
+    }, 1000);
+
+    return () => {
+      if (autoSaveTimerRef.current) {
+        clearTimeout(autoSaveTimerRef.current);
+      }
+    };
+  }, [accessToken, autoSave, config?.has_token]);
+
+  // Mutations
+  const saveConfigMutation = useMutation({
+    mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
+      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
+      showToast('GitHub backup enabled');
+      setAccessToken('');
+      isInitializedRef.current = true;
+    },
+    onError: (error: Error) => {
+      showToast(`Failed to save: ${error.message}`, 'error');
+    },
+  });
+
+  const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({
+    mutationFn: api.triggerGitHubBackup,
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
+      queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
+      if (result.success) {
+        if (result.files_changed > 0) {
+          showToast(`Backup complete - ${result.files_changed} files updated`);
+        } else {
+          showToast('Backup skipped - no changes');
+        }
+      } else {
+        showToast(`Backup failed: ${result.message}`, 'error');
+      }
+    },
+    onError: (error: Error) => {
+      showToast(`Backup failed: ${error.message}`, 'error');
+    },
+  });
+
+  const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
+    mutationFn: () => api.clearGitHubBackupLogs(0),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
+      showToast(`Cleared ${result.deleted} logs`);
+    },
+    onError: (error: Error) => {
+      showToast(`Failed to clear logs: ${error.message}`, 'error');
+    },
+  });
+
+  const handleTestConnection = async () => {
+    setTestLoading(true);
+    setTestResult(null);
+    try {
+      let result;
+      // If user entered a new token, test with those credentials
+      if (accessToken) {
+        if (!repoUrl) {
+          showToast('Enter repository URL', 'error');
+          setTestLoading(false);
+          return;
+        }
+        result = await api.testGitHubConnection(repoUrl, accessToken);
+      } else if (config?.has_token) {
+        // Use stored credentials
+        result = await api.testGitHubStoredConnection();
+      } else {
+        showToast('Enter repository URL and access token', 'error');
+        setTestLoading(false);
+        return;
+      }
+      setTestResult({ success: result.success, message: result.message });
+    } catch (error) {
+      setTestResult({ success: false, message: (error as Error).message });
+    } finally {
+      setTestLoading(false);
+    }
+  };
+
+  // Initial setup save (only for new configs)
+  const handleInitialSetup = () => {
+    if (!repoUrl) {
+      showToast('Repository URL is required', 'error');
+      return;
+    }
+    if (!accessToken) {
+      showToast('Access token is required', 'error');
+      return;
+    }
+
+    saveConfigMutation.mutate({
+      repository_url: repoUrl,
+      access_token: accessToken,
+      branch,
+      schedule_enabled: scheduleEnabled,
+      schedule_type: scheduleType,
+      backup_kprofiles: backupKProfiles,
+      backup_cloud_profiles: backupCloudProfiles,
+      backup_settings: backupSettings,
+      enabled,
+    });
+  };
+
+  if (configLoading) {
+    return (
+      <div className="flex items-center justify-center py-12">
+        <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+      {/* Left Column - GitHub Backup */}
+      <div className="space-y-6">
+        <Card>
+          <CardHeader>
+            <div className="flex items-center justify-between">
+              <div className="flex items-center gap-2">
+                <Github className="w-5 h-5 text-gray-400" />
+                <h2 className="text-lg font-semibold text-white">GitHub Backup</h2>
+              </div>
+              {config && cloudStatus?.is_authenticated && (
+                <div className="flex items-center gap-2">
+                  <span className="text-sm text-bambu-gray">Enabled</span>
+                  <Toggle
+                    checked={enabled}
+                    onChange={setEnabled}
+                  />
+                </div>
+              )}
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            {/* Bambu Cloud required message */}
+            {!cloudStatus?.is_authenticated ? (
+              <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
+                <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
+                <p className="text-sm text-yellow-400">
+                  Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.
+                </p>
+              </div>
+            ) : (
+              <>
+                <p className="text-sm text-bambu-gray">
+                  Automatically sync your profiles to a private GitHub repository for backup and version history.
+                </p>
+
+                {/* Repository URL */}
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    Repository URL
+                  </label>
+                  <input
+                    type="text"
+                    value={repoUrl}
+                    onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
+                    placeholder="https://github.com/username/bambuddy-backup"
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                </div>
+
+                {/* Access Token */}
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    Personal Access Token {config?.has_token && <span className="text-green-400">(saved)</span>}
+                  </label>
+                  <input
+                    type="password"
+                    value={accessToken}
+                    onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
+                    placeholder={config?.has_token ? 'Enter new token to update' : 'ghp_xxxxxxxxxxxx'}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    Fine-grained token with Contents read/write permission
+                  </p>
+                </div>
+
+            {/* Branch - inline with schedule */}
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">Branch</label>
+                <input
+                  type="text"
+                  value={branch}
+                  onChange={(e) => setBranch(e.target.value)}
+                  placeholder="main"
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                />
+              </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">Auto-backup</label>
+                <select
+                  value={scheduleEnabled ? scheduleType : 'disabled'}
+                  onChange={(e) => {
+                    if (e.target.value === 'disabled') {
+                      setScheduleEnabled(false);
+                    } else {
+                      setScheduleEnabled(true);
+                      setScheduleType(e.target.value as ScheduleType);
+                    }
+                  }}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="disabled">Manual only</option>
+                  <option value="hourly">Hourly</option>
+                  <option value="daily">Daily</option>
+                  <option value="weekly">Weekly</option>
+                </select>
+              </div>
+            </div>
+
+            {/* What to backup */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-2">Include in backup</label>
+              <div className="space-y-2">
+                <label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
+                  <input
+                    type="checkbox"
+                    checked={backupKProfiles}
+                    onChange={(e) => setBackupKProfiles(e.target.checked)}
+                    className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    disabled={noPrintersConnected}
+                  />
+                  <div className="flex-1">
+                    <div className="flex items-center gap-2">
+                      <span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>K-Profiles</span>
+                      {noPrintersConnected && (
+                        <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
+                          <AlertTriangle className="w-3 h-3" />
+                          No printers connected
+                        </span>
+                      )}
+                      {somePrintersDisconnected && (
+                        <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
+                          <AlertTriangle className="w-3 h-3" />
+                          {connectedPrinters}/{totalPrinters} connected
+                        </span>
+                      )}
+                    </div>
+                    <p className="text-xs text-bambu-gray">Pressure advance calibration from connected printers</p>
+                  </div>
+                </label>
+                <label className="flex items-start gap-2 cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={backupCloudProfiles}
+                    onChange={(e) => setBackupCloudProfiles(e.target.checked)}
+                    className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    disabled={!cloudStatus?.is_authenticated}
+                  />
+                  <div>
+                    <span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>Cloud Profiles</span>
+                    <p className="text-xs text-bambu-gray">Filament, printer, and process presets from Bambu Cloud</p>
+                  </div>
+                </label>
+                <label className="flex items-start gap-2 cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={backupSettings}
+                    onChange={(e) => setBackupSettings(e.target.checked)}
+                    className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                  />
+                  <div>
+                    <span className="text-white text-sm">App Settings</span>
+                    <p className="text-xs text-bambu-gray">Bambuddy configuration (excludes sensitive data)</p>
+                  </div>
+                </label>
+              </div>
+            </div>
+
+            {/* Test + Status + Actions */}
+            <div className="border-t border-bambu-dark-tertiary pt-4 space-y-3">
+              {/* Status line */}
+              {status?.configured && (
+                <div className="flex items-center justify-between text-sm">
+                  <div className="flex items-center gap-2 text-bambu-gray">
+                    {status.last_backup_at ? (
+                      <>
+                        <span>Last backup: {formatRelativeTime(status.last_backup_at)}</span>
+                        <StatusBadge status={status.last_backup_status} />
+                      </>
+                    ) : (
+                      <span>No backups yet</span>
+                    )}
+                  </div>
+                  {status.next_scheduled_run && (
+                    <span className="text-bambu-gray">
+                      <Clock className="w-3 h-3 inline mr-1" />
+                      Next: {formatRelativeTime(status.next_scheduled_run)}
+                    </span>
+                  )}
+                </div>
+              )}
+
+              {/* Test result */}
+              {testResult && (
+                <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
+                  {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
+                  {testResult.message}
+                </div>
+              )}
+
+              {/* Action buttons */}
+              <div className="flex flex-wrap items-center gap-2">
+                {status?.configured ? (
+                  <>
+                    {(triggerBackupMutation.isPending || status.is_running) ? (
+                      <div className="flex items-center gap-2 text-bambu-green">
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                        <span className="text-sm">{status.progress || 'Starting backup...'}</span>
+                      </div>
+                    ) : (
+                      <>
+                        <Button
+                          variant="primary"
+                          size="sm"
+                          onClick={() => triggerBackupMutation.mutate()}
+                          disabled={!config?.enabled}
+                        >
+                          <Play className="w-4 h-4" />
+                          Backup Now
+                        </Button>
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={handleTestConnection}
+                          disabled={testLoading}
+                        >
+                          {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
+                          Test
+                        </Button>
+                      </>
+                    )}
+                  </>
+                ) : (
+                  <>
+                    <Button
+                      variant="primary"
+                      size="sm"
+                      onClick={handleInitialSetup}
+                      disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
+                    >
+                      {saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
+                      Enable Backup
+                    </Button>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={handleTestConnection}
+                      disabled={testLoading || !repoUrl || !accessToken}
+                    >
+                      {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
+                      Test Connection
+                    </Button>
+                  </>
+                )}
+              </div>
+            </div>
+              </>
+            )}
+          </CardContent>
+        </Card>
+
+        {/* Backup History - only show if configured and has logs */}
+        {logs && logs.length > 0 && (
+          <Card>
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <History className="w-5 h-5 text-gray-400" />
+                  <h2 className="text-lg font-semibold text-white">History</h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => clearLogsMutation.mutate()}
+                  disabled={clearLogsMutation.isPending}
+                >
+                  <Trash2 className="w-4 h-4" />
+                  Clear
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="overflow-x-auto">
+                <table className="w-full text-sm">
+                  <thead>
+                    <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
+                      <th className="text-left py-2 px-2">Date</th>
+                      <th className="text-left py-2 px-2">Status</th>
+                      <th className="text-left py-2 px-2">Commit</th>
+                    </tr>
+                  </thead>
+                  <tbody>
+                    {logs.slice(0, 10).map((log) => (
+                      <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
+                        <td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
+                        <td className="py-2 px-2"><StatusBadge status={log.status} /></td>
+                        <td className="py-2 px-2">
+                          {log.commit_sha ? (
+                            <a
+                              href={`${config?.repository_url}/commit/${log.commit_sha}`}
+                              target="_blank"
+                              rel="noopener noreferrer"
+                              className="text-bambu-green hover:underline inline-flex items-center gap-1"
+                            >
+                              {log.commit_sha.substring(0, 7)}
+                              <ExternalLink className="w-3 h-3" />
+                            </a>
+                          ) : (
+                            <span className="text-bambu-gray">-</span>
+                          )}
+                        </td>
+                      </tr>
+                    ))}
+                  </tbody>
+                </table>
+              </div>
+            </CardContent>
+          </Card>
+        )}
+      </div>
+
+      {/* Right Column - Local Backup */}
+      <div className="space-y-6">
+        <Card>
+          <CardHeader>
+            <div className="flex items-center gap-2">
+              <Database className="w-5 h-5 text-gray-400" />
+              <h2 className="text-lg font-semibold text-white">Local Backup</h2>
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <p className="text-sm text-bambu-gray">
+              Export or import your Bambuddy data as a local file for manual backup or migration.
+            </p>
+
+            <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
+              <div>
+                <p className="text-white">Export Data</p>
+                <p className="text-sm text-bambu-gray">
+                  Download all settings, printers, and profiles
+                </p>
+              </div>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => setShowBackupModal(true)}
+              >
+                <Download className="w-4 h-4" />
+                Export
+              </Button>
+            </div>
+
+            <div className="flex items-center justify-between py-3">
+              <div>
+                <p className="text-white">Import Backup</p>
+                <p className="text-sm text-bambu-gray">
+                  Restore from a previous export file
+                </p>
+              </div>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => setShowRestoreModal(true)}
+              >
+                <Upload className="w-4 h-4" />
+                Import
+              </Button>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* Modals */}
+      {showBackupModal && (
+        <BackupModal
+          onClose={() => setShowBackupModal(false)}
+          onExport={async (categories) => {
+            setShowBackupModal(false);
+            try {
+              const { blob, filename } = await api.exportBackup(categories);
+              const url = URL.createObjectURL(blob);
+              const a = document.createElement('a');
+              a.href = url;
+              a.download = filename;
+              a.click();
+              URL.revokeObjectURL(url);
+              showToast('Backup downloaded successfully');
+            } catch {
+              showToast('Failed to create backup', 'error');
+            }
+          }}
+        />
+      )}
+
+      {showRestoreModal && (
+        <RestoreModal
+          onClose={() => setShowRestoreModal(false)}
+          onRestore={async (file, overwrite) => {
+            return await api.importBackup(file, overwrite);
+          }}
+          onSuccess={() => {
+            setShowRestoreModal(false);
+            showToast('Backup restored successfully');
+            queryClient.invalidateQueries();
+          }}
+        />
+      )}
+    </div>
+  );
+}

+ 50 - 0
frontend/src/components/Layout.tsx

@@ -84,6 +84,11 @@ export function Layout() {
   const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
     sessionStorage.getItem('dismissedUpdateVersion')
   );
+  const [plateDetectionAlert, setPlateDetectionAlert] = useState<{
+    printer_id: number;
+    printer_name: string;
+    message: string;
+  } | null>(null);
 
   // Check for updates
   const { data: versionInfo } = useQuery({
@@ -300,6 +305,20 @@ export function Layout() {
     }
   }, [location.pathname, isMobile]);
 
+  // Listen for plate detection warnings (objects on plate, print paused)
+  useEffect(() => {
+    const handlePlateNotEmpty = (event: Event) => {
+      const detail = (event as CustomEvent).detail;
+      setPlateDetectionAlert({
+        printer_id: detail.printer_id,
+        printer_name: detail.printer_name,
+        message: detail.message,
+      });
+    };
+    window.addEventListener('plate-not-empty', handlePlateNotEmpty);
+    return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
+  }, []);
+
   // Global keyboard shortcuts for navigation
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
     const target = e.target as HTMLElement;
@@ -750,6 +769,37 @@ export function Layout() {
           }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]}
         />
       )}
+
+      {/* Plate Detection Alert Modal */}
+      {plateDetectionAlert && (
+        <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[100] p-4">
+          <div className="bg-bambu-dark-secondary border-2 border-yellow-500 rounded-xl shadow-2xl max-w-md w-full animate-in fade-in zoom-in duration-200">
+            <div className="p-6 text-center">
+              <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center">
+                <svg className="w-10 h-10 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+                </svg>
+              </div>
+              <h2 className="text-xl font-bold text-yellow-400 mb-2">
+                Print Paused!
+              </h2>
+              <p className="text-lg text-white mb-2">
+                {plateDetectionAlert.printer_name}
+              </p>
+              <p className="text-bambu-gray mb-6">
+                Objects detected on build plate. The print has been automatically paused.
+                Please clear the plate and resume the print.
+              </p>
+              <button
+                onClick={() => setPlateDetectionAlert(null)}
+                className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
+              >
+                I Understand
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 16 - 2
frontend/src/components/NotificationProviderCard.tsx

@@ -91,13 +91,13 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {/* Quick enable/disable toggle + Status indicator */}
             <div className="flex items-center gap-3">
               {provider.last_success && (
-                <span className="text-xs text-bambu-green hidden sm:inline">Last: {formatDateOnly(provider.last_success)}</span>
+                <span className="text-xs text-status-ok hidden sm:inline">Last: {formatDateOnly(provider.last_success)}</span>
               )}
               {/* Only show error if it's more recent than last success */}
               {provider.last_error && provider.last_error_at && (
                 !provider.last_success || (parseUTCDate(provider.last_error_at)?.getTime() || 0) > (parseUTCDate(provider.last_success)?.getTime() || 0)
               ) && (
-                <span className="text-xs text-red-400" title={provider.last_error}>Error</span>
+                <span className="text-xs text-status-error" title={provider.last_error}>Error</span>
               )}
               <Toggle
                 checked={provider.enabled}
@@ -124,6 +124,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_print_start && (
               <span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">Start</span>
             )}
+            {provider.on_plate_not_empty && (
+              <span className="px-2 py-0.5 bg-rose-600/20 text-rose-300 text-xs rounded">Plate Check</span>
+            )}
             {provider.on_print_complete && (
               <span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">Complete</span>
             )}
@@ -254,6 +257,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Plate Not Empty</p>
+                    <p className="text-xs text-bambu-gray">Objects detected before print</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_plate_not_empty ?? true}
+                    onChange={(checked) => updateMutation.mutate({ on_plate_not_empty: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Completed</p>
                   <Toggle

+ 3 - 3
frontend/src/components/SmartPlugCard.tsx

@@ -136,11 +136,11 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
               ) : isReachable ? (
                 <div className="flex items-center gap-1 text-sm">
-                  <Wifi className="w-4 h-4 text-bambu-green" />
-                  <span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                  <Wifi className="w-4 h-4 text-status-ok" />
+                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
                 </div>
               ) : (
-                <div className="flex items-center gap-1 text-sm text-red-400">
+                <div className="flex items-center gap-1 text-sm text-status-error">
                   <WifiOff className="w-4 h-4" />
                   <span>Offline</span>
                 </div>

+ 4 - 4
frontend/src/components/SwitchbarPopover.tsx

@@ -53,8 +53,8 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
                 <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
               ) : isReachable ? (
                 <>
-                  <Wifi className="w-3 h-3 text-bambu-green" />
-                  <span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                  <Wifi className="w-3 h-3 text-status-ok" />
+                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
                   {status?.energy?.power !== null && status?.energy?.power !== undefined && (
                     <>
                       <span className="text-bambu-gray mx-1">|</span>
@@ -65,8 +65,8 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
                 </>
               ) : (
                 <>
-                  <WifiOff className="w-3 h-3 text-red-400" />
-                  <span className="text-red-400">Offline</span>
+                  <WifiOff className="w-3 h-3 text-status-error" />
+                  <span className="text-status-error">Offline</span>
                 </>
               )}
             </div>

+ 19 - 5
frontend/src/contexts/AuthContext.tsx

@@ -21,10 +21,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   const [requiresSetup, setRequiresSetup] = useState(false);
   const [loading, setLoading] = useState(true);
   const hasRedirectedRef = useRef(false);
+  const mountedRef = useRef(true);
 
   const checkAuthStatus = async () => {
     try {
       const status = await api.getAuthStatus();
+      if (!mountedRef.current) return;
       setAuthEnabled(status.auth_enabled);
       setRequiresSetup(status.requires_setup);
 
@@ -33,10 +35,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         if (token) {
           try {
             const currentUser = await api.getCurrentUser();
+            if (!mountedRef.current) return;
             setUser(currentUser);
           } catch {
             // Token invalid, clear it
             setAuthToken(null);
+            if (!mountedRef.current) return;
             setUser(null);
           }
         } else {
@@ -46,18 +50,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         // Auth not enabled, allow access
         setUser(null);
       }
-    } catch (error) {
-      console.error('Failed to check auth status:', error);
+    } catch {
+      if (!mountedRef.current) return;
       setAuthEnabled(false);
       setUser(null);
     } finally {
-      setLoading(false);
+      if (mountedRef.current) {
+        setLoading(false);
+      }
     }
   };
 
   useEffect(() => {
+    mountedRef.current = true;
     // Check auth status on mount
     checkAuthStatus();
+    return () => {
+      mountedRef.current = false;
+    };
   }, []);
 
   // Separate effect to handle redirect only when setup is required
@@ -96,10 +106,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     if (authEnabled && getAuthToken()) {
       try {
         const currentUser = await api.getCurrentUser();
-        setUser(currentUser);
+        if (mountedRef.current) {
+          setUser(currentUser);
+        }
       } catch {
         setAuthToken(null);
-        setUser(null);
+        if (mountedRef.current) {
+          setUser(null);
+        }
       }
     }
   };

+ 12 - 0
frontend/src/hooks/useWebSocket.ts

@@ -214,6 +214,18 @@ export function useWebSocket() {
       case 'pong':
         // Keepalive response, ignore
         break;
+
+      case 'plate_not_empty':
+        // Plate detection found objects - print was paused
+        // Dispatch event for toast notification
+        window.dispatchEvent(new CustomEvent('plate-not-empty', {
+          detail: {
+            printer_id: message.printer_id,
+            printer_name: (message as unknown as { printer_name?: string }).printer_name,
+            message: (message as unknown as { message?: string }).message,
+          }
+        }));
+        break;
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
 

+ 0 - 15
frontend/src/i18n/locales/de.ts

@@ -244,21 +244,6 @@ export default {
     latestVersion: 'Neueste Version',
     upToDate: 'Sie sind auf dem neuesten Stand',
     updateAvailable: 'Update verfügbar',
-    telemetry: 'Anonyme Telemetrie',
-    telemetryDescription: 'Helfen Sie BamBuddy zu verbessern, indem Sie anonyme Nutzungsdaten senden',
-    telemetryLearnMore: 'Mehr erfahren',
-    telemetryInfoTitle: 'Welche Daten werden gesammelt?',
-    telemetryInfoIntro: 'BamBuddy sammelt minimale anonyme Daten, um zu verstehen, wie viele Personen die App nutzen und welche Versionen verwendet werden. Dies hilft bei der Priorisierung von Fehlerbehebungen und neuen Funktionen.',
-    telemetryInfoCollected: 'Was wir sammeln:',
-    telemetryInfoItem1: 'Eine zufällige Installations-ID (nicht mit Ihnen oder Ihrer Hardware verknüpft)',
-    telemetryInfoItem2: 'Die App-Version, die Sie verwenden',
-    telemetryInfoItem3: 'Ein Zeitstempel (um tägliche/wöchentliche aktive Nutzer zu zählen)',
-    telemetryInfoNotCollected: 'Was wir NICHT sammeln:',
-    telemetryInfoNotItem1: 'IP-Adressen oder Standortdaten',
-    telemetryInfoNotItem2: 'Druckernamen, Seriennummern oder Druckerdaten',
-    telemetryInfoNotItem3: 'Druckverlauf, Dateinamen oder persönliche Inhalte',
-    telemetryInfoNotItem4: 'Informationen, die Sie identifizieren könnten',
-    telemetryInfoFooter: 'Sie können die Telemetrie jederzeit deaktivieren. Die Installations-ID wird zufällig generiert und kann nicht zu Ihnen zurückverfolgt werden.',
     // Notifications
     notificationLanguage: 'Benachrichtigungssprache',
     notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',

+ 0 - 16
frontend/src/i18n/locales/en.ts

@@ -244,22 +244,6 @@ export default {
     latestVersion: 'Latest Version',
     upToDate: 'You are up to date',
     updateAvailable: 'Update available',
-    telemetry: 'Anonymous telemetry',
-    telemetryDescription: 'Help improve BamBuddy by sending anonymous usage data',
-    telemetryLearnMore: 'Learn more',
-    telemetryInfoTitle: 'What data is collected?',
-    telemetryInfoIntro: 'BamBuddy collects minimal anonymous data to help understand how many people use the app, which versions and printer models are in use. This helps prioritize bug fixes and new features.',
-    telemetryInfoCollected: 'What we collect:',
-    telemetryInfoItem1: 'A random installation ID (not linked to you or your hardware)',
-    telemetryInfoItem2: 'The app version you\'re running',
-    telemetryInfoItem3: 'Printer model types (e.g., X1C, P1S) - not names or serial numbers',
-    telemetryInfoItem4: 'A timestamp (to count daily/weekly active users)',
-    telemetryInfoNotCollected: 'What we do NOT collect:',
-    telemetryInfoNotItem1: 'Your IP address is hashed and cannot be reversed',
-    telemetryInfoNotItem2: 'Printer names, serial numbers, or access codes',
-    telemetryInfoNotItem3: 'Print history, filenames, or any personal content',
-    telemetryInfoNotItem4: 'Any information that could identify you',
-    telemetryInfoFooter: 'You can disable telemetry at any time. The installation ID is randomly generated and cannot be traced back to you.',
     // Notifications
     notificationLanguage: 'Notification Language',
     notificationLanguageDescription: 'Language for push notifications',

+ 10 - 0
frontend/src/index.css

@@ -10,6 +10,11 @@
   --color-bambu-green-light: var(--accent-light);
   --color-bambu-green-dark: var(--accent-dark);
 
+  /* Semantic status colors - fixed, don't change with accent */
+  --color-status-ok: var(--status-ok);
+  --color-status-error: var(--status-error);
+  --color-status-warning: var(--status-warning);
+
   /* Theme-aware colors via CSS variables */
   --color-bambu-dark: var(--bg-primary);
   --color-bambu-dark-secondary: var(--bg-secondary);
@@ -29,6 +34,11 @@
   --accent-light: #00c64d;
   --accent-dark: #009438;
 
+  /* Semantic status colors - these never change with accent theme */
+  --status-ok: #22c55e;      /* green-500 - always green for success/online/ok */
+  --status-error: #ef4444;   /* red-500 - always red for error/offline/failed */
+  --status-warning: #f59e0b; /* amber-500 - always amber for warnings */
+
   /* Default light mode background (neutral) */
   --bg-primary: #f5f5f5;
   --bg-secondary: #ffffff;

+ 113 - 17
frontend/src/pages/ArchivesPage.tsx

@@ -41,6 +41,8 @@ import {
   GitCompare,
   Loader2,
   FolderKanban,
+  ChevronLeft,
+  ChevronRight,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
@@ -133,9 +135,23 @@ function ArchiveCard({
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+  const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
+  const [showPlateNav, setShowPlateNav] = useState(false);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
 
+  // Fetch plates data for multi-plate browsing (lazy - only when hovering)
+  const { data: platesData } = useQuery({
+    queryKey: ['archive-plates', archive.id],
+    queryFn: () => api.getArchivePlates(archive.id),
+    enabled: showPlateNav, // Only fetch when user hovers to see navigation
+    staleTime: 5 * 60 * 1000, // Cache for 5 minutes
+  });
+
+  const plates = platesData?.plates ?? [];
+  const isMultiPlate = platesData?.is_multi_plate ?? false;
+  const displayPlateIndex = currentPlateIndex ?? 0;
+
   const source3mfUploadMutation = useMutation({
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
     onSuccess: (data) => {
@@ -293,10 +309,13 @@ function ArchiveCard({
       },
     ]),
     {
-      label: 'View on MakerWorld',
+      label: archive.external_url ? 'External Link' : 'View on MakerWorld',
       icon: <Globe className="w-4 h-4" />,
-      onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
-      disabled: !archive.makerworld_url,
+      onClick: () => {
+        const url = archive.external_url || archive.makerworld_url;
+        if (url) window.open(url, '_blank');
+      },
+      disabled: !archive.external_url && !archive.makerworld_url,
     },
     { label: '', divider: true, onClick: () => {} },
     {
@@ -502,11 +521,19 @@ function ArchiveCard({
         </button>
       )}
 
-      {/* Thumbnail */}
-      <div className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl">
+      {/* Thumbnail with plate navigation */}
+      <div
+        className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl"
+        onMouseEnter={() => setShowPlateNav(true)}
+        onMouseLeave={() => setShowPlateNav(false)}
+      >
         {archive.thumbnail_path ? (
           <img
-            src={api.getArchiveThumbnail(archive.id)}
+            src={
+              currentPlateIndex !== null && plates.length > 0
+                ? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0)
+                : api.getArchiveThumbnail(archive.id)
+            }
             alt={archive.print_name || archive.filename}
             className="w-full h-full object-cover"
           />
@@ -515,6 +542,63 @@ function ArchiveCard({
             <Image className="w-12 h-12 text-bambu-dark-tertiary" />
           </div>
         )}
+        {/* Plate navigation - only show for multi-plate archives */}
+        {isMultiPlate && plates.length > 1 && (
+          <>
+            {/* Left arrow */}
+            <button
+              className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
+                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+              }`}
+              onClick={(e) => {
+                e.stopPropagation();
+                setCurrentPlateIndex((prev) => {
+                  const current = prev ?? 0;
+                  return current > 0 ? current - 1 : plates.length - 1;
+                });
+              }}
+              title="Previous plate"
+            >
+              <ChevronLeft className="w-4 h-4 text-white" />
+            </button>
+            {/* Right arrow */}
+            <button
+              className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
+                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+              }`}
+              onClick={(e) => {
+                e.stopPropagation();
+                setCurrentPlateIndex((prev) => {
+                  const current = prev ?? 0;
+                  return current < plates.length - 1 ? current + 1 : 0;
+                });
+              }}
+              title="Next plate"
+            >
+              <ChevronRight className="w-4 h-4 text-white" />
+            </button>
+            {/* Dots indicator */}
+            <div
+              className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${
+                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+              }`}
+            >
+              {plates.map((plate, idx) => (
+                <button
+                  key={plate.index}
+                  className={`w-2 h-2 rounded-full transition-colors ${
+                    idx === displayPlateIndex ? 'bg-bambu-green' : 'bg-white/50 hover:bg-white/80'
+                  }`}
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setCurrentPlateIndex(idx);
+                  }}
+                  title={plate.name || `Plate ${plate.index}`}
+                />
+              ))}
+            </div>
+          </>
+        )}
         {/* Context menu button - visible on mobile, shows on hover for desktop */}
         <button
           className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${
@@ -543,7 +627,7 @@ function ArchiveCard({
           />
         </button>
         {(archive.status === 'failed' || archive.status === 'aborted') && (
-          <div className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-red-500/80 text-white">
+          <div className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-status-error/80 text-white">
             {archive.status === 'aborted' ? 'cancelled' : 'failed'}
           </div>
         )}
@@ -812,11 +896,20 @@ function ArchiveCard({
             variant="secondary"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
-            onClick={() => archive.makerworld_url && window.open(archive.makerworld_url, '_blank')}
-            disabled={!archive.makerworld_url}
-            title={archive.makerworld_url ? `MakerWorld: ${archive.designer || 'View project'}` : 'Not from MakerWorld'}
+            onClick={() => {
+              const url = archive.external_url || archive.makerworld_url;
+              if (url) window.open(url, '_blank');
+            }}
+            disabled={!archive.external_url && !archive.makerworld_url}
+            title={
+              archive.external_url
+                ? 'External Link'
+                : archive.makerworld_url
+                  ? `MakerWorld: ${archive.designer || 'View project'}`
+                  : 'No external link'
+            }
           >
-            <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.makerworld_url ? 'opacity-20' : ''}`} />
+            <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />
           </Button>
           <Button
             variant="secondary"
@@ -1283,10 +1376,13 @@ function ArchiveListRow({
       },
     ]),
     {
-      label: 'View on MakerWorld',
+      label: archive.external_url ? 'External Link' : 'View on MakerWorld',
       icon: <Globe className="w-4 h-4" />,
-      onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
-      disabled: !archive.makerworld_url,
+      onClick: () => {
+        const url = archive.external_url || archive.makerworld_url;
+        if (url) window.open(url, '_blank');
+      },
+      disabled: !archive.external_url && !archive.makerworld_url,
     },
     { label: '', divider: true, onClick: () => {} },
     {
@@ -1556,12 +1652,12 @@ function ArchiveListRow({
           >
             <ExternalLink className="w-4 h-4" />
           </Button>
-          {archive.makerworld_url && (
+          {(archive.external_url || archive.makerworld_url) && (
             <Button
               variant="ghost"
               size="sm"
-              onClick={() => window.open(archive.makerworld_url!, '_blank')}
-              title="MakerWorld"
+              onClick={() => window.open((archive.external_url || archive.makerworld_url)!, '_blank')}
+              title={archive.external_url ? 'External Link' : 'MakerWorld'}
             >
               <Globe className="w-4 h-4" />
             </Button>

+ 106 - 4
frontend/src/pages/CameraPage.tsx

@@ -26,6 +26,8 @@ export function CameraPage() {
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [isPanning, setIsPanning] = useState(false);
   const [panStart, setPanStart] = useState({ x: 0, y: 0 });
+  const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
+  const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
   const imgRef = useRef<HTMLImageElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -408,15 +410,26 @@ export function CameraPage() {
     }
   };
 
+  // Calculate max pan based on container size and zoom level
+  const getMaxPan = useCallback(() => {
+    if (!containerRef.current) {
+      return { x: 300, y: 200 };
+    }
+    const container = containerRef.current.getBoundingClientRect();
+    // Allow panning up to half the zoomed overflow in each direction
+    const maxX = (container.width * (zoomLevel - 1)) / 2;
+    const maxY = (container.height * (zoomLevel - 1)) / 2;
+    return { x: Math.max(50, maxX), y: Math.max(50, maxY) };
+  }, [zoomLevel]);
+
   const handleImageMouseMove = (e: React.MouseEvent) => {
     if (isPanning && zoomLevel > 1) {
       const newX = e.clientX - panStart.x;
       const newY = e.clientY - panStart.y;
-      // Limit panning based on zoom level
-      const maxPan = (zoomLevel - 1) * 200;
+      const maxPan = getMaxPan();
       setPanOffset({
-        x: Math.max(-maxPan, Math.min(maxPan, newX)),
-        y: Math.max(-maxPan, Math.min(maxPan, newY)),
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
       });
     }
   };
@@ -425,6 +438,91 @@ export function CameraPage() {
     setIsPanning(false);
   };
 
+  // Touch event handlers for mobile
+  const getTouchDistance = (touches: React.TouchList) => {
+    if (touches.length < 2) return 0;
+    const dx = touches[0].clientX - touches[1].clientX;
+    const dy = touches[0].clientY - touches[1].clientY;
+    return Math.sqrt(dx * dx + dy * dy);
+  };
+
+  const getTouchCenter = (touches: React.TouchList) => {
+    if (touches.length < 2) {
+      return { x: touches[0].clientX, y: touches[0].clientY };
+    }
+    return {
+      x: (touches[0].clientX + touches[1].clientX) / 2,
+      y: (touches[0].clientY + touches[1].clientY) / 2,
+    };
+  };
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    if (e.touches.length === 2) {
+      // Pinch gesture start
+      e.preventDefault();
+      setLastTouchDistance(getTouchDistance(e.touches));
+      setLastTouchCenter(getTouchCenter(e.touches));
+    } else if (e.touches.length === 1 && zoomLevel > 1) {
+      // Single touch pan start
+      e.preventDefault();
+      setIsPanning(true);
+      setPanStart({
+        x: e.touches[0].clientX - panOffset.x,
+        y: e.touches[0].clientY - panOffset.y,
+      });
+    }
+  };
+
+  const handleTouchMove = (e: React.TouchEvent) => {
+    if (e.touches.length === 2 && lastTouchDistance !== null) {
+      // Pinch gesture
+      e.preventDefault();
+      const newDistance = getTouchDistance(e.touches);
+      const scale = newDistance / lastTouchDistance;
+
+      setZoomLevel(prev => {
+        const newZoom = Math.max(1, Math.min(4, prev * scale));
+        if (newZoom === 1) {
+          setPanOffset({ x: 0, y: 0 });
+        }
+        return newZoom;
+      });
+
+      setLastTouchDistance(newDistance);
+
+      // Also handle pan during pinch
+      const newCenter = getTouchCenter(e.touches);
+      if (lastTouchCenter) {
+        const maxPan = getMaxPan();
+        setPanOffset(prev => ({
+          x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),
+          y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),
+        }));
+      }
+      setLastTouchCenter(newCenter);
+    } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {
+      // Single touch pan
+      e.preventDefault();
+      const newX = e.touches[0].clientX - panStart.x;
+      const newY = e.touches[0].clientY - panStart.y;
+      const maxPan = getMaxPan();
+      setPanOffset({
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
+      });
+    }
+  };
+
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (e.touches.length < 2) {
+      setLastTouchDistance(null);
+      setLastTouchCenter(null);
+    }
+    if (e.touches.length === 0) {
+      setIsPanning(false);
+    }
+  };
+
   const resetZoom = () => {
     setZoomLevel(1);
     setPanOffset({ x: 0, y: 0 });
@@ -509,6 +607,10 @@ export function CameraPage() {
         onMouseMove={handleImageMouseMove}
         onMouseUp={handleImageMouseUp}
         onMouseLeave={handleImageMouseUp}
+        onTouchStart={handleTouchStart}
+        onTouchMove={handleTouchMove}
+        onTouchEnd={handleTouchEnd}
+        style={{ touchAction: 'none' }}
       >
         <div className="relative w-full h-full flex items-center justify-center">
           {(streamLoading || transitioning) && !isReconnecting && (

+ 114 - 12
frontend/src/pages/FileManagerPage.tsx

@@ -427,6 +427,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
   const [isDragging, setIsDragging] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
+  const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -482,7 +483,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       try {
         if (files[i].isZip) {
           // Extract ZIP file
-          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure);
+          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip);
           setFiles((prev) =>
             prev.map((f, idx) =>
               idx === i
@@ -552,14 +553,13 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
               {isDragging ? 'Drop files here' : 'Drag & drop files here'}
             </p>
             <p className="text-sm text-bambu-gray mt-1">or click to browse</p>
-            <p className="text-xs text-bambu-gray/70 mt-2">ZIP files will be automatically extracted</p>
+            <p className="text-xs text-bambu-gray/70 mt-2">All file types supported. ZIP files will be extracted.</p>
           </div>
 
           <input
             ref={fileInputRef}
             type="file"
             multiple
-            accept="*/*,.zip"
             className="hidden"
             onChange={handleFileSelect}
           />
@@ -583,6 +583,15 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
                     />
                     <span className="text-sm text-white">Preserve folder structure from ZIP</span>
                   </label>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={createFolderFromZip}
+                      onChange={(e) => setCreateFolderFromZip(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">Create folder from ZIP filename</span>
+                  </label>
                 </div>
               </div>
             </div>
@@ -685,9 +694,10 @@ interface FolderTreeItemProps {
   onLink: (folder: LibraryFolderTree) => void;
   onRename: (folder: LibraryFolderTree) => void;
   depth?: number;
+  wrapNames?: boolean;
 }
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0 }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(true);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
@@ -718,12 +728,12 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
           <div className="w-4.5" />
         )}
         <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
-        <span className="text-sm truncate flex-1">{folder.name}</span>
+        <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
         {/* Link indicator - clickable to change link */}
         {isLinked && (
           <button
             onClick={(e) => { e.stopPropagation(); onLink(folder); }}
-            className="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
+            className="flex-shrink-0 flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
             title={`${folder.project_name ? `Project: ${folder.project_name}` : `Archive: ${folder.archive_name}`} (click to change)`}
           >
             <Link2 className="w-3 h-3" />
@@ -735,19 +745,19 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
           </button>
         )}
         {folder.file_count > 0 && (
-          <span className="text-xs text-bambu-gray">{folder.file_count}</span>
+          <span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
         )}
         {/* Quick link button - always visible for unlinked folders */}
         {!isLinked && (
           <button
             onClick={(e) => { e.stopPropagation(); onLink(folder); }}
-            className="p-1 rounded hover:bg-bambu-dark-tertiary"
+            className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
             title="Link to project or archive"
           >
             <Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
           </button>
         )}
-        <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
+        <div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
           <div className="relative">
             <button
               onClick={() => setShowActions(!showActions)}
@@ -798,6 +808,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onLink={onLink}
               onRename={onRename}
               depth={depth + 1}
+              wrapNames={wrapNames}
             />
           ))}
         </div>
@@ -982,6 +993,55 @@ export function FileManagerPage() {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
+  const [wrapFolderNames, setWrapFolderNames] = useState(() => {
+    return localStorage.getItem('library-wrap-folders') === 'true';
+  });
+
+  // Resizable sidebar state
+  const [sidebarWidth, setSidebarWidth] = useState(() => {
+    const saved = localStorage.getItem('library-sidebar-width');
+    return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px
+  });
+  const [isResizing, setIsResizing] = useState(false);
+  const sidebarRef = useRef<HTMLDivElement>(null);
+
+  // Handle sidebar resize
+  useEffect(() => {
+    if (!isResizing) return;
+
+    // Prevent text selection during resize
+    document.body.style.userSelect = 'none';
+    document.body.style.cursor = 'col-resize';
+
+    const handleMouseMove = (e: MouseEvent) => {
+      if (!sidebarRef.current) return;
+      const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect();
+      if (!containerRect) return;
+      // Calculate new width based on mouse position relative to container
+      const newWidth = e.clientX - containerRect.left;
+      // Clamp between 200px and 500px
+      const clampedWidth = Math.min(500, Math.max(200, newWidth));
+      setSidebarWidth(clampedWidth);
+    };
+
+    const handleMouseUp = () => {
+      setIsResizing(false);
+      document.body.style.userSelect = '';
+      document.body.style.cursor = '';
+      // Save to localStorage
+      localStorage.setItem('library-sidebar-width', String(sidebarWidth));
+    };
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+
+    return () => {
+      document.removeEventListener('mousemove', handleMouseMove);
+      document.removeEventListener('mouseup', handleMouseUp);
+      document.body.style.userSelect = '';
+      document.body.style.cursor = '';
+    };
+  }, [isResizing, sidebarWidth]);
 
   // Filter and sort state
   const [searchQuery, setSearchQuery] = useState('');
@@ -1411,10 +1471,51 @@ export function FileManagerPage() {
 
       {/* Main content */}
       <div className="flex-1 flex gap-6 min-h-0">
-        {/* Folder sidebar */}
-        <div className="w-64 flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col">
-          <div className="p-3 border-b border-bambu-dark-tertiary">
+        {/* Folder sidebar - resizable */}
+        <div
+          ref={sidebarRef}
+          className="flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col relative"
+          style={{ width: `${sidebarWidth}px` }}
+        >
+          {/* Resize handle - drag to resize, double-click to reset */}
+          <div
+            className={`absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize z-10 group/resize flex items-center justify-center transition-colors ${
+              isResizing ? 'bg-bambu-green' : 'hover:bg-bambu-green/50'
+            }`}
+            onMouseDown={(e) => {
+              e.preventDefault();
+              setIsResizing(true);
+            }}
+            onDoubleClick={() => {
+              setSidebarWidth(256); // Reset to default w-64
+              localStorage.setItem('library-sidebar-width', '256');
+            }}
+            title="Drag to resize, double-click to reset"
+          >
+            {/* Grip dots */}
+            <div className={`flex flex-col gap-1 opacity-0 group-hover/resize:opacity-100 transition-opacity ${isResizing ? 'opacity-100' : ''}`}>
+              <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
+              <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
+              <div className="w-0.5 h-0.5 rounded-full bg-white/70" />
+            </div>
+          </div>
+          <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
             <h2 className="text-sm font-medium text-white">Folders</h2>
+            <button
+              onClick={() => {
+                const newValue = !wrapFolderNames;
+                setWrapFolderNames(newValue);
+                localStorage.setItem('library-wrap-folders', String(newValue));
+              }}
+              className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
+                wrapFolderNames
+                  ? 'bg-bambu-green/20 text-bambu-green'
+                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
+              }`}
+              title={wrapFolderNames ? 'Disable text wrapping' : 'Enable text wrapping'}
+            >
+              Wrap
+            </button>
           </div>
           <div className="flex-1 overflow-y-auto p-2">
             {/* All Files (root) */}
@@ -1440,6 +1541,7 @@ export function FileManagerPage() {
                 onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                 onLink={setLinkFolder}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
+                wrapNames={wrapFolderNames}
               />
             ))}
           </div>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini