Просмотр исходного кода

Merge pull request #203 from maziggy/0.1.6-final

v0.1.6 final
MartinNYHC 7 месяцев назад
Родитель
Сommit
1209e181a4
100 измененных файлов с 15847 добавлено и 1413 удалено
  1. 2 0
      .github/ISSUE_TEMPLATE/bug_report.yml
  2. 3 1
      .gitignore
  3. 1 1
      .pre-commit-config.yaml
  4. 0 5
      =2.8.0
  5. 172 0
      CHANGELOG.md
  6. 26 8
      README.md
  7. 318 0
      RELEASE_NOTES_0.1.6.md
  8. BIN
      backend/.coverage
  9. 114 3
      backend/app/api/routes/archives.py
  10. 45 18
      backend/app/api/routes/auth.py
  11. 448 7
      backend/app/api/routes/camera.py
  12. 36 2
      backend/app/api/routes/cloud.py
  13. 319 0
      backend/app/api/routes/github_backup.py
  14. 316 0
      backend/app/api/routes/groups.py
  15. 166 6
      backend/app/api/routes/library.py
  16. 418 0
      backend/app/api/routes/metrics.py
  17. 8 0
      backend/app/api/routes/notification_templates.py
  18. 12 0
      backend/app/api/routes/notifications.py
  19. 226 7
      backend/app/api/routes/print_queue.py
  20. 167 18
      backend/app/api/routes/printers.py
  21. 396 1
      backend/app/api/routes/projects.py
  22. 333 2
      backend/app/api/routes/settings.py
  23. 286 15
      backend/app/api/routes/smart_plugs.py
  24. 138 55
      backend/app/api/routes/users.py
  25. 125 2
      backend/app/core/auth.py
  26. 9 2
      backend/app/core/config.py
  27. 352 16
      backend/app/core/database.py
  28. 392 0
      backend/app/core/permissions.py
  29. 587 73
      backend/app/main.py
  30. 6 0
      backend/app/models/__init__.py
  31. 7 1
      backend/app/models/archive.py
  32. 65 0
      backend/app/models/github_backup.py
  33. 54 0
      backend/app/models/group.py
  34. 10 1
      backend/app/models/library.py
  35. 12 0
      backend/app/models/notification.py
  36. 49 0
      backend/app/models/notification_template.py
  37. 9 0
      backend/app/models/print_queue.py
  38. 11 0
      backend/app/models/printer.py
  39. 31 2
      backend/app/models/smart_plug.py
  40. 82 3
      backend/app/models/user.py
  41. 4 0
      backend/app/schemas/archive.py
  42. 21 1
      backend/app/schemas/auth.py
  43. 154 0
      backend/app/schemas/github_backup.py
  44. 89 0
      backend/app/schemas/group.py
  45. 29 0
      backend/app/schemas/library.py
  46. 24 0
      backend/app/schemas/notification.py
  47. 55 0
      backend/app/schemas/notification_template.py
  48. 33 0
      backend/app/schemas/print_queue.py
  49. 61 0
      backend/app/schemas/printer.py
  50. 55 0
      backend/app/schemas/project.py
  51. 10 4
      backend/app/schemas/settings.py
  52. 58 6
      backend/app/schemas/smart_plug.py
  53. 70 3
      backend/app/services/archive.py
  54. 2 2
      backend/app/services/bambu_ftp.py
  55. 73 4
      backend/app/services/bambu_mqtt.py
  56. 2 2
      backend/app/services/camera.py
  57. 778 0
      backend/app/services/external_camera.py
  58. 1 0
      backend/app/services/firmware_check.py
  59. 744 0
      backend/app/services/github_backup.py
  60. 875 0
      backend/app/services/hms_errors.py
  61. 6 6
      backend/app/services/homeassistant.py
  62. 274 0
      backend/app/services/layer_timelapse.py
  63. 32 1
      backend/app/services/mqtt_relay.py
  64. 488 0
      backend/app/services/mqtt_smart_plug.py
  65. 178 3
      backend/app/services/notification_service.py
  66. 801 0
      backend/app/services/plate_detection.py
  67. 680 69
      backend/app/services/print_scheduler.py
  68. 51 2
      backend/app/services/printer_manager.py
  69. 11 6
      backend/app/services/smart_plug_manager.py
  70. 7 2
      backend/app/services/spoolman.py
  71. 140 0
      backend/app/services/stl_thumbnail.py
  72. 0 133
      backend/app/services/telemetry.py
  73. 87 0
      backend/app/utils/printer_models.py
  74. 57 0
      backend/tests/conftest.py
  75. 146 0
      backend/tests/integration/test_archives_api.py
  76. 330 1
      backend/tests/integration/test_auth_api.py
  77. 255 0
      backend/tests/integration/test_camera_api.py
  78. 255 0
      backend/tests/integration/test_github_backup_api.py
  79. 270 0
      backend/tests/integration/test_library_api.py
  80. 139 0
      backend/tests/integration/test_metrics_api.py
  81. 229 0
      backend/tests/integration/test_print_queue_api.py
  82. 327 0
      backend/tests/integration/test_projects_api.py
  83. 105 0
      backend/tests/integration/test_settings_api.py
  84. 252 0
      backend/tests/integration/test_smart_plugs_api.py
  85. 51 0
      backend/tests/unit/services/test_archive_service.py
  86. 181 0
      backend/tests/unit/services/test_bambu_mqtt.py
  87. 259 0
      backend/tests/unit/services/test_external_camera.py
  88. 76 0
      backend/tests/unit/services/test_hms_errors.py
  89. 320 0
      backend/tests/unit/services/test_layer_timelapse.py
  90. 227 0
      backend/tests/unit/services/test_notification_service.py
  91. 185 0
      backend/tests/unit/services/test_plate_detection.py
  92. 68 1
      backend/tests/unit/services/test_printer_manager.py
  93. 204 0
      backend/tests/unit/services/test_stl_thumbnail.py
  94. 0 229
      backend/tests/unit/services/test_telemetry.py
  95. 267 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  96. 0 83
      bambuddy-issue-notes.txt
  97. 0 4
      demo-video/.gitignore
  98. 0 50
      demo-video/README.md
  99. 0 537
      demo-video/package-lock.json
  100. 0 15
      demo-video/package.json

+ 2 - 0
.github/ISSUE_TEMPLATE/bug_report.yml

@@ -46,12 +46,14 @@ body:
       options:
         - X1 Carbon
         - X1
+        - X1E
         - P1S
         - P1P
         - P2S
         - A1
         - A1 Mini
         - H2D
+        - H2D Pro
         - H2C
         - H2S
         - Multiple printers

+ 3 - 1
.gitignore

@@ -55,4 +55,6 @@ bambutrack.log.*
 firmware/
 
 # Node modules
-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/

+ 0 - 5
=2.8.0

@@ -1,5 +0,0 @@
-Collecting PyJWT
-  Downloading PyJWT-2.10.1-py3-none-any.whl.metadata (4.0 kB)
-Downloading PyJWT-2.10.1-py3-none-any.whl (22 kB)
-Installing collected packages: PyJWT
-Successfully installed PyJWT-2.10.1

+ 172 - 0
CHANGELOG.md

@@ -2,6 +2,178 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6-final] - 2026-01-31
+
+### New Features
+- **Group-Based Permissions** - Granular access control with user groups:
+  - Create custom groups with specific permissions (50+ granular permissions)
+  - Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only)
+  - Users can belong to multiple groups with additive permissions
+  - Permission-based UI: buttons/features disabled when user lacks permission
+  - Groups management page in Settings → Users → Groups tab
+  - Change password: users can change their own password from sidebar
+  - Included in backup/restore
+- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
+  - Checkbox option when uploading STL files to generate thumbnails automatically
+  - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
+  - Individual file thumbnail generation via context menu (three-dot menu)
+  - Works with ZIP extraction (generates thumbnails for all STL files in archive)
+  - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
+  - Thumbnails auto-refresh in UI after generation
+  - Graceful handling of complex/invalid STL files
+- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
+  - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
+  - Real-time print progress, ETA, layer count, and filename display
+  - Bambuddy logo branding (links to GitHub)
+  - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
+  - No authentication required - designed for OBS browser source embedding
+  - Gradient overlay at bottom for readable text over camera feed
+  - Auto-reconnect on camera stream errors
+- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):
+  - New "MQTT" plug type alongside Tasmota and Home Assistant
+  - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)
+  - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state
+  - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`)
+  - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh)
+  - **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1")
+  - Monitor-only: displays power/energy data without control capabilities
+  - Reuses existing MQTT broker settings from Settings → Network
+  - Energy data included in statistics and per-print tracking
+  - Full backup/restore support for MQTT plug configurations
+- **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
+- **Model-Based Queue AMS Mapping** - Fixed "Any [Model]" queue jobs failing at filament loading on H2D Pro and other printers (Issue #192):
+  - Scheduler now computes AMS mapping after printer assignment for model-based jobs
+  - Previously, no AMS mapping was sent because the specific printer wasn't known at queue time
+  - Auto-matches required filaments to available AMS slots by type and color
+
+### 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

+ 26 - 8
README.md

@@ -52,11 +52,16 @@
 - 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)
+- Tag management (rename/delete across all archives)
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
+- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
+- 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)
@@ -72,17 +77,23 @@
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
+- Model-based queue assignment (send to "any X1C" for load balancing)
+- Filament validation (only assign to printers with required filaments)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
-- Smart plug integration (Tasmota, Home Assistant)
+- Smart plug integration (Tasmota, Home Assistant, MQTT)
+- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - Auto power-on before print
 - Auto power-off after cooldown
 
 ### 📁 File Manager (Library)
-- Upload and organize sliced files (3MF, gcode)
+- Upload and organize sliced files (3MF, gcode, STL)
+- **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
+- 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 +108,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 +120,17 @@
 - Quiet hours & daily digest
 - Customizable message templates
 - Print finish photo URL in notifications
+- HMS error alerts (AMS, nozzle, etc.)
+- Build plate detection alerts
+- Queue events (waiting, skipped, failed)
 
 ### 🔧 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
@@ -138,9 +155,10 @@
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
-- Role-based access (Admin/User)
+- Group-based permissions (50+ granular permissions)
+- Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
-- User management (create, edit, delete)
+- User management (create, edit, delete, groups)
 
 </td>
 </tr>
@@ -496,10 +514,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
 
 ---
 
-If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
-
----
-
 ## 📄 License
 
 MIT License — see [LICENSE](LICENSE) for details.
@@ -514,6 +528,10 @@ MIT License — see [LICENSE](LICENSE) for details.
 
 ---
 
+If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
+
+---
+
 <p align="center">
   Made with ❤️ for the 3D printing community
   <br><br>

+ 318 - 0
RELEASE_NOTES_0.1.6.md

@@ -0,0 +1,318 @@
+# Bambuddy v0.1.6 - Final Release
+
+**Release Date:** January 31, 2026
+
+After 11 beta releases and extensive community testing, we're excited to announce **Bambuddy 0.1.6-final** - our biggest release yet! This release brings optional authentication, build plate detection, external camera support, model-based queue scheduling, and much more.
+
+---
+
+## Highlights
+
+### Optional Authentication & User Management
+Secure your Bambuddy instance for the first time:
+- Enable/disable authentication via Settings
+- **Role-based access**: Admin (full access) and User (prints only) roles
+- **Group-based permissions**: 50+ granular permissions with custom groups
+- JWT-based authentication with user management UI
+- Users can change their own password from the sidebar
+
+### Build Plate Empty Detection
+Never start a print with objects on the bed again:
+- Per-printer toggle for plate detection
+- Multi-reference calibration (up to 5 plate types)
+- Automatic print pause when objects detected
+- Push notifications and WebSocket alerts
+- ROI calibration for precise detection area
+
+### External & USB Camera Support
+Use any camera with your printers:
+- **External cameras**: MJPEG, RTSP, HTTP snapshot support
+- **USB cameras**: V4L2 webcam support on Linux
+- Layer-based timelapse with external cameras
+- Finish photo capture from external sources
+
+### Model-Based Queue Assignment
+Perfect for print farms:
+- Queue items to "Any X1C", "Any P1S", etc.
+- Auto-assigns to available printer when ready
+- Automatic filament validation and AMS mapping
+- Matches required filaments to loaded spools
+
+### GitHub Profile Backup
+Automated backup of your settings to GitHub:
+- Schedule hourly, daily, or weekly backups
+- Backs up K-profiles, cloud profiles, and app settings
+- Skip unchanged commits (only commit when data changes)
+- Backup history log with commit links
+
+### Prometheus Metrics
+Export printer telemetry for external monitoring:
+- Endpoint: `GET /api/v1/metrics`
+- Printer temps, fans, WiFi, print progress
+- Ready for Grafana dashboards
+- Optional bearer token authentication
+
+---
+
+## New Features
+
+### Authentication & Security
+- **Optional Authentication** - Secure your Bambuddy instance with JWT-based user authentication
+- **Group-Based Permissions** - 50+ granular permissions with custom groups (Administrators, Operators, Viewers)
+- **Change Password** - Users can update their own password from sidebar
+- **API Keys** - API key authentication with granular permissions
+
+### Virtual Printer
+- **Virtual Printer** - Emulates a Bambu Lab printer on your network for Bambu Studio/Orca Slicer
+- **Virtual Printer Queue Mode** - Auto-archive and queue prints from slicer
+- **Virtual Printer Model Selection** - Choose which printer model to emulate
+- **TLS 1.3 Encryption** - Secure MQTT + FTPS with auto-generated certificates
+
+### Print Queue & Scheduling
+- **Model-Based Queue Assignment** - Queue to "Any X1C", "Any P1S" with auto filament matching
+- **Multi-Printer Selection** - Send prints to multiple printers at once
+- **Per-Printer AMS Mapping** - Configure filament mapping individually per printer
+- **Queue Bulk Edit** - Select and edit multiple queue items at once
+- **Queue Only Mode** - Stage prints without auto-start, release when ready
+- **Unassigned Queue Items** - Queue items without assigned printer
+- **Add to Queue from File Manager** - Queue sliced files directly from library
+- **Print Queue Plate Selection** - Full print configuration in queue modal
+- **Deferred Archive Creation** - Archives created when prints start, not when queued
+
+### Smart Plugs & Automation
+- **MQTT Smart Plug Support** - Monitor energy from Zigbee2MQTT, Shelly, Tasmota
+- **Home Assistant Integration** - Control any HA switch/light as a smart plug
+- **HA Energy Sensors** - Use separate sensor entities for power monitoring
+- **Tasmota Discovery** - Auto-discover Tasmota devices on network
+- **Switchbar Widget** - Quick power toggle in sidebar
+- **Tasmota Admin Link** - Quick access to plug web interface
+
+### Camera & Streaming
+- **External Camera Support** - MJPEG, RTSP, HTTP snapshot cameras
+- **USB Camera Support** - V4L2 webcam support on Linux
+- **Build Plate Empty Detection** - AI-powered detection with multi-reference calibration
+- **OBS Streaming Overlay** - Embeddable page at `/overlay/:printerId`
+- **Camera Zoom & Fullscreen** - 100%-400% zoom with pan support
+- **Multiple Embedded Viewers** - Open multiple camera streams simultaneously
+- **Camera View Mode** - Choose between new window or embedded overlay
+- **Layer-Based Timelapse** - External camera timelapse on layer change
+- **Finish Photo in Notifications** - `{finish_photo_url}` template variable
+
+### File Manager
+- **STL Thumbnail Generation** - Auto-generate 3D previews for STL files
+- **ZIP File Support** - Upload and extract ZIP files directly
+- **Create Folder from ZIP** - Auto-create folder named after ZIP file
+- **File Manager Sorting** - Sort by name, size, or date
+- **File Manager Rename** - Rename files and folders directly
+- **File Manager Print Button** - Print directly from selection toolbar
+- **Resizable Sidebar** - Drag to adjust width (200-500px)
+- **Text Wrap Toggle** - Wrap long folder names instead of truncating
+- **Mobile Accessibility** - Touch-friendly with always-visible menus
+
+### Archives & Projects
+- **Multi-Plate Selection** - Select which plate to print from multi-plate 3MF
+- **Archive Plate Browsing** - Navigate plate thumbnails in archive cards
+- **External Links** - Link archives to Printables, Thingiverse, etc.
+- **Fusion 360 Attachments** - Attach F3D design files to archives
+- **Project Import/Export** - Export/import projects as ZIP with all files
+- **BOM Item Editing** - Edit Bill of Materials items after creation
+- **Bulk Project Assignment** - Assign multiple archives to project at once
+- **Project Parts Tracking** - Track parts separately from plates
+- **Tag Management** - Create, edit, and apply tags to archives
+- **Archive Comparison** - Compare 2-5 archives side-by-side
+- **AMS Filament Preview** - Preview filament colors in archive cards
+
+### Printer Controls
+- **Printer Controls** - Stop and Pause/Resume buttons with confirmation
+- **Skip Objects** - Skip individual objects without canceling print
+- **Chamber Light Control** - Light toggle button on printer cards
+- **Resizable Printer Cards** - Four sizes (S/M/L/XL)
+- **H2D Pro Support** - Full support for H2D Pro printer model
+
+### AMS & Filament
+- **AMS Color Mapping** - Manual slot selection with auto-matching
+- **Expandable Color Picker** - 32 colors in configurable palette
+- **AMS Slot RFID Re-read** - Re-read filament info via hover menu
+- **Print Options in Modals** - Bed leveling, flow cal, vibration cal, timelapse toggles
+
+### Backup & Monitoring
+- **GitHub Profile Backup** - Scheduled backup to GitHub repository
+- **Prometheus Metrics** - Export telemetry for Grafana
+- **MQTT Publishing** - Publish events to external MQTT brokers
+- **Application Log Viewer** - Real-time log viewing with filters
+- **Support Bundle** - Debug logging with ZIP generation
+- **Comprehensive Backup/Restore** - All settings, users, groups included
+
+### Notifications
+- **HMS Error Notifications** - 853 error codes translated to human-readable messages
+- **Plate Not Empty Notification** - Dedicated category for plate detection
+- **Daily Digest** - Consolidated daily notification summary
+- **Notification Templates** - Customizable message templates
+- **Slack/Mattermost Format** - Proper payload format support
+
+### Statistics & Dashboard
+- **Failure Analysis Widget** - Failure rate with correlations and trends
+- **Statistics Improvements** - Size-aware responsive widgets
+- **Recalculate Costs** - Button to recalculate all archive costs
+- **Time Format Setting** - Configurable date/time format
+- **Print Quantity Tracking** - Track items per print for progress
+
+### Other Improvements
+- **Firmware Update Helper** - Check versions against Bambu Lab servers
+- **Disable Firmware Checks** - Toggle to prevent update checks
+- **Printer Discovery** - Docker subnet scanning, model mapping
+- **FTP Reliability** - Configurable retry with SSL fixes
+- **Pre-built Docker Images** - Pull from GitHub Container Registry
+- **One-Shot Install Scripts** - Simple `curl | bash` installation
+- **Mobile PWA** - Full mobile support with touch gestures
+- **Timelapse Editor** - Trim, speed adjustment, music overlay
+- **Sidebar Badge Indicators** - Queue and upload counts
+
+---
+
+## Bug Fixes
+
+### Print Queue & Scheduling
+- **Home Assistant Auto-On for Queued Prints** - Fixed smart plug not turning on for queue-started prints (Issue #200)
+- **AMS Mapping for Model-Based Queue** - Fixed "Any [Model]" queue jobs failing at filament loading (Issue #192)
+- **Queue prints on A1** - Fixed "MicroSD Card read/write exception error" when starting prints from queue
+- **Multi-Plate Queue Thumbnails** - Queue now shows correct plate thumbnail (Issue #166)
+- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
+
+### Printer Status & Display
+- **A1/A1 Mini Status Display** - Fixed incorrect "Printing" status when idle (Issue #168)
+- **Chamber temp on A1/P1S** - Fixed regression where chamber temperature appeared on printers without sensors
+- **Active AMS slot display** - Fixed for H2D printers with multiple AMS units
+- **Printer hour counter** - Fixed not incrementing during prints and inconsistency between views
+
+### AMS & Filament
+- **Empty AMS Slot Recognition** - Fixed removed spools still appearing in Bambuddy (Issue #147)
+- **Spoolman Sync for Transparent Spools** - Fixed sync failures for natural/transparent filaments (Issue #190)
+- **Spoolman tag field** - Now auto-created on first connect, fixing fresh installs (Issue #123)
+- **Spoolman 400 Bad Request** - Fixed when creating spools
+- **AMS filament matching** - Fixed in reprint modal
+- **User preset AMS configuration** - Fixed user presets showing empty fields in Bambu Studio
+
+### Notifications & Webhooks
+- **Progress Milestone Notifications** - Fixed showing wrong time (e.g., "17m" instead of "17h 47m") (Issue #157)
+- **Mattermost/Slack Webhooks** - Added proper payload format support (Issue #133)
+- **Telegram Notification Parsing** - Fixed markdown errors with underscores in error codes
+- **HMS Error Notifications** - 853 error codes now translated to human-readable messages
+- **Notifications sent when printer offline** - Fixed
+
+### Camera & Streaming
+- **Camera stream reconnection** - Automatic recovery from stalled streams
+- **Camera zoom & pan** - Fixed pan range and added pinch-to-zoom for mobile (Issue #132)
+- **P2S/X1E/H2 completion photo** - Fixed internal model codes not recognized (Issue #127)
+- **Browser freeze** - Fixed on print completion when camera stream was open
+- **ffmpeg processes** - Fixed not being killed when closing webcam window
+
+### File Manager & Archives
+- **P2S Empty Archive Tiles** - Fixed FTP search for printers without SD card (Issue #146)
+- **File Manager folder navigation** - Fixed folder opening then jumping back to root (Issue #160)
+- **File Manager upload** - Now accepts all file types, not just ZIP
+- **Multi-plate 3MF metadata** - Single-plate exports now show correct thumbnail
+- **Archive card cache** - Fixed wrong cover image bug
+- **Archive delete safety** - Added checks to prevent deleting parent directories
+
+### Statistics & Tracking
+- **Print time stats** - Now uses actual elapsed time instead of slicer estimates (Issue #137)
+- **Filament cost** - Now uses "Default filament cost" setting instead of hardcoded €25 (Issue #120)
+- **Reprint cost tracking** - Now adds cost to existing total instead of replacing
+- **K-Profiles backup status** - Fixed showing incorrect printer connection count
+
+### Smart Plugs
+- **HA Energy Sensors** - Fixed sensors with lowercase units (w, kwh) not detected (Issue #119)
+
+### UI & UX
+- **Skip objects modal overflow** - Fixed modal going above browser window (Issue #134)
+- **Project card filament badges** - Fixed showing duplicates and raw color codes
+- **Subnet scan serial number** - Fixed A1 Mini showing "unknown-*" placeholder (Issue #140)
+- **Slicer protocol** - Fixed OS detection (Windows vs macOS/Linux)
+
+### API & Backend
+- **Settings API PATCH Method** - Added for Home Assistant rest_command compatibility (Issue #152)
+- **GitHub Backup Timestamps** - Removed volatile timestamps for cleaner git diffs
+- **Plate Calibration Persistence** - Fixed reference images not persisting in Docker
+- **Update module** - Fixed for Docker-based installations
+
+---
+
+## Maintenance
+
+- Upgraded vitest from 2.x to 3.x for security improvements
+- Added security scanning (pip-audit, npm audit) to CI pipeline
+- Replaced python-jose with PyJWT to eliminate ecdsa vulnerability
+- Improved test coverage (796 backend tests, 518 frontend tests)
+
+---
+
+## Thank You!
+
+This release wouldn't be possible without our amazing community. A huge thank you to everyone who contributed code, reported bugs, tested beta releases, and provided feedback!
+
+### Code Contributors
+
+| Contributor | Contribution |
+|-------------|--------------|
+| **[@maziggy](https://github.com/maziggy)** (MartinNYHC) | Lead developer, core features |
+| **[@MisterBeardy](https://github.com/MisterBeardy)** (Wesley Reaves) | STL thumbnail generation |
+| **[@JesseFPV](https://github.com/JesseFPV)** (Jesse Hulswit) | Optional authentication system |
+
+### Issue Reporters & Testers
+
+Special thanks to everyone who reported issues, tested beta releases, and provided valuable feedback:
+
+- **[@Locxion](https://github.com/Locxion)** (Markus Bender) - A1 Mini status bug, log viewer feature
+- **[@cadtoolbox](https://github.com/cadtoolbox)** (Thomas Rambach) - H2D Pro support, model-based queue
+- **[@Twilek-de](https://github.com/Twilek-de)** - Empty AMS slots, Mattermost webhooks, progress milestones
+- **[@elit3ge](https://github.com/elit3ge)** - Archive tiles, completion photos, upload improvements
+- **[@opensourcefan](https://github.com/opensourcefan)** - Subnet scan serial, status colors
+- **[@1nv4lidus3r](https://github.com/1nv4lidus3r)** - Print time stats, skip objects modal
+- **[@joaorgoncalves](https://github.com/joaorgoncalves)** (João Gonçalves) - Filament cost settings, HA energy sync
+- **[@beardofbeespool](https://github.com/beardofbeespool)** (Morton Likely) - STL thumbnail feature request
+- **[@PeterXQChen](https://github.com/PeterXQChen)** (Peter Chen) - File manager folder navigation
+- **[@Robnex](https://github.com/Robnex)** - External links, external spool sync
+- **[@caco3](https://github.com/caco3)** (CaCO3) - Disable firmware checks
+- **[@sbcrumb](https://github.com/sbcrumb)** - Camera zoom feature
+- **[@fcps3](https://github.com/fcps3)** - Spoolman transparent spool sync
+- **[@LucHeart](https://github.com/LucHeart)** - MQTT connection issues
+- **[@ouihq](https://github.com/ouihq)** (Jonas) - File manager queue bug
+- **[@Schuermi7](https://github.com/Schuermi7)** - AMS mapping, cloud 2FA
+- **[@stubbers](https://github.com/stubbers)** (Joseph Stubberfield) - X1C sync issues
+- **[@nvdmedianl](https://github.com/nvdmedianl)** (Nathan) - Spoolman Bambu spool errors
+- **[@lbeumer-bit](https://github.com/lbeumer-bit)** - Notification photos
+- **[@IROKILLER](https://github.com/IROKILLER)** - Home Assistant automations
+- **[@fgrfn](https://github.com/fgrfn)** (Florian) - Dynamic electricity cost
+- **[@JasonSwindle](https://github.com/JasonSwindle)** (Jason Swindle) - Smart plug text overflow
+- **[@Cassiopeia1980](https://github.com/Cassiopeia1980)** - Connection issues
+
+And many more community members who tested, provided feedback, and helped make Bambuddy better!
+
+---
+
+## Upgrade Notes
+
+### From 0.1.5.x or earlier
+- Database migrations run automatically on startup
+- User authentication is optional and disabled by default
+- Existing installations will continue to work without changes
+
+### From 0.1.6 beta
+- All beta migrations are included in the final release
+- No action required - just update and restart
+
+---
+
+## What's Next?
+
+We're already planning 0.1.7 with more exciting features. Stay tuned and keep the feedback coming!
+
+- [GitHub Issues](https://github.com/MisterBeardy/bambuddy/issues) - Report bugs and request features
+- [GitHub Discussions](https://github.com/MisterBeardy/bambuddy/discussions) - Join the conversation
+
+---
+
+**Happy Printing!** 🎉
+
+*— The Bambuddy Team*

BIN
backend/.coverage


+ 114 - 3
backend/app/api/routes/archives.py

@@ -78,12 +78,14 @@ def archive_to_response(
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
+        "sliced_for_model": archive.sliced_for_model,
         "status": archive.status,
         "started_at": archive.started_at,
         "completed_at": archive.completed_at,
         "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,
@@ -513,6 +515,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     if energy_tracking_mode == "total":
         # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
         from backend.app.models.smart_plug import SmartPlug
+        from backend.app.services.homeassistant import homeassistant_service
+        from backend.app.services.mqtt_relay import mqtt_relay
         from backend.app.services.tasmota import tasmota_service
 
         plugs_result = await db.execute(select(SmartPlug))
@@ -520,9 +524,19 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
 
         total_energy_kwh = 0.0
         for plug in plugs:
-            energy = await tasmota_service.get_energy(plug)
-            if energy and energy.get("total") is not None:
-                total_energy_kwh += energy["total"]
+            if plug.plug_type == "tasmota":
+                energy = await tasmota_service.get_energy(plug)
+                if energy and energy.get("total") is not None:
+                    total_energy_kwh += energy["total"]
+            elif plug.plug_type == "homeassistant":
+                energy = await homeassistant_service.get_energy(plug)
+                if energy and energy.get("total") is not None:
+                    total_energy_kwh += energy["total"]
+            elif plug.plug_type == "mqtt":
+                # MQTT plugs report "today" energy, not lifetime total
+                mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+                if mqtt_data and mqtt_data.energy is not None:
+                    total_energy_kwh += mqtt_data.energy
 
         total_energy_kwh = round(total_energy_kwh, 3)
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
@@ -550,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     )
 
 
+@router.get("/tags")
+async def get_all_tags(db: AsyncSession = Depends(get_db)):
+    """List all unique tags with usage counts.
+
+    Returns a list of tags sorted by count (descending), then by name.
+    """
+    # Query all archives with non-null tags
+    result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
+    all_tags_rows = result.all()
+
+    # Count occurrences of each tag
+    tag_counts: dict[str, int] = {}
+    for (tags_str,) in all_tags_rows:
+        if tags_str:
+            for tag in tags_str.split(","):
+                tag = tag.strip()
+                if tag:
+                    tag_counts[tag] = tag_counts.get(tag, 0) + 1
+
+    # Convert to list and sort by count (desc), then name (asc)
+    tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()]
+    tags_list.sort(key=lambda x: (-x["count"], x["name"].lower()))
+
+    return tags_list
+
+
+@router.put("/tags/{tag_name}")
+async def rename_tag(
+    tag_name: str,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+):
+    """Rename a tag across all archives.
+
+    Request body should contain {"new_name": "new tag name"}.
+    Returns the count of affected archives.
+    """
+    body = await request.json()
+    new_name = body.get("new_name", "").strip()
+
+    if not new_name:
+        raise HTTPException(400, "new_name is required")
+
+    if new_name == tag_name:
+        return {"affected": 0}
+
+    # Find all archives containing the old tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Replace old tag with new tag
+            new_tags = [new_name if t == tag_name else t for t in tags]
+            # Remove duplicates while preserving order
+            seen = set()
+            unique_tags = []
+            for t in new_tags:
+                if t not in seen:
+                    seen.add(t)
+                    unique_tags.append(t)
+            archive.tags = ", ".join(unique_tags)
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
+@router.delete("/tags/{tag_name}")
+async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
+    """Delete a tag from all archives.
+
+    Returns the count of affected archives.
+    """
+    # Find all archives containing the tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Remove the tag
+            new_tags = [t for t in tags if t != tag_name]
+            archive.tags = ", ".join(new_tags) if new_tags else None
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
 @router.get("/{archive_id}", response_model=ArchiveResponse)
 async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific archive."""

+ 45 - 18
backend/app/api/routes/auth.py

@@ -3,6 +3,7 @@ from datetime import timedelta
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -13,9 +14,25 @@ from backend.app.core.auth import (
     get_user_by_username,
 )
 from backend.app.core.database import get_db
+from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
-from backend.app.schemas.auth import LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+
+
+def _user_to_response(user: User) -> UserResponse:
+    """Convert a User model to UserResponse schema."""
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
 
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
@@ -126,6 +143,14 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         role="admin",
                         is_active=True,
                     )
+
+                    # Try to add user to Administrators group if it exists
+                    admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators"))
+                    admin_group = admin_group_result.scalar_one_or_none()
+                    if admin_group:
+                        admin_user.groups.append(admin_group)
+                        logger.info("Added new admin user to Administrators group")
+
                     db.add(admin_user)
                     logger.info(f"Admin user added to session: {request.admin_username}")
                     admin_created = True
@@ -179,8 +204,12 @@ async def disable_auth(
 
     logger = logging.getLogger(__name__)
 
+    # Reload user with groups for proper is_admin check
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
     # Only admins can disable authentication
-    if current_user.role != "admin":
+    if not user.is_admin:
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
             detail="Only admins can disable authentication",
@@ -189,7 +218,7 @@ async def disable_auth(
     try:
         await set_auth_enabled(db, False)
         await db.commit()
-        logger.info(f"Authentication disabled by admin user: {current_user.username}")
+        logger.info(f"Authentication disabled by admin user: {user.username}")
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
     except Exception as e:
         await db.rollback()
@@ -219,32 +248,30 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             headers={"WWW-Authenticate": "Bearer"},
         )
 
+    # Reload user with groups for proper permission calculation
+    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
     access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
     return LoginResponse(
         access_token=access_token,
         token_type="bearer",
-        user=UserResponse(
-            id=user.id,
-            username=user.username,
-            role=user.role,
-            is_active=user.is_active,
-            created_at=user.created_at.isoformat(),
-        ),
+        user=_user_to_response(user),
     )
 
 
 @router.get("/me", response_model=UserResponse)
-async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
+async def get_current_user_info(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+):
     """Get current user information."""
-    return UserResponse(
-        id=current_user.id,
-        username=current_user.username,
-        role=current_user.role,
-        is_active=current_user.is_active,
-        created_at=current_user.created_at.isoformat(),
-    )
+    # Reload user with groups for proper permission calculation
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+    return _user_to_response(user)
 
 
 @router.post("/logout")

+ 448 - 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,391 @@ 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
+
+    # Check printer exists first (before OpenCV check)
+    printer = await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        raise HTTPException(
+            status_code=503,
+            detail="Plate detection not available. Install opencv-python-headless to enable.",
+        )
+
+    # 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
+
+    # Check printer exists first (before OpenCV check)
+    printer = await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        raise HTTPException(
+            status_code=503,
+            detail="Plate detection not available. Install opencv-python-headless to enable.",
+        )
+
+    # 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,
+    )
+
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        raise HTTPException(
+            status_code=503,
+            detail="Plate detection not available. Install opencv-python-headless to enable.",
+        )
+
+    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
+
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        return {
+            "available": False,
+            "calibrated": False,
+            "plate_type": plate_type,
+            "chamber_light": False,
+            "message": "OpenCV not installed",
+        }
+
+    # 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
+
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    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
+
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    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
+
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    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
+
+    # Verify printer exists first (before OpenCV check)
+    await get_printer_or_404(printer_id, db)
+
+    if not is_plate_detection_available():
+        raise HTTPException(503, "Plate detection not available")
+
+    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"}

+ 316 - 0
backend/app/api/routes/groups.py

@@ -0,0 +1,316 @@
+"""Group management API routes."""
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import (
+    ALL_PERMISSIONS,
+    PERMISSION_CATEGORIES,
+    Permission,
+)
+from backend.app.models.group import Group
+from backend.app.models.user import User
+from backend.app.schemas.group import (
+    GroupCreate,
+    GroupDetailResponse,
+    GroupResponse,
+    GroupUpdate,
+    PermissionCategory,
+    PermissionInfo,
+    PermissionsListResponse,
+    UserBrief,
+)
+
+router = APIRouter(prefix="/groups", tags=["groups"])
+
+
+def _permission_label(perm: Permission) -> str:
+    """Convert permission enum to human-readable label."""
+    # e.g., "printers:read" -> "Read Printers"
+    parts = perm.value.split(":")
+    if len(parts) == 2:
+        resource, action = parts
+        resource = resource.replace("_", " ").title()
+        action = action.title()
+        return f"{action} {resource}"
+    return perm.value
+
+
+@router.get("/permissions", response_model=PermissionsListResponse)
+async def list_permissions(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
+):
+    """List all available permissions organized by category."""
+    categories = []
+    for name, perms in PERMISSION_CATEGORIES.items():
+        categories.append(
+            PermissionCategory(
+                name=name,
+                permissions=[PermissionInfo(value=p.value, label=_permission_label(p)) for p in perms],
+            )
+        )
+    return PermissionsListResponse(
+        categories=categories,
+        all_permissions=ALL_PERMISSIONS,
+    )
+
+
+@router.get("", response_model=list[GroupResponse])
+@router.get("/", response_model=list[GroupResponse])
+async def list_groups(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all groups."""
+    result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
+    groups = result.scalars().all()
+    return [
+        GroupResponse(
+            id=group.id,
+            name=group.name,
+            description=group.description,
+            permissions=group.permissions or [],
+            is_system=group.is_system,
+            user_count=len(group.users),
+            created_at=group.created_at,
+            updated_at=group.updated_at,
+        )
+        for group in groups
+    ]
+
+
+@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
+@router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
+async def create_group(
+    group_data: GroupCreate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new group."""
+    # Check if group name already exists
+    existing = await db.execute(select(Group).where(Group.name == group_data.name))
+    if existing.scalar_one_or_none():
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Group name already exists",
+        )
+
+    # Validate permissions
+    invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
+    if invalid_perms:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=f"Invalid permissions: {', '.join(invalid_perms)}",
+        )
+
+    group = Group(
+        name=group_data.name,
+        description=group_data.description,
+        permissions=group_data.permissions,
+        is_system=False,  # User-created groups are not system groups
+    )
+    db.add(group)
+    await db.commit()
+    await db.refresh(group)
+
+    return GroupResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        user_count=0,
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+    )
+
+
+@router.get("/{group_id}", response_model=GroupDetailResponse)
+async def get_group(
+    group_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a group by ID with user list."""
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    return GroupDetailResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        user_count=len(group.users),
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+        users=[UserBrief(id=u.id, username=u.username, is_active=u.is_active) for u in group.users],
+    )
+
+
+@router.patch("/{group_id}", response_model=GroupResponse)
+async def update_group(
+    group_id: int,
+    group_data: GroupUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a group."""
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    # Check if updating name to one that already exists
+    if group_data.name is not None and group_data.name != group.name:
+        existing = await db.execute(select(Group).where(Group.name == group_data.name, Group.id != group_id))
+        if existing.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Group name already exists",
+            )
+        # System groups cannot have their name changed
+        if group.is_system:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot rename system groups",
+            )
+        group.name = group_data.name
+
+    if group_data.description is not None:
+        group.description = group_data.description
+
+    if group_data.permissions is not None:
+        # Validate permissions
+        invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
+        if invalid_perms:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"Invalid permissions: {', '.join(invalid_perms)}",
+            )
+        group.permissions = group_data.permissions
+
+    await db.commit()
+    await db.refresh(group)
+
+    return GroupResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        user_count=len(group.users),
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+    )
+
+
+@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_group(
+    group_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a group (non-system groups only)."""
+    result = await db.execute(select(Group).where(Group.id == group_id))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    if group.is_system:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot delete system groups",
+        )
+
+    await db.delete(group)
+    await db.commit()
+
+
+@router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def add_user_to_group(
+    group_id: int,
+    user_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Add a user to a group."""
+    # Get group with users
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    # Get user
+    user_result = await db.execute(select(User).where(User.id == user_id))
+    user = user_result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Check if user is already in group
+    if user in group.users:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="User is already in this group",
+        )
+
+    group.users.append(user)
+    await db.commit()
+
+
+@router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def remove_user_from_group(
+    group_id: int,
+    user_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Remove a user from a group."""
+    # Get group with users
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    # Get user
+    user_result = await db.execute(select(User).where(User.id == user_id))
+    user = user_result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Check if user is in group
+    if user not in group.users:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="User is not in this group",
+        )
+
+    group.users.remove(user)
+    await db.commit()

+ 166 - 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
@@ -25,6 +25,9 @@ from backend.app.schemas.library import (
     AddToQueueRequest,
     AddToQueueResponse,
     AddToQueueResult,
+    BatchThumbnailRequest,
+    BatchThumbnailResponse,
+    BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteResponse,
     FileDuplicate,
@@ -43,6 +46,7 @@ from backend.app.schemas.library import (
     ZipExtractResult,
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
+from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 
 logger = logging.getLogger(__name__)
 
@@ -621,6 +625,7 @@ async def list_files(
 async def upload_file(
     file: UploadFile = File(...),
     folder_id: int | None = None,
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
 ):
     """Upload a file to the library."""
@@ -712,6 +717,11 @@ async def upload_file(
             # For image files, create a thumbnail from the image itself
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
+        elif ext == ".stl":
+            # Generate STL thumbnail if enabled
+            if generate_stl_thumbnails:
+                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
         # Create database entry
         library_file = LibraryFile(
             folder_id=folder_id,
@@ -746,8 +756,10 @@ 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),
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
 ):
     """Upload and extract a ZIP file to the library.
@@ -756,6 +768,8 @@ 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
+        generate_stl_thumbnails: If True, generate thumbnails for STL files
     """
     import tempfile
     import zipfile
@@ -783,6 +797,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
@@ -796,8 +839,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
@@ -805,7 +848,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:
@@ -910,6 +953,11 @@ async def extract_zip_file(
                     elif ext.lower() in IMAGE_EXTENSIONS:
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
+                    elif ext == ".stl":
+                        # Generate STL thumbnail if enabled
+                        if generate_stl_thumbnails:
+                            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
                     # Create database entry
                     library_file = LibraryFile(
                         folder_id=target_folder_id,
@@ -963,6 +1011,118 @@ async def extract_zip_file(
             pass
 
 
+# ============ STL Thumbnail Batch Generation ============
+
+
+@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse)
+async def batch_generate_stl_thumbnails(
+    request: BatchThumbnailRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Generate thumbnails for STL files in batch.
+
+    Can generate thumbnails for:
+    - Specific file IDs (file_ids)
+    - All STL files in a folder (folder_id)
+    - All STL files missing thumbnails (all_missing=True)
+    """
+    thumbnails_dir = get_library_thumbnails_dir()
+    results: list[BatchThumbnailResult] = []
+
+    # Build query based on request
+    query = select(LibraryFile).where(LibraryFile.file_type == "stl")
+
+    if request.file_ids:
+        # Specific files
+        query = query.where(LibraryFile.id.in_(request.file_ids))
+    elif request.folder_id is not None:
+        # All STL files in a specific folder
+        query = query.where(LibraryFile.folder_id == request.folder_id)
+        if not request.all_missing:
+            # If not specifically asking for missing thumbnails, get all
+            pass
+        else:
+            query = query.where(LibraryFile.thumbnail_path.is_(None))
+    elif request.all_missing:
+        # All STL files without thumbnails
+        query = query.where(LibraryFile.thumbnail_path.is_(None))
+    else:
+        # No criteria specified - return empty
+        return BatchThumbnailResponse(
+            processed=0,
+            succeeded=0,
+            failed=0,
+            results=[],
+        )
+
+    result = await db.execute(query)
+    stl_files = result.scalars().all()
+
+    succeeded = 0
+    failed = 0
+
+    for stl_file in stl_files:
+        file_path = Path(stl_file.file_path)
+
+        if not file_path.exists():
+            results.append(
+                BatchThumbnailResult(
+                    file_id=stl_file.id,
+                    filename=stl_file.filename,
+                    success=False,
+                    error="File not found on disk",
+                )
+            )
+            failed += 1
+            continue
+
+        try:
+            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
+            if thumbnail_path:
+                # Update database
+                stl_file.thumbnail_path = thumbnail_path
+                await db.flush()
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=stl_file.id,
+                        filename=stl_file.filename,
+                        success=True,
+                    )
+                )
+                succeeded += 1
+            else:
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=stl_file.id,
+                        filename=stl_file.filename,
+                        success=False,
+                        error="Thumbnail generation failed",
+                    )
+                )
+                failed += 1
+        except Exception as e:
+            logger.error(f"Failed to generate thumbnail for {stl_file.filename}: {e}")
+            results.append(
+                BatchThumbnailResult(
+                    file_id=stl_file.id,
+                    filename=stl_file.filename,
+                    success=False,
+                    error=str(e),
+                )
+            )
+            failed += 1
+
+    await db.commit()
+
+    return BatchThumbnailResponse(
+        processed=len(stl_files),
+        succeeded=succeeded,
+        failed=failed,
+        results=results,
+    )
+
+
 # ============ Queue Operations ============
 # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
 

+ 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")

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

@@ -32,6 +32,14 @@ EVENT_NAMES = {
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "test": "Test Notification",
+    # Queue notifications
+    "queue_job_added": "Queue Job Added",
+    "queue_job_assigned": "Queue Job Assigned",
+    "queue_job_started": "Queue Job Started",
+    "queue_job_waiting": "Queue Job Waiting",
+    "queue_job_skipped": "Queue Job Skipped",
+    "queue_job_failed": "Queue Job Failed",
+    "queue_completed": "Queue Completed",
 }
 
 

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

@@ -51,6 +51,16 @@ 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,
+        # Print queue events
+        "on_queue_job_added": provider.on_queue_job_added,
+        "on_queue_job_assigned": provider.on_queue_job_assigned,
+        "on_queue_job_started": provider.on_queue_job_started,
+        "on_queue_job_waiting": provider.on_queue_job_waiting,
+        "on_queue_job_skipped": provider.on_queue_job_skipped,
+        "on_queue_job_failed": provider.on_queue_job_failed,
+        "on_queue_completed": provider.on_queue_completed,
         # Quiet hours
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
@@ -112,6 +122,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,

+ 226 - 7
backend/app/api/routes/print_queue.py

@@ -2,30 +2,99 @@
 
 import json
 import logging
+import xml.etree.ElementTree as ET
+import zipfile
 from datetime import datetime
+from pathlib import Path
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+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
 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,
     PrintQueueReorder,
 )
+from backend.app.services.notification_service import notification_service
+from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/queue", tags=["queue"])
 
 
+def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = None) -> list[str]:
+    """Extract unique filament types from a 3MF file.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
+
+    Returns:
+        List of unique filament types (e.g., ["PLA", "PETG"])
+    """
+    types: set[str] = set()
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return []
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            if plate_id is not None:
+                # Find the plate element with matching index
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for filament_elem in plate_elem.findall("filament"):
+                            filament_type = filament_elem.get("type", "")
+                            used_g = filament_elem.get("used_g", "0")
+                            try:
+                                used_grams = float(used_g)
+                            except (ValueError, TypeError):
+                                used_grams = 0
+                            if used_grams > 0 and filament_type:
+                                types.add(filament_type)
+                        break
+            else:
+                # No plate_id specified - extract all filaments with used_g > 0
+                for filament_elem in root.findall(".//filament"):
+                    filament_type = filament_elem.get("type", "")
+                    used_g = filament_elem.get("used_g", "0")
+                    try:
+                        used_grams = float(used_g)
+                    except (ValueError, TypeError):
+                        used_grams = 0
+                    if used_grams > 0 and filament_type:
+                        types.add(filament_type)
+
+    except Exception as e:
+        logger.warning(f"Failed to extract filament types from {file_path}: {e}")
+
+    return sorted(types)
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -36,10 +105,21 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             ams_mapping_parsed = None
 
+    # Parse required_filament_types from JSON string
+    required_filament_types_parsed = None
+    if item.required_filament_types:
+        try:
+            required_filament_types_parsed = json.loads(item.required_filament_types)
+        except json.JSONDecodeError:
+            required_filament_types_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
         "printer_id": item.printer_id,
+        "target_model": item.target_model,
+        "required_filament_types": required_filament_types_parsed,
+        "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
         "position": item.position,
@@ -118,29 +198,71 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
 ):
     """Add an item to the print queue."""
+    # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
+    target_model_norm = None
+    if data.target_model:
+        target_model_norm = (
+            normalize_printer_model(data.target_model)
+            or normalize_printer_model_id(data.target_model)
+            or data.target_model
+        )
+
     # Validate that either archive_id or library_file_id is provided
     if not data.archive_id and not data.library_file_id:
         raise HTTPException(400, "Either archive_id or library_file_id must be provided")
 
+    # Cannot specify both printer_id and target_model
+    if data.printer_id and target_model_norm:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-    # Validate archive exists (if provided)
+    # Validate target_model has active printers
+    if target_model_norm:
+        result = await db.execute(
+            select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712
+        )
+        if not result.scalars().first():
+            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+
+    # Validate archive exists (if provided) and get it for filament extraction
+    archive = None
     if data.archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
-        if not result.scalar_one_or_none():
+        archive = result.scalar_one_or_none()
+        if not archive:
             raise HTTPException(400, "Archive not found")
 
-    # Validate library file exists (if provided)
+    # Validate library file exists (if provided) and get it for filament extraction
+    library_file = None
     if data.library_file_id:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
-        if not result.scalar_one_or_none():
+        library_file = result.scalar_one_or_none()
+        if not library_file:
             raise HTTPException(400, "Library file not found")
 
-    # Get next position for this printer (or for unassigned items)
+    # Extract filament types for model-based assignment (used by scheduler for validation)
+    required_filament_types = None
+    if target_model_norm:
+        # Get file path from archive or library file
+        file_path = None
+        if archive:
+            file_path = settings.base_dir / archive.file_path
+        elif library_file:
+            lib_path = Path(library_file.file_path)
+            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+
+        if file_path and file_path.exists():
+            filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
+            if filament_types:
+                required_filament_types = json.dumps(filament_types)
+                logger.info(f"Extracted filament types for model-based queue: {filament_types}")
+
+    # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
@@ -148,7 +270,7 @@ async def add_to_queue(
             .where(PrintQueueItem.status == "pending")
         )
     else:
-        # For unassigned items, get max position across all unassigned
+        # For unassigned/model-based items, get max position across all unassigned
         result = await db.execute(
             select(func.max(PrintQueueItem.position))
             .where(PrintQueueItem.printer_id.is_(None))
@@ -158,6 +280,8 @@ async def add_to_queue(
 
     item = PrintQueueItem(
         printer_id=data.printer_id,
+        target_model=target_model_norm,
+        required_filament_types=required_filament_types,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
@@ -183,7 +307,8 @@ async def add_to_queue(
     await db.refresh(item, ["archive", "printer", "library_file"])
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
-    logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
+    target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
+    logger.info(f"Added {source_name} to queue for {target_desc}")
 
     # MQTT relay - publish queue job added
     try:
@@ -198,9 +323,81 @@ async def add_to_queue(
     except Exception:
         pass  # Don't fail queue add if MQTT fails
 
+    # Send notification for job added
+    try:
+        job_name = (
+            item.archive.filename
+            if item.archive
+            else item.library_file.filename
+            if item.library_file
+            else f"Job #{item.id}"
+        )
+        job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
+        target = (
+            item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
+        )
+        await notification_service.on_queue_job_added(
+            job_name=job_name,
+            target=target,
+            db=db,
+            printer_id=item.printer_id,
+            printer_name=item.printer.name if item.printer else None,
+        )
+    except Exception:
+        pass  # Don't fail queue add if notification fails
+
     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."""
@@ -236,12 +433,34 @@ async def update_queue_item(
 
     update_data = data.model_dump(exclude_unset=True)
 
+    # Normalize target_model if being updated
+    if "target_model" in update_data and update_data["target_model"]:
+        update_data["target_model"] = (
+            normalize_printer_model(update_data["target_model"])
+            or normalize_printer_model_id(update_data["target_model"])
+            or update_data["target_model"]
+        )
+
+    # Cannot specify both printer_id and target_model
+    new_printer_id = update_data.get("printer_id", item.printer_id)
+    new_target_model = update_data.get("target_model", item.target_model)
+    if new_printer_id and new_target_model:
+        raise HTTPException(400, "Cannot specify both printer_id and target_model")
+
     # Validate new printer_id if being changed (and not None)
     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")
 
+    # Validate target_model has active printers
+    if "target_model" in update_data and update_data["target_model"]:
+        result = await db.execute(
+            select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True)  # noqa: E712
+        )
+        if not result.scalars().first():
+            raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
+
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None

+ 167 - 18
backend/app/api/routes/printers.py

@@ -8,9 +8,10 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequireAdminIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.schemas.printer import (
@@ -38,7 +39,10 @@ router = APIRouter(prefix="/printers", tags=["printers"])
 
 
 @router.get("/", response_model=list[PrinterResponse])
-async def list_printers(db: AsyncSession = Depends(get_db)):
+async def list_printers(
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """List all configured printers."""
     result = await db.execute(select(Printer).order_by(Printer.name))
     return list(result.scalars().all())
@@ -47,8 +51,8 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
 @router.post("/", response_model=PrinterResponse)
 async def create_printer(
     printer_data: PrinterCreate,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
     """Add a new printer."""
     # Check if serial number already exists
@@ -68,8 +72,30 @@ async def create_printer(
     return printer
 
 
+@router.get("/usb-cameras")
+async def list_usb_cameras(
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
+    """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)):
+async def get_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Get a specific printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -82,8 +108,8 @@ async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def update_printer(
     printer_id: int,
     printer_data: PrinterUpdate,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
     """Update a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -92,6 +118,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)
 
@@ -111,8 +153,8 @@ async def update_printer(
 async def delete_printer(
     printer_id: int,
     delete_archives: bool = True,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
     """Delete a printer.
 
@@ -159,7 +201,11 @@ async def delete_printer(
 
 
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
-async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_printer_status(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Get real-time status of a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -376,7 +422,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,
@@ -399,7 +445,11 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
 
 
 @router.post("/{printer_id}/refresh-status")
-async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def refresh_printer_status(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Request a full status refresh from the printer (sends pushall command)."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -414,7 +464,11 @@ async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get
 
 
 @router.post("/{printer_id}/connect")
-async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def connect_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Manually connect to a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -426,7 +480,11 @@ async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/{printer_id}/disconnect")
-async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def disconnect_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Manually disconnect from a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -442,6 +500,7 @@ async def test_printer_connection(
     ip_address: str,
     serial_number: str,
     access_code: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
 ):
     """Test connection to a printer without saving."""
     result = await printer_manager.test_connection(
@@ -460,6 +519,7 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get the cover image for the current print job.
@@ -647,6 +707,7 @@ async def get_printer_cover(
 async def list_printer_files(
     printer_id: int,
     path: str = "/",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
 ):
     """List files on the printer at the specified path."""
@@ -671,6 +732,7 @@ async def list_printer_files(
 async def download_printer_file(
     printer_id: int,
     path: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
 ):
     """Download a file from the printer."""
@@ -707,10 +769,56 @@ async def download_printer_file(
     )
 
 
+@router.post("/{printer_id}/files/download-zip")
+async def download_printer_files_as_zip(
+    printer_id: int,
+    request: dict,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    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,
     path: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
 ):
     """Delete a file from the printer."""
@@ -729,6 +837,7 @@ async def delete_printer_file(
 @router.get("/{printer_id}/storage")
 async def get_printer_storage(
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get storage information from the printer."""
@@ -748,7 +857,11 @@ async def get_printer_storage(
 
 
 @router.post("/{printer_id}/logging/enable")
-async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def enable_mqtt_logging(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Enable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -763,7 +876,11 @@ async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db
 
 
 @router.post("/{printer_id}/logging/disable")
-async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def disable_mqtt_logging(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Disable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -778,7 +895,11 @@ async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_d
 
 
 @router.get("/{printer_id}/logging")
-async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_mqtt_logs(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Get MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -801,7 +922,11 @@ async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.delete("/{printer_id}/logging")
-async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def clear_mqtt_logs(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Clear MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -824,6 +949,7 @@ async def set_print_option(
     enabled: bool,
     print_halt: bool = True,
     sensitivity: str = "medium",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set an AI detection / print option on the printer.
@@ -896,6 +1022,7 @@ async def start_calibration(
     motor_noise: bool = False,
     nozzle_offset: bool = False,
     high_temp_heatbed: bool = False,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Start printer calibration with selected options.
@@ -951,6 +1078,7 @@ async def start_calibration(
 @router.get("/{printer_id}/slot-presets")
 async def get_slot_presets(
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get all saved slot-to-preset mappings for a printer."""
@@ -973,6 +1101,7 @@ async def get_slot_preset(
     printer_id: int,
     ams_id: int,
     tray_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get the saved preset for a specific slot."""
@@ -1003,6 +1132,7 @@ async def save_slot_preset(
     tray_id: int,
     preset_id: str,
     preset_name: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Save a preset mapping for a specific slot."""
@@ -1052,6 +1182,7 @@ async def delete_slot_preset(
     printer_id: int,
     ams_id: int,
     tray_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Delete a saved preset mapping for a slot."""
@@ -1088,6 +1219,7 @@ async def configure_ams_slot(
     kprofile_filament_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
 
@@ -1287,7 +1419,11 @@ async def debug_simulate_print_complete(
 
 
 @router.post("/{printer_id}/print/stop")
-async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def stop_print(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Stop/cancel the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -1306,7 +1442,11 @@ async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/{printer_id}/print/pause")
-async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def pause_print(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Pause the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -1325,7 +1465,11 @@ async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/{printer_id}/print/resume")
-async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def resume_print(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Resume a paused print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -1347,6 +1491,7 @@ async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def set_chamber_light(
     printer_id: int,
     on: bool = Query(..., description="True to turn on, False to turn off"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Turn the chamber light on or off."""
@@ -1370,6 +1515,7 @@ async def set_chamber_light(
 async def get_printable_objects(
     printer_id: int,
     reload: bool = False,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get the list of printable objects for the current print.
@@ -1467,6 +1613,7 @@ async def get_printable_objects(
 async def skip_objects(
     printer_id: int,
     object_ids: list[int],
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Skip specific objects during the current print.
@@ -1521,6 +1668,7 @@ async def refresh_ams_slot(
     printer_id: int,
     ams_id: int,
     slot_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
@@ -1543,6 +1691,7 @@ async def refresh_ams_slot(
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Debug endpoint: Get runtime tracking status for a printer."""

+ 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,
+    )

+ 333 - 2
backend/app/api/routes/settings.py

@@ -15,6 +15,8 @@ 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.group import Group
 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 +75,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 +167,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 +246,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 +260,8 @@ async def export_backup(
     include_users: bool = Query(
         False, description="Include users (passwords not exported - users will need new passwords)"
     ),
+    include_groups: bool = Query(False, description="Include groups and user-group assignments"),
+    include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
 ):
     """Export selected data as JSON backup."""
     backup: dict = {
@@ -296,6 +312,14 @@ 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),
+                    "on_queue_job_added": getattr(p, "on_queue_job_added", False),
+                    "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False),
+                    "on_queue_job_started": getattr(p, "on_queue_job_started", False),
+                    "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True),
+                    "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True),
+                    "on_queue_job_failed": getattr(p, "on_queue_job_failed", True),
+                    "on_queue_completed": getattr(p, "on_queue_completed", False),
                     "quiet_hours_enabled": p.quiet_hours_enabled,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
@@ -345,6 +369,21 @@ async def export_backup(
                     "ha_power_entity": plug.ha_power_entity,
                     "ha_energy_today_entity": plug.ha_energy_today_entity,
                     "ha_energy_total_entity": plug.ha_energy_total_entity,
+                    # MQTT plug fields (legacy)
+                    "mqtt_topic": plug.mqtt_topic,
+                    "mqtt_multiplier": plug.mqtt_multiplier,
+                    # MQTT power fields
+                    "mqtt_power_topic": plug.mqtt_power_topic,
+                    "mqtt_power_path": plug.mqtt_power_path,
+                    "mqtt_power_multiplier": plug.mqtt_power_multiplier,
+                    # MQTT energy fields
+                    "mqtt_energy_topic": plug.mqtt_energy_topic,
+                    "mqtt_energy_path": plug.mqtt_energy_path,
+                    "mqtt_energy_multiplier": plug.mqtt_energy_multiplier,
+                    # MQTT state fields
+                    "mqtt_state_topic": plug.mqtt_state_topic,
+                    "mqtt_state_path": plug.mqtt_state_path,
+                    "mqtt_state_on_value": plug.mqtt_state_on_value,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
@@ -361,6 +400,7 @@ async def export_backup(
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "show_in_switchbar": plug.show_in_switchbar,
+                    "show_on_printer_card": plug.show_on_printer_card,
                 }
             )
         backup["included"].append("smart_plugs")
@@ -404,6 +444,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 +460,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 +643,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 +696,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,
@@ -794,11 +878,46 @@ async def export_backup(
                     "username": user.username,
                     "role": user.role,
                     "is_active": user.is_active,
+                    "groups": [g.name for g in user.groups],
                     # password_hash intentionally not exported for security
                 }
             )
         backup["included"].append("users")
 
+    # Groups (permission groups)
+    if include_groups:
+        result = await db.execute(select(Group))
+        groups = result.scalars().all()
+        backup["groups"] = []
+        for group in groups:
+            backup["groups"].append(
+                {
+                    "name": group.name,
+                    "description": group.description,
+                    "permissions": group.permissions,
+                    "is_system": group.is_system,
+                }
+            )
+        backup["included"].append("groups")
+
+    # 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 +963,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 +985,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 +1017,8 @@ async def import_backup(
         "projects": 0,
         "pending_uploads": 0,
         "users": 0,
+        "groups": 0,
+        "github_backup": 0,
     }
     skipped = {
         "settings": 0,
@@ -904,6 +1033,8 @@ async def import_backup(
         "projects": 0,
         "pending_uploads": 0,
         "users": 0,
+        "groups": 0,
+        "github_backup": 0,
     }
     skipped_details = {
         "notification_providers": [],
@@ -916,6 +1047,7 @@ async def import_backup(
         "projects": [],
         "pending_uploads": [],
         "users": [],
+        "groups": [],
     }
 
     # Restore settings (always overwrites)
@@ -959,6 +1091,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 +1128,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 +1220,14 @@ 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.on_queue_job_added = provider_data.get("on_queue_job_added", False)
+                    existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False)
+                    existing.on_queue_job_started = provider_data.get("on_queue_job_started", False)
+                    existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True)
+                    existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True)
+                    existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True)
+                    existing.on_queue_completed = provider_data.get("on_queue_completed", False)
                     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 +1257,14 @@ 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),
+                    on_queue_job_added=provider_data.get("on_queue_job_added", False),
+                    on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False),
+                    on_queue_job_started=provider_data.get("on_queue_job_started", False),
+                    on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True),
+                    on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True),
+                    on_queue_job_failed=provider_data.get("on_queue_job_failed", True),
+                    on_queue_completed=provider_data.get("on_queue_completed", False),
                     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"),
@@ -1106,12 +1316,23 @@ async def import_backup(
             # Determine plug type (default to tasmota for backwards compatibility)
             plug_type = plug_data.get("plug_type", "tasmota")
 
-            # Find existing plug by IP (Tasmota) or entity_id (Home Assistant)
+            # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT)
             existing = None
+            plug_identifier = None
             if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
                 existing = result.scalar_one_or_none()
                 plug_identifier = plug_data["ha_entity_id"]
+            elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")):
+                # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy)
+                power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")
+                result = await db.execute(
+                    select(SmartPlug).where(
+                        (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic)
+                    )
+                )
+                existing = result.scalar_one_or_none()
+                plug_identifier = power_topic
             elif plug_data.get("ip_address"):
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
                 existing = result.scalar_one_or_none()
@@ -1128,6 +1349,21 @@ async def import_backup(
                     existing.ha_power_entity = plug_data.get("ha_power_entity")
                     existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
                     existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
+                    # MQTT fields (legacy)
+                    existing.mqtt_topic = plug_data.get("mqtt_topic")
+                    existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0)
+                    # MQTT power fields
+                    existing.mqtt_power_topic = plug_data.get("mqtt_power_topic")
+                    existing.mqtt_power_path = plug_data.get("mqtt_power_path")
+                    existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0)
+                    # MQTT energy fields
+                    existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic")
+                    existing.mqtt_energy_path = plug_data.get("mqtt_energy_path")
+                    existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0)
+                    # MQTT state fields
+                    existing.mqtt_state_topic = plug_data.get("mqtt_state_topic")
+                    existing.mqtt_state_path = plug_data.get("mqtt_state_path")
+                    existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value")
                     existing.printer_id = printer_id
                     existing.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", True)
@@ -1144,6 +1380,7 @@ async def import_backup(
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
+                    existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
                     restored["smart_plugs"] += 1
                 else:
                     skipped["smart_plugs"] += 1
@@ -1157,6 +1394,21 @@ async def import_backup(
                     ha_power_entity=plug_data.get("ha_power_entity"),
                     ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
                     ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
+                    # MQTT fields (legacy)
+                    mqtt_topic=plug_data.get("mqtt_topic"),
+                    mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0),
+                    # MQTT power fields
+                    mqtt_power_topic=plug_data.get("mqtt_power_topic"),
+                    mqtt_power_path=plug_data.get("mqtt_power_path"),
+                    mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0),
+                    # MQTT energy fields
+                    mqtt_energy_topic=plug_data.get("mqtt_energy_topic"),
+                    mqtt_energy_path=plug_data.get("mqtt_energy_path"),
+                    mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0),
+                    # MQTT state fields
+                    mqtt_state_topic=plug_data.get("mqtt_state_topic"),
+                    mqtt_state_path=plug_data.get("mqtt_state_path"),
+                    mqtt_state_on_value=plug_data.get("mqtt_state_on_value"),
                     printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", True),
@@ -1173,6 +1425,7 @@ async def import_backup(
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
+                    show_on_printer_card=plug_data.get("show_on_printer_card", True),
                 )
                 db.add(plug)
                 restored["smart_plugs"] += 1
@@ -1458,6 +1711,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"),
@@ -1804,6 +2058,39 @@ async def import_backup(
                     }
                 )
 
+    # Restore groups (before users, so groups exist for assignment)
+    if "groups" in backup:
+        for group_data in backup["groups"]:
+            result = await db.execute(select(Group).where(Group.name == group_data["name"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite and not existing.is_system:
+                    # Update non-system groups
+                    existing.description = group_data.get("description")
+                    existing.permissions = group_data.get("permissions", [])
+                    restored["groups"] += 1
+                else:
+                    skipped["groups"] += 1
+                    skipped_details["groups"].append(group_data["name"])
+            else:
+                group = Group(
+                    name=group_data["name"],
+                    description=group_data.get("description"),
+                    permissions=group_data.get("permissions", []),
+                    is_system=group_data.get("is_system", False),
+                )
+                db.add(group)
+                restored["groups"] += 1
+
+    # Flush to ensure groups are persisted before user assignment
+    await db.flush()
+
+    # Build group name to object lookup for user assignment
+    group_name_to_obj: dict[str, Group] = {}
+    result = await db.execute(select(Group))
+    for g in result.scalars().all():
+        group_name_to_obj[g.name] = g
+
     # Restore users (note: passwords not included in backup - users will need new passwords)
     # Users are skipped by default since they have no passwords; admin must recreate them
     new_users: list[str] = []
@@ -1817,6 +2104,10 @@ async def import_backup(
                 if overwrite:
                     existing.role = user_data.get("role", "user")
                     existing.is_active = user_data.get("is_active", True)
+                    # Assign groups if provided
+                    group_names = user_data.get("groups", [])
+                    if group_names:
+                        existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
                     # Don't change password - keep existing
                     restored["users"] += 1
                 else:
@@ -1834,10 +2125,50 @@ async def import_backup(
                     role=user_data.get("role", "user"),
                     is_active=user_data.get("is_active", True),
                 )
+                # Assign groups if provided
+                group_names = user_data.get("groups", [])
+                if group_names:
+                    user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
                 db.add(user)
                 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

+ 286 - 15
backend/app/api/routes/smart_plugs.py

@@ -27,6 +27,7 @@ from backend.app.schemas.smart_plug import (
 )
 from backend.app.services.discovery import tasmota_scanner
 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
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
@@ -56,16 +57,84 @@ async def create_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if printer already has a plug assigned
-        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+        # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
+        is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
+        if not is_script:
+            # For non-script plugs, check there's no other non-script plug assigned
+            result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    # For MQTT plugs, ensure MQTT broker is configured and service is connected
+    if data.plug_type == "mqtt":
+        # Try to configure the smart plug service if not already configured
+        if not mqtt_relay.smart_plug_service.is_configured():
+            # Get MQTT broker settings from database
+            mqtt_broker = await get_setting(db, "mqtt_broker") or ""
+            if not mqtt_broker:
+                raise HTTPException(
+                    400,
+                    "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.",
+                )
+
+            # Configure the smart plug service with broker settings
+            mqtt_settings = {
+                "mqtt_enabled": True,  # Enable for smart plug subscription
+                "mqtt_broker": mqtt_broker,
+                "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
+                "mqtt_username": await get_setting(db, "mqtt_username") or "",
+                "mqtt_password": await get_setting(db, "mqtt_password") or "",
+                "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
+            }
+            await mqtt_relay.smart_plug_service.configure(mqtt_settings)
+
+            # Check if connection succeeded
+            if not mqtt_relay.smart_plug_service.is_configured():
+                raise HTTPException(
+                    400,
+                    f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
+                )
 
     plug = SmartPlug(**data.model_dump())
     db.add(plug)
     await db.commit()
     await db.refresh(plug)
 
-    if plug.plug_type == "homeassistant":
+    # Subscribe MQTT plugs to their topics
+    if plug.plug_type == "mqtt":
+        # Determine effective topics (new fields take priority, fall back to legacy)
+        power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+        energy_topic = plug.mqtt_energy_topic
+        state_topic = plug.mqtt_state_topic
+
+        # Only subscribe if at least one topic is configured
+        if power_topic or energy_topic or state_topic:
+            mqtt_relay.smart_plug_service.subscribe(
+                plug_id=plug.id,
+                # Power source (path is optional)
+                power_topic=power_topic,
+                power_path=plug.mqtt_power_path,
+                power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
+                # Energy source (path is optional)
+                energy_topic=energy_topic,
+                energy_path=plug.mqtt_energy_path,
+                energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
+                # State source (path is optional)
+                state_topic=state_topic,
+                state_path=plug.mqtt_state_path,
+                state_on_value=plug.mqtt_state_on_value,
+            )
+            topics = [t for t in [power_topic, energy_topic, state_topic] if t]
+            logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}")
+    elif plug.plug_type == "homeassistant":
         logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
     else:
         logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
@@ -74,12 +143,48 @@ async def create_smart_plug(
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the smart plug assigned to a printer."""
+    """Get the main smart plug assigned to a printer.
+
+    When multiple plugs are assigned (e.g., a regular plug + script),
+    returns the main (non-script) plug for power control.
+    """
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plug = result.scalar_one_or_none()
-    if not plug:
+    plugs = result.scalars().all()
+
+    if not plugs:
         return None
-    return plug
+
+    # If multiple plugs, prefer the non-script one (main power plug)
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            return plug
+
+    # All are scripts, return the first one
+    return plugs[0]
+
+
+@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
+async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get all HA script plugs assigned to a printer.
+
+    Returns only script entities (script.*) for the printer that have
+    show_on_printer_card enabled.
+    Used to display "Run Script" buttons alongside the main power plug.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Filter to only scripts with show_on_printer_card enabled
+    scripts = [
+        plug
+        for plug in plugs
+        if plug.plug_type == "homeassistant"
+        and plug.ha_entity_id
+        and plug.ha_entity_id.startswith("script.")
+        and plug.show_on_printer_card
+    ]
+    return scripts
 
 
 # Tasmota Discovery Endpoints
@@ -287,14 +392,43 @@ async def update_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if that printer already has a different plug assigned
-        result = await db.execute(
-            select(SmartPlug).where(
-                SmartPlug.printer_id == new_printer_id,
-                SmartPlug.id != plug_id,
+        # Scripts can coexist with other plugs
+        # Determine if the plug being updated is/will be a script
+        new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
+        new_plug_type = update_data.get("plug_type", plug.plug_type)
+        is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
+
+        if not is_script:
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.printer_id == new_printer_id,
+                    SmartPlug.id != plug_id,
+                )
             )
-        )
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    # Track old MQTT settings for comparison
+    old_plug_type = plug.plug_type
+    old_mqtt_config = {
+        "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
+        "power_path": plug.mqtt_power_path,
+        "power_multiplier": plug.mqtt_power_multiplier,
+        "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
+        "energy_path": plug.mqtt_energy_path,
+        "energy_multiplier": plug.mqtt_energy_multiplier,
+        "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
+        "state_path": plug.mqtt_state_path,
+        "state_on_value": plug.mqtt_state_on_value,
+    }
 
     for field, value in update_data.items():
         setattr(plug, field, value)
@@ -302,6 +436,54 @@ async def update_smart_plug(
     await db.commit()
     await db.refresh(plug)
 
+    # Handle MQTT subscription changes
+    if old_plug_type == "mqtt" and plug.plug_type != "mqtt":
+        # Changed away from MQTT - unsubscribe
+        mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+    elif plug.plug_type == "mqtt":
+        # Check if any MQTT config changed
+        new_mqtt_config = {
+            "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
+            "power_path": plug.mqtt_power_path,
+            "power_multiplier": plug.mqtt_power_multiplier,
+            "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
+            "energy_path": plug.mqtt_energy_path,
+            "energy_multiplier": plug.mqtt_energy_multiplier,
+            "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
+            "state_path": plug.mqtt_state_path,
+            "state_on_value": plug.mqtt_state_on_value,
+        }
+
+        mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config
+
+        if mqtt_changed:
+            # Unsubscribe from old topics first
+            if old_plug_type == "mqtt":
+                mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+
+            # Subscribe to new topics
+            power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+            energy_topic = plug.mqtt_energy_topic
+            state_topic = plug.mqtt_state_topic
+
+            # Only subscribe if at least one topic is configured
+            if power_topic or energy_topic or state_topic:
+                mqtt_relay.smart_plug_service.subscribe(
+                    plug_id=plug.id,
+                    # Power source (path is optional)
+                    power_topic=power_topic,
+                    power_path=plug.mqtt_power_path,
+                    power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
+                    # Energy source (path is optional)
+                    energy_topic=energy_topic,
+                    energy_path=plug.mqtt_energy_path,
+                    energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
+                    # State source (path is optional)
+                    state_topic=state_topic,
+                    state_path=plug.mqtt_state_path,
+                    state_on_value=plug.mqtt_state_on_value,
+                )
+
     logger.info(f"Updated smart plug '{plug.name}'")
     return plug
 
@@ -315,6 +497,12 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
         raise HTTPException(404, "Smart plug not found")
 
     plug_name = plug.name
+    plug_type = plug.plug_type
+
+    # Unsubscribe MQTT plug before deletion
+    if plug_type == "mqtt":
+        mqtt_relay.smart_plug_service.unsubscribe(plug_id)
+
     await db.delete(plug)
     await db.commit()
 
@@ -348,6 +536,13 @@ async def control_smart_plug(
     if not plug:
         raise HTTPException(404, "Smart plug not found")
 
+    # MQTT plugs are monitor-only - cannot control them
+    if plug.plug_type == "mqtt":
+        raise HTTPException(
+            400,
+            "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.",
+        )
+
     service = await _get_service_for_plug(plug, db)
 
     if control.action == "on":
@@ -376,6 +571,13 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     await db.commit()
 
+    # Trigger associated scripts if this is a main (non-script) plug
+    is_main_plug = not (
+        plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+    )
+    if is_main_plug and plug.printer_id and expected_state:
+        await trigger_associated_scripts(plug.printer_id, expected_state, db)
+
     # MQTT relay - publish smart plug state change
     if expected_state:
         try:
@@ -401,6 +603,37 @@ async def control_smart_plug(
     return {"success": True, "action": control.action}
 
 
+async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
+    """Trigger scripts linked to a printer based on main plug state change.
+
+    When the main plug turns ON, triggers scripts with auto_on=True.
+    When the main plug turns OFF, triggers scripts with auto_off=True.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Find scripts that should be triggered
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            continue
+
+        should_trigger = False
+        if plug_state == "ON" and plug.auto_on:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
+        elif plug_state == "OFF" and plug.auto_off:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
+
+        if should_trigger:
+            try:
+                service = await _get_service_for_plug(plug, db)
+                await service.turn_on(plug)  # Scripts are triggered by calling turn_on
+            except Exception as e:
+                logger.error(f"Failed to trigger script '{plug.name}': {e}")
+
+
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get current plug status from device including energy data."""
@@ -409,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     if not plug:
         raise HTTPException(404, "Smart plug not found")
 
+    # Handle MQTT plugs - get data from subscription service
+    if plug.plug_type == "mqtt":
+        data = mqtt_relay.smart_plug_service.get_plug_data(plug_id)
+        is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id)
+
+        if data:
+            # Update last state in database
+            if is_reachable and data.state:
+                plug.last_state = data.state
+                plug.last_checked = datetime.utcnow()
+                await db.commit()
+
+            energy_data = None
+            if data.power is not None or data.energy is not None:
+                energy_data = SmartPlugEnergy(
+                    power=data.power,
+                    today=data.energy,
+                )
+                # Check power alerts
+                if data.power is not None:
+                    await check_power_alerts(plug, data.power, db)
+
+            return SmartPlugStatus(
+                state=data.state,
+                reachable=is_reachable,
+                device_name=None,
+                energy=energy_data,
+            )
+
+        # No data received yet
+        return SmartPlugStatus(
+            state=None,
+            reachable=False,
+            device_name=None,
+            energy=None,
+        )
+
+    # Handle Tasmota/HomeAssistant plugs
     service = await _get_service_for_plug(plug, db)
     status = await service.get_status(plug)
 

+ 138 - 55
backend/app/api/routes/users.py

@@ -1,44 +1,57 @@
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import RequireAdmin, get_password_hash
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    get_current_user_optional,
+    get_password_hash,
+    verify_password,
+)
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.group import Group
 from backend.app.models.user import User
-from backend.app.schemas.auth import UserCreate, UserResponse, UserUpdate
+from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 
 router = APIRouter(prefix="/users", tags=["users"])
 
 
+def _user_to_response(user: User) -> UserResponse:
+    """Convert a User model to UserResponse schema."""
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
+
 @router.get("", response_model=list[UserResponse])
 @router.get("/", response_model=list[UserResponse])
 async def list_users(
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """List all users (admin only)."""
-    result = await db.execute(select(User).order_by(User.created_at))
+    """List all users."""
+    result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
     users = result.scalars().all()
-    return [
-        UserResponse(
-            id=user.id,
-            username=user.username,
-            role=user.role,
-            is_active=user.is_active,
-            created_at=user.created_at.isoformat(),
-        )
-        for user in users
-    ]
+    return [_user_to_response(user) for user in users]
 
 
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
     user_data: UserCreate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Create a new user (admin only)."""
+    """Create a new user."""
     # Check if username already exists
     existing_user = await db.execute(select(User).where(User.username == user_data.username))
     if existing_user.scalar_one_or_none():
@@ -60,27 +73,33 @@ async def create_user(
         role=user_data.role,
         is_active=True,
     )
+
+    # Handle group assignments
+    if user_data.group_ids:
+        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
+        groups = groups_result.scalars().all()
+        if len(groups) != len(user_data.group_ids):
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="One or more group IDs are invalid",
+            )
+        new_user.groups = list(groups)
+
     db.add(new_user)
     await db.commit()
     await db.refresh(new_user)
 
-    return UserResponse(
-        id=new_user.id,
-        username=new_user.username,
-        role=new_user.role,
-        is_active=new_user.is_active,
-        created_at=new_user.created_at.isoformat(),
-    )
+    return _user_to_response(new_user)
 
 
 @router.get("/{user_id}", response_model=UserResponse)
 async def get_user(
     user_id: int,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Get a user by ID (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Get a user by ID."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
         raise HTTPException(
@@ -88,24 +107,18 @@ async def get_user(
             detail="User not found",
         )
 
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        role=user.role,
-        is_active=user.is_active,
-        created_at=user.created_at.isoformat(),
-    )
+    return _user_to_response(user)
 
 
 @router.patch("/{user_id}", response_model=UserResponse)
 async def update_user(
     user_id: int,
     user_data: UserUpdate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Update a user (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Update a user."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
         raise HTTPException(
@@ -114,10 +127,21 @@ async def update_user(
         )
 
     # Prevent deactivating the last admin
-    if user_data.is_active is False and user.role == "admin":
+    if user_data.is_active is False and user.is_admin:
+        # Count admins by role or Administrators group membership
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
-        admin_count = len(admin_count_result.scalars().all())
-        if admin_count <= 1:
+        role_admins = admin_count_result.scalars().all()
+
+        # Also check for users in Administrators group
+        admin_group_result = await db.execute(
+            select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
+        )
+        admin_group = admin_group_result.scalar_one_or_none()
+        group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
+
+        # Combine unique admins
+        all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
+        if len(all_admins) <= 1 and user.id in all_admins:
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot deactivate the last admin user",
@@ -157,26 +181,31 @@ async def update_user(
     if user_data.is_active is not None:
         user.is_active = user_data.is_active
 
+    # Handle group assignments
+    if user_data.group_ids is not None:
+        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
+        groups = groups_result.scalars().all()
+        if len(groups) != len(user_data.group_ids):
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="One or more group IDs are invalid",
+            )
+        user.groups = list(groups)
+
     await db.commit()
     await db.refresh(user)
 
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        role=user.role,
-        is_active=user.is_active,
-        created_at=user.created_at.isoformat(),
-    )
+    return _user_to_response(user)
 
 
 @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_user(
     user_id: int,
-    current_user: User = RequireAdmin(),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Delete a user (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Delete a user."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
         raise HTTPException(
@@ -185,17 +214,28 @@ async def delete_user(
         )
 
     # Prevent deleting the last admin
-    if user.role == "admin":
+    if user.is_admin:
+        # Count admins by role or Administrators group membership
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
-        admin_count = len(admin_count_result.scalars().all())
-        if admin_count == 0:
+        other_role_admins = admin_count_result.scalars().all()
+
+        # Also check for users in Administrators group
+        admin_group_result = await db.execute(
+            select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
+        )
+        admin_group = admin_group_result.scalar_one_or_none()
+        other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
+
+        # Combine unique admins
+        all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
+        if len(all_other_admins) == 0:
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot delete the last admin user",
             )
 
-    # Prevent deleting yourself
-    if user.id == current_user.id:
+    # Prevent deleting yourself (only if auth is enabled and we have a current user)
+    if current_user and user.id == current_user.id:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             detail="Cannot delete your own account",
@@ -203,3 +243,46 @@ async def delete_user(
 
     await db.delete(user)
     await db.commit()
+
+
+@router.post("/me/change-password", response_model=dict)
+async def change_own_password(
+    password_data: ChangePasswordRequest,
+    current_user: User | None = Depends(get_current_user_optional),
+    db: AsyncSession = Depends(get_db),
+):
+    """Change the current user's password. Requires current password verification."""
+    if not current_user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Authentication required to change password",
+        )
+
+    # Verify current password
+    if not verify_password(password_data.current_password, current_user.password_hash):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Current password is incorrect",
+        )
+
+    # Validate new password
+    if len(password_data.new_password) < 6:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="New password must be at least 6 characters",
+        )
+
+    # Fetch user from this session to ensure changes are persisted
+    result = await db.execute(select(User).where(User.id == current_user.id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Update password
+    user.password_hash = get_password_hash(password_data.new_password)
+    await db.commit()
+
+    return {"message": "Password changed successfully"}

+ 125 - 2
backend/app/core/auth.py

@@ -11,8 +11,10 @@ from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import async_session, get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -60,8 +62,8 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
 
 
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
-    """Get a user by username."""
-    result = await db.execute(select(User).where(User.username == username))
+    """Get a user by username with groups loaded for permission checks."""
+    result = await db.execute(select(User).where(User.username == username).options(selectinload(User.groups)))
     return result.scalar_one_or_none()
 
 
@@ -347,3 +349,124 @@ def RequireAdmin():
 def RequireAdminIfAuthEnabled():
     """Dependency that requires admin role if auth is enabled."""
     return Depends(require_admin_if_auth_enabled())
+
+
+def require_permission(*permissions: str | Permission):
+    """Dependency factory that requires user to have ALL specified permissions.
+
+    Args:
+        *permissions: Permission strings or Permission enum values to require
+
+    Returns:
+        A dependency function that validates permissions
+    """
+    # Convert Permission enums to strings
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    ) -> User:
+        credentials_exception = HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Could not validate credentials",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+        if credentials is None:
+            raise credentials_exception
+
+        try:
+            token = credentials.credentials
+            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+            username: str = payload.get("sub")
+            if username is None:
+                raise credentials_exception
+        except JWTError:
+            raise credentials_exception
+
+        async with async_session() as db:
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise credentials_exception
+
+            if not user.has_all_permissions(*perm_strings):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                )
+            return user
+
+    return permission_checker
+
+
+def require_permission_if_auth_enabled(*permissions: str | Permission):
+    """Dependency factory that checks permissions only if auth is enabled.
+
+    This provides backward compatibility - when auth is disabled, all access is allowed.
+
+    Args:
+        *permissions: Permission strings or Permission enum values to require
+
+    Returns:
+        A dependency function that validates permissions if auth is enabled
+    """
+    # Convert Permission enums to strings
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            auth_enabled = await is_auth_enabled(db)
+            if not auth_enabled:
+                return None  # Auth disabled, allow access
+
+            if credentials is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Authentication required",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            try:
+                token = credentials.credentials
+                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+            except JWTError:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            if not user.has_all_permissions(*perm_strings):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                )
+            return user
+
+    return permission_checker
+
+
+def RequirePermission(*permissions: str | Permission):
+    """Convenience dependency that requires ALL specified permissions."""
+    return Depends(require_permission(*permissions))
+
+
+def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
+    """Convenience dependency that requires permissions if auth is enabled."""
+    return Depends(require_permission_if_auth_enabled(*permissions))

+ 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)

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

@@ -38,6 +38,8 @@ async def init_db():
         archive,
         external_link,
         filament,
+        github_backup,
+        group,
         kprofile_note,
         library,
         maintenance,
@@ -61,6 +63,9 @@ async def init_db():
     # Seed default notification templates
     await seed_notification_templates()
 
+    # Seed default groups and migrate existing users
+    await seed_default_groups()
+
 
 async def run_migrations(conn):
     """Add new columns to existing tables if they don't exist."""
@@ -288,6 +293,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 +686,253 @@ 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
+
+    # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
+    # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
+    # SQLite requires table recreation to drop constraints
+    try:
+        # Check if we need to migrate (if UNIQUE constraint exists)
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
+        row = result.fetchone()
+        if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
+            # Create new table without UNIQUE constraint on printer_id
+            await conn.execute(
+                text("""
+                CREATE TABLE smart_plugs_temp (
+                    id INTEGER PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    ip_address VARCHAR(45),
+                    plug_type VARCHAR(20) DEFAULT 'tasmota',
+                    ha_entity_id VARCHAR(100),
+                    ha_power_entity VARCHAR(100),
+                    ha_energy_today_entity VARCHAR(100),
+                    ha_energy_total_entity VARCHAR(100),
+                    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+                    enabled BOOLEAN NOT NULL DEFAULT 1,
+                    auto_on BOOLEAN NOT NULL DEFAULT 1,
+                    auto_off BOOLEAN NOT NULL DEFAULT 1,
+                    off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
+                    off_delay_minutes INTEGER NOT NULL DEFAULT 5,
+                    off_temp_threshold INTEGER NOT NULL DEFAULT 70,
+                    username VARCHAR(50),
+                    password VARCHAR(100),
+                    power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    power_alert_high FLOAT,
+                    power_alert_low FLOAT,
+                    power_alert_last_triggered DATETIME,
+                    schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    schedule_on_time VARCHAR(5),
+                    schedule_off_time VARCHAR(5),
+                    show_in_switchbar BOOLEAN DEFAULT 0,
+                    last_state VARCHAR(10),
+                    last_checked DATETIME,
+                    auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
+                    auto_off_pending BOOLEAN DEFAULT 0,
+                    auto_off_pending_since DATETIME,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+                    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
+                )
+            """)
+            )
+            # Copy data
+            await conn.execute(
+                text("""
+                INSERT INTO smart_plugs_temp
+                SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
+                       ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
+                       auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
+                       username, password, power_alert_enabled, power_alert_high, power_alert_low,
+                       power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
+                       show_in_switchbar, last_state, last_checked, auto_off_executed,
+                       auto_off_pending, auto_off_pending_since, created_at, updated_at
+                FROM smart_plugs
+            """)
+            )
+            # Drop old table and rename new one
+            await conn.execute(text("DROP TABLE smart_plugs"))
+            await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
+    except Exception:
+        pass
+
+    # Migration: Add show_on_printer_card column to smart_plugs
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
+    except Exception:
+        pass
+
+    # Migration: Add MQTT smart plug fields (legacy)
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+
+    # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)"))
+    except Exception:
+        pass
+
+    # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
+    try:
+        await conn.execute(
+            text("""
+            UPDATE smart_plugs
+            SET mqtt_power_topic = mqtt_topic,
+                mqtt_power_multiplier = mqtt_multiplier
+            WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
+        """)
+        )
+    except Exception:
+        pass
+
+    # Migration: Create groups table for permission-based access control
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS groups (
+                id INTEGER PRIMARY KEY,
+                name VARCHAR(100) NOT NULL UNIQUE,
+                description VARCHAR(500),
+                permissions JSON,
+                is_system BOOLEAN NOT NULL DEFAULT 0,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        )
+        await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
+    except Exception:
+        pass
+
+    # Migration: Create user_groups association table
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS user_groups (
+                user_id INTEGER NOT NULL,
+                group_id INTEGER NOT NULL,
+                PRIMARY KEY (user_id, group_id),
+                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+                FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
+            )
+        """)
+        )
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
@@ -683,21 +941,99 @@ 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()
+
+
+async def seed_default_groups():
+    """Seed default groups and migrate existing users to appropriate groups.
+
+    Creates the default system groups (Administrators, Operators, Viewers) if they
+    don't exist, then migrates existing users:
+    - Users with role='admin' -> Administrators group
+    - Users with role='user' -> Operators group
+    """
+    import logging
+
+    from sqlalchemy import select
+
+    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.models.group import Group
+    from backend.app.models.user import User
+
+    logger = logging.getLogger(__name__)
+
+    async with async_session() as session:
+        # Get existing groups
+        result = await session.execute(select(Group.name))
+        existing_groups = {row[0] for row in result.fetchall()}
+
+        # Create default groups if they don't exist
+        groups_created = []
+        for group_name, group_config in DEFAULT_GROUPS.items():
+            if group_name not in existing_groups:
+                group = Group(
+                    name=group_name,
+                    description=group_config["description"],
+                    permissions=group_config["permissions"],
+                    is_system=group_config["is_system"],
+                )
+                session.add(group)
+                groups_created.append(group_name)
+                logger.info(f"Created default group: {group_name}")
 
         await session.commit()
+
+        # Migrate existing users to groups if they're not already in any group
+        if groups_created:
+            # Get the groups we need
+            admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
+            admin_group = admin_result.scalar_one_or_none()
+
+            operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
+            operators_group = operators_result.scalar_one_or_none()
+
+            # Get all users
+            users_result = await session.execute(select(User))
+            users = users_result.scalars().all()
+
+            for user in users:
+                # Skip if user already has groups
+                if user.groups:
+                    continue
+
+                if user.role == "admin" and admin_group:
+                    user.groups.append(admin_group)
+                    logger.info(f"Migrated admin user '{user.username}' to Administrators group")
+                elif operators_group:
+                    user.groups.append(operators_group)
+                    logger.info(f"Migrated user '{user.username}' to Operators group")
+
+            await session.commit()

+ 392 - 0
backend/app/core/permissions.py

@@ -0,0 +1,392 @@
+"""Permission definitions for the group-based access control system.
+
+This module defines all permissions using a string enum with `resource:action` naming.
+Permissions are additive across groups - a user has all permissions from all their groups.
+"""
+
+from enum import Enum
+
+
+class Permission(str, Enum):
+    """All available permissions in the system.
+
+    Permissions follow the pattern: resource:action
+    Actions typically include: read, create, update, delete, plus resource-specific actions.
+    """
+
+    # Printers
+    PRINTERS_READ = "printers:read"
+    PRINTERS_CREATE = "printers:create"
+    PRINTERS_UPDATE = "printers:update"
+    PRINTERS_DELETE = "printers:delete"
+    PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
+    PRINTERS_FILES = "printers:files"  # Send files to printer
+
+    # Archives
+    ARCHIVES_READ = "archives:read"
+    ARCHIVES_CREATE = "archives:create"
+    ARCHIVES_UPDATE = "archives:update"
+    ARCHIVES_DELETE = "archives:delete"
+    ARCHIVES_REPRINT = "archives:reprint"  # Reprint from archive
+
+    # Queue
+    QUEUE_READ = "queue:read"
+    QUEUE_CREATE = "queue:create"
+    QUEUE_UPDATE = "queue:update"
+    QUEUE_DELETE = "queue:delete"
+    QUEUE_REORDER = "queue:reorder"
+
+    # Library
+    LIBRARY_READ = "library:read"
+    LIBRARY_UPLOAD = "library:upload"
+    LIBRARY_UPDATE = "library:update"
+    LIBRARY_DELETE = "library:delete"
+
+    # Projects
+    PROJECTS_READ = "projects:read"
+    PROJECTS_CREATE = "projects:create"
+    PROJECTS_UPDATE = "projects:update"
+    PROJECTS_DELETE = "projects:delete"
+
+    # Filaments
+    FILAMENTS_READ = "filaments:read"
+    FILAMENTS_CREATE = "filaments:create"
+    FILAMENTS_UPDATE = "filaments:update"
+    FILAMENTS_DELETE = "filaments:delete"
+
+    # Smart Plugs
+    SMART_PLUGS_READ = "smart_plugs:read"
+    SMART_PLUGS_CREATE = "smart_plugs:create"
+    SMART_PLUGS_UPDATE = "smart_plugs:update"
+    SMART_PLUGS_DELETE = "smart_plugs:delete"
+    SMART_PLUGS_CONTROL = "smart_plugs:control"  # Turn on/off
+
+    # Camera
+    CAMERA_VIEW = "camera:view"
+
+    # Maintenance
+    MAINTENANCE_READ = "maintenance:read"
+    MAINTENANCE_CREATE = "maintenance:create"
+    MAINTENANCE_UPDATE = "maintenance:update"
+    MAINTENANCE_DELETE = "maintenance:delete"
+
+    # K-Profiles
+    KPROFILES_READ = "kprofiles:read"
+    KPROFILES_CREATE = "kprofiles:create"
+    KPROFILES_UPDATE = "kprofiles:update"
+    KPROFILES_DELETE = "kprofiles:delete"
+
+    # Notifications
+    NOTIFICATIONS_READ = "notifications:read"
+    NOTIFICATIONS_CREATE = "notifications:create"
+    NOTIFICATIONS_UPDATE = "notifications:update"
+    NOTIFICATIONS_DELETE = "notifications:delete"
+
+    # Notification Templates
+    NOTIFICATION_TEMPLATES_READ = "notification_templates:read"
+    NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update"
+
+    # External Links
+    EXTERNAL_LINKS_READ = "external_links:read"
+    EXTERNAL_LINKS_CREATE = "external_links:create"
+    EXTERNAL_LINKS_UPDATE = "external_links:update"
+    EXTERNAL_LINKS_DELETE = "external_links:delete"
+
+    # Discovery (network scanning)
+    DISCOVERY_SCAN = "discovery:scan"
+
+    # Firmware
+    FIRMWARE_READ = "firmware:read"
+    FIRMWARE_UPDATE = "firmware:update"
+
+    # AMS History
+    AMS_HISTORY_READ = "ams_history:read"
+
+    # Stats/Metrics
+    STATS_READ = "stats:read"
+
+    # System Info
+    SYSTEM_READ = "system:read"
+
+    # Settings (admin-level)
+    SETTINGS_READ = "settings:read"
+    SETTINGS_UPDATE = "settings:update"
+    SETTINGS_BACKUP = "settings:backup"
+    SETTINGS_RESTORE = "settings:restore"
+
+    # GitHub Backup (admin-level)
+    GITHUB_BACKUP = "github:backup"
+    GITHUB_RESTORE = "github:restore"
+
+    # Cloud Auth (admin-level)
+    CLOUD_AUTH = "cloud:auth"
+
+    # API Keys (admin-level)
+    API_KEYS_READ = "api_keys:read"
+    API_KEYS_CREATE = "api_keys:create"
+    API_KEYS_UPDATE = "api_keys:update"
+    API_KEYS_DELETE = "api_keys:delete"
+
+    # Users (admin-level)
+    USERS_READ = "users:read"
+    USERS_CREATE = "users:create"
+    USERS_UPDATE = "users:update"
+    USERS_DELETE = "users:delete"
+
+    # Groups (admin-level)
+    GROUPS_READ = "groups:read"
+    GROUPS_CREATE = "groups:create"
+    GROUPS_UPDATE = "groups:update"
+    GROUPS_DELETE = "groups:delete"
+
+    # WebSocket connection
+    WEBSOCKET_CONNECT = "websocket:connect"
+
+
+# Permission categories for UI organization
+PERMISSION_CATEGORIES = {
+    "Printers": [
+        Permission.PRINTERS_READ,
+        Permission.PRINTERS_CREATE,
+        Permission.PRINTERS_UPDATE,
+        Permission.PRINTERS_DELETE,
+        Permission.PRINTERS_CONTROL,
+        Permission.PRINTERS_FILES,
+    ],
+    "Archives": [
+        Permission.ARCHIVES_READ,
+        Permission.ARCHIVES_CREATE,
+        Permission.ARCHIVES_UPDATE,
+        Permission.ARCHIVES_DELETE,
+        Permission.ARCHIVES_REPRINT,
+    ],
+    "Queue": [
+        Permission.QUEUE_READ,
+        Permission.QUEUE_CREATE,
+        Permission.QUEUE_UPDATE,
+        Permission.QUEUE_DELETE,
+        Permission.QUEUE_REORDER,
+    ],
+    "Library": [
+        Permission.LIBRARY_READ,
+        Permission.LIBRARY_UPLOAD,
+        Permission.LIBRARY_UPDATE,
+        Permission.LIBRARY_DELETE,
+    ],
+    "Projects": [
+        Permission.PROJECTS_READ,
+        Permission.PROJECTS_CREATE,
+        Permission.PROJECTS_UPDATE,
+        Permission.PROJECTS_DELETE,
+    ],
+    "Filaments": [
+        Permission.FILAMENTS_READ,
+        Permission.FILAMENTS_CREATE,
+        Permission.FILAMENTS_UPDATE,
+        Permission.FILAMENTS_DELETE,
+    ],
+    "Smart Plugs": [
+        Permission.SMART_PLUGS_READ,
+        Permission.SMART_PLUGS_CREATE,
+        Permission.SMART_PLUGS_UPDATE,
+        Permission.SMART_PLUGS_DELETE,
+        Permission.SMART_PLUGS_CONTROL,
+    ],
+    "Camera": [
+        Permission.CAMERA_VIEW,
+    ],
+    "Maintenance": [
+        Permission.MAINTENANCE_READ,
+        Permission.MAINTENANCE_CREATE,
+        Permission.MAINTENANCE_UPDATE,
+        Permission.MAINTENANCE_DELETE,
+    ],
+    "K-Profiles": [
+        Permission.KPROFILES_READ,
+        Permission.KPROFILES_CREATE,
+        Permission.KPROFILES_UPDATE,
+        Permission.KPROFILES_DELETE,
+    ],
+    "Notifications": [
+        Permission.NOTIFICATIONS_READ,
+        Permission.NOTIFICATIONS_CREATE,
+        Permission.NOTIFICATIONS_UPDATE,
+        Permission.NOTIFICATIONS_DELETE,
+        Permission.NOTIFICATION_TEMPLATES_READ,
+        Permission.NOTIFICATION_TEMPLATES_UPDATE,
+    ],
+    "External Links": [
+        Permission.EXTERNAL_LINKS_READ,
+        Permission.EXTERNAL_LINKS_CREATE,
+        Permission.EXTERNAL_LINKS_UPDATE,
+        Permission.EXTERNAL_LINKS_DELETE,
+    ],
+    "Discovery": [
+        Permission.DISCOVERY_SCAN,
+    ],
+    "Firmware": [
+        Permission.FIRMWARE_READ,
+        Permission.FIRMWARE_UPDATE,
+    ],
+    "Stats & History": [
+        Permission.AMS_HISTORY_READ,
+        Permission.STATS_READ,
+    ],
+    "System": [
+        Permission.SYSTEM_READ,
+    ],
+    "Settings": [
+        Permission.SETTINGS_READ,
+        Permission.SETTINGS_UPDATE,
+        Permission.SETTINGS_BACKUP,
+        Permission.SETTINGS_RESTORE,
+    ],
+    "Backup": [
+        Permission.GITHUB_BACKUP,
+        Permission.GITHUB_RESTORE,
+    ],
+    "Cloud": [
+        Permission.CLOUD_AUTH,
+    ],
+    "API Keys": [
+        Permission.API_KEYS_READ,
+        Permission.API_KEYS_CREATE,
+        Permission.API_KEYS_UPDATE,
+        Permission.API_KEYS_DELETE,
+    ],
+    "User Management": [
+        Permission.USERS_READ,
+        Permission.USERS_CREATE,
+        Permission.USERS_UPDATE,
+        Permission.USERS_DELETE,
+        Permission.GROUPS_READ,
+        Permission.GROUPS_CREATE,
+        Permission.GROUPS_UPDATE,
+        Permission.GROUPS_DELETE,
+    ],
+    "WebSocket": [
+        Permission.WEBSOCKET_CONNECT,
+    ],
+}
+
+
+# All permissions as a list
+ALL_PERMISSIONS = [p.value for p in Permission]
+
+
+# Default group definitions
+DEFAULT_GROUPS = {
+    "Administrators": {
+        "description": "Full access to all features and settings",
+        "permissions": ALL_PERMISSIONS,  # All permissions
+        "is_system": True,
+    },
+    "Operators": {
+        "description": "Can control printers, manage queue and archives, view settings",
+        "permissions": [
+            # Printers - full control
+            Permission.PRINTERS_READ.value,
+            Permission.PRINTERS_CREATE.value,
+            Permission.PRINTERS_UPDATE.value,
+            Permission.PRINTERS_DELETE.value,
+            Permission.PRINTERS_CONTROL.value,
+            Permission.PRINTERS_FILES.value,
+            # Archives - full access
+            Permission.ARCHIVES_READ.value,
+            Permission.ARCHIVES_CREATE.value,
+            Permission.ARCHIVES_UPDATE.value,
+            Permission.ARCHIVES_DELETE.value,
+            Permission.ARCHIVES_REPRINT.value,
+            # Queue - full access
+            Permission.QUEUE_READ.value,
+            Permission.QUEUE_CREATE.value,
+            Permission.QUEUE_UPDATE.value,
+            Permission.QUEUE_DELETE.value,
+            Permission.QUEUE_REORDER.value,
+            # Library - full access
+            Permission.LIBRARY_READ.value,
+            Permission.LIBRARY_UPLOAD.value,
+            Permission.LIBRARY_UPDATE.value,
+            Permission.LIBRARY_DELETE.value,
+            # Projects - full access
+            Permission.PROJECTS_READ.value,
+            Permission.PROJECTS_CREATE.value,
+            Permission.PROJECTS_UPDATE.value,
+            Permission.PROJECTS_DELETE.value,
+            # Filaments - full access
+            Permission.FILAMENTS_READ.value,
+            Permission.FILAMENTS_CREATE.value,
+            Permission.FILAMENTS_UPDATE.value,
+            Permission.FILAMENTS_DELETE.value,
+            # Smart Plugs - full access
+            Permission.SMART_PLUGS_READ.value,
+            Permission.SMART_PLUGS_CREATE.value,
+            Permission.SMART_PLUGS_UPDATE.value,
+            Permission.SMART_PLUGS_DELETE.value,
+            Permission.SMART_PLUGS_CONTROL.value,
+            # Camera - view
+            Permission.CAMERA_VIEW.value,
+            # Maintenance - full access
+            Permission.MAINTENANCE_READ.value,
+            Permission.MAINTENANCE_CREATE.value,
+            Permission.MAINTENANCE_UPDATE.value,
+            Permission.MAINTENANCE_DELETE.value,
+            # K-Profiles - full access
+            Permission.KPROFILES_READ.value,
+            Permission.KPROFILES_CREATE.value,
+            Permission.KPROFILES_UPDATE.value,
+            Permission.KPROFILES_DELETE.value,
+            # Notifications - full access
+            Permission.NOTIFICATIONS_READ.value,
+            Permission.NOTIFICATIONS_CREATE.value,
+            Permission.NOTIFICATIONS_UPDATE.value,
+            Permission.NOTIFICATIONS_DELETE.value,
+            Permission.NOTIFICATION_TEMPLATES_READ.value,
+            Permission.NOTIFICATION_TEMPLATES_UPDATE.value,
+            # External Links - full access
+            Permission.EXTERNAL_LINKS_READ.value,
+            Permission.EXTERNAL_LINKS_CREATE.value,
+            Permission.EXTERNAL_LINKS_UPDATE.value,
+            Permission.EXTERNAL_LINKS_DELETE.value,
+            # Discovery
+            Permission.DISCOVERY_SCAN.value,
+            # Firmware - read only
+            Permission.FIRMWARE_READ.value,
+            # Stats & History
+            Permission.AMS_HISTORY_READ.value,
+            Permission.STATS_READ.value,
+            Permission.SYSTEM_READ.value,
+            # Settings - read only
+            Permission.SETTINGS_READ.value,
+            # WebSocket
+            Permission.WEBSOCKET_CONNECT.value,
+        ],
+        "is_system": True,
+    },
+    "Viewers": {
+        "description": "Read-only access to printers, archives, and queue",
+        "permissions": [
+            # Read-only access
+            Permission.PRINTERS_READ.value,
+            Permission.ARCHIVES_READ.value,
+            Permission.QUEUE_READ.value,
+            Permission.LIBRARY_READ.value,
+            Permission.PROJECTS_READ.value,
+            Permission.FILAMENTS_READ.value,
+            Permission.SMART_PLUGS_READ.value,
+            Permission.CAMERA_VIEW.value,
+            Permission.MAINTENANCE_READ.value,
+            Permission.KPROFILES_READ.value,
+            Permission.NOTIFICATIONS_READ.value,
+            Permission.NOTIFICATION_TEMPLATES_READ.value,
+            Permission.EXTERNAL_LINKS_READ.value,
+            Permission.FIRMWARE_READ.value,
+            Permission.AMS_HISTORY_READ.value,
+            Permission.STATS_READ.value,
+            Permission.SYSTEM_READ.value,
+            Permission.SETTINGS_READ.value,
+            Permission.WEBSOCKET_CONNECT.value,
+        ],
+        "is_system": True,
+    },
+}

+ 587 - 73
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,12 @@ from backend.app.api.routes import (
     external_links,
     filaments,
     firmware,
+    github_backup,
+    groups,
     kprofiles,
     library,
     maintenance,
+    metrics,
     notification_templates,
     notifications,
     pending_uploads,
@@ -88,6 +212,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 +225,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,11 +236,23 @@ _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).
+    """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
 
     For HA plugs, configures the service with current settings from DB.
+    For MQTT plugs, returns data from the subscription service.
     """
     if plug.plug_type == "homeassistant":
         from backend.app.api.routes.settings import get_setting
@@ -125,6 +261,17 @@ async def _get_plug_energy(plug, db) -> dict | None:
         ha_token = await get_setting(db, "ha_token") or ""
         homeassistant_service.configure(ha_url, ha_token)
         return await homeassistant_service.get_energy(plug)
+    elif plug.plug_type == "mqtt":
+        # MQTT plugs report "today" energy, not lifetime total
+        # For per-print tracking, we use "today" as the counter (resets at midnight)
+        mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+        if mqtt_data:
+            return {
+                "power": mqtt_data.power,
+                "today": mqtt_data.energy,
+                "total": mqtt_data.energy,  # Use today as total for per-print calculations
+            }
+        return None
     else:
         return await tasmota_service.get_energy(plug)
 
@@ -279,6 +426,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 +726,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 +879,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 +1022,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 +1065,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 +1148,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 +1234,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 +1624,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 +1739,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 +1920,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
@@ -1549,6 +2001,34 @@ async def on_print_complete(printer_id: int, data: dict):
                 except Exception:
                     pass  # Don't fail if MQTT fails
 
+                # Check if queue is now empty and send notification
+                try:
+                    from sqlalchemy import func
+
+                    # Count remaining pending items
+                    count_result = await db.execute(
+                        select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending")
+                    )
+                    pending_count = count_result.scalar() or 0
+
+                    if pending_count == 0:
+                        # Count how many completed today (rough approximation)
+                        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+                        completed_result = await db.execute(
+                            select(func.count(PrintQueueItem.id)).where(
+                                PrintQueueItem.status.in_(["completed", "failed", "skipped"]),
+                                PrintQueueItem.completed_at >= today_start,
+                            )
+                        )
+                        completed_count = completed_result.scalar() or 1
+
+                        await notification_service.on_queue_completed(
+                            completed_count=completed_count,
+                            db=db,
+                        )
+                except Exception:
+                    pass  # Don't fail if notification fails
+
                 # Handle auto_off_after - power off printer if requested (after cooldown)
                 if queue_item.auto_off_after:
                     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
@@ -1904,6 +2384,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
@@ -1919,6 +2408,27 @@ async def lifespan(app: FastAPI):
         }
         await mqtt_relay.configure(mqtt_settings)
 
+        # Restore MQTT smart plug subscriptions
+        if mqtt_settings.get("mqtt_enabled"):
+            from sqlalchemy import select
+
+            from backend.app.models.smart_plug import SmartPlug
+
+            result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
+            mqtt_plugs = result.scalars().all()
+            for plug in mqtt_plugs:
+                if plug.mqtt_topic:
+                    mqtt_relay.smart_plug_service.subscribe(
+                        plug_id=plug.id,
+                        topic=plug.mqtt_topic,
+                        power_path=plug.mqtt_power_path,
+                        energy_path=plug.mqtt_energy_path,
+                        state_path=plug.mqtt_state_path,
+                        multiplier=plug.mqtt_multiplier or 1.0,
+                    )
+            if mqtt_plugs:
+                logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions")
+
     # Connect to all active printers
     async with async_session() as db:
         await init_printer_connections(db)
@@ -1954,15 +2464,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 +2506,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()
@@ -2016,6 +2527,7 @@ app = FastAPI(
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
+app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
@@ -2042,6 +2554,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)

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

@@ -2,6 +2,8 @@ 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.group import Group, user_groups
 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 +35,8 @@ __all__ = [
     "LibraryFolder",
     "LibraryFile",
     "User",
+    "Group",
+    "user_groups",
+    "GitHubBackupConfig",
+    "GitHubBackupLog",
 ]

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

@@ -35,6 +35,9 @@ class PrintArchive(Base):
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
 
+    # Printer model this file was sliced for (extracted from 3MF metadata)
+    sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+
     # Print result
     status: Mapped[str] = mapped_column(String(20), default="completed")
     started_at: Mapped[datetime | None] = mapped_column(DateTime)
@@ -43,10 +46,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")

+ 54 - 0
backend/app/models/group.py

@@ -0,0 +1,54 @@
+"""Group model for permission-based access control."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.types import JSON
+
+from backend.app.core.database import Base
+
+if TYPE_CHECKING:
+    from backend.app.models.user import User
+
+
+# Many-to-many association table between users and groups
+user_groups = Table(
+    "user_groups",
+    Base.metadata,
+    Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
+    Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
+)
+
+
+class Group(Base):
+    """Group model for organizing users and assigning permissions.
+
+    Groups contain a list of permissions that are granted to all members.
+    Users can belong to multiple groups, and their permissions are additive.
+    System groups (Administrators, Operators, Viewers) cannot be deleted.
+    """
+
+    __tablename__ = "groups"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
+    description: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
+    is_system: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationship to users through association table
+    users: Mapped[list[User]] = relationship(
+        "User",
+        secondary=user_groups,
+        back_populates="groups",
+        lazy="selectin",
+    )
+
+    def __repr__(self) -> str:
+        return f"<Group {self.name}>"

+ 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

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

@@ -80,6 +80,18 @@ 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
+
+    # Event triggers - Print queue
+    on_queue_job_added = Column(Boolean, default=False)  # Job added to queue
+    on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer
+    on_queue_job_started = Column(Boolean, default=False)  # Queue job started printing
+    on_queue_job_waiting = Column(Boolean, default=True)  # Job waiting for filament
+    on_queue_job_skipped = Column(Boolean, default=True)  # Job skipped (previous print failed)
+    on_queue_job_failed = Column(Boolean, default=True)  # Job failed to start
+    on_queue_completed = Column(Boolean, default=False)  # All pending jobs finished
+
     # 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"

+ 49 - 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",
@@ -97,4 +103,47 @@ DEFAULT_TEMPLATES = [
         "title_template": "Bambuddy Test",
         "body_template": "This is a test notification. If you see this, notifications are working!",
     },
+    # Queue notifications
+    {
+        "event_type": "queue_job_added",
+        "name": "Queue Job Added",
+        "title_template": "Job Queued",
+        "body_template": "{job_name} added to queue for {target}",
+    },
+    {
+        "event_type": "queue_job_assigned",
+        "name": "Queue Job Assigned",
+        "title_template": "Job Assigned",
+        "body_template": "{job_name} assigned to {printer} (from Any {target_model} queue)",
+    },
+    {
+        "event_type": "queue_job_started",
+        "name": "Queue Job Started",
+        "title_template": "Queue Job Started",
+        "body_template": "{printer}: {job_name}\nEstimated: {estimated_time}",
+    },
+    {
+        "event_type": "queue_job_waiting",
+        "name": "Queue Job Waiting",
+        "title_template": "Job Waiting for Filament",
+        "body_template": "{job_name} waiting for {target_model}\n{waiting_reason}",
+    },
+    {
+        "event_type": "queue_job_skipped",
+        "name": "Queue Job Skipped",
+        "title_template": "Job Skipped",
+        "body_template": "{printer}: {job_name}\nReason: {reason}",
+    },
+    {
+        "event_type": "queue_job_failed",
+        "name": "Queue Job Failed",
+        "title_template": "Job Failed to Start",
+        "body_template": "{printer}: {job_name}\nReason: {reason}",
+    },
+    {
+        "event_type": "queue_completed",
+        "name": "Queue Completed",
+        "title_template": "Queue Complete",
+        "body_template": "All {completed_count} queued jobs have finished",
+    },
 ]

+ 9 - 0
backend/app/models/print_queue.py

@@ -15,6 +15,15 @@ class PrintQueueItem(Base):
 
     # Links
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
+    # Target printer model for model-based assignment (mutually exclusive with printer_id)
+    # When set, scheduler assigns to any idle printer of matching model
+    target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
+    # Used by scheduler to validate printer has compatible filaments loaded
+    required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
+    # Waiting reason - explains why a model-based job hasn't started yet
+    # Set by scheduler when no matching printer is available
+    waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     # Either archive_id OR library_file_id must be set (archive created at print start from library file)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
     library_file_id: Mapped[int | None] = mapped_column(

+ 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())
 

+ 31 - 2
backend/app/models/smart_plug.py

@@ -7,7 +7,7 @@ from backend.app.core.database import Base
 
 
 class SmartPlug(Base):
-    """Smart plug for printer power control (Tasmota or Home Assistant)."""
+    """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT)."""
 
     __tablename__ = "smart_plugs"
 
@@ -15,7 +15,7 @@ class SmartPlug(Base):
     name: Mapped[str] = mapped_column(String(100))
     ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
 
-    # Plug type: "tasmota" (default) or "homeassistant"
+    # Plug type: "tasmota" (default), "homeassistant", or "mqtt"
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
@@ -24,6 +24,32 @@ class SmartPlug(Base):
     ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_today
     ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_total
 
+    # MQTT plug fields (required when plug_type="mqtt")
+    # Legacy field - kept for backward compatibility, now use mqtt_power_topic
+    mqtt_topic: Mapped[str | None] = mapped_column(
+        String(200), nullable=True
+    )  # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic)
+
+    # Power monitoring
+    mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for power data
+    mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "power_l1" or "data.power"
+    mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for power
+
+    # Energy monitoring
+    mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for energy data
+    mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "energy_l1"
+    mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for energy
+
+    # State monitoring
+    mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for state data
+    mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "state_l1" for ON/OFF
+    mqtt_state_on_value: Mapped[str | None] = mapped_column(
+        String(50), nullable=True
+    )  # What value means "ON" (e.g., "ON", "true", "1")
+
+    # Legacy multiplier - kept for backward compatibility
+    mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier
+
     # Link to printer (1:1)
     printer_id: Mapped[int | None] = mapped_column(
         ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
@@ -57,6 +83,9 @@ class SmartPlug(Base):
     # Switchbar visibility
     show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
 
+    # Printer card visibility (for scripts)
+    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)
+
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 82 - 3
backend/app/models/user.py

@@ -1,20 +1,99 @@
+from __future__ import annotations
+
 from datetime import datetime
+from typing import TYPE_CHECKING
 
 from sqlalchemy import DateTime, String, func
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
+if TYPE_CHECKING:
+    from backend.app.models.group import Group
+
 
 class User(Base):
-    """User model for authentication and authorization."""
+    """User model for authentication and authorization.
+
+    Users can belong to multiple groups, and their permissions are additive
+    across all groups. The legacy 'role' field is kept for backward compatibility
+    but is_admin property now also considers group membership.
+    """
 
     __tablename__ = "users"
 
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     password_hash: Mapped[str] = mapped_column(String(255))
-    role: Mapped[str] = mapped_column(String(20), default="user")  # "admin" or "user"
+    role: Mapped[str] = mapped_column(
+        String(20), default="user"
+    )  # "admin" or "user" (legacy, kept for backward compat)
     is_active: Mapped[bool] = mapped_column(default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationship to groups through association table
+    groups: Mapped[list[Group]] = relationship(
+        "Group",
+        secondary="user_groups",
+        back_populates="users",
+        lazy="selectin",
+    )
+
+    @property
+    def is_admin(self) -> bool:
+        """Check if user is an admin.
+
+        Returns True if:
+        - User has legacy role='admin', OR
+        - User belongs to the Administrators group
+        """
+        if self.role == "admin":
+            return True
+        return any(g.name == "Administrators" for g in self.groups)
+
+    def get_permissions(self) -> set[str]:
+        """Get all permissions from all groups the user belongs to.
+
+        Returns a set of permission strings. Permissions are additive across groups.
+        """
+        permissions: set[str] = set()
+        for group in self.groups:
+            if group.permissions:
+                permissions.update(group.permissions)
+        return permissions
+
+    def has_permission(self, permission: str) -> bool:
+        """Check if user has a specific permission.
+
+        Admins have all permissions. For other users, checks if the permission
+        exists in any of their groups.
+        """
+        if self.is_admin:
+            return True
+        return permission in self.get_permissions()
+
+    def has_all_permissions(self, *permissions: str) -> bool:
+        """Check if user has ALL specified permissions.
+
+        Admins have all permissions. For other users, checks if all permissions
+        exist in their combined group permissions.
+        """
+        if self.is_admin:
+            return True
+        user_permissions = self.get_permissions()
+        return all(p in user_permissions for p in permissions)
+
+    def has_any_permission(self, *permissions: str) -> bool:
+        """Check if user has ANY of the specified permissions.
+
+        Admins have all permissions. For other users, checks if at least one
+        permission exists in their combined group permissions.
+        """
+        if self.is_admin:
+            return True
+        user_permissions = self.get_permissions()
+        return any(p in user_permissions for p in permissions)
+
+    def __repr__(self) -> str:
+        return f"<User {self.username}>"

+ 4 - 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):
@@ -62,6 +63,8 @@ class ArchiveResponse(BaseModel):
     bed_temperature: int | None
     nozzle_temperature: int | None
 
+    sliced_for_model: str | None = None  # Printer model this file was sliced for
+
     status: str
     started_at: datetime | None
     completed_at: datetime | None
@@ -70,6 +73,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

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

@@ -1,6 +1,16 @@
 from pydantic import BaseModel
 
 
+class GroupBrief(BaseModel):
+    """Brief group info for embedding in user responses."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
 class LoginRequest(BaseModel):
     username: str
     password: str
@@ -16,6 +26,7 @@ class UserCreate(BaseModel):
     username: str
     password: str
     role: str = "user"
+    group_ids: list[int] | None = None
 
 
 class UserUpdate(BaseModel):
@@ -23,19 +34,28 @@ class UserUpdate(BaseModel):
     password: str | None = None
     role: str | None = None
     is_active: bool | None = None
+    group_ids: list[int] | None = None
 
 
 class UserResponse(BaseModel):
     id: int
     username: str
-    role: str
+    role: str  # Deprecated, kept for backward compatibility
     is_active: bool
+    is_admin: bool  # Computed from role and group membership
+    groups: list[GroupBrief] = []
+    permissions: list[str] = []  # All permissions from groups
     created_at: str
 
     class Config:
         from_attributes = True
 
 
+class ChangePasswordRequest(BaseModel):
+    current_password: str
+    new_password: str
+
+
 class SetupRequest(BaseModel):
     auth_enabled: bool
     admin_username: str | None = 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

+ 89 - 0
backend/app/schemas/group.py

@@ -0,0 +1,89 @@
+"""Pydantic schemas for Group CRUD operations."""
+
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class GroupBrief(BaseModel):
+    """Brief group info for embedding in other responses."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
+class GroupCreate(BaseModel):
+    """Schema for creating a new group."""
+
+    name: str
+    description: str | None = None
+    permissions: list[str] = []
+
+
+class GroupUpdate(BaseModel):
+    """Schema for updating a group."""
+
+    name: str | None = None
+    description: str | None = None
+    permissions: list[str] | None = None
+
+
+class GroupResponse(BaseModel):
+    """Schema for group response."""
+
+    id: int
+    name: str
+    description: str | None
+    permissions: list[str]
+    is_system: bool
+    user_count: int = 0
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class GroupDetailResponse(GroupResponse):
+    """Schema for detailed group response including users."""
+
+    users: list["UserBrief"] = []
+
+
+class UserBrief(BaseModel):
+    """Brief user info for embedding in group response."""
+
+    id: int
+    username: str
+    is_active: bool
+
+    class Config:
+        from_attributes = True
+
+
+class PermissionInfo(BaseModel):
+    """Schema for permission information."""
+
+    value: str
+    label: str
+
+
+class PermissionCategory(BaseModel):
+    """Schema for a category of permissions."""
+
+    name: str
+    permissions: list[PermissionInfo]
+
+
+class PermissionsListResponse(BaseModel):
+    """Schema for listing all permissions by category."""
+
+    categories: list[PermissionCategory]
+    all_permissions: list[str]
+
+
+# Update forward references
+GroupDetailResponse.model_rebuild()

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

@@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel):
     folders_created: int
     files: list[ZipExtractResult]
     errors: list[ZipExtractError]
+
+
+# ============ STL Thumbnail Generation ============
+
+
+class BatchThumbnailRequest(BaseModel):
+    """Schema for batch STL thumbnail generation request."""
+
+    file_ids: list[int] | None = None
+    folder_id: int | None = None
+    all_missing: bool = False
+
+
+class BatchThumbnailResult(BaseModel):
+    """Result for a single file thumbnail generation."""
+
+    file_id: int
+    filename: str
+    success: bool
+    error: str | None = None
+
+
+class BatchThumbnailResponse(BaseModel):
+    """Schema for batch thumbnail generation response."""
+
+    processed: int
+    succeeded: int
+    failed: int
+    results: list[BatchThumbnailResult]

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

@@ -50,6 +50,18 @@ 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")
+
+    # Event triggers - Print queue
+    on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
+    on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
+    on_queue_job_started: bool = Field(default=False, description="Notify when queue job starts printing")
+    on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament")
+    on_queue_job_skipped: bool = Field(default=True, description="Notify when job is skipped")
+    on_queue_job_failed: bool = Field(default=True, description="Notify when job fails to start")
+    on_queue_completed: bool = Field(default=False, description="Notify when all queue jobs finish")
+
     # 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 +126,18 @@ 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
+
+    # Event triggers - Print queue
+    on_queue_job_added: bool | None = None
+    on_queue_job_assigned: bool | None = None
+    on_queue_job_started: bool | None = None
+    on_queue_job_waiting: bool | None = None
+    on_queue_job_skipped: bool | None = None
+    on_queue_job_failed: bool | None = None
+    on_queue_completed: bool | None = None
+
     # Quiet hours
     quiet_hours_enabled: bool | None = None
     quiet_hours_start: str | None = None

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

@@ -45,6 +45,14 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
+    # Queue notifications
+    "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
+    "queue_job_assigned": ["job_name", "printer", "target_model", "timestamp", "app_name"],
+    "queue_job_started": ["printer", "job_name", "estimated_time", "timestamp", "app_name"],
+    "queue_job_waiting": ["job_name", "target_model", "waiting_reason", "timestamp", "app_name"],
+    "queue_job_skipped": ["printer", "job_name", "reason", "timestamp", "app_name"],
+    "queue_job_failed": ["printer", "job_name", "reason", "timestamp", "app_name"],
+    "queue_completed": ["completed_count", "timestamp", "app_name"],
 }
 
 # Sample data for previewing templates
@@ -136,6 +144,53 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
     },
+    # Queue notifications
+    "queue_job_added": {
+        "job_name": "Benchy.3mf",
+        "target": "Bambu X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_assigned": {
+        "job_name": "Benchy.3mf",
+        "printer": "Bambu X1C #1",
+        "target_model": "X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_started": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "estimated_time": "1h 23m",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_waiting": {
+        "job_name": "Benchy.3mf",
+        "target_model": "X1C",
+        "waiting_reason": "Printer1 (needs PLA)",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_skipped": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "reason": "Previous print failed",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_job_failed": {
+        "printer": "Bambu X1C",
+        "job_name": "Benchy.3mf",
+        "reason": "Upload failed: connection timeout",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "queue_completed": {
+        "completed_count": "5",
+        "timestamp": "2024-01-15 18:30",
+        "app_name": "Bambuddy",
+    },
 }
 
 

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

@@ -17,6 +17,8 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
     library_file_id: int | None = None
@@ -40,6 +42,7 @@ class PrintQueueItemCreate(BaseModel):
 
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
+    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     position: int | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
@@ -59,6 +62,9 @@ class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     printer_id: int | None  # None = unassigned
+    target_model: str | None = None  # Target printer model for model-based assignment
+    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
+    waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
     position: int
@@ -100,3 +106,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

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

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
 
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
-    plug_type: Literal["tasmota", "homeassistant"] = "tasmota"
+    plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota"
 
     # Tasmota fields (required when plug_type="tasmota")
     ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
@@ -14,12 +14,36 @@ class SmartPlugBase(BaseModel):
     password: str | None = None
 
     # Home Assistant fields (required when plug_type="homeassistant")
-    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
+    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
     # Home Assistant energy sensor entities (optional, for separate energy sensors)
     ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
 
+    # MQTT fields (required when plug_type="mqtt")
+    # Legacy field - kept for backward compatibility
+    mqtt_topic: str | None = Field(default=None, max_length=200)  # Deprecated, use mqtt_power_topic
+
+    # Power monitoring
+    mqtt_power_topic: str | None = Field(default=None, max_length=200)  # Topic for power data
+    mqtt_power_path: str | None = Field(default=None, max_length=100)  # e.g., "power_l1" or "data.power"
+    mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for power
+
+    # Energy monitoring
+    mqtt_energy_topic: str | None = Field(default=None, max_length=200)  # Topic for energy data
+    mqtt_energy_path: str | None = Field(default=None, max_length=100)  # e.g., "energy_l1"
+    mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for energy
+
+    # State monitoring
+    mqtt_state_topic: str | None = Field(default=None, max_length=200)  # Topic for state data
+    mqtt_state_path: str | None = Field(default=None, max_length=100)  # e.g., "state_l1" for ON/OFF
+    mqtt_state_on_value: str | None = Field(
+        default=None, max_length=50
+    )  # What value means "ON" (e.g., "ON", "true", "1")
+
+    # Legacy multiplier - kept for backward compatibility
+    mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Deprecated, use mqtt_power_multiplier
+
     printer_id: int | None = None
     enabled: bool = True
     auto_on: bool = True
@@ -35,8 +59,9 @@ class SmartPlugBase(BaseModel):
     schedule_enabled: bool = False
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool = False
+    show_on_printer_card: bool = True  # For scripts: show on printer card
 
     @model_validator(mode="after")
     def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -44,6 +69,17 @@ class SmartPlugBase(BaseModel):
             raise ValueError("ip_address is required for Tasmota plugs")
         if self.plug_type == "homeassistant" and not self.ha_entity_id:
             raise ValueError("ha_entity_id is required for Home Assistant plugs")
+        if self.plug_type == "mqtt":
+            # Determine the effective power topic (new field takes priority, fall back to legacy)
+            power_topic = self.mqtt_power_topic or self.mqtt_topic
+            # Path is optional - if not set, raw MQTT payload value will be used
+            has_power = bool(power_topic)
+            has_energy = bool(self.mqtt_energy_topic)
+            has_state = bool(self.mqtt_state_topic)
+
+            # At least one data source must be configured (path is optional)
+            if not has_power and not has_energy and not has_state:
+                raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
         return self
 
 
@@ -53,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase):
 
 class SmartPlugUpdate(BaseModel):
     name: str | None = None
-    plug_type: Literal["tasmota", "homeassistant"] | None = None
+    plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
     ip_address: str | None = None
     ha_entity_id: str | None = None
     # Home Assistant energy sensor entities (optional)
     ha_power_entity: str | None = None
     ha_energy_today_entity: str | None = None
     ha_energy_total_entity: str | None = None
+    # MQTT fields (legacy)
+    mqtt_topic: str | None = None
+    mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT power fields
+    mqtt_power_topic: str | None = None
+    mqtt_power_path: str | None = None
+    mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT energy fields
+    mqtt_energy_topic: str | None = None
+    mqtt_energy_path: str | None = None
+    mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT state fields
+    mqtt_state_topic: str | None = None
+    mqtt_state_path: str | None = None
+    mqtt_state_on_value: str | None = None
     printer_id: int | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
@@ -77,8 +128,9 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool | None = None
+    show_on_printer_card: bool | None = None
 
 
 class SmartPlugResponse(SmartPlugBase):
@@ -147,7 +199,7 @@ class HAEntity(BaseModel):
     entity_id: str
     friendly_name: str
     state: str | None = None
-    domain: str  # "switch", "light", "input_boolean"
+    domain: str  # "switch", "light", "input_boolean", "script"
 
 
 class HASensorEntity(BaseModel):

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

@@ -67,6 +67,19 @@ class ThreeMFParser:
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
 
+                # Extract printer_model_id from plate metadata
+                # Format: <plate><metadata key="printer_model_id" value="C11" /></plate>
+                for meta in root.findall(".//metadata"):
+                    key = meta.get("key")
+                    value = meta.get("value")
+                    if key == "printer_model_id" and value:
+                        from backend.app.utils.printer_models import normalize_printer_model_id
+
+                        normalized = normalize_printer_model_id(value)
+                        if normalized:
+                            self.metadata["sliced_for_model"] = normalized
+                        break
+
                 # Find the plate element (single-plate exports only have one plate)
                 plate = root.find(".//plate")
 
@@ -156,7 +169,7 @@ class ThreeMFParser:
             pass
 
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
-        """Parse G-code file header for total layer count."""
+        """Parse G-code file header for total layer count and printer model."""
         import re
 
         try:
@@ -165,15 +178,25 @@ class ThreeMFParser:
             if not gcode_files:
                 return
 
-            # Read first 2KB of G-code (header contains the layer count)
+            # Read first 4KB of G-code (header contains metadata)
             gcode_path = gcode_files[0]
             with zf.open(gcode_path) as f:
-                header = f.read(2048).decode("utf-8", errors="ignore")
+                header = f.read(4096).decode("utf-8", errors="ignore")
 
             # Look for "; total layer number: XX" pattern
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             if match:
                 self.metadata["total_layers"] = int(match.group(1))
+
+            # Look for printer_model in gcode header (fallback if not found in slice_info)
+            # Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C"
+            if "sliced_for_model" not in self.metadata:
+                match = re.search(r";\s*printer_model\s*=\s*(.+)", header, re.IGNORECASE)
+                if match:
+                    from backend.app.utils.printer_models import normalize_printer_model
+
+                    raw_model = match.group(1).strip()
+                    self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
         except Exception:
             pass
 
@@ -256,6 +279,12 @@ class ThreeMFParser:
                     elif isinstance(val, (int, float, str)):
                         self.metadata["nozzle_temperature"] = int(float(val))
                     break
+
+            # Printer model (extract and normalize)
+            if "printer_model" in data:
+                from backend.app.utils.printer_models import normalize_printer_model
+
+                self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
         except Exception:
             pass
 
@@ -877,6 +906,7 @@ class ArchiveService:
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
+            sliced_for_model=metadata.get("sliced_for_model"),
             makerworld_url=metadata.get("makerworld_url"),
             designer=metadata.get("designer"),
             status=status,
@@ -919,6 +949,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:

+ 73 - 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
@@ -1964,6 +2031,8 @@ class BambuMQTTClient:
             ams_mapping2 = []
             if ams_mapping is not None:
                 for tray_id in ams_mapping:
+                    # Ensure tray_id is an integer (may be string from JSON)
+                    tray_id = int(tray_id) if tray_id is not None else -1
                     if tray_id == -1 or tray_id == 255:
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                     else:

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

@@ -74,7 +74,7 @@ def supports_rtsp(model: str | None) -> bool:
       - O1D: H2D
       - O1C: H2C
       - O1S: H2S
-      - O1E: H2D Pro
+      - O1E, O2D: H2D Pro
       - N7: P2S
     """
     if model:
@@ -83,7 +83,7 @@ def supports_rtsp(model: str | None) -> bool:
         if model_upper.startswith(("X1", "H2", "P2")):
             return True
         # Internal codes for RTSP models
-        if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1S", "O1E", "N7"):
+        if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1S", "O1E", "O2D", "N7"):
             return True
     # A1/P1 and unknown models use chamber image protocol
     return False

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

@@ -0,0 +1,778 @@
+"""External camera service.
+
+Supports MJPEG streams, RTSP streams (via ffmpeg), HTTP snapshot URLs, and USB cameras.
+
+Security Note: This service intentionally makes requests to user-configured camera URLs.
+This is necessary functionality for external camera integration. URLs are validated
+to ensure they are well-formed before use.
+"""
+
+import asyncio
+import logging
+import re
+import shutil
+from collections.abc import AsyncGenerator
+from pathlib import Path
+from urllib.parse import urlparse
+
+import aiohttp
+
+logger = logging.getLogger(__name__)
+
+
+def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
+    """Validate and sanitize camera URL, returning a safe reconstructed URL.
+
+    This validates that the URL is well-formed, uses an allowed scheme,
+    does not target cloud metadata services, and returns a reconstructed
+    URL from validated components.
+
+    Note: This intentionally allows user-provided URLs as that is the
+    purpose of external camera configuration. Local network IPs are
+    allowed since cameras are typically on the same LAN.
+
+    Args:
+        url: URL to validate and sanitize
+        allowed_schemes: Tuple of allowed URL schemes
+
+    Returns:
+        Sanitized URL string if valid, None otherwise
+    """
+    try:
+        parsed = urlparse(url)
+        if not parsed.scheme or not parsed.netloc:
+            return None
+
+        # Validate scheme against allowlist
+        scheme = parsed.scheme.lower()
+        if scheme not in allowed_schemes:
+            return None
+
+        # Block cloud metadata service endpoints (SSRF mitigation)
+        # These are dangerous destinations that should never be accessed
+        hostname = parsed.hostname or ""
+        hostname_lower = hostname.lower()
+        blocked_hosts = (
+            "169.254.169.254",  # AWS/GCP/Azure metadata
+            "metadata.google.internal",  # GCP metadata
+            "metadata.google",
+            "localhost",  # Block localhost to prevent internal service access
+            "127.0.0.1",
+            "::1",
+            "0.0.0.0",
+        )
+        if hostname_lower in blocked_hosts:
+            logger.warning(f"Blocked camera URL targeting restricted host: {hostname}")
+            return None
+
+        # Block link-local addresses (169.254.x.x)
+        if hostname.startswith("169.254."):
+            logger.warning(f"Blocked camera URL targeting link-local address: {hostname}")
+            return None
+
+        # Reconstruct URL from validated components to break taint chain
+        # This creates a new string from validated parts
+        port_str = f":{parsed.port}" if parsed.port else ""
+        path = parsed.path or ""
+        query = f"?{parsed.query}" if parsed.query else ""
+        fragment = f"#{parsed.fragment}" if parsed.fragment else ""
+
+        # Build sanitized URL from validated components
+        sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
+        return sanitized
+    except Exception:
+        return None
+
+
+def _validate_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> bool:
+    """Validate camera URL format (legacy wrapper).
+
+    Args:
+        url: URL to validate
+        allowed_schemes: Tuple of allowed URL schemes
+
+    Returns:
+        True if URL is valid, False otherwise
+    """
+    return _sanitize_camera_url(url, allowed_schemes) is not None
+
+
+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 - must be /dev/videoN format where N is 0-99
+    # This prevents path traversal by using a strict allowlist approach
+    import re as regex_module
+
+    device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
+    if not device_match:
+        logger.error(f"Invalid USB device path format: {device}")
+        return None
+
+    # Convert to integer to break taint chain - integers cannot contain path traversal
+    # lgtm[py/path-injection] - device_num is validated integer 0-99
+    device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
+    if device_num > 99:
+        logger.error(f"USB device number out of range: {device_num}")
+        return None
+
+    # Construct safe path from validated integer (completely untainted)
+    safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
+
+    if not safe_device_path.exists():
+        logger.error(f"USB device does not exist: {safe_device_path}")
+        return None
+
+    # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
+    device = str(safe_device_path)  # lgtm[py/path-injection]
+
+    # 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.
+
+    Note: This function intentionally makes requests to user-configured URLs.
+    External camera support requires connecting to user-specified camera endpoints.
+    URL is sanitized and dangerous destinations are blocked.
+    """
+    # Sanitize URL - returns reconstructed URL from validated components
+    safe_url = _sanitize_camera_url(url, ("http", "https"))
+    if not safe_url:
+        logger.error(f"Invalid MJPEG URL format: {url[:50]}...")
+        return None
+
+    try:
+        async with (
+            aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
+            session.get(safe_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.
+
+    Note: This function intentionally makes requests to user-configured URLs.
+    External camera support requires connecting to user-specified camera endpoints.
+    URL is sanitized and dangerous destinations are blocked.
+    """
+    # Sanitize URL - returns reconstructed URL from validated components
+    safe_url = _sanitize_camera_url(url, ("http", "https"))
+    if not safe_url:
+        logger.error(f"Invalid snapshot URL format: {url[:50]}...")
+        return None
+
+    try:
+        async with (
+            aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
+            session.get(safe_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:
+        # Sanitize error message - don't expose internal details
+        error_type = type(e).__name__
+        logger.error(f"Camera connection test failed: {e}")
+        return {"success": False, "error": f"Connection failed: {error_type}"}
+
+
+async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
+    """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.
+
+    Note: This function intentionally makes requests to user-configured URLs.
+    External camera support requires connecting to user-specified camera endpoints.
+    URL is sanitized and dangerous destinations are blocked.
+    """
+    # Sanitize URL - returns reconstructed URL from validated components
+    safe_url = _sanitize_camera_url(url, ("http", "https"))
+    if not safe_url:
+        logger.error(f"Invalid MJPEG stream URL: {url[:50]}...")
+        return
+
+    try:
+        timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
+        async with aiohttp.ClientSession(timeout=timeout) as session, session.get(safe_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()

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

@@ -44,6 +44,7 @@ MODEL_TO_API_KEY = {
     "X1E": "x1e",
     "H2D Pro": "h2d-pro",
     "H2D-Pro": "h2d-pro",
+    "H2DPRO": "h2d-pro",
 }
 
 # Reverse mapping: API key to model codes

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

@@ -0,0 +1,744 @@
+"""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}")
+            # Sanitize error - don't expose internal details
+            error_type = type(e).__name__
+            return {
+                "success": False,
+                "message": f"Connection failed: {error_type}",
+                "repo_name": None,
+                "permissions": None,
+            }
+
+    def _parse_repo_url(self, url: str) -> tuple[str, str]:
+        """Parse owner and repo from GitHub URL."""
+        # Limit URL length to prevent ReDoS attacks
+        if not url or len(url) > 500:
+            raise ValueError("Invalid GitHub URL: URL too long or empty")
+
+        # Handle HTTPS URLs - use atomic groups via limited character classes
+        # GitHub usernames: 1-39 chars, alphanumeric and hyphens
+        # Repo names: 1-100 chars, alphanumeric, hyphens, underscores, dots
+        match = re.match(r"https://github\.com/([\w-]{1,39})/([\w.\-]{1,100})(?:\.git)?/?$", url)
+        if match:
+            return match.group(1), match.group(2)
+
+        # Handle SSH URLs
+        match = re.match(r"git@github\.com:([\w-]{1,39})/([\w.\-]{1,100})(?:\.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())

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

@@ -228,7 +228,7 @@ class HomeAssistantService:
             - domain: str
         """
         # Default domains for smart plug control
-        default_domains = {"switch", "light", "input_boolean"}
+        default_domains = {"switch", "light", "input_boolean", "script"}
 
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
@@ -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()

+ 32 - 1
backend/app/services/mqtt_relay.py

@@ -34,6 +34,8 @@ class MQTTRelayService:
         self._broker = ""
         self._port = 1883
         self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp
+        self._smart_plug_service = None  # Lazy import to avoid circular dependency
+        self._settings: dict = {}  # Store settings for smart plug service
 
     async def configure(self, settings: dict) -> bool:
         """Configure MQTT connection from settings.
@@ -41,9 +43,12 @@ class MQTTRelayService:
         Returns True if connection was successful or MQTT is disabled.
         """
         self.enabled = settings.get("mqtt_enabled", False)
+        self._settings = settings  # Store for smart plug service
 
         if not self.enabled:
             await self.disconnect()
+            # Also configure smart plug service (will disable it)
+            await self._configure_smart_plug_service(settings)
             logger.info("MQTT relay disabled")
             return True
 
@@ -67,7 +72,33 @@ class MQTTRelayService:
             await self.disconnect()
 
         # Create and connect client
-        return await self._connect(broker, port, username, password, use_tls)
+        result = await self._connect(broker, port, username, password, use_tls)
+
+        # Configure smart plug service with same settings
+        await self._configure_smart_plug_service(settings)
+
+        return result
+
+    async def _configure_smart_plug_service(self, settings: dict):
+        """Configure the MQTT smart plug service with the same broker settings."""
+        try:
+            if self._smart_plug_service is None:
+                from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+
+                self._smart_plug_service = mqtt_smart_plug_service
+
+            await self._smart_plug_service.configure(settings)
+        except Exception as e:
+            logger.error(f"Failed to configure MQTT smart plug service: {e}")
+
+    @property
+    def smart_plug_service(self):
+        """Get the MQTT smart plug service instance."""
+        if self._smart_plug_service is None:
+            from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+
+            self._smart_plug_service = mqtt_smart_plug_service
+        return self._smart_plug_service
 
     async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
         """Establish MQTT connection."""

+ 488 - 0
backend/app/services/mqtt_smart_plug.py

@@ -0,0 +1,488 @@
+"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data.
+
+This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
+"""
+
+import json
+import logging
+import threading
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from typing import Any
+
+import paho.mqtt.client as mqtt
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SmartPlugMQTTData:
+    """Latest data received from an MQTT smart plug."""
+
+    plug_id: int
+    power: float | None = None  # Current power in watts
+    energy: float | None = None  # Energy in kWh (today)
+    state: str | None = None  # "ON" or "OFF"
+    last_seen: datetime = field(default_factory=datetime.utcnow)
+
+
+@dataclass
+class MQTTDataSourceConfig:
+    """Configuration for a single MQTT data source (power, energy, or state)."""
+
+    topic: str
+    path: str
+    multiplier: float = 1.0  # For power/energy
+    on_value: str | None = None  # For state (what value means "ON")
+
+
+class MQTTSmartPlugService:
+    """Subscribes to MQTT topics for smart plug energy monitoring."""
+
+    # Consider plug unreachable if no message received in this time
+    REACHABLE_TIMEOUT_MINUTES = 5
+
+    def __init__(self):
+        self.client: mqtt.Client | None = None
+        self.connected = False
+        self._lock = threading.Lock()
+        # topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state"
+        self.subscriptions: dict[str, list[tuple[int, str]]] = {}
+        # plug_id -> {data_type: MQTTDataSourceConfig}
+        self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
+        # plug_id -> latest data
+        self.plug_data: dict[int, SmartPlugMQTTData] = {}
+        self._configured = False
+        self._broker = ""
+        self._port = 1883
+        self._username = ""
+        self._password = ""
+        self._use_tls = False
+
+    def is_configured(self) -> bool:
+        """Check if the MQTT service is configured and connected."""
+        return self._configured and self.connected
+
+    def has_broker_settings(self) -> bool:
+        """Check if broker settings are available (even if not connected yet)."""
+        return bool(self._broker)
+
+    async def configure(self, settings: dict) -> bool:
+        """Configure MQTT connection from settings.
+
+        Uses the same broker settings as the MQTT relay service.
+        Returns True if connection was successful or MQTT is disabled.
+        """
+        enabled = settings.get("mqtt_enabled", False)
+
+        if not enabled:
+            await self.disconnect()
+            self._configured = False
+            logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)")
+            return True
+
+        broker = settings.get("mqtt_broker", "")
+        port = settings.get("mqtt_port", 1883)
+        username = settings.get("mqtt_username", "")
+        password = settings.get("mqtt_password", "")
+        use_tls = settings.get("mqtt_use_tls", False)
+
+        if not broker:
+            logger.warning("MQTT smart plug service: no broker configured")
+            self._configured = False
+            return False
+
+        # Check if settings changed
+        settings_changed = (
+            self._broker != broker
+            or self._port != port
+            or self._username != username
+            or self._password != password
+            or self._use_tls != use_tls
+        )
+
+        self._broker = broker
+        self._port = port
+        self._username = username
+        self._password = password
+        self._use_tls = use_tls
+        self._configured = True
+
+        # Disconnect and reconnect if settings changed
+        if settings_changed and self.client:
+            await self.disconnect()
+
+        # Connect if not already connected
+        if not self.client or not self.connected:
+            return await self._connect()
+
+        return True
+
+    async def _connect(self) -> bool:
+        """Establish MQTT connection."""
+        import asyncio
+        import ssl
+
+        try:
+            # Create client with callback API version 2
+            self.client = mqtt.Client(
+                callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+                client_id=f"bambuddy-smartplug-{id(self)}",
+                protocol=mqtt.MQTTv311,
+            )
+
+            # Set up callbacks
+            self.client.on_connect = self._on_connect
+            self.client.on_disconnect = self._on_disconnect
+            self.client.on_message = self._on_message
+
+            # Configure authentication
+            if self._username:
+                self.client.username_pw_set(self._username, self._password)
+
+            # Configure TLS
+            if self._use_tls:
+                self.client.tls_set(cert_reqs=ssl.CERT_NONE)
+                self.client.tls_insecure_set(True)
+
+            # Connect with timeout
+            try:
+                await asyncio.wait_for(
+                    asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60),
+                    timeout=3.0,
+                )
+            except TimeoutError:
+                logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out")
+                return False
+
+            self.client.loop_start()
+
+            # Wait briefly for connection
+            await asyncio.sleep(1.0)
+
+            if self.connected:
+                logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}")
+                # Resubscribe to all topics
+                self._resubscribe_all()
+                return True
+            else:
+                logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}")
+                return True  # Connection is async
+
+        except Exception as e:
+            logger.error(f"MQTT smart plug connection failed: {e}")
+            self.connected = False
+            return False
+
+    def _on_connect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags: dict,
+        reason_code: int | mqtt.ReasonCode,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when connected to broker."""
+        rc = reason_code if isinstance(reason_code, int) else reason_code.value
+        if rc == 0:
+            self.connected = True
+            logger.info("MQTT smart plug service connected successfully")
+            # Resubscribe to all topics
+            self._resubscribe_all()
+        else:
+            self.connected = False
+            logger.error(f"MQTT smart plug connection failed: {reason_code}")
+
+    def _on_disconnect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags_or_rc: dict | int | mqtt.ReasonCode,
+        reason_code: int | mqtt.ReasonCode | None = None,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when disconnected from broker."""
+        self.connected = False
+        rc = reason_code if reason_code is not None else flags_or_rc
+        rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
+        if rc_val != 0:
+            logger.warning(f"MQTT smart plug service disconnected: {rc}")
+        else:
+            logger.info("MQTT smart plug service disconnected cleanly")
+
+    def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
+        """Handle incoming MQTT message, extract data using JSON path."""
+        topic = msg.topic
+
+        with self._lock:
+            subscriptions = self.subscriptions.get(topic, [])
+            if not subscriptions:
+                return
+
+            # Parse JSON payload (or treat as raw value)
+            try:
+                payload = json.loads(msg.payload.decode("utf-8"))
+                is_json = True
+            except (json.JSONDecodeError, UnicodeDecodeError):
+                # Not JSON - treat the whole payload as a raw value
+                payload = msg.payload.decode("utf-8").strip()
+                is_json = False
+
+            # Process for each subscribed (plug_id, data_type)
+            for plug_id, data_type in subscriptions:
+                configs = self.plug_configs.get(plug_id, {})
+                config = configs.get(data_type)
+                if not config:
+                    continue
+
+                # Extract value using path (or use raw payload if no path)
+                if is_json and config.path:
+                    raw_value = self._extract_json_path(payload, config.path)
+                elif is_json and not config.path:
+                    # JSON but no path - if it's a simple value use it, otherwise skip
+                    if isinstance(payload, (int, float, str, bool)):
+                        raw_value = payload
+                    else:
+                        # Can't use a dict/list as a value
+                        logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured")
+                        continue
+                else:
+                    # Raw value (non-JSON)
+                    raw_value = payload
+
+                if raw_value is None:
+                    continue
+
+                # Initialize plug data if needed
+                if plug_id not in self.plug_data:
+                    self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
+
+                data = self.plug_data[plug_id]
+                data.last_seen = datetime.utcnow()
+
+                # Process based on data type
+                if data_type == "power":
+                    try:
+                        data.power = float(raw_value) * config.multiplier
+                        logger.debug(f"MQTT smart plug {plug_id}: power={data.power}")
+                    except (ValueError, TypeError):
+                        pass
+
+                elif data_type == "energy":
+                    try:
+                        data.energy = float(raw_value) * config.multiplier
+                        logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}")
+                    except (ValueError, TypeError):
+                        pass
+
+                elif data_type == "state":
+                    state_str = str(raw_value)
+                    # Check against configured ON value if set
+                    if config.on_value:
+                        # Case-insensitive comparison
+                        if state_str.lower() == config.on_value.lower():
+                            data.state = "ON"
+                        else:
+                            data.state = "OFF"
+                    else:
+                        # Default behavior: normalize common values
+                        upper_state = state_str.upper()
+                        if upper_state in ("ON", "1", "TRUE"):
+                            data.state = "ON"
+                        elif upper_state in ("OFF", "0", "FALSE"):
+                            data.state = "OFF"
+                        else:
+                            data.state = state_str
+                    logger.debug(f"MQTT smart plug {plug_id}: state={data.state}")
+
+    def _extract_json_path(self, data: dict, path: str) -> Any:
+        """Extract value using dot notation (e.g., 'power_l1' or 'data.power').
+
+        Supports simple dot notation for nested objects.
+        """
+        if not path:
+            return None
+
+        parts = path.split(".")
+        current = data
+
+        for part in parts:
+            if isinstance(current, dict) and part in current:
+                current = current[part]
+            else:
+                return None
+
+        return current
+
+    def _resubscribe_all(self):
+        """Resubscribe to all registered topics after reconnection."""
+        if not self.client or not self.connected:
+            return
+
+        with self._lock:
+            for topic in self.subscriptions:
+                if self.subscriptions[topic]:  # Only if there are subscribers
+                    try:
+                        self.client.subscribe(topic, qos=1)
+                        logger.debug(f"MQTT smart plug: resubscribed to {topic}")
+                    except Exception as e:
+                        logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}")
+
+    def subscribe(
+        self,
+        plug_id: int,
+        # Power source
+        power_topic: str | None = None,
+        power_path: str | None = None,
+        power_multiplier: float = 1.0,
+        # Energy source
+        energy_topic: str | None = None,
+        energy_path: str | None = None,
+        energy_multiplier: float = 1.0,
+        # State source
+        state_topic: str | None = None,
+        state_path: str | None = None,
+        state_on_value: str | None = None,
+        # Legacy: single topic/path/multiplier (for backward compatibility)
+        topic: str | None = None,
+        multiplier: float = 1.0,
+    ):
+        """Subscribe to MQTT topics for a plug.
+
+        Each data type (power, energy, state) can have its own topic.
+        For backward compatibility, if power_topic is not set but topic is,
+        topic will be used for all data types that have paths configured.
+        """
+        with self._lock:
+            # Initialize config for this plug
+            self.plug_configs[plug_id] = {}
+
+            # Determine topics (new fields take priority, fall back to legacy)
+            effective_power_topic = power_topic or topic
+            effective_energy_topic = energy_topic or topic
+            effective_state_topic = state_topic or topic
+
+            # Use new multipliers or fall back to legacy
+            effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier
+            effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier
+
+            # Configure power subscription (path is optional - empty means use raw payload)
+            if effective_power_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_power_topic,
+                    path=power_path or "",
+                    multiplier=effective_power_mult,
+                )
+                self.plug_configs[plug_id]["power"] = config
+                self._add_subscription(plug_id, effective_power_topic, "power")
+
+            # Configure energy subscription (path is optional - empty means use raw payload)
+            if effective_energy_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_energy_topic,
+                    path=energy_path or "",
+                    multiplier=effective_energy_mult,
+                )
+                self.plug_configs[plug_id]["energy"] = config
+                self._add_subscription(plug_id, effective_energy_topic, "energy")
+
+            # Configure state subscription (path is optional - empty means use raw payload)
+            if effective_state_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_state_topic,
+                    path=state_path or "",
+                    on_value=state_on_value,
+                )
+                self.plug_configs[plug_id]["state"] = config
+                self._add_subscription(plug_id, effective_state_topic, "state")
+
+            # Initialize data entry
+            if plug_id not in self.plug_data:
+                self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
+
+            logger.info(
+                f"MQTT smart plug {plug_id}: configured with "
+                f"power={effective_power_topic if power_path else None}, "
+                f"energy={effective_energy_topic if energy_path else None}, "
+                f"state={effective_state_topic if state_path else None}"
+            )
+
+    def _add_subscription(self, plug_id: int, topic: str, data_type: str):
+        """Add a subscription for a plug/data_type to a topic."""
+        if topic not in self.subscriptions:
+            self.subscriptions[topic] = []
+            # Actually subscribe if connected
+            if self.client and self.connected:
+                try:
+                    self.client.subscribe(topic, qos=1)
+                    logger.info(f"MQTT smart plug: subscribed to {topic}")
+                except Exception as e:
+                    logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}")
+
+        entry = (plug_id, data_type)
+        if entry not in self.subscriptions[topic]:
+            self.subscriptions[topic].append(entry)
+
+    def unsubscribe(self, plug_id: int):
+        """Unsubscribe when plug is deleted/updated."""
+        with self._lock:
+            # Get all configs for this plug
+            configs = self.plug_configs.pop(plug_id, {})
+            if not configs:
+                # Still clean up any stray subscriptions
+                pass
+
+            # Collect all topics this plug was subscribed to
+            topics_to_check = set()
+            for _data_type, config in configs.items():
+                topics_to_check.add(config.topic)
+
+            # Also scan subscriptions to remove any entries for this plug
+            for topic in list(self.subscriptions.keys()):
+                # Remove all entries for this plug_id
+                self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id]
+                topics_to_check.add(topic)
+
+            # Unsubscribe from topics with no more subscribers
+            for topic in topics_to_check:
+                if topic in self.subscriptions and not self.subscriptions[topic]:
+                    del self.subscriptions[topic]
+                    if self.client and self.connected:
+                        try:
+                            self.client.unsubscribe(topic)
+                            logger.info(f"MQTT smart plug: unsubscribed from {topic}")
+                        except Exception as e:
+                            logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}")
+
+            # Remove data
+            self.plug_data.pop(plug_id, None)
+
+    def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None:
+        """Get latest data for a plug (called by status endpoint)."""
+        with self._lock:
+            return self.plug_data.get(plug_id)
+
+    def is_reachable(self, plug_id: int) -> bool:
+        """Check if a plug has received data recently."""
+        data = self.get_plug_data(plug_id)
+        if not data:
+            return False
+
+        timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
+        return datetime.utcnow() - data.last_seen < timeout
+
+    async def disconnect(self):
+        """Disconnect from MQTT broker."""
+        if self.client:
+            try:
+                self.client.loop_stop()
+                self.client.disconnect()
+            except Exception as e:
+                logger.debug(f"MQTT smart plug disconnect error (ignored): {e}")
+            finally:
+                self.client = None
+                self.connected = False
+
+
+# Global instance
+mqtt_smart_plug_service = MQTTSmartPlugService()

+ 178 - 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,
@@ -920,6 +945,156 @@ class NotificationService:
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
 
+    # ==================== Queue Notifications ====================
+
+    async def on_queue_job_added(
+        self,
+        job_name: str,
+        target: str,
+        db: AsyncSession,
+        printer_id: int | None = None,
+        printer_name: str | None = None,
+    ):
+        """Handle queue job added event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_added", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "target": target,  # e.g., "Printer1" or "Any X1C"
+            "printer": printer_name or target,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_added", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_added", printer_id, printer_name)
+
+    async def on_queue_job_assigned(
+        self,
+        job_name: str,
+        printer_id: int,
+        printer_name: str,
+        target_model: str,
+        db: AsyncSession,
+    ):
+        """Handle model-based job assigned to printer event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_assigned", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name,
+            "target_model": target_model,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_assigned", printer_id, printer_name)
+
+    async def on_queue_job_started(
+        self,
+        job_name: str,
+        printer_id: int,
+        printer_name: str,
+        db: AsyncSession,
+        estimated_time: int | None = None,
+    ):
+        """Handle queue job started printing event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_started", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name,
+            "estimated_time": self._format_duration(estimated_time),
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_started", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_started", printer_id, printer_name)
+
+    async def on_queue_job_waiting(
+        self,
+        job_name: str,
+        target_model: str,
+        waiting_reason: str,
+        db: AsyncSession,
+    ):
+        """Handle job waiting for filament event."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_waiting", None)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "target_model": target_model,
+            "waiting_reason": waiting_reason,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_waiting")
+
+    async def on_queue_job_skipped(
+        self,
+        job_name: str,
+        printer_id: int,
+        printer_name: str,
+        reason: str,
+        db: AsyncSession,
+    ):
+        """Handle job skipped event (e.g., previous print failed)."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_skipped", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name,
+            "reason": reason,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_skipped", printer_id, printer_name)
+
+    async def on_queue_job_failed(
+        self,
+        job_name: str,
+        printer_id: int | None,
+        printer_name: str | None,
+        reason: str,
+        db: AsyncSession,
+    ):
+        """Handle job failed to start event (upload error, etc.)."""
+        providers = await self._get_providers_for_event(db, "on_queue_job_failed", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "job_name": job_name,
+            "printer": printer_name or "Unknown",
+            "reason": reason,
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_job_failed", printer_id, printer_name)
+
+    async def on_queue_completed(
+        self,
+        completed_count: int,
+        db: AsyncSession,
+    ):
+        """Handle all queue jobs completed event."""
+        providers = await self._get_providers_for_event(db, "on_queue_completed", None)
+        if not providers:
+            return
+
+        variables = {
+            "completed_count": str(completed_count),
+        }
+
+        title, message = await self._build_message_from_template(db, "queue_completed", variables)
+        await self._send_to_providers(providers, title, message, db, "queue_completed")
+
     async def _queue_for_digest(
         self,
         provider: NotificationProvider,

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

@@ -0,0 +1,801 @@
+"""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.
+"""
+
+from __future__ import annotations
+
+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")
+            # Don't expose exception details to user - log has full info
+            error_type = type(e).__name__
+            return False, f"Calibration error: {error_type}", -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

+ 680 - 69
backend/app/services/print_scheduler.py

@@ -1,21 +1,28 @@
 """Print scheduler service - processes the print queue."""
 
 import asyncio
+import json
 import logging
+import xml.etree.ElementTree as ET
+import zipfile
 from datetime import datetime
+from pathlib import Path
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
 from backend.app.core.database import async_session
 from backend.app.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
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
+from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
-from backend.app.services.tasmota import tasmota_service
+from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.utils.printer_models import normalize_printer_model
 
 logger = logging.getLogger(__name__)
 
@@ -61,13 +68,10 @@ class PrintScheduler:
             if not items:
                 return
 
-            # Group by printer - only process first item per printer
-            processed_printers = set()
+            # Track busy printers to avoid assigning multiple items to same printer
+            busy_printers: set[int] = set()
 
             for item in items:
-                if item.printer_id in processed_printers:
-                    continue
-
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
@@ -76,46 +80,551 @@ class PrintScheduler:
                 if item.manual_start:
                     continue
 
-                # Check if printer is idle
-                printer_idle = self._is_printer_idle(item.printer_id)
-                printer_connected = printer_manager.is_connected(item.printer_id)
-
-                # If printer not connected, try to power on via smart plug
-                if not printer_connected:
-                    plug = await self._get_smart_plug(db, item.printer_id)
-                    if plug and plug.auto_on and plug.enabled:
-                        logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
-                        powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
-                        if powered_on:
-                            printer_connected = True
-                            printer_idle = self._is_printer_idle(item.printer_id)
+                if item.printer_id:
+                    # Specific printer assignment (existing behavior)
+                    if item.printer_id in busy_printers:
+                        continue
+
+                    # Check if printer is idle
+                    printer_idle = self._is_printer_idle(item.printer_id)
+                    printer_connected = printer_manager.is_connected(item.printer_id)
+
+                    # If printer not connected, try to power on via smart plug
+                    if not printer_connected:
+                        plug = await self._get_smart_plug(db, item.printer_id)
+                        if plug and plug.auto_on and plug.enabled:
+                            logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
+                            powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
+                            if powered_on:
+                                printer_connected = True
+                                printer_idle = self._is_printer_idle(item.printer_id)
+                            else:
+                                logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
+                                busy_printers.add(item.printer_id)
+                                continue
                         else:
-                            logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
-                            processed_printers.add(item.printer_id)
+                            # No plug or auto_on disabled
+                            busy_printers.add(item.printer_id)
                             continue
-                    else:
-                        # No plug or auto_on disabled
-                        processed_printers.add(item.printer_id)
+
+                    # Check if printer is idle (busy with another print)
+                    if not printer_idle:
+                        busy_printers.add(item.printer_id)
                         continue
 
-                # Check if printer is idle (busy with another print)
-                if not printer_idle:
-                    processed_printers.add(item.printer_id)
-                    continue
+                    # Check condition (previous print success)
+                    if item.require_previous_success:
+                        if not await self._check_previous_success(db, item):
+                            item.status = "skipped"
+                            item.error_message = "Previous print failed or was aborted"
+                            item.completed_at = datetime.now()
+                            await db.commit()
+                            logger.info(f"Skipped queue item {item.id} - previous print failed")
+
+                            # Send notification
+                            job_name = await self._get_job_name(db, item)
+                            printer = await self._get_printer(db, item.printer_id)
+                            await notification_service.on_queue_job_skipped(
+                                job_name=job_name,
+                                printer_id=item.printer_id,
+                                printer_name=printer.name if printer else "Unknown",
+                                reason="Previous print failed or was aborted",
+                                db=db,
+                            )
+                            continue
 
-                # Check condition (previous print success)
-                if item.require_previous_success:
-                    if not await self._check_previous_success(db, item):
-                        item.status = "skipped"
-                        item.error_message = "Previous print failed or was aborted"
-                        item.completed_at = datetime.now()
+                    # Start the print
+                    await self._start_print(db, item)
+                    busy_printers.add(item.printer_id)
+
+                elif item.target_model:
+                    # Model-based assignment - find any idle printer of matching model
+                    # Parse required filament types if present
+                    required_types = None
+                    if item.required_filament_types:
+                        try:
+                            required_types = json.loads(item.required_filament_types)
+                        except json.JSONDecodeError:
+                            pass
+
+                    printer_id, waiting_reason = await self._find_idle_printer_for_model(
+                        db, item.target_model, busy_printers, required_types
+                    )
+
+                    # Update waiting_reason if changed and send notification when first waiting
+                    if item.waiting_reason != waiting_reason:
+                        was_waiting = item.waiting_reason is not None
+                        item.waiting_reason = waiting_reason
                         await db.commit()
-                        logger.info(f"Skipped queue item {item.id} - previous print failed")
-                        continue
 
-                # Start the print
-                await self._start_print(db, item)
-                processed_printers.add(item.printer_id)
+                        # Send waiting notification only when transitioning to waiting state
+                        if waiting_reason and not was_waiting:
+                            job_name = await self._get_job_name(db, item)
+                            await notification_service.on_queue_job_waiting(
+                                job_name=job_name,
+                                target_model=item.target_model,
+                                waiting_reason=waiting_reason,
+                                db=db,
+                            )
+
+                    if printer_id:
+                        # Check condition (previous print success) before assigning
+                        if item.require_previous_success:
+                            if not await self._check_previous_success(db, item):
+                                item.status = "skipped"
+                                item.error_message = "Previous print failed or was aborted"
+                                item.completed_at = datetime.now()
+                                await db.commit()
+                                logger.info(f"Skipped queue item {item.id} - previous print failed")
+
+                                # Send notification
+                                job_name = await self._get_job_name(db, item)
+                                printer = await self._get_printer(db, printer_id)
+                                await notification_service.on_queue_job_skipped(
+                                    job_name=job_name,
+                                    printer_id=printer_id,
+                                    printer_name=printer.name if printer else "Unknown",
+                                    reason="Previous print failed or was aborted",
+                                    db=db,
+                                )
+                                continue
+
+                        # Assign printer and start - clear waiting reason
+                        item.printer_id = printer_id
+                        item.waiting_reason = None
+                        logger.info(f"Model-based assignment: queue item {item.id} assigned to printer {printer_id}")
+
+                        # Send assignment notification
+                        job_name = await self._get_job_name(db, item)
+                        printer = await self._get_printer(db, printer_id)
+                        await notification_service.on_queue_job_assigned(
+                            job_name=job_name,
+                            printer_id=printer_id,
+                            printer_name=printer.name if printer else "Unknown",
+                            target_model=item.target_model,
+                            db=db,
+                        )
+
+                        # Compute AMS mapping for the assigned printer if not already set
+                        # This is critical for model-based jobs where mapping wasn't computed upfront
+                        if not item.ams_mapping:
+                            computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
+                            if computed_mapping:
+                                item.ams_mapping = json.dumps(computed_mapping)
+                                logger.info(
+                                    f"Queue item {item.id}: Computed AMS mapping for printer {printer_id}: {computed_mapping}"
+                                )
+                                await db.commit()
+
+                        await self._start_print(db, item)
+                        busy_printers.add(printer_id)
+
+    async def _find_idle_printer_for_model(
+        self,
+        db: AsyncSession,
+        model: str,
+        exclude_ids: set[int],
+        required_filament_types: list[str] | None = None,
+    ) -> tuple[int | None, str | None]:
+        """Find an idle, connected printer matching the model with compatible filaments.
+
+        Args:
+            db: Database session
+            model: Printer model to match (e.g., "X1C", "P1S")
+            exclude_ids: Printer IDs to exclude (already busy)
+            required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
+                                     If provided, only printers with all required types loaded will match.
+
+        Returns:
+            Tuple of (printer_id, waiting_reason):
+            - (printer_id, None) if a matching printer was found
+            - (None, reason) if no printer is available, with explanation
+        """
+        # Normalize model name and use case-insensitive matching
+        normalized_model = normalize_printer_model(model) or model
+        result = await db.execute(
+            select(Printer)
+            .where(func.lower(Printer.model) == normalized_model.lower())
+            .where(Printer.is_active == True)  # noqa: E712
+        )
+        printers = list(result.scalars().all())
+
+        if not printers:
+            return None, f"No active {normalized_model} printers configured"
+
+        # Track reasons for skipping printers
+        printers_busy = []
+        printers_offline = []
+        printers_missing_filament = []
+
+        for printer in printers:
+            if printer.id in exclude_ids:
+                printers_busy.append(printer.name)
+                continue
+
+            is_connected = printer_manager.is_connected(printer.id)
+            is_idle = self._is_printer_idle(printer.id) if is_connected else False
+
+            if not is_connected:
+                printers_offline.append(printer.name)
+                continue
+
+            if not is_idle:
+                printers_busy.append(printer.name)
+                continue
+
+            # Validate filament compatibility if required types are specified
+            if required_filament_types:
+                missing = self._get_missing_filament_types(printer.id, required_filament_types)
+                if missing:
+                    printers_missing_filament.append((printer.name, missing))
+                    logger.debug(f"Skipping printer {printer.id} ({printer.name}) - missing filaments: {missing}")
+                    continue
+
+            # Found a matching printer - clear waiting reason
+            return printer.id, None
+
+        # Build waiting reason from what we found
+        reasons = []
+        if printers_missing_filament:
+            # Filament mismatch is most actionable - show first
+            names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
+            reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
+        if printers_busy:
+            reasons.append(f"Busy: {', '.join(printers_busy)}")
+        if printers_offline:
+            reasons.append(f"Offline: {', '.join(printers_offline)}")
+
+        return None, " | ".join(reasons) if reasons else f"No available {model} printers"
+
+    def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
+        """Get the list of required filament types that are not loaded on the printer.
+
+        Args:
+            printer_id: The printer ID
+            required_types: List of filament types needed (e.g., ["PLA", "PETG"])
+
+        Returns:
+            List of missing filament types (empty if all are loaded)
+        """
+        status = printer_manager.get_status(printer_id)
+        if not status:
+            return required_types  # Can't determine, assume all missing
+
+        # Collect all filament types loaded on this printer (AMS units + external spool)
+        loaded_types: set[str] = set()
+
+        # Check AMS units (stored in raw_data["ams"])
+        ams_data = status.raw_data.get("ams", [])
+        if ams_data:
+            for ams_unit in ams_data:
+                for tray in ams_unit.get("tray", []):
+                    tray_type = tray.get("tray_type")
+                    if tray_type:
+                        loaded_types.add(tray_type.upper())
+
+        # Check external spool (virtual tray, stored in raw_data["vt_tray"])
+        vt_tray = status.raw_data.get("vt_tray")
+        if vt_tray:
+            vt_type = vt_tray.get("tray_type")
+            if vt_type:
+                loaded_types.add(vt_type.upper())
+
+        # Find which required types are missing (case-insensitive comparison)
+        missing = []
+        for req_type in required_types:
+            if req_type.upper() not in loaded_types:
+                missing.append(req_type)
+
+        return missing
+
+    async def _compute_ams_mapping_for_printer(
+        self, db: AsyncSession, printer_id: int, item: PrintQueueItem
+    ) -> list[int] | None:
+        """Compute AMS mapping for a printer based on filament requirements.
+
+        This is called for model-based queue items after a printer is assigned,
+        to compute the correct AMS slot mapping for that specific printer's hardware.
+
+        Args:
+            db: Database session
+            printer_id: The assigned printer ID
+            item: The queue item (contains archive_id or library_file_id)
+
+        Returns:
+            AMS mapping array or None if no mapping needed/possible
+        """
+        # Get printer status
+        status = printer_manager.get_status(printer_id)
+        if not status:
+            logger.warning(f"Cannot compute AMS mapping: printer {printer_id} status unavailable")
+            return None
+
+        # Get filament requirements from source file
+        filament_reqs = await self._get_filament_requirements(db, item)
+        if not filament_reqs:
+            logger.debug(f"No filament requirements found for queue item {item.id}")
+            return None
+
+        # Build loaded filaments from printer status
+        loaded_filaments = self._build_loaded_filaments(status)
+        if not loaded_filaments:
+            logger.debug(f"No filaments loaded on printer {printer_id}")
+            return None
+
+        # Compute mapping: match required filaments to available slots
+        return self._match_filaments_to_slots(filament_reqs, loaded_filaments)
+
+    async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
+        """Extract filament requirements from the source 3MF file.
+
+        Args:
+            db: Database session
+            item: Queue item with archive_id or library_file_id
+
+        Returns:
+            List of filament requirement dicts with slot_id, type, color, used_grams
+        """
+        file_path: Path | None = None
+
+        if item.archive_id:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
+            archive = result.scalar_one_or_none()
+            if archive:
+                file_path = settings.base_dir / archive.file_path
+        elif item.library_file_id:
+            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            library_file = result.scalar_one_or_none()
+            if library_file:
+                lib_path = Path(library_file.file_path)
+                file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+
+        if not file_path or not file_path.exists():
+            return None
+
+        filaments = []
+        try:
+            with zipfile.ZipFile(file_path, "r") as zf:
+                if "Metadata/slice_info.config" not in zf.namelist():
+                    return None
+
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                # Check if plate_id is specified - use that plate's filaments
+                plate_id = item.plate_id
+                if plate_id:
+                    for plate_elem in root.findall("./plate"):
+                        plate_index = None
+                        for meta in plate_elem.findall("metadata"):
+                            if meta.get("key") == "index":
+                                plate_index = int(meta.get("value", "0"))
+                                break
+                        if plate_index == plate_id:
+                            for filament_elem in plate_elem.findall("./filament"):
+                                filament_id = filament_elem.get("id")
+                                filament_type = filament_elem.get("type", "")
+                                filament_color = filament_elem.get("color", "")
+                                used_g = filament_elem.get("used_g", "0")
+                                try:
+                                    used_grams = float(used_g)
+                                    if used_grams > 0 and filament_id:
+                                        filaments.append(
+                                            {
+                                                "slot_id": int(filament_id),
+                                                "type": filament_type,
+                                                "color": filament_color,
+                                                "used_grams": round(used_grams, 1),
+                                            }
+                                        )
+                                except (ValueError, TypeError):
+                                    pass
+                            break
+                else:
+                    # No plate_id - extract all filaments with used_g > 0
+                    for filament_elem in root.findall("./filament"):
+                        filament_id = filament_elem.get("id")
+                        filament_type = filament_elem.get("type", "")
+                        filament_color = filament_elem.get("color", "")
+                        used_g = filament_elem.get("used_g", "0")
+                        try:
+                            used_grams = float(used_g)
+                            if used_grams > 0 and filament_id:
+                                filaments.append(
+                                    {
+                                        "slot_id": int(filament_id),
+                                        "type": filament_type,
+                                        "color": filament_color,
+                                        "used_grams": round(used_grams, 1),
+                                    }
+                                )
+                        except (ValueError, TypeError):
+                            pass
+
+                filaments.sort(key=lambda x: x["slot_id"])
+        except Exception as e:
+            logger.warning(f"Failed to parse filament requirements: {e}")
+            return None
+
+        return filaments if filaments else None
+
+    def _build_loaded_filaments(self, status) -> list[dict]:
+        """Build list of loaded filaments from printer status.
+
+        Args:
+            status: PrinterState from printer_manager
+
+        Returns:
+            List of loaded filament dicts with type, color, ams_id, tray_id, global_tray_id
+        """
+        filaments = []
+
+        # Parse AMS units from raw_data
+        ams_data = status.raw_data.get("ams", [])
+        for ams_unit in ams_data:
+            ams_id = ams_unit.get("id", 0)
+            trays = ams_unit.get("tray", [])
+            is_ht = len(trays) == 1  # AMS-HT has single tray
+
+            for tray in trays:
+                tray_type = tray.get("tray_type")
+                if tray_type:
+                    tray_id = tray.get("id", 0)
+                    tray_color = tray.get("tray_color", "")
+                    # Normalize color: remove alpha, add hash
+                    color = self._normalize_color(tray_color)
+                    # Calculate global tray ID
+                    global_tray_id = ams_id * 4 + tray_id
+
+                    filaments.append(
+                        {
+                            "type": tray_type,
+                            "color": color,
+                            "ams_id": ams_id,
+                            "tray_id": tray_id,
+                            "is_ht": is_ht,
+                            "is_external": False,
+                            "global_tray_id": global_tray_id,
+                        }
+                    )
+
+        # Check external spool (vt_tray)
+        vt_tray = status.raw_data.get("vt_tray")
+        if vt_tray and vt_tray.get("tray_type"):
+            color = self._normalize_color(vt_tray.get("tray_color", ""))
+            filaments.append(
+                {
+                    "type": vt_tray["tray_type"],
+                    "color": color,
+                    "ams_id": -1,
+                    "tray_id": 0,
+                    "is_ht": False,
+                    "is_external": True,
+                    "global_tray_id": 254,
+                }
+            )
+
+        return filaments
+
+    def _normalize_color(self, color: str | None) -> str:
+        """Normalize color to #RRGGBB format."""
+        if not color:
+            return "#808080"
+        hex_color = color.replace("#", "")[:6]
+        return f"#{hex_color}"
+
+    def _normalize_color_for_compare(self, color: str | None) -> str:
+        """Normalize color for comparison (lowercase, no hash)."""
+        if not color:
+            return ""
+        return color.replace("#", "").lower()[:6]
+
+    def _colors_are_similar(self, color1: str | None, color2: str | None, threshold: int = 40) -> bool:
+        """Check if two colors are visually similar within a threshold."""
+        hex1 = self._normalize_color_for_compare(color1)
+        hex2 = self._normalize_color_for_compare(color2)
+        if not hex1 or not hex2 or len(hex1) < 6 or len(hex2) < 6:
+            return False
+
+        try:
+            r1 = int(hex1[0:2], 16)
+            g1 = int(hex1[2:4], 16)
+            b1 = int(hex1[4:6], 16)
+            r2 = int(hex2[0:2], 16)
+            g2 = int(hex2[2:4], 16)
+            b2 = int(hex2[4:6], 16)
+            return abs(r1 - r2) <= threshold and abs(g1 - g2) <= threshold and abs(b1 - b2) <= threshold
+        except ValueError:
+            return False
+
+    def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
+        """Match required filaments to loaded filaments and build AMS mapping.
+
+        Priority: exact color match > similar color match > type-only match
+
+        Args:
+            required: List of required filaments with slot_id, type, color
+            loaded: List of loaded filaments with type, color, global_tray_id
+
+        Returns:
+            AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
+        """
+        if not required:
+            return None
+
+        # Track used trays to avoid duplicate assignment
+        used_tray_ids: set[int] = set()
+        comparisons = []
+
+        for req in required:
+            req_type = (req.get("type") or "").upper()
+            req_color = req.get("color", "")
+
+            # Find best match: exact color > similar color > type-only
+            exact_match = None
+            similar_match = None
+            type_only_match = None
+
+            for f in loaded:
+                if f["global_tray_id"] in used_tray_ids:
+                    continue
+                f_type = (f.get("type") or "").upper()
+                if f_type != req_type:
+                    continue
+
+                # Type matches - check color
+                f_color = f.get("color", "")
+                if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
+                    exact_match = f
+                    break  # Best possible match
+                elif self._colors_are_similar(f_color, req_color):
+                    if not similar_match:
+                        similar_match = f
+                elif not type_only_match:
+                    type_only_match = f
+
+            match = exact_match or similar_match or type_only_match
+            if match:
+                used_tray_ids.add(match["global_tray_id"])
+                comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]})
+            else:
+                comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": -1})
+
+        # Build mapping array
+        if not comparisons:
+            return None
+
+        max_slot_id = max(c["slot_id"] for c in comparisons)
+        if max_slot_id <= 0:
+            return None
+
+        mapping = [-1] * max_slot_id
+        for c in comparisons:
+            slot_id = c["slot_id"]
+            if slot_id and slot_id > 0:
+                mapping[slot_id - 1] = c["global_tray_id"]
+
+        return mapping
 
     def _is_printer_idle(self, printer_id: int) -> bool:
         """Check if a printer is connected and idle."""
@@ -126,8 +635,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."""
@@ -139,15 +649,18 @@ class PrintScheduler:
 
         Returns True if printer connected successfully within timeout.
         """
+        # Get the appropriate service for the plug type (Tasmota or Home Assistant)
+        service = await smart_plug_manager.get_service_for_plug(plug, db)
+
         # Check current plug state
-        status = await tasmota_service.get_status(plug)
+        status = await service.get_status(plug)
         if not status.get("reachable"):
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             return False
 
         # Turn on if not already on
         if status.get("state") != "ON":
-            success = await tasmota_service.turn_on(plug)
+            success = await service.turn_on(plug)
             if not success:
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 return False
@@ -216,25 +729,38 @@ class PrintScheduler:
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
-            await tasmota_service.turn_off(plug)
+            service = await smart_plug_manager.get_service_for_plug(plug, db)
+            await service.turn_off(plug)
+
+    async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
+        """Get a human-readable name for a queue item."""
+        if item.archive_id:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
+            archive = result.scalar_one_or_none()
+            if archive:
+                return archive.filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        if item.library_file_id:
+            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            library_file = result.scalar_one_or_none()
+            if library_file:
+                return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        return f"Job #{item.id}"
+
+    async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None:
+        """Get printer by ID."""
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        return result.scalar_one_or_none()
 
     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 +782,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 +844,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"):
@@ -327,20 +902,30 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             await db.commit()
             logger.error(f"Queue item {item.id}: FTP upload failed")
+
+            # Send failure notification
+            await notification_service.on_queue_job_failed(
+                job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                printer_id=printer.id,
+                printer_name=printer.name,
+                reason="Failed to upload file to printer",
+                db=db,
+            )
+
             await self._power_off_if_needed(db, item)
             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
         if item.ams_mapping:
             try:
-                import json
-
                 ams_mapping = json.loads(item.ams_mapping)
             except json.JSONDecodeError:
                 logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring")
@@ -363,7 +948,23 @@ 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}")
+
+            # Get estimated time for notification
+            estimated_time = None
+            if archive and archive.print_time_seconds:
+                estimated_time = archive.print_time_seconds
+            elif library_file and library_file.print_time_seconds:
+                estimated_time = library_file.print_time_seconds
+
+            # Send job started notification
+            await notification_service.on_queue_job_started(
+                job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                printer_id=printer.id,
+                printer_name=printer.name,
+                db=db,
+                estimated_time=estimated_time,
+            )
 
             # MQTT relay - publish queue job started
             try:
@@ -371,7 +972,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,
@@ -384,6 +985,16 @@ class PrintScheduler:
             item.completed_at = datetime.utcnow()
             await db.commit()
             logger.error(f"Queue item {item.id}: Failed to start print")
+
+            # Send failure notification
+            await notification_service.on_queue_job_failed(
+                job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                printer_id=printer.id,
+                printer_name=printer.name,
+                reason="Failed to send print command",
+                db=db,
+            )
+
             await self._power_off_if_needed(db, item)
 
 

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

@@ -29,10 +29,27 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "O1C",  # H2C
         "O1S",  # H2S
         "O1E",  # H2D Pro
+        "O2D",  # H2D Pro (alternate code)
         "N7",  # P2S
     ]
 )
 
+# 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 +64,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 +96,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 +123,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 +170,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 +182,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 +403,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 +620,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)

+ 11 - 6
backend/app/services/smart_plug_manager.py

@@ -27,7 +27,7 @@ class SmartPlugManager:
         self._scheduler_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
-    async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
+    async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
         """Get the appropriate service for the plug type.
 
         For HA plugs, configures the service with current settings from DB.
@@ -110,7 +110,7 @@ class SmartPlugManager:
             plugs = result.scalars().all()
 
             for plug in plugs:
-                service = await self._get_service_for_plug(plug, db)
+                service = await self.get_service_for_plug(plug, db)
 
                 # Check if we should turn on
                 if plug.schedule_on_time == current_time:
@@ -166,7 +166,7 @@ class SmartPlugManager:
 
         # Turn on the plug
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
-        service = await self._get_service_for_plug(plug, db)
+        service = await self.get_service_for_plug(plug, db)
         success = await service.turn_on(plug)
 
         if success:
@@ -195,6 +195,11 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             return
 
+        # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
+        if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+            logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
+            return
+
         # Only auto-off on successful completion, not on failures
         # This allows the user to investigate errors before power-off
         if status != "completed":
@@ -261,7 +266,7 @@ class SmartPlugManager:
                     self.name = f"plug_{plug_id}"
 
             plug_info = PlugInfo()
-            service = await self._get_service_for_plug(plug_info)
+            service = await self.get_service_for_plug(plug_info)
             success = await service.turn_off(plug_info)
             logger.info(f"Turned off plug {plug_id} after time delay")
 
@@ -353,7 +358,7 @@ class SmartPlugManager:
                                 self.name = f"plug_{plug_id}"
 
                         plug_info = PlugInfo()
-                        service = await self._get_service_for_plug(plug_info)
+                        service = await self.get_service_for_plug(plug_info)
                         success = await service.turn_off(plug_info)
                         logger.info(
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
@@ -474,7 +479,7 @@ class SmartPlugManager:
                         # For time mode, just turn off immediately since delay already passed
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
 
-                        service = await self._get_service_for_plug(plug, db)
+                        service = await self.get_service_for_plug(plug, db)
                         success = await service.turn_off(plug)
                         if success:
                             await self._mark_auto_off_executed(plug.id)

+ 7 - 2
backend/app/services/spoolman.py

@@ -541,10 +541,15 @@ class SpoolmanClient:
 
         # Need valid color to create filament
         tray_color = tray_data.get("tray_color", "")
-        if not tray_color or tray_color in ("", "00000000"):
-            logger.debug(f"Skipping tray with invalid color: {tray_color}")
+        if not tray_color or tray_color.strip() == "":
+            logger.debug("Skipping tray with empty color")
             return None
 
+        # Handle transparent/natural filament (RRGGBBAA with alpha=00)
+        # Replace with cream color that represents how natural PLA actually looks
+        if tray_color == "00000000":
+            tray_color = "F5E6D3FF"  # Light cream/natural color
+
         # Get sub_brands, falling back to tray_type
         tray_sub_brands = tray_data.get("tray_sub_brands", "")
         if not tray_sub_brands or tray_sub_brands.strip() == "":

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

@@ -0,0 +1,140 @@
+"""STL Thumbnail Generation Service.
+
+Generates thumbnail images from STL files using trimesh and matplotlib.
+"""
+
+import logging
+import uuid
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Bambu green color for rendering
+BAMBU_GREEN = "#00AE42"
+BACKGROUND_COLOR = "#1a1a1a"
+
+# Maximum vertices before simplification
+MAX_VERTICES = 100000
+
+
+def generate_stl_thumbnail(
+    stl_path: Path,
+    thumbnails_dir: Path,
+    size: int = 256,
+) -> str | None:
+    """Generate a thumbnail image from an STL file.
+
+    Args:
+        stl_path: Path to the STL file
+        thumbnails_dir: Directory to save the thumbnail
+        size: Thumbnail size in pixels (default 256x256)
+
+    Returns:
+        Path to the generated thumbnail, or None on failure
+    """
+    try:
+        import matplotlib
+        import trimesh
+
+        # Use Agg backend for headless rendering
+        matplotlib.use("Agg")
+        import matplotlib.pyplot as plt
+        from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
+        from mpl_toolkits.mplot3d.art3d import Poly3DCollection
+
+        # Load the STL file
+        mesh = trimesh.load(str(stl_path), force="mesh")
+
+        if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
+            logger.warning(f"Failed to load STL or empty mesh: {stl_path}")
+            return None
+
+        # Simplify large meshes for performance
+        if len(mesh.vertices) > MAX_VERTICES:
+            logger.info(f"Simplifying mesh from {len(mesh.vertices)} vertices")
+            try:
+                # Calculate reduction ratio (0-1 range)
+                # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
+                keep_ratio = MAX_VERTICES / len(mesh.vertices)
+                target_reduction = 1.0 - keep_ratio
+                # Clamp to valid range (0.01 to 0.99)
+                target_reduction = max(0.01, min(0.99, target_reduction))
+                mesh = mesh.simplify_quadric_decimation(target_reduction)
+                logger.info(f"Simplified mesh to {len(mesh.vertices)} vertices")
+            except Exception as e:
+                logger.warning(f"Mesh simplification failed, using original: {e}")
+
+        # Get mesh bounds and center it
+        vertices = mesh.vertices
+        bounds_min = vertices.min(axis=0)
+        bounds_max = vertices.max(axis=0)
+        center = (bounds_min + bounds_max) / 2
+        vertices_centered = vertices - center
+
+        # Scale to fit in view
+        max_extent = (bounds_max - bounds_min).max()
+        if max_extent > 0:
+            scale = 1.0 / max_extent
+            vertices_scaled = vertices_centered * scale
+        else:
+            vertices_scaled = vertices_centered
+
+        # Create figure with dark background
+        fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
+        fig.patch.set_facecolor(BACKGROUND_COLOR)
+
+        ax = fig.add_subplot(111, projection="3d")
+        ax.set_facecolor(BACKGROUND_COLOR)
+
+        # Create polygon collection from mesh faces
+        faces = mesh.faces
+        poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]
+
+        collection = Poly3DCollection(
+            poly3d,
+            facecolors=BAMBU_GREEN,
+            edgecolors=BAMBU_GREEN,
+            linewidths=0.1,
+            alpha=0.9,
+        )
+        ax.add_collection3d(collection)
+
+        # Set axis limits
+        ax.set_xlim(-0.6, 0.6)
+        ax.set_ylim(-0.6, 0.6)
+        ax.set_zlim(-0.6, 0.6)
+
+        # Set view angle (isometric-ish)
+        ax.view_init(elev=25, azim=45)
+
+        # Remove axes and grid
+        ax.set_axis_off()
+        ax.grid(False)
+
+        # Remove margins
+        plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
+
+        # Save thumbnail
+        thumb_filename = f"{uuid.uuid4().hex}.png"
+        thumb_path = thumbnails_dir / thumb_filename
+
+        fig.savefig(
+            thumb_path,
+            format="png",
+            facecolor=BACKGROUND_COLOR,
+            edgecolor="none",
+            bbox_inches="tight",
+            pad_inches=0.05,
+            dpi=100,
+        )
+        plt.close(fig)
+
+        logger.info(f"Generated STL thumbnail: {thumb_path}")
+        return str(thumb_path)
+
+    except ImportError as e:
+        logger.warning(f"STL thumbnail generation unavailable (missing dependencies): {e}")
+        return None
+    except Exception as e:
+        logger.warning(f"Failed to generate STL thumbnail for {stl_path}: {e}")
+        return None

+ 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())

+ 87 - 0
backend/app/utils/printer_models.py

@@ -0,0 +1,87 @@
+"""Printer model normalization utilities.
+
+Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to
+normalized short names (e.g., "X1C") that match database storage.
+"""
+
+# Map from 3MF printer_model strings to normalized short names
+PRINTER_MODEL_MAP = {
+    "Bambu Lab X1 Carbon": "X1C",
+    "Bambu Lab X1": "X1",
+    "Bambu Lab X1E": "X1E",
+    "Bambu Lab P1S": "P1S",
+    "Bambu Lab P1P": "P1P",
+    "Bambu Lab P2S": "P2S",
+    "Bambu Lab A1": "A1",
+    "Bambu Lab A1 Mini": "A1 Mini",
+    "Bambu Lab A1 mini": "A1 Mini",
+    "Bambu Lab H2D": "H2D",
+    "Bambu Lab H2D Pro": "H2D Pro",
+}
+
+# Map from printer_model_id (internal codes in slice_info.config) to short names
+# These are the codes Bambu Studio uses internally
+PRINTER_MODEL_ID_MAP = {
+    # X1 series
+    "C11": "X1C",
+    "C12": "X1",
+    "C13": "X1E",
+    # P1 series
+    "P1P": "P1P",
+    "P1S": "P1S",
+    # P2 series
+    "P2S": "P2S",
+    # A1 series
+    "A11": "A1",
+    "A12": "A1 Mini",
+    "N1": "A1",
+    "N2S": "A1 Mini",
+    "A04": "A1 Mini",
+    # H2D series (Office/H series)
+    "O1D": "H2D",
+    "O1E": "H2D Pro",  # Some devices report O1E
+    "O2D": "H2D Pro",  # Some devices report O2D
+}
+
+
+def normalize_printer_model_id(model_id: str | None) -> str | None:
+    """Convert printer_model_id (internal code) to normalized short name.
+
+    Args:
+        model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D")
+
+    Returns:
+        Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown.
+    """
+    if not model_id:
+        return None
+
+    # Check known mappings
+    if model_id in PRINTER_MODEL_ID_MAP:
+        return PRINTER_MODEL_ID_MAP[model_id]
+
+    # Return original if unknown (might already be a short name)
+    return model_id
+
+
+def normalize_printer_model(raw_model: str | None) -> str | None:
+    """Convert 3MF printer_model to normalized short name.
+
+    Args:
+        raw_model: The printer_model string from 3MF metadata
+            (e.g., "Bambu Lab X1 Carbon")
+
+    Returns:
+        Normalized short name (e.g., "X1C") or None if input is empty.
+        Unknown models have "Bambu Lab " prefix stripped.
+    """
+    if not raw_model:
+        return None
+
+    # Check known mappings first
+    if raw_model in PRINTER_MODEL_MAP:
+        return PRINTER_MODEL_MAP[raw_model]
+
+    # Strip "Bambu Lab " prefix for unknown models
+    stripped = raw_model.replace("Bambu Lab ", "").strip()
+    return stripped or None

+ 57 - 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
@@ -50,6 +67,7 @@ async def test_engine():
         archive,
         external_link,
         filament,
+        group,
         kprofile_note,
         maintenance,
         notification,
@@ -105,6 +123,11 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         patch("backend.app.core.auth.async_session", test_async_session),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
     ):
+        # Seed default groups for tests that need them
+        from backend.app.core.database import seed_default_groups
+
+        await seed_default_groups()
+
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
 
@@ -199,6 +222,24 @@ def mock_mqtt_client():
         yield mock
 
 
+@pytest.fixture
+def mock_mqtt_smart_plug_service():
+    """Mock the MQTT smart plug service for MQTT plug tests."""
+    with patch("backend.app.api.routes.smart_plugs.mqtt_relay") as mock:
+        # Create a mock smart_plug_service
+        mock_service = MagicMock()
+        mock_service.is_configured = MagicMock(return_value=True)
+        mock_service.has_broker_settings = MagicMock(return_value=True)
+        mock_service.configure = AsyncMock(return_value=True)
+        mock_service.subscribe = MagicMock()
+        mock_service.unsubscribe = MagicMock()
+        mock_service.get_plug_data = MagicMock(return_value=None)
+        mock_service.is_reachable = MagicMock(return_value=False)
+
+        mock.smart_plug_service = mock_service
+        yield mock
+
+
 @pytest.fixture
 def mock_ftp_client():
     """Mock the FTP client for file transfer tests."""
@@ -279,6 +320,22 @@ def smart_plug_factory(db_session):
         if plug_type == "homeassistant":
             defaults["ha_entity_id"] = "switch.test"
             defaults["ip_address"] = None
+        elif plug_type == "mqtt":
+            # Legacy fields (for backward compatibility tests)
+            defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic")
+            defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0)
+            # New separate topic/path/multiplier fields
+            defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic")
+            defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power")
+            defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0)
+            defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic")
+            defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path")
+            defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0)
+            defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic")
+            defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path")
+            defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value")
+            defaults["ip_address"] = None
+            defaults["ha_entity_id"] = None
         else:
             defaults["ip_address"] = "192.168.1.100"
             defaults["ha_entity_id"] = None

+ 146 - 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
     # ========================================================================
@@ -412,3 +434,127 @@ class TestArchiveF3DEndpoints:
         """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         assert response.status_code == 404
+
+    # ========================================================================
+    # Tag Management endpoints (Issue #183)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_empty(self, async_client: AsyncClient):
+        """Verify empty list when no tags exist."""
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify tags are returned with counts."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
+        await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
+        await archive_factory(printer.id, print_name="Archive 3", tags="test")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+        # Convert to dict for easier lookup
+        tags_dict = {t["name"]: t["count"] for t in data}
+        assert tags_dict.get("functional") == 2
+        assert tags_dict.get("test") == 2
+        assert tags_dict.get("calibration") == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_sorted_by_count(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify tags are sorted by count descending, then by name."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, tags="alpha")
+        await archive_factory(printer.id, tags="beta, alpha")
+        await archive_factory(printer.id, tags="gamma, beta, alpha")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+
+        # alpha=3, beta=2, gamma=1
+        assert data[0]["name"] == "alpha"
+        assert data[0]["count"] == 3
+        assert data[1]["name"] == "beta"
+        assert data[1]["count"] == 2
+        assert data[2]["name"] == "gamma"
+        assert data[2]["count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify renaming a tag updates all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert "new-tag" in response.json()["tags"]
+        assert "old-tag" not in response.json()["tags"]
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        assert response.json()["tags"] == "new-tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_no_change(self, async_client: AsyncClient):
+        """Verify renaming to same name returns 0 affected."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
+        """Verify renaming to empty name returns error."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify deleting a tag removes it from all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.delete("/api/v1/archives/tags/delete-me")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert response.json()["tags"] == "keep"
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        # Should be None or empty when last tag is removed
+        assert response.json()["tags"] is None or response.json()["tags"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag_not_found(self, async_client: AsyncClient):
+        """Verify deleting non-existent tag returns 0 affected."""
+        response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0

+ 330 - 1
backend/tests/integration/test_auth_api.py

@@ -205,7 +205,18 @@ class TestUsersAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_list_users_requires_auth(self, async_client: AsyncClient):
-        """Verify listing users requires authentication."""
+        """Verify listing users requires authentication when auth is enabled."""
+        # First enable auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "authreqadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        # Now try to list users without a token
         response = await async_client.get("/api/v1/users/")
 
         assert response.status_code == 401
@@ -360,3 +371,321 @@ class TestAuthDisableAPI:
         # Verify auth is now disabled
         status_response = await async_client.get("/api/v1/auth/status")
         assert status_response.json()["auth_enabled"] is False
+
+
+class TestGroupsAPI:
+    """Integration tests for /api/v1/groups/ endpoints."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "groupsadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "groupsadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_groups(self, async_client: AsyncClient, auth_token: str):
+        """Verify listing groups returns default groups."""
+        response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        groups = response.json()
+        assert isinstance(groups, list)
+        # Should have default groups: Administrators, Operators, Viewers
+        group_names = [g["name"] for g in groups]
+        assert "Administrators" in group_names
+        assert "Operators" in group_names
+        assert "Viewers" in group_names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_permissions(self, async_client: AsyncClient, auth_token: str):
+        """Verify getting available permissions."""
+        response = await async_client.get(
+            "/api/v1/groups/permissions",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        permissions = response.json()
+        assert isinstance(permissions, dict)
+        # Should have permission categories
+        assert "Printers" in permissions or len(permissions) > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating a new group."""
+        response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "name": "Custom Group",
+                "description": "A custom test group",
+                "permissions": ["printers:read", "archives:read"],
+            },
+        )
+
+        assert response.status_code == 201
+        group = response.json()
+        assert group["name"] == "Custom Group"
+        assert group["description"] == "A custom test group"
+        assert "printers:read" in group["permissions"]
+        assert group["is_system"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify updating a group."""
+        # Create a group first
+        create_response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "name": "Update Test Group",
+                "permissions": ["printers:read"],
+            },
+        )
+        group_id = create_response.json()["id"]
+
+        # Update the group
+        response = await async_client.patch(
+            f"/api/v1/groups/{group_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "description": "Updated description",
+                "permissions": ["printers:read", "printers:control"],
+            },
+        )
+
+        assert response.status_code == 200
+        group = response.json()
+        assert group["description"] == "Updated description"
+        assert "printers:control" in group["permissions"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cannot_delete_system_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify system groups cannot be deleted."""
+        # Get the Administrators group
+        list_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        admin_group = next(g for g in list_response.json() if g["name"] == "Administrators")
+
+        # Try to delete it
+        response = await async_client.delete(
+            f"/api/v1/groups/{admin_group['id']}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 400
+        assert "system group" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_custom_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify custom groups can be deleted."""
+        # Create a group
+        create_response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"name": "Delete Test Group"},
+        )
+        group_id = create_response.json()["id"]
+
+        # Delete it
+        response = await async_client.delete(
+            f"/api/v1/groups/{group_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+
+class TestUserGroupsAPI:
+    """Integration tests for user-group assignments."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "usergroupadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "usergroupadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_with_groups(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating a user with group assignments."""
+        # Get Operators group ID
+        groups_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        operators_group = next(g for g in groups_response.json() if g["name"] == "Operators")
+
+        # Create user with group
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "groupuser",
+                "password": "password123",
+                "group_ids": [operators_group["id"]],
+            },
+        )
+
+        assert response.status_code == 201
+        user = response.json()
+        assert any(g["name"] == "Operators" for g in user["groups"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_user_to_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify adding a user to a group."""
+        # Create a user
+        user_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"username": "addtogroup", "password": "password123"},
+        )
+        user_id = user_response.json()["id"]
+
+        # Get Viewers group
+        groups_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        viewers_group = next(g for g in groups_response.json() if g["name"] == "Viewers")
+
+        # Add user to group
+        response = await async_client.post(
+            f"/api/v1/groups/{viewers_group['id']}/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+        # Verify user is in group
+        user_check = await async_client.get(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert any(g["name"] == "Viewers" for g in user_check.json()["groups"])
+
+
+class TestChangePasswordAPI:
+    """Integration tests for /api/v1/users/me/change-password endpoint."""
+
+    @pytest.fixture
+    async def user_token(self, async_client: AsyncClient):
+        """Setup auth and return regular user token."""
+        # Enable auth with admin
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "pwchangeadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeadmin", "password": "adminpassword123"},
+        )
+        admin_token = admin_login.json()["access_token"]
+
+        # Create a regular user
+        await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={"username": "pwchangeuser", "password": "oldpassword123"},
+        )
+
+        # Login as regular user
+        user_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeuser", "password": "oldpassword123"},
+        )
+        return user_login.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_success(self, async_client: AsyncClient, user_token: str):
+        """Verify user can change their own password."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={
+                "current_password": "oldpassword123",
+                "new_password": "newpassword456",
+            },
+        )
+
+        assert response.status_code == 200
+        assert "success" in response.json()["message"].lower()
+
+        # Verify can login with new password
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeuser", "password": "newpassword456"},
+        )
+        assert login_response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_wrong_current(self, async_client: AsyncClient, user_token: str):
+        """Verify changing password fails with wrong current password."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={
+                "current_password": "wrongpassword",
+                "new_password": "newpassword456",
+            },
+        )
+
+        assert response.status_code == 400
+        assert "incorrect" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_requires_auth(self, async_client: AsyncClient):
+        """Verify changing password requires authentication."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            json={
+                "current_password": "oldpassword",
+                "new_password": "newpassword",
+            },
+        )
+
+        assert response.status_code == 401

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

@@ -223,3 +223,258 @@ 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.debug_image = None
+        mock_result.to_dict.return_value = {
+            "is_empty": True,
+            "confidence": 0.95,
+            "difference_percent": 0.5,
+            "message": "Plate appears empty",
+            "has_debug_image": False,
+            "needs_calibration": False,
+        }
+
+        # Mock PlateDetector for reference count
+        mock_detector = MagicMock()
+        mock_detector.get_calibration_count.return_value = 0
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
+            mock_check.return_value = mock_result
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
+
+        assert response.status_code == 200
+        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.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
+        ):
+            mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
+
+        assert response.status_code == 200
+        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()
+
+        with patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True):
+            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()
+
+        # Mock OpenCV availability and PlateDetector
+        mock_detector = MagicMock()
+        mock_detector.get_references.return_value = []
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
+            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()

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

@@ -1,5 +1,10 @@
 """Integration tests for Library API endpoints."""
 
+import io
+import tempfile
+import zipfile
+from pathlib import Path
+
 import pytest
 from httpx import AsyncClient
 
@@ -445,3 +450,268 @@ class TestLibraryZipExtractAPI:
         result = response.json()
         assert result["extracted"] == 1  # Only real_file.txt
         assert result["files"][0]["filename"] == "real_file.txt"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    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
+
+        # 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)
+
+        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["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"
+
+
+class TestLibraryStlThumbnailAPI:
+    """Integration tests for STL thumbnail generation endpoints."""
+
+    @pytest.fixture
+    async def file_factory(self, db_session):
+        """Factory to create test files."""
+        _counter = [0]
+
+        async def _create_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_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with no 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
+        assert result["results"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with no criteria returns empty."""
+        data = {}
+        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
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_file_not_on_disk(
+        self, async_client: AsyncClient, file_factory, db_session
+    ):
+        """Verify batch thumbnail generation handles missing files gracefully."""
+        # Create a file in DB but not on disk
+        stl_file = await file_factory(
+            filename="missing.stl",
+            file_path="/nonexistent/path/missing.stl",
+            thumbnail_path=None,
+        )
+
+        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
+        assert result["succeeded"] == 0
+        assert result["failed"] == 1
+        assert result["results"][0]["success"] is False
+        assert "not found" in result["results"][0]["error"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with a real STL file."""
+        from backend.app.models.library import LibraryFile
+
+        # Create a simple ASCII STL cube
+        stl_content = """solid cube
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 0
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 1 1 1
+    vertex 1 0 1
+  endloop
+endfacet
+endsolid cube"""
+
+        with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
+            f.write(stl_content)
+            stl_path = f.name
+
+        try:
+            # Create file in DB pointing to real STL
+            lib_file = LibraryFile(
+                filename="test_cube.stl",
+                file_path=stl_path,
+                file_size=len(stl_content),
+                file_type="stl",
+                thumbnail_path=None,
+            )
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+
+            data = {"file_ids": [lib_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
+            # Result depends on whether trimesh/matplotlib are installed
+            # Either succeeds or fails gracefully
+            assert result["succeeded"] + result["failed"] == 1
+        finally:
+            import os
+
+            if os.path.exists(stl_path):
+                os.unlink(stl_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
+        """Verify file upload accepts generate_stl_thumbnails parameter."""
+        # Create a simple STL file
+        stl_content = b"solid test\nendsolid test"
+
+        files = {"file": ("test.stl", stl_content, "application/octet-stream")}
+        params = {"generate_stl_thumbnails": "false"}
+        response = await async_client.post("/api/v1/library/files", files=files, params=params)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filename"] == "test.stl"
+        assert result["file_type"] == "stl"
+        # No thumbnail should be generated when disabled
+        assert result["thumbnail_path"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
+        # Create a ZIP file containing an STL
+        stl_content = b"solid test\nendsolid test"
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("model.stl", stl_content)
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        params = {"generate_stl_thumbnails": "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["extracted"] == 1
+        assert result["files"][0]["filename"] == "model.stl"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify batch thumbnail generation can filter by folder."""
+        from backend.app.models.library import LibraryFolder
+
+        # Create a folder
+        folder = LibraryFolder(name="STL Folder")
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        # Create STL file in folder (no thumbnail)
+        stl_in_folder = await file_factory(
+            filename="in_folder.stl",
+            folder_id=folder.id,
+            thumbnail_path=None,
+        )
+
+        # Create STL file at root (no thumbnail)
+        _stl_at_root = await file_factory(
+            filename="at_root.stl",
+            folder_id=None,
+            thumbnail_path=None,
+        )
+
+        # Request thumbnails only for files in folder
+        data = {"folder_id": folder.id, "all_missing": True}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # Should only process the file in the folder
+        assert result["processed"] == 1
+        assert result["results"][0]["file_id"] == stl_in_folder.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify batch thumbnail generation finds all STL files missing thumbnails."""
+        # Create files with and without thumbnails
+        _stl_with_thumb = await file_factory(
+            filename="with_thumb.stl",
+            thumbnail_path="/some/path/thumb.png",
+        )
+        stl_without_thumb1 = await file_factory(
+            filename="without_thumb1.stl",
+            thumbnail_path=None,
+        )
+        stl_without_thumb2 = await file_factory(
+            filename="without_thumb2.stl",
+            thumbnail_path=None,
+        )
+
+        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()
+        # Should only process files without thumbnails
+        assert result["processed"] == 2
+        file_ids = {r["file_id"] for r in result["results"]}
+        assert stl_without_thumb1.id in file_ids
+        assert stl_without_thumb2.id in file_ids

+ 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

+ 252 - 0
backend/tests/integration/test_smart_plugs_api.py

@@ -573,3 +573,255 @@ class TestSmartPlugsAPI:
 
         assert response.status_code == 400
         assert "not configured" in response.json()["detail"].lower()
+
+    # ========================================================================
+    # MQTT Integration tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with topic and JSON paths."""
+        data = {
+            "name": "MQTT Energy Monitor",
+            "plug_type": "mqtt",
+            "mqtt_topic": "zigbee2mqtt/shelly-working-room",
+            "mqtt_power_path": "power_l1",
+            "mqtt_energy_path": "energy_l1",
+            "mqtt_state_path": "state_l1",
+            "mqtt_multiplier": 1.0,
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "MQTT Energy Monitor"
+        assert result["plug_type"] == "mqtt"
+        assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room"
+        assert result["mqtt_power_path"] == "power_l1"
+        assert result["mqtt_energy_path"] == "energy_l1"
+        assert result["mqtt_state_path"] == "state_l1"
+        assert result["mqtt_multiplier"] == 1.0
+        assert result["ip_address"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without topic fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # Missing mqtt_topic
+            "mqtt_power_path": "power",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without any topic fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # No topic configured at all
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can use multiplier for unit conversion."""
+        data = {
+            "name": "MQTT mW to W",
+            "plug_type": "mqtt",
+            "mqtt_topic": "sensors/power",
+            "mqtt_power_path": "power_mw",
+            "mqtt_multiplier": 0.001,  # Convert mW to W
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_multiplier"] == 0.001
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plugs cannot be controlled (monitor-only)."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="test/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
+
+        assert response.status_code == 400
+        assert "monitor-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plug topic can be updated."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="old/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_topic": "new/topic",
+                "mqtt_power_path": "new_power",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_topic"] == "new/topic"
+        assert result["mqtt_power_path"] == "new_power"
+
+    # ========================================================================
+    # Enhanced MQTT Integration tests (separate topics per data type)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with separate topics for power, energy, and state."""
+        data = {
+            "name": "MQTT Separate Topics",
+            "plug_type": "mqtt",
+            "mqtt_power_topic": "zigbee/power",
+            "mqtt_power_path": "power_l1",
+            "mqtt_power_multiplier": 0.001,
+            "mqtt_energy_topic": "zigbee/energy",
+            "mqtt_energy_path": "energy_total",
+            "mqtt_energy_multiplier": 1.0,
+            "mqtt_state_topic": "zigbee/state",
+            "mqtt_state_path": "state",
+            "mqtt_state_on_value": "ON",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "MQTT Separate Topics"
+        assert result["plug_type"] == "mqtt"
+        # Power fields
+        assert result["mqtt_power_topic"] == "zigbee/power"
+        assert result["mqtt_power_path"] == "power_l1"
+        assert result["mqtt_power_multiplier"] == 0.001
+        # Energy fields
+        assert result["mqtt_energy_topic"] == "zigbee/energy"
+        assert result["mqtt_energy_path"] == "energy_total"
+        assert result["mqtt_energy_multiplier"] == 1.0
+        # State fields
+        assert result["mqtt_state_topic"] == "zigbee/state"
+        assert result["mqtt_state_path"] == "state"
+        assert result["mqtt_state_on_value"] == "ON"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with only energy monitoring."""
+        data = {
+            "name": "Energy Only Monitor",
+            "plug_type": "mqtt",
+            "mqtt_energy_topic": "sensors/energy",
+            "mqtt_energy_path": "kwh",
+            "mqtt_energy_multiplier": 0.001,  # Wh to kWh
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_energy_topic"] == "sensors/energy"
+        assert result["mqtt_energy_path"] == "kwh"
+        assert result["mqtt_energy_multiplier"] == 0.001
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with only state monitoring."""
+        data = {
+            "name": "State Only Monitor",
+            "plug_type": "mqtt",
+            "mqtt_state_topic": "switches/outlet",
+            "mqtt_state_path": "state",
+            "mqtt_state_on_value": "true",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_state_topic"] == "switches/outlet"
+        assert result["mqtt_state_path"] == "state"
+        assert result["mqtt_state_on_value"] == "true"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify creating MQTT plug with topic only (no path) succeeds for raw values."""
+        data = {
+            "name": "Raw MQTT Plug",
+            "plug_type": "mqtt",
+            # Topic only, no path - valid for raw numeric MQTT values
+            "mqtt_power_topic": "zigbee/power",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200  # Should succeed
+        result = response.json()
+        assert result["mqtt_power_topic"] == "zigbee/power"
+        assert result["mqtt_power_path"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_plug_separate_multipliers(
+        self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service
+    ):
+        """Verify MQTT plug multipliers can be updated separately."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_power_topic="test/power",
+            mqtt_power_path="power",
+            mqtt_power_multiplier=1.0,
+            mqtt_energy_topic="test/energy",
+            mqtt_energy_path="energy",
+            mqtt_energy_multiplier=1.0,
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_power_multiplier": 0.001,  # Change power multiplier only
+                "mqtt_energy_multiplier": 0.001,  # Change energy multiplier only
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_power_multiplier"] == 0.001
+        assert result["mqtt_energy_multiplier"] == 0.001

+ 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."""

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

@@ -0,0 +1,204 @@
+"""Unit tests for the STL thumbnail service."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+def _check_trimesh_available():
+    """Check if trimesh is available for import."""
+    try:
+        import trimesh
+
+        return True
+    except ImportError:
+        return False
+
+
+class TestStlThumbnailService:
+    """Tests for STL thumbnail generation service."""
+
+    def test_generate_stl_thumbnail_imports_available(self):
+        """Test that required imports are available."""
+        try:
+            import matplotlib
+            import trimesh
+
+            assert trimesh is not None
+            assert matplotlib is not None
+        except ImportError as e:
+            pytest.skip(f"Required dependencies not installed: {e}")
+
+    def test_generate_stl_thumbnail_returns_none_on_missing_deps(self):
+        """Test graceful degradation when dependencies are missing."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "test.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create a dummy STL file (will fail to parse)
+            stl_path.write_text("invalid stl content")
+
+            # Should return None on failure, not raise
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+    @pytest.mark.skipif(
+        not _check_trimesh_available(),
+        reason="trimesh not installed",
+    )
+    def test_generate_stl_thumbnail_with_simple_cube(self):
+        """Test thumbnail generation with a simple cube STL."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "cube.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create a simple ASCII STL cube
+            stl_content = """solid cube
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 0
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 1 0
+    vertex 0 1 0
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 1 1 1
+    vertex 1 0 1
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 0 1 1
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 0 -1 0
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 1
+    vertex 1 0 0
+  endloop
+endfacet
+facet normal 0 -1 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 0 1
+    vertex 1 0 1
+  endloop
+endfacet
+facet normal 1 0 0
+  outer loop
+    vertex 1 0 0
+    vertex 1 0 1
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 1 0 0
+  outer loop
+    vertex 1 0 0
+    vertex 1 1 1
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 1 0
+  outer loop
+    vertex 0 1 0
+    vertex 1 1 0
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 0 1 0
+  outer loop
+    vertex 0 1 0
+    vertex 1 1 1
+    vertex 0 1 1
+  endloop
+endfacet
+facet normal -1 0 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 1 0
+    vertex 0 1 1
+  endloop
+endfacet
+facet normal -1 0 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 1 1
+    vertex 0 0 1
+  endloop
+endfacet
+endsolid cube"""
+            stl_path.write_text(stl_content)
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+
+            # Should return a path to the generated thumbnail
+            if result:
+                assert Path(result).exists()
+                assert Path(result).suffix == ".png"
+            # If result is None, dependencies might not be fully functional
+            # which is acceptable
+
+    def test_generate_stl_thumbnail_nonexistent_file(self):
+        """Test thumbnail generation with nonexistent file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "nonexistent.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+    def test_generate_stl_thumbnail_empty_file(self):
+        """Test thumbnail generation with empty file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "empty.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create empty file
+            stl_path.write_bytes(b"")
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+
+class TestStlThumbnailConstants:
+    """Tests for STL thumbnail service constants."""
+
+    def test_bambu_green_color(self):
+        """Test that Bambu green color is defined."""
+        from backend.app.services.stl_thumbnail import BAMBU_GREEN
+
+        assert BAMBU_GREEN == "#00AE42"
+
+    def test_background_color(self):
+        """Test that background color is defined."""
+        from backend.app.services.stl_thumbnail import BACKGROUND_COLOR
+
+        assert BACKGROUND_COLOR == "#1a1a1a"
+
+    def test_max_vertices_threshold(self):
+        """Test that max vertices threshold is defined."""
+        from backend.app.services.stl_thumbnail import MAX_VERTICES
+
+        assert MAX_VERTICES == 100000

+ 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"

+ 267 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -0,0 +1,267 @@
+"""Tests for the AMS mapping computation in the print scheduler."""
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+class TestSchedulerAmsMappingHelpers:
+    """Test the AMS mapping helper methods in PrintScheduler."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_normalize_color_with_hash(self, scheduler):
+        """Color with hash should return #RRGGBB format."""
+        result = scheduler._normalize_color("#FF5500")
+        assert result == "#FF5500"
+
+    def test_normalize_color_without_hash(self, scheduler):
+        """Color without hash should add hash prefix."""
+        result = scheduler._normalize_color("FF5500")
+        assert result == "#FF5500"
+
+    def test_normalize_color_with_alpha(self, scheduler):
+        """Color with alpha channel should strip it."""
+        result = scheduler._normalize_color("FF5500AA")
+        assert result == "#FF5500"
+
+    def test_normalize_color_none(self, scheduler):
+        """None color should return default gray."""
+        result = scheduler._normalize_color(None)
+        assert result == "#808080"
+
+    def test_normalize_color_empty(self, scheduler):
+        """Empty color should return default gray."""
+        result = scheduler._normalize_color("")
+        assert result == "#808080"
+
+    def test_normalize_color_for_compare(self, scheduler):
+        """Color for compare should be lowercase without hash."""
+        result = scheduler._normalize_color_for_compare("#FF5500")
+        assert result == "ff5500"
+
+    def test_normalize_color_for_compare_with_alpha(self, scheduler):
+        """Alpha channel should be stripped for comparison."""
+        result = scheduler._normalize_color_for_compare("#FF5500AA")
+        assert result == "ff5500"
+
+    def test_colors_are_similar_exact_match(self, scheduler):
+        """Exact same colors should be similar."""
+        assert scheduler._colors_are_similar("#FF5500", "#FF5500") is True
+
+    def test_colors_are_similar_within_threshold(self, scheduler):
+        """Colors within threshold should be similar."""
+        # Red difference of 10, well within default threshold of 40
+        assert scheduler._colors_are_similar("#FF5500", "#F55500") is True
+
+    def test_colors_are_similar_outside_threshold(self, scheduler):
+        """Colors outside threshold should not be similar."""
+        # Red: FF (255) vs 00 (0) = 255 difference
+        assert scheduler._colors_are_similar("#FF0000", "#00FF00") is False
+
+    def test_colors_are_similar_none_colors(self, scheduler):
+        """None colors should not be similar."""
+        assert scheduler._colors_are_similar(None, "#FF5500") is False
+        assert scheduler._colors_are_similar("#FF5500", None) is False
+
+
+class TestBuildLoadedFilaments:
+    """Test the _build_loaded_filaments method."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_empty_status(self, scheduler):
+        """Empty status should return empty list."""
+
+        class MockStatus:
+            raw_data = {}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert result == []
+
+    def test_build_loaded_filaments_with_ams(self, scheduler):
+        """Should extract filaments from AMS units."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FF0000"},
+                            {"id": 1, "tray_type": "PETG", "tray_color": "00FF00"},
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 2
+
+        # First filament
+        assert result[0]["type"] == "PLA"
+        assert result[0]["color"] == "#FF0000"
+        assert result[0]["ams_id"] == 0
+        assert result[0]["tray_id"] == 0
+        assert result[0]["global_tray_id"] == 0  # 0 * 4 + 0
+
+        # Second filament
+        assert result[1]["type"] == "PETG"
+        assert result[1]["global_tray_id"] == 1  # 0 * 4 + 1
+
+    def test_build_loaded_filaments_with_ht_ams(self, scheduler):
+        """AMS-HT (single tray) should be marked as is_ht."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 128,
+                        "tray": [{"id": 0, "tray_type": "PLA-CF", "tray_color": "000000"}],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["is_ht"] is True
+        assert result[0]["global_tray_id"] == 512  # 128 * 4 + 0
+
+    def test_build_loaded_filaments_with_external(self, scheduler):
+        """Should include external spool."""
+
+        class MockStatus:
+            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["type"] == "TPU"
+        assert result[0]["is_external"] is True
+        assert result[0]["global_tray_id"] == 254
+
+    def test_build_loaded_filaments_skips_empty_trays(self, scheduler):
+        """Trays without tray_type should be skipped."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FF0000"},
+                            {"id": 1, "tray_type": "", "tray_color": ""},  # Empty
+                            {"id": 2},  # No tray_type key
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["type"] == "PLA"
+
+
+class TestMatchFilamentsToSlots:
+    """Test the _match_filaments_to_slots method."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_match_empty_required(self, scheduler):
+        """Empty required list should return None."""
+        result = scheduler._match_filaments_to_slots([], [])
+        assert result is None
+
+    def test_match_exact_color(self, scheduler):
+        """Should prefer exact color match."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 0},  # Wrong color
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1},  # Exact match
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [1]  # Should pick tray 1 (exact color match)
+
+    def test_match_similar_color(self, scheduler):
+        """Should match similar colors when no exact match."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF5500"}]
+        loaded = [
+            {"type": "PLA", "color": "#FF5510", "global_tray_id": 0},  # Similar
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0]
+
+    def test_match_type_only(self, scheduler):
+        """Should match by type when colors don't match."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#0000FF", "global_tray_id": 5},  # Type match, color way off
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [5]
+
+    def test_match_no_match_returns_minus_one(self, scheduler):
+        """Unmatched filaments should have -1 in mapping."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PETG", "color": "#FF0000", "global_tray_id": 0},  # Wrong type
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [-1]
+
+    def test_match_multiple_filaments(self, scheduler):
+        """Should match multiple filaments correctly."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000"},
+            {"slot_id": 2, "type": "PETG", "color": "#00FF00"},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
+            {"type": "PETG", "color": "#00FF00", "global_tray_id": 1},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 1]
+
+    def test_match_avoids_duplicate_assignment(self, scheduler):
+        """Same tray should not be assigned to multiple slots."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000"},
+            {"slot_id": 2, "type": "PLA", "color": "#FF0000"},  # Same requirements
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0},  # Only one PLA
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # First slot gets the match, second slot gets -1
+        assert result == [0, -1]
+
+    def test_match_h2d_pro_ams_ids(self, scheduler):
+        """Should work with H2D Pro's high AMS IDs (128+)."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 512},  # AMS 128, slot 0
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [512]
+
+    def test_match_external_spool(self, scheduler):
+        """Should match external spool with ID 254."""
+        required = [{"slot_id": 1, "type": "TPU", "color": "#0000FF"}]
+        loaded = [
+            {"type": "TPU", "color": "#0000FF", "global_tray_id": 254, "is_external": True},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [254]

+ 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)

+ 0 - 4
demo-video/.gitignore

@@ -1,4 +0,0 @@
-node_modules/
-output/
-*.webm
-*.mp4

+ 0 - 50
demo-video/README.md

@@ -1,50 +0,0 @@
-# Bambuddy Demo Video Recorder
-
-Automated demo video recording using Playwright.
-
-## Setup
-
-```bash
-cd demo-video
-npm install
-npm run install-browsers
-```
-
-## Recording
-
-### Record with visible browser (recommended for debugging)
-```bash
-npm run record
-```
-
-### Record headless (faster, no window)
-```bash
-npm run record:headless
-```
-
-### Custom URL
-```bash
-DEMO_URL=https://your-bambuddy.example.com npm run record
-```
-
-## Output
-
-Videos are saved to `output/` as `.webm` files.
-
-### Convert to MP4
-```bash
-ffmpeg -i output/video.webm -c:v libx264 -crf 23 demo.mp4
-```
-
-### Convert with better quality
-```bash
-ffmpeg -i output/video.webm -c:v libx264 -crf 18 -preset slow demo.mp4
-```
-
-## Customization
-
-Edit `record-demo.ts` to:
-- Adjust timing (TIMING constants)
-- Add/remove page demonstrations
-- Customize interactions per page
-- Change viewport resolution (CONFIG)

+ 0 - 537
demo-video/package-lock.json

@@ -1,537 +0,0 @@
-{
-  "name": "bambuddy-demo-video",
-  "version": "1.0.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "bambuddy-demo-video",
-      "version": "1.0.0",
-      "dependencies": {
-        "playwright": "^1.40.0",
-        "tsx": "^4.7.0"
-      }
-    },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
-      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
-      "cpu": [
-        "ppc64"
-      ],
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
-      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
-      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
-      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
-      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
-      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
-      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
-      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
-      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
-      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
-      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
-      "cpu": [
-        "loong64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
-      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
-      "cpu": [
-        "mips64el"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
-      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
-      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
-      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
-      "cpu": [
-        "s390x"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
-      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
-      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
-      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
-      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
-      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
-      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
-      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
-      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/esbuild": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
-      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.27.2",
-        "@esbuild/android-arm": "0.27.2",
-        "@esbuild/android-arm64": "0.27.2",
-        "@esbuild/android-x64": "0.27.2",
-        "@esbuild/darwin-arm64": "0.27.2",
-        "@esbuild/darwin-x64": "0.27.2",
-        "@esbuild/freebsd-arm64": "0.27.2",
-        "@esbuild/freebsd-x64": "0.27.2",
-        "@esbuild/linux-arm": "0.27.2",
-        "@esbuild/linux-arm64": "0.27.2",
-        "@esbuild/linux-ia32": "0.27.2",
-        "@esbuild/linux-loong64": "0.27.2",
-        "@esbuild/linux-mips64el": "0.27.2",
-        "@esbuild/linux-ppc64": "0.27.2",
-        "@esbuild/linux-riscv64": "0.27.2",
-        "@esbuild/linux-s390x": "0.27.2",
-        "@esbuild/linux-x64": "0.27.2",
-        "@esbuild/netbsd-arm64": "0.27.2",
-        "@esbuild/netbsd-x64": "0.27.2",
-        "@esbuild/openbsd-arm64": "0.27.2",
-        "@esbuild/openbsd-x64": "0.27.2",
-        "@esbuild/openharmony-arm64": "0.27.2",
-        "@esbuild/sunos-x64": "0.27.2",
-        "@esbuild/win32-arm64": "0.27.2",
-        "@esbuild/win32-ia32": "0.27.2",
-        "@esbuild/win32-x64": "0.27.2"
-      }
-    },
-    "node_modules/fsevents": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-      "hasInstallScript": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
-    "node_modules/get-tsconfig": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
-      "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
-      "dependencies": {
-        "resolve-pkg-maps": "^1.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
-      }
-    },
-    "node_modules/playwright": {
-      "version": "1.57.0",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
-      "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
-      "dependencies": {
-        "playwright-core": "1.57.0"
-      },
-      "bin": {
-        "playwright": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "fsevents": "2.3.2"
-      }
-    },
-    "node_modules/playwright-core": {
-      "version": "1.57.0",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
-      "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
-      "bin": {
-        "playwright-core": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/resolve-pkg-maps": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
-      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
-      "funding": {
-        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
-      }
-    },
-    "node_modules/tsx": {
-      "version": "4.21.0",
-      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
-      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
-      "dependencies": {
-        "esbuild": "~0.27.0",
-        "get-tsconfig": "^4.7.5"
-      },
-      "bin": {
-        "tsx": "dist/cli.mjs"
-      },
-      "engines": {
-        "node": ">=18.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      }
-    },
-    "node_modules/tsx/node_modules/fsevents": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
-      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "hasInstallScript": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    }
-  }
-}

+ 0 - 15
demo-video/package.json

@@ -1,15 +0,0 @@
-{
-  "name": "bambuddy-demo-video",
-  "version": "1.0.0",
-  "description": "Automated demo video recording for Bambuddy",
-  "type": "module",
-  "scripts": {
-    "record": "npx tsx record-demo.ts",
-    "record:headless": "HEADLESS=true npx tsx record-demo.ts",
-    "install-browsers": "npx playwright install chromium"
-  },
-  "dependencies": {
-    "playwright": "^1.40.0",
-    "tsx": "^4.7.0"
-  }
-}

Некоторые файлы не были показаны из-за большого количества измененных файлов