Browse Source

Merge pull request #11 from maziggy/0.1.5b

0.1.5b

* Updated README
* Redesign AMS section with BambuStudio-style device icons
  - Add AMS4TrayIcon and AMS1TrayIcon SVG components with colored spool slots
  - Spool colors displayed inside device icon windows with gray overlay frames
  - Empty slots shown as white with diagonal strike (matching slicer style)
  - Fix AMS-HT nozzle mapping bug (HT-A was showing Left instead of Right)
  - Use actual ams.id for extruder map lookup before normalizing
* Added smart plug monitoring and scheduling
* Added daily digest to notification module
* Added template system to notification module.
* Added maintenance interval type clendar days
* Minor improvements to maintenance module
* Added tabed design and auto-save to settings page.
* Add Cloud Profiles template visibility and preset diff view
* Cloud Profiles (ProfilesPage.tsx):
  - Add template visibility control (showInModal flag)
    - Eye/EyeOff toggle in templates modal to show/hide templates
    - Only templates with showInModal=true appear in preset modals
    - Default new templates to showInModal=true in save dialog
  - Add preset diff/compare view with two modes:
    - Compare button in edit modal (preset vs base)
    - Compare mode on main page (two-preset comparison)
    - Side-by-side diff with added/removed/changed highlighting
    - Stats showing added/removed/changed/same counts
    - Search filter and Changes/All toggle
    - Type restriction (only compare same preset types)
  - Fix array value display (show "value" instead of ["value"])
  - Fix printer preset G-code display (format escaped \n as real newlines)
  - Fix modal overflow issues with proper flex patterns
  - Fix light theme colors for compare selection text
* Add AMS humidity/temperature indicators with configurable thresholds
  - Add dynamic humidity indicator with Bambu Lab style water drop icons
    - Empty drop for good (dry), half-filled for fair, full for bad (wet)
    - Configurable thresholds via Settings page
  - Add dynamic temperature indicator with thermometer icons
    - Empty for good, half-filled for fair, full for hot
    - Colors match humidity: green/gold/red
  - Add AMS Display Thresholds settings card
    - Humidity thresholds (good/fair) configurable
    - Temperature thresholds (good/fair) configurable
    - Persisted in backend settings
  - Fix AMS card stability issues
    - Add ams_extruder_map to WebSocket broadcasts
    - Cache AMS data and extruder map to prevent bouncing
    - Fix L/R nozzle indicator for dual-nozzle H2 printers
  - Fix status summary bar counting
    - Don't count printers with unknown status as offline
    - Theme-aware L/R nozzle badges (light/dark theme support)
* Added printer image to printer card
* Added Wifi signal strength to printer card
* Fixed bug in MQTT debug viewer; Added filter and search for MQTT messages
* Added power switch dropdown to printer card for offline printers
* Added AMS discovery module
* Added total priner hours to printer card
* Refactored maintenance settings
* Completely refactored k-profile module
* Minor bugfix
* Fixed  bug when retrieving k profiles from printer
* Added multi language support
* Added auto app update; Added maintenance module with notifications
MartinNYHC 9 months ago
parent
commit
af5fe56399
100 changed files with 7986 additions and 425 deletions
  1. 1 0
      .gitignore
  2. 208 147
      PLAN.md
  3. 99 1
      README.md
  4. 280 0
      backend/app/api/routes/camera.py
  5. 177 1
      backend/app/api/routes/cloud.py
  6. 177 0
      backend/app/api/routes/kprofiles.py
  7. 44 5
      backend/app/api/routes/maintenance.py
  8. 148 0
      backend/app/api/routes/notification_templates.py
  9. 211 17
      backend/app/api/routes/notifications.py
  10. 1 0
      backend/app/api/routes/print_queue.py
  11. 415 1
      backend/app/api/routes/printers.py
  12. 156 19
      backend/app/api/routes/settings.py
  13. 60 1
      backend/app/api/routes/smart_plugs.py
  14. 1 1
      backend/app/core/config.py
  15. 117 1
      backend/app/core/database.py
  16. 625 0
      backend/app/data/filament_fields.json
  17. 574 0
      backend/app/data/printer_fields.json
  18. 923 0
      backend/app/data/process_fields.json
  19. 17 1
      backend/app/main.py
  20. 6 0
      backend/app/models/__init__.py
  21. 36 0
      backend/app/models/kprofile_note.py
  22. 4 0
      backend/app/models/maintenance.py
  23. 45 1
      backend/app/models/notification.py
  24. 90 0
      backend/app/models/notification_template.py
  25. 5 0
      backend/app/models/printer.py
  26. 37 0
      backend/app/models/slot_preset.py
  27. 12 1
      backend/app/models/smart_plug.py
  28. 38 0
      backend/app/schemas/cloud.py
  29. 13 0
      backend/app/schemas/kprofile.py
  30. 15 4
      backend/app/schemas/maintenance.py
  31. 40 1
      backend/app/schemas/notification.py
  32. 166 0
      backend/app/schemas/notification_template.py
  33. 1 0
      backend/app/schemas/print_queue.py
  34. 90 0
      backend/app/schemas/printer.py
  35. 20 0
      backend/app/schemas/settings.py
  36. 17 0
      backend/app/schemas/smart_plug.py
  37. 3 1
      backend/app/services/archive.py
  38. 164 1
      backend/app/services/bambu_cloud.py
  39. 9 2
      backend/app/services/bambu_ftp.py
  40. 930 31
      backend/app/services/bambu_mqtt.py
  41. 51 2
      backend/app/services/camera.py
  42. 434 140
      backend/app/services/notification_service.py
  43. 92 1
      backend/app/services/printer_manager.py
  44. 70 0
      backend/app/services/smart_plug_manager.py
  45. 322 0
      docs/bambu_lab_preset_sync_api.md
  46. 11 28
      frontend/package-lock.json
  47. BIN
      frontend/public/icons/ams-ht.png
  48. 1 0
      frontend/public/icons/ams-settings.svg
  49. 9 0
      frontend/public/icons/ams-wiring-center.svg
  50. 17 0
      frontend/public/icons/ams-wiring-left.svg
  51. 17 0
      frontend/public/icons/ams-wiring-right.svg
  52. BIN
      frontend/public/icons/ams.png
  53. 1 0
      frontend/public/icons/chamber.svg
  54. BIN
      frontend/public/icons/dual-extruder-left.png
  55. BIN
      frontend/public/icons/dual-extruder-right.png
  56. BIN
      frontend/public/icons/dual-extruder-right_sav.png
  57. BIN
      frontend/public/icons/dual-extruder.png
  58. BIN
      frontend/public/icons/extruder-change-filament.png
  59. BIN
      frontend/public/icons/extruder-left-right.png
  60. 51 0
      frontend/public/icons/eye.svg
  61. 1 0
      frontend/public/icons/heatbed.svg
  62. 44 0
      frontend/public/icons/home.svg
  63. 6 0
      frontend/public/icons/hotend.svg
  64. 4 0
      frontend/public/icons/humidity-empty.svg
  65. 4 0
      frontend/public/icons/humidity-full.svg
  66. 4 0
      frontend/public/icons/humidity-half.svg
  67. BIN
      frontend/public/icons/jogpad.png
  68. 5 0
      frontend/public/icons/jogpad.svg
  69. 4 0
      frontend/public/icons/lamp.svg
  70. 12 0
      frontend/public/icons/micro-sd.svg
  71. 1 0
      frontend/public/icons/reload.svg
  72. 0 0
      frontend/public/icons/settings.svg
  73. BIN
      frontend/public/icons/single-extruder1.png
  74. BIN
      frontend/public/icons/single-extruder2.png
  75. 1 0
      frontend/public/icons/skip-objects.svg
  76. 53 0
      frontend/public/icons/snowflake.svg
  77. 6 0
      frontend/public/icons/speed.svg
  78. 4 0
      frontend/public/icons/temperature.svg
  79. 6 0
      frontend/public/icons/ventilation.svg
  80. 1 0
      frontend/public/icons/video-camera.svg
  81. 2 0
      frontend/public/icons/water.svg
  82. 73 0
      frontend/public/icons/webcam.svg
  83. BIN
      frontend/public/img/printers/a1.png
  84. BIN
      frontend/public/img/printers/a1f.png
  85. BIN
      frontend/public/img/printers/a1mini.png
  86. BIN
      frontend/public/img/printers/default.png
  87. BIN
      frontend/public/img/printers/h2d.png
  88. BIN
      frontend/public/img/printers/o1c.png
  89. BIN
      frontend/public/img/printers/o1e.png
  90. BIN
      frontend/public/img/printers/o1s.png
  91. BIN
      frontend/public/img/printers/p1p.png
  92. BIN
      frontend/public/img/printers/p1s.png
  93. BIN
      frontend/public/img/printers/printer_placeholder.png
  94. BIN
      frontend/public/img/printers/x1c.png
  95. BIN
      frontend/public/img/printers/x1e.png
  96. 4 0
      frontend/src/App.tsx
  97. 438 5
      frontend/src/api/client.ts
  98. 130 3
      frontend/src/components/AddNotificationModal.tsx
  99. 117 1
      frontend/src/components/AddSmartPlugModal.tsx
  100. 15 8
      frontend/src/components/BatchTagModal.tsx

+ 1 - 0
.gitignore

@@ -42,3 +42,4 @@ archive/
 # Logs
 *.log
 logs/
+*.log*

+ 208 - 147
PLAN.md

@@ -1,186 +1,247 @@
-# Notifications Feature Implementation Plan
+# Notification Templates Management System
 
 ## Overview
-Add push notifications for print events (start, complete, fail) with support for multiple notification providers.
 
-## Supported Providers (Initial Release)
-1. **CallMeBot/WhatsApp** - Free, uses HTTP API with phone number + API key
-2. **ntfy** - Self-hosted or ntfy.sh, simple HTTP POST
-3. **Pushover** - Commercial ($5 one-time), HTTP API with user key + app token
-4. **Telegram** - Free bot API, requires bot token + chat ID
-5. **Email (SMTP)** - Universal fallback
+Replace hardcoded notification messages with a flexible template system that allows users to customize notification content per event type, with provider-specific formatting support.
 
-## Database Design
+---
+
+## Data Model
+
+### New Table: `notification_templates`
 
-### New Table: `notification_providers`
 ```sql
-CREATE TABLE notification_providers (
+CREATE TABLE notification_templates (
     id INTEGER PRIMARY KEY,
-    name TEXT NOT NULL,                    -- User-defined name ("My WhatsApp")
-    provider_type TEXT NOT NULL,           -- "callmebot", "ntfy", "pushover", "telegram", "email"
-    enabled BOOLEAN DEFAULT true,
+    event_type VARCHAR(50) NOT NULL,  -- print_start, print_complete, etc.
+    name VARCHAR(100) NOT NULL,       -- User-friendly name
+    title_template TEXT NOT NULL,     -- Template for notification title
+    body_template TEXT NOT NULL,      -- Template for notification body
+    is_default BOOLEAN DEFAULT 0,     -- System default (non-deletable)
+    created_at DATETIME,
+    updated_at DATETIME
+);
+```
 
-    -- Provider-specific config (JSON or individual fields)
-    config TEXT NOT NULL,                  -- JSON: {"phone": "+1234", "apikey": "xxx"}
+**Event Types:**
+- `print_start`
+- `print_complete`
+- `print_failed`
+- `print_stopped`
+- `print_progress`
+- `printer_offline`
+- `printer_error`
+- `filament_low`
+- `maintenance_due`
+- `test` (for test notifications)
 
-    -- Event triggers (which events send notifications)
-    on_print_start BOOLEAN DEFAULT false,
-    on_print_complete BOOLEAN DEFAULT true,
-    on_print_failed BOOLEAN DEFAULT true,
+---
 
-    -- Optional: Link to specific printer (NULL = all printers)
-    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+## Template Variables
 
-    -- Timestamps
-    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-);
-```
+Variables use `{variable_name}` syntax (Python format strings).
 
-### Config JSON Structure by Provider
-```python
-# CallMeBot/WhatsApp
-{"phone": "+1234567890", "apikey": "123456"}
+### Per-Event Variables:
 
-# ntfy
-{"server": "https://ntfy.sh", "topic": "my-printer", "auth_token": "optional"}
+| Event | Variables |
+|-------|-----------|
+| `print_start` | `{printer}`, `{filename}`, `{estimated_time}` |
+| `print_complete` | `{printer}`, `{filename}`, `{duration}`, `{filament_grams}` |
+| `print_failed` | `{printer}`, `{filename}`, `{duration}`, `{reason}` |
+| `print_stopped` | `{printer}`, `{filename}`, `{duration}` |
+| `print_progress` | `{printer}`, `{filename}`, `{progress}`, `{remaining_time}` |
+| `printer_offline` | `{printer}` |
+| `printer_error` | `{printer}`, `{error_type}`, `{error_detail}` |
+| `filament_low` | `{printer}`, `{slot}`, `{remaining_percent}`, `{color}` |
+| `maintenance_due` | `{printer}`, `{items}` (formatted list) |
+| `test` | `{app_name}` |
 
-# Pushover
-{"user_key": "xxx", "app_token": "yyy", "priority": 0}
+### Common Variables (all events):
+- `{timestamp}` - Current date/time
+- `{app_name}` - "BambuTrack"
 
-# Telegram
-{"bot_token": "123:ABC", "chat_id": "12345678"}
+---
 
-# Email (SMTP)
-{"smtp_server": "smtp.gmail.com", "smtp_port": 587, "username": "x", "password": "y", "from_email": "x@gmail.com", "to_email": "dest@example.com"}
-```
+## Default Templates
 
-## Backend Implementation
-
-### 1. Model: `backend/app/models/notification.py`
-- SQLAlchemy model for `notification_providers` table
-- Relationship to Printer (optional, nullable)
-
-### 2. Schema: `backend/app/schemas/notification.py`
-- `NotificationProviderBase` - Common fields
-- `NotificationProviderCreate` - For creating new providers
-- `NotificationProviderUpdate` - For partial updates
-- `NotificationProviderResponse` - API response with id/timestamps
-- `NotificationTestRequest` - For testing notifications
-
-### 3. Service: `backend/app/services/notification_service.py`
-Core notification dispatcher with provider implementations:
-
-```python
-class NotificationService:
-    async def send_notification(self, provider: NotificationProvider, event: str, data: dict) -> bool
-    async def on_print_start(self, printer_id: int, data: dict, db: AsyncSession)
-    async def on_print_complete(self, printer_id: int, status: str, data: dict, db: AsyncSession)
-
-    # Provider-specific methods
-    async def _send_callmebot(self, config: dict, message: str) -> bool
-    async def _send_ntfy(self, config: dict, title: str, message: str) -> bool
-    async def _send_pushover(self, config: dict, title: str, message: str) -> bool
-    async def _send_telegram(self, config: dict, message: str) -> bool
-    async def _send_email(self, config: dict, subject: str, body: str) -> bool
-```
+Pre-seeded templates for each event (marked `is_default=True`):
 
-### 4. Routes: `backend/app/api/routes/notifications.py`
-```
-GET    /notifications/              - List all providers
-POST   /notifications/              - Create provider
-GET    /notifications/{id}          - Get provider details
-PATCH  /notifications/{id}          - Update provider
-DELETE /notifications/{id}          - Delete provider
-POST   /notifications/{id}/test     - Send test notification
-POST   /notifications/test-config   - Test config before saving
 ```
+print_start:
+  title: "Print Started"
+  body: "{printer}: {filename}\nEstimated: {estimated_time}"
 
-### 5. Integration in `main.py`
-Add calls to notification service in existing event handlers:
-- `on_print_start()` - After smart_plug_manager call (line ~244)
-- `on_print_complete()` - After archive update (line ~580)
+print_complete:
+  title: "Print Completed"
+  body: "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g"
 
-## Frontend Implementation
+print_failed:
+  title: "Print Failed"
+  body: "{printer}: {filename}\nTime: {duration}\nReason: {reason}"
 
-### 1. API Client: `frontend/src/api/client.ts`
-Add types and API methods for notification providers.
+print_stopped:
+  title: "Print Stopped"
+  body: "{printer}: {filename}\nTime: {duration}"
 
-### 2. Components
-- `NotificationProviderCard.tsx` - Display single provider with enable/disable toggle
-- `AddNotificationModal.tsx` - Modal for adding/editing providers with provider-specific forms
+print_progress:
+  title: "Print {progress}% Complete"
+  body: "{printer}: {filename}\nRemaining: {remaining_time}"
 
-### 3. Settings Page Integration
-Add "Notifications" section in SettingsPage.tsx (similar to Smart Plugs section):
-- List of configured providers
-- Add button
-- Per-provider enable/disable
-- Test button
-- Event toggles (start/complete/failed)
+printer_offline:
+  title: "Printer Offline"
+  body: "{printer} has disconnected"
 
-## Message Templates
+printer_error:
+  title: "Printer Error: {error_type}"
+  body: "{printer}\n{error_detail}"
 
-### Print Started
-```
-🖨️ Print Started
-{printer_name}: {filename}
-Estimated time: {est_time}
-```
+filament_low:
+  title: "Filament Low"
+  body: "{printer}: Slot {slot} at {remaining_percent}%"
 
-### Print Completed
-```
-✅ Print Completed
-{printer_name}: {filename}
-Time: {actual_time}
-Filament: {filament_used}g
-```
+maintenance_due:
+  title: "Maintenance Due"
+  body: "{printer}:\n{items}"
 
-### Print Failed
-```
-❌ Print Failed
-{printer_name}: {filename}
-Status: {failure_reason}
-Progress: {progress}%
+test:
+  title: "BambuTrack Test"
+  body: "This is a test notification. If you see this, notifications are working!"
 ```
 
-## Implementation Order
+---
+
+## Provider-Specific Formatting
+
+The template system supports provider-specific formatting via a simple approach:
+
+1. **Plain text** (default) - Used for CallMeBot, ntfy, Pushover, Email
+2. **Markdown** - Automatically applied for Telegram (wrap title in `*bold*`)
+
+The notification service will:
+- Render the template with variables
+- Apply provider-specific formatting when sending
+
+---
 
-### Phase 1: Backend Core
-1. Create notification model with migrations
-2. Create notification schema
-3. Create notification service with all 5 providers
-4. Create notification routes (CRUD + test)
-5. Register routes in main.py
-6. Integrate into print event handlers
+## Implementation Steps
 
-### Phase 2: Frontend
-7. Add API types and methods
-8. Create NotificationProviderCard component
-9. Create AddNotificationModal component
-10. Add Notifications section to SettingsPage
+### Backend
 
-### Phase 3: Testing & Polish
-11. Test each provider
-12. Add error handling and logging
-13. Handle network failures gracefully (don't block print events)
+1. **Create model** `backend/app/models/notification_template.py`
+   - NotificationTemplate SQLAlchemy model
+
+2. **Create schemas** `backend/app/schemas/notification_template.py`
+   - NotificationTemplateCreate, Update, Response
+   - TemplateVariables (documentation of available vars per event)
+
+3. **Add migration** in `backend/app/core/database.py`
+   - Create table if not exists
+   - Seed default templates
+
+4. **Create API routes** `backend/app/api/routes/notification_templates.py`
+   - `GET /api/v1/notification-templates` - List all templates
+   - `GET /api/v1/notification-templates/{id}` - Get single template
+   - `PUT /api/v1/notification-templates/{id}` - Update template
+   - `POST /api/v1/notification-templates/{id}/reset` - Reset to default
+   - `GET /api/v1/notification-templates/variables` - List available variables per event
+   - `POST /api/v1/notification-templates/preview` - Preview template with sample data
+
+5. **Update notification service** `backend/app/services/notification_service.py`
+   - Load templates from database
+   - Render templates with variables
+   - Remove hardcoded message builders
+
+6. **Register routes** in `backend/app/main.py`
+
+### Frontend
+
+7. **Add API client methods** `frontend/src/api/client.ts`
+   - getNotificationTemplates, updateNotificationTemplate, etc.
+
+8. **Create template editor component** `frontend/src/components/NotificationTemplateEditor.tsx`
+   - Template editing UI with variable insertion buttons
+   - Live preview with sample data
+   - Reset to default button
+
+9. **Update SettingsPage** `frontend/src/pages/SettingsPage.tsx`
+   - Add "Templates" sub-section in Notifications tab
+   - List all templates with edit capability
+
+---
+
+## UI Design
+
+### Templates Section (in Settings > Notifications)
+
+```
++--------------------------------------------------+
+| Message Templates                                |
+| Customize notification messages for each event   |
++--------------------------------------------------+
+|                                                  |
+| +----------------+  +----------------+           |
+| | Print Started  |  | Print Complete |  ...     |
+| | "Print Started"|  | "Print Compl..." |        |
+| | [Edit]         |  | [Edit]         |          |
+| +----------------+  +----------------+           |
+|                                                  |
++--------------------------------------------------+
+```
+
+### Template Editor Modal
+
+```
++--------------------------------------------------+
+| Edit Template: Print Complete              [X]   |
++--------------------------------------------------+
+| Title:                                           |
+| [Print Completed_________________________]       |
+|                                                  |
+| Body:                                            |
+| +----------------------------------------------+ |
+| | {printer}: {filename}                        | |
+| | Time: {duration}                             | |
+| | Filament: {filament_grams}g                  | |
+| +----------------------------------------------+ |
+|                                                  |
+| Available Variables:                             |
+| [+printer] [+filename] [+duration] [+filament]   |
+|                                                  |
+| Preview:                                         |
+| +----------------------------------------------+ |
+| | Title: Print Completed                       | |
+| | Body:  Bambu X1C: Benchy.3mf                 | |
+| |        Time: 1h 23m                          | |
+| |        Filament: 15.2g                       | |
+| +----------------------------------------------+ |
+|                                                  |
+| [Reset to Default]              [Cancel] [Save]  |
++--------------------------------------------------+
+```
 
-## Technical Notes
+---
 
-### Async HTTP Requests
-Use `httpx` (already available) for async HTTP calls to notification APIs.
+## File Changes Summary
 
-### Error Handling
-- Notifications should NEVER block print events
-- Log failures but continue processing
-- Store last_error and last_success timestamps for UI feedback
+| File | Action |
+|------|--------|
+| `backend/app/models/notification_template.py` | Create |
+| `backend/app/schemas/notification_template.py` | Create |
+| `backend/app/api/routes/notification_templates.py` | Create |
+| `backend/app/core/database.py` | Modify (add migration + seeding) |
+| `backend/app/models/__init__.py` | Modify (export new model) |
+| `backend/app/services/notification_service.py` | Modify (use templates) |
+| `backend/app/main.py` | Modify (register routes) |
+| `frontend/src/api/client.ts` | Modify (add API methods + types) |
+| `frontend/src/components/NotificationTemplateEditor.tsx` | Create |
+| `frontend/src/pages/SettingsPage.tsx` | Modify (add templates section) |
 
-### Security
-- Store credentials in database (SQLite file already contains access codes)
-- Consider encryption for sensitive fields in future
+---
 
-### Rate Limiting
-- Debounce rapid events (don't spam on quick start/stop cycles)
-- Consider per-provider rate limits
+## Notes
 
-## Questions for User
-None - proceeding with the 5 providers as discussed.
+- Default templates cannot be deleted, only modified and reset
+- Templates are language-agnostic (user writes in their preferred language)
+- The existing `notification_language` setting can be removed later (templates replace i18n)
+- Variables that are unavailable for an event will render as empty string
+- Template rendering uses safe formatting (missing vars don't crash)

+ 99 - 1
README.md

@@ -38,6 +38,8 @@ Since I only have X1C and H2D devices, I'm not able to test the application with
   - Auto power-off when print completes
   - Time-based delay (1-60 minutes)
   - Temperature-based delay (waits for nozzle to cool down)
+  - Scheduled power on/off times (daily schedule)
+  - Power consumption monitoring with alerts
 - **Print Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets
   - Print success rates
   - Filament usage trends
@@ -86,8 +88,12 @@ Since I only have X1C and H2D devices, I'm not able to test the application with
   - Pushover
   - Telegram
   - Email (SMTP with TLS/SSL/plain options)
+  - Discord webhooks
+  - Generic webhooks (custom integrations)
   - Configurable event triggers (start, complete, failed, stopped, progress milestones)
   - Quiet hours to suppress notifications during sleep
+  - Daily digest mode (batch notifications into daily summary)
+  - Customizable message templates with variables
   - Per-printer filtering
 - **Spoolman Integration** - Sync AMS filament data with your Spoolman server
   - Automatic or manual sync modes
@@ -97,6 +103,16 @@ Since I only have X1C and H2D devices, I'm not able to test the application with
   - Tracks filament usage during prints
   - Third-party spools (SpoolEase, etc.) gracefully skipped
 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
+  - View and manage filament, printer, and process presets
+  - **Template system** for quick preset creation:
+    - Save any preset as a reusable template
+    - Visibility control to choose which templates appear in modals
+    - Apply templates when creating or editing presets
+  - **Preset comparison/diff view**:
+    - Compare any preset against its base preset
+    - Compare any two presets of the same type side-by-side
+    - Highlights added, removed, and changed settings
+    - Searchable diff with change statistics
 - **File Manager** - Browse and manage files on your printer's SD card
 - **Re-print** - Send archived prints back to any connected printer
 - **Dark/Light Theme** - Easy on the eyes, day or night
@@ -558,10 +574,37 @@ Once linked to a printer, you can configure:
 - **Time-based**: Wait a fixed number of minutes (1-60) after print completes
 - **Temperature-based**: Wait until nozzle temperature drops below threshold (default 70°C)
 
+#### Scheduled Power On/Off
+
+Set daily schedules to automatically turn plugs on or off at specific times:
+
+1. Expand the plug settings panel
+2. Enable **Scheduled On** and/or **Scheduled Off**
+3. Set the desired time for each
+
+Use cases:
+- Turn on printer at 8am to warm up before you start working
+- Turn off printer at midnight as a safety measure
+- Save energy by scheduling off during non-printing hours
+
+#### Power Monitoring & Alerts
+
+For Tasmota plugs with energy monitoring (e.g., Sonoff S31), Bambusy can alert you when power consumption exceeds a threshold:
+
+1. Enable **Power Alert** in the plug settings
+2. Set the **Power Threshold** in watts (e.g., 200W)
+3. Receive notifications when power exceeds the threshold
+
+This is useful for detecting:
+- Printer issues (unexpected high power draw)
+- Heater failures (power too low during printing)
+- Confirming the printer is actively heating/printing
+
 #### Manual Control
 
 Each plug card shows:
 - Current status (ON/OFF/Offline)
+- Current power consumption (if supported)
 - On/Off buttons for manual control
 - Expandable settings panel
 
@@ -578,6 +621,8 @@ Bambusy can send push notifications when print events occur. Notifications are u
 | **Pushover** | [Pushover](https://pushover.net/) push notifications | Pushover account + app token |
 | **Telegram** | Via Telegram Bot | Bot token from @BotFather |
 | **Email** | SMTP email | SMTP server credentials |
+| **Discord** | Discord channel webhooks | Webhook URL from Discord channel settings |
+| **Webhook** | Generic HTTP webhooks | Any URL that accepts POST requests |
 
 #### Adding a Notification Provider
 
@@ -621,6 +666,43 @@ By default, notifications are sent for all printers. To limit notifications to a
 2. Select a printer from the **Printer** dropdown
 3. Only events from that printer will trigger notifications
 
+#### Daily Digest
+
+Instead of receiving individual notifications for each event, you can batch them into a single daily summary:
+
+1. Enable **Daily Digest** toggle on a notification provider
+2. Set the digest time (e.g., 08:00)
+3. All notifications for that provider are collected throughout the day
+4. At the scheduled time, a single summary notification is sent
+
+The digest includes counts and details of all events that occurred since the last digest.
+
+#### Customizable Message Templates
+
+Customize the content of your notification messages using templates with variables:
+
+1. Go to **Settings** > **Notifications** > **Templates** tab
+2. Click on any event type to edit its template
+3. Use the variable buttons to insert dynamic content
+4. Preview your template with sample data
+5. Click **Save** to apply changes
+
+**Available Variables by Event:**
+
+| Event | Variables |
+|-------|-----------|
+| Print Start | `{printer}`, `{filename}`, `{estimated_time}` |
+| Print Complete | `{printer}`, `{filename}`, `{duration}`, `{filament_grams}` |
+| Print Failed | `{printer}`, `{filename}`, `{duration}`, `{reason}` |
+| Print Progress | `{printer}`, `{filename}`, `{progress}`, `{remaining_time}` |
+| Printer Offline | `{printer}` |
+| Printer Error | `{printer}`, `{error_type}`, `{error_detail}` |
+| Filament Low | `{printer}`, `{slot}`, `{remaining_percent}`, `{color}` |
+
+Common variables available for all events: `{timestamp}`, `{app_name}`
+
+**Reset to Default:** Click the reset button on any template to restore the original message.
+
 ### Spoolman Integration
 
 Bambusy integrates with [Spoolman](https://github.com/Donkie/Spoolman) for filament inventory management. When enabled, AMS filament data syncs with your Spoolman server, allowing you to track remaining filament across all your spools.
@@ -711,6 +793,18 @@ Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique
 3. Choose security mode: STARTTLS (port 587), SSL (port 465), or None (port 25)
 4. Enable/disable authentication as needed
 
+**Discord:**
+1. In your Discord server, go to channel settings > Integrations > Webhooks
+2. Click "New Webhook" and customize the name/avatar if desired
+3. Copy the webhook URL
+4. Paste the webhook URL in Bambusy
+
+**Webhook (Generic):**
+1. Enter any URL that accepts POST requests
+2. Optionally add custom headers (e.g., Authorization tokens)
+3. Bambusy sends JSON payloads with event details
+4. Useful for integrating with custom systems, Home Assistant, IFTTT, etc.
+
 ## Tech Stack
 
 - **Backend**: Python / FastAPI
@@ -882,11 +976,15 @@ To fix the printer's clock:
 - [x] Print scheduling and queuing
 - [x] Automatic finish photo capture
 - [x] K-Profiles management (pressure advance)
-- [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email)
+- [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email, Discord, Webhook)
+- [x] Notification message templates
+- [x] Daily digest notifications
 - [x] Spoolman integration (filament inventory sync)
 - [x] Maintenance tracker
 - [x] Multi-language support (English, German)
+- [x] Smart plug scheduling and power alerts
 - [x] Auto updates from GitHub releases
+- [x] Cloud Profiles template system and diff view
 - [ ] Full printer control
 - [ ] Mobile-optimized UI
 - [ ] docs: readme -> wiki

+ 280 - 0
backend/app/api/routes/camera.py

@@ -0,0 +1,280 @@
+"""Camera streaming API endpoints for Bambu Lab printers."""
+
+import asyncio
+import logging
+from typing import AsyncGenerator
+
+from fastapi import APIRouter, HTTPException, Depends
+from fastapi.responses import StreamingResponse, Response
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.printer import Printer
+from backend.app.services.camera import (
+    build_camera_url,
+    capture_camera_frame,
+    test_camera_connection,
+    get_ffmpeg_path,
+    get_camera_port,
+)
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/printers", tags=["camera"])
+
+
+async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
+    """Get printer by ID or raise 404."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+    return printer
+
+
+async def generate_mjpeg_stream(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    fps: int = 10,
+) -> AsyncGenerator[bytes, None]:
+    """Generate MJPEG stream from printer camera using ffmpeg.
+
+    This captures frames continuously and yields them in MJPEG format.
+    """
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - camera streaming requires ffmpeg")
+        yield (
+            b"--frame\r\n"
+            b"Content-Type: text/plain\r\n\r\n"
+            b"Error: ffmpeg not installed\r\n"
+        )
+        return
+
+    port = get_camera_port(model)
+    camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
+
+    # ffmpeg command to output MJPEG stream to stdout
+    # -rtsp_transport tcp: Use TCP for reliability
+    # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
+    # -f mjpeg: Output as MJPEG
+    # -q:v 5: Quality (lower = better, 2-10 is good range)
+    # -r: Output framerate
+    cmd = [
+        ffmpeg,
+        "-rtsp_transport", "tcp",
+        "-rtsp_flags", "prefer_tcp",
+        "-i", camera_url,
+        "-f", "mjpeg",
+        "-q:v", "5",
+        "-r", str(fps),
+        "-an",  # No audio
+        "-"  # Output to stdout
+    ]
+
+    logger.info(f"Starting camera stream for {ip_address} using URL: rtsps://bblp:***@{ip_address}:{port}/streaming/live/1")
+    logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
+
+    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 failed immediately: {stderr.decode()}")
+            yield (
+                b"--frame\r\n"
+                b"Content-Type: text/plain\r\n\r\n"
+                b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n"
+            )
+            return
+
+        # Read JPEG frames from ffmpeg output
+        # JPEG images start with 0xFFD8 and end with 0xFFD9
+        buffer = b""
+        jpeg_start = b"\xff\xd8"
+        jpeg_end = b"\xff\xd9"
+
+        while True:
+            try:
+                # Read chunk from ffmpeg
+                chunk = await asyncio.wait_for(
+                    process.stdout.read(8192),
+                    timeout=10.0
+                )
+
+                if not chunk:
+                    logger.warning("Camera stream ended (no more data)")
+                    break
+
+                buffer += chunk
+
+                # Find complete JPEG frames in buffer
+                while True:
+                    start_idx = buffer.find(jpeg_start)
+                    if start_idx == -1:
+                        # No start marker, clear buffer up to last 2 bytes
+                        buffer = buffer[-2:] if len(buffer) > 2 else buffer
+                        break
+
+                    # Trim anything before the start marker
+                    if start_idx > 0:
+                        buffer = buffer[start_idx:]
+
+                    end_idx = buffer.find(jpeg_end, 2)  # Skip first 2 bytes
+                    if end_idx == -1:
+                        # No end marker yet, wait for more data
+                        break
+
+                    # Extract complete frame
+                    frame = buffer[:end_idx + 2]
+                    buffer = buffer[end_idx + 2:]
+
+                    # Yield frame in MJPEG format
+                    yield (
+                        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"
+                    )
+
+            except asyncio.TimeoutError:
+                logger.warning("Camera stream read timeout")
+                break
+            except asyncio.CancelledError:
+                logger.info("Camera stream cancelled")
+                break
+
+    except FileNotFoundError:
+        logger.error("ffmpeg not found - camera streaming requires ffmpeg")
+        yield (
+            b"--frame\r\n"
+            b"Content-Type: text/plain\r\n\r\n"
+            b"Error: ffmpeg not installed\r\n"
+        )
+    except Exception as e:
+        logger.exception(f"Camera stream error: {e}")
+    finally:
+        if process:
+            try:
+                process.terminate()
+                await asyncio.wait_for(process.wait(), timeout=5.0)
+            except Exception:
+                process.kill()
+                await process.wait()
+            logger.info(f"Camera stream stopped for {ip_address}")
+
+
+@router.get("/{printer_id}/camera/stream")
+async def camera_stream(
+    printer_id: int,
+    fps: int = 10,
+    db: AsyncSession = Depends(get_db),
+):
+    """Stream live video from printer camera as MJPEG.
+
+    This endpoint returns a multipart MJPEG stream that can be used directly
+    in an <img> tag or video player.
+
+    Args:
+        printer_id: Printer ID
+        fps: Target frames per second (default: 10, max: 30)
+    """
+    printer = await get_printer_or_404(printer_id, db)
+
+    # Validate FPS
+    fps = min(max(fps, 1), 30)
+
+    return StreamingResponse(
+        generate_mjpeg_stream(
+            ip_address=printer.ip_address,
+            access_code=printer.access_code,
+            model=printer.model,
+            fps=fps,
+        ),
+        media_type="multipart/x-mixed-replace; boundary=frame",
+        headers={
+            "Cache-Control": "no-cache, no-store, must-revalidate",
+            "Pragma": "no-cache",
+            "Expires": "0",
+        }
+    )
+
+
+@router.get("/{printer_id}/camera/snapshot")
+async def camera_snapshot(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Capture a single frame from the printer camera.
+
+    Returns a JPEG image.
+    """
+    import tempfile
+    from pathlib import Path
+
+    printer = await get_printer_or_404(printer_id, db)
+
+    # Create temporary file for the snapshot
+    with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
+        temp_path = Path(f.name)
+
+    try:
+        success = await capture_camera_frame(
+            ip_address=printer.ip_address,
+            access_code=printer.access_code,
+            model=printer.model,
+            output_path=temp_path,
+            timeout=15,
+        )
+
+        if not success:
+            raise HTTPException(
+                status_code=503,
+                detail="Failed to capture camera frame. Is the printer powered on?"
+            )
+
+        # Read and return the image
+        with open(temp_path, "rb") as f:
+            image_data = f.read()
+
+        return Response(
+            content=image_data,
+            media_type="image/jpeg",
+            headers={
+                "Cache-Control": "no-cache, no-store, must-revalidate",
+                "Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"'
+            }
+        )
+    finally:
+        # Clean up temp file
+        if temp_path.exists():
+            temp_path.unlink()
+
+
+@router.get("/{printer_id}/camera/test")
+async def test_camera(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Test camera connection for a printer.
+
+    Returns success status and any error message.
+    """
+    printer = await get_printer_or_404(printer_id, db)
+
+    result = await test_camera_connection(
+        ip_address=printer.ip_address,
+        access_code=printer.access_code,
+        model=printer.model,
+    )
+
+    return result

+ 177 - 1
backend/app/api/routes/cloud.py

@@ -4,6 +4,10 @@ Bambu Lab Cloud API Routes
 Handles authentication and profile management with Bambu Cloud.
 """
 
+import json
+from pathlib import Path
+from typing import Literal
+
 from fastapi import APIRouter, HTTPException, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
@@ -24,6 +28,9 @@ from backend.app.schemas.cloud import (
     SlicerSettingsResponse,
     SlicerSetting,
     CloudDevice,
+    SlicerSettingCreate,
+    SlicerSettingUpdate,
+    SlicerSettingDeleteResponse,
 )
 
 router = APIRouter(prefix="/cloud", tags=["cloud"])
@@ -169,7 +176,7 @@ async def logout(db: AsyncSession = Depends(get_db)):
 
 @router.get("/settings", response_model=SlicerSettingsResponse)
 async def get_slicer_settings(
-    version: str = "01.09.00.00",
+    version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
 ):
     """
@@ -287,3 +294,172 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/settings")
+async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
+    """
+    Create a new slicer preset/setting.
+
+    Creates a new preset on Bambu Cloud. The preset inherits from a base preset
+    and only stores the delta (modified values).
+
+    Type should be: 'filament', 'print', or 'printer'
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        data = await cloud.create_setting(
+            preset_type=request.type,
+            name=request.name,
+            base_id=request.base_id,
+            setting=request.setting,
+            version=request.version,
+        )
+        return data
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/settings/{setting_id}")
+async def update_setting(
+    setting_id: str,
+    request: SlicerSettingUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """
+    Update an existing slicer preset/setting.
+
+    Updates the preset's name and/or settings on Bambu Cloud.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        data = await cloud.update_setting(
+            setting_id=setting_id,
+            name=request.name,
+            setting=request.setting,
+        )
+        return data
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
+async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
+    """
+    Delete a slicer preset/setting.
+
+    Removes the preset from Bambu Cloud. This cannot be undone.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        result = await cloud.delete_setting(setting_id)
+        return SlicerSettingDeleteResponse(
+            success=result.get("success", True),
+            message=result.get("message", "Setting deleted"),
+        )
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+# Path to field definition files
+FIELDS_DATA_DIR = Path(__file__).parent.parent.parent / "data"
+
+# Cache for field definitions (loaded once)
+_fields_cache: dict[str, dict] = {}
+
+
+def _load_fields(preset_type: str) -> dict:
+    """Load field definitions from JSON file."""
+    if preset_type in _fields_cache:
+        return _fields_cache[preset_type]
+
+    # Map API type names to file names
+    file_map = {
+        "filament": "filament_fields.json",
+        "print": "process_fields.json",
+        "process": "process_fields.json",
+        "printer": "printer_fields.json",
+    }
+
+    filename = file_map.get(preset_type)
+    if not filename:
+        raise HTTPException(status_code=400, detail=f"Unknown preset type: {preset_type}")
+
+    file_path = FIELDS_DATA_DIR / filename
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
+
+    with open(file_path, "r") as f:
+        data = json.load(f)
+
+    _fields_cache[preset_type] = data
+    return data
+
+
+@router.get("/fields/{preset_type}")
+async def get_preset_fields(preset_type: Literal["filament", "print", "process", "printer"]):
+    """
+    Get field definitions for a preset type.
+
+    Returns a list of field definitions including:
+    - key: The setting key name
+    - label: Human-readable label
+    - type: Field type (text, number, boolean, select)
+    - category: Grouping category
+    - description: Field description
+    - options: For select fields, available options
+    - unit: Unit of measurement (if applicable)
+    - min/max/step: For number fields, validation constraints
+    """
+    data = _load_fields(preset_type)
+    return data
+
+
+@router.get("/fields")
+async def get_all_preset_fields():
+    """
+    Get all field definitions for all preset types.
+
+    Returns field definitions organized by type.
+    """
+    return {
+        "filament": _load_fields("filament"),
+        "process": _load_fields("process"),
+        "printer": _load_fields("printer"),
+    }

+ 177 - 0
backend/app/api/routes/kprofiles.py

@@ -9,11 +9,14 @@ from sqlalchemy import select
 
 from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
+from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
 from backend.app.schemas.kprofile import (
     KProfile,
     KProfileCreate,
     KProfileDelete,
     KProfilesResponse,
+    KProfileNote,
+    KProfileNoteResponse,
 )
 from backend.app.services.printer_manager import printer_manager
 
@@ -170,6 +173,64 @@ async def set_kprofile(
     return {"success": True, "message": message}
 
 
+@router.post("/batch", response_model=dict)
+async def set_kprofiles_batch(
+    printer_id: int,
+    profiles: list[KProfileCreate],
+    db: AsyncSession = Depends(get_db),
+):
+    """Create multiple K-profiles in a single command (for dual-nozzle).
+
+    This sends all profiles in one MQTT command, which is more reliable
+    for dual-nozzle printers that may not handle sequential commands well.
+
+    Args:
+        printer_id: ID of the printer
+        profiles: List of K-profiles to set
+    """
+    if not profiles:
+        raise HTTPException(400, "No profiles provided")
+
+    logger.info(f"[API] set_kprofiles_batch: printer={printer_id}, {len(profiles)} profiles")
+    for p in profiles:
+        logger.info(f"  - extruder_id={p.extruder_id}, name={p.name}, k_value={p.k_value}")
+
+    # Check printer exists
+    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")
+
+    # Get MQTT client for printer
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    # Build list of profile dicts for batch command
+    profile_dicts = [
+        {
+            "filament_id": p.filament_id,
+            "name": p.name,
+            "k_value": p.k_value,
+            "nozzle_id": p.nozzle_id,
+            "extruder_id": p.extruder_id,
+            "setting_id": p.setting_id,
+            "slot_id": p.slot_id,
+        }
+        for p in profiles
+    ]
+
+    # Get nozzle_diameter from first profile (all should have same)
+    nozzle_diameter = profiles[0].nozzle_diameter
+
+    success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter)
+
+    if not success:
+        raise HTTPException(500, "Failed to send K-profiles batch command")
+
+    return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
+
+
 @router.delete("/", response_model=dict)
 async def delete_kprofile(
     printer_id: int,
@@ -194,6 +255,10 @@ async def delete_kprofile(
         raise HTTPException(400, "Printer not connected")
 
     # Send the delete command to printer
+    logger.info(
+        f"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, "
+        f"setting_id={profile.setting_id}, filament_id={profile.filament_id}"
+    )
     success = client.delete_kprofile(
         cali_idx=profile.slot_id,
         filament_id=profile.filament_id,
@@ -207,3 +272,115 @@ async def delete_kprofile(
         raise HTTPException(500, "Failed to send K-profile delete command")
 
     return {"success": True, "message": "K-profile deleted successfully"}
+
+
+@router.get("/notes", response_model=KProfileNoteResponse)
+async def get_kprofile_notes(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get all K-profile notes for a printer.
+
+    Notes are stored locally since printers don't support notes.
+
+    Args:
+        printer_id: ID of the printer
+    """
+    # Check printer exists
+    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")
+
+    # Get all notes for this printer
+    result = await db.execute(
+        select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id)
+    )
+    notes = result.scalars().all()
+
+    # Return as a dictionary mapping setting_id -> note
+    return KProfileNoteResponse(
+        notes={note.setting_id: note.note for note in notes}
+    )
+
+
+@router.put("/notes", response_model=dict)
+async def set_kprofile_note(
+    printer_id: int,
+    note_data: KProfileNote,
+    db: AsyncSession = Depends(get_db),
+):
+    """Set or update a note for a K-profile.
+
+    Args:
+        printer_id: ID of the printer
+        note_data: The note data (setting_id and note content)
+    """
+    # Check printer exists
+    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")
+
+    # Find existing note or create new one
+    result = await db.execute(
+        select(KProfileNoteModel).where(
+            KProfileNoteModel.printer_id == printer_id,
+            KProfileNoteModel.setting_id == note_data.setting_id,
+        )
+    )
+    existing_note = result.scalar_one_or_none()
+
+    if note_data.note.strip():
+        # Save or update note
+        if existing_note:
+            existing_note.note = note_data.note
+        else:
+            new_note = KProfileNoteModel(
+                printer_id=printer_id,
+                setting_id=note_data.setting_id,
+                note=note_data.note,
+            )
+            db.add(new_note)
+        await db.commit()
+        return {"success": True, "message": "Note saved"}
+    else:
+        # Delete note if empty
+        if existing_note:
+            await db.delete(existing_note)
+            await db.commit()
+        return {"success": True, "message": "Note deleted"}
+
+
+@router.delete("/notes/{setting_id}", response_model=dict)
+async def delete_kprofile_note(
+    printer_id: int,
+    setting_id: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a note for a K-profile.
+
+    Args:
+        printer_id: ID of the printer
+        setting_id: The setting_id of the K-profile
+    """
+    # Check printer exists
+    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")
+
+    # Find and delete the note
+    result = await db.execute(
+        select(KProfileNoteModel).where(
+            KProfileNoteModel.printer_id == printer_id,
+            KProfileNoteModel.setting_id == setting_id,
+        )
+    )
+    existing_note = result.scalar_one_or_none()
+
+    if existing_note:
+        await db.delete(existing_note)
+        await db.commit()
+
+    return {"success": True, "message": "Note deleted"}

+ 44 - 5
backend/app/api/routes/maintenance.py

@@ -136,6 +136,7 @@ async def create_maintenance_type(
         name=data.name,
         description=data.description,
         default_interval_hours=data.default_interval_hours,
+        interval_type=data.interval_type,
         icon=data.icon,
         is_system=False,
     )
@@ -225,11 +226,16 @@ async def _get_printer_maintenance_internal(
     due_count = 0
     warning_count = 0
 
+    now = datetime.utcnow()
+
     for maint_type in all_types:
         item = existing_items.get(maint_type.id)
+        default_interval_type = getattr(maint_type, 'interval_type', 'hours') or 'hours'
 
         if item:
             interval = item.custom_interval_hours or maint_type.default_interval_hours
+            # Use custom interval type if set, otherwise use type's default
+            interval_type = getattr(item, 'custom_interval_type', None) or default_interval_type
             enabled = item.enabled
             last_performed_hours = item.last_performed_hours
             last_performed_at = item.last_performed_at
@@ -246,15 +252,41 @@ async def _get_printer_maintenance_internal(
             await db.flush()
 
             interval = maint_type.default_interval_hours
+            interval_type = default_interval_type
             enabled = True
             last_performed_hours = 0.0
             last_performed_at = None
             item_id = item.id
 
-        hours_since = total_hours - last_performed_hours
-        hours_until = interval - hours_since
-        is_due = hours_until <= 0
-        is_warning = hours_until <= (interval * 0.1) and not is_due
+        # Calculate status based on interval type
+        if interval_type == "days":
+            # Time-based: calculate days since last performed
+            if last_performed_at:
+                days_since = (now - last_performed_at).total_seconds() / 86400.0
+            else:
+                # Never performed - consider it due
+                days_since = interval + 1
+
+            days_until = interval - days_since
+            is_due = days_until <= 0
+            is_warning = days_until <= (interval * 0.1) and not is_due
+
+            # For compatibility, also set hours values (but they won't be primary)
+            hours_since = total_hours - last_performed_hours
+            hours_until = 0  # Not applicable for time-based
+        else:
+            # Print-hours based (default)
+            hours_since = total_hours - last_performed_hours
+            hours_until = interval - hours_since
+            is_due = hours_until <= 0
+            is_warning = hours_until <= (interval * 0.1) and not is_due
+
+            # Calculate days for reference
+            if last_performed_at:
+                days_since = (now - last_performed_at).total_seconds() / 86400.0
+            else:
+                days_since = None
+            days_until = None
 
         if enabled:
             if is_due:
@@ -271,9 +303,12 @@ async def _get_printer_maintenance_internal(
             maintenance_type_icon=maint_type.icon,
             enabled=enabled,
             interval_hours=interval,
+            interval_type=interval_type,
             current_hours=total_hours,
             hours_since_maintenance=hours_since,
             hours_until_due=hours_until,
+            days_since_maintenance=days_since if interval_type == "days" else None,
+            days_until_due=days_until if interval_type == "days" else None,
             is_due=is_due,
             is_warning=is_warning,
             last_performed_at=last_performed_at,
@@ -389,6 +424,7 @@ async def perform_maintenance(
 
     # Calculate status
     interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
+    interval_type = getattr(item.maintenance_type, 'interval_type', 'hours') or 'hours'
     hours_since = current_hours - item.last_performed_hours
     hours_until = interval - hours_since
 
@@ -401,9 +437,12 @@ async def perform_maintenance(
         maintenance_type_icon=item.maintenance_type.icon,
         enabled=item.enabled,
         interval_hours=interval,
+        interval_type=interval_type,
         current_hours=current_hours,
         hours_since_maintenance=hours_since,
-        hours_until_due=hours_until,
+        hours_until_due=hours_until if interval_type == "hours" else 0,
+        days_since_maintenance=0 if interval_type == "days" else None,
+        days_until_due=interval if interval_type == "days" else None,
         is_due=False,
         is_warning=False,
         last_performed_at=item.last_performed_at,

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

@@ -0,0 +1,148 @@
+"""API routes for notification template management."""
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+from backend.app.schemas.notification_template import (
+    NotificationTemplateResponse,
+    NotificationTemplateUpdate,
+    EventVariablesResponse,
+    TemplatePreviewRequest,
+    TemplatePreviewResponse,
+    EVENT_VARIABLES,
+    SAMPLE_DATA,
+)
+from backend.app.services.notification_service import notification_service
+
+router = APIRouter(prefix="/notification-templates", tags=["notification-templates"])
+
+
+# Event type display names
+EVENT_NAMES = {
+    "print_start": "Print Started",
+    "print_complete": "Print Completed",
+    "print_failed": "Print Failed",
+    "print_stopped": "Print Stopped",
+    "print_progress": "Print Progress",
+    "printer_offline": "Printer Offline",
+    "printer_error": "Printer Error",
+    "filament_low": "Filament Low",
+    "maintenance_due": "Maintenance Due",
+    "test": "Test Notification",
+}
+
+
+@router.get("", response_model=list[NotificationTemplateResponse])
+async def get_templates(db: AsyncSession = Depends(get_db)):
+    """Get all notification templates."""
+    result = await db.execute(
+        select(NotificationTemplate).order_by(NotificationTemplate.id)
+    )
+    return result.scalars().all()
+
+
+@router.get("/variables", response_model=list[EventVariablesResponse])
+async def get_variables():
+    """Get available variables for each event type."""
+    return [
+        EventVariablesResponse(
+            event_type=event_type,
+            event_name=EVENT_NAMES.get(event_type, event_type),
+            variables=variables,
+        )
+        for event_type, variables in EVENT_VARIABLES.items()
+    ]
+
+
+@router.get("/{template_id}", response_model=NotificationTemplateResponse)
+async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a single notification template."""
+    result = await db.execute(
+        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
+    )
+    template = result.scalar_one_or_none()
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+    return template
+
+
+@router.put("/{template_id}", response_model=NotificationTemplateResponse)
+async def update_template(
+    template_id: int,
+    update: NotificationTemplateUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a notification template."""
+    result = await db.execute(
+        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
+    )
+    template = result.scalar_one_or_none()
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    if update.title_template is not None:
+        template.title_template = update.title_template
+    if update.body_template is not None:
+        template.body_template = update.body_template
+
+    await db.commit()
+    await db.refresh(template)
+
+    # Clear template cache so changes take effect immediately
+    notification_service.clear_template_cache()
+
+    return template
+
+
+@router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
+async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
+    """Reset a notification template to its default values."""
+    result = await db.execute(
+        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
+    )
+    template = result.scalar_one_or_none()
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    # Find the default template
+    default = next(
+        (t for t in DEFAULT_TEMPLATES if t["event_type"] == template.event_type),
+        None,
+    )
+    if not default:
+        raise HTTPException(status_code=500, detail="Default template not found")
+
+    template.title_template = default["title_template"]
+    template.body_template = default["body_template"]
+
+    await db.commit()
+    await db.refresh(template)
+
+    # Clear template cache so changes take effect immediately
+    notification_service.clear_template_cache()
+
+    return template
+
+
+@router.post("/preview", response_model=TemplatePreviewResponse)
+async def preview_template(request: TemplatePreviewRequest):
+    """Preview a template with sample data."""
+    sample = SAMPLE_DATA.get(request.event_type, {})
+
+    # Safe template rendering - replace missing vars with empty string
+    def safe_format(template: str, data: dict) -> str:
+        result = template
+        for key, value in data.items():
+            result = result.replace("{" + key + "}", str(value))
+        # Remove any remaining unreplaced placeholders
+        import re
+        result = re.sub(r"\{[a-z_]+\}", "", result)
+        return result
+
+    return TemplatePreviewResponse(
+        title=safe_format(request.title_template, sample),
+        body=safe_format(request.body_template, sample),
+    )

+ 211 - 17
backend/app/api/routes/notifications.py

@@ -2,14 +2,17 @@
 
 import json
 import logging
+from datetime import datetime, timedelta
 
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
-from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.schemas.notification import (
+    NotificationLogResponse,
+    NotificationLogStats,
     NotificationProviderCreate,
     NotificationProviderResponse,
     NotificationProviderUpdate,
@@ -46,6 +49,9 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
         "quiet_hours_end": provider.quiet_hours_end,
+        # Daily digest
+        "daily_digest_enabled": provider.daily_digest_enabled,
+        "daily_digest_time": provider.daily_digest_time,
         # Printer filter
         "printer_id": provider.printer_id,
         # Status tracking
@@ -58,6 +64,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
     }
 
 
+# ============================================================================
+# Provider List/Create Routes (no path parameters)
+# ============================================================================
+
 @router.get("/", response_model=list[NotificationProviderResponse])
 async def list_notification_providers(db: AsyncSession = Depends(get_db)):
     """List all notification providers."""
@@ -106,6 +116,204 @@ async def create_notification_provider(
     return _provider_to_dict(provider)
 
 
+# ============================================================================
+# Static Path Routes (must come BEFORE parameterized routes)
+# ============================================================================
+
+@router.post("/test-config", response_model=NotificationTestResponse)
+async def test_notification_config(
+    test_request: NotificationTestRequest,
+):
+    """Test notification configuration before saving."""
+    success, message = await notification_service.send_test_notification(
+        test_request.provider_type.value, test_request.config
+    )
+
+    return NotificationTestResponse(success=success, message=message)
+
+
+@router.post("/test-all")
+async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
+    """Send a test notification to all enabled providers."""
+    result = await db.execute(
+        select(NotificationProvider).where(NotificationProvider.enabled == True)
+    )
+    providers = result.scalars().all()
+
+    if not providers:
+        return {"tested": 0, "success": 0, "failed": 0, "results": []}
+
+    results = []
+    success_count = 0
+    failed_count = 0
+
+    for provider in providers:
+        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        success, message = await notification_service.send_test_notification(
+            provider.provider_type, config
+        )
+
+        # Update provider status
+        if success:
+            provider.last_success = datetime.utcnow()
+            success_count += 1
+        else:
+            provider.last_error = message
+            provider.last_error_at = datetime.utcnow()
+            failed_count += 1
+
+        results.append({
+            "provider_id": provider.id,
+            "provider_name": provider.name,
+            "provider_type": provider.provider_type,
+            "success": success,
+            "message": message,
+        })
+
+    await db.commit()
+
+    return {
+        "tested": len(providers),
+        "success": success_count,
+        "failed": failed_count,
+        "results": results,
+    }
+
+
+# ============================================================================
+# Notification Log Routes (must come BEFORE /{provider_id} routes)
+# ============================================================================
+
+@router.get("/logs", response_model=list[NotificationLogResponse])
+async def get_notification_logs(
+    limit: int = Query(default=100, ge=1, le=500),
+    offset: int = Query(default=0, ge=0),
+    provider_id: int | None = Query(default=None),
+    event_type: str | None = Query(default=None),
+    success: bool | None = Query(default=None),
+    days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get notification logs with optional filters."""
+    query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
+
+    # Apply filters
+    if provider_id is not None:
+        query = query.where(NotificationLog.provider_id == provider_id)
+    if event_type is not None:
+        query = query.where(NotificationLog.event_type == event_type)
+    if success is not None:
+        query = query.where(NotificationLog.success == success)
+    if days is not None:
+        cutoff = datetime.utcnow() - timedelta(days=days)
+        query = query.where(NotificationLog.created_at >= cutoff)
+
+    query = query.offset(offset).limit(limit)
+
+    result = await db.execute(query)
+    logs = result.scalars().all()
+
+    # Get provider info for each log
+    response = []
+    providers_cache: dict[int, NotificationProvider | None] = {}
+
+    for log in logs:
+        if log.provider_id not in providers_cache:
+            provider_result = await db.execute(
+                select(NotificationProvider).where(NotificationProvider.id == log.provider_id)
+            )
+            providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
+
+        provider = providers_cache[log.provider_id]
+        response.append(NotificationLogResponse(
+            id=log.id,
+            provider_id=log.provider_id,
+            provider_name=provider.name if provider else None,
+            provider_type=provider.provider_type if provider else None,
+            event_type=log.event_type,
+            title=log.title,
+            message=log.message,
+            success=log.success,
+            error_message=log.error_message,
+            printer_id=log.printer_id,
+            printer_name=log.printer_name,
+            created_at=log.created_at,
+        ))
+
+    return response
+
+
+@router.get("/logs/stats", response_model=NotificationLogStats)
+async def get_notification_log_stats(
+    days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get notification log statistics."""
+    cutoff = datetime.utcnow() - timedelta(days=days)
+
+    # Total counts
+    total_result = await db.execute(
+        select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff)
+    )
+    total = total_result.scalar() or 0
+
+    success_result = await db.execute(
+        select(func.count(NotificationLog.id)).where(
+            NotificationLog.created_at >= cutoff,
+            NotificationLog.success == True
+        )
+    )
+    success_count = success_result.scalar() or 0
+
+    # By event type
+    event_result = await db.execute(
+        select(NotificationLog.event_type, func.count(NotificationLog.id))
+        .where(NotificationLog.created_at >= cutoff)
+        .group_by(NotificationLog.event_type)
+    )
+    by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
+
+    # By provider (need to join to get name)
+    provider_result = await db.execute(
+        select(NotificationProvider.name, func.count(NotificationLog.id))
+        .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
+        .where(NotificationLog.created_at >= cutoff)
+        .group_by(NotificationProvider.name)
+    )
+    by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
+
+    return NotificationLogStats(
+        total=total,
+        success_count=success_count,
+        failure_count=total - success_count,
+        by_event_type=by_event_type,
+        by_provider=by_provider,
+    )
+
+
+@router.delete("/logs")
+async def clear_notification_logs(
+    older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Clear old notification logs."""
+    cutoff = datetime.utcnow() - timedelta(days=older_than_days)
+
+    result = await db.execute(
+        delete(NotificationLog).where(NotificationLog.created_at < cutoff)
+    )
+    await db.commit()
+
+    deleted_count = result.rowcount
+    logger.info(f"Deleted {deleted_count} notification logs older than {older_than_days} days")
+
+    return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
+
+
+# ============================================================================
+# Provider Instance Routes (parameterized - must come LAST)
+# ============================================================================
+
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 async def get_notification_provider(
     provider_id: int,
@@ -201,25 +409,11 @@ async def test_notification_provider(
 
     # Update provider status
     if success:
-        from datetime import datetime
         provider.last_success = datetime.utcnow()
     else:
-        from datetime import datetime
         provider.last_error = message
         provider.last_error_at = datetime.utcnow()
 
     await db.commit()
 
     return NotificationTestResponse(success=success, message=message)
-
-
-@router.post("/test-config", response_model=NotificationTestResponse)
-async def test_notification_config(
-    test_request: NotificationTestRequest,
-):
-    """Test notification configuration before saving."""
-    success, message = await notification_service.send_test_notification(
-        test_request.provider_type.value, test_request.config
-    )
-
-    return NotificationTestResponse(success=success, message=message)

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

@@ -30,6 +30,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     if item.archive:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
+        response.print_time_seconds = item.archive.print_time_seconds
     if item.printer:
         response.printer_name = item.printer.name
     return response

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

@@ -13,14 +13,20 @@ from sqlalchemy import select
 from backend.app.core.database import get_db
 from backend.app.core.config import settings
 from backend.app.models.printer import Printer
+from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.schemas.printer import (
     PrinterCreate,
     PrinterUpdate,
     PrinterResponse,
     PrinterStatus,
     HMSErrorResponse,
+    AMSUnit,
+    AMSTray,
+    NozzleInfoResponse,
+    PrintOptionsResponse,
 )
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.bambu_mqtt import get_stage_name
 from backend.app.services.bambu_ftp import (
     download_file_try_paths_async,
     list_files_async,
@@ -141,10 +147,135 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
 
     # Convert HMS errors to response format
     hms_errors = [
-        HMSErrorResponse(code=e.code, module=e.module, severity=e.severity)
+        HMSErrorResponse(code=e.code, attr=e.attr, module=e.module, severity=e.severity)
         for e in (state.hms_errors or [])
     ]
 
+    # Parse AMS data from raw_data
+    ams_units = []
+    vt_tray = None
+    ams_exists = False
+    raw_data = state.raw_data or {}
+
+    if "ams" in raw_data and isinstance(raw_data["ams"], list):
+        ams_exists = True
+        for ams_data in raw_data["ams"]:
+            # Skip if ams_data is not a dict (defensive check)
+            if not isinstance(ams_data, dict):
+                continue
+            trays = []
+            for tray_data in ams_data.get("tray", []):
+                # Filter out empty/invalid tag values
+                tag_uid = tray_data.get("tag_uid", "")
+                if tag_uid in ("", "0000000000000000"):
+                    tag_uid = None
+                tray_uuid = tray_data.get("tray_uuid", "")
+                if tray_uuid in ("", "00000000000000000000000000000000"):
+                    tray_uuid = None
+                trays.append(AMSTray(
+                    id=tray_data.get("id", 0),
+                    tray_color=tray_data.get("tray_color"),
+                    tray_type=tray_data.get("tray_type"),
+                    tray_sub_brands=tray_data.get("tray_sub_brands"),
+                    tray_id_name=tray_data.get("tray_id_name"),
+                    tray_info_idx=tray_data.get("tray_info_idx"),
+                    remain=tray_data.get("remain", 0),
+                    k=tray_data.get("k"),
+                    tag_uid=tag_uid,
+                    tray_uuid=tray_uuid,
+                    nozzle_temp_min=tray_data.get("nozzle_temp_min"),
+                    nozzle_temp_max=tray_data.get("nozzle_temp_max"),
+                ))
+            # Prefer humidity_raw (percentage) over humidity (index 1-5)
+            # humidity_raw is the actual percentage value from the sensor
+            humidity_raw = ams_data.get("humidity_raw")
+            humidity_idx = ams_data.get("humidity")
+            humidity_value = None
+
+            if humidity_raw is not None:
+                try:
+                    humidity_value = int(humidity_raw)
+                except (ValueError, TypeError):
+                    pass
+            if humidity_value is None and humidity_idx is not None:
+                try:
+                    humidity_value = int(humidity_idx)
+                except (ValueError, TypeError):
+                    pass
+            # AMS-HT has 1 tray, regular AMS has 4 trays
+            is_ams_ht = len(trays) == 1
+
+            ams_units.append(AMSUnit(
+                id=ams_data.get("id", 0),
+                humidity=humidity_value,
+                temp=ams_data.get("temp"),
+                is_ams_ht=is_ams_ht,
+                tray=trays,
+            ))
+
+    # Virtual tray (external spool holder) - comes from vt_tray in raw_data
+    if "vt_tray" in raw_data:
+        vt_data = raw_data["vt_tray"]
+        # Filter out empty/invalid tag values for vt_tray
+        vt_tag_uid = vt_data.get("tag_uid", "")
+        if vt_tag_uid in ("", "0000000000000000"):
+            vt_tag_uid = None
+        vt_tray_uuid = vt_data.get("tray_uuid", "")
+        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+            vt_tray_uuid = None
+        vt_tray = AMSTray(
+            id=254,  # Virtual tray ID
+            tray_color=vt_data.get("tray_color"),
+            tray_type=vt_data.get("tray_type"),
+            tray_sub_brands=vt_data.get("tray_sub_brands"),
+            remain=vt_data.get("remain", 0),
+            k=vt_data.get("k"),
+            tag_uid=vt_tag_uid,
+            tray_uuid=vt_tray_uuid,
+            nozzle_temp_min=vt_data.get("nozzle_temp_min"),
+            nozzle_temp_max=vt_data.get("nozzle_temp_max"),
+        )
+
+    # Convert nozzle info to response format
+    nozzles = [
+        NozzleInfoResponse(
+            nozzle_type=n.nozzle_type,
+            nozzle_diameter=n.nozzle_diameter,
+        )
+        for n in (state.nozzles or [])
+    ]
+
+    # Convert print options to response format
+    print_options = PrintOptionsResponse(
+        spaghetti_detector=state.print_options.spaghetti_detector,
+        print_halt=state.print_options.print_halt,
+        halt_print_sensitivity=state.print_options.halt_print_sensitivity,
+        first_layer_inspector=state.print_options.first_layer_inspector,
+        printing_monitor=state.print_options.printing_monitor,
+        buildplate_marker_detector=state.print_options.buildplate_marker_detector,
+        allow_skip_parts=state.print_options.allow_skip_parts,
+        nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
+        nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
+        pileup_detector=state.print_options.pileup_detector,
+        pileup_sensitivity=state.print_options.pileup_sensitivity,
+        airprint_detector=state.print_options.airprint_detector,
+        airprint_sensitivity=state.print_options.airprint_sensitivity,
+        auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
+        filament_tangle_detect=state.print_options.filament_tangle_detect,
+    )
+
+    # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
+    ams_mapping = raw_data.get("ams_mapping", [])
+    # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
+    ams_extruder_map = raw_data.get("ams_extruder_map", {})
+    logger.debug(f"API returning ams_mapping: {ams_mapping}, ams_extruder_map: {ams_extruder_map}")
+
+    # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
+    # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
+    # No conversion needed - just use the raw value directly
+    tray_now = state.tray_now
+    logger.debug(f"Using tray_now directly as global ID: {tray_now}")
+
     return PrinterStatus(
         id=printer_id,
         name=printer.name,
@@ -160,6 +291,30 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         temperatures=state.temperatures,
         cover_url=cover_url,
         hms_errors=hms_errors,
+        ams=ams_units,
+        ams_exists=ams_exists,
+        vt_tray=vt_tray,
+        sdcard=state.sdcard,
+        store_to_sdcard=state.store_to_sdcard,
+        timelapse=state.timelapse,
+        ipcam=state.ipcam,
+        wifi_signal=state.wifi_signal,
+        nozzles=nozzles,
+        print_options=print_options,
+        stg_cur=state.stg_cur,
+        stg_cur_name=get_stage_name(state.stg_cur) if state.stg_cur >= 0 else None,
+        stg=state.stg,
+        airduct_mode=state.airduct_mode,
+        speed_level=state.speed_level,
+        chamber_light=state.chamber_light,
+        active_extruder=state.active_extruder,
+        ams_mapping=ams_mapping,
+        ams_extruder_map=ams_extruder_map,
+        tray_now=tray_now,
+        ams_status_main=state.ams_status_main,
+        ams_status_sub=state.ams_status_sub,
+        mc_print_sub_stage=state.mc_print_sub_stage,
+        last_ams_update=state.last_ams_update,
     )
 
 
@@ -487,3 +642,262 @@ async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
 
     printer_manager.clear_logs(printer_id)
     return {"status": "cleared"}
+
+
+# ============================================
+# Print Options (AI Detection) Endpoints
+# ============================================
+
+@router.post("/{printer_id}/print-options")
+async def set_print_option(
+    printer_id: int,
+    module_name: str,
+    enabled: bool,
+    print_halt: bool = True,
+    sensitivity: str = "medium",
+    db: AsyncSession = Depends(get_db),
+):
+    """Set an AI detection / print option on the printer.
+
+    Valid module_name values:
+    - spaghetti_detector: Spaghetti detection
+    - first_layer_inspector: First layer inspection
+    - printing_monitor: AI print quality monitoring
+    - buildplate_marker_detector: Build plate marker detection
+    - allow_skip_parts: Allow skipping failed parts
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    # Validate module_name
+    valid_modules = [
+        "spaghetti_detector",
+        "first_layer_inspector",
+        "printing_monitor",
+        "buildplate_marker_detector",
+        "allow_skip_parts",
+        "pileup_detector",
+        "clump_detector",
+        "airprint_detector",
+        "auto_recovery_step_loss",
+    ]
+    if module_name not in valid_modules:
+        raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
+
+    # Validate sensitivity
+    valid_sensitivities = ["low", "medium", "high", "never_halt"]
+    if sensitivity not in valid_sensitivities:
+        raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
+
+    success = client.set_xcam_option(
+        module_name=module_name,
+        enabled=enabled,
+        print_halt=print_halt,
+        sensitivity=sensitivity,
+    )
+
+    if not success:
+        raise HTTPException(500, "Failed to send command to printer")
+
+    return {
+        "success": True,
+        "module_name": module_name,
+        "enabled": enabled,
+        "print_halt": print_halt,
+        "sensitivity": sensitivity,
+    }
+
+
+# ============================================
+# Calibration
+# ============================================
+
+@router.post("/{printer_id}/calibration")
+async def start_calibration(
+    printer_id: int,
+    bed_leveling: bool = False,
+    vibration: bool = False,
+    motor_noise: bool = False,
+    nozzle_offset: bool = False,
+    high_temp_heatbed: bool = False,
+    db: AsyncSession = Depends(get_db),
+):
+    """Start printer calibration with selected options.
+
+    At least one option must be selected.
+
+    Options:
+    - bed_leveling: Run bed leveling calibration
+    - vibration: Run vibration compensation calibration
+    - motor_noise: Run motor noise cancellation calibration
+    - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
+    - high_temp_heatbed: Run high-temperature heatbed calibration
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    # Check that at least one option is selected
+    if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
+        raise HTTPException(400, "At least one calibration option must be selected")
+
+    success = client.start_calibration(
+        bed_leveling=bed_leveling,
+        vibration=vibration,
+        motor_noise=motor_noise,
+        nozzle_offset=nozzle_offset,
+        high_temp_heatbed=high_temp_heatbed,
+    )
+
+    if not success:
+        raise HTTPException(500, "Failed to send calibration command to printer")
+
+    return {
+        "success": True,
+        "bed_leveling": bed_leveling,
+        "vibration": vibration,
+        "motor_noise": motor_noise,
+        "nozzle_offset": nozzle_offset,
+        "high_temp_heatbed": high_temp_heatbed,
+    }
+
+
+# ============================================================================
+# Slot Preset Mapping Endpoints
+# ============================================================================
+
+
+@router.get("/{printer_id}/slot-presets")
+async def get_slot_presets(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get all saved slot-to-preset mappings for a printer."""
+    result = await db.execute(
+        select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id)
+    )
+    mappings = result.scalars().all()
+
+    return {
+        mapping.ams_id * 4 + mapping.tray_id: {
+            "ams_id": mapping.ams_id,
+            "tray_id": mapping.tray_id,
+            "preset_id": mapping.preset_id,
+            "preset_name": mapping.preset_name,
+        }
+        for mapping in mappings
+    }
+
+
+@router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
+async def get_slot_preset(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the saved preset for a specific slot."""
+    result = await db.execute(
+        select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer_id,
+            SlotPresetMapping.ams_id == ams_id,
+            SlotPresetMapping.tray_id == tray_id,
+        )
+    )
+    mapping = result.scalar_one_or_none()
+
+    if not mapping:
+        return None
+
+    return {
+        "ams_id": mapping.ams_id,
+        "tray_id": mapping.tray_id,
+        "preset_id": mapping.preset_id,
+        "preset_name": mapping.preset_name,
+    }
+
+
+@router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
+async def save_slot_preset(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    preset_id: str,
+    preset_name: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Save a preset mapping for a specific slot."""
+    # Check printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Printer not found")
+
+    # Check for existing mapping
+    result = await db.execute(
+        select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer_id,
+            SlotPresetMapping.ams_id == ams_id,
+            SlotPresetMapping.tray_id == tray_id,
+        )
+    )
+    mapping = result.scalar_one_or_none()
+
+    if mapping:
+        # Update existing
+        mapping.preset_id = preset_id
+        mapping.preset_name = preset_name
+    else:
+        # Create new
+        mapping = SlotPresetMapping(
+            printer_id=printer_id,
+            ams_id=ams_id,
+            tray_id=tray_id,
+            preset_id=preset_id,
+            preset_name=preset_name,
+        )
+        db.add(mapping)
+
+    await db.commit()
+    await db.refresh(mapping)
+
+    return {
+        "ams_id": mapping.ams_id,
+        "tray_id": mapping.tray_id,
+        "preset_id": mapping.preset_id,
+        "preset_name": mapping.preset_name,
+    }
+
+
+@router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
+async def delete_slot_preset(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a saved preset mapping for a slot."""
+    result = await db.execute(
+        select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer_id,
+            SlotPresetMapping.ams_id == ams_id,
+            SlotPresetMapping.tray_id == tray_id,
+        )
+    )
+    mapping = result.scalar_one_or_none()
+
+    if mapping:
+        await db.delete(mapping)
+        await db.commit()
+
+    return {"success": True}

+ 156 - 19
backend/app/api/routes/settings.py

@@ -1,12 +1,15 @@
-import shutil
-from pathlib import Path
+import json
+from datetime import datetime
 
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, UploadFile, File
+from fastapi.responses import JSONResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 
 from backend.app.core.database import get_db
 from backend.app.models.settings import Settings
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.smart_plug import SmartPlug
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 
 
@@ -49,8 +52,13 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
             # Parse the value based on the expected type
             if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
-            elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
+            elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 settings_dict[setting.key] = float(setting.value)
+            elif setting.key in ["ams_humidity_good", "ams_humidity_fair"]:
+                settings_dict[setting.key] = int(setting.value)
+            elif setting.key == "default_printer_id":
+                # Handle nullable integer
+                settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
             else:
                 settings_dict[setting.key] = setting.value
 
@@ -69,6 +77,8 @@ async def update_settings(
         # Convert value to string for storage
         if isinstance(value, bool):
             str_value = "true" if value else "false"
+        elif value is None:
+            str_value = "None"
         else:
             str_value = str(value)
         await set_setting(db, key, str_value)
@@ -95,21 +105,9 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
 @router.get("/check-ffmpeg")
 async def check_ffmpeg():
     """Check if ffmpeg is installed and available."""
-    ffmpeg_path = shutil.which("ffmpeg")
-
-    # If not found via PATH, check common installation locations
-    # (systemd services often have limited PATH)
-    if ffmpeg_path is None:
-        common_paths = [
-            "/usr/bin/ffmpeg",
-            "/usr/local/bin/ffmpeg",
-            "/opt/homebrew/bin/ffmpeg",
-            "/snap/bin/ffmpeg",
-        ]
-        for path in common_paths:
-            if Path(path).exists():
-                ffmpeg_path = path
-                break
+    from backend.app.services.camera import get_ffmpeg_path
+
+    ffmpeg_path = get_ffmpeg_path()
 
     return {
         "installed": ffmpeg_path is not None,
@@ -148,3 +146,142 @@ async def update_spoolman_settings(
 
     # Return updated settings
     return await get_spoolman_settings(db)
+
+
+@router.get("/backup")
+async def export_backup(db: AsyncSession = Depends(get_db)):
+    """Export all settings, notification providers, and smart plugs as JSON backup."""
+    # Get all settings
+    result = await db.execute(select(Settings))
+    db_settings = result.scalars().all()
+    settings_data = {s.key: s.value for s in db_settings}
+
+    # Get notification providers
+    result = await db.execute(select(NotificationProvider))
+    providers = result.scalars().all()
+    providers_data = []
+    for p in providers:
+        providers_data.append({
+            "name": p.name,
+            "provider_type": p.provider_type,
+            "enabled": p.enabled,
+            "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
+            "on_print_start": p.on_print_start,
+            "on_print_complete": p.on_print_complete,
+            "on_print_failed": p.on_print_failed,
+            "on_print_stopped": p.on_print_stopped,
+            "on_print_progress": p.on_print_progress,
+            "on_printer_offline": p.on_printer_offline,
+            "on_printer_error": p.on_printer_error,
+            "on_filament_low": p.on_filament_low,
+            "on_maintenance_due": p.on_maintenance_due,
+            "quiet_hours_enabled": p.quiet_hours_enabled,
+            "quiet_hours_start": p.quiet_hours_start,
+            "quiet_hours_end": p.quiet_hours_end,
+        })
+
+    # Get smart plugs
+    result = await db.execute(select(SmartPlug))
+    plugs = result.scalars().all()
+    plugs_data = []
+    for plug in plugs:
+        plugs_data.append({
+            "name": plug.name,
+            "ip_address": plug.ip_address,
+            "enabled": plug.enabled,
+            "auto_off_enabled": plug.auto_off_enabled,
+            "auto_off_delay_minutes": plug.auto_off_delay_minutes,
+        })
+
+    backup = {
+        "version": "1.0",
+        "exported_at": datetime.utcnow().isoformat(),
+        "settings": settings_data,
+        "notification_providers": providers_data,
+        "smart_plugs": plugs_data,
+    }
+
+    return JSONResponse(
+        content=backup,
+        headers={
+            "Content-Disposition": f"attachment; filename=bambutrack-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
+        }
+    )
+
+
+@router.post("/restore")
+async def import_backup(
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Restore settings, notification providers, and smart plugs from JSON backup."""
+    try:
+        content = await file.read()
+        backup = json.loads(content.decode("utf-8"))
+    except Exception as e:
+        return {"success": False, "message": f"Invalid backup file: {str(e)}"}
+
+    restored = {"settings": 0, "notification_providers": 0, "smart_plugs": 0}
+
+    # Restore settings
+    if "settings" in backup:
+        for key, value in backup["settings"].items():
+            await set_setting(db, key, value)
+            restored["settings"] += 1
+
+    # Restore notification providers (skip duplicates by name)
+    if "notification_providers" in backup:
+        for provider_data in backup["notification_providers"]:
+            # Check if provider with same name exists
+            result = await db.execute(
+                select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
+            )
+            existing = result.scalar_one_or_none()
+            if not existing:
+                provider = NotificationProvider(
+                    name=provider_data["name"],
+                    provider_type=provider_data["provider_type"],
+                    enabled=provider_data.get("enabled", True),
+                    config=json.dumps(provider_data.get("config", {})),
+                    on_print_start=provider_data.get("on_print_start", False),
+                    on_print_complete=provider_data.get("on_print_complete", True),
+                    on_print_failed=provider_data.get("on_print_failed", True),
+                    on_print_stopped=provider_data.get("on_print_stopped", True),
+                    on_print_progress=provider_data.get("on_print_progress", False),
+                    on_printer_offline=provider_data.get("on_printer_offline", False),
+                    on_printer_error=provider_data.get("on_printer_error", False),
+                    on_filament_low=provider_data.get("on_filament_low", False),
+                    on_maintenance_due=provider_data.get("on_maintenance_due", 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"),
+                )
+                db.add(provider)
+                restored["notification_providers"] += 1
+
+    # Restore smart plugs (skip duplicates by IP)
+    if "smart_plugs" in backup:
+        for plug_data in backup["smart_plugs"]:
+            # Check if plug with same IP exists
+            result = await db.execute(
+                select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
+            )
+            existing = result.scalar_one_or_none()
+            if not existing:
+                plug = SmartPlug(
+                    name=plug_data["name"],
+                    ip_address=plug_data["ip_address"],
+                    enabled=plug_data.get("enabled", True),
+                    auto_off_enabled=plug_data.get("auto_off_enabled", False),
+                    auto_off_delay_minutes=plug_data.get("auto_off_delay_minutes", 5),
+                )
+                db.add(plug)
+                restored["smart_plugs"] += 1
+
+    await db.commit()
+
+    return {
+        "success": True,
+        "message": f"Restored {restored['settings']} settings, {restored['notification_providers']} notification providers, {restored['smart_plugs']} smart plugs",
+        "restored": restored,
+    }

+ 60 - 1
backend/app/api/routes/smart_plugs.py

@@ -1,7 +1,7 @@
 """API routes for smart plug management."""
 
 import logging
-from datetime import datetime
+from datetime import datetime, timedelta
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,6 +21,7 @@ from backend.app.schemas.smart_plug import (
 )
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.notification_service import notification_service
 
 logger = logging.getLogger(__name__)
 
@@ -211,6 +212,9 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
         if energy:
             energy_data = SmartPlugEnergy(**energy)
 
+            # Check power alerts
+            await check_power_alerts(plug, energy.get("power"), db)
+
     return SmartPlugStatus(
         state=status["state"],
         reachable=status["reachable"],
@@ -219,6 +223,61 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     )
 
 
+async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
+    """Check if power crosses alert thresholds and send notifications."""
+    if not plug.power_alert_enabled or current_power is None:
+        return
+
+    # Cooldown: don't alert more than once per 5 minutes
+    cooldown_minutes = 5
+    if plug.power_alert_last_triggered:
+        time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
+        if time_since_last < timedelta(minutes=cooldown_minutes):
+            return
+
+    alert_triggered = False
+    alert_type = None
+    threshold = None
+
+    # Check high threshold
+    if plug.power_alert_high is not None and current_power > plug.power_alert_high:
+        alert_triggered = True
+        alert_type = "high"
+        threshold = plug.power_alert_high
+
+    # Check low threshold
+    if plug.power_alert_low is not None and current_power < plug.power_alert_low:
+        alert_triggered = True
+        alert_type = "low"
+        threshold = plug.power_alert_low
+
+    if alert_triggered:
+        plug.power_alert_last_triggered = datetime.utcnow()
+        await db.commit()
+
+        # Send notification
+        title = f"Power Alert: {plug.name}"
+        if alert_type == "high":
+            message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
+        else:
+            message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
+
+        logger.info(f"Power alert triggered for {plug.name}: {message}")
+
+        # Use printer_error event type for power alerts (closest match)
+        await notification_service.send_notification(
+            event_type="printer_error",
+            title=title,
+            message=message,
+            printer_id=plug.printer_id,
+            printer_name=plug.name,
+            context={
+                "error_type": f"Power {alert_type.title()}",
+                "error_detail": message,
+            },
+        )
+
+
 @router.post("/test-connection")
 async def test_connection(data: SmartPlugTestConnection):
     """Test connection to a Tasmota device."""

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

@@ -2,7 +2,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.1.4"
+APP_VERSION = "0.1.5b"
 GITHUB_REPO = "maziggy/bambusy"
 
 

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

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
@@ -42,6 +42,9 @@ async def init_db():
         # Run migrations for new columns (SQLite doesn't auto-add columns)
         await run_migrations(conn)
 
+    # Seed default notification templates
+    await seed_notification_templates()
+
 
 async def run_migrations(conn):
     """Add new columns to existing tables if they don't exist."""
@@ -100,3 +103,116 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add location column to printers for grouping
+    try:
+        await conn.execute(text(
+            "ALTER TABLE printers ADD COLUMN location VARCHAR(100)"
+        ))
+    except Exception:
+        # Column already exists
+        pass
+
+    # Migration: Add interval_type column to maintenance_types
+    try:
+        await conn.execute(text(
+            "ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"
+        ))
+    except Exception:
+        # Column already exists
+        pass
+
+    # Migration: Add custom_interval_type column to printer_maintenance
+    try:
+        await conn.execute(text(
+            "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"
+        ))
+    except Exception:
+        # Column already exists
+        pass
+
+    # Migration: Add power alert columns to smart_plugs
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"
+        ))
+    except Exception:
+        pass
+
+    # Migration: Add schedule columns to smart_plugs
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"
+        ))
+    except Exception:
+        pass
+
+    # Migration: Add daily digest columns to notification_providers
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"
+        ))
+    except Exception:
+        pass
+
+
+async def seed_notification_templates():
+    """Seed default notification templates if they don't exist."""
+    from sqlalchemy import select
+    from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+
+    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)
+
+        await session.commit()

+ 625 - 0
backend/app/data/filament_fields.json

@@ -0,0 +1,625 @@
+{
+  "version": "1.0.0",
+  "description": "Filament preset field definitions for Bambu Lab printers",
+  "fields": [
+    {
+      "key": "filament_vendor",
+      "label": "Vendor",
+      "type": "text",
+      "category": "basic",
+      "description": "Filament manufacturer name"
+    },
+    {
+      "key": "filament_type",
+      "label": "Filament Type",
+      "type": "select",
+      "category": "basic",
+      "description": "Material type",
+      "options": [
+        {"value": "PLA", "label": "PLA"},
+        {"value": "ABS", "label": "ABS"},
+        {"value": "PETG", "label": "PETG"},
+        {"value": "TPU", "label": "TPU"},
+        {"value": "PA", "label": "PA (Nylon)"},
+        {"value": "PA-CF", "label": "PA-CF"},
+        {"value": "PET-CF", "label": "PET-CF"},
+        {"value": "PC", "label": "PC"},
+        {"value": "ASA", "label": "ASA"},
+        {"value": "PVA", "label": "PVA"},
+        {"value": "HIPS", "label": "HIPS"}
+      ]
+    },
+    {
+      "key": "filament_cost",
+      "label": "Filament Cost",
+      "type": "number",
+      "category": "basic",
+      "description": "Cost per kg",
+      "unit": "$/kg"
+    },
+    {
+      "key": "filament_density",
+      "label": "Density",
+      "type": "number",
+      "category": "basic",
+      "description": "Material density",
+      "unit": "g/cm³",
+      "step": 0.01
+    },
+    {
+      "key": "filament_diameter",
+      "label": "Filament Diameter",
+      "type": "number",
+      "category": "basic",
+      "description": "Filament diameter",
+      "unit": "mm",
+      "step": 0.01,
+      "min": 1.5,
+      "max": 3.0
+    },
+    {
+      "key": "nozzle_temperature",
+      "label": "Nozzle Temperature",
+      "type": "text",
+      "category": "temperature",
+      "description": "Printing temperature (comma-separated: normal,first layer)",
+      "unit": "°C"
+    },
+    {
+      "key": "nozzle_temperature_initial_layer",
+      "label": "Initial Layer Nozzle Temp",
+      "type": "text",
+      "category": "temperature",
+      "description": "First layer nozzle temperature",
+      "unit": "°C"
+    },
+    {
+      "key": "nozzle_temperature_range_low",
+      "label": "Min Nozzle Temp",
+      "type": "number",
+      "category": "temperature",
+      "description": "Minimum recommended nozzle temperature",
+      "unit": "°C",
+      "min": 150,
+      "max": 350
+    },
+    {
+      "key": "nozzle_temperature_range_high",
+      "label": "Max Nozzle Temp",
+      "type": "number",
+      "category": "temperature",
+      "description": "Maximum recommended nozzle temperature",
+      "unit": "°C",
+      "min": 150,
+      "max": 350
+    },
+    {
+      "key": "hot_plate_temp",
+      "label": "Bed Temperature (Hot Plate)",
+      "type": "number",
+      "category": "temperature",
+      "description": "Bed temperature for standard plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "hot_plate_temp_initial_layer",
+      "label": "Initial Layer Bed Temp (Hot Plate)",
+      "type": "number",
+      "category": "temperature",
+      "description": "First layer bed temperature for standard plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "cool_plate_temp",
+      "label": "Bed Temperature (Cool Plate)",
+      "type": "number",
+      "category": "temperature",
+      "description": "Bed temperature for cool plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "cool_plate_temp_initial_layer",
+      "label": "Initial Layer Bed Temp (Cool Plate)",
+      "type": "number",
+      "category": "temperature",
+      "description": "First layer bed temperature for cool plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "eng_plate_temp",
+      "label": "Bed Temperature (Engineering Plate)",
+      "type": "number",
+      "category": "temperature",
+      "description": "Bed temperature for engineering plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "eng_plate_temp_initial_layer",
+      "label": "Initial Layer Bed Temp (Eng Plate)",
+      "type": "number",
+      "category": "temperature",
+      "description": "First layer bed temperature for engineering plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "textured_plate_temp",
+      "label": "Bed Temperature (Textured Plate)",
+      "type": "number",
+      "category": "temperature",
+      "description": "Bed temperature for textured plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "textured_plate_temp_initial_layer",
+      "label": "Initial Layer Bed Temp (Textured)",
+      "type": "number",
+      "category": "temperature",
+      "description": "First layer bed temperature for textured plate",
+      "unit": "°C",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "temperature_vitrification",
+      "label": "Glass Transition Temperature",
+      "type": "number",
+      "category": "temperature",
+      "description": "Glass transition temperature of material",
+      "unit": "°C"
+    },
+    {
+      "key": "pressure_advance",
+      "label": "Pressure Advance",
+      "type": "number",
+      "category": "flow",
+      "description": "Pressure advance value for linear advance",
+      "step": 0.001,
+      "min": 0,
+      "max": 0.2
+    },
+    {
+      "key": "enable_pressure_advance",
+      "label": "Enable Pressure Advance",
+      "type": "boolean",
+      "category": "flow",
+      "description": "Enable pressure advance compensation"
+    },
+    {
+      "key": "filament_flow_ratio",
+      "label": "Flow Ratio",
+      "type": "number",
+      "category": "flow",
+      "description": "Flow rate multiplier",
+      "step": 0.01,
+      "min": 0.8,
+      "max": 1.2
+    },
+    {
+      "key": "filament_max_volumetric_speed",
+      "label": "Max Volumetric Speed",
+      "type": "text",
+      "category": "flow",
+      "description": "Maximum volumetric flow rate (comma-separated values)",
+      "unit": "mm³/s"
+    },
+    {
+      "key": "fan_min_speed",
+      "label": "Min Fan Speed",
+      "type": "number",
+      "category": "cooling",
+      "description": "Minimum part cooling fan speed",
+      "unit": "%",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "fan_max_speed",
+      "label": "Max Fan Speed",
+      "type": "number",
+      "category": "cooling",
+      "description": "Maximum part cooling fan speed",
+      "unit": "%",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "fan_cooling_layer_time",
+      "label": "Fan Cooling Layer Time",
+      "type": "number",
+      "category": "cooling",
+      "description": "Layer time threshold for max fan",
+      "unit": "s",
+      "min": 0,
+      "max": 120
+    },
+    {
+      "key": "slow_down_layer_time",
+      "label": "Slow Down Layer Time",
+      "type": "number",
+      "category": "cooling",
+      "description": "Minimum layer time before slowing down",
+      "unit": "s",
+      "min": 0,
+      "max": 60
+    },
+    {
+      "key": "slow_down_min_speed",
+      "label": "Slow Down Min Speed",
+      "type": "number",
+      "category": "cooling",
+      "description": "Minimum speed when slowing down for cooling",
+      "unit": "mm/s",
+      "min": 5,
+      "max": 100
+    },
+    {
+      "key": "close_fan_the_first_x_layers",
+      "label": "Disable Fan First Layers",
+      "type": "number",
+      "category": "cooling",
+      "description": "Number of initial layers without fan",
+      "min": 0,
+      "max": 10
+    },
+    {
+      "key": "overhang_fan_threshold",
+      "label": "Overhang Fan Threshold",
+      "type": "text",
+      "category": "cooling",
+      "description": "Overhang angle to trigger fan boost"
+    },
+    {
+      "key": "overhang_fan_speed",
+      "label": "Overhang Fan Speed",
+      "type": "number",
+      "category": "cooling",
+      "description": "Fan speed for overhangs",
+      "unit": "%",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "reduce_fan_stop_start_freq",
+      "label": "Reduce Fan Stop/Start Frequency",
+      "type": "boolean",
+      "category": "cooling",
+      "description": "Prevent frequent fan speed changes"
+    },
+    {
+      "key": "activate_air_filtration",
+      "label": "Activate Air Filtration",
+      "type": "boolean",
+      "category": "chamber",
+      "description": "Enable chamber air filtration during print"
+    },
+    {
+      "key": "during_print_exhaust_fan_speed",
+      "label": "Exhaust Fan Speed (During Print)",
+      "type": "number",
+      "category": "chamber",
+      "description": "Exhaust fan speed during printing",
+      "unit": "%",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "complete_print_exhaust_fan_speed",
+      "label": "Exhaust Fan Speed (After Print)",
+      "type": "number",
+      "category": "chamber",
+      "description": "Exhaust fan speed after print completes",
+      "unit": "%",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "activate_chamber_temp_control",
+      "label": "Chamber Temp Control",
+      "type": "boolean",
+      "category": "chamber",
+      "description": "Enable active chamber temperature control"
+    },
+    {
+      "key": "chamber_temperature",
+      "label": "Chamber Temperature",
+      "type": "number",
+      "category": "chamber",
+      "description": "Target chamber temperature",
+      "unit": "°C",
+      "min": 0,
+      "max": 60
+    },
+    {
+      "key": "filament_retraction_length",
+      "label": "Retraction Length",
+      "type": "number",
+      "category": "retraction",
+      "description": "Length of filament to retract",
+      "unit": "mm",
+      "step": 0.1,
+      "min": 0,
+      "max": 10
+    },
+    {
+      "key": "filament_retract_before_wipe",
+      "label": "Retract Before Wipe",
+      "type": "number",
+      "category": "retraction",
+      "description": "Percentage of retraction before wipe",
+      "unit": "%",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "filament_retraction_speed",
+      "label": "Retraction Speed",
+      "type": "number",
+      "category": "retraction",
+      "description": "Speed for retraction moves",
+      "unit": "mm/s",
+      "min": 10,
+      "max": 120
+    },
+    {
+      "key": "filament_deretraction_speed",
+      "label": "Deretraction Speed",
+      "type": "number",
+      "category": "retraction",
+      "description": "Speed for priming after retraction",
+      "unit": "mm/s",
+      "min": 10,
+      "max": 120
+    },
+    {
+      "key": "filament_retract_restart_extra",
+      "label": "Extra Length on Restart",
+      "type": "number",
+      "category": "retraction",
+      "description": "Extra filament to prime after retraction",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "filament_retract_when_changing_layer",
+      "label": "Retract on Layer Change",
+      "type": "boolean",
+      "category": "retraction",
+      "description": "Force retraction when changing layers"
+    },
+    {
+      "key": "filament_wipe",
+      "label": "Enable Wipe",
+      "type": "boolean",
+      "category": "retraction",
+      "description": "Enable nozzle wipe during retraction"
+    },
+    {
+      "key": "filament_wipe_distance",
+      "label": "Wipe Distance",
+      "type": "number",
+      "category": "retraction",
+      "description": "Distance to wipe while retracting",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "filament_z_hop",
+      "label": "Z Hop Height",
+      "type": "number",
+      "category": "retraction",
+      "description": "Lift height during travel moves",
+      "unit": "mm",
+      "step": 0.1,
+      "min": 0,
+      "max": 2
+    },
+    {
+      "key": "filament_z_hop_types",
+      "label": "Z Hop Type",
+      "type": "select",
+      "category": "retraction",
+      "description": "Type of Z hop motion",
+      "options": [
+        {"value": "Normal Lift", "label": "Normal Lift"},
+        {"value": "Slope Lift", "label": "Slope Lift"},
+        {"value": "Spiral Lift", "label": "Spiral Lift"}
+      ]
+    },
+    {
+      "key": "filament_retraction_minimum_travel",
+      "label": "Min Travel After Retraction",
+      "type": "number",
+      "category": "retraction",
+      "description": "Minimum travel distance to trigger retraction",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "filament_retract_layer_change",
+      "label": "Retract on Layer Change",
+      "type": "boolean",
+      "category": "retraction",
+      "description": "Retract when layer changes"
+    },
+    {
+      "key": "filament_colour",
+      "label": "Filament Color",
+      "type": "text",
+      "category": "appearance",
+      "description": "Filament color (hex format #RRGGBB)"
+    },
+    {
+      "key": "default_filament_colour",
+      "label": "Default Filament Color",
+      "type": "text",
+      "category": "appearance",
+      "description": "Default color for this filament type"
+    },
+    {
+      "key": "filament_is_support",
+      "label": "Is Support Material",
+      "type": "boolean",
+      "category": "advanced",
+      "description": "Mark as support material filament"
+    },
+    {
+      "key": "filament_soluble",
+      "label": "Soluble Material",
+      "type": "boolean",
+      "category": "advanced",
+      "description": "Material dissolves in liquid"
+    },
+    {
+      "key": "required_nozzle_HRC",
+      "label": "Required Nozzle HRC",
+      "type": "number",
+      "category": "advanced",
+      "description": "Minimum nozzle hardness required",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "filament_start_gcode",
+      "label": "Filament Start G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code executed when switching to this filament"
+    },
+    {
+      "key": "filament_end_gcode",
+      "label": "Filament End G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code executed when switching from this filament"
+    },
+    {
+      "key": "filament_loading_speed",
+      "label": "Loading Speed",
+      "type": "number",
+      "category": "ams",
+      "description": "Speed for loading filament",
+      "unit": "mm/s"
+    },
+    {
+      "key": "filament_loading_speed_start",
+      "label": "Loading Start Speed",
+      "type": "number",
+      "category": "ams",
+      "description": "Initial speed when loading filament",
+      "unit": "mm/s"
+    },
+    {
+      "key": "filament_unloading_speed",
+      "label": "Unloading Speed",
+      "type": "number",
+      "category": "ams",
+      "description": "Speed for unloading filament",
+      "unit": "mm/s"
+    },
+    {
+      "key": "filament_unloading_speed_start",
+      "label": "Unloading Start Speed",
+      "type": "number",
+      "category": "ams",
+      "description": "Initial speed when unloading filament",
+      "unit": "mm/s"
+    },
+    {
+      "key": "filament_toolchange_delay",
+      "label": "Toolchange Delay",
+      "type": "number",
+      "category": "ams",
+      "description": "Delay after tool change",
+      "unit": "s"
+    },
+    {
+      "key": "filament_cooling_moves",
+      "label": "Cooling Moves",
+      "type": "number",
+      "category": "ams",
+      "description": "Number of cooling moves during unload"
+    },
+    {
+      "key": "filament_cooling_initial_speed",
+      "label": "Cooling Initial Speed",
+      "type": "number",
+      "category": "ams",
+      "description": "Initial speed for tip shaping cooling moves",
+      "unit": "mm/s"
+    },
+    {
+      "key": "filament_cooling_final_speed",
+      "label": "Cooling Final Speed",
+      "type": "number",
+      "category": "ams",
+      "description": "Final speed for tip shaping cooling moves",
+      "unit": "mm/s"
+    },
+    {
+      "key": "filament_ramming_parameters",
+      "label": "Ramming Parameters",
+      "type": "text",
+      "category": "ams",
+      "description": "Ramming sequence parameters"
+    },
+    {
+      "key": "filament_minimal_purge_on_wipe_tower",
+      "label": "Min Purge on Wipe Tower",
+      "type": "number",
+      "category": "ams",
+      "description": "Minimum purge volume on wipe tower",
+      "unit": "mm³"
+    },
+    {
+      "key": "filament_multitool_ramming",
+      "label": "Multitool Ramming",
+      "type": "boolean",
+      "category": "ams",
+      "description": "Enable ramming for multi-tool"
+    },
+    {
+      "key": "filament_multitool_ramming_volume",
+      "label": "Multitool Ramming Volume",
+      "type": "number",
+      "category": "ams",
+      "description": "Volume to ram during multi-tool change",
+      "unit": "mm³"
+    },
+    {
+      "key": "filament_multitool_ramming_flow",
+      "label": "Multitool Ramming Flow",
+      "type": "number",
+      "category": "ams",
+      "description": "Flow rate during multi-tool ramming",
+      "unit": "mm³/s"
+    },
+    {
+      "key": "filament_long_retractions_when_cut",
+      "label": "Long Retractions When Cut",
+      "type": "boolean",
+      "category": "ams",
+      "description": "Use long retraction when cutting filament"
+    },
+    {
+      "key": "filament_retraction_distances_when_cut",
+      "label": "Retraction Distance When Cut",
+      "type": "text",
+      "category": "ams",
+      "description": "Retraction distance during filament cut"
+    }
+  ]
+}

+ 574 - 0
backend/app/data/printer_fields.json

@@ -0,0 +1,574 @@
+{
+  "version": "1.0.0",
+  "description": "Printer/machine preset field definitions for Bambu Lab printers",
+  "fields": [
+    {
+      "key": "printer_model",
+      "label": "Printer Model",
+      "type": "text",
+      "category": "basic",
+      "description": "Printer model identifier"
+    },
+    {
+      "key": "printer_variant",
+      "label": "Printer Variant",
+      "type": "text",
+      "category": "basic",
+      "description": "Nozzle variant (e.g., 0.4 nozzle)"
+    },
+    {
+      "key": "printer_notes",
+      "label": "Printer Notes",
+      "type": "text",
+      "category": "basic",
+      "description": "Notes about this printer profile"
+    },
+    {
+      "key": "printable_area",
+      "label": "Printable Area",
+      "type": "text",
+      "category": "dimensions",
+      "description": "Bed shape coordinates"
+    },
+    {
+      "key": "printable_height",
+      "label": "Printable Height",
+      "type": "number",
+      "category": "dimensions",
+      "description": "Maximum print height",
+      "unit": "mm"
+    },
+    {
+      "key": "bed_exclude_area",
+      "label": "Bed Exclude Area",
+      "type": "text",
+      "category": "dimensions",
+      "description": "Coordinates of excluded bed areas"
+    },
+    {
+      "key": "nozzle_diameter",
+      "label": "Nozzle Diameter",
+      "type": "number",
+      "category": "extruder",
+      "description": "Diameter of the nozzle",
+      "unit": "mm",
+      "step": 0.1,
+      "min": 0.1,
+      "max": 1.2
+    },
+    {
+      "key": "nozzle_type",
+      "label": "Nozzle Type",
+      "type": "select",
+      "category": "extruder",
+      "description": "Type of nozzle installed",
+      "options": [
+        {"value": "brass", "label": "Brass"},
+        {"value": "stainless_steel", "label": "Stainless Steel"},
+        {"value": "hardened_steel", "label": "Hardened Steel"}
+      ]
+    },
+    {
+      "key": "nozzle_hrc",
+      "label": "Nozzle HRC",
+      "type": "number",
+      "category": "extruder",
+      "description": "Hardness rating of nozzle"
+    },
+    {
+      "key": "nozzle_volume",
+      "label": "Nozzle Volume",
+      "type": "number",
+      "category": "extruder",
+      "description": "Melt zone volume",
+      "unit": "mm³"
+    },
+    {
+      "key": "extruder_type",
+      "label": "Extruder Type",
+      "type": "select",
+      "category": "extruder",
+      "description": "Type of extruder",
+      "options": [
+        {"value": "DirectDrive", "label": "Direct Drive"},
+        {"value": "Bowden", "label": "Bowden"}
+      ]
+    },
+    {
+      "key": "retraction_length",
+      "label": "Retraction Length",
+      "type": "number",
+      "category": "retraction",
+      "description": "Default retraction length",
+      "unit": "mm",
+      "step": 0.1,
+      "min": 0,
+      "max": 10
+    },
+    {
+      "key": "retraction_speed",
+      "label": "Retraction Speed",
+      "type": "number",
+      "category": "retraction",
+      "description": "Speed for retraction",
+      "unit": "mm/s"
+    },
+    {
+      "key": "deretraction_speed",
+      "label": "Deretraction Speed",
+      "type": "number",
+      "category": "retraction",
+      "description": "Speed for deretraction",
+      "unit": "mm/s"
+    },
+    {
+      "key": "retract_before_wipe",
+      "label": "Retract Before Wipe",
+      "type": "number",
+      "category": "retraction",
+      "description": "Percentage to retract before wipe",
+      "unit": "%"
+    },
+    {
+      "key": "retract_when_changing_layer",
+      "label": "Retract on Layer Change",
+      "type": "boolean",
+      "category": "retraction",
+      "description": "Retract when changing layers"
+    },
+    {
+      "key": "wipe",
+      "label": "Enable Wipe",
+      "type": "boolean",
+      "category": "retraction",
+      "description": "Enable wipe during retraction"
+    },
+    {
+      "key": "wipe_distance",
+      "label": "Wipe Distance",
+      "type": "number",
+      "category": "retraction",
+      "description": "Distance to wipe",
+      "unit": "mm"
+    },
+    {
+      "key": "z_hop",
+      "label": "Z Hop Height",
+      "type": "number",
+      "category": "retraction",
+      "description": "Height to lift during travel",
+      "unit": "mm",
+      "step": 0.1,
+      "min": 0,
+      "max": 2
+    },
+    {
+      "key": "z_hop_types",
+      "label": "Z Hop Type",
+      "type": "select",
+      "category": "retraction",
+      "description": "Type of Z hop motion",
+      "options": [
+        {"value": "Normal Lift", "label": "Normal Lift"},
+        {"value": "Slope Lift", "label": "Slope Lift"},
+        {"value": "Spiral Lift", "label": "Spiral Lift"}
+      ]
+    },
+    {
+      "key": "retraction_minimum_travel",
+      "label": "Min Travel for Retraction",
+      "type": "number",
+      "category": "retraction",
+      "description": "Minimum travel to trigger retraction",
+      "unit": "mm"
+    },
+    {
+      "key": "retract_lift_above",
+      "label": "Retract Lift Above",
+      "type": "number",
+      "category": "retraction",
+      "description": "Only lift Z above this height",
+      "unit": "mm"
+    },
+    {
+      "key": "retract_lift_below",
+      "label": "Retract Lift Below",
+      "type": "number",
+      "category": "retraction",
+      "description": "Only lift Z below this height",
+      "unit": "mm"
+    },
+    {
+      "key": "machine_max_speed_x",
+      "label": "Max Speed X",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum X axis speed",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_max_speed_y",
+      "label": "Max Speed Y",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum Y axis speed",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_max_speed_z",
+      "label": "Max Speed Z",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum Z axis speed",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_max_speed_e",
+      "label": "Max Speed E",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum extruder speed",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_max_acceleration_x",
+      "label": "Max Acceleration X",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum X axis acceleration",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "machine_max_acceleration_y",
+      "label": "Max Acceleration Y",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum Y axis acceleration",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "machine_max_acceleration_z",
+      "label": "Max Acceleration Z",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum Z axis acceleration",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "machine_max_acceleration_e",
+      "label": "Max Acceleration E",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum extruder acceleration",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "machine_max_acceleration_extruding",
+      "label": "Max Print Acceleration",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum acceleration while printing",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "machine_max_acceleration_retracting",
+      "label": "Max Retract Acceleration",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum acceleration during retraction",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "machine_max_acceleration_travel",
+      "label": "Max Travel Acceleration",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum acceleration during travel",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "machine_max_jerk_x",
+      "label": "Max Jerk X",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum X axis jerk",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_max_jerk_y",
+      "label": "Max Jerk Y",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum Y axis jerk",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_max_jerk_z",
+      "label": "Max Jerk Z",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum Z axis jerk",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_max_jerk_e",
+      "label": "Max Jerk E",
+      "type": "number",
+      "category": "limits",
+      "description": "Maximum extruder jerk",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_min_extruding_rate",
+      "label": "Min Extruding Rate",
+      "type": "number",
+      "category": "limits",
+      "description": "Minimum extrusion flow rate",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_min_travel_rate",
+      "label": "Min Travel Rate",
+      "type": "number",
+      "category": "limits",
+      "description": "Minimum travel speed",
+      "unit": "mm/s"
+    },
+    {
+      "key": "machine_start_gcode",
+      "label": "Start G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code executed at print start"
+    },
+    {
+      "key": "machine_end_gcode",
+      "label": "End G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code executed at print end"
+    },
+    {
+      "key": "before_layer_change_gcode",
+      "label": "Before Layer Change G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code before each layer change"
+    },
+    {
+      "key": "layer_change_gcode",
+      "label": "Layer Change G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code after each layer change"
+    },
+    {
+      "key": "change_filament_gcode",
+      "label": "Filament Change G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code for filament changes"
+    },
+    {
+      "key": "machine_pause_gcode",
+      "label": "Pause G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "G-code for pause command"
+    },
+    {
+      "key": "template_custom_gcode",
+      "label": "Template Custom G-code",
+      "type": "text",
+      "category": "gcode",
+      "description": "Custom G-code template"
+    },
+    {
+      "key": "gcode_flavor",
+      "label": "G-code Flavor",
+      "type": "select",
+      "category": "gcode",
+      "description": "G-code dialect",
+      "options": [
+        {"value": "marlin", "label": "Marlin"},
+        {"value": "marlin2", "label": "Marlin 2"},
+        {"value": "reprap", "label": "RepRap"},
+        {"value": "klipper", "label": "Klipper"},
+        {"value": "smoothie", "label": "Smoothieware"}
+      ]
+    },
+    {
+      "key": "thumbnails",
+      "label": "Thumbnail Sizes",
+      "type": "text",
+      "category": "output",
+      "description": "Sizes for embedded thumbnails"
+    },
+    {
+      "key": "thumbnails_format",
+      "label": "Thumbnail Format",
+      "type": "select",
+      "category": "output",
+      "description": "Format for thumbnails",
+      "options": [
+        {"value": "PNG", "label": "PNG"},
+        {"value": "JPG", "label": "JPG"},
+        {"value": "QOI", "label": "QOI"}
+      ]
+    },
+    {
+      "key": "use_relative_e_distances",
+      "label": "Use Relative E Distances",
+      "type": "boolean",
+      "category": "output",
+      "description": "Use relative extrusion"
+    },
+    {
+      "key": "use_firmware_retraction",
+      "label": "Use Firmware Retraction",
+      "type": "boolean",
+      "category": "output",
+      "description": "Use G10/G11 for retraction"
+    },
+    {
+      "key": "silent_mode",
+      "label": "Silent Mode",
+      "type": "boolean",
+      "category": "output",
+      "description": "Enable silent mode profile"
+    },
+    {
+      "key": "fan_kickstart",
+      "label": "Fan Kickstart",
+      "type": "number",
+      "category": "cooling",
+      "description": "Time to kickstart fan",
+      "unit": "s"
+    },
+    {
+      "key": "auxiliary_fan",
+      "label": "Auxiliary Fan",
+      "type": "boolean",
+      "category": "cooling",
+      "description": "Printer has auxiliary fan"
+    },
+    {
+      "key": "support_air_filtration",
+      "label": "Support Air Filtration",
+      "type": "boolean",
+      "category": "features",
+      "description": "Printer has air filtration"
+    },
+    {
+      "key": "support_chamber_temp_control",
+      "label": "Support Chamber Temp Control",
+      "type": "boolean",
+      "category": "features",
+      "description": "Printer has chamber heating"
+    },
+    {
+      "key": "support_multi_bed_types",
+      "label": "Support Multiple Bed Types",
+      "type": "boolean",
+      "category": "features",
+      "description": "Printer supports multiple bed plates"
+    },
+    {
+      "key": "upward_compatible_machine",
+      "label": "Upward Compatible Machines",
+      "type": "text",
+      "category": "compatibility",
+      "description": "List of compatible machine models"
+    },
+    {
+      "key": "single_extruder_multi_material",
+      "label": "Single Extruder Multi-Material",
+      "type": "boolean",
+      "category": "multimaterial",
+      "description": "Single extruder with MMU/AMS"
+    },
+    {
+      "key": "cooling_tube_length",
+      "label": "Cooling Tube Length",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Length of cooling tube",
+      "unit": "mm"
+    },
+    {
+      "key": "cooling_tube_retraction",
+      "label": "Cooling Tube Retraction",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Retraction into cooling tube",
+      "unit": "mm"
+    },
+    {
+      "key": "extra_loading_move",
+      "label": "Extra Loading Move",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Extra move after loading",
+      "unit": "mm"
+    },
+    {
+      "key": "high_current_on_filament_swap",
+      "label": "High Current on Swap",
+      "type": "boolean",
+      "category": "multimaterial",
+      "description": "Use high current during swap"
+    },
+    {
+      "key": "parking_pos_retraction",
+      "label": "Parking Position Retraction",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Retraction for parking",
+      "unit": "mm"
+    },
+    {
+      "key": "bed_custom_model",
+      "label": "Custom Bed Model",
+      "type": "text",
+      "category": "dimensions",
+      "description": "Path to custom bed STL model"
+    },
+    {
+      "key": "bed_custom_texture",
+      "label": "Custom Bed Texture",
+      "type": "text",
+      "category": "dimensions",
+      "description": "Path to custom bed texture"
+    },
+    {
+      "key": "scan_first_layer",
+      "label": "Scan First Layer",
+      "type": "boolean",
+      "category": "features",
+      "description": "Enable first layer scanning"
+    },
+    {
+      "key": "time_cost",
+      "label": "Time Cost Factor",
+      "type": "number",
+      "category": "advanced",
+      "description": "Cost per hour of operation"
+    },
+    {
+      "key": "machine_load_filament_time",
+      "label": "Filament Load Time",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Time to load filament",
+      "unit": "s"
+    },
+    {
+      "key": "machine_unload_filament_time",
+      "label": "Filament Unload Time",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Time to unload filament",
+      "unit": "s"
+    }
+  ]
+}

+ 923 - 0
backend/app/data/process_fields.json

@@ -0,0 +1,923 @@
+{
+  "version": "1.0.0",
+  "description": "Print process preset field definitions for Bambu Lab printers",
+  "fields": [
+    {
+      "key": "layer_height",
+      "label": "Layer Height",
+      "type": "number",
+      "category": "quality",
+      "description": "Height of each printed layer",
+      "unit": "mm",
+      "step": 0.01,
+      "min": 0.04,
+      "max": 0.6
+    },
+    {
+      "key": "initial_layer_height",
+      "label": "Initial Layer Height",
+      "type": "number",
+      "category": "quality",
+      "description": "Height of the first layer",
+      "unit": "mm",
+      "step": 0.01,
+      "min": 0.1,
+      "max": 0.5
+    },
+    {
+      "key": "line_width",
+      "label": "Line Width",
+      "type": "number",
+      "category": "quality",
+      "description": "Default extrusion width",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "initial_layer_line_width",
+      "label": "Initial Layer Line Width",
+      "type": "number",
+      "category": "quality",
+      "description": "First layer extrusion width",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "outer_wall_line_width",
+      "label": "Outer Wall Line Width",
+      "type": "number",
+      "category": "quality",
+      "description": "Outer perimeter extrusion width",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "inner_wall_line_width",
+      "label": "Inner Wall Line Width",
+      "type": "number",
+      "category": "quality",
+      "description": "Inner perimeter extrusion width",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "sparse_infill_line_width",
+      "label": "Infill Line Width",
+      "type": "number",
+      "category": "quality",
+      "description": "Sparse infill extrusion width",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "top_surface_line_width",
+      "label": "Top Surface Line Width",
+      "type": "number",
+      "category": "quality",
+      "description": "Top surface extrusion width",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "support_line_width",
+      "label": "Support Line Width",
+      "type": "number",
+      "category": "quality",
+      "description": "Support structure extrusion width",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "wall_loops",
+      "label": "Wall Loops",
+      "type": "number",
+      "category": "strength",
+      "description": "Number of perimeter walls",
+      "min": 1,
+      "max": 10
+    },
+    {
+      "key": "top_shell_layers",
+      "label": "Top Layers",
+      "type": "number",
+      "category": "strength",
+      "description": "Number of solid top layers",
+      "min": 1,
+      "max": 20
+    },
+    {
+      "key": "bottom_shell_layers",
+      "label": "Bottom Layers",
+      "type": "number",
+      "category": "strength",
+      "description": "Number of solid bottom layers",
+      "min": 1,
+      "max": 20
+    },
+    {
+      "key": "top_shell_thickness",
+      "label": "Top Shell Thickness",
+      "type": "number",
+      "category": "strength",
+      "description": "Minimum top shell thickness",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "bottom_shell_thickness",
+      "label": "Bottom Shell Thickness",
+      "type": "number",
+      "category": "strength",
+      "description": "Minimum bottom shell thickness",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "sparse_infill_density",
+      "label": "Infill Density",
+      "type": "number",
+      "category": "infill",
+      "description": "Percentage of infill",
+      "unit": "%",
+      "min": 0,
+      "max": 100
+    },
+    {
+      "key": "sparse_infill_pattern",
+      "label": "Infill Pattern",
+      "type": "select",
+      "category": "infill",
+      "description": "Pattern for sparse infill",
+      "options": [
+        {"value": "grid", "label": "Grid"},
+        {"value": "triangles", "label": "Triangles"},
+        {"value": "tri-hexagon", "label": "Tri-Hexagon"},
+        {"value": "cubic", "label": "Cubic"},
+        {"value": "gyroid", "label": "Gyroid"},
+        {"value": "honeycomb", "label": "Honeycomb"},
+        {"value": "3dhoneycomb", "label": "3D Honeycomb"},
+        {"value": "line", "label": "Line"},
+        {"value": "rectilinear", "label": "Rectilinear"},
+        {"value": "concentric", "label": "Concentric"},
+        {"value": "zig-zag", "label": "Zig-Zag"},
+        {"value": "crosshatch", "label": "Cross Hatch"},
+        {"value": "lightning", "label": "Lightning"},
+        {"value": "supportcubic", "label": "Support Cubic"},
+        {"value": "adaptivecubic", "label": "Adaptive Cubic"}
+      ]
+    },
+    {
+      "key": "top_surface_pattern",
+      "label": "Top Surface Pattern",
+      "type": "select",
+      "category": "infill",
+      "description": "Pattern for top solid layers",
+      "options": [
+        {"value": "monotonic", "label": "Monotonic"},
+        {"value": "monotoniclines", "label": "Monotonic Lines"},
+        {"value": "alignedrectilinear", "label": "Aligned Rectilinear"},
+        {"value": "rectilinear", "label": "Rectilinear"},
+        {"value": "concentric", "label": "Concentric"},
+        {"value": "hilbertcurve", "label": "Hilbert Curve"},
+        {"value": "archimedeanchords", "label": "Archimedean Chords"},
+        {"value": "octagramspiral", "label": "Octagram Spiral"}
+      ]
+    },
+    {
+      "key": "bottom_surface_pattern",
+      "label": "Bottom Surface Pattern",
+      "type": "select",
+      "category": "infill",
+      "description": "Pattern for bottom solid layers",
+      "options": [
+        {"value": "monotonic", "label": "Monotonic"},
+        {"value": "monotoniclines", "label": "Monotonic Lines"},
+        {"value": "rectilinear", "label": "Rectilinear"},
+        {"value": "concentric", "label": "Concentric"}
+      ]
+    },
+    {
+      "key": "infill_direction",
+      "label": "Infill Direction",
+      "type": "number",
+      "category": "infill",
+      "description": "Angle of infill pattern",
+      "unit": "°",
+      "min": 0,
+      "max": 360
+    },
+    {
+      "key": "infill_anchor",
+      "label": "Infill Anchor Length",
+      "type": "number",
+      "category": "infill",
+      "description": "Connect infill to walls",
+      "unit": "mm"
+    },
+    {
+      "key": "infill_anchor_max",
+      "label": "Max Infill Anchor Length",
+      "type": "number",
+      "category": "infill",
+      "description": "Maximum anchor length",
+      "unit": "mm"
+    },
+    {
+      "key": "infill_combination",
+      "label": "Combine Infill Layers",
+      "type": "boolean",
+      "category": "infill",
+      "description": "Print infill at higher layer heights"
+    },
+    {
+      "key": "outer_wall_speed",
+      "label": "Outer Wall Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for outer perimeters",
+      "unit": "mm/s",
+      "min": 10,
+      "max": 500
+    },
+    {
+      "key": "inner_wall_speed",
+      "label": "Inner Wall Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for inner perimeters",
+      "unit": "mm/s",
+      "min": 10,
+      "max": 500
+    },
+    {
+      "key": "sparse_infill_speed",
+      "label": "Infill Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for sparse infill",
+      "unit": "mm/s",
+      "min": 10,
+      "max": 500
+    },
+    {
+      "key": "internal_solid_infill_speed",
+      "label": "Internal Solid Infill Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for internal solid layers",
+      "unit": "mm/s"
+    },
+    {
+      "key": "top_surface_speed",
+      "label": "Top Surface Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for top surface",
+      "unit": "mm/s"
+    },
+    {
+      "key": "bridge_speed",
+      "label": "Bridge Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for bridging",
+      "unit": "mm/s"
+    },
+    {
+      "key": "gap_infill_speed",
+      "label": "Gap Fill Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for gap filling",
+      "unit": "mm/s"
+    },
+    {
+      "key": "travel_speed",
+      "label": "Travel Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for non-printing moves",
+      "unit": "mm/s",
+      "min": 50,
+      "max": 1000
+    },
+    {
+      "key": "initial_layer_speed",
+      "label": "Initial Layer Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for first layer",
+      "unit": "mm/s"
+    },
+    {
+      "key": "initial_layer_infill_speed",
+      "label": "Initial Layer Infill Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for first layer infill",
+      "unit": "mm/s"
+    },
+    {
+      "key": "support_speed",
+      "label": "Support Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for support structures",
+      "unit": "mm/s"
+    },
+    {
+      "key": "support_interface_speed",
+      "label": "Support Interface Speed",
+      "type": "number",
+      "category": "speed",
+      "description": "Speed for support interface",
+      "unit": "mm/s"
+    },
+    {
+      "key": "outer_wall_acceleration",
+      "label": "Outer Wall Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Acceleration for outer perimeters",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "inner_wall_acceleration",
+      "label": "Inner Wall Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Acceleration for inner perimeters",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "default_acceleration",
+      "label": "Default Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Default print acceleration",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "travel_acceleration",
+      "label": "Travel Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Acceleration for travel moves",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "initial_layer_acceleration",
+      "label": "Initial Layer Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Acceleration for first layer",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "top_surface_acceleration",
+      "label": "Top Surface Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Acceleration for top surface",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "bridge_acceleration",
+      "label": "Bridge Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Acceleration for bridging",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "sparse_infill_acceleration",
+      "label": "Infill Acceleration",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Acceleration for infill",
+      "unit": "mm/s²"
+    },
+    {
+      "key": "outer_wall_jerk",
+      "label": "Outer Wall Jerk",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Jerk for outer perimeters",
+      "unit": "mm/s"
+    },
+    {
+      "key": "inner_wall_jerk",
+      "label": "Inner Wall Jerk",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Jerk for inner perimeters",
+      "unit": "mm/s"
+    },
+    {
+      "key": "default_jerk",
+      "label": "Default Jerk",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Default jerk value",
+      "unit": "mm/s"
+    },
+    {
+      "key": "travel_jerk",
+      "label": "Travel Jerk",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Jerk for travel moves",
+      "unit": "mm/s"
+    },
+    {
+      "key": "initial_layer_jerk",
+      "label": "Initial Layer Jerk",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Jerk for first layer",
+      "unit": "mm/s"
+    },
+    {
+      "key": "top_surface_jerk",
+      "label": "Top Surface Jerk",
+      "type": "number",
+      "category": "acceleration",
+      "description": "Jerk for top surface",
+      "unit": "mm/s"
+    },
+    {
+      "key": "enable_support",
+      "label": "Enable Support",
+      "type": "boolean",
+      "category": "support",
+      "description": "Generate support structures"
+    },
+    {
+      "key": "support_type",
+      "label": "Support Type",
+      "type": "select",
+      "category": "support",
+      "description": "Type of support generation",
+      "options": [
+        {"value": "normal(auto)", "label": "Normal (Auto)"},
+        {"value": "tree(auto)", "label": "Tree (Auto)"},
+        {"value": "hybrid(auto)", "label": "Hybrid (Auto)"}
+      ]
+    },
+    {
+      "key": "support_style",
+      "label": "Support Style",
+      "type": "select",
+      "category": "support",
+      "description": "Style of support structure",
+      "options": [
+        {"value": "default", "label": "Default"},
+        {"value": "grid", "label": "Grid"},
+        {"value": "snug", "label": "Snug"},
+        {"value": "organic", "label": "Organic"}
+      ]
+    },
+    {
+      "key": "support_threshold_angle",
+      "label": "Support Overhang Angle",
+      "type": "number",
+      "category": "support",
+      "description": "Minimum angle for support generation",
+      "unit": "°",
+      "min": 0,
+      "max": 90
+    },
+    {
+      "key": "support_base_pattern",
+      "label": "Support Pattern",
+      "type": "select",
+      "category": "support",
+      "description": "Pattern for support base",
+      "options": [
+        {"value": "rectilinear", "label": "Rectilinear"},
+        {"value": "rectilinear-grid", "label": "Rectilinear Grid"},
+        {"value": "honeycomb", "label": "Honeycomb"},
+        {"value": "lightning", "label": "Lightning"},
+        {"value": "default", "label": "Default"},
+        {"value": "hollow", "label": "Hollow"}
+      ]
+    },
+    {
+      "key": "support_base_pattern_spacing",
+      "label": "Support Pattern Spacing",
+      "type": "number",
+      "category": "support",
+      "description": "Spacing between support lines",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "support_interface_top_layers",
+      "label": "Support Interface Top Layers",
+      "type": "number",
+      "category": "support",
+      "description": "Number of interface layers on top"
+    },
+    {
+      "key": "support_interface_bottom_layers",
+      "label": "Support Interface Bottom Layers",
+      "type": "number",
+      "category": "support",
+      "description": "Number of interface layers on bottom"
+    },
+    {
+      "key": "support_interface_spacing",
+      "label": "Support Interface Spacing",
+      "type": "number",
+      "category": "support",
+      "description": "Spacing of interface pattern",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "support_object_xy_distance",
+      "label": "Support XY Distance",
+      "type": "number",
+      "category": "support",
+      "description": "Gap between support and object",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "support_top_z_distance",
+      "label": "Support Top Z Distance",
+      "type": "number",
+      "category": "support",
+      "description": "Gap between support top and object",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "support_bottom_z_distance",
+      "label": "Support Bottom Z Distance",
+      "type": "number",
+      "category": "support",
+      "description": "Gap between object and support top",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "support_on_build_plate_only",
+      "label": "Support On Build Plate Only",
+      "type": "boolean",
+      "category": "support",
+      "description": "Only generate support from build plate"
+    },
+    {
+      "key": "support_critical_regions_only",
+      "label": "Support Critical Regions Only",
+      "type": "boolean",
+      "category": "support",
+      "description": "Only support critical overhangs"
+    },
+    {
+      "key": "brim_type",
+      "label": "Brim Type",
+      "type": "select",
+      "category": "adhesion",
+      "description": "Type of brim to generate",
+      "options": [
+        {"value": "no_brim", "label": "No Brim"},
+        {"value": "auto_brim", "label": "Auto"},
+        {"value": "outer_only", "label": "Outer Only"},
+        {"value": "inner_only", "label": "Inner Only"},
+        {"value": "outer_and_inner", "label": "Outer and Inner"},
+        {"value": "brim_ears", "label": "Brim Ears"}
+      ]
+    },
+    {
+      "key": "brim_width",
+      "label": "Brim Width",
+      "type": "number",
+      "category": "adhesion",
+      "description": "Width of the brim",
+      "unit": "mm",
+      "min": 0,
+      "max": 20
+    },
+    {
+      "key": "brim_object_gap",
+      "label": "Brim-Object Gap",
+      "type": "number",
+      "category": "adhesion",
+      "description": "Gap between brim and object",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "skirt_loops",
+      "label": "Skirt Loops",
+      "type": "number",
+      "category": "adhesion",
+      "description": "Number of skirt loops",
+      "min": 0,
+      "max": 10
+    },
+    {
+      "key": "skirt_distance",
+      "label": "Skirt Distance",
+      "type": "number",
+      "category": "adhesion",
+      "description": "Distance from object to skirt",
+      "unit": "mm"
+    },
+    {
+      "key": "skirt_height",
+      "label": "Skirt Height",
+      "type": "number",
+      "category": "adhesion",
+      "description": "Height of skirt in layers"
+    },
+    {
+      "key": "raft_layers",
+      "label": "Raft Layers",
+      "type": "number",
+      "category": "adhesion",
+      "description": "Number of raft layers"
+    },
+    {
+      "key": "prime_tower_enable",
+      "label": "Enable Prime Tower",
+      "type": "boolean",
+      "category": "multimaterial",
+      "description": "Generate prime/wipe tower"
+    },
+    {
+      "key": "prime_tower_width",
+      "label": "Prime Tower Width",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Width of prime tower",
+      "unit": "mm"
+    },
+    {
+      "key": "prime_tower_brim_width",
+      "label": "Prime Tower Brim Width",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Brim width for prime tower",
+      "unit": "mm"
+    },
+    {
+      "key": "wipe_tower_no_sparse_layers",
+      "label": "No Sparse Layers in Tower",
+      "type": "boolean",
+      "category": "multimaterial",
+      "description": "Disable sparse layers in wipe tower"
+    },
+    {
+      "key": "flush_into_infill",
+      "label": "Flush Into Infill",
+      "type": "boolean",
+      "category": "multimaterial",
+      "description": "Purge into infill instead of tower"
+    },
+    {
+      "key": "flush_into_support",
+      "label": "Flush Into Support",
+      "type": "boolean",
+      "category": "multimaterial",
+      "description": "Purge into support instead of tower"
+    },
+    {
+      "key": "flush_into_objects",
+      "label": "Flush Into Objects",
+      "type": "boolean",
+      "category": "multimaterial",
+      "description": "Purge into object infill"
+    },
+    {
+      "key": "prime_volume",
+      "label": "Prime Volume",
+      "type": "number",
+      "category": "multimaterial",
+      "description": "Volume to prime after tool change",
+      "unit": "mm³"
+    },
+    {
+      "key": "seam_position",
+      "label": "Seam Position",
+      "type": "select",
+      "category": "quality",
+      "description": "Position of layer start/seam",
+      "options": [
+        {"value": "nearest", "label": "Nearest"},
+        {"value": "aligned", "label": "Aligned"},
+        {"value": "back", "label": "Back"},
+        {"value": "random", "label": "Random"}
+      ]
+    },
+    {
+      "key": "staggered_inner_seams",
+      "label": "Staggered Inner Seams",
+      "type": "boolean",
+      "category": "quality",
+      "description": "Stagger seams on inner walls"
+    },
+    {
+      "key": "wall_sequence",
+      "label": "Wall Sequence",
+      "type": "select",
+      "category": "quality",
+      "description": "Order of printing walls",
+      "options": [
+        {"value": "inner wall/outer wall", "label": "Inner/Outer"},
+        {"value": "outer wall/inner wall", "label": "Outer/Inner"},
+        {"value": "inner-outer-inner wall", "label": "Inner-Outer-Inner"}
+      ]
+    },
+    {
+      "key": "only_one_wall_top",
+      "label": "Only One Wall on Top",
+      "type": "boolean",
+      "category": "quality",
+      "description": "Use single wall on top surface"
+    },
+    {
+      "key": "detect_thin_wall",
+      "label": "Detect Thin Walls",
+      "type": "boolean",
+      "category": "quality",
+      "description": "Enable thin wall detection"
+    },
+    {
+      "key": "enable_overhang_speed",
+      "label": "Enable Overhang Speed",
+      "type": "boolean",
+      "category": "quality",
+      "description": "Slow down for overhangs"
+    },
+    {
+      "key": "overhang_speed_classic",
+      "label": "Classic Overhang Speed",
+      "type": "boolean",
+      "category": "quality",
+      "description": "Use classic overhang speed calculation"
+    },
+    {
+      "key": "bridge_flow",
+      "label": "Bridge Flow Ratio",
+      "type": "number",
+      "category": "quality",
+      "description": "Flow ratio for bridges",
+      "step": 0.01
+    },
+    {
+      "key": "thick_bridges",
+      "label": "Thick Bridges",
+      "type": "boolean",
+      "category": "quality",
+      "description": "Use thicker bridges"
+    },
+    {
+      "key": "ironing_type",
+      "label": "Ironing Type",
+      "type": "select",
+      "category": "quality",
+      "description": "Type of ironing",
+      "options": [
+        {"value": "no ironing", "label": "No Ironing"},
+        {"value": "top", "label": "Top Surface Only"},
+        {"value": "topmost", "label": "Topmost Surface Only"},
+        {"value": "allsolid", "label": "All Solid Layers"}
+      ]
+    },
+    {
+      "key": "ironing_speed",
+      "label": "Ironing Speed",
+      "type": "number",
+      "category": "quality",
+      "description": "Speed for ironing",
+      "unit": "mm/s"
+    },
+    {
+      "key": "ironing_flow",
+      "label": "Ironing Flow",
+      "type": "number",
+      "category": "quality",
+      "description": "Flow rate for ironing",
+      "unit": "%"
+    },
+    {
+      "key": "ironing_spacing",
+      "label": "Ironing Spacing",
+      "type": "number",
+      "category": "quality",
+      "description": "Spacing between ironing lines",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "fuzzy_skin",
+      "label": "Fuzzy Skin",
+      "type": "select",
+      "category": "quality",
+      "description": "Add texture to outer walls",
+      "options": [
+        {"value": "none", "label": "None"},
+        {"value": "external", "label": "External"},
+        {"value": "all", "label": "All Walls"},
+        {"value": "allwalls", "label": "All Walls"}
+      ]
+    },
+    {
+      "key": "fuzzy_skin_thickness",
+      "label": "Fuzzy Skin Thickness",
+      "type": "number",
+      "category": "quality",
+      "description": "Thickness of fuzzy skin texture",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "fuzzy_skin_point_dist",
+      "label": "Fuzzy Skin Point Distance",
+      "type": "number",
+      "category": "quality",
+      "description": "Distance between fuzzy skin points",
+      "unit": "mm",
+      "step": 0.1
+    },
+    {
+      "key": "xy_hole_compensation",
+      "label": "XY Hole Compensation",
+      "type": "number",
+      "category": "quality",
+      "description": "Compensation for holes",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "xy_contour_compensation",
+      "label": "XY Contour Compensation",
+      "type": "number",
+      "category": "quality",
+      "description": "Compensation for contours",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "elefant_foot_compensation",
+      "label": "Elephant Foot Compensation",
+      "type": "number",
+      "category": "quality",
+      "description": "Compensation for first layer expansion",
+      "unit": "mm",
+      "step": 0.01
+    },
+    {
+      "key": "resolution",
+      "label": "Resolution",
+      "type": "number",
+      "category": "advanced",
+      "description": "G-code resolution",
+      "unit": "mm",
+      "step": 0.001
+    },
+    {
+      "key": "gcode_comments",
+      "label": "Verbose G-code",
+      "type": "boolean",
+      "category": "advanced",
+      "description": "Add comments to G-code"
+    },
+    {
+      "key": "reduce_crossing_wall",
+      "label": "Avoid Crossing Walls",
+      "type": "boolean",
+      "category": "advanced",
+      "description": "Avoid crossing walls during travel"
+    },
+    {
+      "key": "max_travel_detour_distance",
+      "label": "Max Travel Detour",
+      "type": "number",
+      "category": "advanced",
+      "description": "Maximum detour to avoid crossing walls",
+      "unit": "mm"
+    },
+    {
+      "key": "timelapse_type",
+      "label": "Timelapse Type",
+      "type": "select",
+      "category": "special",
+      "description": "Type of timelapse recording",
+      "options": [
+        {"value": "0", "label": "Traditional"},
+        {"value": "1", "label": "Smooth"}
+      ]
+    },
+    {
+      "key": "enable_arc_fitting",
+      "label": "Enable Arc Fitting",
+      "type": "boolean",
+      "category": "advanced",
+      "description": "Convert linear moves to arcs"
+    }
+  ]
+}

+ 17 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -993,10 +993,18 @@ async def lifespan(app: FastAPI):
     # Start the print scheduler
     asyncio.create_task(print_scheduler.run())
 
+    # Start the smart plug scheduler for time-based on/off
+    smart_plug_manager.start_scheduler()
+
+    # Start the notification digest scheduler
+    notification_service.start_digest_scheduler()
+
     yield
 
     # Shutdown
     print_scheduler.stop()
+    smart_plug_manager.stop_scheduler()
+    notification_service.stop_digest_scheduler()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
@@ -1018,9 +1026,11 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
+app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
+app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 
@@ -1037,6 +1047,12 @@ if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
             StaticFiles(directory=app_settings.static_dir / "img"),
             name="img",
         )
+    if (app_settings.static_dir / "icons").exists():
+        app.mount(
+            "/icons",
+            StaticFiles(directory=app_settings.static_dir / "icons"),
+            name="icons",
+        )
 
 
 @app.get("/")

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

@@ -4,6 +4,9 @@ from backend.app.models.filament import Filament
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
+from backend.app.models.kprofile_note import KProfileNote
+from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.notification import NotificationLog
 
 __all__ = [
     "Printer",
@@ -14,4 +17,7 @@ __all__ = [
     "MaintenanceType",
     "PrinterMaintenance",
     "MaintenanceHistory",
+    "KProfileNote",
+    "NotificationTemplate",
+    "NotificationLog",
 ]

+ 36 - 0
backend/app/models/kprofile_note.py

@@ -0,0 +1,36 @@
+"""Model for K-profile notes stored locally (not on printer)."""
+
+from datetime import datetime
+from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class KProfileNote(Base):
+    """Notes for K-profiles stored locally since printers don't support notes."""
+
+    __tablename__ = "kprofile_notes"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    # setting_id is the unique identifier for a K-profile on the printer
+    setting_id: Mapped[str] = mapped_column(String(100))
+    note: Mapped[str] = mapped_column(Text, default="")
+    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 printer
+    printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes")
+
+    # Composite index for efficient lookups
+    __table_args__ = (
+        Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),
+    )
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 4 - 0
backend/app/models/maintenance.py

@@ -15,6 +15,8 @@ class MaintenanceType(Base):
     name: Mapped[str] = mapped_column(String(100))
     description: Mapped[str | None] = mapped_column(Text)
     default_interval_hours: Mapped[float] = mapped_column(Float, default=100.0)
+    # Interval type: "hours" (print hours) or "days" (calendar days)
+    interval_type: Mapped[str] = mapped_column(String(20), default="hours")
     icon: Mapped[str | None] = mapped_column(String(50))  # Icon name for UI
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)  # Pre-defined vs custom
     created_at: Mapped[datetime] = mapped_column(
@@ -37,6 +39,8 @@ class PrinterMaintenance(Base):
 
     # Custom interval for this printer (overrides default if set)
     custom_interval_hours: Mapped[float | None] = mapped_column(Float, nullable=True)
+    # Custom interval type for this printer (overrides default if set)
+    custom_interval_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
 
     # Tracking
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)

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

@@ -1,4 +1,4 @@
-"""Notification provider model for push notifications."""
+"""Notification provider and log models for push notifications."""
 
 from datetime import datetime
 
@@ -8,6 +8,44 @@ from sqlalchemy.orm import relationship
 from backend.app.core.database import Base
 
 
+class NotificationDigestQueue(Base):
+    """Model for queuing notifications to be sent in daily digest."""
+
+    __tablename__ = "notification_digest_queue"
+
+    id = Column(Integer, primary_key=True, index=True)
+    provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
+    event_type = Column(String(50), nullable=False)  # print_start, print_complete, etc.
+    title = Column(String(255), nullable=False)
+    message = Column(Text, nullable=False)
+    printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
+    printer_name = Column(String(100), nullable=True)
+    created_at = Column(DateTime, default=datetime.utcnow, index=True)
+
+    # Relationships
+    provider = relationship("NotificationProvider", back_populates="digest_queue")
+
+
+class NotificationLog(Base):
+    """Model for logging sent notifications."""
+
+    __tablename__ = "notification_logs"
+
+    id = Column(Integer, primary_key=True, index=True)
+    provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
+    event_type = Column(String(50), nullable=False)  # print_start, print_complete, etc.
+    title = Column(String(255), nullable=False)
+    message = Column(Text, nullable=False)
+    success = Column(Boolean, default=True)
+    error_message = Column(Text, nullable=True)
+    printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
+    printer_name = Column(String(100), nullable=True)  # Store name in case printer is deleted
+    created_at = Column(DateTime, default=datetime.utcnow, index=True)
+
+    # Relationships
+    provider = relationship("NotificationProvider", back_populates="logs")
+
+
 class NotificationProvider(Base):
     """Model for notification providers (WhatsApp, ntfy, Pushover, etc.)."""
 
@@ -39,6 +77,10 @@ class NotificationProvider(Base):
     quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"
     quiet_hours_end = Column(String(5), nullable=True)  # HH:MM format, e.g., "07:00"
 
+    # Daily digest (batch notifications into a single daily summary)
+    daily_digest_enabled = Column(Boolean, default=False)
+    daily_digest_time = Column(String(5), nullable=True)  # HH:MM format, e.g., "08:00"
+
     # Optional: Link to specific printer (NULL = all printers)
     printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
@@ -53,3 +95,5 @@ class NotificationProvider(Base):
 
     # Relationships
     printer = relationship("Printer", back_populates="notification_providers")
+    logs = relationship("NotificationLog", back_populates="provider", cascade="all, delete-orphan")
+    digest_queue = relationship("NotificationDigestQueue", back_populates="provider", cascade="all, delete-orphan")

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

@@ -0,0 +1,90 @@
+"""Notification template model for customizable notification messages."""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class NotificationTemplate(Base):
+    """Model for notification message templates."""
+
+    __tablename__ = "notification_templates"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    event_type: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
+    name: Mapped[str] = mapped_column(String(100), nullable=False)
+    title_template: Mapped[str] = mapped_column(Text, nullable=False)
+    body_template: Mapped[str] = mapped_column(Text, nullable=False)
+    is_default: Mapped[bool] = mapped_column(Boolean, 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()
+    )
+
+
+# Default templates for seeding
+DEFAULT_TEMPLATES = [
+    {
+        "event_type": "print_start",
+        "name": "Print Started",
+        "title_template": "Print Started",
+        "body_template": "{printer}: {filename}\nEstimated: {estimated_time}",
+    },
+    {
+        "event_type": "print_complete",
+        "name": "Print Completed",
+        "title_template": "Print Completed",
+        "body_template": "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g",
+    },
+    {
+        "event_type": "print_failed",
+        "name": "Print Failed",
+        "title_template": "Print Failed",
+        "body_template": "{printer}: {filename}\nTime: {duration}\nReason: {reason}",
+    },
+    {
+        "event_type": "print_stopped",
+        "name": "Print Stopped",
+        "title_template": "Print Stopped",
+        "body_template": "{printer}: {filename}\nTime: {duration}",
+    },
+    {
+        "event_type": "print_progress",
+        "name": "Print Progress",
+        "title_template": "Print {progress}% Complete",
+        "body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
+    },
+    {
+        "event_type": "printer_offline",
+        "name": "Printer Offline",
+        "title_template": "Printer Offline",
+        "body_template": "{printer} has disconnected",
+    },
+    {
+        "event_type": "printer_error",
+        "name": "Printer Error",
+        "title_template": "Printer Error: {error_type}",
+        "body_template": "{printer}\n{error_detail}",
+    },
+    {
+        "event_type": "filament_low",
+        "name": "Filament Low",
+        "title_template": "Filament Low",
+        "body_template": "{printer}: Slot {slot} at {remaining_percent}%",
+    },
+    {
+        "event_type": "maintenance_due",
+        "name": "Maintenance Due",
+        "title_template": "Maintenance Due",
+        "body_template": "{printer}:\n{items}",
+    },
+    {
+        "event_type": "test",
+        "name": "Test Notification",
+        "title_template": "BambuTrack Test",
+        "body_template": "This is a test notification. If you see this, notifications are working!",
+    },
+]

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

@@ -14,6 +14,7 @@ class Printer(Base):
     ip_address: Mapped[str] = mapped_column(String(45))
     access_code: Mapped[str] = mapped_column(String(20))
     model: Mapped[str | None] = mapped_column(String(50))
+    location: Mapped[str | None] = mapped_column(String(100))  # Group/location name
     nozzle_count: Mapped[int] = mapped_column(default=1)  # 1 or 2, auto-detected from MQTT
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -38,9 +39,13 @@ class Printer(Base):
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
     )
+    kprofile_notes: Mapped[list["KProfileNote"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
 from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402

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

@@ -0,0 +1,37 @@
+"""Model for storing AMS slot to filament preset mappings.
+
+This stores the user's preferred filament preset for each AMS slot,
+similar to how Bambu Studio remembers preset selections.
+"""
+
+from datetime import datetime
+from sqlalchemy import String, Integer, DateTime, ForeignKey, func, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SlotPresetMapping(Base):
+    """Maps an AMS slot to a cloud filament preset."""
+
+    __tablename__ = "slot_preset_mappings"
+    __table_args__ = (
+        UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    ams_id: Mapped[int] = mapped_column(Integer)  # AMS unit ID (0, 1, 2, 3)
+    tray_id: Mapped[int] = mapped_column(Integer)  # Tray ID within AMS (0-3)
+    preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
+    preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
+    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
+    printer: Mapped["Printer"] = relationship()
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 12 - 1
backend/app/models/smart_plug.py

@@ -1,5 +1,5 @@
 from datetime import datetime
-from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, func
+from sqlalchemy import String, Boolean, Integer, Float, DateTime, ForeignKey, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -33,6 +33,17 @@ class SmartPlug(Base):
     username: Mapped[str | None] = mapped_column(String(50), nullable=True)
     password: Mapped[str | None] = mapped_column(String(100), nullable=True)
 
+    # Power alerts
+    power_alert_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    power_alert_high: Mapped[float | None] = mapped_column(Float, nullable=True)  # Alert when power > this (watts)
+    power_alert_low: Mapped[float | None] = mapped_column(Float, nullable=True)  # Alert when power < this (watts)
+    power_alert_last_triggered: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # Cooldown tracking
+
+    # Schedule (time-based on/off)
+    schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
+    schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
+
     # 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)

+ 38 - 0
backend/app/schemas/cloud.py

@@ -57,3 +57,41 @@ class CloudDevice(BaseModel):
     dev_model_name: Optional[str] = None
     dev_product_name: Optional[str] = None
     online: bool = False
+
+
+class SlicerSettingCreate(BaseModel):
+    """Request to create a new slicer preset."""
+    type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'")
+    name: str = Field(..., description="Display name for the preset")
+    base_id: str = Field(..., description="Base preset ID to inherit from")
+    version: str = Field(default="2.0.0.0", description="Version string for the preset")
+    setting: dict = Field(default_factory=dict, description="Setting key-value pairs (delta from base)")
+
+
+class SlicerSettingUpdate(BaseModel):
+    """Request to update an existing slicer preset."""
+    name: Optional[str] = Field(None, description="New display name")
+    setting: Optional[dict] = Field(None, description="Setting key-value pairs to update")
+
+
+class SlicerSettingDetail(BaseModel):
+    """Detailed slicer setting/preset response."""
+    message: Optional[str] = None
+    code: Optional[str] = None
+    error: Optional[str] = None
+    public: bool = False
+    version: Optional[str] = None
+    type: str
+    name: str
+    update_time: Optional[str] = None
+    nickname: Optional[str] = None
+    base_id: Optional[str] = None
+    setting: dict = Field(default_factory=dict)
+    filament_id: Optional[str] = None
+    setting_id: Optional[str] = None  # For response after create
+
+
+class SlicerSettingDeleteResponse(BaseModel):
+    """Response from deleting a preset."""
+    success: bool
+    message: str

+ 13 - 0
backend/app/schemas/kprofile.py

@@ -51,3 +51,16 @@ class KProfileDelete(BaseModel):
     nozzle_diameter: str  # e.g., "0.4"
     filament_id: str  # Bambu filament identifier
     setting_id: str | None = None  # Setting ID (for X1C series)
+
+
+class KProfileNote(BaseModel):
+    """Schema for K-profile notes (stored locally, not on printer)."""
+
+    setting_id: str  # Unique identifier for the K-profile
+    note: str  # The note content
+
+
+class KProfileNoteResponse(BaseModel):
+    """Response containing notes for K-profiles."""
+
+    notes: dict[str, str]  # mapping of setting_id -> note

+ 15 - 4
backend/app/schemas/maintenance.py

@@ -9,6 +9,8 @@ class MaintenanceTypeBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     description: str | None = None
     default_interval_hours: float = Field(default=100.0, ge=1.0)
+    # "hours" = print hours, "days" = calendar days
+    interval_type: str = Field(default="hours", pattern="^(hours|days)$")
     icon: str | None = None
 
 
@@ -20,6 +22,7 @@ class MaintenanceTypeUpdate(BaseModel):
     name: str | None = None
     description: str | None = None
     default_interval_hours: float | None = Field(default=None, ge=1.0)
+    interval_type: str | None = Field(default=None, pattern="^(hours|days)$")
     icon: str | None = None
 
 
@@ -46,6 +49,7 @@ class PrinterMaintenanceCreate(PrinterMaintenanceBase):
 
 class PrinterMaintenanceUpdate(BaseModel):
     custom_interval_hours: float | None = None
+    custom_interval_type: str | None = Field(default=None, pattern="^(hours|days)$")
     enabled: bool | None = None
 
 
@@ -94,12 +98,19 @@ class MaintenanceStatus(BaseModel):
     maintenance_type_name: str
     maintenance_type_icon: str | None
     enabled: bool
-    interval_hours: float  # custom or default
+    # Interval configuration
+    interval_hours: float  # custom or default (hours for print-based, days for time-based)
+    interval_type: str  # "hours" or "days"
+    # For print-hour based maintenance
     current_hours: float  # total print hours for printer
     hours_since_maintenance: float  # current - last_performed
-    hours_until_due: float  # interval - hours_since
-    is_due: bool  # hours_until_due <= 0
-    is_warning: bool  # hours_until_due <= 10% of interval
+    hours_until_due: float  # interval - hours_since (for hours type)
+    # For time-based maintenance
+    days_since_maintenance: float | None  # days since last performed
+    days_until_due: float | None  # for days type
+    # Status flags
+    is_due: bool  # hours_until_due <= 0 OR days_until_due <= 0
+    is_warning: bool  # within 10% of interval
     last_performed_at: datetime | None
 
 

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

@@ -43,10 +43,14 @@ class NotificationProviderBase(BaseModel):
     quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
     quiet_hours_end: str | None = Field(default=None, description="End time in HH:MM format")
 
+    # Daily digest
+    daily_digest_enabled: bool = Field(default=False, description="Batch notifications into daily digest")
+    daily_digest_time: str | None = Field(default=None, description="Time to send digest in HH:MM format")
+
     # Printer filter
     printer_id: int | None = Field(default=None, description="Specific printer ID or null for all")
 
-    @field_validator("quiet_hours_start", "quiet_hours_end")
+    @field_validator("quiet_hours_start", "quiet_hours_end", "daily_digest_time")
     @classmethod
     def validate_time_format(cls, v: str | None) -> str | None:
         if v is None:
@@ -95,6 +99,10 @@ class NotificationProviderUpdate(BaseModel):
     quiet_hours_start: str | None = None
     quiet_hours_end: str | None = None
 
+    # Daily digest
+    daily_digest_enabled: bool | None = None
+    daily_digest_time: str | None = None
+
     # Printer filter
     printer_id: int | None = None
 
@@ -168,3 +176,34 @@ class EmailConfig(BaseModel):
     from_email: str = Field(..., description="From email address")
     to_email: str = Field(..., description="Recipient email address")
     use_tls: bool = Field(default=True, description="Use TLS encryption")
+
+
+# Notification Log schemas
+class NotificationLogResponse(BaseModel):
+    """Schema for notification log API responses."""
+
+    id: int
+    provider_id: int
+    provider_name: str | None = None
+    provider_type: str | None = None
+    event_type: str
+    title: str
+    message: str
+    success: bool
+    error_message: str | None = None
+    printer_id: int | None = None
+    printer_name: str | None = None
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class NotificationLogStats(BaseModel):
+    """Statistics for notification logs."""
+
+    total: int
+    success_count: int
+    failure_count: int
+    by_event_type: dict[str, int]
+    by_provider: dict[str, int]

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

@@ -0,0 +1,166 @@
+"""Pydantic schemas for notification templates."""
+
+from datetime import datetime
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+
+class EventType(str, Enum):
+    """Supported notification event types."""
+
+    PRINT_START = "print_start"
+    PRINT_COMPLETE = "print_complete"
+    PRINT_FAILED = "print_failed"
+    PRINT_STOPPED = "print_stopped"
+    PRINT_PROGRESS = "print_progress"
+    PRINTER_OFFLINE = "printer_offline"
+    PRINTER_ERROR = "printer_error"
+    FILAMENT_LOW = "filament_low"
+    MAINTENANCE_DUE = "maintenance_due"
+    TEST = "test"
+
+
+# Available variables for each event type
+EVENT_VARIABLES: dict[str, list[str]] = {
+    "print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"],
+    "print_complete": ["printer", "filename", "duration", "filament_grams", "timestamp", "app_name"],
+    "print_failed": ["printer", "filename", "duration", "reason", "timestamp", "app_name"],
+    "print_stopped": ["printer", "filename", "duration", "timestamp", "app_name"],
+    "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
+    "printer_offline": ["printer", "timestamp", "app_name"],
+    "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
+    "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
+    "maintenance_due": ["printer", "items", "timestamp", "app_name"],
+    "test": ["app_name", "timestamp"],
+}
+
+# Sample data for previewing templates
+SAMPLE_DATA: dict[str, dict[str, str]] = {
+    "print_start": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "estimated_time": "1h 23m",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "print_complete": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "duration": "1h 18m",
+        "filament_grams": "15.2",
+        "timestamp": "2024-01-15 15:48",
+        "app_name": "BambuTrack",
+    },
+    "print_failed": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "duration": "0h 45m",
+        "reason": "Filament runout",
+        "timestamp": "2024-01-15 15:15",
+        "app_name": "BambuTrack",
+    },
+    "print_stopped": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "duration": "0h 30m",
+        "timestamp": "2024-01-15 15:00",
+        "app_name": "BambuTrack",
+    },
+    "print_progress": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "progress": "50",
+        "remaining_time": "0h 41m",
+        "timestamp": "2024-01-15 15:00",
+        "app_name": "BambuTrack",
+    },
+    "printer_offline": {
+        "printer": "Bambu X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "printer_error": {
+        "printer": "Bambu X1C",
+        "error_type": "AMS Error",
+        "error_detail": "Filament slot 1 jammed",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "filament_low": {
+        "printer": "Bambu X1C",
+        "slot": "1",
+        "remaining_percent": "15",
+        "color": "Black PLA",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "maintenance_due": {
+        "printer": "Bambu X1C",
+        "items": "• Nozzle cleaning (OVERDUE)\n• Carbon rod lubrication (Soon)",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "test": {
+        "app_name": "BambuTrack",
+        "timestamp": "2024-01-15 14:30",
+    },
+}
+
+
+class NotificationTemplateBase(BaseModel):
+    """Base schema for notification templates."""
+
+    title_template: str = Field(..., min_length=1, max_length=200)
+    body_template: str = Field(..., min_length=1, max_length=2000)
+
+
+class NotificationTemplateUpdate(BaseModel):
+    """Schema for updating a notification template."""
+
+    title_template: str | None = Field(default=None, min_length=1, max_length=200)
+    body_template: str | None = Field(default=None, min_length=1, max_length=2000)
+
+
+class NotificationTemplateResponse(NotificationTemplateBase):
+    """Schema for notification template API responses."""
+
+    id: int
+    event_type: str
+    name: str
+    is_default: bool
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class TemplateVariableInfo(BaseModel):
+    """Information about a template variable."""
+
+    name: str
+    description: str
+
+
+class EventVariablesResponse(BaseModel):
+    """Response for available variables per event type."""
+
+    event_type: str
+    event_name: str
+    variables: list[str]
+
+
+class TemplatePreviewRequest(BaseModel):
+    """Request to preview a template with sample data."""
+
+    event_type: str
+    title_template: str
+    body_template: str
+
+
+class TemplatePreviewResponse(BaseModel):
+    """Response with rendered template preview."""
+
+    title: str
+    body: str

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

@@ -48,6 +48,7 @@ class PrintQueueItemResponse(BaseModel):
     archive_name: str | None = None
     archive_thumbnail: str | None = None
     printer_name: str | None = None
+    print_time_seconds: int | None = None  # Estimated print time from archive
 
     class Config:
         from_attributes = True

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

@@ -8,6 +8,7 @@ class PrinterBase(BaseModel):
     ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
     access_code: str = Field(..., min_length=1, max_length=20)
     model: str | None = None
+    location: str | None = None  # Group/location name
     auto_archive: bool = True
 
 
@@ -20,6 +21,7 @@ class PrinterUpdate(BaseModel):
     ip_address: str | None = None
     access_code: str | None = None
     model: str | None = None
+    location: str | None = None
     is_active: bool | None = None
     auto_archive: bool | None = None
     print_hours_offset: float | None = None
@@ -39,10 +41,60 @@ class PrinterResponse(PrinterBase):
 
 class HMSErrorResponse(BaseModel):
     code: str
+    attr: int = 0  # Attribute value for constructing wiki URL
     module: int
     severity: int  # 1=fatal, 2=serious, 3=common, 4=info
 
 
+class AMSTray(BaseModel):
+    id: int
+    tray_color: str | None = None
+    tray_type: str | None = None
+    tray_sub_brands: str | None = None  # Full name like "PLA Basic", "PETG HF"
+    tray_id_name: str | None = None  # Bambu filament ID like "A00-Y2" (can decode to color)
+    tray_info_idx: str | None = None  # Filament preset ID like "GFA00"
+    remain: int = 0
+    k: float | None = None  # Pressure advance value
+    tag_uid: str | None = None  # RFID tag UID (any tag)
+    tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)
+    nozzle_temp_min: int | None = None  # Min nozzle temperature
+    nozzle_temp_max: int | None = None  # Max nozzle temperature
+
+
+class AMSUnit(BaseModel):
+    id: int
+    humidity: int | None = None
+    temp: float | None = None
+    is_ams_ht: bool = False  # True for AMS-HT (single spool), False for regular AMS (4 spools)
+    tray: list[AMSTray] = []
+
+
+class NozzleInfoResponse(BaseModel):
+    nozzle_type: str = ""  # "stainless_steel" or "hardened_steel"
+    nozzle_diameter: str = ""  # e.g., "0.4"
+
+
+class PrintOptionsResponse(BaseModel):
+    """AI detection and print options from xcam data."""
+    # Core AI detectors
+    spaghetti_detector: bool = False
+    print_halt: bool = False
+    halt_print_sensitivity: str = "medium"  # Spaghetti sensitivity
+    first_layer_inspector: bool = False
+    printing_monitor: bool = False
+    buildplate_marker_detector: bool = False
+    allow_skip_parts: bool = False
+    # Additional AI detectors (decoded from cfg bitmask)
+    nozzle_clumping_detector: bool = True
+    nozzle_clumping_sensitivity: str = "medium"
+    pileup_detector: bool = True
+    pileup_sensitivity: str = "medium"
+    airprint_detector: bool = True
+    airprint_sensitivity: str = "medium"
+    auto_recovery_step_loss: bool = True
+    filament_tangle_detect: bool = False
+
+
 class PrinterStatus(BaseModel):
     id: int
     name: str
@@ -58,3 +110,41 @@ class PrinterStatus(BaseModel):
     temperatures: dict | None = None
     cover_url: str | None = None
     hms_errors: list[HMSErrorResponse] = []
+    ams: list[AMSUnit] = []
+    ams_exists: bool = False
+    vt_tray: AMSTray | None = None  # Virtual tray / external spool
+    sdcard: bool = False  # SD card inserted
+    store_to_sdcard: bool = False  # Store sent files on SD card
+    timelapse: bool = False  # Timelapse recording active
+    ipcam: bool = False  # Live view enabled
+    wifi_signal: int | None = None  # WiFi signal strength in dBm
+    nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)
+    print_options: PrintOptionsResponse | None = None  # AI detection and print options
+    # Calibration stage tracking
+    stg_cur: int = -1  # Current stage number (-1 = not calibrating)
+    stg_cur_name: str | None = None  # Human-readable current stage name
+    stg: list[int] = []  # List of stage numbers in calibration sequence
+    # Air conditioning mode (0=cooling, 1=heating)
+    airduct_mode: int = 0
+    # Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
+    speed_level: int = 2
+    # Chamber light on/off
+    chamber_light: bool = False
+    # Active extruder for dual nozzle (0=right, 1=left)
+    active_extruder: int = 0
+    # AMS mapping for dual nozzle: which AMS is connected to which nozzle
+    ams_mapping: list[int] = []
+    # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
+    ams_extruder_map: dict[str, int] = {}
+    # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
+    tray_now: int = 255
+    # AMS status for filament change tracking
+    # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
+    ams_status_main: int = 0
+    # Sub status: specific step within filament change (when main=1)
+    # Known values: 4=retraction, 6=load verification, 7=purge
+    ams_status_sub: int = 0
+    # mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
+    mc_print_sub_stage: int = 0
+    # Timestamp of last AMS data update (for RFID refresh detection)
+    last_ams_update: float = 0.0

+ 20 - 0
backend/app/schemas/settings.py

@@ -23,6 +23,19 @@ class AppSettings(BaseModel):
     # Language
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
 
+    # AMS threshold settings for humidity and temperature coloring
+    ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
+    ams_humidity_fair: int = Field(default=60, description="Humidity threshold for fair (orange): <= this value, > is red")
+    ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
+    ams_temp_fair: float = Field(default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red")
+
+    # Date/time display format
+    date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
+    time_format: str = Field(default="system", description="Time format: system, 12h, 24h")
+
+    # Default printer for operations
+    default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -39,3 +52,10 @@ class AppSettingsUpdate(BaseModel):
     spoolman_sync_mode: str | None = None
     check_updates: bool | None = None
     notification_language: str | None = None
+    ams_humidity_good: int | None = None
+    ams_humidity_fair: int | None = None
+    ams_temp_good: float | None = None
+    ams_temp_fair: float | None = None
+    date_format: str | None = None
+    time_format: str | None = None
+    default_printer_id: int | None = None

+ 17 - 0
backend/app/schemas/smart_plug.py

@@ -15,6 +15,14 @@ class SmartPlugBase(BaseModel):
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
     username: str | None = None
     password: str | None = None
+    # Power alerts
+    power_alert_enabled: bool = False
+    power_alert_high: float | None = Field(default=None, ge=0, le=5000)  # Alert when power > this (watts)
+    power_alert_low: float | None = Field(default=None, ge=0, le=5000)  # Alert when power < this (watts)
+    # Schedule
+    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
 
 
 class SmartPlugCreate(SmartPlugBase):
@@ -33,6 +41,14 @@ class SmartPlugUpdate(BaseModel):
     off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
     username: str | None = None
     password: str | None = None
+    # Power alerts
+    power_alert_enabled: bool | None = None
+    power_alert_high: float | None = Field(default=None, ge=0, le=5000)
+    power_alert_low: float | None = Field(default=None, ge=0, le=5000)
+    # Schedule
+    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$")
 
 
 class SmartPlugResponse(SmartPlugBase):
@@ -40,6 +56,7 @@ class SmartPlugResponse(SmartPlugBase):
     last_state: str | None = None
     last_checked: datetime | None = None
     auto_off_executed: bool = False  # True when auto-off was triggered after print
+    power_alert_last_triggered: datetime | None = None
     created_at: datetime
     updated_at: datetime
 

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

@@ -573,8 +573,10 @@ class ArchiveService:
                 name_conditions.append(PrintArchive.print_name.ilike(print_name))
             if makerworld_model_id:
                 # Match by MakerWorld model ID stored in extra_data
+                # Use json_extract for SQLite compatibility (astext is PostgreSQL-only)
+                from sqlalchemy import func, cast, String
                 name_conditions.append(
-                    PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id
+                    func.json_extract(PrintArchive.extra_data, '$.makerworld_model_id') == str(makerworld_model_id)
                 )
 
             if name_conditions:

+ 164 - 1
backend/app/services/bambu_cloud.py

@@ -174,7 +174,7 @@ class BambuCloudService:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
 
-    async def get_slicer_settings(self, version: str = "01.09.00.00") -> dict:
+    async def get_slicer_settings(self, version: str = "02.04.00.70") -> dict:
         """
         Get all slicer settings (filament, printer, process presets).
 
@@ -220,6 +220,169 @@ class BambuCloudService:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
 
+    async def create_setting(self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0") -> dict:
+        """
+        Create a new slicer preset/setting.
+
+        Args:
+            preset_type: Type of preset - "filament", "print", or "printer"
+            name: Display name for the preset
+            base_id: Base preset ID to inherit from (e.g., "GFSA00")
+            setting: Dict of setting key-value pairs (only modified values from base)
+            version: Version string for the preset (default: "2.0.0.0")
+
+        Returns:
+            Created preset data including the new setting_id
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            # Add timestamp if not present
+            import time
+            if "updated_time" not in setting:
+                setting["updated_time"] = str(int(time.time()))
+
+            payload = {
+                "type": preset_type,
+                "name": name,
+                "version": version,
+                "base_id": base_id,
+                "setting": setting,
+            }
+
+            response = await self._client.post(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting",
+                headers=self._get_headers(),
+                json=payload
+            )
+
+            data = response.json()
+
+            if response.status_code in (200, 201):
+                return data
+
+            error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
+            raise BambuCloudError(f"Failed to create setting: {error_msg}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
+        """
+        Update an existing slicer preset/setting.
+
+        Note: Bambu Cloud API doesn't support true updates. Instead, we:
+        1. Fetch the current setting metadata (type, base_id, version)
+        2. Use the provided settings as the new complete settings (NOT merged)
+        3. Delete the old setting first (to avoid name conflicts)
+        4. Create a new setting via POST
+
+        Args:
+            setting_id: ID of the preset to update
+            name: New display name (optional)
+            setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
+
+        Returns:
+            Updated preset data with new setting_id
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            # Fetch current setting to get metadata (type, base_id, version)
+            current = await self.get_setting_detail(setting_id)
+            preset_type = current.get("type", "filament")
+
+            # Use provided settings directly (complete replacement, not merge)
+            # This allows the frontend to edit the full settings JSON
+            if setting is not None:
+                updated_setting = setting.copy()
+            else:
+                updated_setting = current.get("setting", {}).copy()
+
+            # Extract name from settings_id field in the JSON, or use provided name, or fall back to current
+            # The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
+            settings_id_key = {
+                "filament": "filament_settings_id",
+                "print": "print_settings_id",
+                "printer": "printer_settings_id",
+            }.get(preset_type, "filament_settings_id")
+
+            settings_id_value = updated_setting.get(settings_id_key, "")
+            if settings_id_value:
+                # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
+                updated_name = settings_id_value.strip('"')
+            elif name is not None:
+                updated_name = name
+            else:
+                updated_name = current.get("name", "Untitled")
+
+            # Update the timestamp
+            import time
+            updated_setting["updated_time"] = str(int(time.time()))
+
+            # Ensure settings_id field matches the name
+            updated_setting[settings_id_key] = f'"{updated_name}"'
+
+            # Delete the old setting FIRST to avoid name conflicts
+            await self.delete_setting(setting_id)
+
+            # Create new setting via POST
+            payload = {
+                "type": preset_type,
+                "name": updated_name,
+                "version": current.get("version", "2.0.0.0"),
+                "base_id": current.get("base_id", ""),
+                "setting": updated_setting,
+            }
+
+            response = await self._client.post(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting",
+                headers=self._get_headers(),
+                json=payload
+            )
+
+            data = response.json()
+
+            if response.status_code == 200:
+                return data
+
+            error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
+            raise BambuCloudError(f"Failed to update setting: {error_msg}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def delete_setting(self, setting_id: str) -> dict:
+        """
+        Delete a slicer preset/setting.
+
+        Args:
+            setting_id: ID of the preset to delete
+
+        Returns:
+            Deletion confirmation
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            response = await self._client.delete(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
+                headers=self._get_headers()
+            )
+
+            if response.status_code in (200, 204):
+                return {"success": True, "message": "Setting deleted"}
+
+            data = response.json() if response.content else {}
+            error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
+            raise BambuCloudError(f"Failed to delete setting: {error_msg}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
     async def get_devices(self) -> dict:
         """Get list of bound devices."""
         if not self.is_authenticated:

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

@@ -241,13 +241,20 @@ class BambuFTPClient:
         # Try AVBL command (available space) - some FTP servers support this
         try:
             response = self._ftp.sendcmd("AVBL")
+            logger.debug(f"AVBL response: {response}")
             # Response format: "213 <bytes available>"
             if response.startswith("213"):
                 parts = response.split()
                 if len(parts) >= 2:
                     result["free_bytes"] = int(parts[1])
-        except Exception:
-            pass
+        except Exception as e:
+            logger.debug(f"AVBL command not supported: {e}")
+            # Try STAT command as fallback
+            try:
+                response = self._ftp.sendcmd("STAT")
+                logger.debug(f"STAT response: {response}")
+            except Exception:
+                pass
 
         # Calculate used space by listing root directories
         try:

File diff suppressed because it is too large
+ 930 - 31
backend/app/services/bambu_mqtt.py


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

@@ -5,7 +5,7 @@ Captures images from the printer's RTSPS camera stream using ffmpeg.
 
 import asyncio
 import logging
-import subprocess
+import shutil
 from pathlib import Path
 from datetime import datetime
 import uuid
@@ -14,6 +14,46 @@ from backend.app.core.config import settings
 
 logger = logging.getLogger(__name__)
 
+# Cache the ffmpeg path after first lookup
+_ffmpeg_path: str | None = None
+
+
+def get_ffmpeg_path() -> str | None:
+    """Find the ffmpeg executable path.
+
+    Uses shutil.which first, then checks common installation locations
+    for systems where PATH may be limited (e.g., systemd services).
+    """
+    global _ffmpeg_path
+
+    if _ffmpeg_path is not None:
+        return _ffmpeg_path
+
+    # Try PATH first
+    ffmpeg_path = shutil.which("ffmpeg")
+
+    # If not found via PATH, check common installation locations
+    if ffmpeg_path is None:
+        common_paths = [
+            "/usr/bin/ffmpeg",
+            "/usr/local/bin/ffmpeg",
+            "/opt/homebrew/bin/ffmpeg",  # macOS Homebrew
+            "/snap/bin/ffmpeg",  # Ubuntu Snap
+            "C:\\ffmpeg\\bin\\ffmpeg.exe",  # Windows common
+        ]
+        for path in common_paths:
+            if Path(path).exists():
+                ffmpeg_path = path
+                break
+
+    _ffmpeg_path = ffmpeg_path
+    if ffmpeg_path:
+        logger.info(f"Found ffmpeg at: {ffmpeg_path}")
+    else:
+        logger.warning("ffmpeg not found in PATH or common locations")
+
+    return ffmpeg_path
+
 
 def get_camera_port(model: str | None) -> int:
     """Get the RTSPS port based on printer model.
@@ -59,17 +99,26 @@ async def capture_camera_frame(
     # Ensure output directory exists
     output_path.parent.mkdir(parents=True, exist_ok=True)
 
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
+        return False
+
     # ffmpeg command to capture a single frame from RTSPS stream
     # -rtsp_transport tcp: Use TCP for RTSP (more reliable)
+    # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
     # -y: Overwrite output file
     # -frames:v 1: Capture only 1 frame
+    # -update 1: Allow writing single image without sequence pattern
     # -q:v 2: High quality JPEG (1-31, lower is better)
     cmd = [
-        "ffmpeg",
+        ffmpeg,
         "-y",  # Overwrite output
         "-rtsp_transport", "tcp",
+        "-rtsp_flags", "prefer_tcp",
         "-i", camera_url,
         "-frames:v", "1",
+        "-update", "1",
         "-q:v", "2",
         str(output_path),
     ]

+ 434 - 140
backend/app/services/notification_service.py

@@ -1,7 +1,9 @@
 """Notification service for sending push notifications via various providers."""
 
+import asyncio
 import json
 import logging
+import re
 import smtplib
 from datetime import datetime
 from email.mime.multipart import MIMEMultipart
@@ -13,9 +15,8 @@ import httpx
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.models.notification import NotificationProvider
-from backend.app.models.settings import Settings
-from backend.app.i18n import Translator
+from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue
+from backend.app.models.notification_template import NotificationTemplate
 
 logger = logging.getLogger(__name__)
 
@@ -25,6 +26,9 @@ class NotificationService:
 
     def __init__(self):
         self._http_client: httpx.AsyncClient | None = None
+        self._template_cache: dict[str, NotificationTemplate] = {}
+        self._digest_scheduler_task: asyncio.Task | None = None
+        self._last_digest_check: str = ""  # "HH:MM" to avoid duplicate checks
 
     async def _get_client(self) -> httpx.AsyncClient:
         """Get or create HTTP client."""
@@ -66,136 +70,77 @@ class NotificationService:
             logger.warning(f"Invalid quiet hours format for provider {provider.name}")
             return False
 
-    async def _get_notification_language(self, db: AsyncSession) -> str:
-        """Get the notification language from settings."""
+    async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
+        """Get a notification template by event type."""
+        # Check cache first
+        if event_type in self._template_cache:
+            return self._template_cache[event_type]
+
         result = await db.execute(
-            select(Settings).where(Settings.key == "notification_language")
+            select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)
         )
-        setting = result.scalar_one_or_none()
-        return setting.value if setting else "en"
+        template = result.scalar_one_or_none()
+
+        if template:
+            self._template_cache[event_type] = template
+
+        return template
+
+    def _render_template(self, template_str: str, variables: dict[str, Any]) -> str:
+        """Render a template string with variables. Missing variables become empty."""
+        result = template_str
+        for key, value in variables.items():
+            result = result.replace("{" + key + "}", str(value) if value is not None else "")
+        # Remove any remaining unreplaced placeholders
+        result = re.sub(r"\{[a-z_]+\}", "", result)
+        return result
 
-    def _format_duration(self, seconds: int | None, translator: Translator) -> str:
+    def _format_duration(self, seconds: int | None) -> str:
         """Format duration in seconds to human-readable string."""
         if seconds is None:
-            return translator.t("notification.unknown")
+            return "Unknown"
         hours = seconds // 3600
         minutes = (seconds % 3600) // 60
         if hours > 0:
             return f"{hours}h {minutes}m"
         return f"{minutes}m"
 
-    def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]:
-        """Build notification message for print start event."""
-        filename = data.get("filename", translator.t("notification.unknown"))
-        # Clean up filename
-        if filename.endswith(".gcode.3mf"):
-            filename = filename[:-10]
-        elif filename.endswith(".3mf"):
-            filename = filename[:-4]
-
-        title = translator.t("notification.print_started")
-
-        estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
-        time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator)
-
-        message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}"
-        return title, message
-
-    def _build_print_complete_message(
-        self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None
-    ) -> tuple[str, str]:
-        """Build notification message for print complete event."""
-        filename = data.get("filename", translator.t("notification.unknown"))
+    def _clean_filename(self, filename: str) -> str:
+        """Remove file extensions from filename."""
         if filename.endswith(".gcode.3mf"):
-            filename = filename[:-10]
+            return filename[:-10]
         elif filename.endswith(".3mf"):
-            filename = filename[:-4]
-
-        if status == "completed":
-            title = translator.t("notification.print_completed")
-        elif status == "failed":
-            title = translator.t("notification.print_failed")
-        elif status in ("aborted", "stopped", "cancelled"):
-            title = translator.t("notification.print_stopped")
-        else:
-            title = translator.t("notification.print_ended")
+            return filename[:-4]
+        return filename
 
-        lines = [f"{printer_name}: {filename}"]
-
-        if archive_data:
-            # Add print time if available
-            if archive_data.get("print_time_seconds"):
-                lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}")
-            # Add filament used if available
-            if archive_data.get("actual_filament_grams"):
-                lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g")
-            # Add failure reason if failed
-            if status == "failed" and archive_data.get("failure_reason"):
-                lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}")
-
-        message = "\n".join(lines)
-        return title, message
-
-    def _build_progress_message(
-        self, printer_name: str, filename: str, progress: int, translator: Translator
+    async def _build_message_from_template(
+        self, db: AsyncSession, event_type: str, variables: dict[str, Any]
     ) -> tuple[str, str]:
-        """Build notification message for print progress milestone."""
-        if filename.endswith(".gcode.3mf"):
-            filename = filename[:-10]
-        elif filename.endswith(".3mf"):
-            filename = filename[:-4]
-
-        title = translator.t("notification.print_progress", progress=progress)
-        message = f"{printer_name}: {filename}"
-        return title, message
+        """Build notification title and body from template."""
+        # Add common variables
+        variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
+        variables["app_name"] = "BambuTrack"
 
-    def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]:
-        """Build notification message for printer offline event."""
-        title = translator.t("notification.printer_offline")
-        message = translator.t("notification.printer_disconnected", printer=printer_name)
-        return title, message
+        template = await self._get_template(db, event_type)
+        if not template:
+            # Fallback to simple message
+            logger.warning(f"Template not found for event type: {event_type}")
+            return event_type.replace("_", " ").title(), str(variables)
 
-    def _build_printer_error_message(
-        self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None
-    ) -> tuple[str, str]:
-        """Build notification message for printer error event."""
-        title = translator.t("notification.printer_error", error_type=error_type)
-        message = f"{printer_name}"
-        if error_detail:
-            message += f"\n{error_detail}"
-        return title, message
-
-    def _build_filament_low_message(
-        self, printer_name: str, slot: int, remaining_percent: int, translator: Translator
-    ) -> tuple[str, str]:
-        """Build notification message for low filament event."""
-        title = translator.t("notification.filament_low")
-        message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent)
-        return title, message
+        title = self._render_template(template.title_template, variables)
+        body = self._render_template(template.body_template, variables)
 
-    def _build_maintenance_due_message(
-        self, printer_name: str, maintenance_items: list[dict], translator: Translator
-    ) -> tuple[str, str]:
-        """Build notification message for maintenance due event."""
-        title = translator.t("notification.maintenance_due")
-        lines = [f"{printer_name}:"]
-        for item in maintenance_items:
-            status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon")
-            lines.append(f"• {item['name']} ({status})")
-        message = "\n".join(lines)
-        return title, message
+        return title, body
 
     async def send_test_notification(
         self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
     ) -> tuple[bool, str]:
         """Send a test notification to verify configuration."""
-        lang = "en"
         if db:
-            lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-
-        title = translator.t("notification.test_title")
-        message = translator.t("notification.test_message")
+            title, message = await self._build_message_from_template(db, "test", {})
+        else:
+            title = "BambuTrack Test"
+            message = "This is a test notification. If you see this, notifications are working!"
 
         try:
             if provider_type == "callmebot":
@@ -208,6 +153,10 @@ class NotificationService:
                 return await self._send_telegram(config, f"*{title}*\n{message}")
             elif provider_type == "email":
                 return await self._send_email(config, title, message)
+            elif provider_type == "discord":
+                return await self._send_discord(config, title, message)
+            elif provider_type == "webhook":
+                return await self._send_webhook(config, title, message)
             else:
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
@@ -366,6 +315,70 @@ class NotificationService:
         except Exception as e:
             return False, f"Email error: {str(e)}"
 
+    async def _send_discord(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+        """Send notification via Discord webhook."""
+        webhook_url = config.get("webhook_url", "").strip()
+
+        if not webhook_url:
+            return False, "Webhook URL is required"
+
+        if not webhook_url.startswith("https://discord.com/api/webhooks/"):
+            return False, "Invalid Discord webhook URL"
+
+        # Discord embed format for nicer messages
+        data = {
+            "embeds": [{
+                "title": title,
+                "description": message,
+                "color": 0x00AE42,  # Bambu green
+            }]
+        }
+
+        client = await self._get_client()
+        response = await client.post(webhook_url, json=data)
+
+        if response.status_code in (200, 204):
+            return True, "Message sent successfully"
+        else:
+            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+
+    async def _send_webhook(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+        """Send notification via generic webhook (POST JSON)."""
+        webhook_url = config.get("webhook_url", "").strip()
+        auth_header = config.get("auth_header", "").strip()
+        custom_field_title = config.get("field_title", "title").strip() or "title"
+        custom_field_message = config.get("field_message", "message").strip() or "message"
+
+        if not webhook_url:
+            return False, "Webhook URL is required"
+
+        # Build payload with custom field names
+        data = {
+            custom_field_title: title,
+            custom_field_message: message,
+            "timestamp": datetime.now().isoformat(),
+            "source": "BambuTrack",
+        }
+
+        headers = {"Content-Type": "application/json"}
+        if auth_header:
+            # Support "Bearer token" or just "token" format
+            if " " in auth_header:
+                headers["Authorization"] = auth_header
+            else:
+                headers["Authorization"] = f"Bearer {auth_header}"
+
+        client = await self._get_client()
+        try:
+            response = await client.post(webhook_url, json=data, headers=headers)
+
+            if response.status_code in (200, 201, 202, 204):
+                return True, "Webhook delivered successfully"
+            else:
+                return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        except Exception as e:
+            return False, f"Webhook error: {str(e)}"
+
     async def _send_to_provider(
         self, provider: NotificationProvider, title: str, message: str
     ) -> tuple[bool, str]:
@@ -388,6 +401,10 @@ class NotificationService:
                 return await self._send_telegram(config, f"*{title}*\n{message}")
             elif provider.provider_type == "email":
                 return await self._send_email(config, title, message)
+            elif provider.provider_type == "discord":
+                return await self._send_discord(config, title, message)
+            elif provider.provider_type == "webhook":
+                return await self._send_webhook(config, title, message)
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:
@@ -431,18 +448,75 @@ class NotificationService:
         result = await db.execute(query)
         return list(result.scalars().all())
 
+    async def _log_notification(
+        self,
+        db: AsyncSession,
+        provider_id: int,
+        event_type: str,
+        title: str,
+        message: str,
+        success: bool,
+        error_message: str | None = None,
+        printer_id: int | None = None,
+        printer_name: str | None = None,
+    ):
+        """Create a log entry for a sent notification."""
+        try:
+            log = NotificationLog(
+                provider_id=provider_id,
+                event_type=event_type,
+                title=title,
+                message=message,
+                success=success,
+                error_message=error_message,
+                printer_id=printer_id,
+                printer_name=printer_name,
+            )
+            db.add(log)
+            await db.commit()
+        except Exception as e:
+            logger.warning(f"Failed to log notification: {e}")
+            # Don't fail the notification just because logging failed
+
     async def _send_to_providers(
         self,
         providers: list[NotificationProvider],
         title: str,
         message: str,
         db: AsyncSession,
+        event_type: str = "unknown",
+        printer_id: int | None = None,
+        printer_name: str | None = None,
     ):
-        """Send notification to multiple providers."""
+        """Send notification to multiple providers and log the results."""
         for provider in providers:
             try:
+                # Check if provider wants digest mode
+                if provider.daily_digest_enabled and provider.daily_digest_time:
+                    await self._queue_for_digest(
+                        provider=provider,
+                        event_type=event_type,
+                        title=title,
+                        message=message,
+                        db=db,
+                        printer_id=printer_id,
+                        printer_name=printer_name,
+                    )
+                    continue
+
                 success, error = await self._send_to_provider(provider, title, message)
                 await self._update_provider_status(db, provider.id, success, error if not success else None)
+                await self._log_notification(
+                    db=db,
+                    provider_id=provider.id,
+                    event_type=event_type,
+                    title=title,
+                    message=message,
+                    success=success,
+                    error_message=error if not success else None,
+                    printer_id=printer_id,
+                    printer_name=printer_name,
+                )
                 if success:
                     logger.info(f"Sent notification via {provider.name}")
                 else:
@@ -450,6 +524,17 @@ class NotificationService:
             except Exception as e:
                 logger.exception(f"Error sending notification via {provider.name}")
                 await self._update_provider_status(db, provider.id, False, str(e))
+                await self._log_notification(
+                    db=db,
+                    provider_id=provider.id,
+                    event_type=event_type,
+                    title=title,
+                    message=message,
+                    success=False,
+                    error_message=str(e),
+                    printer_id=printer_id,
+                    printer_name=printer_name,
+                )
 
     async def on_print_start(
         self, printer_id: int, printer_name: str, data: dict, db: AsyncSession
@@ -461,11 +546,19 @@ class NotificationService:
             logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
+        filename = self._clean_filename(data.get("filename", "Unknown"))
+        estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
+        time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
+
+        variables = {
+            "printer": printer_name,
+            "filename": filename,
+            "estimated_time": time_str,
+        }
+
         logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
-        title, message = self._build_print_start_message(printer_name, data, translator)
-        await self._send_to_providers(providers, title, message, db)
+        title, message = await self._build_message_from_template(db, "print_start", variables)
+        await self._send_to_providers(providers, title, message, db, "print_start", printer_id, printer_name)
 
     async def on_print_complete(
         self,
@@ -478,28 +571,48 @@ class NotificationService:
     ):
         """Handle print complete event - send notifications to relevant providers."""
         logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
-        # Determine which event type this is
+
+        # Determine event type based on status
         if status == "completed":
             event_field = "on_print_complete"
+            event_type = "print_complete"
         elif status in ("failed",):
             event_field = "on_print_failed"
+            event_type = "print_failed"
         elif status in ("aborted", "stopped", "cancelled"):
             event_field = "on_print_stopped"
+            event_type = "print_stopped"
         else:
-            # Unknown status, default to on_print_complete
             logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
             event_field = "on_print_complete"
+            event_type = "print_complete"
 
         providers = await self._get_providers_for_event(db, event_field, printer_id)
         if not providers:
             logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
+        filename = self._clean_filename(data.get("filename", "Unknown"))
+
+        variables = {
+            "printer": printer_name,
+            "filename": filename,
+            "duration": "",
+            "filament_grams": "",
+            "reason": "",
+        }
+
+        if archive_data:
+            if archive_data.get("print_time_seconds"):
+                variables["duration"] = self._format_duration(archive_data["print_time_seconds"])
+            if archive_data.get("actual_filament_grams"):
+                variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
+            if status == "failed" and archive_data.get("failure_reason"):
+                variables["reason"] = archive_data["failure_reason"]
+
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
-        title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data)
-        await self._send_to_providers(providers, title, message, db)
+        title, message = await self._build_message_from_template(db, event_type, variables)
+        await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
 
     async def on_print_progress(
         self,
@@ -508,16 +621,22 @@ class NotificationService:
         filename: str,
         progress: int,
         db: AsyncSession,
+        remaining_time: int | None = None,
     ):
         """Handle print progress milestone (25%, 50%, 75%)."""
         providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_progress_message(printer_name, filename, progress, translator)
-        await self._send_to_providers(providers, title, message, db)
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            "progress": str(progress),
+            "remaining_time": self._format_duration(remaining_time) if remaining_time else "",
+        }
+
+        title, message = await self._build_message_from_template(db, "print_progress", variables)
+        await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name)
 
     async def on_printer_offline(
         self, printer_id: int, printer_name: str, db: AsyncSession
@@ -527,10 +646,10 @@ class NotificationService:
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_printer_offline_message(printer_name, translator)
-        await self._send_to_providers(providers, title, message, db)
+        variables = {"printer": printer_name}
+
+        title, message = await self._build_message_from_template(db, "printer_offline", variables)
+        await self._send_to_providers(providers, title, message, db, "printer_offline", printer_id, printer_name)
 
     async def on_printer_error(
         self,
@@ -545,10 +664,14 @@ class NotificationService:
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail)
-        await self._send_to_providers(providers, title, message, db)
+        variables = {
+            "printer": printer_name,
+            "error_type": error_type,
+            "error_detail": error_detail or "",
+        }
+
+        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_filament_low(
         self,
@@ -557,16 +680,22 @@ class NotificationService:
         slot: int,
         remaining_percent: int,
         db: AsyncSession,
+        color: str | None = None,
     ):
         """Handle low filament event."""
         providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator)
-        await self._send_to_providers(providers, title, message, db)
+        variables = {
+            "printer": printer_name,
+            "slot": str(slot),
+            "remaining_percent": str(remaining_percent),
+            "color": color or "",
+        }
+
+        title, message = await self._build_message_from_template(db, "filament_low", variables)
+        await self._send_to_providers(providers, title, message, db, "filament_low", printer_id, printer_name)
 
     async def on_maintenance_due(
         self,
@@ -584,11 +713,176 @@ class NotificationService:
             logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
+        # Format maintenance items list
+        items_list = []
+        for item in maintenance_items:
+            status = "OVERDUE" if item.get("is_due") else "Soon"
+            items_list.append(f"- {item['name']} ({status})")
+        items_str = "\n".join(items_list)
+
+        variables = {
+            "printer": printer_name,
+            "items": items_str,
+        }
+
         logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
-        title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator)
-        await self._send_to_providers(providers, title, message, db)
+        title, message = await self._build_message_from_template(db, "maintenance_due", variables)
+        await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
+
+    def clear_template_cache(self):
+        """Clear the template cache. Call this when templates are updated."""
+        self._template_cache.clear()
+
+    async def _queue_for_digest(
+        self,
+        provider: NotificationProvider,
+        event_type: str,
+        title: str,
+        message: str,
+        db: AsyncSession,
+        printer_id: int | None = None,
+        printer_name: str | None = None,
+    ):
+        """Queue a notification for later delivery in the daily digest."""
+        try:
+            queue_entry = NotificationDigestQueue(
+                provider_id=provider.id,
+                event_type=event_type,
+                title=title,
+                message=message,
+                printer_id=printer_id,
+                printer_name=printer_name,
+            )
+            db.add(queue_entry)
+            await db.commit()
+            logger.info(f"Queued notification for digest: {event_type} for provider {provider.name}")
+        except Exception as e:
+            logger.warning(f"Failed to queue notification for digest: {e}")
+
+    async def send_digest(self, provider_id: int):
+        """Send all queued notifications as a single digest for a provider."""
+        from backend.app.core.database import async_session
+
+        async with async_session() as db:
+            # Get the provider
+            result = await db.execute(
+                select(NotificationProvider).where(NotificationProvider.id == provider_id)
+            )
+            provider = result.scalar_one_or_none()
+
+            if not provider or not provider.enabled:
+                return
+
+            # Get all queued notifications for this provider
+            result = await db.execute(
+                select(NotificationDigestQueue)
+                .where(NotificationDigestQueue.provider_id == provider_id)
+                .order_by(NotificationDigestQueue.created_at)
+            )
+            queue_entries = list(result.scalars().all())
+
+            if not queue_entries:
+                logger.debug(f"No queued notifications for provider {provider.name}")
+                return
+
+            # Build digest message
+            title = f"Daily Digest - {len(queue_entries)} Events"
+
+            # Group by event type
+            events_by_type: dict[str, list] = {}
+            for entry in queue_entries:
+                if entry.event_type not in events_by_type:
+                    events_by_type[entry.event_type] = []
+                events_by_type[entry.event_type].append(entry)
+
+            # Format the digest body
+            body_parts = []
+            for event_type, entries in events_by_type.items():
+                event_label = event_type.replace("_", " ").title()
+                body_parts.append(f"== {event_label} ({len(entries)}) ==")
+                for entry in entries:
+                    time_str = entry.created_at.strftime("%H:%M")
+                    printer_info = f"[{entry.printer_name}] " if entry.printer_name else ""
+                    body_parts.append(f"  {time_str} {printer_info}{entry.title}")
+                body_parts.append("")
+
+            body = "\n".join(body_parts)
+
+            # Send the digest
+            success, error = await self._send_to_provider(provider, title, body)
+
+            # Log the digest
+            await self._log_notification(
+                db=db,
+                provider_id=provider.id,
+                event_type="daily_digest",
+                title=title,
+                message=body,
+                success=success,
+                error_message=error if not success else None,
+            )
+
+            # Clear the queue
+            for entry in queue_entries:
+                await db.delete(entry)
+            await db.commit()
+
+            if success:
+                logger.info(f"Sent daily digest with {len(queue_entries)} events to {provider.name}")
+            else:
+                logger.warning(f"Failed to send daily digest to {provider.name}: {error}")
+
+    async def check_and_send_digests(self):
+        """Check all providers and send digests if it's their scheduled time."""
+        from backend.app.core.database import async_session
+
+        current_time = datetime.now().strftime("%H:%M")
+
+        # Avoid duplicate checks within the same minute
+        if current_time == self._last_digest_check:
+            return
+        self._last_digest_check = current_time
+
+        async with async_session() as db:
+            # Find all providers with digest enabled at this time
+            result = await db.execute(
+                select(NotificationProvider).where(
+                    NotificationProvider.enabled == True,
+                    NotificationProvider.daily_digest_enabled == True,
+                    NotificationProvider.daily_digest_time == current_time,
+                )
+            )
+            providers = result.scalars().all()
+
+            for provider in providers:
+                try:
+                    await self.send_digest(provider.id)
+                except Exception as e:
+                    logger.error(f"Error sending digest for provider {provider.id}: {e}")
+
+    def start_digest_scheduler(self):
+        """Start the background scheduler for daily digest notifications."""
+        if self._digest_scheduler_task is None:
+            self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop())
+            logger.info("Notification digest scheduler started")
+
+    def stop_digest_scheduler(self):
+        """Stop the background scheduler for daily digests."""
+        if self._digest_scheduler_task:
+            self._digest_scheduler_task.cancel()
+            self._digest_scheduler_task = None
+            logger.info("Notification digest scheduler stopped")
+
+    async def _digest_scheduler_loop(self):
+        """Background loop that checks for scheduled digests every minute."""
+        while True:
+            try:
+                await self.check_and_send_digests()
+            except Exception as e:
+                logger.error(f"Error in digest scheduler: {e}")
+
+            # Wait until the next minute
+            await asyncio.sleep(60)
 
 
 # Global instance

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

@@ -229,6 +229,15 @@ class PrinterManager:
             return self._clients[printer_id].logging_enabled
         return False
 
+    def request_status_update(self, printer_id: int) -> bool:
+        """Request a full status update from the printer.
+
+        This sends a 'pushall' command to get the latest data including nozzle info.
+        """
+        if printer_id in self._clients:
+            return self._clients[printer_id].request_status_update()
+        return False
+
     async def test_connection(
         self,
         ip_address: str,
@@ -259,6 +268,77 @@ class PrinterManager:
 
 def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
     """Convert PrinterState to a JSON-serializable dict."""
+    # Parse AMS data from raw_data
+    ams_units = []
+    vt_tray = None
+    raw_data = state.raw_data or {}
+
+    if "ams" in raw_data and isinstance(raw_data["ams"], list):
+        for ams_data in raw_data["ams"]:
+            trays = []
+            for tray in ams_data.get("tray", []):
+                tag_uid = tray.get("tag_uid")
+                if tag_uid in ("", "0000000000000000"):
+                    tag_uid = None
+                tray_uuid = tray.get("tray_uuid")
+                if tray_uuid in ("", "00000000000000000000000000000000"):
+                    tray_uuid = None
+                trays.append({
+                    "id": tray.get("id", 0),
+                    "tray_color": tray.get("tray_color"),
+                    "tray_type": tray.get("tray_type"),
+                    "tray_sub_brands": tray.get("tray_sub_brands"),
+                    "remain": tray.get("remain", 0),
+                    "k": tray.get("k"),
+                    "tag_uid": tag_uid,
+                    "tray_uuid": tray_uuid,
+                })
+            # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
+            humidity_raw = ams_data.get("humidity_raw")
+            humidity_idx = ams_data.get("humidity")
+            humidity_value = None
+
+            if humidity_raw is not None:
+                try:
+                    humidity_value = int(humidity_raw)
+                except (ValueError, TypeError):
+                    pass
+            # Fall back to index if no raw value (index is 1-5, not percentage)
+            if humidity_value is None and humidity_idx is not None:
+                try:
+                    humidity_value = int(humidity_idx)
+                except (ValueError, TypeError):
+                    pass
+
+            # AMS-HT has 1 tray, regular AMS has 4 trays
+            is_ams_ht = len(trays) == 1
+
+            ams_units.append({
+                "id": ams_data.get("id", 0),
+                "humidity": humidity_value,
+                "temp": ams_data.get("temp"),
+                "is_ams_ht": is_ams_ht,
+                "tray": trays,
+            })
+
+    # Parse virtual tray (external spool)
+    if "vt_tray" in raw_data:
+        vt_data = raw_data["vt_tray"]
+        vt_tag_uid = vt_data.get("tag_uid")
+        if vt_tag_uid in ("", "0000000000000000"):
+            vt_tag_uid = None
+        vt_tray = {
+            "id": 254,
+            "tray_color": vt_data.get("tray_color"),
+            "tray_type": vt_data.get("tray_type"),
+            "tray_sub_brands": vt_data.get("tray_sub_brands"),
+            "remain": vt_data.get("remain", 0),
+            "tag_uid": vt_tag_uid,
+        }
+
+    # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
+    ams_extruder_map = raw_data.get("ams_extruder_map", {})
+
     result = {
         "connected": state.connected,
         "state": state.state,
@@ -271,9 +351,20 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "total_layers": state.total_layers,
         "temperatures": state.temperatures,
         "hms_errors": [
-            {"code": e.code, "module": e.module, "severity": e.severity}
+            {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
             for e in (state.hms_errors or [])
         ],
+        # AMS data for filament colors
+        "ams": ams_units if ams_units else None,
+        "vt_tray": vt_tray,
+        # AMS status for filament change tracking
+        "ams_status_main": state.ams_status_main,
+        "ams_status_sub": state.ams_status_sub,
+        "tray_now": state.tray_now,
+        # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
+        "ams_extruder_map": ams_extruder_map,
+        # WiFi signal strength
+        "wifi_signal": state.wifi_signal,
     }
     # Add cover URL if there's an active print and printer_id is provided
     if printer_id and state.state == "RUNNING" and state.gcode_file:

+ 70 - 0
backend/app/services/smart_plug_manager.py

@@ -23,11 +23,81 @@ class SmartPlugManager:
     def __init__(self):
         self._pending_off: dict[int, asyncio.Task] = {}  # plug_id -> task
         self._loop: asyncio.AbstractEventLoop | None = None
+        self._scheduler_task: asyncio.Task | None = None
+        self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async operations."""
         self._loop = loop
 
+    def start_scheduler(self):
+        """Start the background scheduler for time-based plug control."""
+        if self._scheduler_task is None:
+            self._scheduler_task = asyncio.create_task(self._schedule_loop())
+            logger.info("Smart plug scheduler started")
+
+    def stop_scheduler(self):
+        """Stop the background scheduler."""
+        if self._scheduler_task:
+            self._scheduler_task.cancel()
+            self._scheduler_task = None
+            logger.info("Smart plug scheduler stopped")
+
+    async def _schedule_loop(self):
+        """Background loop that checks scheduled on/off times every minute."""
+        while True:
+            try:
+                await self._check_schedules()
+            except Exception as e:
+                logger.error(f"Error in schedule check: {e}")
+
+            # Wait until the next minute
+            await asyncio.sleep(60)
+
+    async def _check_schedules(self):
+        """Check all plugs for scheduled on/off times."""
+        from backend.app.core.database import async_session
+        from backend.app.models.smart_plug import SmartPlug
+
+        current_time = datetime.now().strftime("%H:%M")
+
+        async with async_session() as db:
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.enabled == True,
+                    SmartPlug.schedule_enabled == True,
+                )
+            )
+            plugs = result.scalars().all()
+
+            for plug in plugs:
+                # Check if we should turn on
+                if plug.schedule_on_time == current_time:
+                    last_check = self._last_schedule_check.get(plug.id)
+                    if last_check != f"on:{current_time}":
+                        logger.info(f"Schedule: Turning on plug '{plug.name}' at {current_time}")
+                        success = await tasmota_service.turn_on(plug)
+                        if success:
+                            plug.last_state = "ON"
+                            plug.last_checked = datetime.utcnow()
+                            self._last_schedule_check[plug.id] = f"on:{current_time}"
+
+                # Check if we should turn off
+                if plug.schedule_off_time == current_time:
+                    last_check = self._last_schedule_check.get(plug.id)
+                    if last_check != f"off:{current_time}":
+                        logger.info(f"Schedule: Turning off plug '{plug.name}' at {current_time}")
+                        success = await tasmota_service.turn_off(plug)
+                        if success:
+                            plug.last_state = "OFF"
+                            plug.last_checked = datetime.utcnow()
+                            self._last_schedule_check[plug.id] = f"off:{current_time}"
+                            # Mark printer offline if linked
+                            if plug.printer_id:
+                                printer_manager.mark_printer_offline(plug.printer_id)
+
+            await db.commit()
+
     async def _get_plug_for_printer(
         self, printer_id: int, db: AsyncSession
     ) -> "SmartPlug | None":

+ 322 - 0
docs/bambu_lab_preset_sync_api.md

@@ -0,0 +1,322 @@
+# Bambu Lab Preset Sync API Documentation
+
+This document describes the Bambu Lab cloud API endpoints for syncing slicer presets (filament, print process, and machine profiles) between Bambu Studio and the cloud.
+
+**Captured from:** Bambu Studio v2.4.0.70 with bambu_network_agent v02.04.00.58
+**Date:** 2025-12-08
+
+---
+
+## Authentication
+
+All API requests require authentication via Bearer token.
+
+### Required Headers
+
+```http
+Host: api.bambulab.com
+Authorization: Bearer <access_token>
+User-Agent: bambu_network_agent/02.04.00.58
+X-BBL-Client-Name: BambuStudio
+X-BBL-Client-Type: slicer
+X-BBL-Client-Version: 02.04.00.70
+X-BBL-Device-ID: <uuid>
+X-BBL-Language: en-US
+X-BBL-OS-Type: macos|windows|linux
+X-BBL-OS-Version: <version>
+X-BBL-Agent-Version: 02.04.00.58
+accept: application/json
+```
+
+---
+
+## Endpoints
+
+### 1. Get User Profile
+
+```http
+GET /v1/user-service/my/profile
+```
+
+Returns user account information including UID.
+
+### 2. List All User Presets
+
+```http
+GET /v1/iot-service/api/slicer/setting?version={slicer_version}&public=false
+```
+
+**Parameters:**
+- `version`: Slicer version (e.g., `2.4.0.5`)
+- `public`: Set to `false` for user presets only
+
+**Response:** Returns a list of preset IDs that the user has synced to cloud.
+
+### 3. Get Individual Preset
+
+```http
+GET /v1/iot-service/api/slicer/setting/{preset_id}
+```
+
+**Response:**
+```json
+{
+    "message": "success",
+    "code": null,
+    "error": null,
+    "public": false,
+    "version": "1.5.0.20",
+    "type": "filament",
+    "name": "Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle",
+    "update_time": "2025-12-08 01:06:27",
+    "nickname": null,
+    "base_id": "GFSA00",
+    "setting": {
+        "inherits": "Bambu PLA Basic @BBL X1C",
+        "filament_vendor": "\"Devil Design\"",
+        "nozzle_temperature": "225,220",
+        "pressure_advance": "0.03",
+        "updated_time": "1765138658"
+    },
+    "filament_id": null
+}
+```
+
+---
+
+## Preset ID Naming Convention
+
+Preset IDs follow a specific prefix pattern indicating the type:
+
+| Prefix | Type | Description |
+|--------|------|-------------|
+| `PPUS` | Print Process | Print/quality settings (layer height, speeds, infill, etc.) |
+| `PFUS` | Filament | Filament settings (temperatures, flow, pressure advance, etc.) |
+| `PMUS` | Printer/Machine | Machine settings (gcode, bed size, kinematics, etc.) |
+
+The suffix after the prefix is a unique hash identifier.
+
+**Examples:**
+- `PPUS1b03400426f57d` - Print process preset
+- `PFUS169056f3003bb4` - Filament preset
+- `PMUSbc396893c54df0` - Machine/printer preset
+
+---
+
+## Preset Response Schema
+
+### Common Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `message` | string | API response status ("success") |
+| `code` | int/null | Error code if any |
+| `error` | string/null | Error message if any |
+| `public` | boolean | Whether preset is publicly shared |
+| `version` | string | Preset version |
+| `type` | string | Preset type: "filament", "print", or "printer" |
+| `name` | string | Display name of the preset |
+| `update_time` | string | Last update timestamp (ISO format) |
+| `nickname` | string/null | Optional user-defined nickname |
+| `base_id` | string | Reference ID of the parent/base preset |
+| `setting` | object | Key-value pairs of customized settings |
+| `filament_id` | string/null | Bambu filament ID if applicable |
+
+### Setting Object
+
+The `setting` object contains **only the delta/modified values** from the parent preset. Key fields include:
+
+- `inherits`: Name of the parent preset this inherits from
+- `updated_time`: Unix timestamp of last modification
+- Other fields depend on preset type (see below)
+
+---
+
+## Preset Types and Common Settings
+
+### Filament Presets (PFUS)
+
+```json
+{
+    "inherits": "Bambu PLA Basic @BBL X1C",
+    "filament_vendor": "\"Devil Design\"",
+    "filament_cost": "20",
+    "filament_settings_id": "\"Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle\"",
+    "nozzle_temperature": "225,220",
+    "nozzle_temperature_initial_layer": "225,220",
+    "hot_plate_temp": "60",
+    "cool_plate_temp": "60",
+    "textured_plate_temp": "60",
+    "pressure_advance": "0.03",
+    "enable_pressure_advance": "1",
+    "filament_max_volumetric_speed": "30,29",
+    "activate_air_filtration": "1",
+    "during_print_exhaust_fan_speed": "50",
+    "complete_print_exhaust_fan_speed": "50",
+    "close_fan_the_first_x_layers": "2",
+    "overhang_fan_threshold": "10%",
+    "slow_down_layer_time": "5",
+    "temperature_vitrification": "65",
+    "filament_start_gcode": "...",
+    "filament_end_gcode": "..."
+}
+```
+
+### Print Process Presets (PPUS)
+
+```json
+{
+    "inherits": "0.08mm Extra Fine @BBL H2D",
+    "print_settings_id": "# 0.08mm Extra Fine @BBL H2D",
+    "prime_tower_max_speed": "100",
+    "prime_tower_rib_wall": "0",
+    "prime_tower_width": "20"
+}
+```
+
+### Machine/Printer Presets (PMUS)
+
+```json
+{
+    "inherits": "Bambu Lab H2D 0.4 nozzle",
+    "printer_settings_id": "# Bambu Lab H2D 0.4 nozzle",
+    "bed_custom_model": "/path/to/model.stl",
+    "machine_start_gcode": "...",
+    "machine_end_gcode": "...",
+    "change_filament_gcode": "...",
+    "printer_notes": "...",
+    "support_air_filtration": "1"
+}
+```
+
+---
+
+## Base ID Reference
+
+The `base_id` field references Bambu's internal preset database:
+
+| Prefix | Type |
+|--------|------|
+| `GF` | Generic Filament |
+| `GP` | Generic Print Process |
+| `GM` | Generic Machine |
+
+Examples:
+- `GFSA00` - Generic filament base
+- `GP136` - Generic print process base
+- `GM033` - Generic machine base (H2D)
+
+---
+
+## API Operations (Verified)
+
+### Create Preset
+
+```http
+POST /v1/iot-service/api/slicer/setting
+Content-Type: application/json
+
+{
+    "type": "filament",
+    "name": "My Custom PLA",
+    "version": "2.0.0.0",
+    "base_id": "GFSA00",
+    "setting": {
+        "inherits": "Bambu PLA Basic @BBL X1C",
+        "nozzle_temperature": "210,205",
+        "updated_time": "1733665800"
+    }
+}
+```
+
+**Required fields:**
+- `type`: "filament", "print", or "printer"
+- `name`: Display name
+- `version`: Version string (e.g., "2.0.0.0", "2.3.0.2")
+- `base_id`: Parent preset ID
+- `setting`: Object with modified values including `updated_time` (Unix timestamp)
+
+**Response:**
+```json
+{
+    "message": "success",
+    "code": null,
+    "error": null,
+    "setting_id": "PFUSe99f2ff04974b4",
+    "update_time": "2025-12-08 16:31:48"
+}
+```
+
+### Update Preset
+
+**Important:** The Bambu Cloud API does NOT support true updates via PUT/PATCH.
+
+- `PUT /v1/iot-service/api/slicer/setting/{preset_id}` returns **405 Method Not Allowed**
+- `PATCH /v1/iot-service/api/slicer/setting/{preset_id}` returns **500 Cloud database failed**
+
+**Workaround:** To "update" a preset:
+1. GET the existing preset details
+2. Merge your changes
+3. POST to create a new preset (returns new `setting_id`)
+4. DELETE the old preset
+
+This mimics how Bambu Studio handles preset updates.
+
+### Delete Preset
+
+```http
+DELETE /v1/iot-service/api/slicer/setting/{preset_id}
+```
+
+**Response:**
+```json
+{
+    "message": "success",
+    "code": null,
+    "error": null
+}
+```
+
+---
+
+## Related Endpoints
+
+### Slicer Resources
+
+```http
+GET /v1/iot-service/api/slicer/resource?slicer/plugins/cloud={version}
+GET /v1/iot-service/api/slicer/resource?slicer/printer/bbl={version}
+GET /v1/iot-service/api/slicer/resource?policy/privacy={version}
+```
+
+### User Print Status
+
+```http
+GET /v1/iot-service/api/user/print?force=true
+```
+
+### User Tasks
+
+```http
+GET /v1/user-service/my/tasks?limit=5&offset=0&status=0
+```
+
+### MQTT Certificate
+
+```http
+GET /v1/iot-service/api/user/applications/{app_id}/cert?aes256={encrypted_key}
+```
+
+---
+
+## Notes
+
+1. **Delta Storage**: Presets only store modified values from the parent, using the `inherits` field to reference the base preset.
+
+2. **Version Tracking**: The `updated_time` field (Unix timestamp) is used for sync conflict resolution.
+
+3. **Gcode Escaping**: Gcode fields use `\\n` for newlines within JSON strings.
+
+4. **Multi-value Fields**: Some fields like `nozzle_temperature` contain comma-separated values for different conditions.
+
+5. **Authentication**: The access token can be obtained via Bambu Lab OAuth flow or the `/v1/user-service/user/ticket/{code}` endpoint.

+ 11 - 28
frontend/package-lock.json

@@ -97,7 +97,6 @@
       "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.27.1",
         "@babel/generator": "^7.28.5",
@@ -381,7 +380,6 @@
       "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
       "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@dnd-kit/accessibility": "^3.1.1",
         "@dnd-kit/utilities": "^3.2.2",
@@ -1027,6 +1025,16 @@
         "@floating-ui/utils": "^0.2.10"
       }
     },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+      "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/core": "^1.7.3",
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
     "node_modules/@floating-ui/utils": {
       "version": "0.2.10",
       "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
@@ -1807,7 +1815,6 @@
       "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz",
       "integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==",
       "license": "MIT",
-      "peer": true,
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
@@ -2056,7 +2063,6 @@
       "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz",
       "integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==",
       "license": "MIT",
-      "peer": true,
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
@@ -2162,7 +2168,6 @@
       "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.11.1.tgz",
       "integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==",
       "license": "MIT",
-      "peer": true,
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
@@ -2189,7 +2194,6 @@
       "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz",
       "integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==",
       "license": "MIT",
-      "peer": true,
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
@@ -2204,7 +2208,6 @@
       "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz",
       "integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "prosemirror-changeset": "^2.3.0",
         "prosemirror-collab": "^1.3.1",
@@ -2449,7 +2452,6 @@
       "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "undici-types": "~7.16.0"
       }
@@ -2459,7 +2461,6 @@
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
       "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "csstype": "^3.2.2"
       }
@@ -2469,7 +2470,6 @@
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
       "license": "MIT",
-      "peer": true,
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
@@ -2553,7 +2553,6 @@
       "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@typescript-eslint/scope-manager": "8.48.0",
         "@typescript-eslint/types": "8.48.0",
@@ -2811,7 +2810,6 @@
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -2954,7 +2952,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.8.25",
         "caniuse-lite": "^1.0.30001754",
@@ -3390,7 +3387,6 @@
       "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.1",
@@ -3820,7 +3816,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@babel/runtime": "^7.28.4"
       },
@@ -4631,7 +4626,6 @@
       "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -4659,7 +4653,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "nanoid": "^3.3.11",
         "picocolors": "^1.1.1",
@@ -4804,7 +4797,6 @@
       "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
       "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "orderedmap": "^2.0.0"
       }
@@ -4834,7 +4826,6 @@
       "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
       "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "prosemirror-model": "^1.0.0",
         "prosemirror-transform": "^1.0.0",
@@ -4883,7 +4874,6 @@
       "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
       "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "prosemirror-model": "^1.20.0",
         "prosemirror-state": "^1.0.0",
@@ -4914,7 +4904,6 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
       "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -4924,7 +4913,6 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
       "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -4971,7 +4959,6 @@
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
       "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@types/use-sync-external-store": "^0.0.6",
         "use-sync-external-store": "^1.4.0"
@@ -5087,8 +5074,7 @@
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/redux-thunk": {
       "version": "3.1.0",
@@ -5359,7 +5345,6 @@
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "devOptional": true,
       "license": "Apache-2.0",
-      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -5489,7 +5474,6 @@
       "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.5.0",
@@ -5642,7 +5626,6 @@
       "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }

BIN
frontend/public/icons/ams-ht.png


File diff suppressed because it is too large
+ 1 - 0
frontend/public/icons/ams-settings.svg


+ 9 - 0
frontend/public/icons/ams-wiring-center.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 50">
+  <!-- Left wire: horizontal from left edge, then down to extruder left inlet -->
+  <line x1="0" y1="0" x2="10" y2="0" stroke="#909090" stroke-width="2" />
+  <line x1="10" y1="0" x2="10" y2="50" stroke="#909090" stroke-width="2" />
+
+  <!-- Right wire: horizontal from right edge, then down to extruder right inlet -->
+  <line x1="40" y1="0" x2="30" y2="0" stroke="#909090" stroke-width="2" />
+  <line x1="30" y1="0" x2="30" y2="50" stroke="#909090" stroke-width="2" />
+</svg>

+ 17 - 0
frontend/public/icons/ams-wiring-left.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 50">
+  <!-- Vertical lines from slots down to horizontal bar -->
+  <line x1="28" y1="0" x2="28" y2="14" stroke="#909090" stroke-width="2" />
+  <line x1="82" y1="0" x2="82" y2="14" stroke="#909090" stroke-width="2" />
+  <line x1="138" y1="0" x2="138" y2="14" stroke="#909090" stroke-width="2" />
+  <line x1="192" y1="0" x2="192" y2="14" stroke="#909090" stroke-width="2" />
+
+  <!-- Horizontal bar across all slots -->
+  <line x1="28" y1="14" x2="192" y2="14" stroke="#909090" stroke-width="2" />
+
+  <!-- Center hub box -->
+  <rect x="96" y="8" width="28" height="12" rx="2" fill="#c0c0c0" stroke="#909090" stroke-width="1" />
+
+  <!-- Wire from hub: down, then right to edge (at same level as hub horizontal bar) -->
+  <line x1="110" y1="20" x2="110" y2="35" stroke="#909090" stroke-width="2" />
+  <line x1="110" y1="35" x2="220" y2="35" stroke="#909090" stroke-width="2" />
+</svg>

+ 17 - 0
frontend/public/icons/ams-wiring-right.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 50">
+  <!-- Vertical lines from slots down to horizontal bar -->
+  <line x1="28" y1="0" x2="28" y2="14" stroke="#909090" stroke-width="2" />
+  <line x1="82" y1="0" x2="82" y2="14" stroke="#909090" stroke-width="2" />
+  <line x1="138" y1="0" x2="138" y2="14" stroke="#909090" stroke-width="2" />
+  <line x1="192" y1="0" x2="192" y2="14" stroke="#909090" stroke-width="2" />
+
+  <!-- Horizontal bar across all slots -->
+  <line x1="28" y1="14" x2="192" y2="14" stroke="#909090" stroke-width="2" />
+
+  <!-- Center hub box -->
+  <rect x="96" y="8" width="28" height="12" rx="2" fill="#c0c0c0" stroke="#909090" stroke-width="1" />
+
+  <!-- Wire from hub: down, then left to edge (at same level as hub horizontal bar) -->
+  <line x1="110" y1="20" x2="110" y2="35" stroke="#909090" stroke-width="2" />
+  <line x1="0" y1="35" x2="110" y2="35" stroke="#909090" stroke-width="2" />
+</svg>

BIN
frontend/public/icons/ams.png


+ 1 - 0
frontend/public/icons/chamber.svg

@@ -0,0 +1 @@
+<svg id="Layer_1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m15 12h-6c-1.103 0-2 .897-2 2v4c0 1.103.897 2 2 2h6c1.103 0 2-.897 2-2v-4c0-1.103-.897-2-2-2zm1 6c0 .552-.448 1-1 1h-6c-.551 0-1-.448-1-1v-4c0-.552.449-1 1-1h6c.552 0 1 .448 1 1zm-2.5-2.5c0 .276-.224.5-.5.5h-2c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h2c.276 0 .5.224.5.5zm6-13.5h-1.5v-1.5c0-.276-.224-.5-.5-.5s-.5.224-.5.5v1.5h-10v-1.5c0-.276-.224-.5-.5-.5s-.5.224-.5.5v1.5h-1.5c-2.481 0-4.5 2.019-4.5 4.5v13c0 2.481 2.019 4.5 4.5 4.5h15c2.481 0 4.5-2.019 4.5-4.5v-13c0-2.481-2.019-4.5-4.5-4.5zm-15 1h15c1.93 0 3.5 1.57 3.5 3.5v1.5h-22v-1.5c0-1.93 1.57-3.5 3.5-3.5zm15 20h-15c-1.93 0-3.5-1.57-3.5-3.5v-10.5h22v10.5c0 1.93-1.57 3.5-3.5 3.5z"/></svg>

BIN
frontend/public/icons/dual-extruder-left.png


BIN
frontend/public/icons/dual-extruder-right.png


BIN
frontend/public/icons/dual-extruder-right_sav.png


BIN
frontend/public/icons/dual-extruder.png


BIN
frontend/public/icons/extruder-change-filament.png


BIN
frontend/public/icons/extruder-left-right.png


+ 51 - 0
frontend/public/icons/eye.svg

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 511.999 511.999" style="enable-background:new 0 0 511.999 511.999;" xml:space="preserve">
+<g>
+	<g>
+		<path d="M508.745,246.041c-4.574-6.257-113.557-153.206-252.748-153.206S7.818,239.784,3.249,246.035
+			c-4.332,5.936-4.332,13.987,0,19.923c4.569,6.257,113.557,153.206,252.748,153.206s248.174-146.95,252.748-153.201
+			C513.083,260.028,513.083,251.971,508.745,246.041z M255.997,385.406c-102.529,0-191.33-97.533-217.617-129.418
+			c26.253-31.913,114.868-129.395,217.617-129.395c102.524,0,191.319,97.516,217.617,129.418
+			C447.361,287.923,358.746,385.406,255.997,385.406z"/>
+	</g>
+</g>
+<g>
+	<g>
+		<path d="M255.997,154.725c-55.842,0-101.275,45.433-101.275,101.275s45.433,101.275,101.275,101.275
+			s101.275-45.433,101.275-101.275S311.839,154.725,255.997,154.725z M255.997,323.516c-37.23,0-67.516-30.287-67.516-67.516
+			s30.287-67.516,67.516-67.516s67.516,30.287,67.516,67.516S293.227,323.516,255.997,323.516z"/>
+	</g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>

+ 1 - 0
frontend/public/icons/heatbed.svg

@@ -0,0 +1 @@
+<svg id="Layer_1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><g fill="rgb(0,0,0)"><path d="m6.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m12.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m18.3 3.9c.54-.4 1.2-.9 1.2-1.9 0-.28-.22-.5-.5-.5s-.5.22-.5.5c0 .48-.29.71-.8 1.1-.53.4-1.2.9-1.2 1.9s.67 1.5 1.2 1.9c.51.38.8.62.8 1.1s-.29.72-.8 1.1c-.54.4-1.2.9-1.2 1.9 0 .28.22.5.5.5s.5-.22.5-.5c0-.48.29-.72.8-1.1.54-.4 1.2-.9 1.2-1.9s-.67-1.5-1.2-1.9c-.51-.38-.8-.62-.8-1.1s.29-.72.8-1.1z"/><path d="m22 13.5c-1.07 0-1.61.65-2.05 1.18-.44.52-.71.82-1.29.82s-.85-.3-1.29-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.85-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18s-1.61.65-2.05 1.18c-.44.52-.71.82-1.28.82s-.84-.3-1.28-.82c-.44-.53-.98-1.18-2.05-1.18-.28 0-.5.22-.5.5s.22.5.5.5c.57 0 .84.3 1.28.82.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.28.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.28-.82s.85.3 1.29.82c.44.53.98 1.18 2.05 1.18s1.61-.65 2.05-1.18c.44-.52.71-.82 1.29-.82.28 0 .5-.22.5-.5s-.22-.5-.5-.5z"/><path d="m21 18.5h-18c-.83 0-1.5.67-1.5 1.5v1c0 .83.67 1.5 1.5 1.5h18c.83 0 1.5-.67 1.5-1.5v-1c0-.83-.67-1.5-1.5-1.5zm.5 2.5c0 .28-.22.5-.5.5h-18c-.28 0-.5-.22-.5-.5v-1c0-.28.22-.5.5-.5h18c.28 0 .5.22.5.5z"/></g></svg>

+ 44 - 0
frontend/public/icons/home.svg

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 476.912 476.912" style="enable-background:new 0 0 476.912 476.912;" xml:space="preserve">
+<g>
+	<g>
+		<path d="M461.776,209.408L249.568,4.52c-6.182-6.026-16.042-6.026-22.224,0L15.144,209.4c-3.124,3.015-4.888,7.17-4.888,11.512
+			c0,8.837,7.164,16,16,16h28.2v224c0,8.837,7.163,16,16,16h112c8.837,0,16-7.163,16-16v-128h80v128c0,8.837,7.163,16,16,16h112
+			c8.837,0,16-7.163,16-16v-224h28.2c4.338,0,8.489-1.761,11.504-4.88C468.301,225.678,468.129,215.549,461.776,209.408z
+			 M422.456,220.912c-8.837,0-16,7.163-16,16v224h-112v-128c0-8.837-7.163-16-16-16h-80c-8.837,0-16,7.163-16,16v128h-112v-224
+			c0-8.837-7.163-16-16-16h-28.2l212.2-204.88l212.28,204.88H422.456z"/>
+	</g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>

+ 6 - 0
frontend/public/icons/hotend.svg

@@ -0,0 +1,6 @@
+<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.4759 7.06822H13.7562L8.74263 11.2268C8.514 11.416 8.10431 11.416 7.87554 11.227C7.87545 11.2269 7.87536 11.2268 7.87528 11.2268L2.86167 7.06822H4.142C4.54877 7.06822 4.93613 6.94338 5.23267 6.71974C5.52893 6.49633 5.76004 6.14967 5.76004 5.72506V1.84316C5.76004 1.80403 5.78049 1.72911 5.88953 1.64688C5.99827 1.56488 6.16993 1.5 6.37809 1.5H10.2398C10.448 1.5 10.6196 1.56488 10.7284 1.64688C10.8374 1.72911 10.8579 1.80403 10.8579 1.84316V5.72506C10.8579 6.14967 11.089 6.49633 11.3852 6.71974C11.6818 6.94338 12.0691 7.06822 12.4759 7.06822ZM2.36979 7.09452C2.36773 7.09555 2.36658 7.096 2.36652 7.09597C2.36645 7.09594 2.36748 7.09542 2.36979 7.09452ZM14.2475 7.09456C14.2498 7.09545 14.2508 7.09596 14.2507 7.096C14.2507 7.09603 14.2495 7.09558 14.2475 7.09456Z" stroke="#6B6B6B"/>
+<path d="M3.80389 10.668C3.58699 10.7742 3.42822 10.9007 3.42822 11.0895C3.42822 11.673 4.95994 11.673 4.95994 12.2548C4.95994 12.8383 3.42822 12.8383 3.42822 13.42C3.42822 14.0035 4.95994 14.0035 4.95994 14.587C4.95994 15.1704 3.42822 15.1704 3.42822 15.7539" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M8.63467 14.1348C8.88288 14.2477 9.07518 14.381 9.07518 14.5867C9.07518 15.1702 7.54346 15.1702 7.54346 15.7536" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M11.893 11.4316C12.3223 11.7065 13.1899 11.8161 13.1899 12.2546C13.1899 12.838 11.6582 12.838 11.6582 13.4198C11.6582 14.0033 13.1899 14.0033 13.1899 14.5867C13.1899 15.1702 11.6582 15.1702 11.6582 15.7537" stroke="#6B6B6B" stroke-miterlimit="10" stroke-linecap="round"/>
+</svg>

+ 4 - 0
frontend/public/icons/humidity-empty.svg

@@ -0,0 +1,4 @@
+<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.8131 0.00537678C18.4463 -0.150913 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00537678ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#D0D0D0"/>
+<path d="M8 46C12 48 24 48 28 46C26 50 22 52 18 52C14 52 10 50 8 46Z" fill="#1F8FEB"/>
+</svg>

+ 4 - 0
frontend/public/icons/humidity-full.svg

@@ -0,0 +1,4 @@
+<svg width="36" height="54" viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
+<path d="M17.7948 0.00537678C18.4273 -0.150913 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0133678 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00537678ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#D0D0D0"/>
+</svg>

+ 4 - 0
frontend/public/icons/humidity-half.svg

@@ -0,0 +1,4 @@
+<svg width="35" height="53" viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.3165 0.00379674C17.932 -0.149588 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148377 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.00379674ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#D0D0D0"/>
+<path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
+</svg>

BIN
frontend/public/icons/jogpad.png


File diff suppressed because it is too large
+ 5 - 0
frontend/public/icons/jogpad.svg


+ 4 - 0
frontend/public/icons/lamp.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
+  <path d="m6.443,4.08L4.304.567,5.157.048l2.14,3.513-.854.52Zm13.557,7.92c0,2.323-1.01,4.528-2.771,6.051-.781.674-1.229,1.641-1.229,2.653v3.296h-8v-3.295c0-1.007-.456-1.982-1.252-2.675-2.062-1.796-3.058-4.497-2.661-7.227.512-3.521,3.457-6.36,7.003-6.753,2.307-.256,4.527.45,6.245,1.987,1.693,1.517,2.665,3.689,2.665,5.962Zm-5,8.704c0-.239.04-.471.077-.704h-6.156c.038.233.078.467.078.705v2.295h6v-2.296Zm4-8.704c0-1.988-.85-3.89-2.332-5.217-1.502-1.344-3.438-1.964-5.469-1.738-3.1.343-5.675,2.825-6.122,5.903-.348,2.391.522,4.757,2.327,6.328.553.481.963,1.076,1.234,1.724h2.861v-5.551c-1.14-.232-2-1.242-2-2.449h1c0,.827.673,1.5,1.5,1.5s1.5-.673,1.5-1.5h1c0,1.208-.86,2.217-2,2.449v5.551h2.856c.268-.645.672-1.234,1.218-1.706,1.542-1.332,2.426-3.262,2.426-5.294Zm.696-11.433l-.854-.52-2.14,3.513.854.52,2.14-3.513Zm3.86,4.342l-3.536,1.597.412.912,3.536-1.597-.412-.912ZM.031,5.821l3.536,1.597.412-.912L.443,4.909l-.412.912Z"/>
+</svg>

+ 12 - 0
frontend/public/icons/micro-sd.svg

@@ -0,0 +1,12 @@
+<?xml version='1.0' encoding='utf-8'?>
+<!-- Generator: imaengine 6.0   -->
+<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0,0,512,512" style="enable-background:new 0 0 512 512;" version="1.1">
+<defs/>
+<g id="layer0">
+<path d="M-0.00100857,91.001L-0.000996768,361.001C-0.000995864,381.679 16.821,398.501 37.499,398.501L154.393,398.501L212.196,456.305C213.603,457.711 215.51,458.501 217.5,458.501L277.5,458.501C281.643,458.501 285,455.144 285,451.001L285,428.501L334.394,428.501L362.197,456.305C363.604,457.711 365.511,458.501 367.501,458.501L474.501,458.501C495.179,458.501 512.001,441.679 512.001,421.001L512.001,91.001C512.001,70.323 495.179,53.501 474.501,53.501L37.501,53.501C16.821,53.501 -0.00100947,70.323 -0.00100857,91.001L-0.00100857,91.001ZM496.999,91.001L496.999,421.001C496.999,433.407 486.905,443.501 474.499,443.501L436.999,443.501L436.999,121.001C436.999,116.858 433.642,113.501 429.499,113.501C425.356,113.501 421.999,116.858 421.999,121.001L421.999,443.501L370.605,443.501L342.802,415.697C341.395,414.291 339.488,413.501 337.498,413.501L277.498,413.501C273.355,413.501 269.998,416.858 269.998,421.001L269.998,443.501L220.604,443.501L162.801,385.697C161.394,384.291 159.487,383.501 157.497,383.501L37.497,383.501C25.091,383.501 14.997,373.407 14.997,361.001L14.997,91.001C14.997,78.595 25.091,68.501 37.497,68.501L421.999,68.501L421.999,91.001C421.999,95.144 425.356,98.501 429.499,98.501C433.642,98.501 436.999,95.144 436.999,91.001L436.999,68.501L474.499,68.501C486.905,68.501 496.999,78.595 496.999,91.001L496.999,91.001Z" fill="#000000"/>
+<path d="M29.999,316.001L29.999,361.001C29.999,365.144 33.356,368.501 37.499,368.501L157.499,368.501C161.642,368.501 164.999,365.144 164.999,361.001L164.999,316.001C164.999,311.858 161.642,308.501 157.499,308.501L37.499,308.501C33.356,308.501 29.999,311.858 29.999,316.001L29.999,316.001ZM149.999,323.501L149.999,353.501L44.999,353.501L44.999,323.501L149.999,323.501Z" fill="#000000"/>
+<path d="M29.999,241.001L29.999,286.001C29.999,290.144 33.356,293.501 37.499,293.501L157.499,293.501C161.642,293.501 164.999,290.144 164.999,286.001L164.999,241.001C164.999,236.858 161.642,233.501 157.499,233.501L37.499,233.501C33.356,233.501 29.999,236.858 29.999,241.001L29.999,241.001ZM149.999,248.501L149.999,278.501L44.999,278.501L44.999,248.501L149.999,248.501Z" fill="#000000"/>
+<path d="M29.999,166.001L29.999,211.001C29.999,215.144 33.356,218.501 37.499,218.501L157.499,218.501C161.642,218.501 164.999,215.144 164.999,211.001L164.999,166.001C164.999,161.858 161.642,158.501 157.499,158.501L37.499,158.501C33.356,158.501 29.999,161.858 29.999,166.001L29.999,166.001ZM149.999,173.501L149.999,203.501L44.999,203.501L44.999,173.501L149.999,173.501Z" fill="#000000"/>
+<path d="M157.499,83.501L37.499,83.501C33.356,83.501 29.999,86.858 29.999,91.001L29.999,136.001C29.999,140.144 33.356,143.501 37.499,143.501L157.499,143.501C161.642,143.501 164.999,140.144 164.999,136.001L164.999,91.001C164.999,86.858 161.642,83.501 157.499,83.501L157.499,83.501ZM149.999,98.501L149.999,128.501L44.999,128.501L44.999,98.501L149.999,98.501Z" fill="#000000"/>
+</g>
+</svg>

+ 1 - 0
frontend/public/icons/reload.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m22 11a1 1 0 0 0 -1 1 9 9 0 1 1 -9-9 8.9 8.9 0 0 1 4.42 1.166l-1.127 1.127a1 1 0 0 0 .707 1.707h4a1 1 0 0 0 1-1v-4a1 1 0 0 0 -1.707-.707l-1.411 1.407a10.9 10.9 0 0 0 -5.882-1.7 11 11 0 1 0 11 11 1 1 0 0 0 -1-1z"/></svg>

File diff suppressed because it is too large
+ 0 - 0
frontend/public/icons/settings.svg


BIN
frontend/public/icons/single-extruder1.png


BIN
frontend/public/icons/single-extruder2.png


+ 1 - 0
frontend/public/icons/skip-objects.svg

@@ -0,0 +1 @@
+<svg id="Layer_1" height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m30 17-3 3-3-3h2c0-6.0654-4.9355-11-11-11s-11 4.9346-11 11h-2c0-7.168 5.832-13 13-13s13 5.832 13 13zm0 5h-6v2h6zm-10 0h-2v2h2zm-16 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2zm4 0h-2v2h2z"/></svg>

+ 53 - 0
frontend/public/icons/snowflake.svg

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 412.8 412.8" style="enable-background:new 0 0 412.8 412.8;" xml:space="preserve">
+<g>
+	<g>
+		<path d="M378.4,225.6L304,251.2L274,234v-27.6v-27.2l30-17.2l74.4,25.6c5.2,2,11.2-1.2,12.8-6.4c2-5.2-1.2-11.2-6.4-12.8
+			l-57.6-19.6l54-31.2c4.8-2.8,6.4-9.2,3.6-14c-2.8-4.8-9.2-6.4-14-3.6l-54,31.2l11.6-59.6c1.2-5.6-2.4-10.8-8-12
+			c-5.6-1.2-10.8,2.4-12,8l-15.2,77.2l-30,17.2l-22.8-13.2l-0.4-0.4l-23.2-13.6v-34.4L276,48.8c4.4-3.6,4.8-10,0.8-14.4
+			c-3.6-4.4-10-4.8-14.4-0.8l-45.6,40V10.4c0-5.6-4.4-10.4-10.4-10.4C200.8,0,196,4.4,196,10.4v62.4l-45.6-39.6
+			C146,29.6,139.6,30,136,34c-3.6,4.4-3.2,10.8,0.8,14.4L196,100v34.4L172.8,148l-23.2,13.6l-30-17.2l-15.2-77.2
+			c-1.2-5.6-6.4-9.2-12-8c-5.6,1.2-9.2,6.4-8,12L96,130.8L42,99.6c-4.8-2.8-11.2-1.2-14,3.6s-1.2,11.2,3.6,14l54,31.2L28,168
+			c-5.2,2-8.4,7.6-6.4,12.8s7.6,8.4,12.8,6.4l74.4-25.6l30,17.2v27.6v27.2h0.4l-30,17.2l-74.4-25.6c-5.2-2-11.2,1.2-12.8,6.4
+			c-2,5.2,1.2,11.2,6.4,12.8L86,264l-54,31.2c-4.8,2.8-6.4,9.2-3.6,14c2.8,4.8,9.2,6.4,14,3.6l54-31.2l-11.6,59.6
+			c-1.2,5.6,2.4,10.8,8,12c5.6,1.2,10.8-2.4,12-8L120,268l30-17.2l23.6,13.6l23.2,13.6v34.4L137.6,364c-4.4,3.6-4.8,10-0.8,14.4
+			c3.6,4.4,10,4.8,14.4,0.8l45.6-40v63.2c0,5.6,4.4,10.4,10.4,10.4c5.6,0,10.4-4.4,10.4-10.4V340l45.6,40c4.4,3.6,10.8,3.2,14.4-0.8
+			c3.6-4.4,3.2-10.8-0.8-14.4l-60-52v-34.4l23.2-13.6l23.2-13.6l30,17.2l15.2,77.2c1.2,5.6,6.4,9.2,12,8c5.6-1.2,9.2-6.4,8-12
+			L316.8,282l54,31.2c4.8,2.8,11.2,1.2,14-3.6c2.8-4.8,1.2-11.2-3.6-14l-54-31.2l57.6-19.6c5.2-2,8.4-7.6,6.4-12.8
+			C389.2,226.8,383.6,223.6,378.4,225.6z M252.4,206.4v27.2l-23.2,13.6l-22.8,13.2l-23.6-13.6l-23.2-13.6v-26.8v-27.2l23.2-13.6
+			L206,152l23.2,13.6l0.4,0.4l22.8,13.2V206.4z"/>
+	</g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>

+ 6 - 0
frontend/public/icons/speed.svg

@@ -0,0 +1,6 @@
+<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.4005 12.7763C17.649 12.8443 17.907 12.6982 17.9606 12.4463C18.1963 11.3405 18.1993 10.1959 17.9674 9.08583C17.7037 7.8233 17.1438 6.64152 16.3337 5.63787C15.5236 4.63422 14.4866 3.83744 13.3082 3.3132C12.1298 2.78897 10.8436 2.55228 9.55579 2.62265C8.26794 2.69302 7.01524 3.06843 5.90095 3.71795C4.78665 4.36746 3.84266 5.27248 3.14678 6.35842C2.45089 7.44436 2.02303 8.68012 1.89846 9.96387C1.78893 11.0926 1.91663 12.23 2.27134 13.3036C2.35217 13.5482 2.62452 13.6653 2.8641 13.5706V13.5706C3.10368 13.4759 3.21959 13.2053 3.14058 12.9601C2.83915 12.0245 2.73177 11.0355 2.82702 10.054C2.93731 8.91737 3.31613 7.82324 3.93226 6.86177C4.54839 5.9003 5.38418 5.09901 6.37076 4.52394C7.35733 3.94887 8.46645 3.61649 9.60669 3.55418C10.7469 3.49188 11.8857 3.70144 12.929 4.16559C13.9724 4.62974 14.8905 5.33519 15.6077 6.2238C16.3249 7.11242 16.8207 8.15875 17.0542 9.27658C17.2558 10.2419 17.2569 11.2367 17.0592 12.1995C17.0073 12.4519 17.152 12.7083 17.4005 12.7763V12.7763Z" fill="#323A3D"/>
+<path d="M15.4157 8.78647C15.593 8.71313 15.6782 8.50941 15.5948 8.33658C15.1923 7.50231 14.6033 6.76987 13.8715 6.19699C13.0484 5.5526 12.0726 5.13199 11.0389 4.97602C10.0053 4.82004 8.94883 4.93399 7.9722 5.30681C7.10396 5.63825 6.32502 6.16427 5.6943 6.84264C5.56365 6.98317 5.58494 7.20296 5.73272 7.32535V7.32535C5.88051 7.44775 6.09873 7.42629 6.23043 7.28674C6.78398 6.70015 7.46373 6.2447 8.22002 5.956C9.08471 5.62591 10.0201 5.52502 10.9353 5.66312C11.8504 5.80122 12.7144 6.17362 13.4432 6.74415C14.0806 7.24316 14.5957 7.8789 14.9515 8.60271C15.0362 8.77491 15.2383 8.85981 15.4157 8.78647V8.78647Z" fill="#323A3D"/>
+<path d="M13.7871 9.75159L10.6357 14.0831" stroke="#323A3D" stroke-width="1.04628" stroke-linecap="round"/>
+<circle cx="10.1926" cy="14.5735" r="1.32935" fill="#323A3D"/>
+</svg>

+ 4 - 0
frontend/public/icons/temperature.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
+  <path d="m12.5,15.051V5h-1v10.051c-1.14.232-2,1.242-2,2.449,0,1.379,1.121,2.5,2.5,2.5s2.5-1.121,2.5-2.5c0-1.208-.86-2.217-2-2.449Zm-.5,3.949c-.827,0-1.5-.673-1.5-1.5s.673-1.5,1.5-1.5,1.5.673,1.5,1.5-.673,1.5-1.5,1.5Zm4.5-6.181V4.5c0-2.481-2.019-4.5-4.5-4.5s-4.5,2.019-4.5,4.5v8.319c-1.627,1.561-2.32,3.805-1.859,6.049.508,2.472,2.506,4.476,4.972,4.987.459.096.92.143,1.376.143,1.495,0,2.942-.503,4.111-1.454,1.525-1.241,2.4-3.08,2.4-5.044,0-1.763-.727-3.456-2-4.681Zm-1.031,8.949c-1.292,1.05-2.989,1.454-4.653,1.108-2.081-.432-3.767-2.124-4.194-4.21-.405-1.968.235-3.933,1.713-5.258l.166-.148V4.5c0-1.93,1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5v8.761l.166.148c1.166,1.046,1.834,2.537,1.834,4.091,0,1.662-.74,3.218-2.031,4.269Z"/>
+</svg>

+ 6 - 0
frontend/public/icons/ventilation.svg

@@ -0,0 +1,6 @@
+<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.6444 8.72819C12.3737 7.93152 12.8358 6.88333 12.8815 5.7196C12.9537 3.86391 11.9465 2.21641 10.4205 1.38122C10.2905 1.31022 10.1557 1.24523 10.0186 1.18506C9.74417 1.07796 9.44933 1.01417 9.13884 1.00214C7.64057 0.943171 6.37816 2.1105 6.3204 3.60878C6.2867 4.48608 6.67301 5.28155 7.29879 5.80264C7.34813 5.83754 7.39507 5.87485 7.442 5.91336C7.92097 6.31169 8.25913 6.87129 8.37587 7.5043C8.67793 7.40923 9.00045 7.3635 9.335 7.37673C10.317 7.41765 11.1642 7.95077 11.6444 8.72819Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M6.3842 10.1074C6.39743 9.77525 6.46723 9.45995 6.58276 9.16752C5.57669 8.98339 4.50443 9.12179 3.52844 9.63445C1.88455 10.4997 0.961517 12.1954 1.00123 13.9343C1.00484 14.0823 1.01567 14.2316 1.03252 14.3808C1.07705 14.6708 1.16971 14.9597 1.31412 15.234C2.01212 16.5602 3.65481 17.0705 4.98099 16.3713C5.75721 15.9621 6.25422 15.2304 6.39142 14.4265C6.39743 14.3664 6.40586 14.3062 6.41669 14.2472C6.53583 13.5564 6.91972 12.9174 7.52024 12.4926C6.79938 11.9523 6.34689 11.0774 6.3842 10.1074Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M16.7327 11.0132C15.9902 10.545 15.1081 10.4813 14.3427 10.7641C14.2874 10.7881 14.232 10.811 14.1754 10.8327C13.4895 11.0854 12.7097 11.0601 12.0213 10.7147C11.8083 11.9254 10.8347 12.8592 9.62646 13.0313C9.94778 14.0747 10.6277 15.0134 11.6242 15.6416C13.1959 16.632 15.1262 16.5839 16.6124 15.6801C16.7388 15.6031 16.8627 15.5188 16.9831 15.4298C17.2129 15.2457 17.4163 15.0218 17.5812 14.7595C18.3815 13.4898 18.0012 11.8135 16.7327 11.0132Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
+<path d="M9.20391 11.3717C9.76553 11.3717 10.2208 10.9164 10.2208 10.3548C10.2208 9.79317 9.76553 9.33789 9.20391 9.33789C8.64229 9.33789 8.18701 9.79317 8.18701 10.3548C8.18701 10.9164 8.64229 11.3717 9.20391 11.3717Z" stroke="#00AE42" stroke-miterlimit="10" stroke-linecap="round"/>
+</svg>

+ 1 - 0
frontend/public/icons/video-camera.svg

@@ -0,0 +1 @@
+<svg height="472pt" viewBox="0 -87 472 472" width="472pt" xmlns="http://www.w3.org/2000/svg"><path d="m467.101562 26.527344c-3.039062-1.800782-6.796874-1.871094-9.898437-.179688l-108.296875 59.132813v-35.480469c-.03125-27.601562-22.398438-49.96875-50-50h-248.90625c-27.601562.03125-49.96875 22.398438-50 50v197.421875c.03125 27.601563 22.398438 49.96875 50 50h248.90625c27.601562-.03125 49.96875-22.398437 50-50v-34.835937l108.300781 59.132812c3.097657 1.691406 6.859375 1.625 9.894531-.175781 3.039063-1.804688 4.898438-5.074219 4.898438-8.601563v-227.816406c0-3.53125-1.863281-6.796875-4.898438-8.597656zm-138.203124 220.898437c-.015626 16.5625-13.4375 29.980469-30 30h-248.898438c-16.5625-.019531-29.980469-13.4375-30-30v-197.425781c.019531-16.558594 13.4375-29.980469 30-30h248.90625c16.558594.019531 29.980469 13.441406 30 30zm123.101562-1.335937-103.09375-56.289063v-81.535156l103.09375-56.285156zm0 0"/></svg>

+ 2 - 0
frontend/public/icons/water.svg

@@ -0,0 +1,2 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512"><g id="Water"><path d="M24,46A16.0183,16.0183,0,0,1,8,30C8,16.0942,22.708,2.8125,23.3345,2.2539a.9983.9983,0,0,1,1.331,0C25.292,2.8125,40,16.0942,40,30A16.0183,16.0183,0,0,1,24,46ZM24,4.3721C21.1333,7.1372,10,18.6118,10,30a14,14,0,0,0,28,0C38,18.6118,26.8667,7.1372,24,4.3721Z"/><path d="M18.4976,40.5273a.9946.9946,0,0,1-.5-.1342A12.0449,12.0449,0,0,1,12,30a1,1,0,0,1,2,0,10.0373,10.0373,0,0,0,5,8.6616,1,1,0,0,1-.5019,1.8657Z"/></g></svg>

+ 73 - 0
frontend/public/icons/webcam.svg

@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
+<g>
+	<g>
+		<path d="M256,40c-5.52,0-10,4.48-10,10s4.48,10,10,10s10-4.48,10-10S261.52,40,256,40z"/>
+	</g>
+</g>
+<g>
+	<g>
+		<path d="M466,210C466,94.206,371.794,0,256,0S46,94.206,46,210c0,96.488,66.579,180.855,159.516,203.859
+			c-1.591,14.119-6.958,31.441-13.568,38.051l-0.131,0.131c-18.899,0.353-32.638,3.149-42.999,8.73
+			C133.677,468.949,126,482.82,126,502c0,5.522,4.478,10,10,10h240c5.522,0,10-4.478,10-10c0-19.187-7.68-33.058-22.824-41.229
+			c-10.344-5.58-24.082-8.378-42.992-8.731l-0.132-0.132c-6.61-6.609-11.977-23.931-13.568-38.05
+			C399.423,390.853,466,306.486,466,210z M316,472c33.23,0,45.303,7.689,48.794,20H147.226c2.172-7.762,6.862-11.345,11.087-13.626
+			C166.274,474.085,178.603,472,196,472H316z M215.517,452c5.068-10.601,8.238-23.466,9.638-34.27
+			C235.326,419.232,245.658,420,256,420c10.342,0,20.674-0.768,30.845-2.27c1.401,10.804,4.57,23.67,9.638,34.27H215.517z
+			 M294.015,396.179c-0.019,0.004-0.037,0.007-0.056,0.011c-24.788,5.056-51.127,5.057-75.922-0.001
+			c-0.017-0.004-0.035-0.007-0.052-0.01C129.918,378.227,66,299.929,66,210c0-104.767,85.233-190,190-190s190,85.233,190,190
+			C446,299.929,382.082,378.227,294.015,396.179z"/>
+	</g>
+</g>
+<g>
+	<g>
+		<path d="M389.606,104.994c-23.072-29.303-55.544-50.505-91.434-59.701c-5.355-1.374-10.799,1.855-12.17,7.205
+			c-1.37,5.35,1.855,10.798,7.205,12.169c31.66,8.112,60.314,26.828,80.686,52.7c3.426,4.352,9.716,5.077,14.043,1.67
+			C392.275,115.621,393.023,109.333,389.606,104.994z"/>
+	</g>
+</g>
+<g>
+	<g>
+		<path d="M256,100c-60.654,0-110,49.346-110,110s49.346,110,110,110s110-49.346,110-110S316.654,100,256,100z M256,300
+			c-49.626,0-90-40.374-90-90c0-49.626,40.374-90,90-90c49.626,0,90,40.374,90,90C346,259.626,305.626,300,256,300z"/>
+	</g>
+</g>
+<g>
+	<g>
+		<path d="M256,140c-38.598,0-70,31.402-70,70c0,38.598,31.402,70,70,70c38.598,0,70-31.402,70-70C326,171.402,294.598,140,256,140z
+			 M256,260c-27.57,0-50-22.43-50-50s22.43-50,50-50s50,22.43,50,50S283.57,260,256,260z"/>
+	</g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>

BIN
frontend/public/img/printers/a1.png


BIN
frontend/public/img/printers/a1f.png


BIN
frontend/public/img/printers/a1mini.png


BIN
frontend/public/img/printers/default.png


BIN
frontend/public/img/printers/h2d.png


BIN
frontend/public/img/printers/o1c.png


BIN
frontend/public/img/printers/o1e.png


BIN
frontend/public/img/printers/o1s.png


BIN
frontend/public/img/printers/p1p.png


BIN
frontend/public/img/printers/p1s.png


BIN
frontend/public/img/printers/printer_placeholder.png


BIN
frontend/public/img/printers/x1c.png


BIN
frontend/public/img/printers/x1e.png


+ 4 - 0
frontend/src/App.tsx

@@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
+import { CameraPage } from './pages/CameraPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -34,6 +35,9 @@ function App() {
           <WebSocketProvider>
             <BrowserRouter>
               <Routes>
+                {/* Camera page - standalone, no layout */}
+                <Route path="/camera/:printerId" element={<CameraPage />} />
+
                 <Route path="/" element={<Layout />}>
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />

+ 438 - 5
frontend/src/api/client.ts

@@ -28,6 +28,7 @@ export interface Printer {
   ip_address: string;
   access_code: string;
   model: string | null;
+  location: string | null;  // Group/location name
   nozzle_count: number;  // 1 or 2, auto-detected from MQTT
   is_active: boolean;
   auto_archive: boolean;
@@ -37,10 +38,59 @@ export interface Printer {
 
 export interface HMSError {
   code: string;
+  attr: number;  // Attribute value for constructing wiki URL
   module: number;
   severity: number;  // 1=fatal, 2=serious, 3=common, 4=info
 }
 
+export interface AMSTray {
+  id: number;
+  tray_color: string | null;
+  tray_type: string | null;
+  tray_sub_brands: string | null;  // Full name like "PLA Basic", "PETG HF"
+  tray_id_name: string | null;  // Bambu filament ID like "A00-Y2" (can decode to color)
+  tray_info_idx: string | null;  // Filament preset ID like "GFA00" - maps to cloud setting_id
+  remain: number;
+  k: number | null;  // Pressure advance value
+  tag_uid: string | null;  // RFID tag UID (any tag)
+  tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
+  nozzle_temp_min: number | null;  // Min nozzle temperature
+  nozzle_temp_max: number | null;  // Max nozzle temperature
+}
+
+export interface AMSUnit {
+  id: number;
+  humidity: number | null;
+  temp: number | null;
+  is_ams_ht: boolean;  // True for AMS-HT (single spool), False for regular AMS (4 spools)
+  tray: AMSTray[];
+}
+
+export interface NozzleInfo {
+  nozzle_type: string;  // "stainless_steel" or "hardened_steel"
+  nozzle_diameter: string;  // e.g., "0.4"
+}
+
+export interface PrintOptions {
+  // Core AI detectors
+  spaghetti_detector: boolean;
+  print_halt: boolean;
+  halt_print_sensitivity: string;  // "low", "medium", "high" - spaghetti sensitivity
+  first_layer_inspector: boolean;
+  printing_monitor: boolean;
+  buildplate_marker_detector: boolean;
+  allow_skip_parts: boolean;
+  // Additional AI detectors (decoded from cfg bitmask)
+  nozzle_clumping_detector: boolean;
+  nozzle_clumping_sensitivity: string;  // "low", "medium", "high"
+  pileup_detector: boolean;
+  pileup_sensitivity: string;  // "low", "medium", "high"
+  airprint_detector: boolean;
+  airprint_sensitivity: string;  // "low", "medium", "high"
+  auto_recovery_step_loss: boolean;
+  filament_tangle_detect: boolean;
+}
+
 export interface PrinterStatus {
   id: number;
   name: string;
@@ -58,10 +108,51 @@ export interface PrinterStatus {
     bed_target?: number;
     nozzle?: number;
     nozzle_target?: number;
+    nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)
+    nozzle_2_target?: number;
     chamber?: number;
   } | null;
   cover_url: string | null;
   hms_errors: HMSError[];
+  ams: AMSUnit[];
+  ams_exists: boolean;
+  vt_tray: AMSTray | null;  // Virtual tray / external spool
+  sdcard: boolean;  // SD card inserted
+  store_to_sdcard: boolean;  // Store sent files on SD card
+  timelapse: boolean;  // Timelapse recording active
+  ipcam: boolean;  // Live view enabled
+  wifi_signal: number | null;  // WiFi signal strength in dBm
+  nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
+  print_options: PrintOptions | null;  // AI detection and print options
+  // Calibration stage tracking
+  stg_cur: number;  // Current stage number (-1 = not calibrating)
+  stg_cur_name: string | null;  // Human-readable current stage name
+  stg: number[];  // List of stage numbers in calibration sequence
+  // Air conditioning mode (0=cooling, 1=heating)
+  airduct_mode: number;
+  // Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
+  speed_level: number;
+  // Chamber light on/off
+  chamber_light: boolean;
+  // Active extruder for dual nozzle (0=right, 1=left)
+  active_extruder: number;
+  // AMS mapping - which AMS is connected to which nozzle
+  // Format: [ams_id_for_nozzle0, ams_id_for_nozzle1, ...] where -1 means no AMS
+  ams_mapping: number[];
+  // Per-AMS extruder mapping - extracted from each AMS unit's info field
+  // Format: {ams_id: extruder_id} where extruder 0=right, 1=left
+  // Note: JSON keys are always strings
+  ams_extruder_map: Record<string, number>;
+  // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
+  tray_now: number;
+  // AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
+  ams_status_main: number;
+  // AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge
+  ams_status_sub: number;
+  // mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
+  mc_print_sub_stage: number;
+  // Timestamp of last AMS data update (for RFID refresh detection)
+  last_ams_update: number;
 }
 
 export interface PrinterCreate {
@@ -70,6 +161,7 @@ export interface PrinterCreate {
   ip_address: string;
   access_code: string;
   model?: string;
+  location?: string;
   auto_archive?: boolean;
 }
 
@@ -155,6 +247,16 @@ export interface AppSettings {
   energy_tracking_mode: 'print' | 'total';
   check_updates: boolean;
   notification_language: string;
+  // AMS threshold settings
+  ams_humidity_good: number;  // <= this is green
+  ams_humidity_fair: number;  // <= this is orange, > is red
+  ams_temp_good: number;      // <= this is green/blue
+  ams_temp_fair: number;      // <= this is orange, > is red
+  // Date/time format settings
+  date_format: 'system' | 'us' | 'eu' | 'iso';
+  time_format: 'system' | '12h' | '24h';
+  // Default printer
+  default_printer_id: number | null;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -186,6 +288,63 @@ export interface SlicerSettingsResponse {
   process: SlicerSetting[];
 }
 
+export interface SlicerSettingDetail {
+  message?: string | null;
+  code?: string | null;
+  error?: string | null;
+  public: boolean;
+  version?: string | null;
+  type: string;
+  name: string;
+  update_time?: string | null;
+  nickname?: string | null;
+  base_id?: string | null;
+  setting: Record<string, unknown>;
+  filament_id?: string | null;
+  setting_id?: string | null;
+}
+
+export interface SlicerSettingCreate {
+  type: string;  // 'filament', 'print', or 'printer'
+  name: string;
+  base_id: string;
+  setting: Record<string, unknown>;
+}
+
+export interface SlicerSettingUpdate {
+  name?: string;
+  setting?: Record<string, unknown>;
+}
+
+export interface SlicerSettingDeleteResponse {
+  success: boolean;
+  message: string;
+}
+
+export interface FieldOption {
+  value: string;
+  label: string;
+}
+
+export interface FieldDefinition {
+  key: string;
+  label: string;
+  type: 'text' | 'number' | 'boolean' | 'select';
+  category: string;
+  description?: string;
+  options?: FieldOption[];
+  unit?: string;
+  min?: number;
+  max?: number;
+  step?: number;
+}
+
+export interface FieldDefinitionsResponse {
+  version: string;
+  description: string;
+  fields: FieldDefinition[];
+}
+
 export interface CloudDevice {
   dev_id: string;
   name: string;
@@ -208,6 +367,16 @@ export interface SmartPlug {
   off_temp_threshold: number;
   username: string | null;
   password: string | null;
+  // Power alerts
+  power_alert_enabled: boolean;
+  power_alert_high: number | null;
+  power_alert_low: number | null;
+  power_alert_last_triggered: string | null;
+  // Schedule
+  schedule_enabled: boolean;
+  schedule_on_time: string | null;
+  schedule_off_time: string | null;
+  // Status
   last_state: string | null;
   last_checked: string | null;
   auto_off_executed: boolean;  // True when auto-off was triggered after print
@@ -227,6 +396,14 @@ export interface SmartPlugCreate {
   off_temp_threshold?: number;
   username?: string | null;
   password?: string | null;
+  // Power alerts
+  power_alert_enabled?: boolean;
+  power_alert_high?: number | null;
+  power_alert_low?: number | null;
+  // Schedule
+  schedule_enabled?: boolean;
+  schedule_on_time?: string | null;
+  schedule_off_time?: string | null;
 }
 
 export interface SmartPlugUpdate {
@@ -241,6 +418,14 @@ export interface SmartPlugUpdate {
   off_temp_threshold?: number;
   username?: string | null;
   password?: string | null;
+  // Power alerts
+  power_alert_enabled?: boolean;
+  power_alert_high?: number | null;
+  power_alert_low?: number | null;
+  // Schedule
+  schedule_enabled?: boolean;
+  schedule_on_time?: string | null;
+  schedule_off_time?: string | null;
 }
 
 export interface SmartPlugEnergy {
@@ -285,6 +470,7 @@ export interface PrintQueueItem {
   archive_name?: string | null;
   archive_thumbnail?: string | null;
   printer_name?: string | null;
+  print_time_seconds?: number | null;  // Estimated print time from archive
 }
 
 export interface PrintQueueItemCreate {
@@ -359,8 +545,45 @@ export interface KProfilesResponse {
   nozzle_diameter: string;
 }
 
+export interface KProfileNote {
+  setting_id: string;
+  note: string;
+}
+
+export interface KProfileNotesResponse {
+  notes: Record<string, string>;  // setting_id -> note
+}
+
+// Slot Preset Mapping
+export interface SlotPresetMapping {
+  ams_id: number;
+  tray_id: number;
+  preset_id: string;
+  preset_name: string;
+}
+
+// Filament types
+export interface Filament {
+  id: number;
+  name: string;
+  type: string;  // PLA, PETG, ABS, etc.
+  brand: string | null;
+  color: string | null;
+  color_hex: string | null;
+  cost_per_kg: number;
+  spool_weight_g: number;
+  currency: string;
+  density: number | null;
+  print_temp_min: number | null;
+  print_temp_max: number | null;
+  bed_temp_min: number | null;
+  bed_temp_max: number | null;
+  created_at: string;
+  updated_at: string;
+}
+
 // Notification Provider types
-export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook';
 
 export interface NotificationProvider {
   id: number;
@@ -383,6 +606,9 @@ export interface NotificationProvider {
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
   quiet_hours_end: string | null;
+  // Daily digest
+  daily_digest_enabled: boolean;
+  daily_digest_time: string | null;
   // Printer filter
   printer_id: number | null;
   // Status tracking
@@ -414,6 +640,9 @@ export interface NotificationProviderCreate {
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_end?: string | null;
+  // Daily digest
+  daily_digest_enabled?: boolean;
+  daily_digest_time?: string | null;
   // Printer filter
   printer_id?: number | null;
 }
@@ -438,6 +667,9 @@ export interface NotificationProviderUpdate {
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_end?: string | null;
+  // Daily digest
+  daily_digest_enabled?: boolean;
+  daily_digest_time?: string | null;
   // Printer filter
   printer_id?: number | null;
 }
@@ -485,6 +717,64 @@ export interface EmailConfig {
   use_tls?: boolean;
 }
 
+// Notification Template types
+export interface NotificationTemplate {
+  id: number;
+  event_type: string;
+  name: string;
+  title_template: string;
+  body_template: string;
+  is_default: boolean;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface NotificationTemplateUpdate {
+  title_template?: string;
+  body_template?: string;
+}
+
+export interface EventVariablesResponse {
+  event_type: string;
+  event_name: string;
+  variables: string[];
+}
+
+export interface TemplatePreviewRequest {
+  event_type: string;
+  title_template: string;
+  body_template: string;
+}
+
+export interface TemplatePreviewResponse {
+  title: string;
+  body: string;
+}
+
+// Notification Log types
+export interface NotificationLogEntry {
+  id: number;
+  provider_id: number;
+  provider_name: string | null;
+  provider_type: string | null;
+  event_type: string;
+  title: string;
+  message: string;
+  success: boolean;
+  error_message: string | null;
+  printer_id: number | null;
+  printer_name: string | null;
+  created_at: string;
+}
+
+export interface NotificationLogStats {
+  total: number;
+  success_count: number;
+  failure_count: number;
+  by_event_type: Record<string, number>;
+  by_provider: Record<string, number>;
+}
+
 // Spoolman types
 export interface SpoolmanStatus {
   enabled: boolean;
@@ -529,6 +819,7 @@ export interface MaintenanceType {
   name: string;
   description: string | null;
   default_interval_hours: number;
+  interval_type: 'hours' | 'days';  // "hours" = print hours, "days" = calendar days
   icon: string | null;
   is_system: boolean;
   created_at: string;
@@ -538,6 +829,7 @@ export interface MaintenanceTypeCreate {
   name: string;
   description?: string | null;
   default_interval_hours?: number;
+  interval_type?: 'hours' | 'days';
   icon?: string | null;
 }
 
@@ -549,10 +841,13 @@ export interface MaintenanceStatus {
   maintenance_type_name: string;
   maintenance_type_icon: string | null;
   enabled: boolean;
-  interval_hours: number;
+  interval_hours: number;  // For hours type: print hours; for days type: number of days
+  interval_type: 'hours' | 'days';
   current_hours: number;
   hours_since_maintenance: number;
   hours_until_due: number;
+  days_since_maintenance: number | null;  // For days type
+  days_until_due: number | null;  // For days type
   is_due: boolean;
   is_warning: boolean;
   last_performed_at: string | null;
@@ -854,6 +1149,23 @@ export const api = {
     }),
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
+  exportBackup: async () => {
+    const response = await fetch(`${API_BASE}/settings/backup`);
+    return response.json();
+  },
+  importBackup: async (file: File) => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/settings/restore`, {
+      method: 'POST',
+      body: formData,
+    });
+    return response.json() as Promise<{
+      success: boolean;
+      message: string;
+      restored?: { settings: number; notification_providers: number; smart_plugs: number };
+    }>;
+  },
   checkFfmpeg: () =>
     request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
 
@@ -876,11 +1188,29 @@ export const api = {
     }),
   cloudLogout: () =>
     request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
-  getCloudSettings: (version = '01.09.00.00') =>
+  getCloudSettings: (version = '02.04.00.70') =>
     request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
   getCloudSettingDetail: (settingId: string) =>
-    request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
+    request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
+  createCloudSetting: (data: SlicerSettingCreate) =>
+    request<SlicerSettingDetail>('/cloud/settings', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateCloudSetting: (settingId: string, data: SlicerSettingUpdate) =>
+    request<SlicerSettingDetail>(`/cloud/settings/${settingId}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteCloudSetting: (settingId: string) =>
+    request<SlicerSettingDeleteResponse>(`/cloud/settings/${settingId}`, {
+      method: 'DELETE',
+    }),
   getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
+  getCloudFields: (presetType: 'filament' | 'print' | 'process' | 'printer') =>
+    request<FieldDefinitionsResponse>(`/cloud/fields/${presetType}`),
+  getAllCloudFields: () =>
+    request<Record<string, FieldDefinitionsResponse>>('/cloud/fields'),
 
   // Smart Plugs
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
@@ -954,6 +1284,43 @@ export const api = {
       method: 'DELETE',
       body: JSON.stringify(profile),
     }),
+  setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, {
+      method: 'POST',
+      body: JSON.stringify(profiles),
+    }),
+
+  // K-Profile Notes (stored locally, not on printer)
+  getKProfileNotes: (printerId: number) =>
+    request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),
+  setKProfileNote: (printerId: number, settingId: string, note: string) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes`, {
+      method: 'PUT',
+      body: JSON.stringify({ setting_id: settingId, note }),
+    }),
+  deleteKProfileNote: (printerId: number, settingId: string) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, {
+      method: 'DELETE',
+    }),
+
+  // Slot Preset Mappings
+  getSlotPresets: (printerId: number) =>
+    request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
+  getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
+    request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
+  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) =>
+    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}`, {
+      method: 'PUT',
+    }),
+  deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
+    request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
+      method: 'DELETE',
+    }),
+
+  // Filaments
+  listFilaments: () => request<Filament[]>('/filaments/'),
+  getFilament: (id: number) => request<Filament>(`/filaments/${id}`),
+  getFilamentsByType: (type: string) => request<Filament[]>(`/filaments/by-type/${type}`),
 
   // Notification Providers
   getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
@@ -977,6 +1344,64 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  testAllNotificationProviders: () =>
+    request<{
+      tested: number;
+      success: number;
+      failed: number;
+      results: Array<{
+        provider_id: number;
+        provider_name: string;
+        provider_type: string;
+        success: boolean;
+        message: string;
+      }>;
+    }>('/notifications/test-all', { method: 'POST' }),
+
+  // Notification Templates
+  getNotificationTemplates: () => request<NotificationTemplate[]>('/notification-templates'),
+  getNotificationTemplate: (id: number) => request<NotificationTemplate>(`/notification-templates/${id}`),
+  updateNotificationTemplate: (id: number, data: NotificationTemplateUpdate) =>
+    request<NotificationTemplate>(`/notification-templates/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  resetNotificationTemplate: (id: number) =>
+    request<NotificationTemplate>(`/notification-templates/${id}/reset`, {
+      method: 'POST',
+    }),
+  getTemplateVariables: () => request<EventVariablesResponse[]>('/notification-templates/variables'),
+  previewTemplate: (data: TemplatePreviewRequest) =>
+    request<TemplatePreviewResponse>('/notification-templates/preview', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
+  // Notification Logs
+  getNotificationLogs: (params?: {
+    limit?: number;
+    offset?: number;
+    provider_id?: number;
+    event_type?: string;
+    success?: boolean;
+    days?: number;
+  }) => {
+    const searchParams = new URLSearchParams();
+    if (params?.limit) searchParams.set('limit', String(params.limit));
+    if (params?.offset) searchParams.set('offset', String(params.offset));
+    if (params?.provider_id) searchParams.set('provider_id', String(params.provider_id));
+    if (params?.event_type) searchParams.set('event_type', params.event_type);
+    if (params?.success !== undefined) searchParams.set('success', String(params.success));
+    if (params?.days) searchParams.set('days', String(params.days));
+    return request<NotificationLogEntry[]>(`/notifications/logs?${searchParams}`);
+  },
+  getNotificationLogStats: (days = 7) =>
+    request<NotificationLogStats>(`/notifications/logs/stats?days=${days}`),
+  clearNotificationLogs: (olderThanDays = 30) =>
+    request<{ deleted: number; message: string }>(
+      `/notifications/logs?older_than_days=${olderThanDays}`,
+      { method: 'DELETE' }
+    ),
 
   // Spoolman Integration
   getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
@@ -1027,7 +1452,7 @@ export const api = {
   getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),
   getPrinterMaintenance: (printerId: number) =>
     request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
-  updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; enabled?: boolean }) =>
+  updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null; enabled?: boolean }) =>
     request<MaintenanceStatus>(`/maintenance/items/${itemId}`, {
       method: 'PATCH',
       body: JSON.stringify(data),
@@ -1045,4 +1470,12 @@ export const api = {
       `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
       { method: 'PATCH' }
     ),
+
+  // Camera
+  getCameraStreamUrl: (printerId: number, fps = 10) =>
+    `${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`,
+  getCameraSnapshotUrl: (printerId: number) =>
+    `${API_BASE}/printers/${printerId}/camera/snapshot`,
+  testCameraConnection: (printerId: number) =>
+    request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
 };

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

@@ -12,11 +12,13 @@ interface AddNotificationModalProps {
 }
 
 const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
-  { value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
+  { value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
+  { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
   { value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
   { value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
-  { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
   { value: 'email', label: 'Email', description: 'SMTP email notifications' },
+  { value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
+  { value: 'webhook', label: 'Webhook', description: 'Generic HTTP POST to any URL' },
 ];
 
 export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
@@ -24,12 +26,27 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const isEditing = !!provider;
 
   const [name, setName] = useState(provider?.name || '');
-  const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'ntfy');
+  const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'discord');
   const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
   const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
   const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
   const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
 
+  // Daily digest
+  const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);
+  const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');
+
+  // Event toggles
+  const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
+  const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
+  const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
+  const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
+  const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
+  const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
+  const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
+  const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
+  const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
+
   // Provider-specific config
   const [config, setConfig] = useState<Record<string, string>>(
     provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
@@ -115,6 +132,19 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       quiet_hours_enabled: quietHoursEnabled,
       quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
       quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
+      // Daily digest
+      daily_digest_enabled: dailyDigestEnabled,
+      daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,
+      // Event toggles
+      on_print_start: onPrintStart,
+      on_print_complete: onPrintComplete,
+      on_print_failed: onPrintFailed,
+      on_print_stopped: onPrintStopped,
+      on_print_progress: onPrintProgress,
+      on_printer_offline: onPrinterOffline,
+      on_printer_error: onPrinterError,
+      on_filament_low: onFilamentLow,
+      on_maintenance_due: onMaintenanceDue,
     };
 
     if (isEditing) {
@@ -169,6 +199,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
           { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
         ];
+      case 'discord':
+        return [
+          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },
+        ];
+      case 'webhook':
+        return [
+          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },
+          { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
+          { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },
+          { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },
+        ];
       default:
         return [];
     }
@@ -380,6 +421,92 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
             )}
           </div>
 
+          {/* Daily Digest */}
+          <div className="space-y-2">
+            <div className="flex items-center justify-between">
+              <div>
+                <label className="text-sm text-white">Daily Digest</label>
+                <p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
+              </div>
+              <Toggle
+                checked={dailyDigestEnabled}
+                onChange={setDailyDigestEnabled}
+              />
+            </div>
+            {dailyDigestEnabled && (
+              <div>
+                <label className="block text-xs text-bambu-gray mb-1">Send digest at</label>
+                <input
+                  type="time"
+                  value={dailyDigestTime}
+                  onChange={(e) => setDailyDigestTime(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                />
+                <p className="text-xs text-bambu-gray mt-1">
+                  Events will be collected and sent as a single summary at this time
+                </p>
+              </div>
+            )}
+          </div>
+
+          {/* Event Toggles */}
+          <div className="space-y-3">
+            <p className="text-sm text-bambu-gray">Notification Events</p>
+
+            {/* Print Events */}
+            <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
+              <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Print Events</p>
+              <div className="grid grid-cols-2 gap-2">
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Start</span>
+                  <Toggle checked={onPrintStart} onChange={setOnPrintStart} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Complete</span>
+                  <Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Failed</span>
+                  <Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Stopped</span>
+                  <Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
+                </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">Progress</span>
+                    <span className="text-xs text-bambu-gray ml-1">(25%, 50%, 75%)</span>
+                  </div>
+                  <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
+                </div>
+              </div>
+            </div>
+
+            {/* Printer Status Events */}
+            <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
+              <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Printer Status</p>
+              <div className="grid grid-cols-2 gap-2">
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Offline</span>
+                  <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Error</span>
+                  <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Low Filament</span>
+                  <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Maintenance</span>
+                  <Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
+                </div>
+              </div>
+            </div>
+          </div>
+
           {/* Actions */}
           <div className="flex gap-3 pt-2">
             <Button

+ 117 - 1
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
 import { Button } from './Button';
@@ -22,6 +22,16 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [error, setError] = useState<string | null>(null);
 
+  // Power alert settings
+  const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
+  const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
+  const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
+
+  // Schedule settings
+  const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
+  const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
+  const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
+
   // Fetch printers for linking
   const { data: printers } = useQuery({
     queryKey: ['printers'],
@@ -109,6 +119,14 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       username: username.trim() || null,
       password: password.trim() || null,
       printer_id: printerId,
+      // Power alerts
+      power_alert_enabled: powerAlertEnabled,
+      power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
+      power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
+      // Schedule
+      schedule_enabled: scheduleEnabled,
+      schedule_on_time: scheduleOnTime || null,
+      schedule_off_time: scheduleOffTime || null,
     };
 
     if (isEditing) {
@@ -266,6 +284,104 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </p>
           </div>
 
+          {/* Power Alerts */}
+          <div className="border-t border-bambu-dark-tertiary pt-4">
+            <div className="flex items-center justify-between mb-3">
+              <div className="flex items-center gap-2">
+                <Bell className="w-4 h-4 text-bambu-green" />
+                <span className="text-white font-medium">Power Alerts</span>
+              </div>
+              <label className="relative inline-flex items-center cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={powerAlertEnabled}
+                  onChange={(e) => setPowerAlertEnabled(e.target.checked)}
+                  className="sr-only peer"
+                />
+                <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+              </label>
+            </div>
+            {powerAlertEnabled && (
+              <div className="space-y-3">
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
+                    <input
+                      type="number"
+                      value={powerAlertHigh}
+                      onChange={(e) => setPowerAlertHigh(e.target.value)}
+                      placeholder="e.g. 200"
+                      min="0"
+                      max="5000"
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Alert if below (W)</label>
+                    <input
+                      type="number"
+                      value={powerAlertLow}
+                      onChange={(e) => setPowerAlertLow(e.target.value)}
+                      placeholder="e.g. 10"
+                      min="0"
+                      max="5000"
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray">
+                  Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
+                </p>
+              </div>
+            )}
+          </div>
+
+          {/* Schedule */}
+          <div className="border-t border-bambu-dark-tertiary pt-4">
+            <div className="flex items-center justify-between mb-3">
+              <div className="flex items-center gap-2">
+                <Clock className="w-4 h-4 text-bambu-green" />
+                <span className="text-white font-medium">Daily Schedule</span>
+              </div>
+              <label className="relative inline-flex items-center cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={scheduleEnabled}
+                  onChange={(e) => setScheduleEnabled(e.target.checked)}
+                  className="sr-only peer"
+                />
+                <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+              </label>
+            </div>
+            {scheduleEnabled && (
+              <div className="space-y-3">
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
+                    <input
+                      type="time"
+                      value={scheduleOnTime}
+                      onChange={(e) => setScheduleOnTime(e.target.value)}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
+                    <input
+                      type="time"
+                      value={scheduleOffTime}
+                      onChange={(e) => setScheduleOffTime(e.target.value)}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray">
+                  Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
+                </p>
+              </div>
+            )}
+          </div>
+
           {/* Actions */}
           <div className="flex gap-3 pt-2">
             <Button

+ 15 - 8
frontend/src/components/BatchTagModal.tsx

@@ -31,8 +31,11 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
   const batchTagMutation = useMutation({
     mutationFn: async () => {
       const tagsArray = Array.from(selectedTags);
-      await Promise.all(
-        selectedIds.map(async (id) => {
+      let successCount = 0;
+
+      // Process sequentially to avoid SQLite database locks
+      for (const id of selectedIds) {
+        try {
           const archive = await api.getArchive(id);
           const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
 
@@ -45,18 +48,22 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
             newTags = currentTags.filter(t => !selectedTags.has(t));
           }
 
-          return api.updateArchive(id, { tags: newTags.join(', ') });
-        })
-      );
-      return { count: selectedIds.length, mode, tags: tagsArray };
+          await api.updateArchive(id, { tags: newTags.join(', ') });
+          successCount++;
+        } catch (err) {
+          console.error(`Failed to update archive ${id}:`, err);
+          throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
+        }
+      }
+      return { count: successCount, mode, tags: tagsArray };
     },
     onSuccess: ({ count, mode, tags }) => {
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
       onClose();
     },
-    onError: () => {
-      showToast('Failed to update tags', 'error');
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to update tags', 'error');
     },
   });
 

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