Browse Source

Merge pull request #54 from maziggy/0.1.6b7

v0.1.6b6

  ### FEATURES

  - Resizable printer cards - Four sizes (Small, Medium, Large, XL) with persistent preference
  - Queue Only mode - Stage prints without automatic scheduling, manual release with play button
  - Virtual printer model selection - Choose from X1, P, A1, H2 series models with auto-restart on change
  - Pending upload delete confirmation - Confirmation modal when discarding pending uploads

  ### Fixes

  - Camera stream reconnection - Improved stuck stream detection with automatic reconnection
  - Virtual printer SSDP model codes - Corrected C11=P1P, C12=P1S, N7=P2S
  - Virtual printer serial prefixes - Fixed to match actual Bambu Lab format
  - Virtual printer startup model - Now correctly loads saved model from database on restart
  - Virtual printer model change - Model changes auto-restart (no manual disable/enable needed)
  - Docker certificate persistence - Certificates correctly persist in bambuddy_data volume
MartinNYHC 8 months ago
parent
commit
63dee1c2ef
100 changed files with 4210 additions and 1957 deletions
  1. 70 1
      CHANGELOG.md
  2. 10 4
      Dockerfile
  3. 7 0
      README.md
  4. 5 6
      backend/app/api/routes/ams_history.py
  5. 6 7
      backend/app/api/routes/api_keys.py
  6. 210 55
      backend/app/api/routes/archives.py
  7. 88 10
      backend/app/api/routes/camera.py
  8. 14 38
      backend/app/api/routes/external_links.py
  9. 103 33
      backend/app/api/routes/filaments.py
  10. 5 9
      backend/app/api/routes/kprofiles.py
  11. 9 16
      backend/app/api/routes/notification_templates.py
  12. 40 53
      backend/app/api/routes/notifications.py
  13. 48 29
      backend/app/api/routes/print_queue.py
  14. 84 20
      backend/app/api/routes/printers.py
  15. 44 2
      backend/app/api/routes/settings.py
  16. 28 33
      backend/app/api/routes/system.py
  17. 33 35
      backend/app/api/routes/webhook.py
  18. 15 10
      backend/app/api/routes/websocket.py
  19. 10 19
      backend/app/core/auth.py
  20. 1 1
      backend/app/core/config.py
  21. 6 0
      backend/app/core/database.py
  22. 34 23
      backend/app/core/websocket.py
  23. 0 8
      backend/app/i18n/__init__.py
  24. 110 9
      backend/app/main.py
  25. 5 7
      backend/app/models/ams_history.py
  26. 2 1
      backend/app/models/api_key.py
  27. 5 9
      backend/app/models/external_link.py
  28. 4 7
      backend/app/models/filament.py
  29. 5 10
      backend/app/models/kprofile_note.py
  30. 9 13
      backend/app/models/maintenance.py
  31. 1 1
      backend/app/models/notification.py
  32. 1 3
      backend/app/models/notification_template.py
  33. 7 11
      backend/app/models/print_queue.py
  34. 4 7
      backend/app/models/settings.py
  35. 4 7
      backend/app/models/slot_preset.py
  36. 10 10
      backend/app/schemas/__init__.py
  37. 5 0
      backend/app/schemas/api_key.py
  38. 29 18
      backend/app/schemas/cloud.py
  39. 1 0
      backend/app/schemas/external_link.py
  40. 1 0
      backend/app/schemas/filament.py
  41. 4 0
      backend/app/schemas/maintenance.py
  42. 3 1
      backend/app/schemas/notification.py
  43. 6 2
      backend/app/schemas/print_queue.py
  44. 5 0
      backend/app/schemas/printer.py
  45. 36 22
      backend/app/services/archive.py
  46. 61 55
      backend/app/services/archive_comparison.py
  47. 29 53
      backend/app/services/bambu_cloud.py
  48. 36 26
      backend/app/services/bambu_ftp.py
  49. 120 30
      backend/app/services/bambu_mqtt.py
  50. 1 1
      backend/app/services/discovery.py
  51. 30 28
      backend/app/services/notification_service.py
  52. 9 11
      backend/app/services/print_scheduler.py
  53. 5 0
      backend/app/services/printer_manager.py
  54. 19 47
      backend/app/services/smart_plug_manager.py
  55. 16 8
      backend/app/services/spoolman.py
  56. 8 26
      backend/app/services/tasmota.py
  57. 3 9
      backend/app/services/telemetry.py
  58. 10 2
      backend/app/services/virtual_printer/__init__.py
  59. 100 22
      backend/app/services/virtual_printer/certificate.py
  60. 75 1
      backend/app/services/virtual_printer/ftp_server.py
  61. 134 27
      backend/app/services/virtual_printer/manager.py
  62. 2 2
      backend/app/services/virtual_printer/mqtt_server.py
  63. 68 62
      backend/tests/conftest.py
  64. 13 37
      backend/tests/integration/test_ams_history_api.py
  65. 10 34
      backend/tests/integration/test_archives_api.py
  66. 16 17
      backend/tests/integration/test_camera_api.py
  67. 10 25
      backend/tests/integration/test_external_links_api.py
  68. 6 14
      backend/tests/integration/test_filaments_api.py
  69. 17 52
      backend/tests/integration/test_maintenance_api.py
  70. 22 54
      backend/tests/integration/test_notifications_api.py
  71. 446 0
      backend/tests/integration/test_print_queue_api.py
  72. 7 17
      backend/tests/integration/test_projects_api.py
  73. 19 20
      backend/tests/integration/test_system_api.py
  74. 15 14
      backend/tests/unit/services/test_archive_service.py
  75. 64 49
      backend/tests/unit/services/test_bambu_mqtt.py
  76. 149 279
      backend/tests/unit/services/test_notification_service.py
  77. 100 180
      backend/tests/unit/services/test_smart_plug_manager.py
  78. 30 75
      backend/tests/unit/services/test_tasmota.py
  79. 18 16
      backend/tests/unit/services/test_telemetry.py
  80. 52 1
      backend/tests/unit/services/test_virtual_printer.py
  81. 22 14
      backend/tests/unit/test_code_quality.py
  82. 78 68
      backend/tests/unit/test_log_error_detection.py
  83. 4 0
      demo-video/.gitignore
  84. 50 0
      demo-video/README.md
  85. 537 0
      demo-video/package-lock.json
  86. 15 0
      demo-video/package.json
  87. 566 0
      demo-video/record-demo.ts
  88. 9 7
      docker-compose.yml
  89. 24 10
      docker-publish.sh
  90. 0 1
      frontend/package-lock.json
  91. 1 1
      frontend/public/icons/chamber.svg
  92. 1 1
      frontend/public/icons/heatbed.svg
  93. 1 1
      frontend/public/icons/reload.svg
  94. 0 0
      frontend/public/icons/settings.svg
  95. 1 1
      frontend/public/icons/skip-objects.svg
  96. 1 1
      frontend/public/icons/video-camera.svg
  97. BIN
      frontend/public/img/bambuddy_logo_dark_transparent.png
  98. 1 1
      frontend/public/vite.svg
  99. 28 7
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  100. 24 0
      frontend/src/api/client.ts

+ 70 - 1
CHANGELOG.md

@@ -2,6 +2,54 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.6b6] - 2026-01-04
+
+### Added
+- **Resizable printer cards** - Adjust printer card size from the Printers page toolbar:
+  - Four sizes: Small, Medium (default), Large, XL
+  - Plus/minus buttons in toolbar header
+  - Size preference saved to localStorage
+  - Responsive grid adapts to selected size
+- **Queue Only mode** - Stage prints without automatic scheduling:
+  - New "Queue Only" option when adding prints to queue
+  - Staged prints show purple "Staged" badge
+  - Play button to manually release staged prints to the queue
+  - Edit queue items to switch between ASAP, Scheduled, and Queue Only modes
+  - Useful for preparing print batches before activating
+- **Virtual printer model selection** - Choose which Bambu printer model to emulate:
+  - Dropdown in Settings > Virtual Printer to select model
+  - Supports X1 series (X1C, X1, X1E), P series (P1S, P1P, P2S), A1 series (A1, A1 Mini), and H2 series (H2D, H2C, H2S)
+  - Affects how slicers detect and interact with the virtual printer
+  - Model change requires disabling/re-enabling the virtual printer
+  - Models sorted alphabetically in dropdown
+- **Pending upload delete confirmation** - Confirmation modal when discarding pending uploads in queue review mode
+
+### Fixed
+- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection
+- **Virtual printer SSDP model codes** - Corrected model codes for slicer compatibility:
+  - C11=P1P, C12=P1S (were incorrectly swapped)
+  - N7=P2S (was incorrectly using C13 which is X1E)
+  - 3DPrinter-X1-Carbon for X1C (full model name format)
+- **Virtual printer serial prefixes** - Fixed serial number prefixes to match real printers:
+  - Based on actual Bambu Lab serial number format (MMM??RYMDDUUUUU)
+  - X1C=00M, P1S=01P, P1P=01S, P2S=22E, A1=039, A1M=030, H2D=094, X1E=03W
+- **Docker certificate persistence** - Fixed virtual printer certificate storage:
+  - Removed unused `bambuddy_vprinter` volume (was mounting to wrong path)
+  - Certificates now correctly persist in `bambuddy_data` volume
+  - Added optional bind mount for sharing certs between Docker and native installations
+
+### Changed
+- **Virtual printer setup documentation** - Improved setup instructions:
+  - Prominent "Setup Required" warning in UI linking to documentation
+  - Certificate must be appended to slicer's printer.cer file (not system cert store)
+  - Platform-specific instructions for Linux, Docker, macOS, Windows, Unraid, Synology, TrueNAS, Proxmox
+
+### Tests
+- Added integration tests for print queue API endpoints (16 new tests)
+- Tests cover queue CRUD, manual_start flag, and start/cancel endpoints
+- Added unit tests for virtual printer model configuration (3 new tests)
+- Updated VirtualPrinterSettings tests for new UI layout and model codes
+
 ## [0.1.6b5] - 2026-01-02
 ## [0.1.6b5] - 2026-01-02
 
 
 ### Added
 ### Added
@@ -41,6 +89,17 @@ All notable changes to Bambuddy will be documented in this file.
   - Light and dark theme support
   - Light and dark theme support
   - Close with ESC key or click outside
   - Close with ESC key or click outside
   - Requires "Exclude Objects" option enabled in slicer
   - Requires "Exclude Objects" option enabled in slicer
+- **Interactive API Browser** - Explore and test all API endpoints directly in Bambuddy:
+  - Settings → API Keys now includes a full API browser
+  - Fetches OpenAPI schema automatically
+  - Endpoints grouped by category (printers, archives, settings, etc.)
+  - Expandable sections with color-coded method badges (GET, POST, PATCH, DELETE)
+  - Parameter inputs for path, query, and JSON body
+  - Auto-populates request body with schema examples
+  - Live API execution with response display (status, timing, formatted JSON)
+  - Paste API key to test authenticated endpoints
+  - Search to filter endpoints across all categories
+  - Two-column layout: API key management + API browser side-by-side
 - **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
 - **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
   - Menu button (⋮) appears on hover over AMS slots
   - Menu button (⋮) appears on hover over AMS slots
   - "Re-read RFID" option triggers filament info refresh
   - "Re-read RFID" option triggers filament info refresh
@@ -53,9 +112,19 @@ All notable changes to Bambuddy will be documented in this file.
   - Progress bar tracks items toward target count
   - Progress bar tracks items toward target count
   - Useful for batch printing (e.g., 10 copies in one print = 10 items)
   - Useful for batch printing (e.g., 10 copies in one print = 10 items)
   - Default quantity of 1 for backwards compatibility
   - Default quantity of 1 for backwards compatibility
+- **Fan status display** - Real-time fan speeds in new Controls section:
+  - Part cooling fan, Auxiliary fan, Chamber fan status
+  - Distinct icons for each fan type (Fan, Wind, AirVent)
+  - Dynamic coloring: active fans show colored, off fans show gray
+  - Percentage display (0-100%)
+  - Real-time updates via WebSocket
+  - Always visible on printer cards in expanded view
 
 
 ### Changed
 ### Changed
-- **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
+- **Printer card layout** - Reorganized expanded view with new Controls section:
+  - Temperature display is now standalone (no longer shares row with buttons)
+  - New Controls section contains fan status (left) and print buttons (right)
+  - Removed divider lines before Controls and Filaments sections for cleaner look
 - **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal
 - **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal
 - **Spoolman info banner** - Updated settings UI with clearer sync documentation
 - **Spoolman info banner** - Updated settings UI with clearer sync documentation
 
 

+ 10 - 4
Dockerfile

@@ -3,8 +3,12 @@ FROM node:22-bookworm-slim AS frontend-builder
 
 
 WORKDIR /app/frontend
 WORKDIR /app/frontend
 
 
+# Copy package files first for better caching
 COPY frontend/package*.json ./
 COPY frontend/package*.json ./
-RUN npm ci
+
+# Use cache mount for npm
+RUN --mount=type=cache,target=/root/.npm \
+    npm ci
 
 
 COPY frontend/ ./
 COPY frontend/ ./
 RUN npm run build
 RUN npm run build
@@ -21,9 +25,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     ffmpeg \
     ffmpeg \
     && rm -rf /var/lib/apt/lists/*
     && rm -rf /var/lib/apt/lists/*
 
 
-# Install Python dependencies
+# Install Python dependencies with cache mount
 COPY requirements.txt ./
 COPY requirements.txt ./
-RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt
+RUN --mount=type=cache,target=/root/.cache/pip \
+    pip install --root-user-action=ignore -r requirements.txt
 
 
 # Copy backend
 # Copy backend
 COPY backend/ ./backend/
 COPY backend/ ./backend/
@@ -46,4 +51,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
     CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
     CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
 
 
 # Run the application
 # Run the application
-CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+# Use standard asyncio loop (uvloop has permission issues in some Docker environments)
+CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--loop", "asyncio"]

+ 7 - 0
README.md

@@ -55,7 +55,9 @@
 ### 📊 Monitoring & Control
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
 - Live camera streaming (MJPEG) & snapshots
+- Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume)
 - Printer control (stop, pause, resume)
+- Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read
 - HMS error monitoring with history
 - HMS error monitoring with history
@@ -67,6 +69,7 @@
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
+- Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota)
 - Smart plug integration (Tasmota)
 - Energy consumption tracking
 - Energy consumption tracking
 - Auto power-on before print
 - Auto power-on before print
@@ -95,10 +98,12 @@
 - K-profiles (pressure advance)
 - K-profiles (pressure advance)
 - External sidebar links
 - External sidebar links
 - Webhooks & API keys
 - Webhooks & API keys
+- Interactive API browser with live testing
 
 
 ### 🖨️ Virtual Printer
 ### 🖨️ Virtual Printer
 - Emulates a Bambu Lab printer on your network
 - Emulates a Bambu Lab printer on your network
 - Send prints directly from Bambu Studio/Orca Slicer
 - Send prints directly from Bambu Studio/Orca Slicer
+- Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Queue mode or auto-start mode
 - Queue mode or auto-start mode
 - SSDP discovery (appears in slicer automatically)
 - SSDP discovery (appears in slicer automatically)
 - Secure TLS/MQTT communication
 - Secure TLS/MQTT communication
@@ -275,6 +280,8 @@ Open **http://localhost:8000** in your browser.
 
 
 > **Multi-architecture support:** Pre-built images are available for `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5).
 > **Multi-architecture support:** Pre-built images are available for `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5).
 
 
+> **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP.
+
 <details>
 <details>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 
 

+ 5 - 6
backend/app/api/routes/ams_history.py

@@ -1,10 +1,11 @@
 """API routes for AMS sensor history."""
 """API routes for AMS sensor history."""
 
 
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
+
 from fastapi import APIRouter, Depends, Query
 from fastapi import APIRouter, Depends, Query
-from sqlalchemy import select, func, and_
-from sqlalchemy.ext.asyncio import AsyncSession
 from pydantic import BaseModel
 from pydantic import BaseModel
+from sqlalchemy import and_, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_history import AMSSensorHistory
@@ -64,8 +65,7 @@ async def get_ams_history(
             func.min(AMSSensorHistory.temperature).label("min_temp"),
             func.min(AMSSensorHistory.temperature).label("min_temp"),
             func.max(AMSSensorHistory.temperature).label("max_temp"),
             func.max(AMSSensorHistory.temperature).label("max_temp"),
             func.avg(AMSSensorHistory.temperature).label("avg_temp"),
             func.avg(AMSSensorHistory.temperature).label("avg_temp"),
-        )
-        .where(
+        ).where(
             and_(
             and_(
                 AMSSensorHistory.printer_id == printer_id,
                 AMSSensorHistory.printer_id == printer_id,
                 AMSSensorHistory.ams_id == ams_id,
                 AMSSensorHistory.ams_id == ams_id,
@@ -106,8 +106,7 @@ async def delete_old_history(
     cutoff = datetime.now() - timedelta(days=days)
     cutoff = datetime.now() - timedelta(days=days)
 
 
     result = await db.execute(
     result = await db.execute(
-        select(func.count(AMSSensorHistory.id))
-        .where(
+        select(func.count(AMSSensorHistory.id)).where(
             and_(
             and_(
                 AMSSensorHistory.printer_id == printer_id,
                 AMSSensorHistory.printer_id == printer_id,
                 AMSSensorHistory.recorded_at < cutoff,
                 AMSSensorHistory.recorded_at < cutoff,

+ 6 - 7
backend/app/api/routes/api_keys.py

@@ -1,16 +1,17 @@
 import logging
 import logging
+
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.database import get_db
 from backend.app.core.auth import generate_api_key
 from backend.app.core.auth import generate_api_key
+from backend.app.core.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.schemas.api_key import (
 from backend.app.schemas.api_key import (
     APIKeyCreate,
     APIKeyCreate,
-    APIKeyUpdate,
-    APIKeyResponse,
     APIKeyCreateResponse,
     APIKeyCreateResponse,
+    APIKeyResponse,
+    APIKeyUpdate,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -21,9 +22,7 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
 @router.get("/", response_model=list[APIKeyResponse])
 @router.get("/", response_model=list[APIKeyResponse])
 async def list_api_keys(db: AsyncSession = Depends(get_db)):
 async def list_api_keys(db: AsyncSession = Depends(get_db)):
     """List all API keys (without full key values)."""
     """List all API keys (without full key values)."""
-    result = await db.execute(
-        select(APIKey).order_by(APIKey.created_at.desc())
-    )
+    result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 

+ 210 - 55
backend/app/api/routes/archives.py

@@ -1520,38 +1520,122 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
 
 
     has_model = False
     has_model = False
     has_gcode = False
     has_gcode = False
+    has_source = False
     build_volume = {"x": 256, "y": 256, "z": 256}  # Default to X1/P1 size
     build_volume = {"x": 256, "y": 256, "z": 256}  # Default to X1/P1 size
     filament_colors: list[str] = []
     filament_colors: list[str] = []
 
 
+    # Check if source 3MF exists - this is where actual mesh data typically lives
+    source_path = None
+    if archive.source_3mf_path:
+        source_path = settings.base_dir / archive.source_3mf_path
+        if source_path.exists():
+            has_source = True
+
+    # Helper function to check for mesh data and extract colors from a 3MF file
+    def extract_3mf_info(zf_path: Path) -> tuple[bool, list[str], dict]:
+        """Extract mesh presence, colors, and build volume from a 3MF file."""
+        found_mesh = False
+        colors: list[str] = []
+        volume = {"x": 256, "y": 256, "z": 256}
+
+        try:
+            with zipfile.ZipFile(zf_path, "r") as zf:
+                names = zf.namelist()
+
+                # Check for 3D model - look for actual mesh data
+                for name in names:
+                    if name.endswith(".model"):
+                        try:
+                            content = zf.read(name).decode("utf-8")
+                            if "<vertex" in content or "<mesh" in content:
+                                found_mesh = True
+                                break
+                        except Exception:
+                            pass
+
+                # Extract filament colors from project_settings.config
+                if "Metadata/project_settings.config" in names:
+                    try:
+                        config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
+                        config_data = json.loads(config_content)
+
+                        # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
+                        printable_area = config_data.get("printable_area", [])
+                        if printable_area and len(printable_area) >= 3:
+                            max_x = 0
+                            max_y = 0
+                            for coord in printable_area:
+                                if "x" in coord:
+                                    parts = coord.split("x")
+                                    if len(parts) == 2:
+                                        try:
+                                            x, y = int(parts[0]), int(parts[1])
+                                            max_x = max(max_x, x)
+                                            max_y = max(max_y, y)
+                                        except ValueError:
+                                            pass
+                            if max_x > 0 and max_y > 0:
+                                volume["x"] = max_x
+                                volume["y"] = max_y
+
+                        # Parse printable_height
+                        printable_height = config_data.get("printable_height")
+                        if printable_height:
+                            try:
+                                volume["z"] = int(printable_height)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Extract filament colors
+                        raw_colors = config_data.get("filament_colour", [])
+                        if raw_colors:
+                            for color in raw_colors:
+                                if color and isinstance(color, str):
+                                    colors.append(color)
+                    except Exception:
+                        pass
+        except zipfile.BadZipFile:
+            pass
+
+        return found_mesh, colors, volume
+
+    # First check source 3MF for mesh data and colors (preferred for 3D model viewing)
+    if has_source and source_path:
+        source_has_mesh, source_colors, source_volume = extract_3mf_info(source_path)
+        if source_has_mesh:
+            has_model = True
+        if source_colors:
+            filament_colors = source_colors
+        if source_volume["x"] != 256 or source_volume["y"] != 256 or source_volume["z"] != 256:
+            build_volume = source_volume
+
     try:
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
         with zipfile.ZipFile(file_path, "r") as zf:
             names = zf.namelist()
             names = zf.namelist()
 
 
-            # Check for G-code
+            # Check for G-code in the sliced file
             has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names)
             has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names)
 
 
-            # Check for 3D model - need to look for actual mesh data
-            for name in names:
-                if name.endswith(".model"):
-                    try:
-                        content = zf.read(name).decode("utf-8")
-                        # Check if this model file contains actual mesh vertices
-                        if "<vertex" in content or "<mesh" in content:
-                            has_model = True
-                            break
-                    except Exception:
-                        pass
+            # Check for 3D model in sliced file (fallback if no source)
+            if not has_model:
+                for name in names:
+                    if name.endswith(".model"):
+                        try:
+                            content = zf.read(name).decode("utf-8")
+                            if "<vertex" in content or "<mesh" in content:
+                                has_model = True
+                                break
+                        except Exception:
+                            pass
 
 
-            # Extract filament colors from slice_info.config
+            # Extract filament colors from slice_info.config (for gcode preview)
             # These are the actual filaments used in the print, indexed by tool/extruder
             # These are the actual filaments used in the print, indexed by tool/extruder
+            slice_colors: list[str] = []
             if "Metadata/slice_info.config" in names:
             if "Metadata/slice_info.config" in names:
                 try:
                 try:
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     root = ET.fromstring(slice_content)
                     root = ET.fromstring(slice_content)
 
 
-                    # Get all filaments with their IDs and colors
-                    # <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
-                    # ID corresponds to the tool number in G-code (T0, T1, etc.)
                     filaments = root.findall(".//filament")
                     filaments = root.findall(".//filament")
                     filament_map: dict[int, str] = {}
                     filament_map: dict[int, str] = {}
                     for f in filaments:
                     for f in filaments:
@@ -1563,59 +1647,66 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
                         except (ValueError, TypeError):
                         except (ValueError, TypeError):
                             used_amount = 0
                             used_amount = 0
 
 
-                        # Include all filaments, but mark unused ones
                         if fid is not None and fcolor:
                         if fid is not None and fcolor:
                             try:
                             try:
-                                # IDs are 1-based in slice_info, tools are 0-based
                                 tool_id = int(fid) - 1
                                 tool_id = int(fid) - 1
                                 if tool_id >= 0 and used_amount > 0:
                                 if tool_id >= 0 and used_amount > 0:
                                     filament_map[tool_id] = fcolor
                                     filament_map[tool_id] = fcolor
                             except ValueError:
                             except ValueError:
                                 pass
                                 pass
 
 
-                    # Convert to ordered list (tool 0, tool 1, etc.)
                     if filament_map:
                     if filament_map:
                         max_tool = max(filament_map.keys())
                         max_tool = max(filament_map.keys())
                         for i in range(max_tool + 1):
                         for i in range(max_tool + 1):
-                            filament_colors.append(filament_map.get(i, "#00AE42"))
+                            slice_colors.append(filament_map.get(i, "#00AE42"))
                 except Exception:
                 except Exception:
                     pass
                     pass
 
 
-            # Extract build volume from project settings
-            if "Metadata/project_settings.config" in names:
-                try:
-                    config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
-                    config_data = json.loads(config_content)
-
-                    # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
-                    printable_area = config_data.get("printable_area", [])
-                    if printable_area and len(printable_area) >= 3:
-                        # Get max X and Y from the corner coordinates
-                        max_x = 0
-                        max_y = 0
-                        for coord in printable_area:
-                            if "x" in coord:
-                                parts = coord.split("x")
-                                if len(parts) == 2:
-                                    try:
-                                        x, y = int(parts[0]), int(parts[1])
-                                        max_x = max(max_x, x)
-                                        max_y = max(max_y, y)
-                                    except ValueError:
-                                        pass
-                        if max_x > 0 and max_y > 0:
-                            build_volume["x"] = max_x
-                            build_volume["y"] = max_y
-
-                    # Parse printable_height
-                    printable_height = config_data.get("printable_height")
-                    if printable_height:
-                        try:
-                            build_volume["z"] = int(printable_height)
-                        except (ValueError, TypeError):
-                            pass
-                except Exception:
-                    pass
+            # Use slice_info colors if we don't have colors from source yet
+            if not filament_colors and slice_colors:
+                filament_colors = slice_colors
+
+            # Extract build volume from sliced file if not already set from source
+            if build_volume["x"] == 256 and build_volume["y"] == 256:
+                if "Metadata/project_settings.config" in names:
+                    try:
+                        config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
+                        config_data = json.loads(config_content)
+
+                        printable_area = config_data.get("printable_area", [])
+                        if printable_area and len(printable_area) >= 3:
+                            max_x = 0
+                            max_y = 0
+                            for coord in printable_area:
+                                if "x" in coord:
+                                    parts = coord.split("x")
+                                    if len(parts) == 2:
+                                        try:
+                                            x, y = int(parts[0]), int(parts[1])
+                                            max_x = max(max_x, x)
+                                            max_y = max(max_y, y)
+                                        except ValueError:
+                                            pass
+                            if max_x > 0 and max_y > 0:
+                                build_volume["x"] = max_x
+                                build_volume["y"] = max_y
+
+                        printable_height = config_data.get("printable_height")
+                        if printable_height:
+                            try:
+                                build_volume["z"] = int(printable_height)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Fallback colors from project_settings if still empty
+                        if not filament_colors:
+                            raw_colors = config_data.get("filament_colour", [])
+                            if raw_colors:
+                                for color in raw_colors:
+                                    if color and isinstance(color, str):
+                                        filament_colors.append(color)
+                    except Exception:
+                        pass
 
 
     except zipfile.BadZipFile:
     except zipfile.BadZipFile:
         raise HTTPException(400, "Invalid 3MF file")
         raise HTTPException(400, "Invalid 3MF file")
@@ -1623,6 +1714,7 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
     return {
     return {
         "has_model": has_model,
         "has_model": has_model,
         "has_gcode": has_gcode,
         "has_gcode": has_gcode,
+        "has_source": has_source,
         "build_volume": build_volume,
         "build_volume": build_volume,
         "filament_colors": filament_colors,
         "filament_colors": filament_colors,
     }
     }
@@ -1661,6 +1753,69 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
         raise HTTPException(500, f"Error extracting G-code: {str(e)}")
         raise HTTPException(500, f"Error extracting G-code: {str(e)}")
 
 
 
 
+@router.get("/{archive_id}/plate-preview")
+async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the plate preview image from the 3MF file.
+
+    Returns the slicer-generated plate thumbnail which shows the model
+    with correct colors and positioning.
+    """
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            names = zf.namelist()
+
+            # Try to find plate preview images in order of preference
+            # First look for the specific plate being printed (check slice_info for plate index)
+            plate_num = 1
+            if "Metadata/slice_info.config" in names:
+                try:
+                    import xml.etree.ElementTree as ET
+
+                    slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
+                    root = ET.fromstring(slice_content)
+                    plate_elem = root.find(".//plate/metadata[@key='index']")
+                    if plate_elem is not None:
+                        plate_num = int(plate_elem.get("value", "1"))
+                except Exception:
+                    pass
+
+            # Try plate-specific image first, then fall back to plate_1
+            preview_paths = [
+                f"Metadata/plate_{plate_num}.png",
+                "Metadata/plate_1.png",
+                "Metadata/thumbnail.png",
+            ]
+
+            for preview_path in preview_paths:
+                if preview_path in names:
+                    image_data = zf.read(preview_path)
+                    return Response(content=image_data, media_type="image/png")
+
+            # If no plate image, try any PNG in Metadata
+            for name in names:
+                if name.startswith("Metadata/plate_") and name.endswith(".png") and "_small" not in name:
+                    image_data = zf.read(name)
+                    return Response(content=image_data, media_type="image/png")
+
+            raise HTTPException(404, "No plate preview found in 3MF file")
+
+    except zipfile.BadZipFile:
+        raise HTTPException(400, "Invalid 3MF file")
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(500, f"Error extracting plate preview: {str(e)}")
+
+
 @router.post("/upload")
 @router.post("/upload")
 async def upload_archive(
 async def upload_archive(
     file: UploadFile = File(...),
     file: UploadFile = File(...),

+ 88 - 10
backend/app/api/routes/camera.py

@@ -33,6 +33,12 @@ _active_chamber_streams: dict[str, tuple] = {}
 # Store last frame for each printer (for photo capture from active stream)
 # Store last frame for each printer (for photo capture from active stream)
 _last_frames: dict[int, bytes] = {}
 _last_frames: dict[int, bytes] = {}
 
 
+# Track last frame timestamp for each printer (for stall detection)
+_last_frame_times: dict[int, float] = {}
+
+# Track stream start times for each printer
+_stream_start_times: dict[int, float] = {}
+
 
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
     """Get the last buffered frame for a printer from an active stream.
@@ -98,9 +104,12 @@ async def generate_chamber_mjpeg_stream(
                 logger.warning(f"Chamber image stream ended for {stream_id}")
                 logger.warning(f"Chamber image stream ended for {stream_id}")
                 break
                 break
 
 
-            # Save frame to buffer for photo capture
+            # Save frame to buffer for photo capture and track timestamp
             if printer_id is not None:
             if printer_id is not None:
+                import time
+
                 _last_frames[printer_id] = frame
                 _last_frames[printer_id] = frame
+                _last_frame_times[printer_id] = time.time()
 
 
             # Rate limiting - skip frames if needed to maintain target FPS
             # Rate limiting - skip frames if needed to maintain target FPS
             current_time = asyncio.get_event_loop().time()
             current_time = asyncio.get_event_loop().time()
@@ -127,9 +136,11 @@ async def generate_chamber_mjpeg_stream(
         if stream_id and stream_id in _active_chamber_streams:
         if stream_id and stream_id in _active_chamber_streams:
             del _active_chamber_streams[stream_id]
             del _active_chamber_streams[stream_id]
 
 
-        # Clean up frame buffer
-        if printer_id is not None and printer_id in _last_frames:
-            del _last_frames[printer_id]
+        # Clean up frame buffer and timestamps
+        if printer_id is not None:
+            _last_frames.pop(printer_id, None)
+            _last_frame_times.pop(printer_id, None)
+            _stream_start_times.pop(printer_id, None)
 
 
         # Close the connection
         # Close the connection
         try:
         try:
@@ -156,7 +167,7 @@ async def generate_rtsp_mjpeg_stream(
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
         logger.error("ffmpeg not found - camera streaming requires 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")
+        yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
         return
         return
 
 
     port = get_camera_port(model)
     port = get_camera_port(model)
@@ -265,9 +276,12 @@ async def generate_rtsp_mjpeg_stream(
                     frame = buffer[: end_idx + 2]
                     frame = buffer[: end_idx + 2]
                     buffer = buffer[end_idx + 2 :]
                     buffer = buffer[end_idx + 2 :]
 
 
-                    # Save frame to buffer for photo capture
+                    # Save frame to buffer for photo capture and track timestamp
                     if printer_id is not None:
                     if printer_id is not None:
+                        import time
+
                         _last_frames[printer_id] = frame
                         _last_frames[printer_id] = frame
+                        _last_frame_times[printer_id] = time.time()
 
 
                     # Yield frame in MJPEG format
                     # Yield frame in MJPEG format
                     yield (
                     yield (
@@ -289,7 +303,7 @@ async def generate_rtsp_mjpeg_stream(
 
 
     except FileNotFoundError:
     except FileNotFoundError:
         logger.error("ffmpeg not found - camera streaming requires 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")
+        yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
     except asyncio.CancelledError:
     except asyncio.CancelledError:
         logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
         logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
     except GeneratorExit:
     except GeneratorExit:
@@ -301,9 +315,11 @@ async def generate_rtsp_mjpeg_stream(
         if stream_id and stream_id in _active_streams:
         if stream_id and stream_id in _active_streams:
             del _active_streams[stream_id]
             del _active_streams[stream_id]
 
 
-        # Clean up frame buffer
-        if printer_id is not None and printer_id in _last_frames:
-            del _last_frames[printer_id]
+        # Clean up frame buffer and timestamps
+        if printer_id is not None:
+            _last_frames.pop(printer_id, None)
+            _last_frame_times.pop(printer_id, None)
+            _stream_start_times.pop(printer_id, None)
 
 
         if process and process.returncode is None:
         if process and process.returncode is None:
             logger.info(f"Terminating ffmpeg process for stream {stream_id}")
             logger.info(f"Terminating ffmpeg process for stream {stream_id}")
@@ -366,6 +382,11 @@ async def camera_stream(
         stream_generator = generate_rtsp_mjpeg_stream
         stream_generator = generate_rtsp_mjpeg_stream
         logger.info(f"Using RTSP protocol for {printer.model}")
         logger.info(f"Using RTSP protocol for {printer.model}")
 
 
+    # Track stream start time
+    import time
+
+    _stream_start_times[printer_id] = time.time()
+
     async def stream_with_disconnect_check():
     async def stream_with_disconnect_check():
         """Wrapper generator that monitors for client disconnect."""
         """Wrapper generator that monitors for client disconnect."""
         try:
         try:
@@ -519,3 +540,60 @@ async def test_camera(
     )
     )
 
 
     return result
     return result
+
+
+@router.get("/{printer_id}/camera/status")
+async def camera_status(printer_id: int):
+    """Get the status of an active camera stream.
+
+    Returns whether a stream is active and when the last frame was received.
+    Used by the frontend to detect stalled streams and auto-reconnect.
+    """
+    import time
+
+    # Check if there's an active stream for this printer
+    has_active_stream = False
+
+    # Check ffmpeg/RTSP streams
+    for stream_id in _active_streams:
+        if stream_id.startswith(f"{printer_id}-"):
+            process = _active_streams[stream_id]
+            if process.returncode is None:
+                has_active_stream = True
+                break
+
+    # Check chamber image streams
+    if not has_active_stream:
+        for stream_id in _active_chamber_streams:
+            if stream_id.startswith(f"{printer_id}-"):
+                has_active_stream = True
+                break
+
+    # Get timing information
+    current_time = time.time()
+    last_frame_time = _last_frame_times.get(printer_id)
+    stream_start_time = _stream_start_times.get(printer_id)
+
+    # Calculate seconds since last frame
+    seconds_since_frame = None
+    if last_frame_time is not None:
+        seconds_since_frame = current_time - last_frame_time
+
+    # Calculate stream uptime
+    stream_uptime = None
+    if stream_start_time is not None:
+        stream_uptime = current_time - stream_start_time
+
+    return {
+        "active": has_active_stream,
+        "has_frames": printer_id in _last_frames,
+        "seconds_since_frame": seconds_since_frame,
+        "stream_uptime": stream_uptime,
+        # Consider stalled if no frame for more than 10 seconds after stream started
+        "stalled": (
+            has_active_stream
+            and stream_uptime is not None
+            and stream_uptime > 5  # Give 5 seconds for stream to start
+            and (seconds_since_frame is None or seconds_since_frame > 10)
+        ),
+    }

+ 14 - 38
backend/app/api/routes/external_links.py

@@ -1,11 +1,10 @@
 """API routes for external sidebar links."""
 """API routes for external sidebar links."""
 
 
 import logging
 import logging
-import os
 import uuid
 import uuid
 from pathlib import Path
 from pathlib import Path
 
 
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,9 +14,9 @@ from backend.app.core.database import get_db
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.external_link import ExternalLink
 from backend.app.schemas.external_link import (
 from backend.app.schemas.external_link import (
     ExternalLinkCreate,
     ExternalLinkCreate,
-    ExternalLinkUpdate,
-    ExternalLinkResponse,
     ExternalLinkReorder,
     ExternalLinkReorder,
+    ExternalLinkResponse,
+    ExternalLinkUpdate,
 )
 )
 
 
 # Directory for storing custom icons
 # Directory for storing custom icons
@@ -32,9 +31,7 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
 @router.get("/", response_model=list[ExternalLinkResponse])
 @router.get("/", response_model=list[ExternalLinkResponse])
 async def list_external_links(db: AsyncSession = Depends(get_db)):
 async def list_external_links(db: AsyncSession = Depends(get_db)):
     """List all external links ordered by sort_order."""
     """List all external links ordered by sort_order."""
-    result = await db.execute(
-        select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)
-    )
+    result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
     links = result.scalars().all()
     return links
     return links
 
 
@@ -46,9 +43,7 @@ async def create_external_link(
 ):
 ):
     """Create a new external link."""
     """Create a new external link."""
     # Get the highest sort_order to place new link at end
     # Get the highest sort_order to place new link at end
-    result = await db.execute(
-        select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1)
-    )
+    result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1))
     last_link = result.scalar_one_or_none()
     last_link = result.scalar_one_or_none()
     next_order = (last_link.sort_order + 1) if last_link else 0
     next_order = (last_link.sort_order + 1) if last_link else 0
 
 
@@ -74,9 +69,7 @@ async def get_external_link(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get a specific external link."""
     """Get a specific external link."""
-    result = await db.execute(
-        select(ExternalLink).where(ExternalLink.id == link_id)
-    )
+    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()
 
 
     if not link:
     if not link:
@@ -92,9 +85,7 @@ async def update_external_link(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update an external link."""
     """Update an external link."""
-    result = await db.execute(
-        select(ExternalLink).where(ExternalLink.id == link_id)
-    )
+    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()
 
 
     if not link:
     if not link:
@@ -119,9 +110,7 @@ async def delete_external_link(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete an external link."""
     """Delete an external link."""
-    result = await db.execute(
-        select(ExternalLink).where(ExternalLink.id == link_id)
-    )
+    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()
 
 
     if not link:
     if not link:
@@ -144,9 +133,7 @@ async def reorder_external_links(
     """Update the sort order of external links."""
     """Update the sort order of external links."""
     # Update sort_order for each link based on position in the list
     # Update sort_order for each link based on position in the list
     for index, link_id in enumerate(reorder_data.ids):
     for index, link_id in enumerate(reorder_data.ids):
-        result = await db.execute(
-            select(ExternalLink).where(ExternalLink.id == link_id)
-        )
+        result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
         link = result.scalar_one_or_none()
         link = result.scalar_one_or_none()
         if link:
         if link:
             link.sort_order = index
             link.sort_order = index
@@ -154,9 +141,7 @@ async def reorder_external_links(
     await db.commit()
     await db.commit()
 
 
     # Return updated list
     # Return updated list
-    result = await db.execute(
-        select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)
-    )
+    result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
     links = result.scalars().all()
 
 
     logger.info(f"Reordered {len(reorder_data.ids)} external links")
     logger.info(f"Reordered {len(reorder_data.ids)} external links")
@@ -171,9 +156,7 @@ async def upload_icon(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Upload a custom icon for an external link."""
     """Upload a custom icon for an external link."""
-    result = await db.execute(
-        select(ExternalLink).where(ExternalLink.id == link_id)
-    )
+    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()
 
 
     if not link:
     if not link:
@@ -185,10 +168,7 @@ async def upload_icon(
 
 
     ext = Path(file.filename).suffix.lower()
     ext = Path(file.filename).suffix.lower()
     if ext not in ALLOWED_EXTENSIONS:
     if ext not in ALLOWED_EXTENSIONS:
-        raise HTTPException(
-            status_code=400,
-            detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
-        )
+        raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}")
 
 
     # Create icons directory if it doesn't exist
     # Create icons directory if it doesn't exist
     ICONS_DIR.mkdir(parents=True, exist_ok=True)
     ICONS_DIR.mkdir(parents=True, exist_ok=True)
@@ -224,9 +204,7 @@ async def delete_icon(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete the custom icon for an external link."""
     """Delete the custom icon for an external link."""
-    result = await db.execute(
-        select(ExternalLink).where(ExternalLink.id == link_id)
-    )
+    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()
 
 
     if not link:
     if not link:
@@ -250,9 +228,7 @@ async def get_icon(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the custom icon for an external link."""
     """Get the custom icon for an external link."""
-    result = await db.execute(
-        select(ExternalLink).where(ExternalLink.id == link_id)
-    )
+    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     link = result.scalar_one_or_none()
 
 
     if not link:
     if not link:

+ 103 - 33
backend/app/api/routes/filaments.py

@@ -1,26 +1,23 @@
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.schemas.filament import (
 from backend.app.schemas.filament import (
+    FilamentCostCalculation,
     FilamentCreate,
     FilamentCreate,
-    FilamentUpdate,
     FilamentResponse,
     FilamentResponse,
-    FilamentCostCalculation,
+    FilamentUpdate,
 )
 )
 
 
-
 router = APIRouter(prefix="/filaments", tags=["filaments"])
 router = APIRouter(prefix="/filaments", tags=["filaments"])
 
 
 
 
 @router.get("/", response_model=list[FilamentResponse])
 @router.get("/", response_model=list[FilamentResponse])
 async def list_filaments(db: AsyncSession = Depends(get_db)):
 async def list_filaments(db: AsyncSession = Depends(get_db)):
     """List all filaments."""
     """List all filaments."""
-    result = await db.execute(
-        select(Filament).order_by(Filament.type, Filament.name)
-    )
+    result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 
@@ -40,9 +37,7 @@ async def create_filament(
 @router.get("/{filament_id}", response_model=FilamentResponse)
 @router.get("/{filament_id}", response_model=FilamentResponse)
 async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
 async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific filament."""
     """Get a specific filament."""
-    result = await db.execute(
-        select(Filament).where(Filament.id == filament_id)
-    )
+    result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
     filament = result.scalar_one_or_none()
     if not filament:
     if not filament:
         raise HTTPException(404, "Filament not found")
         raise HTTPException(404, "Filament not found")
@@ -56,9 +51,7 @@ async def update_filament(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a filament."""
     """Update a filament."""
-    result = await db.execute(
-        select(Filament).where(Filament.id == filament_id)
-    )
+    result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
     filament = result.scalar_one_or_none()
     if not filament:
     if not filament:
         raise HTTPException(404, "Filament not found")
         raise HTTPException(404, "Filament not found")
@@ -74,9 +67,7 @@ async def update_filament(
 @router.delete("/{filament_id}")
 @router.delete("/{filament_id}")
 async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
 async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
     """Delete a filament."""
     """Delete a filament."""
-    result = await db.execute(
-        select(Filament).where(Filament.id == filament_id)
-    )
+    result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
     filament = result.scalar_one_or_none()
     if not filament:
     if not filament:
         raise HTTPException(404, "Filament not found")
         raise HTTPException(404, "Filament not found")
@@ -93,9 +84,7 @@ async def calculate_cost(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Calculate the cost for a given weight of filament."""
     """Calculate the cost for a given weight of filament."""
-    result = await db.execute(
-        select(Filament).where(Filament.id == filament_id)
-    )
+    result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
     filament = result.scalar_one_or_none()
     if not filament:
     if not filament:
         raise HTTPException(404, "Filament not found")
         raise HTTPException(404, "Filament not found")
@@ -117,11 +106,7 @@ async def get_filaments_by_type(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get all filaments of a specific type."""
     """Get all filaments of a specific type."""
-    result = await db.execute(
-        select(Filament)
-        .where(Filament.type.ilike(f"%{filament_type}%"))
-        .order_by(Filament.name)
-    )
+    result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 
@@ -129,15 +114,100 @@ async def get_filaments_by_type(
 async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
 async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
     """Seed the database with common filament types."""
     """Seed the database with common filament types."""
     defaults = [
     defaults = [
-        {"name": "Generic PLA", "type": "PLA", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 50, "bed_temp_max": 60, "density": 1.24},
-        {"name": "Generic PETG", "type": "PETG", "cost_per_kg": 25.0, "print_temp_min": 230, "print_temp_max": 250, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27},
-        {"name": "Generic ABS", "type": "ABS", "cost_per_kg": 22.0, "print_temp_min": 230, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.04},
-        {"name": "Generic TPU", "type": "TPU", "cost_per_kg": 35.0, "print_temp_min": 220, "print_temp_max": 250, "bed_temp_min": 40, "bed_temp_max": 60, "density": 1.21},
-        {"name": "Generic ASA", "type": "ASA", "cost_per_kg": 28.0, "print_temp_min": 240, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.07},
-        {"name": "Bambu PLA Basic", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24},
-        {"name": "Bambu PLA Matte", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24},
-        {"name": "Bambu PETG Basic", "type": "PETG", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 250, "print_temp_max": 270, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27},
-        {"name": "Bambu ABS", "type": "ABS", "brand": "Bambu Lab", "cost_per_kg": 30.0, "print_temp_min": 260, "print_temp_max": 280, "bed_temp_min": 90, "bed_temp_max": 100, "density": 1.04},
+        {
+            "name": "Generic PLA",
+            "type": "PLA",
+            "cost_per_kg": 20.0,
+            "print_temp_min": 190,
+            "print_temp_max": 220,
+            "bed_temp_min": 50,
+            "bed_temp_max": 60,
+            "density": 1.24,
+        },
+        {
+            "name": "Generic PETG",
+            "type": "PETG",
+            "cost_per_kg": 25.0,
+            "print_temp_min": 230,
+            "print_temp_max": 250,
+            "bed_temp_min": 70,
+            "bed_temp_max": 80,
+            "density": 1.27,
+        },
+        {
+            "name": "Generic ABS",
+            "type": "ABS",
+            "cost_per_kg": 22.0,
+            "print_temp_min": 230,
+            "print_temp_max": 260,
+            "bed_temp_min": 90,
+            "bed_temp_max": 110,
+            "density": 1.04,
+        },
+        {
+            "name": "Generic TPU",
+            "type": "TPU",
+            "cost_per_kg": 35.0,
+            "print_temp_min": 220,
+            "print_temp_max": 250,
+            "bed_temp_min": 40,
+            "bed_temp_max": 60,
+            "density": 1.21,
+        },
+        {
+            "name": "Generic ASA",
+            "type": "ASA",
+            "cost_per_kg": 28.0,
+            "print_temp_min": 240,
+            "print_temp_max": 260,
+            "bed_temp_min": 90,
+            "bed_temp_max": 110,
+            "density": 1.07,
+        },
+        {
+            "name": "Bambu PLA Basic",
+            "type": "PLA",
+            "brand": "Bambu Lab",
+            "cost_per_kg": 20.0,
+            "print_temp_min": 190,
+            "print_temp_max": 220,
+            "bed_temp_min": 35,
+            "bed_temp_max": 55,
+            "density": 1.24,
+        },
+        {
+            "name": "Bambu PLA Matte",
+            "type": "PLA",
+            "brand": "Bambu Lab",
+            "cost_per_kg": 25.0,
+            "print_temp_min": 190,
+            "print_temp_max": 220,
+            "bed_temp_min": 35,
+            "bed_temp_max": 55,
+            "density": 1.24,
+        },
+        {
+            "name": "Bambu PETG Basic",
+            "type": "PETG",
+            "brand": "Bambu Lab",
+            "cost_per_kg": 25.0,
+            "print_temp_min": 250,
+            "print_temp_max": 270,
+            "bed_temp_min": 70,
+            "bed_temp_max": 80,
+            "density": 1.27,
+        },
+        {
+            "name": "Bambu ABS",
+            "type": "ABS",
+            "brand": "Bambu Lab",
+            "cost_per_kg": 30.0,
+            "print_temp_min": 260,
+            "print_temp_max": 280,
+            "bed_temp_min": 90,
+            "bed_temp_max": 100,
+            "density": 1.04,
+        },
     ]
     ]
 
 
     created = 0
     created = 0

+ 5 - 9
backend/app/api/routes/kprofiles.py

@@ -4,19 +4,19 @@ import asyncio
 import logging
 import logging
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.database import get_db
 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.models.kprofile_note import KProfileNote as KProfileNoteModel
+from backend.app.models.printer import Printer
 from backend.app.schemas.kprofile import (
 from backend.app.schemas.kprofile import (
     KProfile,
     KProfile,
     KProfileCreate,
     KProfileCreate,
     KProfileDelete,
     KProfileDelete,
-    KProfilesResponse,
     KProfileNote,
     KProfileNote,
     KProfileNoteResponse,
     KProfileNoteResponse,
+    KProfilesResponse,
 )
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 
 
@@ -293,15 +293,11 @@ async def get_kprofile_notes(
         raise HTTPException(404, "Printer not found")
         raise HTTPException(404, "Printer not found")
 
 
     # Get all notes for this printer
     # Get all notes for this printer
-    result = await db.execute(
-        select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id)
-    )
+    result = await db.execute(select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id))
     notes = result.scalars().all()
     notes = result.scalars().all()
 
 
     # Return as a dictionary mapping setting_id -> note
     # Return as a dictionary mapping setting_id -> note
-    return KProfileNoteResponse(
-        notes={note.setting_id: note.note for note in notes}
-    )
+    return KProfileNoteResponse(notes={note.setting_id: note.note for note in notes})
 
 
 
 
 @router.put("/notes", response_model=dict)
 @router.put("/notes", response_model=dict)

+ 9 - 16
backend/app/api/routes/notification_templates.py

@@ -5,15 +5,15 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
-from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
 from backend.app.schemas.notification_template import (
 from backend.app.schemas.notification_template import (
+    EVENT_VARIABLES,
+    SAMPLE_DATA,
+    EventVariablesResponse,
     NotificationTemplateResponse,
     NotificationTemplateResponse,
     NotificationTemplateUpdate,
     NotificationTemplateUpdate,
-    EventVariablesResponse,
     TemplatePreviewRequest,
     TemplatePreviewRequest,
     TemplatePreviewResponse,
     TemplatePreviewResponse,
-    EVENT_VARIABLES,
-    SAMPLE_DATA,
 )
 )
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 
 
@@ -38,9 +38,7 @@ EVENT_NAMES = {
 @router.get("", response_model=list[NotificationTemplateResponse])
 @router.get("", response_model=list[NotificationTemplateResponse])
 async def get_templates(db: AsyncSession = Depends(get_db)):
 async def get_templates(db: AsyncSession = Depends(get_db)):
     """Get all notification templates."""
     """Get all notification templates."""
-    result = await db.execute(
-        select(NotificationTemplate).order_by(NotificationTemplate.id)
-    )
+    result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
     return result.scalars().all()
     return result.scalars().all()
 
 
 
 
@@ -60,9 +58,7 @@ async def get_variables():
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
 async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
 async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
     """Get a single notification template."""
     """Get a single notification template."""
-    result = await db.execute(
-        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
-    )
+    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
     template = result.scalar_one_or_none()
     if not template:
     if not template:
         raise HTTPException(status_code=404, detail="Template not found")
         raise HTTPException(status_code=404, detail="Template not found")
@@ -76,9 +72,7 @@ async def update_template(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a notification template."""
     """Update a notification template."""
-    result = await db.execute(
-        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
-    )
+    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
     template = result.scalar_one_or_none()
     if not template:
     if not template:
         raise HTTPException(status_code=404, detail="Template not found")
         raise HTTPException(status_code=404, detail="Template not found")
@@ -100,9 +94,7 @@ async def update_template(
 @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
 @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
 async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
 async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
     """Reset a notification template to its default values."""
     """Reset a notification template to its default values."""
-    result = await db.execute(
-        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
-    )
+    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
     template = result.scalar_one_or_none()
     if not template:
     if not template:
         raise HTTPException(status_code=404, detail="Template not found")
         raise HTTPException(status_code=404, detail="Template not found")
@@ -139,6 +131,7 @@ async def preview_template(request: TemplatePreviewRequest):
             result = result.replace("{" + key + "}", str(value))
             result = result.replace("{" + key + "}", str(value))
         # Remove any remaining unreplaced placeholders
         # Remove any remaining unreplaced placeholders
         import re
         import re
+
         result = re.sub(r"\{[a-z_]+\}", "", result)
         result = re.sub(r"\{[a-z_]+\}", "", result)
         return result
         return result
 
 

+ 40 - 53
backend/app/api/routes/notifications.py

@@ -74,12 +74,11 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
 # Provider List/Create Routes (no path parameters)
 # Provider List/Create Routes (no path parameters)
 # ============================================================================
 # ============================================================================
 
 
+
 @router.get("/", response_model=list[NotificationProviderResponse])
 @router.get("/", response_model=list[NotificationProviderResponse])
 async def list_notification_providers(db: AsyncSession = Depends(get_db)):
 async def list_notification_providers(db: AsyncSession = Depends(get_db)):
     """List all notification providers."""
     """List all notification providers."""
-    result = await db.execute(
-        select(NotificationProvider).order_by(NotificationProvider.created_at.desc())
-    )
+    result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
     providers = result.scalars().all()
     providers = result.scalars().all()
 
 
     return [_provider_to_dict(provider) for provider in providers]
     return [_provider_to_dict(provider) for provider in providers]
@@ -137,6 +136,7 @@ async def create_notification_provider(
 # Static Path Routes (must come BEFORE parameterized routes)
 # Static Path Routes (must come BEFORE parameterized routes)
 # ============================================================================
 # ============================================================================
 
 
+
 @router.post("/test-config", response_model=NotificationTestResponse)
 @router.post("/test-config", response_model=NotificationTestResponse)
 async def test_notification_config(
 async def test_notification_config(
     test_request: NotificationTestRequest,
     test_request: NotificationTestRequest,
@@ -153,9 +153,7 @@ async def test_notification_config(
 @router.post("/test-all")
 @router.post("/test-all")
 async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
 async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
     """Send a test notification to all enabled providers."""
     """Send a test notification to all enabled providers."""
-    result = await db.execute(
-        select(NotificationProvider).where(NotificationProvider.enabled == True)
-    )
+    result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     providers = result.scalars().all()
     providers = result.scalars().all()
 
 
     if not providers:
     if not providers:
@@ -167,9 +165,7 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
 
 
     for provider in providers:
     for provider in providers:
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
         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, db
-        )
+        success, message = await notification_service.send_test_notification(provider.provider_type, config, db)
 
 
         # Update provider status
         # Update provider status
         if success:
         if success:
@@ -180,13 +176,15 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
             provider.last_error_at = datetime.utcnow()
             provider.last_error_at = datetime.utcnow()
             failed_count += 1
             failed_count += 1
 
 
-        results.append({
-            "provider_id": provider.id,
-            "provider_name": provider.name,
-            "provider_type": provider.provider_type,
-            "success": success,
-            "message": message,
-        })
+        results.append(
+            {
+                "provider_id": provider.id,
+                "provider_name": provider.name,
+                "provider_type": provider.provider_type,
+                "success": success,
+                "message": message,
+            }
+        )
 
 
     await db.commit()
     await db.commit()
 
 
@@ -202,6 +200,7 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
 # Notification Log Routes (must come BEFORE /{provider_id} routes)
 # Notification Log Routes (must come BEFORE /{provider_id} routes)
 # ============================================================================
 # ============================================================================
 
 
+
 @router.get("/logs", response_model=list[NotificationLogResponse])
 @router.get("/logs", response_model=list[NotificationLogResponse])
 async def get_notification_logs(
 async def get_notification_logs(
     limit: int = Query(default=100, ge=1, le=500),
     limit: int = Query(default=100, ge=1, le=500),
@@ -243,20 +242,22 @@ async def get_notification_logs(
             providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
             providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
 
 
         provider = providers_cache[log.provider_id]
         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,
-        ))
+        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
     return response
 
 
@@ -270,15 +271,12 @@ async def get_notification_log_stats(
     cutoff = datetime.utcnow() - timedelta(days=days)
     cutoff = datetime.utcnow() - timedelta(days=days)
 
 
     # Total counts
     # Total counts
-    total_result = await db.execute(
-        select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff)
-    )
+    total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
     total = total_result.scalar() or 0
     total = total_result.scalar() or 0
 
 
     success_result = await db.execute(
     success_result = await db.execute(
         select(func.count(NotificationLog.id)).where(
         select(func.count(NotificationLog.id)).where(
-            NotificationLog.created_at >= cutoff,
-            NotificationLog.success == True
+            NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
         )
         )
     )
     )
     success_count = success_result.scalar() or 0
     success_count = success_result.scalar() or 0
@@ -317,9 +315,7 @@ async def clear_notification_logs(
     """Clear old notification logs."""
     """Clear old notification logs."""
     cutoff = datetime.utcnow() - timedelta(days=older_than_days)
     cutoff = datetime.utcnow() - timedelta(days=older_than_days)
 
 
-    result = await db.execute(
-        delete(NotificationLog).where(NotificationLog.created_at < cutoff)
-    )
+    result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
     await db.commit()
     await db.commit()
 
 
     deleted_count = result.rowcount
     deleted_count = result.rowcount
@@ -332,15 +328,14 @@ async def clear_notification_logs(
 # Provider Instance Routes (parameterized - must come LAST)
 # Provider Instance Routes (parameterized - must come LAST)
 # ============================================================================
 # ============================================================================
 
 
+
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 async def get_notification_provider(
 async def get_notification_provider(
     provider_id: int,
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get a specific notification provider."""
     """Get a specific notification provider."""
-    result = await db.execute(
-        select(NotificationProvider).where(NotificationProvider.id == provider_id)
-    )
+    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     provider = result.scalar_one_or_none()
     provider = result.scalar_one_or_none()
 
 
     if not provider:
     if not provider:
@@ -356,9 +351,7 @@ async def update_notification_provider(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a notification provider."""
     """Update a notification provider."""
-    result = await db.execute(
-        select(NotificationProvider).where(NotificationProvider.id == provider_id)
-    )
+    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     provider = result.scalar_one_or_none()
     provider = result.scalar_one_or_none()
 
 
     if not provider:
     if not provider:
@@ -389,9 +382,7 @@ async def delete_notification_provider(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a notification provider."""
     """Delete a notification provider."""
-    result = await db.execute(
-        select(NotificationProvider).where(NotificationProvider.id == provider_id)
-    )
+    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     provider = result.scalar_one_or_none()
     provider = result.scalar_one_or_none()
 
 
     if not provider:
     if not provider:
@@ -412,18 +403,14 @@ async def test_notification_provider(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Send a test notification using an existing provider."""
     """Send a test notification using an existing provider."""
-    result = await db.execute(
-        select(NotificationProvider).where(NotificationProvider.id == provider_id)
-    )
+    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     provider = result.scalar_one_or_none()
     provider = result.scalar_one_or_none()
 
 
     if not provider:
     if not provider:
         raise HTTPException(status_code=404, detail="Notification provider not found")
         raise HTTPException(status_code=404, detail="Notification provider not found")
 
 
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     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, db
-    )
+    success, message = await notification_service.send_test_notification(provider.provider_type, config, db)
 
 
     # Update provider status
     # Update provider status
     if success:
     if success:

+ 48 - 29
backend/app/api/routes/print_queue.py

@@ -4,18 +4,18 @@ import logging
 from datetime import datetime
 from datetime import datetime
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
-from backend.app.models.archive import PrintArchive
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
     PrintQueueItemCreate,
     PrintQueueItemCreate,
-    PrintQueueItemUpdate,
     PrintQueueItemResponse,
     PrintQueueItemResponse,
+    PrintQueueItemUpdate,
     PrintQueueReorder,
     PrintQueueReorder,
 )
 )
 
 
@@ -89,6 +89,7 @@ async def add_to_queue(
         scheduled_time=data.scheduled_time,
         scheduled_time=data.scheduled_time,
         require_previous_success=data.require_previous_success,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
         auto_off_after=data.auto_off_after,
+        manual_start=data.manual_start,
         position=max_pos + 1,
         position=max_pos + 1,
         status="pending",
         status="pending",
     )
     )
@@ -124,9 +125,7 @@ async def update_queue_item(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a queue item."""
     """Update a queue item."""
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -138,9 +137,7 @@ async def update_queue_item(
 
 
     # Validate new printer_id if being changed
     # Validate new printer_id if being changed
     if "printer_id" in update_data:
     if "printer_id" in update_data:
-        result = await db.execute(
-            select(Printer).where(Printer.id == update_data["printer_id"])
-        )
+        result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
@@ -157,9 +154,7 @@ async def update_queue_item(
 @router.delete("/{item_id}")
 @router.delete("/{item_id}")
 async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
 async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Remove an item from the queue."""
     """Remove an item from the queue."""
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -181,9 +176,7 @@ async def reorder_queue(
 ):
 ):
     """Bulk update positions for queue items."""
     """Bulk update positions for queue items."""
     for reorder_item in data.items:
     for reorder_item in data.items:
-        result = await db.execute(
-            select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id)
-        )
+        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id))
         item = result.scalar_one_or_none()
         item = result.scalar_one_or_none()
         if item and item.status == "pending":
         if item and item.status == "pending":
             item.position = reorder_item.position
             item.position = reorder_item.position
@@ -196,9 +189,7 @@ async def reorder_queue(
 @router.post("/{item_id}/cancel")
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
 async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Cancel a pending queue item."""
     """Cancel a pending queue item."""
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -220,14 +211,13 @@ async def stop_queue_item(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Stop an actively printing queue item."""
     """Stop an actively printing queue item."""
+    import asyncio
+
+    from backend.app.models.smart_plug import SmartPlug
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.tasmota import tasmota_service
     from backend.app.services.tasmota import tasmota_service
-    from backend.app.models.smart_plug import SmartPlug
-    import asyncio
 
 
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -257,9 +247,7 @@ async def stop_queue_item(
     # Get smart plug info if auto-off is enabled
     # Get smart plug info if auto-off is enabled
     plug_ip = None
     plug_ip = None
     if auto_off_after:
     if auto_off_after:
-        result = await db.execute(
-            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-        )
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         plug = result.scalar_one_or_none()
         plug = result.scalar_one_or_none()
         if plug and plug.enabled:
         if plug and plug.enabled:
             plug_ip = plug.ip_address
             plug_ip = plug.ip_address
@@ -268,15 +256,15 @@ async def stop_queue_item(
 
 
     # Schedule background task for cooldown + power off
     # Schedule background task for cooldown + power off
     if plug_ip:
     if plug_ip:
+
         async def cooldown_and_poweroff():
         async def cooldown_and_poweroff():
             logger.info(f"Auto-off: Waiting for printer {printer_id} to cool down before power off...")
             logger.info(f"Auto-off: Waiting for printer {printer_id} to cool down before power off...")
             await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
             await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
             # Re-fetch plug since we're in a new async context
             # Re-fetch plug since we're in a new async context
             from backend.app.core.database import async_session
             from backend.app.core.database import async_session
+
             async with async_session() as new_db:
             async with async_session() as new_db:
-                result = await new_db.execute(
-                    select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-                )
+                result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug and plug.enabled:
                 if plug and plug.enabled:
                     logger.info(f"Auto-off: Powering off printer {printer_id}")
                     logger.info(f"Auto-off: Powering off printer {printer_id}")
@@ -285,3 +273,34 @@ async def stop_queue_item(
         asyncio.create_task(cooldown_and_poweroff())
         asyncio.create_task(cooldown_and_poweroff())
 
 
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
+
+
+@router.post("/{item_id}/start")
+async def start_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Manually start a staged (manual_start) queue item.
+
+    This clears the manual_start flag so the scheduler will pick it up,
+    or starts immediately if the printer is ready.
+    """
+    result = await db.execute(
+        select(PrintQueueItem)
+        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .where(PrintQueueItem.id == item_id)
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Queue item not found")
+
+    if item.status != "pending":
+        raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
+
+    # Clear manual_start flag so scheduler picks it up
+    item.manual_start = False
+    await db.commit()
+    await db.refresh(item, ["archive", "printer"])
+
+    logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
+    return _enrich_response(item)

+ 84 - 20
backend/app/api/routes/printers.py

@@ -1,4 +1,6 @@
+import asyncio
 import logging
 import logging
+import re
 import zipfile
 import zipfile
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
@@ -447,12 +449,21 @@ async def get_printer_cover(
     if not subtask_name:
     if not subtask_name:
         raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
         raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
 
 
+    # Extract plate number from gcode_file (e.g., "/data/Metadata/plate_12.gcode" -> 12)
+    plate_num = 1
+    gcode_file = state.gcode_file
+    if gcode_file:
+        match = re.search(r"plate_(\d+)\.gcode", gcode_file)
+        if match:
+            plate_num = int(match.group(1))
+            logger.info(f"Detected plate number {plate_num} from gcode_file: {gcode_file}")
+
     # Normalize view parameter
     # Normalize view parameter
     view_key = view or "default"
     view_key = view or "default"
 
 
-    # Check cache
+    # Check cache - include plate_num in cache key for multi-plate projects
     if printer_id in _cover_cache:
     if printer_id in _cover_cache:
-        cache_key = (subtask_name, view_key)
+        cache_key = (subtask_name, plate_num, view_key)
         if cache_key in _cover_cache[printer_id]:
         if cache_key in _cover_cache[printer_id]:
             return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
             return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
 
 
@@ -475,16 +486,31 @@ async def get_printer_cover(
 
 
     logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}")
     logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}")
 
 
-    try:
-        downloaded = await download_file_try_paths_async(
-            printer.ip_address,
-            printer.access_code,
-            remote_paths,
-            temp_path,
-        )
-    except Exception as e:
-        logger.error(f"FTP download exception: {e}")
-        raise HTTPException(500, f"FTP download failed: {e}")
+    # Retry logic for transient FTP failures
+    max_retries = 2
+    last_error = None
+    downloaded = False
+
+    for attempt in range(max_retries + 1):
+        try:
+            downloaded = await download_file_try_paths_async(
+                printer.ip_address,
+                printer.access_code,
+                remote_paths,
+                temp_path,
+            )
+            if downloaded:
+                break
+        except Exception as e:
+            last_error = e
+            if attempt < max_retries:
+                logger.warning(f"FTP download attempt {attempt + 1} failed: {e}, retrying...")
+                await asyncio.sleep(0.5 * (attempt + 1))  # Brief backoff
+            else:
+                logger.error(f"FTP download failed after {max_retries + 1} attempts: {e}")
+
+    if last_error and not downloaded:
+        raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
 
 
     if not downloaded:
     if not downloaded:
         raise HTTPException(
         raise HTTPException(
@@ -513,21 +539,24 @@ async def get_printer_cover(
 
 
         try:
         try:
             # Try common thumbnail paths in 3MF files
             # Try common thumbnail paths in 3MF files
+            # Use plate_num to get the correct plate's thumbnail for multi-plate projects
             # Use top-down view if requested (better for skip objects modal)
             # Use top-down view if requested (better for skip objects modal)
             if view == "top":
             if view == "top":
                 thumbnail_paths = [
                 thumbnail_paths = [
+                    f"Metadata/top_{plate_num}.png",
+                    # Fall back to plate 1 if specific plate not found
                     "Metadata/top_1.png",
                     "Metadata/top_1.png",
-                    "Metadata/top_2.png",
-                    "Metadata/top_3.png",
-                    "Metadata/top_4.png",
-                    # Fall back to regular views if no top view
+                    f"Metadata/plate_{plate_num}.png",
                     "Metadata/plate_1.png",
                     "Metadata/plate_1.png",
                     "Metadata/thumbnail.png",
                     "Metadata/thumbnail.png",
                 ]
                 ]
             else:
             else:
                 thumbnail_paths = [
                 thumbnail_paths = [
+                    f"Metadata/plate_{plate_num}.png",
+                    # Fall back to plate 1 if specific plate not found
                     "Metadata/plate_1.png",
                     "Metadata/plate_1.png",
                     "Metadata/thumbnail.png",
                     "Metadata/thumbnail.png",
+                    f"Metadata/plate_{plate_num}_small.png",
                     "Metadata/plate_1_small.png",
                     "Metadata/plate_1_small.png",
                     "Thumbnails/thumbnail.png",
                     "Thumbnails/thumbnail.png",
                     "thumbnail.png",
                     "thumbnail.png",
@@ -536,10 +565,10 @@ async def get_printer_cover(
             for thumb_path in thumbnail_paths:
             for thumb_path in thumbnail_paths:
                 try:
                 try:
                     image_data = zf.read(thumb_path)
                     image_data = zf.read(thumb_path)
-                    # Cache the result
+                    # Cache the result - include plate_num in cache key
                     if printer_id not in _cover_cache:
                     if printer_id not in _cover_cache:
                         _cover_cache[printer_id] = {}
                         _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
+                    _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
                     return Response(content=image_data, media_type="image/png")
                     return Response(content=image_data, media_type="image/png")
                 except KeyError:
                 except KeyError:
                     continue
                     continue
@@ -550,7 +579,7 @@ async def get_printer_cover(
                     image_data = zf.read(name)
                     image_data = zf.read(name)
                     if printer_id not in _cover_cache:
                     if printer_id not in _cover_cache:
                         _cover_cache[printer_id] = {}
                         _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
+                    _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
                     return Response(content=image_data, media_type="image/png")
                     return Response(content=image_data, media_type="image/png")
 
 
             raise HTTPException(404, "No thumbnail found in 3MF file")
             raise HTTPException(404, "No thumbnail found in 3MF file")
@@ -1152,9 +1181,10 @@ async def get_printable_objects(
                 if downloaded and temp_path.exists():
                 if downloaded and temp_path.exists():
                     with open(temp_path, "rb") as f:
                     with open(temp_path, "rb") as f:
                         data = f.read()
                         data = f.read()
-                    objects = extract_printable_objects_from_3mf(data, include_positions=True)
+                    objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)
                     if objects:
                     if objects:
                         client.state.printable_objects = objects
                         client.state.printable_objects = objects
+                        client.state.printable_objects_bbox_all = bbox_all
                         logger.info(f"Reloaded {len(objects)} objects for printer {printer_id}")
                         logger.info(f"Reloaded {len(objects)} objects for printer {printer_id}")
             except Exception as e:
             except Exception as e:
                 logger.debug(f"Failed to reload objects from printer: {e}")
                 logger.debug(f"Failed to reload objects from printer: {e}")
@@ -1190,6 +1220,7 @@ async def get_printable_objects(
         "total": len(objects),
         "total": len(objects),
         "skipped_count": len(client.state.skipped_objects),
         "skipped_count": len(client.state.skipped_objects),
         "is_printing": client.state.state in ("RUNNING", "PAUSE"),
         "is_printing": client.state.state in ("RUNNING", "PAUSE"),
+        "bbox_all": getattr(client.state, "printable_objects_bbox_all", None),
     }
     }
 
 
 
 
@@ -1268,3 +1299,36 @@ async def refresh_ams_slot(
         raise HTTPException(400, message)
         raise HTTPException(400, message)
 
 
     return {"success": True, "message": message}
     return {"success": True, "message": message}
+
+
+@router.get("/{printer_id}/runtime-debug")
+async def get_runtime_debug(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Debug endpoint: Get runtime tracking status for a printer."""
+    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")
+
+    state = printer_manager.get_status(printer_id)
+
+    return {
+        "printer_name": printer.name,
+        "runtime_seconds": printer.runtime_seconds,
+        "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
+        "print_hours_offset": printer.print_hours_offset,
+        "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
+        + (printer.print_hours_offset or 0),
+        "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
+        "mqtt_state": {
+            "connected": state.connected if state else False,
+            "state": state.state if state else None,
+            "progress": state.progress if state else None,
+            "gcode_file": state.gcode_file if state else None,
+        }
+        if state
+        else None,
+        "is_active": printer.is_active,
+    }

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

@@ -478,6 +478,7 @@ async def export_backup(
                     "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
                     "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
                     "require_previous_success": qi.require_previous_success,
                     "require_previous_success": qi.require_previous_success,
                     "auto_off_after": qi.auto_off_after,
                     "auto_off_after": qi.auto_off_after,
+                    "manual_start": qi.manual_start,
                     "status": qi.status,
                     "status": qi.status,
                     "started_at": qi.started_at.isoformat() if qi.started_at else None,
                     "started_at": qi.started_at.isoformat() if qi.started_at else None,
                     "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
                     "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
@@ -1468,6 +1469,7 @@ async def import_backup(
                 position=qi_data.get("position", 0),
                 position=qi_data.get("position", 0),
                 require_previous_success=qi_data.get("require_previous_success", False),
                 require_previous_success=qi_data.get("require_previous_success", False),
                 auto_off_after=qi_data.get("auto_off_after", False),
                 auto_off_after=qi_data.get("auto_off_after", False),
+                manual_start=qi_data.get("manual_start", False),
                 status=qi_data.get("status", "pending"),
                 status=qi_data.get("status", "pending"),
                 error_message=qi_data.get("error_message"),
                 error_message=qi_data.get("error_message"),
             )
             )
@@ -1590,22 +1592,26 @@ async def import_backup(
             vp_enabled = await get_setting(db, "virtual_printer_enabled")
             vp_enabled = await get_setting(db, "virtual_printer_enabled")
             vp_access_code = await get_setting(db, "virtual_printer_access_code")
             vp_access_code = await get_setting(db, "virtual_printer_access_code")
             vp_mode = await get_setting(db, "virtual_printer_mode")
             vp_mode = await get_setting(db, "virtual_printer_mode")
+            vp_model = await get_setting(db, "virtual_printer_model")
 
 
             enabled = vp_enabled and vp_enabled.lower() == "true"
             enabled = vp_enabled and vp_enabled.lower() == "true"
             access_code = vp_access_code or ""
             access_code = vp_access_code or ""
             mode = vp_mode or "immediate"
             mode = vp_mode or "immediate"
+            model = vp_model or ""
 
 
             if enabled and access_code:
             if enabled and access_code:
                 await virtual_printer_manager.configure(
                 await virtual_printer_manager.configure(
                     enabled=True,
                     enabled=True,
                     access_code=access_code,
                     access_code=access_code,
                     mode=mode,
                     mode=mode,
+                    model=model,
                 )
                 )
             elif not enabled and virtual_printer_manager.is_enabled:
             elif not enabled and virtual_printer_manager.is_enabled:
                 await virtual_printer_manager.configure(
                 await virtual_printer_manager.configure(
                     enabled=False,
                     enabled=False,
                     access_code=access_code,
                     access_code=access_code,
                     mode=mode,
                     mode=mode,
+                    model=model,
                 )
                 )
         except Exception:
         except Exception:
             pass  # Virtual printer config failed, but don't fail the restore
             pass  # Virtual printer config failed, but don't fail the restore
@@ -1647,19 +1653,38 @@ async def import_backup(
 # =============================================================================
 # =============================================================================
 
 
 
 
+@router.get("/virtual-printer/models")
+async def get_virtual_printer_models():
+    """Get available virtual printer models."""
+    from backend.app.services.virtual_printer import (
+        DEFAULT_VIRTUAL_PRINTER_MODEL,
+        VIRTUAL_PRINTER_MODELS,
+    )
+
+    return {
+        "models": VIRTUAL_PRINTER_MODELS,
+        "default": DEFAULT_VIRTUAL_PRINTER_MODEL,
+    }
+
+
 @router.get("/virtual-printer")
 @router.get("/virtual-printer")
 async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
 async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
     """Get virtual printer settings and status."""
     """Get virtual printer settings and status."""
-    from backend.app.services.virtual_printer import virtual_printer_manager
+    from backend.app.services.virtual_printer import (
+        DEFAULT_VIRTUAL_PRINTER_MODEL,
+        virtual_printer_manager,
+    )
 
 
     enabled = await get_setting(db, "virtual_printer_enabled")
     enabled = await get_setting(db, "virtual_printer_enabled")
     access_code = await get_setting(db, "virtual_printer_access_code")
     access_code = await get_setting(db, "virtual_printer_access_code")
     mode = await get_setting(db, "virtual_printer_mode")
     mode = await get_setting(db, "virtual_printer_mode")
+    model = await get_setting(db, "virtual_printer_model")
 
 
     return {
     return {
         "enabled": enabled == "true" if enabled else False,
         "enabled": enabled == "true" if enabled else False,
         "access_code_set": bool(access_code),
         "access_code_set": bool(access_code),
         "mode": mode or "immediate",
         "mode": mode or "immediate",
+        "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "status": virtual_printer_manager.get_status(),
         "status": virtual_printer_manager.get_status(),
     }
     }
 
 
@@ -1669,20 +1694,27 @@ async def update_virtual_printer_settings(
     enabled: bool = None,
     enabled: bool = None,
     access_code: str = None,
     access_code: str = None,
     mode: str = None,
     mode: str = None,
+    model: str = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update virtual printer settings and restart services if needed."""
     """Update virtual printer settings and restart services if needed."""
-    from backend.app.services.virtual_printer import virtual_printer_manager
+    from backend.app.services.virtual_printer import (
+        DEFAULT_VIRTUAL_PRINTER_MODEL,
+        VIRTUAL_PRINTER_MODELS,
+        virtual_printer_manager,
+    )
 
 
     # Get current values
     # Get current values
     current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
     current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
     current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
     current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
+    current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
 
 
     # Apply updates
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
     new_enabled = enabled if enabled is not None else current_enabled
     new_access_code = access_code if access_code is not None else current_access_code
     new_access_code = access_code if access_code is not None else current_access_code
     new_mode = mode if mode is not None else current_mode
     new_mode = mode if mode is not None else current_mode
+    new_model = model if model is not None else current_model
 
 
     # Validate mode
     # Validate mode
     if new_mode not in ("immediate", "queue"):
     if new_mode not in ("immediate", "queue"):
@@ -1691,6 +1723,13 @@ async def update_virtual_printer_settings(
             content={"detail": "Mode must be 'immediate' or 'queue'"},
             content={"detail": "Mode must be 'immediate' or 'queue'"},
         )
         )
 
 
+    # Validate model
+    if model is not None and model not in VIRTUAL_PRINTER_MODELS:
+        return JSONResponse(
+            status_code=400,
+            content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
+        )
+
     # Validate access code when enabling
     # Validate access code when enabling
     if new_enabled and not new_access_code:
     if new_enabled and not new_access_code:
         return JSONResponse(
         return JSONResponse(
@@ -1710,6 +1749,8 @@ async def update_virtual_printer_settings(
     if access_code is not None:
     if access_code is not None:
         await set_setting(db, "virtual_printer_access_code", access_code)
         await set_setting(db, "virtual_printer_access_code", access_code)
     await set_setting(db, "virtual_printer_mode", new_mode)
     await set_setting(db, "virtual_printer_mode", new_mode)
+    if model is not None:
+        await set_setting(db, "virtual_printer_model", model)
     await db.commit()
     await db.commit()
 
 
     # Reconfigure virtual printer
     # Reconfigure virtual printer
@@ -1718,6 +1759,7 @@ async def update_virtual_printer_settings(
             enabled=new_enabled,
             enabled=new_enabled,
             access_code=new_access_code,
             access_code=new_access_code,
             mode=new_mode,
             mode=new_mode,
+            model=new_model,
         )
         )
     except ValueError as e:
     except ValueError as e:
         return JSONResponse(
         return JSONResponse(

+ 28 - 33
backend/app/api/routes/system.py

@@ -1,20 +1,19 @@
 """System information API routes."""
 """System information API routes."""
 
 
-import os
 import platform
 import platform
-import psutil
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
+import psutil
 from fastapi import APIRouter, Depends
 from fastapi import APIRouter, Depends
-from sqlalchemy import select, func
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.config import settings, APP_VERSION
+from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
-from backend.app.models.printer import Printer
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
+from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
@@ -26,7 +25,7 @@ def get_directory_size(path: Path) -> int:
     """Calculate total size of a directory in bytes."""
     """Calculate total size of a directory in bytes."""
     total = 0
     total = 0
     try:
     try:
-        for entry in path.rglob('*'):
+        for entry in path.rglob("*"):
             if entry.is_file():
             if entry.is_file():
                 total += entry.stat().st_size
                 total += entry.stat().st_size
     except (PermissionError, OSError):
     except (PermissionError, OSError):
@@ -36,7 +35,7 @@ def get_directory_size(path: Path) -> int:
 
 
 def format_bytes(bytes_value: int) -> str:
 def format_bytes(bytes_value: int) -> str:
     """Format bytes to human-readable string."""
     """Format bytes to human-readable string."""
-    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
+    for unit in ["B", "KB", "MB", "GB", "TB"]:
         if bytes_value < 1024:
         if bytes_value < 1024:
             return f"{bytes_value:.1f} {unit}"
             return f"{bytes_value:.1f} {unit}"
         bytes_value /= 1024
         bytes_value /= 1024
@@ -72,29 +71,25 @@ async def get_system_info(db: AsyncSession = Depends(get_db)):
     smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
     smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
 
 
     # Archive stats by status
     # Archive stats by status
-    completed_count = await db.scalar(
-        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")
-    )
-    failed_count = await db.scalar(
-        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")
-    )
-    printing_count = await db.scalar(
-        select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing")
-    )
+    completed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
+    failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
+    printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing"))
 
 
     # Total print time
     # Total print time
-    total_print_time = await db.scalar(
-        select(func.sum(PrintArchive.print_time_seconds)).where(
-            PrintArchive.print_time_seconds.isnot(None)
+    total_print_time = (
+        await db.scalar(
+            select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None))
         )
         )
-    ) or 0
+        or 0
+    )
 
 
     # Total filament used
     # Total filament used
-    total_filament = await db.scalar(
-        select(func.sum(PrintArchive.filament_used_grams)).where(
-            PrintArchive.filament_used_grams.isnot(None)
+    total_filament = (
+        await db.scalar(
+            select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None))
         )
         )
-    ) or 0
+        or 0
+    )
 
 
     # Connected printers
     # Connected printers
     connected_printers = []
     connected_printers = []
@@ -102,18 +97,18 @@ async def get_system_info(db: AsyncSession = Depends(get_db)):
         state = client.state
         state = client.state
         if state and state.connected:
         if state and state.connected:
             # Get printer name and model from database
             # Get printer name and model from database
-            result = await db.execute(
-                select(Printer.name, Printer.model).where(Printer.id == printer_id)
-            )
+            result = await db.execute(select(Printer.name, Printer.model).where(Printer.id == printer_id))
             row = result.first()
             row = result.first()
             name = row[0] if row else f"Printer {printer_id}"
             name = row[0] if row else f"Printer {printer_id}"
             model = row[1] if row else "unknown"
             model = row[1] if row else "unknown"
-            connected_printers.append({
-                "id": printer_id,
-                "name": name,
-                "state": state.state,
-                "model": model,
-            })
+            connected_printers.append(
+                {
+                    "id": printer_id,
+                    "name": name,
+                    "state": state.state,
+                    "model": model,
+                }
+            )
 
 
     # Storage info
     # Storage info
     archive_dir = settings.archive_dir
     archive_dir = settings.archive_dir

+ 33 - 35
backend/app/api/routes/webhook.py

@@ -1,11 +1,12 @@
 import logging
 import logging
+
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
 from pydantic import BaseModel
 from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core.auth import check_permission, check_printer_access, get_api_key
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
-from backend.app.core.auth import get_api_key, check_permission, check_printer_access
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
@@ -56,6 +57,7 @@ class QueueStatusResponse(BaseModel):
 
 
 # Webhook endpoints
 # Webhook endpoints
 
 
+
 @router.post("/queue/add", response_model=QueueAddResponse)
 @router.post("/queue/add", response_model=QueueAddResponse)
 async def webhook_add_to_queue(
 async def webhook_add_to_queue(
     data: QueueAddRequest,
     data: QueueAddRequest,
@@ -66,21 +68,17 @@ async def webhook_add_to_queue(
 
 
     Requires 'can_queue' permission.
     Requires 'can_queue' permission.
     """
     """
-    check_permission(api_key, 'queue')
+    check_permission(api_key, "queue")
     check_printer_access(api_key, data.printer_id)
     check_printer_access(api_key, data.printer_id)
 
 
     # Verify archive exists
     # Verify archive exists
-    result = await db.execute(
-        select(PrintArchive).where(PrintArchive.id == data.archive_id)
-    )
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
     if not archive:
     if not archive:
         raise HTTPException(status_code=404, detail="Archive not found")
         raise HTTPException(status_code=404, detail="Archive not found")
 
 
     # Verify printer exists
     # Verify printer exists
-    result = await db.execute(
-        select(Printer).where(Printer.id == data.printer_id)
-    )
+    result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
         raise HTTPException(status_code=404, detail="Printer not found")
         raise HTTPException(status_code=404, detail="Printer not found")
@@ -102,8 +100,9 @@ async def webhook_add_to_queue(
     scheduled_time = None
     scheduled_time = None
     if data.scheduled_time:
     if data.scheduled_time:
         from datetime import datetime
         from datetime import datetime
+
         try:
         try:
-            scheduled_time = datetime.fromisoformat(data.scheduled_time.replace('Z', '+00:00'))
+            scheduled_time = datetime.fromisoformat(data.scheduled_time.replace("Z", "+00:00"))
         except ValueError:
         except ValueError:
             raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
             raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
 
 
@@ -141,7 +140,7 @@ async def webhook_start_print(
 
 
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
-    check_permission(api_key, 'control_printer')
+    check_permission(api_key, "control_printer")
     check_printer_access(api_key, printer_id)
     check_printer_access(api_key, printer_id)
 
 
     # Get printer
     # Get printer
@@ -170,10 +169,7 @@ async def webhook_start_print(
         raise HTTPException(status_code=503, detail="Printer not connected")
         raise HTTPException(status_code=503, detail="Printer not connected")
 
 
     if status.get("state") not in ["IDLE", "FINISH", "FAILED"]:
     if status.get("state") not in ["IDLE", "FINISH", "FAILED"]:
-        raise HTTPException(
-            status_code=409,
-            detail=f"Printer is busy (state: {status.get('state')})"
-        )
+        raise HTTPException(status_code=409, detail=f"Printer is busy (state: {status.get('state')})")
 
 
     # Start the print
     # Start the print
     try:
     try:
@@ -194,7 +190,7 @@ async def webhook_stop_print(
 
 
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
-    check_permission(api_key, 'control_printer')
+    check_permission(api_key, "control_printer")
     check_printer_access(api_key, printer_id)
     check_printer_access(api_key, printer_id)
 
 
     status = printer_manager.get_status(printer_id)
     status = printer_manager.get_status(printer_id)
@@ -222,7 +218,7 @@ async def webhook_cancel_print(
 
 
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
-    check_permission(api_key, 'control_printer')
+    check_permission(api_key, "control_printer")
     check_printer_access(api_key, printer_id)
     check_printer_access(api_key, printer_id)
 
 
     status = printer_manager.get_status(printer_id)
     status = printer_manager.get_status(printer_id)
@@ -251,7 +247,7 @@ async def webhook_get_printer_status(
 
 
     Requires 'can_read_status' permission.
     Requires 'can_read_status' permission.
     """
     """
-    check_permission(api_key, 'read_status')
+    check_permission(api_key, "read_status")
     check_printer_access(api_key, printer_id)
     check_printer_access(api_key, printer_id)
 
 
     # Get printer
     # Get printer
@@ -283,7 +279,7 @@ async def webhook_get_queue_status(
 
 
     Requires 'can_read_status' permission.
     Requires 'can_read_status' permission.
     """
     """
-    check_permission(api_key, 'read_status')
+    check_permission(api_key, "read_status")
 
 
     # Get printers
     # Get printers
     if printer_id:
     if printer_id:
@@ -313,20 +309,22 @@ async def webhook_get_queue_status(
         pending_count = sum(1 for i in items if i.status == "pending")
         pending_count = sum(1 for i in items if i.status == "pending")
         printing_count = sum(1 for i in items if i.status == "printing")
         printing_count = sum(1 for i in items if i.status == "printing")
 
 
-        response.append(QueueStatusResponse(
-            printer_id=printer.id,
-            printer_name=printer.name,
-            pending=pending_count,
-            printing=printing_count,
-            items=[
-                {
-                    "id": item.id,
-                    "archive_id": item.archive_id,
-                    "position": item.position,
-                    "status": item.status,
-                }
-                for item in items
-            ],
-        ))
+        response.append(
+            QueueStatusResponse(
+                printer_id=printer.id,
+                printer_name=printer.name,
+                pending=pending_count,
+                printing=printing_count,
+                items=[
+                    {
+                        "id": item.id,
+                        "archive_id": item.archive_id,
+                        "position": item.position,
+                        "status": item.status,
+                    }
+                    for item in items
+                ],
+            )
+        )
 
 
     return response
     return response

+ 15 - 10
backend/app/api/routes/websocket.py

@@ -1,4 +1,5 @@
 import logging
 import logging
+
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect
 
 
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
@@ -19,11 +20,13 @@ async def websocket_endpoint(websocket: WebSocket):
         # Send initial status of all printers
         # Send initial status of all printers
         statuses = printer_manager.get_all_statuses()
         statuses = printer_manager.get_all_statuses()
         for printer_id, state in statuses.items():
         for printer_id, state in statuses.items():
-            await websocket.send_json({
-                "type": "printer_status",
-                "printer_id": printer_id,
-                "data": printer_state_to_dict(state),
-            })
+            await websocket.send_json(
+                {
+                    "type": "printer_status",
+                    "printer_id": printer_id,
+                    "data": printer_state_to_dict(state),
+                }
+            )
         logger.info(f"Sent initial status for {len(statuses)} printers")
         logger.info(f"Sent initial status for {len(statuses)} printers")
 
 
         # Keep connection alive and handle incoming messages
         # Keep connection alive and handle incoming messages
@@ -40,11 +43,13 @@ async def websocket_endpoint(websocket: WebSocket):
                 if printer_id:
                 if printer_id:
                     state = printer_manager.get_status(printer_id)
                     state = printer_manager.get_status(printer_id)
                     if state:
                     if state:
-                        await websocket.send_json({
-                            "type": "printer_status",
-                            "printer_id": printer_id,
-                            "data": printer_state_to_dict(state),
-                        })
+                        await websocket.send_json(
+                            {
+                                "type": "printer_status",
+                                "printer_id": printer_id,
+                                "data": printer_state_to_dict(state),
+                            }
+                        )
 
 
     except WebSocketDisconnect:
     except WebSocketDisconnect:
         logger.info("WebSocket client disconnected normally")
         logger.info("WebSocket client disconnected normally")

+ 10 - 19
backend/app/core/auth.py

@@ -1,11 +1,10 @@
 import hashlib
 import hashlib
 import secrets
 import secrets
 from datetime import datetime
 from datetime import datetime
-from typing import Optional
 
 
-from fastapi import Header, HTTPException, Depends
-from sqlalchemy.ext.asyncio import AsyncSession
+from fastapi import Depends, Header, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
@@ -39,9 +38,7 @@ async def get_api_key(
     """
     """
     key_hash = hash_api_key(x_api_key)
     key_hash = hash_api_key(x_api_key)
 
 
-    result = await db.execute(
-        select(APIKey).where(APIKey.key_hash == key_hash)
-    )
+    result = await db.execute(select(APIKey).where(APIKey.key_hash == key_hash))
     api_key = result.scalar_one_or_none()
     api_key = result.scalar_one_or_none()
 
 
     if not api_key:
     if not api_key:
@@ -60,9 +57,9 @@ async def get_api_key(
 
 
 
 
 async def get_optional_api_key(
 async def get_optional_api_key(
-    x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
+    x_api_key: str | None = Header(None, alias="X-API-Key"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-) -> Optional[APIKey]:
+) -> APIKey | None:
     """Get API key if provided, return None otherwise."""
     """Get API key if provided, return None otherwise."""
     if not x_api_key:
     if not x_api_key:
         return None
         return None
@@ -83,19 +80,16 @@ def check_permission(api_key: APIKey, permission: str) -> None:
     Raises HTTPException if permission is denied.
     Raises HTTPException if permission is denied.
     """
     """
     permission_map = {
     permission_map = {
-        'queue': api_key.can_queue,
-        'control_printer': api_key.can_control_printer,
-        'read_status': api_key.can_read_status,
+        "queue": api_key.can_queue,
+        "control_printer": api_key.can_control_printer,
+        "read_status": api_key.can_read_status,
     }
     }
 
 
     if permission not in permission_map:
     if permission not in permission_map:
         raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
         raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
 
 
     if not permission_map[permission]:
     if not permission_map[permission]:
-        raise HTTPException(
-            status_code=403,
-            detail=f"API key does not have '{permission}' permission"
-        )
+        raise HTTPException(status_code=403, detail=f"API key does not have '{permission}' permission")
 
 
 
 
 def check_printer_access(api_key: APIKey, printer_id: int) -> None:
 def check_printer_access(api_key: APIKey, printer_id: int) -> None:
@@ -108,7 +102,4 @@ def check_printer_access(api_key: APIKey, printer_id: int) -> None:
     Raises HTTPException if access is denied.
     Raises HTTPException if access is denied.
     """
     """
     if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
     if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
-        raise HTTPException(
-            status_code=403,
-            detail=f"API key does not have access to printer {printer_id}"
-        )
+        raise HTTPException(status_code=403, detail=f"API key does not have access to printer {printer_id}")

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

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.6b4"
+APP_VERSION = "0.1.6b6"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # App directory - where the application is installed (for static files)

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

@@ -381,6 +381,12 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add manual_start column to print_queue for staged prints
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 34 - 23
backend/app/core/websocket.py

@@ -1,6 +1,7 @@
 import asyncio
 import asyncio
 import json
 import json
 from typing import Any
 from typing import Any
+
 from fastapi import WebSocket
 from fastapi import WebSocket
 
 
 
 
@@ -44,41 +45,51 @@ class ConnectionManager:
 
 
     async def send_printer_status(self, printer_id: int, status: dict):
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""
         """Send printer status update to all clients."""
-        await self.broadcast({
-            "type": "printer_status",
-            "printer_id": printer_id,
-            "data": status,
-        })
+        await self.broadcast(
+            {
+                "type": "printer_status",
+                "printer_id": printer_id,
+                "data": status,
+            }
+        )
 
 
     async def send_print_start(self, printer_id: int, data: dict):
     async def send_print_start(self, printer_id: int, data: dict):
         """Notify clients that a print has started."""
         """Notify clients that a print has started."""
-        await self.broadcast({
-            "type": "print_start",
-            "printer_id": printer_id,
-            "data": data,
-        })
+        await self.broadcast(
+            {
+                "type": "print_start",
+                "printer_id": printer_id,
+                "data": data,
+            }
+        )
 
 
     async def send_print_complete(self, printer_id: int, data: dict):
     async def send_print_complete(self, printer_id: int, data: dict):
         """Notify clients that a print has completed."""
         """Notify clients that a print has completed."""
-        await self.broadcast({
-            "type": "print_complete",
-            "printer_id": printer_id,
-            "data": data,
-        })
+        await self.broadcast(
+            {
+                "type": "print_complete",
+                "printer_id": printer_id,
+                "data": data,
+            }
+        )
 
 
     async def send_archive_created(self, archive: dict):
     async def send_archive_created(self, archive: dict):
         """Notify clients that a new archive was created."""
         """Notify clients that a new archive was created."""
-        await self.broadcast({
-            "type": "archive_created",
-            "data": archive,
-        })
+        await self.broadcast(
+            {
+                "type": "archive_created",
+                "data": archive,
+            }
+        )
 
 
     async def send_archive_updated(self, archive: dict):
     async def send_archive_updated(self, archive: dict):
         """Notify clients that an archive was updated."""
         """Notify clients that an archive was updated."""
-        await self.broadcast({
-            "type": "archive_updated",
-            "data": archive,
-        })
+        await self.broadcast(
+            {
+                "type": "archive_updated",
+                "data": archive,
+            }
+        )
 
 
 
 
 # Global connection manager
 # Global connection manager

+ 0 - 8
backend/app/i18n/__init__.py

@@ -17,21 +17,17 @@ EN = {
         "filament": "Filament",
         "filament": "Filament",
         "reason": "Reason",
         "reason": "Reason",
         "unknown": "Unknown",
         "unknown": "Unknown",
-
         # Printer events
         # Printer events
         "printer_offline": "Printer Offline",
         "printer_offline": "Printer Offline",
         "printer_disconnected": "{printer} has disconnected",
         "printer_disconnected": "{printer} has disconnected",
         "printer_error": "Printer Error: {error_type}",
         "printer_error": "Printer Error: {error_type}",
-
         # Filament
         # Filament
         "filament_low": "Filament Low",
         "filament_low": "Filament Low",
         "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
         "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
-
         # Maintenance
         # Maintenance
         "maintenance_due": "Maintenance Due",
         "maintenance_due": "Maintenance Due",
         "overdue": "OVERDUE",
         "overdue": "OVERDUE",
         "soon": "Soon",
         "soon": "Soon",
-
         # Test notification
         # Test notification
         "test_title": "Bambuddy Test",
         "test_title": "Bambuddy Test",
         "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
         "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
@@ -53,21 +49,17 @@ DE = {
         "filament": "Filament",
         "filament": "Filament",
         "reason": "Grund",
         "reason": "Grund",
         "unknown": "Unbekannt",
         "unknown": "Unbekannt",
-
         # Printer events
         # Printer events
         "printer_offline": "Drucker offline",
         "printer_offline": "Drucker offline",
         "printer_disconnected": "{printer} wurde getrennt",
         "printer_disconnected": "{printer} wurde getrennt",
         "printer_error": "Druckerfehler: {error_type}",
         "printer_error": "Druckerfehler: {error_type}",
-
         # Filament
         # Filament
         "filament_low": "Wenig Filament",
         "filament_low": "Wenig Filament",
         "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
         "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
-
         # Maintenance
         # Maintenance
         "maintenance_due": "Wartung fällig",
         "maintenance_due": "Wartung fällig",
         "overdue": "ÜBERFÄLLIG",
         "overdue": "ÜBERFÄLLIG",
         "soon": "Bald",
         "soon": "Bald",
-
         # Test notification
         # Test notification
         "test_title": "Bambuddy Test",
         "test_title": "Bambuddy Test",
         "test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
         "test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",

+ 110 - 9
backend/app/main.py

@@ -237,7 +237,8 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     status_key = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
-        f"{state.stg_cur}:{bed_target}:{nozzle_target}"
+        f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
+        f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}"
     )
     )
     if _last_status_broadcast.get(printer_id) == status_key:
     if _last_status_broadcast.get(printer_id) == status_key:
         return  # No change, skip broadcast
         return  # No change, skip broadcast
@@ -352,11 +353,12 @@ def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
             with open(file_path, "rb") as f:
             with open(file_path, "rb") as f:
                 threemf_data = f.read()
                 threemf_data = f.read()
             # Extract with positions for UI overlay
             # Extract with positions for UI overlay
-            printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
+            printable_objects, bbox_all = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
             if printable_objects:
             if printable_objects:
                 client = printer_manager.get_client(printer_id)
                 client = printer_manager.get_client(printer_id)
                 if client:
                 if client:
                     client.state.printable_objects = printable_objects
                     client.state.printable_objects = printable_objects
+                    client.state.printable_objects_bbox_all = bbox_all
                     client.state.skipped_objects = []
                     client.state.skipped_objects = []
                     logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
                     logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
     except Exception as e:
     except Exception as e:
@@ -604,13 +606,19 @@ async def on_print_start(printer_id: int, data: dict):
         # If still not found, try listing /cache to find matching file
         # If still not found, try listing /cache to find matching file
         if not downloaded_filename and (filename or subtask_name):
         if not downloaded_filename and (filename or subtask_name):
             search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
             search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
+            logger.info(f"Direct FTP download failed, listing /cache to find '{search_term}'")
             try:
             try:
                 cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
                 cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
+                threemf_files = [f.get("name") for f in cache_files if f.get("name", "").endswith(".3mf")]
+                logger.info(
+                    f"Found {len(threemf_files)} 3MF files in /cache: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
+                )
                 for f in cache_files:
                 for f in cache_files:
                     if f.get("is_directory"):
                     if f.get("is_directory"):
                         continue
                         continue
                     fname = f.get("name", "")
                     fname = f.get("name", "")
                     if fname.endswith(".3mf") and search_term in fname.lower():
                     if fname.endswith(".3mf") and search_term in fname.lower():
+                        logger.info(f"Found matching file: {fname}")
                         temp_path = app_settings.archive_dir / "temp" / fname
                         temp_path = app_settings.archive_dir / "temp" / fname
                         temp_path.parent.mkdir(parents=True, exist_ok=True)
                         temp_path.parent.mkdir(parents=True, exist_ok=True)
                         if await download_file_async(
                         if await download_file_async(
@@ -627,10 +635,81 @@ async def on_print_start(printer_id: int, data: dict):
 
 
         if not downloaded_filename or not temp_path:
         if not downloaded_filename or not temp_path:
             logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
             logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
-            # Send notification without archive data (file not found)
-            if not notification_sent:
-                await _send_print_start_notification(printer_id, data, logger=logger)
-            return
+            # Create a fallback archive without 3MF data so the print is still tracked
+            # This commonly happens with P1S/A1 printers where FTP has file size limitations
+            try:
+                from backend.app.models.archive import PrintArchive
+
+                # Derive print name from subtask_name or filename
+                print_name = subtask_name or filename
+                if print_name:
+                    # Clean up the name (remove extensions, path parts)
+                    print_name = print_name.split("/")[-1]
+                    print_name = print_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
+                else:
+                    print_name = "Unknown Print"
+
+                # Create minimal archive entry
+                fallback_archive = PrintArchive(
+                    printer_id=printer_id,
+                    filename=filename or f"{print_name}.3mf",
+                    file_path="",  # Empty - no 3MF file available
+                    file_size=0,
+                    print_name=print_name,
+                    status="printing",
+                    started_at=datetime.now(),
+                    extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data},
+                )
+
+                db.add(fallback_archive)
+                await db.commit()
+                await db.refresh(fallback_archive)
+
+                logger.info(f"Created fallback archive {fallback_archive.id} for {print_name} (no 3MF available)")
+
+                # Track as active print
+                _active_prints[(printer_id, fallback_archive.filename)] = fallback_archive.id
+                if filename:
+                    _active_prints[(printer_id, filename)] = fallback_archive.id
+                if subtask_name:
+                    _active_prints[(printer_id, f"{subtask_name}.3mf")] = fallback_archive.id
+                    _active_prints[(printer_id, subtask_name)] = fallback_archive.id
+
+                # Record starting energy if smart plug available
+                try:
+                    plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+                    plug = plug_result.scalar_one_or_none()
+                    if plug:
+                        energy = await tasmota_service.get_energy(plug)
+                        if energy and energy.get("total") is not None:
+                            _print_energy_start[fallback_archive.id] = energy["total"]
+                            logger.info(
+                                f"[ENERGY] Recorded starting energy for fallback archive {fallback_archive.id}: {energy['total']} kWh"
+                            )
+                except Exception as e:
+                    logger.warning(f"Failed to record starting energy for fallback: {e}")
+
+                # Send WebSocket notification
+                await ws_manager.send_archive_created(
+                    {
+                        "id": fallback_archive.id,
+                        "printer_id": fallback_archive.printer_id,
+                        "filename": fallback_archive.filename,
+                        "print_name": fallback_archive.print_name,
+                        "status": fallback_archive.status,
+                    }
+                )
+
+                # Send notification without archive data (file not found)
+                if not notification_sent:
+                    await _send_print_start_notification(printer_id, data, logger=logger)
+                return
+            except Exception as e:
+                logger.error(f"Failed to create fallback archive: {e}")
+                # Send notification without archive data (file not found)
+                if not notification_sent:
+                    await _send_print_start_notification(printer_id, data, logger=logger)
+                return
 
 
         try:
         try:
             # Archive the file with status "printing"
             # Archive the file with status "printing"
@@ -696,12 +775,15 @@ async def on_print_start(printer_id: int, data: dict):
                     with open(temp_path, "rb") as f:
                     with open(temp_path, "rb") as f:
                         threemf_data = f.read()
                         threemf_data = f.read()
                     # Extract with positions for UI overlay
                     # Extract with positions for UI overlay
-                    printable_objects = extract_printable_objects_from_3mf(threemf_data, include_positions=True)
+                    printable_objects, bbox_all = extract_printable_objects_from_3mf(
+                        threemf_data, include_positions=True
+                    )
                     if printable_objects:
                     if printable_objects:
                         # Store objects in printer state
                         # Store objects in printer state
                         client = printer_manager.get_client(printer_id)
                         client = printer_manager.get_client(printer_id)
                         if client:
                         if client:
                             client.state.printable_objects = printable_objects
                             client.state.printable_objects = printable_objects
+                            client.state.printable_objects_bbox_all = bbox_all
                             client.state.skipped_objects = []  # Reset skipped objects for new print
                             client.state.skipped_objects = []  # Reset skipped objects for new print
                             logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
                             logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
                 except Exception as e:
                 except Exception as e:
@@ -1515,7 +1597,11 @@ async def track_printer_runtime():
                 for printer in printers:
                 for printer in printers:
                     # Get current state from printer manager
                     # Get current state from printer manager
                     state = printer_manager.get_status(printer.id)
                     state = printer_manager.get_status(printer.id)
-                    if not state or not state.connected:
+                    if not state:
+                        logger.debug(f"[{printer.name}] Runtime tracking: no state available")
+                        continue
+                    if not state.connected:
+                        logger.debug(f"[{printer.name}] Runtime tracking: not connected")
                         continue
                         continue
 
 
                     # Check if printer is in an active state (RUNNING or PAUSE)
                     # Check if printer is in an active state (RUNNING or PAUSE)
@@ -1528,14 +1614,27 @@ async def track_printer_runtime():
                                 printer.runtime_seconds += int(elapsed)
                                 printer.runtime_seconds += int(elapsed)
                                 updated_count += 1
                                 updated_count += 1
                                 needs_commit = True
                                 needs_commit = True
+                                logger.debug(
+                                    f"[{printer.name}] Runtime tracking: added {int(elapsed)}s, "
+                                    f"total={printer.runtime_seconds}s ({printer.runtime_seconds / 3600:.2f}h)"
+                                )
+                            else:
+                                logger.warning(
+                                    f"[{printer.name}] Runtime tracking: skipped elapsed={elapsed:.1f}s "
+                                    f"(outside valid range 0-{RUNTIME_TRACKING_INTERVAL * 2}s)"
+                                )
                         else:
                         else:
                             # First time seeing printer active - need to commit to save timestamp
                             # First time seeing printer active - need to commit to save timestamp
                             needs_commit = True
                             needs_commit = True
+                            logger.debug(f"[{printer.name}] Runtime tracking: first active detection")
 
 
                         printer.last_runtime_update = now
                         printer.last_runtime_update = now
                     else:
                     else:
                         # Printer is idle/offline - clear last_runtime_update
                         # Printer is idle/offline - clear last_runtime_update
                         if printer.last_runtime_update is not None:
                         if printer.last_runtime_update is not None:
+                            logger.debug(
+                                f"[{printer.name}] Runtime tracking: state={state.state}, clearing last_runtime_update"
+                            )
                             printer.last_runtime_update = None
                             printer.last_runtime_update = None
                             needs_commit = True
                             needs_commit = True
 
 
@@ -1638,6 +1737,7 @@ async def lifespan(app: FastAPI):
         if vp_enabled and vp_enabled.lower() == "true":
         if vp_enabled and vp_enabled.lower() == "true":
             vp_access_code = await get_setting(db, "virtual_printer_access_code") or ""
             vp_access_code = await get_setting(db, "virtual_printer_access_code") or ""
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
+            vp_model = await get_setting(db, "virtual_printer_model") or ""
 
 
             if vp_access_code:
             if vp_access_code:
                 try:
                 try:
@@ -1645,8 +1745,9 @@ async def lifespan(app: FastAPI):
                         enabled=True,
                         enabled=True,
                         access_code=vp_access_code,
                         access_code=vp_access_code,
                         mode=vp_mode,
                         mode=vp_mode,
+                        model=vp_model,
                     )
                     )
-                    logging.info("Virtual printer started")
+                    logging.info(f"Virtual printer started (model={vp_model or 'default'})")
                 except Exception as e:
                 except Exception as e:
                     logging.warning(f"Failed to start virtual printer: {e}")
                     logging.warning(f"Failed to start virtual printer: {e}")
 
 

+ 5 - 7
backend/app/models/ams_history.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import Integer, Float, DateTime, ForeignKey, String, func, Index
+
+from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -7,6 +8,7 @@ from backend.app.core.database import Base
 
 
 class AMSSensorHistory(Base):
 class AMSSensorHistory(Base):
     """Historical sensor data from AMS units (humidity and temperature)."""
     """Historical sensor data from AMS units (humidity and temperature)."""
+
     __tablename__ = "ams_sensor_history"
     __tablename__ = "ams_sensor_history"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,14 +17,10 @@ class AMSSensorHistory(Base):
     humidity: Mapped[float | None] = mapped_column(Float)  # Humidity percentage
     humidity: Mapped[float | None] = mapped_column(Float)  # Humidity percentage
     humidity_raw: Mapped[float | None] = mapped_column(Float)  # Raw humidity value
     humidity_raw: Mapped[float | None] = mapped_column(Float)  # Raw humidity value
     temperature: Mapped[float | None] = mapped_column(Float)  # Temperature in Celsius
     temperature: Mapped[float | None] = mapped_column(Float)  # Temperature in Celsius
-    recorded_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), index=True
-    )
+    recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
 
 
     # Indexes for efficient querying
     # Indexes for efficient querying
-    __table_args__ = (
-        Index('ix_ams_history_printer_ams_time', 'printer_id', 'ams_id', 'recorded_at'),
-    )
+    __table_args__ = (Index("ix_ams_history_printer_ams_time", "printer_id", "ams_id", "recorded_at"),)
 
 
     # Relationship
     # Relationship
     printer: Mapped["Printer"] = relationship(back_populates="ams_history")
     printer: Mapped["Printer"] = relationship(back_populates="ams_history")

+ 2 - 1
backend/app/models/api_key.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Boolean, DateTime, Text, JSON, func
+
+from sqlalchemy import JSON, Boolean, DateTime, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base

+ 5 - 9
backend/app/models/external_link.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from typing import Optional
-from sqlalchemy import String, Integer, DateTime, func
+
+from sqlalchemy import DateTime, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -15,11 +15,7 @@ class ExternalLink(Base):
     name: Mapped[str] = mapped_column(String(50))
     name: Mapped[str] = mapped_column(String(50))
     url: Mapped[str] = mapped_column(String(500))
     url: Mapped[str] = mapped_column(String(500))
     icon: Mapped[str] = mapped_column(String(50), default="link")
     icon: Mapped[str] = mapped_column(String(50), default="link")
-    custom_icon: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon
+    custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon
     sort_order: Mapped[int] = mapped_column(Integer, default=0)
     sort_order: Mapped[int] = mapped_column(Integer, default=0)
-    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()
-    )
+    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())

+ 4 - 7
backend/app/models/filament.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Float, DateTime, func
+
+from sqlalchemy import DateTime, Float, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -29,9 +30,5 @@ class Filament(Base):
     bed_temp_min: Mapped[int | None] = mapped_column()
     bed_temp_min: Mapped[int | None] = mapped_column()
     bed_temp_max: Mapped[int | None] = mapped_column()
     bed_temp_max: Mapped[int | None] = mapped_column()
 
 
-    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()
-    )
+    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())

+ 5 - 10
backend/app/models/kprofile_note.py

@@ -1,7 +1,8 @@
 """Model for K-profile notes stored locally (not on printer)."""
 """Model for K-profile notes stored locally (not on printer)."""
 
 
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index
+
+from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -17,20 +18,14 @@ class KProfileNote(Base):
     # setting_id is the unique identifier for a K-profile on the printer
     # setting_id is the unique identifier for a K-profile on the printer
     setting_id: Mapped[str] = mapped_column(String(100))
     setting_id: Mapped[str] = mapped_column(String(100))
     note: Mapped[str] = mapped_column(Text, default="")
     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()
-    )
+    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
     # Relationship to printer
     printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes")
     printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes")
 
 
     # Composite index for efficient lookups
     # Composite index for efficient lookups
-    __table_args__ = (
-        Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),
-    )
+    __table_args__ = (Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),)
 
 
 
 
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402

+ 9 - 13
backend/app/models/maintenance.py

@@ -1,7 +1,8 @@
 """Maintenance tracking models."""
 """Maintenance tracking models."""
 
 
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Boolean, DateTime, Integer, Float, ForeignKey, Text, func
+
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -9,6 +10,7 @@ from backend.app.core.database import Base
 
 
 class MaintenanceType(Base):
 class MaintenanceType(Base):
     """Defines a type of maintenance task with default interval."""
     """Defines a type of maintenance task with default interval."""
+
     __tablename__ = "maintenance_types"
     __tablename__ = "maintenance_types"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
@@ -19,9 +21,7 @@ class MaintenanceType(Base):
     interval_type: Mapped[str] = mapped_column(String(20), default="hours")
     interval_type: Mapped[str] = mapped_column(String(20), default="hours")
     icon: Mapped[str | None] = mapped_column(String(50))  # Icon name for UI
     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
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)  # Pre-defined vs custom
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
 
     # Relationships
     # Relationships
     printer_maintenance: Mapped[list["PrinterMaintenance"]] = relationship(
     printer_maintenance: Mapped[list["PrinterMaintenance"]] = relationship(
@@ -31,6 +31,7 @@ class MaintenanceType(Base):
 
 
 class PrinterMaintenance(Base):
 class PrinterMaintenance(Base):
     """Tracks maintenance status for a specific printer."""
     """Tracks maintenance status for a specific printer."""
+
     __tablename__ = "printer_maintenance"
     __tablename__ = "printer_maintenance"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
@@ -47,12 +48,8 @@ class PrinterMaintenance(Base):
     last_performed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_performed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_performed_hours: Mapped[float] = mapped_column(Float, default=0.0)  # Hours at last reset
     last_performed_hours: Mapped[float] = mapped_column(Float, default=0.0)  # Hours at last reset
 
 
-    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()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
     # Relationships
     # Relationships
     printer: Mapped["Printer"] = relationship(back_populates="maintenance_items")
     printer: Mapped["Printer"] = relationship(back_populates="maintenance_items")
@@ -64,12 +61,11 @@ class PrinterMaintenance(Base):
 
 
 class MaintenanceHistory(Base):
 class MaintenanceHistory(Base):
     """Log of maintenance actions performed."""
     """Log of maintenance actions performed."""
+
     __tablename__ = "maintenance_history"
     __tablename__ = "maintenance_history"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
-    printer_maintenance_id: Mapped[int] = mapped_column(
-        ForeignKey("printer_maintenance.id", ondelete="CASCADE")
-    )
+    printer_maintenance_id: Mapped[int] = mapped_column(ForeignKey("printer_maintenance.id", ondelete="CASCADE"))
     performed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     performed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     hours_at_maintenance: Mapped[float] = mapped_column(Float, default=0.0)
     hours_at_maintenance: Mapped[float] = mapped_column(Float, default=0.0)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)

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

@@ -2,7 +2,7 @@
 
 
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Time
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
 from sqlalchemy.orm import relationship
 from sqlalchemy.orm import relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base

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

@@ -20,9 +20,7 @@ class NotificationTemplate(Base):
     body_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)
     is_default: Mapped[bool] = mapped_column(Boolean, default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     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()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
 
 
 # Default templates for seeding
 # Default templates for seeding

+ 7 - 11
backend/app/models/print_queue.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, func
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -13,19 +14,14 @@ class PrintQueueItem(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
 
 
     # Links
     # Links
-    printer_id: Mapped[int] = mapped_column(
-        ForeignKey("printers.id", ondelete="CASCADE")
-    )
-    archive_id: Mapped[int] = mapped_column(
-        ForeignKey("print_archives.id", ondelete="CASCADE")
-    )
-    project_id: Mapped[int | None] = mapped_column(
-        ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
-    )
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
+    project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
 
     # Scheduling
     # Scheduling
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
     scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # None = ASAP
     scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # None = ASAP
+    manual_start: Mapped[bool] = mapped_column(Boolean, default=False)  # Requires manual trigger to start
 
 
     # Conditions
     # Conditions
     require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
     require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -50,6 +46,6 @@ class PrintQueueItem(Base):
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
 
 
 
 
-from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402

+ 4 - 7
backend/app/models/settings.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Text, DateTime, func
+
+from sqlalchemy import DateTime, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -13,9 +14,5 @@ class Settings(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     value: Mapped[str] = mapped_column(Text)
     value: Mapped[str] = mapped_column(Text)
-    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()
-    )
+    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())

+ 4 - 7
backend/app/models/slot_preset.py

@@ -5,7 +5,8 @@ similar to how Bambu Studio remembers preset selections.
 """
 """
 
 
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Integer, DateTime, ForeignKey, func, UniqueConstraint
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -15,9 +16,7 @@ class SlotPresetMapping(Base):
     """Maps an AMS slot to a cloud filament preset."""
     """Maps an AMS slot to a cloud filament preset."""
 
 
     __tablename__ = "slot_preset_mappings"
     __tablename__ = "slot_preset_mappings"
-    __table_args__ = (
-        UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"),
-    )
+    __table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"),)
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
     printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
@@ -26,9 +25,7 @@ class SlotPresetMapping(Base):
     preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
     preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
     preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
     preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     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()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
     # Relationship
     # Relationship
     printer: Mapped["Printer"] = relationship()
     printer: Mapped["Printer"] = relationship()

+ 10 - 10
backend/app/schemas/__init__.py

@@ -1,25 +1,25 @@
+from backend.app.schemas.archive import (
+    ArchiveBase,
+    ArchiveResponse,
+    ArchiveUpdate,
+    ProjectPageImage,
+    ProjectPageResponse,
+)
 from backend.app.schemas.printer import (
 from backend.app.schemas.printer import (
     PrinterBase,
     PrinterBase,
     PrinterCreate,
     PrinterCreate,
-    PrinterUpdate,
     PrinterResponse,
     PrinterResponse,
     PrinterStatus,
     PrinterStatus,
-)
-from backend.app.schemas.archive import (
-    ArchiveBase,
-    ArchiveUpdate,
-    ArchiveResponse,
-    ProjectPageResponse,
-    ProjectPageImage,
+    PrinterUpdate,
 )
 )
 from backend.app.schemas.smart_plug import (
 from backend.app.schemas.smart_plug import (
     SmartPlugBase,
     SmartPlugBase,
+    SmartPlugControl,
     SmartPlugCreate,
     SmartPlugCreate,
-    SmartPlugUpdate,
     SmartPlugResponse,
     SmartPlugResponse,
-    SmartPlugControl,
     SmartPlugStatus,
     SmartPlugStatus,
     SmartPlugTestConnection,
     SmartPlugTestConnection,
+    SmartPlugUpdate,
 )
 )
 
 
 __all__ = [
 __all__ = [

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

@@ -1,9 +1,11 @@
 from datetime import datetime
 from datetime import datetime
+
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
 
 
 class APIKeyCreate(BaseModel):
 class APIKeyCreate(BaseModel):
     """Schema for creating a new API key."""
     """Schema for creating a new API key."""
+
     name: str
     name: str
     can_queue: bool = True
     can_queue: bool = True
     can_control_printer: bool = False
     can_control_printer: bool = False
@@ -14,6 +16,7 @@ class APIKeyCreate(BaseModel):
 
 
 class APIKeyUpdate(BaseModel):
 class APIKeyUpdate(BaseModel):
     """Schema for updating an API key."""
     """Schema for updating an API key."""
+
     name: str | None = None
     name: str | None = None
     can_queue: bool | None = None
     can_queue: bool | None = None
     can_control_printer: bool | None = None
     can_control_printer: bool | None = None
@@ -25,6 +28,7 @@ class APIKeyUpdate(BaseModel):
 
 
 class APIKeyResponse(BaseModel):
 class APIKeyResponse(BaseModel):
     """Schema for API key response (without full key)."""
     """Schema for API key response (without full key)."""
+
     id: int
     id: int
     name: str
     name: str
     key_prefix: str  # First 8 chars for identification
     key_prefix: str  # First 8 chars for identification
@@ -43,4 +47,5 @@ class APIKeyResponse(BaseModel):
 
 
 class APIKeyCreateResponse(APIKeyResponse):
 class APIKeyCreateResponse(APIKeyResponse):
     """Response when creating a key - includes full key (shown only once)."""
     """Response when creating a key - includes full key (shown only once)."""
+
     key: str  # Full API key, only shown on creation
     key: str  # Full API key, only shown on creation

+ 29 - 18
backend/app/schemas/cloud.py

@@ -1,9 +1,9 @@
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
-from typing import Optional
 
 
 
 
 class CloudLoginRequest(BaseModel):
 class CloudLoginRequest(BaseModel):
     """Request to initiate cloud login."""
     """Request to initiate cloud login."""
+
     email: str = Field(..., description="Bambu Lab account email")
     email: str = Field(..., description="Bambu Lab account email")
     password: str = Field(..., description="Account password")
     password: str = Field(..., description="Account password")
     region: str = Field(default="global", description="Region: 'global' or 'china'")
     region: str = Field(default="global", description="Region: 'global' or 'china'")
@@ -11,12 +11,14 @@ class CloudLoginRequest(BaseModel):
 
 
 class CloudVerifyRequest(BaseModel):
 class CloudVerifyRequest(BaseModel):
     """Request to verify login with 2FA code."""
     """Request to verify login with 2FA code."""
+
     email: str = Field(..., description="Bambu Lab account email")
     email: str = Field(..., description="Bambu Lab account email")
     code: str = Field(..., description="6-digit verification code from email")
     code: str = Field(..., description="6-digit verification code from email")
 
 
 
 
 class CloudLoginResponse(BaseModel):
 class CloudLoginResponse(BaseModel):
     """Response from login attempt."""
     """Response from login attempt."""
+
     success: bool
     success: bool
     needs_verification: bool = False
     needs_verification: bool = False
     message: str
     message: str
@@ -24,27 +26,31 @@ class CloudLoginResponse(BaseModel):
 
 
 class CloudAuthStatus(BaseModel):
 class CloudAuthStatus(BaseModel):
     """Current authentication status."""
     """Current authentication status."""
+
     is_authenticated: bool
     is_authenticated: bool
-    email: Optional[str] = None
+    email: str | None = None
 
 
 
 
 class CloudTokenRequest(BaseModel):
 class CloudTokenRequest(BaseModel):
     """Request to set access token directly."""
     """Request to set access token directly."""
+
     access_token: str = Field(..., description="Bambu Lab access token")
     access_token: str = Field(..., description="Bambu Lab access token")
 
 
 
 
 class SlicerSetting(BaseModel):
 class SlicerSetting(BaseModel):
     """A slicer setting/preset."""
     """A slicer setting/preset."""
+
     setting_id: str
     setting_id: str
     name: str
     name: str
     type: str  # filament, printer, process
     type: str  # filament, printer, process
-    version: Optional[str] = None
-    user_id: Optional[str] = None
-    updated_time: Optional[str] = None
+    version: str | None = None
+    user_id: str | None = None
+    updated_time: str | None = None
 
 
 
 
 class SlicerSettingsResponse(BaseModel):
 class SlicerSettingsResponse(BaseModel):
     """Response containing slicer settings."""
     """Response containing slicer settings."""
+
     filament: list[SlicerSetting] = []
     filament: list[SlicerSetting] = []
     printer: list[SlicerSetting] = []
     printer: list[SlicerSetting] = []
     process: list[SlicerSetting] = []
     process: list[SlicerSetting] = []
@@ -52,15 +58,17 @@ class SlicerSettingsResponse(BaseModel):
 
 
 class CloudDevice(BaseModel):
 class CloudDevice(BaseModel):
     """A bound printer device."""
     """A bound printer device."""
+
     dev_id: str
     dev_id: str
     name: str
     name: str
-    dev_model_name: Optional[str] = None
-    dev_product_name: Optional[str] = None
+    dev_model_name: str | None = None
+    dev_product_name: str | None = None
     online: bool = False
     online: bool = False
 
 
 
 
 class SlicerSettingCreate(BaseModel):
 class SlicerSettingCreate(BaseModel):
     """Request to create a new slicer preset."""
     """Request to create a new slicer preset."""
+
     type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'")
     type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'")
     name: str = Field(..., description="Display name for the preset")
     name: str = Field(..., description="Display name for the preset")
     base_id: str = Field(..., description="Base preset ID to inherit from")
     base_id: str = Field(..., description="Base preset ID to inherit from")
@@ -70,28 +78,31 @@ class SlicerSettingCreate(BaseModel):
 
 
 class SlicerSettingUpdate(BaseModel):
 class SlicerSettingUpdate(BaseModel):
     """Request to update an existing slicer preset."""
     """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")
+
+    name: str | None = Field(None, description="New display name")
+    setting: dict | None = Field(None, description="Setting key-value pairs to update")
 
 
 
 
 class SlicerSettingDetail(BaseModel):
 class SlicerSettingDetail(BaseModel):
     """Detailed slicer setting/preset response."""
     """Detailed slicer setting/preset response."""
-    message: Optional[str] = None
-    code: Optional[str] = None
-    error: Optional[str] = None
+
+    message: str | None = None
+    code: str | None = None
+    error: str | None = None
     public: bool = False
     public: bool = False
-    version: Optional[str] = None
+    version: str | None = None
     type: str
     type: str
     name: str
     name: str
-    update_time: Optional[str] = None
-    nickname: Optional[str] = None
-    base_id: Optional[str] = None
+    update_time: str | None = None
+    nickname: str | None = None
+    base_id: str | None = None
     setting: dict = Field(default_factory=dict)
     setting: dict = Field(default_factory=dict)
-    filament_id: Optional[str] = None
-    setting_id: Optional[str] = None  # For response after create
+    filament_id: str | None = None
+    setting_id: str | None = None  # For response after create
 
 
 
 
 class SlicerSettingDeleteResponse(BaseModel):
 class SlicerSettingDeleteResponse(BaseModel):
     """Response from deleting a preset."""
     """Response from deleting a preset."""
+
     success: bool
     success: bool
     message: str
     message: str

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

@@ -1,4 +1,5 @@
 from datetime import datetime
 from datetime import datetime
+
 from pydantic import BaseModel, Field, field_validator
 from pydantic import BaseModel, Field, field_validator
 
 
 
 

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

@@ -1,4 +1,5 @@
 from datetime import datetime
 from datetime import datetime
+
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 
 
 
 

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

@@ -1,6 +1,7 @@
 """Maintenance tracking schemas."""
 """Maintenance tracking schemas."""
 
 
 from datetime import datetime
 from datetime import datetime
+
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 
 
 
 
@@ -91,6 +92,7 @@ class MaintenanceHistoryResponse(MaintenanceHistoryBase):
 # Combined status response for frontend
 # Combined status response for frontend
 class MaintenanceStatus(BaseModel):
 class MaintenanceStatus(BaseModel):
     """Maintenance status for a printer with calculated values."""
     """Maintenance status for a printer with calculated values."""
+
     id: int
     id: int
     printer_id: int
     printer_id: int
     printer_name: str
     printer_name: str
@@ -116,6 +118,7 @@ class MaintenanceStatus(BaseModel):
 
 
 class PrinterMaintenanceOverview(BaseModel):
 class PrinterMaintenanceOverview(BaseModel):
     """Overview of all maintenance items for a printer."""
     """Overview of all maintenance items for a printer."""
+
     printer_id: int
     printer_id: int
     printer_name: str
     printer_name: str
     total_print_hours: float
     total_print_hours: float
@@ -126,4 +129,5 @@ class PrinterMaintenanceOverview(BaseModel):
 
 
 class PerformMaintenanceRequest(BaseModel):
 class PerformMaintenanceRequest(BaseModel):
     """Request to mark maintenance as performed."""
     """Request to mark maintenance as performed."""
+
     notes: str | None = None
     notes: str | None = None

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

@@ -46,7 +46,9 @@ class NotificationProviderBase(BaseModel):
 
 
     # Event triggers - AMS-HT environmental alarms
     # Event triggers - AMS-HT environmental alarms
     on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold")
     on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold")
-    on_ams_ht_temperature_high: bool = Field(default=False, description="Notify when AMS-HT temperature exceeds threshold")
+    on_ams_ht_temperature_high: bool = Field(
+        default=False, description="Notify when AMS-HT temperature exceeds threshold"
+    )
 
 
     # Quiet hours
     # Quiet hours
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")

+ 6 - 2
backend/app/schemas/print_queue.py

@@ -1,5 +1,6 @@
-from datetime import datetime, timezone
-from typing import Literal, Annotated
+from datetime import datetime
+from typing import Annotated, Literal
+
 from pydantic import BaseModel, PlainSerializer
 from pydantic import BaseModel, PlainSerializer
 
 
 
 
@@ -20,6 +21,7 @@ class PrintQueueItemCreate(BaseModel):
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     auto_off_after: bool = False  # Power off printer after print completes
+    manual_start: bool = False  # Requires manual trigger to start (staged)
 
 
 
 
 class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
@@ -28,6 +30,7 @@ class PrintQueueItemUpdate(BaseModel):
     scheduled_time: datetime | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
     require_previous_success: bool | None = None
     auto_off_after: bool | None = None
     auto_off_after: bool | None = None
+    manual_start: bool | None = None
 
 
 
 
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
@@ -38,6 +41,7 @@ class PrintQueueItemResponse(BaseModel):
     scheduled_time: UTCDatetime
     scheduled_time: UTCDatetime
     require_previous_success: bool
     require_previous_success: bool
     auto_off_after: bool
     auto_off_after: bool
+    manual_start: bool
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     started_at: UTCDatetime
     completed_at: UTCDatetime
     completed_at: UTCDatetime

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

@@ -153,3 +153,8 @@ class PrinterStatus(BaseModel):
     last_ams_update: float = 0.0
     last_ams_update: float = 0.0
     # Number of printable objects in current print (for skip objects feature)
     # Number of printable objects in current print (for skip objects feature)
     printable_objects_count: int = 0
     printable_objects_count: int = 0
+    # Fan speeds (0-100 percentage, None if not available for this model)
+    cooling_fan_speed: int | None = None  # Part cooling fan
+    big_fan1_speed: int | None = None  # Auxiliary fan
+    big_fan2_speed: int | None = None  # Chamber/exhaust fan
+    heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan

+ 36 - 22
backend/app/services/archive.py

@@ -342,7 +342,7 @@ class ThreeMFParser:
 
 
 def extract_printable_objects_from_3mf(
 def extract_printable_objects_from_3mf(
     data: bytes, plate_number: int | None = None, include_positions: bool = False
     data: bytes, plate_number: int | None = None, include_positions: bool = False
-) -> dict[int, str] | dict[int, dict]:
+) -> dict[int, str] | dict[int, dict] | tuple[dict[int, dict], list | None]:
     """Extract printable objects from 3MF file bytes.
     """Extract printable objects from 3MF file bytes.
 
 
     This is a lightweight function used during print start to get the list
     This is a lightweight function used during print start to get the list
@@ -351,16 +351,17 @@ def extract_printable_objects_from_3mf(
     Args:
     Args:
         data: Raw bytes of the 3MF file
         data: Raw bytes of the 3MF file
         plate_number: Which plate was printed (1-based), or None for first plate
         plate_number: Which plate was printed (1-based), or None for first plate
-        include_positions: If True, return dict with name and position info
+        include_positions: If True, return tuple of (objects dict, bbox_all)
 
 
     Returns:
     Returns:
         If include_positions=False: Dictionary mapping identify_id (int) to object name (str)
         If include_positions=False: Dictionary mapping identify_id (int) to object name (str)
-        If include_positions=True: Dictionary mapping identify_id to {name, x, y} dict
+        If include_positions=True: Tuple of (dict mapping identify_id to {name, x, y}, bbox_all list or None)
     """
     """
     import json
     import json
     from io import BytesIO
     from io import BytesIO
 
 
     printable_objects: dict = {}
     printable_objects: dict = {}
+    bbox_all: list | None = None
 
 
     try:
     try:
         with zipfile.ZipFile(BytesIO(data), "r") as zf:
         with zipfile.ZipFile(BytesIO(data), "r") as zf:
@@ -371,7 +372,6 @@ def extract_printable_objects_from_3mf(
             root = ET.fromstring(content)
             root = ET.fromstring(content)
 
 
             # Find the correct plate
             # Find the correct plate
-            plate_idx = plate_number or 1
             if plate_number:
             if plate_number:
                 plate = root.find(f".//plate[@plate_idx='{plate_number}']")
                 plate = root.find(f".//plate[@plate_idx='{plate_number}']")
                 if plate is None:
                 if plate is None:
@@ -382,19 +382,35 @@ def extract_printable_objects_from_3mf(
             if plate is None:
             if plate is None:
                 return printable_objects
                 return printable_objects
 
 
+            # Get actual plate index from metadata (sliced files only have one plate)
+            plate_idx = plate_number or 1
+            for meta in plate.findall("metadata"):
+                if meta.get("key") == "index":
+                    try:
+                        plate_idx = int(meta.get("value", "1"))
+                    except ValueError:
+                        pass
+                    break
+
             # Load position data from plate_N.json if we need positions
             # Load position data from plate_N.json if we need positions
-            bbox_objects = []
+            # Build a lookup by name since bbox_objects.id != slice_info identify_id
+            bbox_by_name: dict[str, list] = {}
             if include_positions:
             if include_positions:
                 plate_json_path = f"Metadata/plate_{plate_idx}.json"
                 plate_json_path = f"Metadata/plate_{plate_idx}.json"
                 if plate_json_path in zf.namelist():
                 if plate_json_path in zf.namelist():
                     try:
                     try:
                         plate_json = json.loads(zf.read(plate_json_path).decode())
                         plate_json = json.loads(zf.read(plate_json_path).decode())
-                        bbox_objects = plate_json.get("bbox_objects", [])
+                        # Get bbox_all - the bounding box of all objects (used for image bounds)
+                        bbox_all = plate_json.get("bbox_all")
+                        for bbox_obj in plate_json.get("bbox_objects", []):
+                            obj_name = bbox_obj.get("name")
+                            bbox = bbox_obj.get("bbox", [])
+                            if obj_name and len(bbox) >= 4:
+                                bbox_by_name[obj_name] = bbox
                     except (json.JSONDecodeError, KeyError):
                     except (json.JSONDecodeError, KeyError):
                         pass
                         pass
 
 
             # Extract objects from slice_info.config
             # Extract objects from slice_info.config
-            objects_list = []
             for obj in plate.findall("object"):
             for obj in plate.findall("object"):
                 identify_id = obj.get("identify_id")
                 identify_id = obj.get("identify_id")
                 name = obj.get("name")
                 name = obj.get("name")
@@ -403,27 +419,25 @@ def extract_printable_objects_from_3mf(
                 if identify_id and name and skipped.lower() != "true":
                 if identify_id and name and skipped.lower() != "true":
                     try:
                     try:
                         obj_id = int(identify_id)
                         obj_id = int(identify_id)
-                        objects_list.append((obj_id, name))
+                        if include_positions:
+                            x, y = None, None
+                            # Match by name to get bbox coordinates
+                            bbox = bbox_by_name.get(name)
+                            if bbox:
+                                # Calculate center from bbox [x_min, y_min, x_max, y_max]
+                                x = (bbox[0] + bbox[2]) / 2
+                                y = (bbox[1] + bbox[3]) / 2
+                            printable_objects[obj_id] = {"name": name, "x": x, "y": y}
+                        else:
+                            printable_objects[obj_id] = name
                     except ValueError:
                     except ValueError:
                         pass
                         pass
 
 
-            # Match objects with positions by index (both lists are in same order)
-            for idx, (obj_id, name) in enumerate(objects_list):
-                if include_positions:
-                    x, y = None, None
-                    if idx < len(bbox_objects):
-                        bbox = bbox_objects[idx].get("bbox", [])
-                        if len(bbox) >= 4:
-                            # Calculate center from bbox [x_min, y_min, x_max, y_max]
-                            x = (bbox[0] + bbox[2]) / 2
-                            y = (bbox[1] + bbox[3]) / 2
-                    printable_objects[obj_id] = {"name": name, "x": x, "y": y}
-                else:
-                    printable_objects[obj_id] = name
-
     except Exception:
     except Exception:
         pass
         pass
 
 
+    if include_positions:
+        return printable_objects, bbox_all
     return printable_objects
     return printable_objects
 
 
 
 

+ 61 - 55
backend/app/services/archive_comparison.py

@@ -1,5 +1,5 @@
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
@@ -40,9 +40,7 @@ class ArchiveComparisonService:
 
 
         # Fetch archives
         # Fetch archives
         result = await self.db.execute(
         result = await self.db.execute(
-            select(PrintArchive)
-            .options(selectinload(PrintArchive.project))
-            .where(PrintArchive.id.in_(archive_ids))
+            select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(archive_ids))
         )
         )
         archives = {a.id: a for a in result.scalars().all()}
         archives = {a.id: a for a in result.scalars().all()}
 
 
@@ -90,7 +88,7 @@ class ArchiveComparisonService:
 
 
             # Check if values differ
             # Check if values differ
             non_none_values = [v for v in values if v is not None]
             non_none_values = [v for v in values if v is not None]
-            has_difference = len(set(str(v) for v in non_none_values)) > 1 if non_none_values else False
+            has_difference = len({str(v) for v in non_none_values}) > 1 if non_none_values else False
 
 
             field_data = {
             field_data = {
                 "field": field_name,
                 "field": field_name,
@@ -130,7 +128,7 @@ class ArchiveComparisonService:
         # Find settings that differ between successful and failed
         # Find settings that differ between successful and failed
         insights = []
         insights = []
 
 
-        for field_name, display_name, unit in self.COMPARABLE_FIELDS:
+        for field_name, display_name, _unit in self.COMPARABLE_FIELDS:
             if field_name == "status":
             if field_name == "status":
                 continue
                 continue
 
 
@@ -147,26 +145,30 @@ class ArchiveComparisonService:
 
 
                 if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01):
                 if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01):
                     direction = "higher" if success_avg > failed_avg else "lower"
                     direction = "higher" if success_avg > failed_avg else "lower"
-                    insights.append({
-                        "field": field_name,
-                        "label": display_name,
-                        "success_avg": round(success_avg, 2),
-                        "failed_avg": round(failed_avg, 2),
-                        "insight": f"Successful prints had {direction} {display_name}",
-                    })
+                    insights.append(
+                        {
+                            "field": field_name,
+                            "label": display_name,
+                            "success_avg": round(success_avg, 2),
+                            "failed_avg": round(failed_avg, 2),
+                            "insight": f"Successful prints had {direction} {display_name}",
+                        }
+                    )
             else:
             else:
                 # For categorical fields, check if success uses different values
                 # For categorical fields, check if success uses different values
-                success_set = set(str(v) for v in success_values)
-                failed_set = set(str(v) for v in failed_values)
+                success_set = {str(v) for v in success_values}
+                failed_set = {str(v) for v in failed_values}
 
 
                 if success_set != failed_set:
                 if success_set != failed_set:
-                    insights.append({
-                        "field": field_name,
-                        "label": display_name,
-                        "success_values": list(success_set),
-                        "failed_values": list(failed_set),
-                        "insight": f"Different {display_name} used in successful vs failed prints",
-                    })
+                    insights.append(
+                        {
+                            "field": field_name,
+                            "label": display_name,
+                            "success_values": list(success_set),
+                            "failed_values": list(failed_set),
+                            "insight": f"Different {display_name} used in successful vs failed prints",
+                        }
+                    )
 
 
         return {
         return {
             "has_both_outcomes": True,
             "has_both_outcomes": True,
@@ -190,9 +192,7 @@ class ArchiveComparisonService:
             List of similar archives with match reasons
             List of similar archives with match reasons
         """
         """
         # Get the reference archive
         # Get the reference archive
-        result = await self.db.execute(
-            select(PrintArchive).where(PrintArchive.id == archive_id)
-        )
+        result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         reference = result.scalar_one_or_none()
         reference = result.scalar_one_or_none()
 
 
         if not reference:
         if not reference:
@@ -213,16 +213,18 @@ class ArchiveComparisonService:
                 .limit(limit)
                 .limit(limit)
             )
             )
             for a in result.scalars().all():
             for a in result.scalars().all():
-                similar.append({
-                    "archive": {
-                        "id": a.id,
-                        "print_name": a.print_name or a.filename,
-                        "status": a.status,
-                        "created_at": a.created_at.isoformat() if a.created_at else None,
-                    },
-                    "match_reason": "Same print name",
-                    "match_score": 100,
-                })
+                similar.append(
+                    {
+                        "archive": {
+                            "id": a.id,
+                            "print_name": a.print_name or a.filename,
+                            "status": a.status,
+                            "created_at": a.created_at.isoformat() if a.created_at else None,
+                        },
+                        "match_reason": "Same print name",
+                        "match_score": 100,
+                    }
+                )
 
 
         # By content hash
         # By content hash
         if reference.content_hash and len(similar) < limit:
         if reference.content_hash and len(similar) < limit:
@@ -237,16 +239,18 @@ class ArchiveComparisonService:
             )
             )
             for a in result.scalars().all():
             for a in result.scalars().all():
                 if not any(s["archive"]["id"] == a.id for s in similar):
                 if not any(s["archive"]["id"] == a.id for s in similar):
-                    similar.append({
-                        "archive": {
-                            "id": a.id,
-                            "print_name": a.print_name or a.filename,
-                            "status": a.status,
-                            "created_at": a.created_at.isoformat() if a.created_at else None,
-                        },
-                        "match_reason": "Same file content",
-                        "match_score": 95,
-                    })
+                    similar.append(
+                        {
+                            "archive": {
+                                "id": a.id,
+                                "print_name": a.print_name or a.filename,
+                                "status": a.status,
+                                "created_at": a.created_at.isoformat() if a.created_at else None,
+                            },
+                            "match_reason": "Same file content",
+                            "match_score": 95,
+                        }
+                    )
 
 
         # By same filament type
         # By same filament type
         if reference.filament_type and len(similar) < limit:
         if reference.filament_type and len(similar) < limit:
@@ -261,16 +265,18 @@ class ArchiveComparisonService:
             )
             )
             for a in result.scalars().all():
             for a in result.scalars().all():
                 if not any(s["archive"]["id"] == a.id for s in similar):
                 if not any(s["archive"]["id"] == a.id for s in similar):
-                    similar.append({
-                        "archive": {
-                            "id": a.id,
-                            "print_name": a.print_name or a.filename,
-                            "status": a.status,
-                            "created_at": a.created_at.isoformat() if a.created_at else None,
-                        },
-                        "match_reason": f"Same filament type ({reference.filament_type})",
-                        "match_score": 50,
-                    })
+                    similar.append(
+                        {
+                            "archive": {
+                                "id": a.id,
+                                "print_name": a.print_name or a.filename,
+                                "status": a.status,
+                                "created_at": a.created_at.isoformat() if a.created_at else None,
+                            },
+                            "match_reason": f"Same filament type ({reference.filament_type})",
+                            "match_score": 50,
+                        }
+                    )
 
 
         # Sort by match score
         # Sort by match score
         similar.sort(key=lambda x: x["match_score"], reverse=True)
         similar.sort(key=lambda x: x["match_score"], reverse=True)

+ 29 - 53
backend/app/services/bambu_cloud.py

@@ -4,13 +4,11 @@ Bambu Lab Cloud API Service
 Handles authentication and profile management with Bambu Lab's cloud services.
 Handles authentication and profile management with Bambu Lab's cloud services.
 """
 """
 
 
-import httpx
-import json
 import logging
 import logging
-from typing import Optional
-from pathlib import Path
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 
 
+import httpx
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 BAMBU_API_BASE = "https://api.bambulab.com"
 BAMBU_API_BASE = "https://api.bambulab.com"
@@ -19,11 +17,13 @@ BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 
 
 class BambuCloudError(Exception):
 class BambuCloudError(Exception):
     """Base exception for Bambu Cloud errors."""
     """Base exception for Bambu Cloud errors."""
+
     pass
     pass
 
 
 
 
 class BambuCloudAuthError(BambuCloudError):
 class BambuCloudAuthError(BambuCloudError):
     """Authentication related errors."""
     """Authentication related errors."""
+
     pass
     pass
 
 
 
 
@@ -32,9 +32,9 @@ class BambuCloudService:
 
 
     def __init__(self, region: str = "global"):
     def __init__(self, region: str = "global"):
         self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
         self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
-        self.access_token: Optional[str] = None
-        self.refresh_token: Optional[str] = None
-        self.token_expiry: Optional[datetime] = None
+        self.access_token: str | None = None
+        self.refresh_token: str | None = None
+        self.token_expiry: datetime | None = None
         self._client = httpx.AsyncClient(timeout=30.0)
         self._client = httpx.AsyncClient(timeout=30.0)
 
 
     @property
     @property
@@ -42,9 +42,7 @@ class BambuCloudService:
         """Check if we have a valid token."""
         """Check if we have a valid token."""
         if not self.access_token:
         if not self.access_token:
             return False
             return False
-        if self.token_expiry and datetime.now() > self.token_expiry:
-            return False
-        return True
+        return not (self.token_expiry and datetime.now() > self.token_expiry)
 
 
     def _get_headers(self) -> dict:
     def _get_headers(self) -> dict:
         """Get headers for authenticated requests."""
         """Get headers for authenticated requests."""
@@ -69,7 +67,7 @@ class BambuCloudService:
                 json={
                 json={
                     "account": email,
                     "account": email,
                     "password": password,
                     "password": password,
-                }
+                },
             )
             )
 
 
             data = response.json()
             data = response.json()
@@ -78,28 +76,16 @@ class BambuCloudService:
                 # Check if we need verification code
                 # Check if we need verification code
                 # Bambu API returns loginType or may require tfaKey
                 # Bambu API returns loginType or may require tfaKey
                 if data.get("loginType") == "verifyCode" or "tfaKey" in data:
                 if data.get("loginType") == "verifyCode" or "tfaKey" in data:
-                    return {
-                        "success": False,
-                        "needs_verification": True,
-                        "message": "Verification code sent to email"
-                    }
+                    return {"success": False, "needs_verification": True, "message": "Verification code sent to email"}
 
 
                 # Direct login success (rare, usually needs 2FA)
                 # Direct login success (rare, usually needs 2FA)
                 if "accessToken" in data:
                 if "accessToken" in data:
                     self._set_tokens(data)
                     self._set_tokens(data)
-                    return {
-                        "success": True,
-                        "needs_verification": False,
-                        "message": "Login successful"
-                    }
+                    return {"success": True, "needs_verification": False, "message": "Login successful"}
 
 
             # Handle specific error codes
             # Handle specific error codes
             error_msg = data.get("message") or data.get("error") or "Login failed"
             error_msg = data.get("message") or data.get("error") or "Login failed"
-            return {
-                "success": False,
-                "needs_verification": False,
-                "message": error_msg
-            }
+            return {"success": False, "needs_verification": False, "message": error_msg}
 
 
         except Exception as e:
         except Exception as e:
             logger.error(f"Login request failed: {e}")
             logger.error(f"Login request failed: {e}")
@@ -116,22 +102,16 @@ class BambuCloudService:
                 json={
                 json={
                     "account": email,
                     "account": email,
                     "code": code,
                     "code": code,
-                }
+                },
             )
             )
 
 
             data = response.json()
             data = response.json()
 
 
             if response.status_code == 200 and "accessToken" in data:
             if response.status_code == 200 and "accessToken" in data:
                 self._set_tokens(data)
                 self._set_tokens(data)
-                return {
-                    "success": True,
-                    "message": "Login successful"
-                }
-
-            return {
-                "success": False,
-                "message": data.get("message", "Verification failed")
-            }
+                return {"success": True, "message": "Login successful"}
+
+            return {"success": False, "message": data.get("message", "Verification failed")}
 
 
         except Exception as e:
         except Exception as e:
             logger.error(f"Verification failed: {e}")
             logger.error(f"Verification failed: {e}")
@@ -162,8 +142,7 @@ class BambuCloudService:
 
 
         try:
         try:
             response = await self._client.get(
             response = await self._client.get(
-                f"{self.base_url}/v1/design-user-service/my/preference",
-                headers=self._get_headers()
+                f"{self.base_url}/v1/design-user-service/my/preference", headers=self._get_headers()
             )
             )
 
 
             if response.status_code == 200:
             if response.status_code == 200:
@@ -188,7 +167,7 @@ class BambuCloudService:
             response = await self._client.get(
             response = await self._client.get(
                 f"{self.base_url}/v1/iot-service/api/slicer/setting",
                 f"{self.base_url}/v1/iot-service/api/slicer/setting",
                 headers=self._get_headers(),
                 headers=self._get_headers(),
-                params={"version": version}
+                params={"version": version},
             )
             )
 
 
             data = response.json()
             data = response.json()
@@ -208,8 +187,7 @@ class BambuCloudService:
 
 
         try:
         try:
             response = await self._client.get(
             response = await self._client.get(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
-                headers=self._get_headers()
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
             )
             )
 
 
             if response.status_code == 200:
             if response.status_code == 200:
@@ -220,7 +198,9 @@ class BambuCloudService:
         except httpx.RequestError as e:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {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:
+    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.
         Create a new slicer preset/setting.
 
 
@@ -240,6 +220,7 @@ class BambuCloudService:
         try:
         try:
             # Add timestamp if not present
             # Add timestamp if not present
             import time
             import time
+
             if "updated_time" not in setting:
             if "updated_time" not in setting:
                 setting["updated_time"] = str(int(time.time()))
                 setting["updated_time"] = str(int(time.time()))
 
 
@@ -252,9 +233,7 @@ class BambuCloudService:
             }
             }
 
 
             response = await self._client.post(
             response = await self._client.post(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting",
-                headers=self._get_headers(),
-                json=payload
+                f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
             )
             )
 
 
             data = response.json()
             data = response.json()
@@ -320,6 +299,7 @@ class BambuCloudService:
 
 
             # Update the timestamp
             # Update the timestamp
             import time
             import time
+
             updated_setting["updated_time"] = str(int(time.time()))
             updated_setting["updated_time"] = str(int(time.time()))
 
 
             # Ensure settings_id field matches the name
             # Ensure settings_id field matches the name
@@ -338,9 +318,7 @@ class BambuCloudService:
             }
             }
 
 
             response = await self._client.post(
             response = await self._client.post(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting",
-                headers=self._get_headers(),
-                json=payload
+                f"{self.base_url}/v1/iot-service/api/slicer/setting", headers=self._get_headers(), json=payload
             )
             )
 
 
             data = response.json()
             data = response.json()
@@ -369,8 +347,7 @@ class BambuCloudService:
 
 
         try:
         try:
             response = await self._client.delete(
             response = await self._client.delete(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
-                headers=self._get_headers()
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
             )
             )
 
 
             if response.status_code in (200, 204):
             if response.status_code in (200, 204):
@@ -390,8 +367,7 @@ class BambuCloudService:
 
 
         try:
         try:
             response = await self._client.get(
             response = await self._client.get(
-                f"{self.base_url}/v1/iot-service/api/user/bind",
-                headers=self._get_headers()
+                f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
             )
             )
 
 
             if response.status_code == 200:
             if response.status_code == 200:
@@ -408,7 +384,7 @@ class BambuCloudService:
 
 
 
 
 # Singleton instance
 # Singleton instance
-_cloud_service: Optional[BambuCloudService] = None
+_cloud_service: BambuCloudService | None = None
 
 
 
 
 def get_cloud_service() -> BambuCloudService:
 def get_cloud_service() -> BambuCloudService:

+ 36 - 26
backend/app/services/bambu_ftp.py

@@ -1,10 +1,10 @@
-import ssl
-import socket
 import asyncio
 import asyncio
 import logging
 import logging
-from ftplib import FTP_TLS, FTP
-from pathlib import Path
+import socket
+import ssl
+from ftplib import FTP, FTP_TLS
 from io import BytesIO
 from io import BytesIO
+from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -19,7 +19,7 @@ class ImplicitFTP_TLS(FTP_TLS):
         self.ssl_context.check_hostname = False
         self.ssl_context.check_hostname = False
         self.ssl_context.verify_mode = ssl.CERT_NONE
         self.ssl_context.verify_mode = ssl.CERT_NONE
 
 
-    def connect(self, host='', port=990, timeout=-999, source_address=None):
+    def connect(self, host="", port=990, timeout=-999, source_address=None):
         """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
         """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
         if host:
         if host:
             self.host = host
             self.host = host
@@ -31,14 +31,10 @@ class ImplicitFTP_TLS(FTP_TLS):
             self.source_address = source_address
             self.source_address = source_address
 
 
         # Create and wrap socket immediately (implicit TLS)
         # Create and wrap socket immediately (implicit TLS)
-        self.sock = socket.create_connection(
-            (self.host, self.port),
-            self.timeout,
-            source_address=self.source_address
-        )
+        self.sock = socket.create_connection((self.host, self.port), self.timeout, source_address=self.source_address)
         self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)
         self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)
         self.af = self.sock.family
         self.af = self.sock.family
-        self.file = self.sock.makefile('r', encoding=self.encoding)
+        self.file = self.sock.makefile("r", encoding=self.encoding)
         self.welcome = self.getresp()
         self.welcome = self.getresp()
         return self.welcome
         return self.welcome
 
 
@@ -50,12 +46,11 @@ class ImplicitFTP_TLS(FTP_TLS):
             conn = self.ssl_context.wrap_socket(
             conn = self.ssl_context.wrap_socket(
                 conn,
                 conn,
                 server_hostname=self.host,
                 server_hostname=self.host,
-                session=self.sock.session  # Reuse session!
+                session=self.sock.session,  # Reuse session!
             )
             )
         return conn, size
         return conn, size
 
 
 
 
-
 class BambuFTPClient:
 class BambuFTPClient:
     """FTP client for retrieving files from Bambu Lab printers."""
     """FTP client for retrieving files from Bambu Lab printers."""
 
 
@@ -69,11 +64,15 @@ class BambuFTPClient:
     def connect(self) -> bool:
     def connect(self) -> bool:
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
         try:
+            logger.debug(f"FTP connecting to {self.ip_address}:{self.FTP_PORT}")
             self._ftp = ImplicitFTP_TLS()
             self._ftp = ImplicitFTP_TLS()
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=10)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=10)
+            logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             self._ftp.login("bblp", self.access_code)
+            logger.debug("FTP logged in, setting prot_p and passive mode")
             self._ftp.prot_p()
             self._ftp.prot_p()
             self._ftp.set_pasv(True)
             self._ftp.set_pasv(True)
+            logger.info(f"FTP connected successfully to {self.ip_address}")
             return True
             return True
         except Exception as e:
         except Exception as e:
             logger.warning(f"FTP connection failed to {self.ip_address}: {e}")
             logger.warning(f"FTP connection failed to {self.ip_address}: {e}")
@@ -112,6 +111,7 @@ class BambuFTPClient:
                     mtime = None
                     mtime = None
                     try:
                     try:
                         from datetime import datetime
                         from datetime import datetime
+
                         month = parts[5]
                         month = parts[5]
                         day = parts[6]
                         day = parts[6]
                         time_or_year = parts[7]
                         time_or_year = parts[7]
@@ -141,8 +141,9 @@ class BambuFTPClient:
                     if mtime:
                     if mtime:
                         file_entry["mtime"] = mtime
                         file_entry["mtime"] = mtime
                     files.append(file_entry)
                     files.append(file_entry)
-        except Exception:
-            pass
+            logger.debug(f"Listed {len(files)} files in {path}")
+        except Exception as e:
+            logger.info(f"FTP list_files failed for {path}: {e}")
 
 
         return files
         return files
 
 
@@ -168,10 +169,12 @@ class BambuFTPClient:
             local_path.parent.mkdir(parents=True, exist_ok=True)
             local_path.parent.mkdir(parents=True, exist_ok=True)
             with open(local_path, "wb") as f:
             with open(local_path, "wb") as f:
                 self._ftp.retrbinary(f"RETR {remote_path}", f.write)
                 self._ftp.retrbinary(f"RETR {remote_path}", f.write)
-            logger.info(f"Successfully downloaded {remote_path} to {local_path}")
+            file_size = local_path.stat().st_size if local_path.exists() else 0
+            logger.info(f"Successfully downloaded {remote_path} to {local_path} ({file_size} bytes)")
             return True
             return True
         except Exception as e:
         except Exception as e:
-            logger.debug(f"Failed to download {remote_path}: {e}")
+            # Log at INFO level so we can see failures in normal logs
+            logger.info(f"FTP download failed for {remote_path}: {e}")
             # Clean up partial file if it exists
             # Clean up partial file if it exists
             if local_path.exists():
             if local_path.exists():
                 try:
                 try:
@@ -183,7 +186,7 @@ class BambuFTPClient:
     def upload_file(self, local_path: Path, remote_path: str) -> bool:
     def upload_file(self, local_path: Path, remote_path: str) -> bool:
         """Upload a file to the printer."""
         """Upload a file to the printer."""
         if not self._ftp:
         if not self._ftp:
-            logger.warning(f"upload_file: FTP not connected")
+            logger.warning("upload_file: FTP not connected")
             return False
             return False
 
 
         try:
         try:
@@ -289,8 +292,9 @@ async def download_file_async(
     access_code: str,
     access_code: str,
     remote_path: str,
     remote_path: str,
     local_path: Path,
     local_path: Path,
+    timeout: float = 60.0,
 ) -> bool:
 ) -> bool:
-    """Async wrapper for downloading a file."""
+    """Async wrapper for downloading a file with timeout."""
     loop = asyncio.get_event_loop()
     loop = asyncio.get_event_loop()
 
 
     def _download():
     def _download():
@@ -302,7 +306,11 @@ async def download_file_async(
                 client.disconnect()
                 client.disconnect()
         return False
         return False
 
 
-    return await loop.run_in_executor(None, _download)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(None, _download), timeout=timeout)
+    except TimeoutError:
+        logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
+        return False
 
 
 
 
 async def download_file_try_paths_async(
 async def download_file_try_paths_async(
@@ -320,10 +328,7 @@ async def download_file_try_paths_async(
             return False
             return False
 
 
         try:
         try:
-            for remote_path in remote_paths:
-                if client.download_to_file(remote_path, local_path):
-                    return True
-            return False
+            return any(client.download_to_file(remote_path, local_path) for remote_path in remote_paths)
         finally:
         finally:
             client.disconnect()
             client.disconnect()
 
 
@@ -358,8 +363,9 @@ async def list_files_async(
     ip_address: str,
     ip_address: str,
     access_code: str,
     access_code: str,
     path: str = "/",
     path: str = "/",
+    timeout: float = 30.0,
 ) -> list[dict]:
 ) -> list[dict]:
-    """Async wrapper for listing files."""
+    """Async wrapper for listing files with timeout."""
     loop = asyncio.get_event_loop()
     loop = asyncio.get_event_loop()
 
 
     def _list():
     def _list():
@@ -371,7 +377,11 @@ async def list_files_async(
                 client.disconnect()
                 client.disconnect()
         return []
         return []
 
 
-    return await loop.run_in_executor(None, _list)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
+    except TimeoutError:
+        logger.warning(f"FTP list_files timed out after {timeout}s for {path}")
+        return []
 
 
 
 
 async def delete_file_async(
 async def delete_file_async(

+ 120 - 30
backend/app/services/bambu_mqtt.py

@@ -150,6 +150,11 @@ class PrinterState:
     printable_objects: dict = field(default_factory=dict)
     printable_objects: dict = field(default_factory=dict)
     # Objects that have been skipped during the current print
     # Objects that have been skipped during the current print
     skipped_objects: list = field(default_factory=list)
     skipped_objects: list = field(default_factory=list)
+    # Fan speeds (0-100 percentage, None if not available for this model)
+    cooling_fan_speed: int | None = None  # Part cooling fan
+    big_fan1_speed: int | None = None  # Auxiliary fan
+    big_fan2_speed: int | None = None  # Chamber/exhaust fan
+    heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
 
 
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -698,12 +703,15 @@ class BambuMQTTClient:
         import hashlib
         import hashlib
 
 
         # Handle nested ams structure: {"ams": {"ams": [...]}} or {"ams": [...]}
         # Handle nested ams structure: {"ams": {"ams": [...]}} or {"ams": [...]}
-        if isinstance(ams_data, dict) and "ams" in ams_data:
-            ams_list = ams_data["ams"]
+        # Also handle P1S partial updates: {"tray_now": ..., "tray_tar": ...} without "ams" key
+        ams_list = None
+        if isinstance(ams_data, dict):
+            if "ams" in ams_data:
+                ams_list = ams_data["ams"]
             # Log all AMS dict fields to debug tray_now for H2D dual-nozzle
             # Log all AMS dict fields to debug tray_now for H2D dual-nozzle
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             if non_list_fields:
             if non_list_fields:
-                logger.info(f"[{self.serial_number}] AMS dict fields: {non_list_fields}")
+                logger.debug(f"[{self.serial_number}] AMS dict fields: {non_list_fields}")
 
 
             # IMPORTANT: Parse ams_status FIRST before tray_now, so we have fresh status
             # IMPORTANT: Parse ams_status FIRST before tray_now, so we have fresh status
             # when checking if we're in filament change mode for tray_now disambiguation
             # when checking if we're in filament change mode for tray_now disambiguation
@@ -725,6 +733,7 @@ class BambuMQTTClient:
                 )
                 )
 
 
             # Parse tray_now from AMS dict - this is the currently loaded tray global ID
             # Parse tray_now from AMS dict - this is the currently loaded tray global ID
+            # Note: tray_tar is also available but on H2D it's just slot number (0-3), not global ID
             if "tray_now" in ams_data:
             if "tray_now" in ams_data:
                 raw_tray_now = ams_data["tray_now"]
                 raw_tray_now = ams_data["tray_now"]
                 # Convert string to int if needed
                 # Convert string to int if needed
@@ -767,33 +776,61 @@ class BambuMQTTClient:
                         active_ext = self.state.active_extruder  # 0=right, 1=left
                         active_ext = self.state.active_extruder  # 0=right, 1=left
                         ams_map = self.state.ams_extruder_map  # {ams_id: extruder_id}
                         ams_map = self.state.ams_extruder_map  # {ams_id: extruder_id}
 
 
-                        # Find the AMS connected to the active extruder
-                        active_ams_id = None
-                        for ams_id_str, ext_id in ams_map.items():
-                            if ext_id == active_ext:
-                                try:
-                                    active_ams_id = int(ams_id_str)
-                                except ValueError:
-                                    pass
-                                break
-
-                        if active_ams_id is not None:
-                            # Calculate global tray ID using the active AMS
-                            global_tray_id = active_ams_id * 4 + parsed_tray_now
-                            logger.info(
-                                f"[{self.serial_number}] H2D tray_now disambiguation: "
-                                f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}"
-                            )
-                            self.state.tray_now = global_tray_id
-                        else:
-                            # No AMS found for active extruder - check if we already have a resolved global ID
-                            current_tray = self.state.tray_now
-                            if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
-                                # Current tray_now is already a valid global ID that matches this slot
+                        # First, check if current tray_now is already a valid global ID
+                        # that matches this slot AND is connected to the active extruder
+                        current_tray = self.state.tray_now
+                        if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
+                            current_ams = current_tray // 4
+                            current_ams_extruder = ams_map.get(str(current_ams))
+                            if current_ams_extruder == active_ext:
+                                # Current tray is valid, matches slot, and on correct extruder - keep it
                                 logger.debug(
                                 logger.debug(
                                     f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
                                     f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
-                                    f"(matches incoming slot {parsed_tray_now})"
+                                    f"(slot {parsed_tray_now}, AMS {current_ams} on extruder {active_ext})"
                                 )
                                 )
+                                # Don't update tray_now - it's already correct
+                                pass
+                            else:
+                                # Current AMS is on wrong extruder - need to find correct AMS
+                                active_ams_id = None
+                                for ams_id_str, ext_id in ams_map.items():
+                                    if ext_id == active_ext:
+                                        try:
+                                            active_ams_id = int(ams_id_str)
+                                        except ValueError:
+                                            pass
+                                        break
+                                if active_ams_id is not None:
+                                    global_tray_id = active_ams_id * 4 + parsed_tray_now
+                                    logger.info(
+                                        f"[{self.serial_number}] H2D tray_now disambiguation: "
+                                        f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}"
+                                    )
+                                    self.state.tray_now = global_tray_id
+                                else:
+                                    logger.warning(
+                                        f"[{self.serial_number}] H2D tray_now: no ams_extruder_map for active_extruder {active_ext}"
+                                    )
+                                    self.state.tray_now = parsed_tray_now
+                        else:
+                            # No valid current tray - find an AMS connected to the active extruder
+                            active_ams_id = None
+                            for ams_id_str, ext_id in ams_map.items():
+                                if ext_id == active_ext:
+                                    try:
+                                        active_ams_id = int(ams_id_str)
+                                    except ValueError:
+                                        pass
+                                    break
+
+                            if active_ams_id is not None:
+                                # Calculate global tray ID using the active AMS
+                                global_tray_id = active_ams_id * 4 + parsed_tray_now
+                                logger.info(
+                                    f"[{self.serial_number}] H2D tray_now disambiguation: "
+                                    f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}"
+                                )
+                                self.state.tray_now = global_tray_id
                             else:
                             else:
                                 # Fallback: use slot as-is
                                 # Fallback: use slot as-is
                                 logger.warning(
                                 logger.warning(
@@ -813,17 +850,36 @@ class BambuMQTTClient:
 
 
             # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct
             # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct
             # state when checking filament change mode for H2D disambiguation
             # state when checking filament change mode for H2D disambiguation
+
+            # P1S/P1P send partial updates without "ams" key - this is valid, not an error
+            # We've already processed the status fields above, so just return if no ams list
+            if ams_list is None:
+                logger.debug(f"[{self.serial_number}] AMS partial update (no tray data)")
+                return
         elif isinstance(ams_data, list):
         elif isinstance(ams_data, list):
             ams_list = ams_data
             ams_list = ams_data
         else:
         else:
             logger.warning(f"[{self.serial_number}] Unexpected AMS data format: {type(ams_data)}")
             logger.warning(f"[{self.serial_number}] Unexpected AMS data format: {type(ams_data)}")
             return
             return
 
 
-        # Store AMS data in raw_data so it's accessible via API
-        self.state.raw_data["ams"] = ams_list
+        # Merge AMS data instead of replacing, to handle partial updates
+        # During prints, the printer may only send updates for active AMS units
+        existing_ams = self.state.raw_data.get("ams", [])
+        existing_by_id = {ams.get("id"): ams for ams in existing_ams if ams.get("id") is not None}
+
+        # Update existing units with new data, add new units
+        for ams_unit in ams_list:
+            ams_id = ams_unit.get("id")
+            if ams_id is not None:
+                existing_by_id[ams_id] = ams_unit
+
+        # Convert back to list, sorted by ID for consistent ordering
+        merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get("id", 0))
+        self.state.raw_data["ams"] = merged_ams
+
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
         self.state.last_ams_update = time.time()
         self.state.last_ams_update = time.time()
-        logger.debug(f"[{self.serial_number}] Stored AMS data with {len(ams_list)} units")
+        logger.debug(f"[{self.serial_number}] Merged AMS data: {len(ams_list)} new units, {len(merged_ams)} total")
 
 
         # Extract ams_extruder_map from each AMS unit's info field
         # Extract ams_extruder_map from each AMS unit's info field
         # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
         # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
@@ -903,6 +959,40 @@ class BambuMQTTClient:
         if "total_layer_num" in data:
         if "total_layer_num" in data:
             self.state.total_layers = int(data["total_layer_num"])
             self.state.total_layers = int(data["total_layer_num"])
 
 
+        # Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
+        # Convert to 0-100 percentage for display
+        def parse_fan_speed(value: str | int | None) -> int | None:
+            if value is None:
+                return None
+            try:
+                speed = int(value)
+                # MQTT reports 0-15 speed levels, convert to percentage (0-100)
+                # 15 = 100%, so multiply by 100/15 ≈ 6.67
+                if speed <= 15:
+                    return round(speed * 100 / 15)
+                # If already a percentage (0-255 scale from some printers), convert
+                elif speed <= 255:
+                    return round(speed * 100 / 255)
+                return speed
+            except (ValueError, TypeError):
+                return None
+
+        # Log fan fields once for debugging
+        if not hasattr(self, "_fan_fields_logged"):
+            fan_fields = {k: v for k, v in data.items() if "fan" in k.lower()}
+            if fan_fields:
+                logger.info(f"[{self.serial_number}] Fan fields in MQTT data: {fan_fields}")
+                self._fan_fields_logged = True
+
+        if "cooling_fan_speed" in data:
+            self.state.cooling_fan_speed = parse_fan_speed(data["cooling_fan_speed"])
+        if "big_fan1_speed" in data:
+            self.state.big_fan1_speed = parse_fan_speed(data["big_fan1_speed"])
+        if "big_fan2_speed" in data:
+            self.state.big_fan2_speed = parse_fan_speed(data["big_fan2_speed"])
+        if "heatbreak_fan_speed" in data:
+            self.state.heatbreak_fan_speed = parse_fan_speed(data["heatbreak_fan_speed"])
+
         # Calibration stage tracking
         # Calibration stage tracking
         if "stg_cur" in data:
         if "stg_cur" in data:
             new_stg = data["stg_cur"]
             new_stg = data["stg_cur"]

+ 1 - 1
backend/app/services/discovery.py

@@ -566,7 +566,7 @@ class TasmotaScanner:
                 try:
                 try:
                     await asyncio.gather(*tasks, return_exceptions=True)
                     await asyncio.gather(*tasks, return_exceptions=True)
                 except Exception as e:
                 except Exception as e:
-                    logger.warning(f"Batch {i//batch_size} error: {e}")
+                    logger.warning(f"Batch {i // batch_size} error: {e}")
                 self._scanned = min(i + batch_size, len(hosts))
                 self._scanned = min(i + batch_size, len(hosts))
 
 
             logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
             logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")

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

@@ -15,9 +15,8 @@ import httpx
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue
+from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
-from backend.app.models.settings import Settings
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -77,9 +76,7 @@ class NotificationService:
         if event_type in self._template_cache:
         if event_type in self._template_cache:
             return self._template_cache[event_type]
             return self._template_cache[event_type]
 
 
-        result = await db.execute(
-            select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)
-        )
+        result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
         template = result.scalar_one_or_none()
         template = result.scalar_one_or_none()
 
 
         if template:
         if template:
@@ -109,6 +106,7 @@ class NotificationService:
     def _clean_filename(self, filename: str) -> str:
     def _clean_filename(self, filename: str) -> str:
         """Extract filename and remove file extensions."""
         """Extract filename and remove file extensions."""
         import os
         import os
+
         # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)
         # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)
         filename = os.path.basename(filename)
         filename = os.path.basename(filename)
         # Remove common extensions
         # Remove common extensions
@@ -334,11 +332,13 @@ class NotificationService:
 
 
         # Discord embed format for nicer messages
         # Discord embed format for nicer messages
         data = {
         data = {
-            "embeds": [{
-                "title": title,
-                "description": message,
-                "color": 0x00AE42,  # Bambu green
-            }]
+            "embeds": [
+                {
+                    "title": title,
+                    "description": message,
+                    "color": 0x00AE42,  # Bambu green
+                }
+            ]
         }
         }
 
 
         client = await self._get_client()
         client = await self._get_client()
@@ -422,9 +422,7 @@ class NotificationService:
         self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
         self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
     ):
     ):
         """Update provider status after sending notification."""
         """Update provider status after sending notification."""
-        result = await db.execute(
-            select(NotificationProvider).where(NotificationProvider.id == provider_id)
-        )
+        result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
         provider = result.scalar_one_or_none()
         provider = result.scalar_one_or_none()
         if provider:
         if provider:
             if success:
             if success:
@@ -443,13 +441,13 @@ class NotificationService:
         """Get all enabled providers that want a specific event type."""
         """Get all enabled providers that want a specific event type."""
         # Build the query dynamically based on event field
         # Build the query dynamically based on event field
         query = select(NotificationProvider).where(
         query = select(NotificationProvider).where(
-            NotificationProvider.enabled == True,
-            getattr(NotificationProvider, event_field) == True,
+            NotificationProvider.enabled.is_(True),
+            getattr(NotificationProvider, event_field).is_(True),
         )
         )
 
 
         if printer_id is not None:
         if printer_id is not None:
             query = query.where(
             query = query.where(
-                (NotificationProvider.printer_id == None) | (NotificationProvider.printer_id == printer_id)
+                (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)
             )
             )
 
 
         result = await db.execute(query)
         result = await db.execute(query)
@@ -700,9 +698,7 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "print_progress", variables)
         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)
         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
-    ):
+    async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         """Handle printer offline event."""
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
         if not providers:
         if not providers:
@@ -814,7 +810,9 @@ class NotificationService:
 
 
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
-        await self._send_to_providers(providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True)
+        await self._send_to_providers(
+            providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True
+        )
 
 
     async def on_ams_temperature_high(
     async def on_ams_temperature_high(
         self,
         self,
@@ -839,7 +837,9 @@ class NotificationService:
 
 
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
-        await self._send_to_providers(providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True)
+        await self._send_to_providers(
+            providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True
+        )
 
 
     async def on_ams_ht_humidity_high(
     async def on_ams_ht_humidity_high(
         self,
         self,
@@ -865,7 +865,9 @@ class NotificationService:
         # Use the same template as regular AMS (can create separate templates later if needed)
         # Use the same template as regular AMS (can create separate templates later if needed)
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
-        await self._send_to_providers(providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True)
+        await self._send_to_providers(
+            providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True
+        )
 
 
     async def on_ams_ht_temperature_high(
     async def on_ams_ht_temperature_high(
         self,
         self,
@@ -891,7 +893,9 @@ class NotificationService:
         # Use the same template as regular AMS (can create separate templates later if needed)
         # Use the same template as regular AMS (can create separate templates later if needed)
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
-        await self._send_to_providers(providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True)
+        await self._send_to_providers(
+            providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True
+        )
 
 
     def clear_template_cache(self):
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         """Clear the template cache. Call this when templates are updated."""
@@ -929,9 +933,7 @@ class NotificationService:
 
 
         async with async_session() as db:
         async with async_session() as db:
             # Get the provider
             # Get the provider
-            result = await db.execute(
-                select(NotificationProvider).where(NotificationProvider.id == provider_id)
-            )
+            result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
             provider = result.scalar_one_or_none()
             provider = result.scalar_one_or_none()
 
 
             if not provider or not provider.enabled:
             if not provider or not provider.enabled:
@@ -1011,8 +1013,8 @@ class NotificationService:
             # Find all providers with digest enabled at this time
             # Find all providers with digest enabled at this time
             result = await db.execute(
             result = await db.execute(
                 select(NotificationProvider).where(
                 select(NotificationProvider).where(
-                    NotificationProvider.enabled == True,
-                    NotificationProvider.daily_digest_enabled == True,
+                    NotificationProvider.enabled.is_(True),
+                    NotificationProvider.daily_digest_enabled.is_(True),
                     NotificationProvider.daily_digest_time == current_time,
                     NotificationProvider.daily_digest_time == current_time,
                 )
                 )
             )
             )

+ 9 - 11
backend/app/services/print_scheduler.py

@@ -3,16 +3,15 @@
 import asyncio
 import asyncio
 import logging
 import logging
 from datetime import datetime
 from datetime import datetime
-from pathlib import Path
 
 
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
-from backend.app.models.archive import PrintArchive
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import upload_file_async
 from backend.app.services.bambu_ftp import upload_file_async
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
@@ -73,6 +72,10 @@ class PrintScheduler:
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
                     continue
 
 
+                # Skip items that require manual start
+                if item.manual_start:
+                    continue
+
                 # Check if printer is idle
                 # Check if printer is idle
                 printer_idle = self._is_printer_idle(item.printer_id)
                 printer_idle = self._is_printer_idle(item.printer_id)
                 printer_connected = printer_manager.is_connected(item.printer_id)
                 printer_connected = printer_manager.is_connected(item.printer_id)
@@ -128,9 +131,7 @@ class PrintScheduler:
 
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""
         """Get the smart plug associated with a printer."""
-        result = await db.execute(
-            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-        )
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return result.scalar_one_or_none()
         return result.scalar_one_or_none()
 
 
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
@@ -222,9 +223,7 @@ class PrintScheduler:
         logger.info(f"Starting queue item {item.id}")
         logger.info(f"Starting queue item {item.id}")
 
 
         # Get archive
         # Get archive
-        result = await db.execute(
-            select(PrintArchive).where(PrintArchive.id == item.archive_id)
-        )
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
         archive = result.scalar_one_or_none()
         archive = result.scalar_one_or_none()
         if not archive:
         if not archive:
             item.status = "failed"
             item.status = "failed"
@@ -236,9 +235,7 @@ class PrintScheduler:
             return
             return
 
 
         # Get printer
         # Get printer
-        result = await db.execute(
-            select(Printer).where(Printer.id == item.printer_id)
-        )
+        result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
         if not printer:
         if not printer:
             item.status = "failed"
             item.status = "failed"
@@ -296,6 +293,7 @@ class PrintScheduler:
 
 
         # Register as expected print so we don't create a duplicate archive
         # Register as expected print so we don't create a duplicate archive
         from backend.app.main import register_expected_print
         from backend.app.main import register_expected_print
+
         register_expected_print(item.printer_id, remote_filename, archive.id)
         register_expected_print(item.printer_id, remote_filename, archive.id)
 
 
         # Start the print
         # Start the print

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

@@ -477,6 +477,11 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "stg_cur_name": get_derived_status_name(state),
         "stg_cur_name": get_derived_status_name(state),
         # Printable objects count for skip objects feature
         # Printable objects count for skip objects feature
         "printable_objects_count": len(state.printable_objects),
         "printable_objects_count": len(state.printable_objects),
+        # Fan speeds (0-100 percentage, None if not available)
+        "cooling_fan_speed": state.cooling_fan_speed,
+        "big_fan1_speed": state.big_fan1_speed,
+        "big_fan2_speed": state.big_fan2_speed,
+        "heatbreak_fan_speed": state.heatbreak_fan_speed,
     }
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Add cover URL if there's an active print and printer_id is provided
     # Include PAUSE/PAUSED states so skip objects modal can show cover
     # Include PAUSE/PAUSED states so skip objects modal can show cover

+ 19 - 47
backend/app/services/smart_plug_manager.py

@@ -5,11 +5,11 @@ import logging
 from datetime import datetime
 from datetime import datetime
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.services.tasmota import tasmota_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.tasmota import tasmota_service
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.models.smart_plug import SmartPlug
@@ -64,8 +64,8 @@ class SmartPlugManager:
         async with async_session() as db:
         async with async_session() as db:
             result = await db.execute(
             result = await db.execute(
                 select(SmartPlug).where(
                 select(SmartPlug).where(
-                    SmartPlug.enabled == True,
-                    SmartPlug.schedule_enabled == True,
+                    SmartPlug.enabled.is_(True),
+                    SmartPlug.schedule_enabled.is_(True),
                 )
                 )
             )
             )
             plugs = result.scalars().all()
             plugs = result.scalars().all()
@@ -98,15 +98,11 @@ class SmartPlugManager:
 
 
             await db.commit()
             await db.commit()
 
 
-    async def _get_plug_for_printer(
-        self, printer_id: int, db: AsyncSession
-    ) -> "SmartPlug | None":
+    async def _get_plug_for_printer(self, printer_id: int, db: AsyncSession) -> "SmartPlug | None":
         """Get the smart plug linked to a printer."""
         """Get the smart plug linked to a printer."""
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug import SmartPlug
 
 
-        result = await db.execute(
-            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-        )
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return result.scalar_one_or_none()
         return result.scalar_one_or_none()
 
 
     async def on_print_start(self, printer_id: int, db: AsyncSession):
     async def on_print_start(self, printer_id: int, db: AsyncSession):
@@ -138,9 +134,7 @@ class SmartPlugManager:
             plug.auto_off_executed = False  # Reset flag when turning on
             plug.auto_off_executed = False  # Reset flag when turning on
             await db.commit()
             await db.commit()
 
 
-    async def on_print_complete(
-        self, printer_id: int, status: str, db: AsyncSession
-    ):
+    async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
         """Called when a print completes - schedule turn off if configured.
         """Called when a print completes - schedule turn off if configured.
 
 
         Only triggers auto-off on successful completion (status='completed').
         Only triggers auto-off on successful completion (status='completed').
@@ -168,10 +162,7 @@ class SmartPlugManager:
             )
             )
             return
             return
 
 
-        logger.info(
-            f"Print completed successfully on printer {printer_id}, "
-            f"scheduling turn-off for plug '{plug.name}'"
-        )
+        logger.info(f"Print completed successfully on printer {printer_id}, scheduling turn-off for plug '{plug.name}'")
 
 
         if plug.off_delay_mode == "time":
         if plug.off_delay_mode == "time":
             self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
             self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
@@ -183,9 +174,7 @@ class SmartPlugManager:
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
-        logger.info(
-            f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds"
-        )
+        logger.info(f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds")
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -231,17 +220,12 @@ class SmartPlugManager:
         finally:
         finally:
             self._pending_off.pop(plug_id, None)
             self._pending_off.pop(plug_id, None)
 
 
-    def _schedule_temp_based_off(
-        self, plug: "SmartPlug", printer_id: int, temp_threshold: int
-    ):
+    def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
         """Monitor temperature and turn off when below threshold."""
         """Monitor temperature and turn off when below threshold."""
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
-        logger.info(
-            f"Scheduling temperature-based turn-off for plug '{plug.name}' "
-            f"(threshold: {temp_threshold}°C)"
-        )
+        logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' (threshold: {temp_threshold}°C)")
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -295,10 +279,7 @@ class SmartPlugManager:
                             f"threshold={temp_threshold}°C"
                             f"threshold={temp_threshold}°C"
                         )
                         )
                     else:
                     else:
-                        logger.info(
-                            f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, "
-                            f"threshold={temp_threshold}°C"
-                        )
+                        logger.info(f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, threshold={temp_threshold}°C")
 
 
                     if max_nozzle_temp < temp_threshold:
                     if max_nozzle_temp < temp_threshold:
                         # All nozzles are below threshold, turn off
                         # All nozzles are below threshold, turn off
@@ -328,9 +309,7 @@ class SmartPlugManager:
                 elapsed += check_interval
                 elapsed += check_interval
 
 
             if elapsed >= max_wait:
             if elapsed >= max_wait:
-                logger.warning(
-                    f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s"
-                )
+                logger.warning(f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s")
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
             logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
@@ -344,9 +323,7 @@ class SmartPlugManager:
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
 
 
             async with async_session() as db:
             async with async_session() as db:
-                result = await db.execute(
-                    select(SmartPlug).where(SmartPlug.id == plug_id)
-                )
+                result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug:
                 if plug:
                     plug.auto_off_pending = pending
                     plug.auto_off_pending = pending
@@ -363,9 +340,7 @@ class SmartPlugManager:
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
 
 
             async with async_session() as db:
             async with async_session() as db:
-                result = await db.execute(
-                    select(SmartPlug).where(SmartPlug.id == plug_id)
-                )
+                result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug:
                 if plug:
                     plug.auto_off = False  # Disable auto-off (one-shot behavior)
                     plug.auto_off = False  # Disable auto-off (one-shot behavior)
@@ -407,8 +382,8 @@ class SmartPlugManager:
                 # Find all plugs with pending auto-off
                 # Find all plugs with pending auto-off
                 result = await db.execute(
                 result = await db.execute(
                     select(SmartPlug).where(
                     select(SmartPlug).where(
-                        SmartPlug.auto_off_pending == True,
-                        SmartPlug.printer_id != None,
+                        SmartPlug.auto_off_pending.is_(True),
+                        SmartPlug.printer_id.isnot(None),
                     )
                     )
                 )
                 )
                 pending_plugs = result.scalars().all()
                 pending_plugs = result.scalars().all()
@@ -419,7 +394,7 @@ class SmartPlugManager:
                         elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
                         elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
                         if elapsed > 7200:  # 2 hours
                         if elapsed > 7200:  # 2 hours
                             logger.warning(
                             logger.warning(
-                                f"Auto-off for plug '{plug.name}' was pending for {elapsed/60:.0f} minutes, "
+                                f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
                                 f"clearing stale pending state"
                                 f"clearing stale pending state"
                             )
                             )
                             plug.auto_off_pending = False
                             plug.auto_off_pending = False
@@ -427,10 +402,7 @@ class SmartPlugManager:
                             await db.commit()
                             await db.commit()
                             continue
                             continue
 
 
-                    logger.info(
-                        f"Resuming pending auto-off for plug '{plug.name}' "
-                        f"(printer {plug.printer_id})"
-                    )
+                    logger.info(f"Resuming pending auto-off for plug '{plug.name}' (printer {plug.printer_id})")
 
 
                     # Resume the appropriate off mode
                     # Resume the appropriate off mode
                     if plug.off_delay_mode == "temperature":
                     if plug.off_delay_mode == "temperature":

+ 16 - 8
backend/app/services/spoolman.py

@@ -577,9 +577,7 @@ class SpoolmanClient:
             )
             )
 
 
         # Spool not found - auto-create it
         # Spool not found - auto-create it
-        logger.info(
-            f"Creating new spool in Spoolman for {tray.tray_sub_brands} " f"(tray_uuid: {tray.tray_uuid[:16]}...)"
-        )
+        logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tray_uuid: {tray.tray_uuid[:16]}...)")
 
 
         # First find or create the filament type
         # First find or create the filament type
         filament = await self._find_or_create_filament(tray)
         filament = await self._find_or_create_filament(tray)
@@ -602,17 +600,28 @@ class SpoolmanClient:
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
         """Find existing filament or create new one.
         """Find existing filament or create new one.
 
 
+        Only matches Bambu Lab vendor filaments since this is called for
+        Bambu Lab spools. Third-party filaments (like 3DJAKE) are ignored
+        to prevent incorrect matching by color alone.
+
         Args:
         Args:
             tray: The AMSTray containing filament info
             tray: The AMSTray containing filament info
 
 
         Returns:
         Returns:
             Filament dictionary or None on failure.
             Filament dictionary or None on failure.
         """
         """
-        # Search internal filaments first
-        filaments = await self.get_filaments()
+        # Get Bambu Lab vendor ID for filtering
+        bambu_vendor_id = await self.ensure_bambu_vendor()
         color_hex = tray.tray_color[:6]  # Strip alpha channel
         color_hex = tray.tray_color[:6]  # Strip alpha channel
 
 
+        # Search internal filaments - only match Bambu Lab vendor
+        filaments = await self.get_filaments()
         for filament in filaments:
         for filament in filaments:
+            # Only match filaments from Bambu Lab vendor
+            fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
+            if fil_vendor_id != bambu_vendor_id:
+                continue
+
             # Match by material and color (handle None values)
             # Match by material and color (handle None values)
             fil_material = filament.get("material") or ""
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
             fil_color = filament.get("color_hex") or ""
@@ -628,11 +637,10 @@ class SpoolmanClient:
                 # Found in external library - need to create internal copy
                 # Found in external library - need to create internal copy
                 return await self._create_filament_from_external(filament, tray)
                 return await self._create_filament_from_external(filament, tray)
 
 
-        # Not found - create new filament
-        vendor_id = await self.ensure_bambu_vendor()
+        # Not found - create new Bambu Lab filament
         return await self.create_filament(
         return await self.create_filament(
             name=tray.tray_sub_brands or tray.tray_type,
             name=tray.tray_sub_brands or tray.tray_type,
-            vendor_id=vendor_id,
+            vendor_id=bambu_vendor_id,
             material=tray.tray_type,
             material=tray.tray_type,
             color_hex=color_hex,
             color_hex=color_hex,
             weight=tray.tray_weight,
             weight=tray.tray_weight,

+ 8 - 26
backend/app/services/tasmota.py

@@ -1,8 +1,6 @@
 """Service for communicating with Tasmota devices via HTTP API."""
 """Service for communicating with Tasmota devices via HTTP API."""
 
 
-import asyncio
 import logging
 import logging
-from datetime import datetime
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
 import httpx
 import httpx
@@ -70,9 +68,7 @@ class TasmotaService:
             - reachable: bool
             - reachable: bool
             - device_name: str or None
             - device_name: str or None
         """
         """
-        result = await self._send_command(
-            plug.ip_address, "Power", plug.username, plug.password
-        )
+        result = await self._send_command(plug.ip_address, "Power", plug.username, plug.password)
 
 
         if result is None:
         if result is None:
             return {"state": None, "reachable": False, "device_name": None}
             return {"state": None, "reachable": False, "device_name": None}
@@ -89,9 +85,7 @@ class TasmotaService:
 
 
     async def turn_on(self, plug: "SmartPlug") -> bool:
     async def turn_on(self, plug: "SmartPlug") -> bool:
         """Turn on the plug. Returns True if successful."""
         """Turn on the plug. Returns True if successful."""
-        result = await self._send_command(
-            plug.ip_address, "Power On", plug.username, plug.password
-        )
+        result = await self._send_command(plug.ip_address, "Power On", plug.username, plug.password)
 
 
         if result is None:
         if result is None:
             return False
             return False
@@ -103,17 +97,13 @@ class TasmotaService:
         if success:
         if success:
             logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}")
             logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}")
         else:
         else:
-            logger.warning(
-                f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}"
-            )
+            logger.warning(f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}")
 
 
         return success
         return success
 
 
     async def turn_off(self, plug: "SmartPlug") -> bool:
     async def turn_off(self, plug: "SmartPlug") -> bool:
         """Turn off the plug. Returns True if successful."""
         """Turn off the plug. Returns True if successful."""
-        result = await self._send_command(
-            plug.ip_address, "Power Off", plug.username, plug.password
-        )
+        result = await self._send_command(plug.ip_address, "Power Off", plug.username, plug.password)
 
 
         if result is None:
         if result is None:
             return False
             return False
@@ -125,17 +115,13 @@ class TasmotaService:
         if success:
         if success:
             logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}")
             logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}")
         else:
         else:
-            logger.warning(
-                f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}"
-            )
+            logger.warning(f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}")
 
 
         return success
         return success
 
 
     async def toggle(self, plug: "SmartPlug") -> bool:
     async def toggle(self, plug: "SmartPlug") -> bool:
         """Toggle the plug state. Returns True if successful."""
         """Toggle the plug state. Returns True if successful."""
-        result = await self._send_command(
-            plug.ip_address, "Power Toggle", plug.username, plug.password
-        )
+        result = await self._send_command(plug.ip_address, "Power Toggle", plug.username, plug.password)
 
 
         if result is None:
         if result is None:
             return False
             return False
@@ -144,9 +130,7 @@ class TasmotaService:
         success = state in ["ON", "OFF"]
         success = state in ["ON", "OFF"]
 
 
         if success:
         if success:
-            logger.info(
-                f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}"
-            )
+            logger.info(f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}")
 
 
         return success
         return success
 
 
@@ -161,9 +145,7 @@ class TasmotaService:
             - total: Total energy in kWh
             - total: Total energy in kWh
             - factor: Power factor (0-1)
             - factor: Power factor (0-1)
         """
         """
-        result = await self._send_command(
-            plug.ip_address, "Status 8", plug.username, plug.password
-        )
+        result = await self._send_command(plug.ip_address, "Status 8", plug.username, plug.password)
 
 
         if result is None:
         if result is None:
             return None
             return None

+ 3 - 9
backend/app/services/telemetry.py

@@ -25,9 +25,7 @@ _last_heartbeat: datetime | None = None
 
 
 async def get_or_create_installation_id(db: AsyncSession) -> str:
 async def get_or_create_installation_id(db: AsyncSession) -> str:
     """Get existing installation ID or create a new one."""
     """Get existing installation ID or create a new one."""
-    result = await db.execute(
-        select(Settings).where(Settings.key == "installation_id")
-    )
+    result = await db.execute(select(Settings).where(Settings.key == "installation_id"))
     setting = result.scalar_one_or_none()
     setting = result.scalar_one_or_none()
 
 
     if setting:
     if setting:
@@ -47,9 +45,7 @@ async def get_or_create_installation_id(db: AsyncSession) -> str:
 
 
 async def is_telemetry_enabled(db: AsyncSession) -> bool:
 async def is_telemetry_enabled(db: AsyncSession) -> bool:
     """Check if telemetry is enabled (opt-out model)."""
     """Check if telemetry is enabled (opt-out model)."""
-    result = await db.execute(
-        select(Settings).where(Settings.key == "telemetry_enabled")
-    )
+    result = await db.execute(select(Settings).where(Settings.key == "telemetry_enabled"))
     setting = result.scalar_one_or_none()
     setting = result.scalar_one_or_none()
 
 
     # Default to enabled (opt-out model)
     # Default to enabled (opt-out model)
@@ -61,9 +57,7 @@ async def is_telemetry_enabled(db: AsyncSession) -> bool:
 
 
 async def get_telemetry_url(db: AsyncSession) -> str:
 async def get_telemetry_url(db: AsyncSession) -> str:
     """Get telemetry server URL from settings."""
     """Get telemetry server URL from settings."""
-    result = await db.execute(
-        select(Settings).where(Settings.key == "telemetry_url")
-    )
+    result = await db.execute(select(Settings).where(Settings.key == "telemetry_url"))
     setting = result.scalar_one_or_none()
     setting = result.scalar_one_or_none()
 
 
     return setting.value if setting else DEFAULT_TELEMETRY_URL
     return setting.value if setting else DEFAULT_TELEMETRY_URL

+ 10 - 2
backend/app/services/virtual_printer/__init__.py

@@ -1,5 +1,13 @@
 """Virtual printer services for slicer integration."""
 """Virtual printer services for slicer integration."""
 
 
-from backend.app.services.virtual_printer.manager import virtual_printer_manager
+from backend.app.services.virtual_printer.manager import (
+    DEFAULT_VIRTUAL_PRINTER_MODEL,
+    VIRTUAL_PRINTER_MODELS,
+    virtual_printer_manager,
+)
 
 
-__all__ = ["virtual_printer_manager"]
+__all__ = [
+    "virtual_printer_manager",
+    "VIRTUAL_PRINTER_MODELS",
+    "DEFAULT_VIRTUAL_PRINTER_MODEL",
+]

+ 100 - 22
backend/app/services/virtual_printer/certificate.py

@@ -3,6 +3,9 @@
 Generates certificates that mimic real Bambu printer certificate format:
 Generates certificates that mimic real Bambu printer certificate format:
 - CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
 - CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
 - Printer certificate has CN = serial number, signed by the CA
 - Printer certificate has CN = serial number, signed by the CA
+
+The CA certificate is persistent and only regenerated if missing or expired.
+This allows users to add the CA to their slicer's trust store once.
 """
 """
 
 
 import logging
 import logging
@@ -21,6 +24,9 @@ logger = logging.getLogger(__name__)
 # Default serial number for virtual printer (matches SSDP/MQTT config)
 # Default serial number for virtual printer (matches SSDP/MQTT config)
 DEFAULT_SERIAL = "00M09A391800001"
 DEFAULT_SERIAL = "00M09A391800001"
 
 
+# Minimum days remaining before CA is considered expired and needs regeneration
+CA_EXPIRY_THRESHOLD_DAYS = 30
+
 
 
 def _get_local_ip() -> str:
 def _get_local_ip() -> str:
     """Get the local IP address."""
     """Get the local IP address."""
@@ -67,8 +73,70 @@ class CertificateService:
             return self.cert_path, self.key_path
             return self.cert_path, self.key_path
         return self.generate_certificates()
         return self.generate_certificates()
 
 
+    def _load_existing_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate] | None:
+        """Try to load existing CA certificate and key.
+
+        Returns:
+            Tuple of (ca_private_key, ca_certificate) if valid CA exists, None otherwise
+        """
+        if not self.ca_cert_path.exists() or not self.ca_key_path.exists():
+            logger.debug("CA certificate or key not found")
+            return None
+
+        try:
+            # Load CA certificate
+            ca_cert_pem = self.ca_cert_path.read_bytes()
+            ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
+
+            # Check if CA is expired or about to expire
+            now = datetime.now(UTC)
+            days_remaining = (ca_cert.not_valid_after_utc - now).days
+            if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:
+                logger.warning(f"CA certificate expires in {days_remaining} days, will regenerate")
+                return None
+
+            # Load CA private key
+            ca_key_pem = self.ca_key_path.read_bytes()
+            ca_key = serialization.load_pem_private_key(ca_key_pem, password=None)
+
+            logger.info(f"Using existing CA certificate (expires in {days_remaining} days)")
+            return ca_key, ca_cert
+
+        except Exception as e:
+            logger.warning(f"Failed to load existing CA: {e}")
+            return None
+
+    def _get_or_create_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
+        """Get existing CA or create a new one.
+
+        Returns:
+            Tuple of (ca_private_key, ca_certificate)
+        """
+        # Try to load existing CA first
+        existing = self._load_existing_ca()
+        if existing:
+            return existing
+
+        # Generate new CA
+        ca_key, ca_cert = self._generate_ca_certificate()
+
+        # Save CA certificate and key
+        self.cert_dir.mkdir(parents=True, exist_ok=True)
+        self.ca_key_path.write_bytes(
+            ca_key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+        self.ca_key_path.chmod(0o600)
+        self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
+
+        logger.info("Saved new CA certificate")
+        return ca_key, ca_cert
+
     def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
     def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
-        """Generate a CA certificate for the virtual printer.
+        """Generate a new CA certificate for the virtual printer.
 
 
         We use a generic name instead of mimicking BBL CA, since the slicer
         We use a generic name instead of mimicking BBL CA, since the slicer
         may specifically reject certificates claiming to be from BBL but
         may specifically reject certificates claiming to be from BBL but
@@ -77,7 +145,7 @@ class CertificateService:
         Returns:
         Returns:
             Tuple of (ca_private_key, ca_certificate)
             Tuple of (ca_private_key, ca_certificate)
         """
         """
-        logger.info("Generating Virtual Printer CA certificate...")
+        logger.info("Generating new Virtual Printer CA certificate...")
 
 
         # Generate CA private key
         # Generate CA private key
         ca_key = rsa.generate_private_key(
         ca_key = rsa.generate_private_key(
@@ -126,11 +194,11 @@ class CertificateService:
         return ca_key, ca_cert
         return ca_key, ca_cert
 
 
     def generate_certificates(self) -> tuple[Path, Path]:
     def generate_certificates(self) -> tuple[Path, Path]:
-        """Generate CA and printer certificates.
+        """Generate printer certificate (reusing existing CA if available).
 
 
         Creates a certificate chain mimicking real Bambu printers:
         Creates a certificate chain mimicking real Bambu printers:
-        - BBL CA (self-signed root)
-        - Printer certificate (CN=serial, signed by BBL CA)
+        - CA certificate (reused if exists and valid, otherwise generated)
+        - Printer certificate (CN=serial, signed by CA)
 
 
         Returns:
         Returns:
             Tuple of (cert_path, key_path)
             Tuple of (cert_path, key_path)
@@ -140,19 +208,8 @@ class CertificateService:
         # Ensure directory exists
         # Ensure directory exists
         self.cert_dir.mkdir(parents=True, exist_ok=True)
         self.cert_dir.mkdir(parents=True, exist_ok=True)
 
 
-        # Generate or load CA
-        ca_key, ca_cert = self._generate_ca_certificate()
-
-        # Save CA certificate and key
-        self.ca_key_path.write_bytes(
-            ca_key.private_bytes(
-                encoding=serialization.Encoding.PEM,
-                format=serialization.PrivateFormat.TraditionalOpenSSL,
-                encryption_algorithm=serialization.NoEncryption(),
-            )
-        )
-        self.ca_key_path.chmod(0o600)
-        self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
+        # Get or create CA (reuses existing if valid)
+        ca_key, ca_cert = self._get_or_create_ca()
 
 
         # Generate printer private key
         # Generate printer private key
         printer_key = rsa.generate_private_key(
         printer_key = rsa.generate_private_key(
@@ -246,9 +303,30 @@ class CertificateService:
         logger.info(f"  Printer: CN={self.serial}")
         logger.info(f"  Printer: CN={self.serial}")
         return self.cert_path, self.key_path
         return self.cert_path, self.key_path
 
 
-    def delete_certificates(self) -> None:
-        """Delete existing certificates."""
-        for path in [self.cert_path, self.key_path, self.ca_cert_path, self.ca_key_path]:
+    def delete_printer_certificate(self) -> None:
+        """Delete only the printer certificate (preserves CA)."""
+        for path in [self.cert_path, self.key_path]:
+            if path.exists():
+                path.unlink()
+        logger.info("Deleted printer certificate (CA preserved)")
+
+    def delete_certificates(self, include_ca: bool = False) -> None:
+        """Delete existing certificates.
+
+        Args:
+            include_ca: If True, also delete CA certificate and key.
+                       If False (default), only delete printer certificate.
+        """
+        # Always delete printer certificate
+        for path in [self.cert_path, self.key_path]:
             if path.exists():
             if path.exists():
                 path.unlink()
                 path.unlink()
-        logger.info("Deleted virtual printer certificates")
+
+        # Only delete CA if explicitly requested
+        if include_ca:
+            for path in [self.ca_cert_path, self.ca_key_path]:
+                if path.exists():
+                    path.unlink()
+            logger.info("Deleted all certificates including CA")
+        else:
+            logger.info("Deleted printer certificate (CA preserved)")

+ 75 - 1
backend/app/services/virtual_printer/ftp_server.py

@@ -196,6 +196,40 @@ class FTPSession:
         else:
         else:
             await self.send(504, "Type not supported")
             await self.send(504, "Type not supported")
 
 
+    async def cmd_EPSV(self, arg: str) -> None:
+        """Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
+        if not self.authenticated:
+            await self.send(530, "Not logged in")
+            return
+
+        # Close any existing data connection/server
+        await self._close_data_connection()
+
+        # Reset connection state
+        self._data_connected.clear()
+        self._data_reader = None
+        self._data_writer = None
+
+        # Find a free port for passive data connection
+        self.data_port = random.randint(50000, 60000)
+
+        try:
+            # Create data server with TLS - use same context for session reuse
+            self.data_server = await asyncio.start_server(
+                self._handle_data_connection,
+                "0.0.0.0",
+                self.data_port,
+                ssl=self.ssl_context,
+            )
+
+            # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
+            await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
+            logger.info(f"FTP EPSV listening on port {self.data_port}")
+
+        except Exception as e:
+            logger.error(f"Failed to create EPSV data connection: {e}")
+            await self.send(425, "Cannot open data connection")
+
     async def cmd_PASV(self, arg: str) -> None:
     async def cmd_PASV(self, arg: str) -> None:
         """Handle PASV command - set up passive data connection."""
         """Handle PASV command - set up passive data connection."""
         if not self.authenticated:
         if not self.authenticated:
@@ -244,6 +278,16 @@ class FTPSession:
 
 
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
         """Handle incoming data connection (used by PASV)."""
         """Handle incoming data connection (used by PASV)."""
+        # Log TLS details for debugging
+        ssl_obj = writer.get_extra_info("ssl_object")
+        if ssl_obj:
+            logger.info(
+                f"FTP data TLS from {self.remote_ip}: cipher={ssl_obj.cipher()}, "
+                f"version={ssl_obj.version()}, session_reused={ssl_obj.session_reused}"
+            )
+        else:
+            logger.warning(f"FTP data connection from {self.remote_ip} has no SSL!")
+
         logger.info(f"FTP data connection established from {self.remote_ip}")
         logger.info(f"FTP data connection established from {self.remote_ip}")
         self._data_reader = reader
         self._data_reader = reader
         self._data_writer = writer
         self._data_writer = writer
@@ -252,6 +296,8 @@ class FTPSession:
 
 
     async def _close_data_connection(self) -> None:
     async def _close_data_connection(self) -> None:
         """Close the data connection and server."""
         """Close the data connection and server."""
+        had_connection = self._data_writer is not None or self.data_server is not None
+
         if self._data_writer:
         if self._data_writer:
             try:
             try:
                 self._data_writer.close()
                 self._data_writer.close()
@@ -269,6 +315,10 @@ class FTPSession:
                 pass
                 pass
             self.data_server = None
             self.data_server = None
 
 
+        # Only delay if we actually closed something
+        if had_connection:
+            await asyncio.sleep(0.1)
+
     async def cmd_STOR(self, arg: str) -> None:
     async def cmd_STOR(self, arg: str) -> None:
         """Handle STOR command - receive file upload."""
         """Handle STOR command - receive file upload."""
         if not self.authenticated:
         if not self.authenticated:
@@ -436,6 +486,7 @@ class VirtualPrinterFTPServer:
         self._server: asyncio.Server | None = None
         self._server: asyncio.Server | None = None
         self._running = False
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
         self._ssl_context: ssl.SSLContext | None = None
+        self._active_sessions: list[asyncio.Task] = []
 
 
     async def start(self) -> None:
     async def start(self) -> None:
         """Start the implicit FTPS server."""
         """Start the implicit FTPS server."""
@@ -454,6 +505,11 @@ class VirtualPrinterFTPServer:
         self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
         self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))
         self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
         self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
 
 
+        # Use standard TLS settings for compatibility
+        self._ssl_context.set_ciphers("HIGH:!aNULL:!MD5:!RC4")
+
+        logger.info("FTP SSL context created with standard settings")
+
         try:
         try:
             # Create server with SSL - TLS handshake happens before any FTP data
             # Create server with SSL - TLS handshake happens before any FTP data
             self._server = await asyncio.start_server(
             self._server = await asyncio.start_server(
@@ -495,13 +551,31 @@ class VirtualPrinterFTPServer:
             on_file_received=self.on_file_received,
             on_file_received=self.on_file_received,
         )
         )
 
 
-        await session.handle()
+        # Track the session task so we can cancel it on stop
+        task = asyncio.current_task()
+        if task:
+            self._active_sessions.append(task)
+        try:
+            await session.handle()
+        finally:
+            if task and task in self._active_sessions:
+                self._active_sessions.remove(task)
 
 
     async def stop(self) -> None:
     async def stop(self) -> None:
         """Stop the FTPS server."""
         """Stop the FTPS server."""
         logger.info("Stopping FTP server")
         logger.info("Stopping FTP server")
         self._running = False
         self._running = False
 
 
+        # Cancel all active sessions first
+        for task in self._active_sessions[:]:  # Copy list to avoid modification during iteration
+            task.cancel()
+
+        # Wait briefly for sessions to clean up
+        if self._active_sessions:
+            await asyncio.sleep(0.1)
+
+        self._active_sessions.clear()
+
         if self._server:
         if self._server:
             try:
             try:
                 self._server.close()
                 self._server.close()

+ 134 - 27
backend/app/services/virtual_printer/manager.py

@@ -15,13 +15,66 @@ from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPS
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+# Mapping of SSDP model codes to display names
+# These are the codes that slicers expect during discovery
+# Sources:
+#   - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
+#   - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery
+VIRTUAL_PRINTER_MODELS = {
+    # X1 Series
+    "3DPrinter-X1-Carbon": "X1C",  # X1 Carbon
+    "3DPrinter-X1": "X1",  # X1
+    "C13": "X1E",  # X1E
+    # P Series
+    "C11": "P1P",  # P1P
+    "C12": "P1S",  # P1S
+    "N7": "P2S",  # P2S
+    # A1 Series
+    "N2S": "A1",  # A1
+    "N1": "A1 Mini",  # A1 Mini
+    # H2 Series
+    "O1D": "H2D",  # H2D
+    "O1C": "H2C",  # H2C
+    "O1S": "H2S",  # H2S
+}
+
+# Serial number prefixes for each model (based on Bambu Lab serial number format)
+# Format: MMM??RYMDDUUUUU (15 chars total)
+#   MMM = Model prefix (3 chars)
+#   ?? = Unknown/revision code (2 chars)
+#   R = Revision letter (1 char)
+#   Y = Year digit (1 char)
+#   M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)
+#   DD = Day (2 chars)
+#   UUUUU = Unit number (5 chars)
+MODEL_SERIAL_PREFIXES = {
+    # X1 Series
+    "3DPrinter-X1-Carbon": "00M00A",  # X1C
+    "3DPrinter-X1": "00M00A",  # X1
+    "C13": "03W00A",  # X1E
+    # P Series
+    "C11": "01S00A",  # P1P
+    "C12": "01P00A",  # P1S
+    "N7": "22E00A",  # P2S
+    # A1 Series
+    "N2S": "03900A",  # A1
+    "N1": "03000A",  # A1 Mini
+    # H2 Series
+    "O1D": "09400A",  # H2D
+    "O1C": "09400A",  # H2C
+    "O1S": "09400A",  # H2S
+}
+
+# Default model
+DEFAULT_VIRTUAL_PRINTER_MODEL = "3DPrinter-X1-Carbon"  # X1C
+
+
 class VirtualPrinterManager:
 class VirtualPrinterManager:
     """Manages the virtual printer lifecycle and coordinates all services."""
     """Manages the virtual printer lifecycle and coordinates all services."""
 
 
     # Fixed configuration
     # Fixed configuration
     PRINTER_NAME = "Bambuddy"
     PRINTER_NAME = "Bambuddy"
-    PRINTER_SERIAL = "00M09A391800001"  # X1C serial format
-    PRINTER_MODEL = "3DPrinter-X1-Carbon"  # Full model name for slicer compatibility
+    SERIAL_SUFFIX = "391800001"  # Fixed suffix for virtual printer
 
 
     def __init__(self):
     def __init__(self):
         """Initialize the virtual printer manager."""
         """Initialize the virtual printer manager."""
@@ -29,6 +82,7 @@ class VirtualPrinterManager:
         self._enabled = False
         self._enabled = False
         self._access_code = ""
         self._access_code = ""
         self._mode = "immediate"
         self._mode = "immediate"
+        self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
 
 
         # Service instances
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ssdp: VirtualPrinterSSDPServer | None = None
@@ -43,12 +97,29 @@ class VirtualPrinterManager:
         self._upload_dir = self._base_dir / "uploads"
         self._upload_dir = self._base_dir / "uploads"
         self._cert_dir = self._base_dir / "certs"
         self._cert_dir = self._base_dir / "certs"
 
 
-        # Certificate service - pass serial to match CN in certificate
-        self._cert_service = CertificateService(self._cert_dir, serial=self.PRINTER_SERIAL)
+        # Certificate service
+        self._cert_service = CertificateService(self._cert_dir)
 
 
         # Track pending uploads for MQTT correlation
         # Track pending uploads for MQTT correlation
         self._pending_files: dict[str, Path] = {}
         self._pending_files: dict[str, Path] = {}
 
 
+    def _get_serial_for_model(self, model: str) -> str:
+        """Get appropriate serial number for the given model.
+
+        Args:
+            model: SSDP model code (e.g., 'BL-P001', 'C11')
+
+        Returns:
+            Serial number with correct prefix for the model
+        """
+        prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
+        return f"{prefix}{self.SERIAL_SUFFIX}"
+
+    @property
+    def printer_serial(self) -> str:
+        """Get the current printer serial number based on model."""
+        return self._get_serial_for_model(self._model)
+
     def set_session_factory(self, session_factory: Callable) -> None:
     def set_session_factory(self, session_factory: Callable) -> None:
         """Set the database session factory.
         """Set the database session factory.
 
 
@@ -72,6 +143,7 @@ class VirtualPrinterManager:
         enabled: bool,
         enabled: bool,
         access_code: str = "",
         access_code: str = "",
         mode: str = "immediate",
         mode: str = "immediate",
+        model: str = "",
     ) -> None:
     ) -> None:
         """Configure and start/stop virtual printer.
         """Configure and start/stop virtual printer.
 
 
@@ -79,17 +151,43 @@ class VirtualPrinterManager:
             enabled: Whether to enable the virtual printer
             enabled: Whether to enable the virtual printer
             access_code: Authentication password for slicer connections
             access_code: Authentication password for slicer connections
             mode: Archive mode - 'immediate' or 'queue'
             mode: Archive mode - 'immediate' or 'queue'
+            model: SSDP model code (e.g., 'BL-P001' for X1C)
         """
         """
         if enabled and not access_code:
         if enabled and not access_code:
             raise ValueError("Access code is required when enabling virtual printer")
             raise ValueError("Access code is required when enabling virtual printer")
 
 
+        # Validate model if provided
+        new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
+        model_changed = new_model != self._model
+        old_model = self._model
+
+        logger.debug(
+            f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
+            f"model={model}, new_model={new_model}, old_model={old_model}, model_changed={model_changed}"
+        )
+
         self._access_code = access_code
         self._access_code = access_code
         self._mode = mode
         self._mode = mode
+        self._model = new_model
 
 
         if enabled and not self._enabled:
         if enabled and not self._enabled:
+            logger.info("Starting virtual printer (was disabled)")
             await self._start()
             await self._start()
         elif not enabled and self._enabled:
         elif not enabled and self._enabled:
+            logger.info("Stopping virtual printer (was enabled)")
             await self._stop()
             await self._stop()
+        elif enabled and self._enabled and model_changed:
+            # Model changed while running - restart services
+            logger.info(f"Model changed from {old_model} to {new_model}, restarting...")
+            await self._stop()
+            # Give time for ports to be released
+            await asyncio.sleep(0.5)
+            await self._start()
+            logger.info("Virtual printer restarted with new model")
+        else:
+            logger.debug(
+                f"No state change needed (enabled={enabled}, self._enabled={self._enabled}, model_changed={model_changed})"
+            )
 
 
         self._enabled = enabled
         self._enabled = enabled
 
 
@@ -97,8 +195,14 @@ class VirtualPrinterManager:
         """Start all virtual printer services."""
         """Start all virtual printer services."""
         logger.info("Starting virtual printer services...")
         logger.info("Starting virtual printer services...")
 
 
-        # Ensure certificates exist
-        cert_path, key_path = self._cert_service.ensure_certificates()
+        # Update certificate service with current serial (based on model)
+        current_serial = self.printer_serial
+        self._cert_service.serial = current_serial
+
+        # Regenerate printer cert if serial changed (CA is preserved)
+        self._cert_service.delete_printer_certificate()
+        cert_path, key_path = self._cert_service.generate_certificates()
+        logger.info(f"Generated certificate for serial: {current_serial}")
 
 
         # Create directories
         # Create directories
         self._upload_dir.mkdir(parents=True, exist_ok=True)
         self._upload_dir.mkdir(parents=True, exist_ok=True)
@@ -107,8 +211,8 @@ class VirtualPrinterManager:
         # Initialize services
         # Initialize services
         self._ssdp = VirtualPrinterSSDPServer(
         self._ssdp = VirtualPrinterSSDPServer(
             name=self.PRINTER_NAME,
             name=self.PRINTER_NAME,
-            serial=self.PRINTER_SERIAL,
-            model=self.PRINTER_MODEL,
+            serial=self.printer_serial,
+            model=self._model,
         )
         )
 
 
         self._ftp = VirtualPrinterFTPServer(
         self._ftp = VirtualPrinterFTPServer(
@@ -120,7 +224,7 @@ class VirtualPrinterManager:
         )
         )
 
 
         self._mqtt = SimpleMQTTServer(
         self._mqtt = SimpleMQTTServer(
-            serial=self.PRINTER_SERIAL,
+            serial=self.printer_serial,
             access_code=self._access_code,
             access_code=self._access_code,
             cert_path=cert_path,
             cert_path=cert_path,
             key_path=key_path,
             key_path=key_path,
@@ -141,27 +245,13 @@ class VirtualPrinterManager:
             asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
             asyncio.create_task(run_with_logging(self._mqtt.start(), "MQTT"), name="virtual_printer_mqtt"),
         ]
         ]
 
 
-        logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.PRINTER_SERIAL})")
+        logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.printer_serial})")
 
 
     async def _stop(self) -> None:
     async def _stop(self) -> None:
         """Stop all virtual printer services."""
         """Stop all virtual printer services."""
         logger.info("Stopping virtual printer services...")
         logger.info("Stopping virtual printer services...")
 
 
-        # Cancel all tasks
-        for task in self._tasks:
-            task.cancel()
-            try:
-                await task
-            except asyncio.CancelledError:
-                pass
-
-        self._tasks = []
-
-        # Stop services
-        if self._ssdp:
-            await self._ssdp.stop()
-            self._ssdp = None
-
+        # Stop services first - this closes servers and cancels active sessions
         if self._ftp:
         if self._ftp:
             await self._ftp.stop()
             await self._ftp.stop()
             self._ftp = None
             self._ftp = None
@@ -170,6 +260,22 @@ class VirtualPrinterManager:
             await self._mqtt.stop()
             await self._mqtt.stop()
             self._mqtt = None
             self._mqtt = None
 
 
+        if self._ssdp:
+            await self._ssdp.stop()
+            self._ssdp = None
+
+        # Cancel remaining tasks with short timeout
+        for task in self._tasks:
+            task.cancel()
+
+        if self._tasks:
+            try:
+                await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)
+            except TimeoutError:
+                logger.debug("Some tasks didn't stop in time")
+
+        self._tasks = []
+
         logger.info("Virtual printer stopped")
         logger.info("Virtual printer stopped")
 
 
     async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
     async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
@@ -313,8 +419,9 @@ class VirtualPrinterManager:
             "running": self.is_running,
             "running": self.is_running,
             "mode": self._mode,
             "mode": self._mode,
             "name": self.PRINTER_NAME,
             "name": self.PRINTER_NAME,
-            "serial": self.PRINTER_SERIAL,
-            "model": self.PRINTER_MODEL,
+            "serial": self.printer_serial,
+            "model": self._model,
+            "model_name": VIRTUAL_PRINTER_MODELS.get(self._model, self._model),
             "pending_files": len(self._pending_files),
             "pending_files": len(self._pending_files),
         }
         }
 
 

+ 2 - 2
backend/app/services/virtual_printer/mqtt_server.py

@@ -289,8 +289,8 @@ class SimpleMQTTServer:
                 pass
                 pass
             self._status_push_task = None
             self._status_push_task = None
 
 
-        # Close all client connections
-        for _client_id, writer in self._clients.items():
+        # Close all client connections (iterate over copy to avoid modification during iteration)
+        for _client_id, writer in list(self._clients.items()):
             try:
             try:
                 writer.close()
                 writer.close()
                 await writer.wait_closed()
                 await writer.wait_closed()

+ 68 - 62
backend/tests/conftest.py

@@ -5,25 +5,26 @@ import json
 import logging
 import logging
 import os
 import os
 import sys
 import sys
-import pytest
-from typing import AsyncGenerator
+from collections.abc import AsyncGenerator
 from datetime import datetime
 from datetime import datetime
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
+import pytest
+
 # IMPORTANT: Set environment variables BEFORE any app imports
 # IMPORTANT: Set environment variables BEFORE any app imports
 # This must happen before settings/config are loaded
 # This must happen before settings/config are loaded
 os.environ["LOG_TO_FILE"] = "false"
 os.environ["LOG_TO_FILE"] = "false"
 os.environ["DEBUG"] = "false"
 os.environ["DEBUG"] = "false"
 
 
-from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
-from httpx import AsyncClient, ASGITransport
+from httpx import ASGITransport, AsyncClient  # noqa: E402
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine  # noqa: E402
 
 
 # Ensure settings use our env vars - import and override before database import
 # Ensure settings use our env vars - import and override before database import
-from backend.app.core.config import settings
-settings.log_to_file = False
+from backend.app.core.config import settings  # noqa: E402
 
 
-from backend.app.core.database import Base
+settings.log_to_file = False
 
 
+from backend.app.core.database import Base  # noqa: E402
 
 
 # Use in-memory SQLite for tests
 # Use in-memory SQLite for tests
 TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
 TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@@ -44,10 +45,20 @@ async def test_engine():
 
 
     # Import all models to register them
     # Import all models to register them
     from backend.app.models import (
     from backend.app.models import (
-        printer, archive, filament, settings, smart_plug,
-        print_queue, notification, maintenance, kprofile_note,
-        notification_template, external_link, project, api_key,
-        ams_history
+        ams_history,
+        api_key,
+        archive,
+        external_link,
+        filament,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_queue,
+        printer,
+        project,
+        settings,
+        smart_plug,
     )
     )
 
 
     async with engine.begin() as conn:
     async with engine.begin() as conn:
@@ -63,9 +74,7 @@ async def test_engine():
 @pytest.fixture
 @pytest.fixture
 async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
 async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
     """Create a test database session."""
     """Create a test database session."""
-    async_session_maker = async_sessionmaker(
-        test_engine, class_=AsyncSession, expire_on_commit=False
-    )
+    async_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
     async with async_session_maker() as session:
     async with async_session_maker() as session:
         yield session
         yield session
 
 
@@ -73,13 +82,11 @@ async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
 @pytest.fixture
 @pytest.fixture
 async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, None]:
 async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, None]:
     """Create an async test client."""
     """Create an async test client."""
+    from backend.app.core.database import async_session, get_db
     from backend.app.main import app
     from backend.app.main import app
-    from backend.app.core.database import get_db, async_session
 
 
     # Create a new session maker for the test engine
     # Create a new session maker for the test engine
-    test_async_session = async_sessionmaker(
-        test_engine, class_=AsyncSession, expire_on_commit=False
-    )
+    test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
 
 
     async def override_get_db():
     async def override_get_db():
         async with test_async_session() as session:
         async with test_async_session() as session:
@@ -88,11 +95,8 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
     app.dependency_overrides[get_db] = override_get_db
     app.dependency_overrides[get_db] = override_get_db
 
 
     # Also patch the module-level async_session used by services
     # Also patch the module-level async_session used by services
-    with patch('backend.app.core.database.async_session', test_async_session):
-        async with AsyncClient(
-            transport=ASGITransport(app=app),
-            base_url="http://test"
-        ) as client:
+    with patch("backend.app.core.database.async_session", test_async_session):
+        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
             yield client
 
 
     app.dependency_overrides.clear()
     app.dependency_overrides.clear()
@@ -102,33 +106,30 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
 # Mock External Services
 # Mock External Services
 # ============================================================================
 # ============================================================================
 
 
+
 @pytest.fixture
 @pytest.fixture
 def mock_tasmota_service():
 def mock_tasmota_service():
     """Mock the Tasmota service for smart plug tests."""
     """Mock the Tasmota service for smart plug tests."""
     # Patch both the module where it's defined and where it's imported
     # Patch both the module where it's defined and where it's imported
-    with patch('backend.app.services.tasmota.tasmota_service') as mock, \
-         patch('backend.app.api.routes.smart_plugs.tasmota_service') as mock2:
+    with (
+        patch("backend.app.services.tasmota.tasmota_service") as mock,
+        patch("backend.app.api.routes.smart_plugs.tasmota_service") as mock2,
+    ):
         mock.turn_on = AsyncMock(return_value=True)
         mock.turn_on = AsyncMock(return_value=True)
         mock.turn_off = AsyncMock(return_value=True)
         mock.turn_off = AsyncMock(return_value=True)
         mock.toggle = AsyncMock(return_value=True)
         mock.toggle = AsyncMock(return_value=True)
-        mock.get_status = AsyncMock(return_value={
-            "state": "ON",
-            "reachable": True,
-            "device_name": "Test Plug"
-        })
-        mock.get_energy = AsyncMock(return_value={
-            "power": 150.5,
-            "voltage": 120.0,
-            "current": 1.25,
-            "today": 2.5,
-            "total": 100.0,
-            "factor": 0.95,
-        })
-        mock.test_connection = AsyncMock(return_value={
-            "success": True,
-            "state": "ON",
-            "device_name": "Test Plug"
-        })
+        mock.get_status = AsyncMock(return_value={"state": "ON", "reachable": True, "device_name": "Test Plug"})
+        mock.get_energy = AsyncMock(
+            return_value={
+                "power": 150.5,
+                "voltage": 120.0,
+                "current": 1.25,
+                "today": 2.5,
+                "total": 100.0,
+                "factor": 0.95,
+            }
+        )
+        mock.test_connection = AsyncMock(return_value={"success": True, "state": "ON", "device_name": "Test Plug"})
         # Copy mocks to second patch target
         # Copy mocks to second patch target
         mock2.turn_on = mock.turn_on
         mock2.turn_on = mock.turn_on
         mock2.turn_off = mock.turn_off
         mock2.turn_off = mock.turn_off
@@ -142,14 +143,9 @@ def mock_tasmota_service():
 @pytest.fixture
 @pytest.fixture
 def mock_mqtt_client():
 def mock_mqtt_client():
     """Mock the MQTT client for printer communication tests."""
     """Mock the MQTT client for printer communication tests."""
-    with patch('backend.app.services.bambu_mqtt.BambuMQTTClient') as mock:
+    with patch("backend.app.services.bambu_mqtt.BambuMQTTClient") as mock:
         instance = MagicMock()
         instance = MagicMock()
-        instance.state = MagicMock(
-            connected=True,
-            state="IDLE",
-            progress=0,
-            temperatures={"nozzle": 25, "bed": 25}
-        )
+        instance.state = MagicMock(connected=True, state="IDLE", progress=0, temperatures={"nozzle": 25, "bed": 25})
         instance.connect = MagicMock()
         instance.connect = MagicMock()
         instance.disconnect = MagicMock()
         instance.disconnect = MagicMock()
         mock.return_value = instance
         mock.return_value = instance
@@ -159,8 +155,10 @@ def mock_mqtt_client():
 @pytest.fixture
 @pytest.fixture
 def mock_ftp_client():
 def mock_ftp_client():
     """Mock the FTP client for file transfer tests."""
     """Mock the FTP client for file transfer tests."""
-    with patch('backend.app.services.bambu_ftp.download_file_async') as download_mock, \
-         patch('backend.app.services.bambu_ftp.list_files_async') as list_mock:
+    with (
+        patch("backend.app.services.bambu_ftp.download_file_async") as download_mock,
+        patch("backend.app.services.bambu_ftp.list_files_async") as list_mock,
+    ):
         download_mock.return_value = True
         download_mock.return_value = True
         list_mock.return_value = []
         list_mock.return_value = []
         yield {"download": download_mock, "list": list_mock}
         yield {"download": download_mock, "list": list_mock}
@@ -169,7 +167,7 @@ def mock_ftp_client():
 @pytest.fixture
 @pytest.fixture
 def mock_httpx_client():
 def mock_httpx_client():
     """Mock httpx for webhook/notification HTTP calls."""
     """Mock httpx for webhook/notification HTTP calls."""
-    with patch('httpx.AsyncClient') as mock_class:
+    with patch("httpx.AsyncClient") as mock_class:
         mock_instance = AsyncMock()
         mock_instance = AsyncMock()
         mock_response = MagicMock()
         mock_response = MagicMock()
         mock_response.status_code = 200
         mock_response.status_code = 200
@@ -188,14 +186,16 @@ def mock_httpx_client():
 @pytest.fixture
 @pytest.fixture
 def mock_printer_manager():
 def mock_printer_manager():
     """Mock the printer manager for status checks."""
     """Mock the printer manager for status checks."""
-    with patch('backend.app.services.printer_manager.printer_manager') as mock:
-        mock.get_status = MagicMock(return_value=MagicMock(
-            connected=True,
-            state="IDLE",
-            progress=0,
-            temperatures={"nozzle": 25, "bed": 25, "chamber": 25},
-            raw_data={}
-        ))
+    with patch("backend.app.services.printer_manager.printer_manager") as mock:
+        mock.get_status = MagicMock(
+            return_value=MagicMock(
+                connected=True,
+                state="IDLE",
+                progress=0,
+                temperatures={"nozzle": 25, "bed": 25, "chamber": 25},
+                raw_data={},
+            )
+        )
         mock.mark_printer_offline = MagicMock()
         mock.mark_printer_offline = MagicMock()
         yield mock
         yield mock
 
 
@@ -204,9 +204,11 @@ def mock_printer_manager():
 # Factory Fixtures for Test Data
 # Factory Fixtures for Test Data
 # ============================================================================
 # ============================================================================
 
 
+
 @pytest.fixture
 @pytest.fixture
 def smart_plug_factory(db_session):
 def smart_plug_factory(db_session):
     """Factory to create test smart plugs."""
     """Factory to create test smart plugs."""
+
     async def _create_plug(**kwargs):
     async def _create_plug(**kwargs):
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug import SmartPlug
 
 
@@ -267,6 +269,7 @@ def printer_factory(db_session):
 @pytest.fixture
 @pytest.fixture
 def notification_provider_factory(db_session):
 def notification_provider_factory(db_session):
     """Factory to create test notification providers."""
     """Factory to create test notification providers."""
+
     async def _create_provider(**kwargs):
     async def _create_provider(**kwargs):
         from backend.app.models.notification import NotificationProvider
         from backend.app.models.notification import NotificationProvider
 
 
@@ -307,6 +310,7 @@ def notification_provider_factory(db_session):
 @pytest.fixture
 @pytest.fixture
 def archive_factory(db_session):
 def archive_factory(db_session):
     """Factory to create test archives."""
     """Factory to create test archives."""
+
     async def _create_archive(printer_id: int, **kwargs):
     async def _create_archive(printer_id: int, **kwargs):
         from backend.app.models.archive import PrintArchive
         from backend.app.models.archive import PrintArchive
 
 
@@ -336,6 +340,7 @@ def archive_factory(db_session):
 # Sample Data Fixtures
 # Sample Data Fixtures
 # ============================================================================
 # ============================================================================
 
 
+
 @pytest.fixture
 @pytest.fixture
 def sample_mqtt_print_start():
 def sample_mqtt_print_start():
     """Sample MQTT message for print start."""
     """Sample MQTT message for print start."""
@@ -385,6 +390,7 @@ def sample_printer_status():
 # Log Capture Fixtures for Error Detection
 # Log Capture Fixtures for Error Detection
 # ============================================================================
 # ============================================================================
 
 
+
 class LogCapture(logging.Handler):
 class LogCapture(logging.Handler):
     """Handler that captures log records for testing."""
     """Handler that captures log records for testing."""
 
 
@@ -415,7 +421,7 @@ class LogCapture(logging.Handler):
         errors = self.get_errors()
         errors = self.get_errors()
         if not errors:
         if not errors:
             return "No errors"
             return "No errors"
-        formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
+        formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
         return "\n".join(formatter.format(r) for r in errors)
         return "\n".join(formatter.format(r) for r in errors)
 
 
 
 

+ 13 - 37
backend/tests/integration/test_ams_history_api.py

@@ -1,7 +1,8 @@
 """Integration tests for AMS History API endpoints."""
 """Integration tests for AMS History API endpoints."""
 
 
-import pytest
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
+
+import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
 
 
@@ -11,6 +12,7 @@ class TestAMSHistoryAPI:
     @pytest.fixture
     @pytest.fixture
     async def ams_history_factory(self, db_session, printer_factory):
     async def ams_history_factory(self, db_session, printer_factory):
         """Factory to create test AMS history records."""
         """Factory to create test AMS history records."""
+
         async def _create_history(printer_id=None, ams_id=0, **kwargs):
         async def _create_history(printer_id=None, ams_id=0, **kwargs):
             from backend.app.models.ams_history import AMSSensorHistory
             from backend.app.models.ams_history import AMSSensorHistory
 
 
@@ -38,9 +40,7 @@ class TestAMSHistoryAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_ams_history_empty(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_get_ams_history_empty(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify empty history returns empty data array."""
         """Verify empty history returns empty data array."""
         printer = await printer_factory()
         printer = await printer_factory()
         response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
         response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
@@ -52,9 +52,7 @@ class TestAMSHistoryAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_ams_history_with_data(
-        self, async_client: AsyncClient, ams_history_factory, db_session
-    ):
+    async def test_get_ams_history_with_data(self, async_client: AsyncClient, ams_history_factory, db_session):
         """Verify history returns recorded data."""
         """Verify history returns recorded data."""
         # Create history records
         # Create history records
         history = await ams_history_factory()
         history = await ams_history_factory()
@@ -95,15 +93,9 @@ class TestAMSHistoryAPI:
         """Verify hours parameter filters data."""
         """Verify hours parameter filters data."""
         printer = await printer_factory()
         printer = await printer_factory()
         # Create a recent record
         # Create a recent record
-        await ams_history_factory(
-            printer_id=printer.id,
-            recorded_at=datetime.now()
-        )
+        await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now())
         # Create an old record (outside default 24h)
         # Create an old record (outside default 24h)
-        await ams_history_factory(
-            printer_id=printer.id,
-            recorded_at=datetime.now() - timedelta(hours=48)
-        )
+        await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(hours=48))
 
 
         # Request only last 24 hours (default)
         # Request only last 24 hours (default)
         response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
         response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
@@ -114,15 +106,10 @@ class TestAMSHistoryAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_ams_history_custom_hours(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_get_ams_history_custom_hours(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify custom hours parameter works."""
         """Verify custom hours parameter works."""
         printer = await printer_factory()
         printer = await printer_factory()
-        response = await async_client.get(
-            f"/api/v1/ams-history/{printer.id}/0",
-            params={"hours": 48}
-        )
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0", params={"hours": 48})
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
         assert data["printer_id"] == printer.id
         assert data["printer_id"] == printer.id
@@ -159,31 +146,20 @@ class TestAMSHistoryAPI:
         """Verify old history can be deleted."""
         """Verify old history can be deleted."""
         printer = await printer_factory()
         printer = await printer_factory()
         # Create an old record
         # Create an old record
-        await ams_history_factory(
-            printer_id=printer.id,
-            recorded_at=datetime.now() - timedelta(days=60)
-        )
+        await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(days=60))
 
 
         # Delete records older than 30 days
         # Delete records older than 30 days
-        response = await async_client.delete(
-            f"/api/v1/ams-history/{printer.id}",
-            params={"days": 30}
-        )
+        response = await async_client.delete(f"/api/v1/ams-history/{printer.id}", params={"days": 30})
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
         assert data["deleted"] >= 1
         assert data["deleted"] >= 1
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_delete_old_history_no_records(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_delete_old_history_no_records(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify delete with no old records returns 0."""
         """Verify delete with no old records returns 0."""
         printer = await printer_factory()
         printer = await printer_factory()
-        response = await async_client.delete(
-            f"/api/v1/ams-history/{printer.id}",
-            params={"days": 30}
-        )
+        response = await async_client.delete(f"/api/v1/ams-history/{printer.id}", params={"days": 30})
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
         assert data["deleted"] == 0
         assert data["deleted"] == 0

+ 10 - 34
backend/tests/integration/test_archives_api.py

@@ -72,9 +72,7 @@ class TestArchivesAPI:
         await archive_factory(printer1.id, print_name="Printer 1 Archive")
         await archive_factory(printer1.id, print_name="Printer 1 Archive")
         await archive_factory(printer2.id, print_name="Printer 2 Archive")
         await archive_factory(printer2.id, print_name="Printer 2 Archive")
 
 
-        response = await async_client.get(
-            f"/api/v1/archives/?printer_id={printer1.id}"
-        )
+        response = await async_client.get(f"/api/v1/archives/?printer_id={printer1.id}")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
@@ -86,9 +84,7 @@ class TestArchivesAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_archive(
-        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
-    ):
+    async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
         """Verify single archive can be retrieved."""
         """Verify single archive can be retrieved."""
         printer = await printer_factory()
         printer = await printer_factory()
         archive = await archive_factory(printer.id, print_name="Get Test Archive")
         archive = await archive_factory(printer.id, print_name="Get Test Archive")
@@ -114,34 +110,24 @@ class TestArchivesAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_archive_name(
-        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
-    ):
+    async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
         """Verify archive name can be updated."""
         """Verify archive name can be updated."""
         printer = await printer_factory()
         printer = await printer_factory()
         archive = await archive_factory(printer.id, print_name="Original Name")
         archive = await archive_factory(printer.id, print_name="Original Name")
 
 
-        response = await async_client.patch(
-            f"/api/v1/archives/{archive.id}",
-            json={"print_name": "Updated Name"}
-        )
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"print_name": "Updated Name"})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["print_name"] == "Updated Name"
         assert response.json()["print_name"] == "Updated Name"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_archive_notes(
-        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
-    ):
+    async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
         """Verify archive notes can be updated."""
         """Verify archive notes can be updated."""
         printer = await printer_factory()
         printer = await printer_factory()
         archive = await archive_factory(printer.id)
         archive = await archive_factory(printer.id)
 
 
-        response = await async_client.patch(
-            f"/api/v1/archives/{archive.id}",
-            json={"notes": "Great print!"}
-        )
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Great print!"})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["notes"] == "Great print!"
         assert response.json()["notes"] == "Great print!"
@@ -155,10 +141,7 @@ class TestArchivesAPI:
         printer = await printer_factory()
         printer = await printer_factory()
         archive = await archive_factory(printer.id)
         archive = await archive_factory(printer.id)
 
 
-        response = await async_client.patch(
-            f"/api/v1/archives/{archive.id}",
-            json={"is_favorite": True}
-        )
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"is_favorite": True})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["is_favorite"] is True
         assert response.json()["is_favorite"] is True
@@ -169,9 +152,7 @@ class TestArchivesAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_delete_archive(
-        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
-    ):
+    async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
         """Verify archive can be deleted."""
         """Verify archive can be deleted."""
         printer = await printer_factory()
         printer = await printer_factory()
         archive = await archive_factory(printer.id)
         archive = await archive_factory(printer.id)
@@ -199,9 +180,7 @@ class TestArchivesAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_archive_stats(
-        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
-    ):
+    async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
         """Verify archive statistics can be retrieved."""
         """Verify archive statistics can be retrieved."""
         printer = await printer_factory()
         printer = await printer_factory()
         await archive_factory(
         await archive_factory(
@@ -282,10 +261,7 @@ class TestArchiveDataIntegrity:
         archive = await archive_factory(printer.id, notes="Original notes")
         archive = await archive_factory(printer.id, notes="Original notes")
 
 
         # Update
         # Update
-        await async_client.patch(
-            f"/api/v1/archives/{archive.id}",
-            json={"notes": "Updated notes", "is_favorite": True}
-        )
+        await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
 
 
         # Verify persistence
         # Verify persistence
         response = await async_client.get(f"/api/v1/archives/{archive.id}")
         response = await async_client.get(f"/api/v1/archives/{archive.id}")

+ 16 - 17
backend/tests/integration/test_camera_api.py

@@ -3,9 +3,10 @@
 Tests the full request/response cycle for /api/v1/printers/{id}/camera/ endpoints.
 Tests the full request/response cycle for /api/v1/printers/{id}/camera/ endpoints.
 """
 """
 
 
-import pytest
-from unittest.mock import patch, AsyncMock, MagicMock
 import asyncio
 import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
 
 
@@ -64,7 +65,7 @@ class TestCameraAPI:
         mock_process.returncode = None
         mock_process.returncode = None
         mock_process.terminate = MagicMock()
         mock_process.terminate = MagicMock()
 
 
-        with patch('backend.app.api.routes.camera._active_streams', {f"{printer.id}-abc123": mock_process}):
+        with patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc123": mock_process}):
             response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -92,7 +93,7 @@ class TestCameraAPI:
             f"{printer2.id}-def456": mock_process2,
             f"{printer2.id}-def456": mock_process2,
         }
         }
 
 
-        with patch('backend.app.api.routes.camera._active_streams', active_streams):
+        with patch("backend.app.api.routes.camera._active_streams", active_streams):
             response = await async_client.post(f"/api/v1/printers/{printer1.id}/camera/stop")
             response = await async_client.post(f"/api/v1/printers/{printer1.id}/camera/stop")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -119,7 +120,7 @@ class TestCameraAPI:
         """Verify camera test returns success when camera is accessible."""
         """Verify camera test returns success when camera is accessible."""
         printer = await printer_factory()
         printer = await printer_factory()
 
 
-        with patch('backend.app.api.routes.camera.test_camera_connection', new_callable=AsyncMock) as mock_test:
+        with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test:
             mock_test.return_value = {"success": True, "message": "Camera connected"}
             mock_test.return_value = {"success": True, "message": "Camera connected"}
 
 
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
@@ -134,7 +135,7 @@ class TestCameraAPI:
         """Verify camera test returns failure when camera is not accessible."""
         """Verify camera test returns failure when camera is not accessible."""
         printer = await printer_factory()
         printer = await printer_factory()
 
 
-        with patch('backend.app.api.routes.camera.test_camera_connection', new_callable=AsyncMock) as mock_test:
+        with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test:
             mock_test.return_value = {"success": False, "message": "Connection timeout"}
             mock_test.return_value = {"success": False, "message": "Connection timeout"}
 
 
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
@@ -162,18 +163,17 @@ class TestCameraAPI:
         printer = await printer_factory()
         printer = await printer_factory()
 
 
         # Create a fake JPEG (starts with FFD8)
         # Create a fake JPEG (starts with FFD8)
-        fake_jpeg = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00'
+        fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
 
 
-        with patch('backend.app.api.routes.camera.capture_camera_frame', new_callable=AsyncMock) as mock_capture:
+        with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture:
             mock_capture.return_value = True
             mock_capture.return_value = True
 
 
             # Mock the file read
             # Mock the file read
-            with patch('builtins.open', create=True) as mock_open:
+            with patch("builtins.open", create=True) as mock_open:
                 mock_open.return_value.__enter__.return_value.read.return_value = fake_jpeg
                 mock_open.return_value.__enter__.return_value.read.return_value = fake_jpeg
 
 
-                with patch('pathlib.Path.exists', return_value=True), \
-                     patch('pathlib.Path.unlink'):
-                    response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+                with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink"):
+                    _response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
 
 
         # Note: The actual test might fail due to file operations, but this tests the endpoint structure
         # Note: The actual test might fail due to file operations, but this tests the endpoint structure
         # In production tests, we'd mock more comprehensively
         # In production tests, we'd mock more comprehensively
@@ -184,11 +184,10 @@ class TestCameraAPI:
         """Verify 503 when camera capture fails."""
         """Verify 503 when camera capture fails."""
         printer = await printer_factory()
         printer = await printer_factory()
 
 
-        with patch('backend.app.api.routes.camera.capture_camera_frame', new_callable=AsyncMock) as mock_capture:
+        with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture:
             mock_capture.return_value = False
             mock_capture.return_value = False
 
 
-            with patch('pathlib.Path.exists', return_value=False), \
-                 patch('pathlib.Path.unlink'):
+            with patch("pathlib.Path.exists", return_value=False), patch("pathlib.Path.unlink"):
                 response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
                 response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
 
 
         assert response.status_code == 503
         assert response.status_code == 503
@@ -216,11 +215,11 @@ class TestCameraAPI:
         # Testing that the endpoint accepts various FPS values without error
         # Testing that the endpoint accepts various FPS values without error
         # (actual streaming would require mocking ffmpeg)
         # (actual streaming would require mocking ffmpeg)
 
 
-        with patch('backend.app.api.routes.camera.get_ffmpeg_path', return_value=None):
+        with patch("backend.app.api.routes.camera.get_ffmpeg_path", return_value=None):
             # With no ffmpeg, stream should return error message but not crash
             # With no ffmpeg, stream should return error message but not crash
             response = await async_client.get(
             response = await async_client.get(
                 f"/api/v1/printers/{printer.id}/camera/stream",
                 f"/api/v1/printers/{printer.id}/camera/stream",
-                params={"fps": 100}  # Should be clamped to 30
+                params={"fps": 100},  # Should be clamped to 30
             )
             )
             # Response will be a streaming response with error
             # Response will be a streaming response with error
             assert response.status_code == 200
             assert response.status_code == 200

+ 10 - 25
backend/tests/integration/test_external_links_api.py

@@ -44,9 +44,7 @@ class TestExternalLinksAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_list_external_links_with_data(
-        self, async_client: AsyncClient, link_factory, db_session
-    ):
+    async def test_list_external_links_with_data(self, async_client: AsyncClient, link_factory, db_session):
         """Verify list returns existing links."""
         """Verify list returns existing links."""
         await link_factory(name="My Link")
         await link_factory(name="My Link")
         response = await async_client.get("/api/v1/external-links/")
         response = await async_client.get("/api/v1/external-links/")
@@ -71,9 +69,7 @@ class TestExternalLinksAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_external_link(
-        self, async_client: AsyncClient, link_factory, db_session
-    ):
+    async def test_get_external_link(self, async_client: AsyncClient, link_factory, db_session):
         """Verify single link can be retrieved."""
         """Verify single link can be retrieved."""
         link = await link_factory(name="Get Test Link")
         link = await link_factory(name="Get Test Link")
         response = await async_client.get(f"/api/v1/external-links/{link.id}")
         response = await async_client.get(f"/api/v1/external-links/{link.id}")
@@ -89,14 +85,11 @@ class TestExternalLinksAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_external_link(
-        self, async_client: AsyncClient, link_factory, db_session
-    ):
+    async def test_update_external_link(self, async_client: AsyncClient, link_factory, db_session):
         """Verify link can be updated."""
         """Verify link can be updated."""
         link = await link_factory(name="Original")
         link = await link_factory(name="Original")
         response = await async_client.patch(
         response = await async_client.patch(
-            f"/api/v1/external-links/{link.id}",
-            json={"name": "Updated", "url": "https://updated.example.com"}
+            f"/api/v1/external-links/{link.id}", json={"name": "Updated", "url": "https://updated.example.com"}
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -105,9 +98,7 @@ class TestExternalLinksAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_delete_external_link(
-        self, async_client: AsyncClient, link_factory, db_session
-    ):
+    async def test_delete_external_link(self, async_client: AsyncClient, link_factory, db_session):
         """Verify link can be deleted."""
         """Verify link can be deleted."""
         link = await link_factory()
         link = await link_factory()
         response = await async_client.delete(f"/api/v1/external-links/{link.id}")
         response = await async_client.delete(f"/api/v1/external-links/{link.id}")
@@ -118,9 +109,7 @@ class TestExternalLinksAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_reorder_external_links(
-        self, async_client: AsyncClient, link_factory, db_session
-    ):
+    async def test_reorder_external_links(self, async_client: AsyncClient, link_factory, db_session):
         """Verify links can be reordered."""
         """Verify links can be reordered."""
         link1 = await link_factory(name="Link 1")
         link1 = await link_factory(name="Link 1")
         link2 = await link_factory(name="Link 2")
         link2 = await link_factory(name="Link 2")
@@ -128,8 +117,7 @@ class TestExternalLinksAPI:
 
 
         # Reorder: 3, 1, 2
         # Reorder: 3, 1, 2
         response = await async_client.put(
         response = await async_client.put(
-            "/api/v1/external-links/reorder",
-            json={"ids": [link3.id, link1.id, link2.id]}
+            "/api/v1/external-links/reorder", json={"ids": [link3.id, link1.id, link2.id]}
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
@@ -144,6 +132,7 @@ class TestExternalLinksIconAPI:
     @pytest.fixture
     @pytest.fixture
     async def link_factory(self, db_session):
     async def link_factory(self, db_session):
         """Factory to create test external links."""
         """Factory to create test external links."""
+
         async def _create_link(**kwargs):
         async def _create_link(**kwargs):
             from backend.app.models.external_link import ExternalLink
             from backend.app.models.external_link import ExternalLink
 
 
@@ -165,9 +154,7 @@ class TestExternalLinksIconAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_icon_not_set(
-        self, async_client: AsyncClient, link_factory, db_session
-    ):
+    async def test_get_icon_not_set(self, async_client: AsyncClient, link_factory, db_session):
         """Verify 404 when no custom icon is set."""
         """Verify 404 when no custom icon is set."""
         link = await link_factory()
         link = await link_factory()
         response = await async_client.get(f"/api/v1/external-links/{link.id}/icon")
         response = await async_client.get(f"/api/v1/external-links/{link.id}/icon")
@@ -175,9 +162,7 @@ class TestExternalLinksIconAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_delete_icon_when_none(
-        self, async_client: AsyncClient, link_factory, db_session
-    ):
+    async def test_delete_icon_when_none(self, async_client: AsyncClient, link_factory, db_session):
         """Verify deleting non-existent icon succeeds silently."""
         """Verify deleting non-existent icon succeeds silently."""
         link = await link_factory()
         link = await link_factory()
         response = await async_client.delete(f"/api/v1/external-links/{link.id}/icon")
         response = await async_client.delete(f"/api/v1/external-links/{link.id}/icon")

+ 6 - 14
backend/tests/integration/test_filaments_api.py

@@ -10,6 +10,7 @@ class TestFilamentsAPI:
     @pytest.fixture
     @pytest.fixture
     async def filament_factory(self, db_session):
     async def filament_factory(self, db_session):
         """Factory to create test filaments."""
         """Factory to create test filaments."""
+
         async def _create_filament(**kwargs):
         async def _create_filament(**kwargs):
             from backend.app.models.filament import Filament
             from backend.app.models.filament import Filament
 
 
@@ -41,9 +42,7 @@ class TestFilamentsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_list_filaments_with_data(
-        self, async_client: AsyncClient, filament_factory, db_session
-    ):
+    async def test_list_filaments_with_data(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify list returns existing filaments."""
         """Verify list returns existing filaments."""
         await filament_factory(name="Test Filament")
         await filament_factory(name="Test Filament")
         response = await async_client.get("/api/v1/filaments/")
         response = await async_client.get("/api/v1/filaments/")
@@ -71,9 +70,7 @@ class TestFilamentsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_filament(
-        self, async_client: AsyncClient, filament_factory, db_session
-    ):
+    async def test_get_filament(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify single filament can be retrieved."""
         """Verify single filament can be retrieved."""
         filament = await filament_factory(name="Get Test")
         filament = await filament_factory(name="Get Test")
         response = await async_client.get(f"/api/v1/filaments/{filament.id}")
         response = await async_client.get(f"/api/v1/filaments/{filament.id}")
@@ -89,14 +86,11 @@ class TestFilamentsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_filament(
-        self, async_client: AsyncClient, filament_factory, db_session
-    ):
+    async def test_update_filament(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify filament can be updated."""
         """Verify filament can be updated."""
         filament = await filament_factory(name="Original")
         filament = await filament_factory(name="Original")
         response = await async_client.patch(
         response = await async_client.patch(
-            f"/api/v1/filaments/{filament.id}",
-            json={"name": "Updated", "cost_per_kg": 35.0}
+            f"/api/v1/filaments/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0}
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -105,9 +99,7 @@ class TestFilamentsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_delete_filament(
-        self, async_client: AsyncClient, filament_factory, db_session
-    ):
+    async def test_delete_filament(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify filament can be deleted."""
         """Verify filament can be deleted."""
         filament = await filament_factory()
         filament = await filament_factory()
         response = await async_client.delete(f"/api/v1/filaments/{filament.id}")
         response = await async_client.delete(f"/api/v1/filaments/{filament.id}")

+ 17 - 52
backend/tests/integration/test_maintenance_api.py

@@ -56,17 +56,13 @@ class TestMaintenanceTypesAPI:
             "description": "Original",
             "description": "Original",
             "default_interval_hours": 100.0,
             "default_interval_hours": 100.0,
         }
         }
-        create_response = await async_client.post(
-            "/api/v1/maintenance/types", json=create_data
-        )
+        create_response = await async_client.post("/api/v1/maintenance/types", json=create_data)
         assert create_response.status_code == 200
         assert create_response.status_code == 200
         type_id = create_response.json()["id"]
         type_id = create_response.json()["id"]
 
 
         # Update it
         # Update it
         update_data = {"description": "Updated description"}
         update_data = {"description": "Updated description"}
-        response = await async_client.patch(
-            f"/api/v1/maintenance/types/{type_id}", json=update_data
-        )
+        response = await async_client.patch(f"/api/v1/maintenance/types/{type_id}", json=update_data)
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["description"] == "Updated description"
         assert response.json()["description"] == "Updated description"
 
 
@@ -80,9 +76,7 @@ class TestMaintenanceTypesAPI:
             "description": "To be deleted",
             "description": "To be deleted",
             "default_interval_hours": 50.0,
             "default_interval_hours": 50.0,
         }
         }
-        create_response = await async_client.post(
-            "/api/v1/maintenance/types", json=create_data
-        )
+        create_response = await async_client.post("/api/v1/maintenance/types", json=create_data)
         type_id = create_response.json()["id"]
         type_id = create_response.json()["id"]
 
 
         # Delete it
         # Delete it
@@ -102,9 +96,7 @@ class TestPrinterMaintenanceAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_printer_maintenance(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_get_printer_maintenance(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify maintenance overview for a printer."""
         """Verify maintenance overview for a printer."""
         printer = await printer_factory(name="Maintenance Test Printer")
         printer = await printer_factory(name="Maintenance Test Printer")
         response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}")
         response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}")
@@ -117,9 +109,7 @@ class TestPrinterMaintenanceAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_all_maintenance_overview(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_get_all_maintenance_overview(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify overview endpoint returns all printers."""
         """Verify overview endpoint returns all printers."""
         await printer_factory(name="Overview Printer 1")
         await printer_factory(name="Overview Printer 1")
         await printer_factory(name="Overview Printer 2")
         await printer_factory(name="Overview Printer 2")
@@ -158,50 +148,39 @@ class TestMaintenanceItemsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_maintenance_item(
-        self, async_client: AsyncClient, maintenance_item
-    ):
+    async def test_update_maintenance_item(self, async_client: AsyncClient, maintenance_item):
         """Verify maintenance item can be updated."""
         """Verify maintenance item can be updated."""
         if not maintenance_item:
         if not maintenance_item:
             pytest.skip("No maintenance items available")
             pytest.skip("No maintenance items available")
 
 
         item_id = maintenance_item["id"]
         item_id = maintenance_item["id"]
         response = await async_client.patch(
         response = await async_client.patch(
-            f"/api/v1/maintenance/items/{item_id}",
-            json={"custom_interval_hours": 150.0}
+            f"/api/v1/maintenance/items/{item_id}", json={"custom_interval_hours": 150.0}
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_disable_maintenance_item(
-        self, async_client: AsyncClient, maintenance_item
-    ):
+    async def test_disable_maintenance_item(self, async_client: AsyncClient, maintenance_item):
         """Verify maintenance item can be disabled."""
         """Verify maintenance item can be disabled."""
         if not maintenance_item:
         if not maintenance_item:
             pytest.skip("No maintenance items available")
             pytest.skip("No maintenance items available")
 
 
         item_id = maintenance_item["id"]
         item_id = maintenance_item["id"]
-        response = await async_client.patch(
-            f"/api/v1/maintenance/items/{item_id}",
-            json={"enabled": False}
-        )
+        response = await async_client.patch(f"/api/v1/maintenance/items/{item_id}", json={"enabled": False})
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["enabled"] is False
         assert response.json()["enabled"] is False
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_perform_maintenance(
-        self, async_client: AsyncClient, maintenance_item
-    ):
+    async def test_perform_maintenance(self, async_client: AsyncClient, maintenance_item):
         """Verify maintenance can be marked as performed."""
         """Verify maintenance can be marked as performed."""
         if not maintenance_item:
         if not maintenance_item:
             pytest.skip("No maintenance items available")
             pytest.skip("No maintenance items available")
 
 
         item_id = maintenance_item["id"]
         item_id = maintenance_item["id"]
         response = await async_client.post(
         response = await async_client.post(
-            f"/api/v1/maintenance/items/{item_id}/perform",
-            json={"notes": "Test maintenance performed"}
+            f"/api/v1/maintenance/items/{item_id}/perform", json={"notes": "Test maintenance performed"}
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
@@ -209,19 +188,14 @@ class TestMaintenanceItemsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_maintenance_history(
-        self, async_client: AsyncClient, maintenance_item
-    ):
+    async def test_get_maintenance_history(self, async_client: AsyncClient, maintenance_item):
         """Verify maintenance history can be retrieved."""
         """Verify maintenance history can be retrieved."""
         if not maintenance_item:
         if not maintenance_item:
             pytest.skip("No maintenance items available")
             pytest.skip("No maintenance items available")
 
 
         item_id = maintenance_item["id"]
         item_id = maintenance_item["id"]
         # First perform maintenance to create history
         # First perform maintenance to create history
-        await async_client.post(
-            f"/api/v1/maintenance/items/{item_id}/perform",
-            json={"notes": "History test"}
-        )
+        await async_client.post(f"/api/v1/maintenance/items/{item_id}/perform", json={"notes": "History test"})
 
 
         response = await async_client.get(f"/api/v1/maintenance/items/{item_id}/history")
         response = await async_client.get(f"/api/v1/maintenance/items/{item_id}/history")
         assert response.status_code == 200
         assert response.status_code == 200
@@ -232,10 +206,7 @@ class TestMaintenanceItemsAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_update_maintenance_item_not_found(self, async_client: AsyncClient):
     async def test_update_maintenance_item_not_found(self, async_client: AsyncClient):
         """Verify 404 for non-existent maintenance item."""
         """Verify 404 for non-existent maintenance item."""
-        response = await async_client.patch(
-            "/api/v1/maintenance/items/9999",
-            json={"enabled": False}
-        )
+        response = await async_client.patch("/api/v1/maintenance/items/9999", json={"enabled": False})
         assert response.status_code == 404
         assert response.status_code == 404
 
 
 
 
@@ -244,14 +215,11 @@ class TestPrinterHoursAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_set_printer_hours(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_set_printer_hours(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify printer hours can be set."""
         """Verify printer hours can be set."""
         printer = await printer_factory(name="Hours Test Printer")
         printer = await printer_factory(name="Hours Test Printer")
         response = await async_client.patch(
         response = await async_client.patch(
-            f"/api/v1/maintenance/printers/{printer.id}/hours",
-            params={"total_hours": 500.0}
+            f"/api/v1/maintenance/printers/{printer.id}/hours", params={"total_hours": 500.0}
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
@@ -261,8 +229,5 @@ class TestPrinterHoursAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_set_printer_hours_not_found(self, async_client: AsyncClient):
     async def test_set_printer_hours_not_found(self, async_client: AsyncClient):
         """Verify 404 for non-existent printer."""
         """Verify 404 for non-existent printer."""
-        response = await async_client.patch(
-            "/api/v1/maintenance/printers/9999/hours",
-            params={"total_hours": 100.0}
-        )
+        response = await async_client.patch("/api/v1/maintenance/printers/9999/hours", params={"total_hours": 100.0})
         assert response.status_code == 404
         assert response.status_code == 404

+ 22 - 54
backend/tests/integration/test_notifications_api.py

@@ -16,9 +16,7 @@ class TestNotificationsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_list_notification_providers_empty(
-        self, async_client: AsyncClient
-    ):
+    async def test_list_notification_providers_empty(self, async_client: AsyncClient):
         """Verify empty list is returned when no providers exist."""
         """Verify empty list is returned when no providers exist."""
         response = await async_client.get("/api/v1/notifications/")
         response = await async_client.get("/api/v1/notifications/")
 
 
@@ -31,7 +29,7 @@ class TestNotificationsAPI:
         self, async_client: AsyncClient, notification_provider_factory, db_session
         self, async_client: AsyncClient, notification_provider_factory, db_session
     ):
     ):
         """Verify list returns existing providers."""
         """Verify list returns existing providers."""
-        provider = await notification_provider_factory(name="Test Provider")
+        _provider = await notification_provider_factory(name="Test Provider")
 
 
         response = await async_client.get("/api/v1/notifications/")
         response = await async_client.get("/api/v1/notifications/")
 
 
@@ -91,9 +89,7 @@ class TestNotificationsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_create_provider_with_printer(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_create_provider_with_printer(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify provider can be linked to specific printer."""
         """Verify provider can be linked to specific printer."""
         printer = await printer_factory(name="Test Printer")
         printer = await printer_factory(name="Test Printer")
 
 
@@ -143,9 +139,7 @@ class TestNotificationsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_event_toggles(
-        self, async_client: AsyncClient, notification_provider_factory, db_session
-    ):
+    async def test_update_event_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session):
         """CRITICAL: Verify notification event toggles persist correctly."""
         """CRITICAL: Verify notification event toggles persist correctly."""
         provider = await notification_provider_factory(
         provider = await notification_provider_factory(
             on_print_start=True,
             on_print_start=True,
@@ -154,10 +148,7 @@ class TestNotificationsAPI:
         )
         )
 
 
         # Toggle on_print_stopped to True
         # Toggle on_print_stopped to True
-        response = await async_client.patch(
-            f"/api/v1/notifications/{provider.id}",
-            json={"on_print_stopped": True}
-        )
+        response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"on_print_stopped": True})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["on_print_stopped"] is True
         assert response.json()["on_print_stopped"] is True
@@ -168,9 +159,7 @@ class TestNotificationsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_ams_alarm_toggles(
-        self, async_client: AsyncClient, notification_provider_factory, db_session
-    ):
+    async def test_update_ams_alarm_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session):
         """CRITICAL: Verify AMS alarm toggles persist correctly."""
         """CRITICAL: Verify AMS alarm toggles persist correctly."""
         provider = await notification_provider_factory(
         provider = await notification_provider_factory(
             on_ams_humidity_high=False,
             on_ams_humidity_high=False,
@@ -183,7 +172,7 @@ class TestNotificationsAPI:
             json={
             json={
                 "on_ams_humidity_high": True,
                 "on_ams_humidity_high": True,
                 "on_ams_temperature_high": True,
                 "on_ams_temperature_high": True,
-            }
+            },
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -199,35 +188,25 @@ class TestNotificationsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_enable_disable_provider(
-        self, async_client: AsyncClient, notification_provider_factory, db_session
-    ):
+    async def test_enable_disable_provider(self, async_client: AsyncClient, notification_provider_factory, db_session):
         """Verify provider can be enabled/disabled."""
         """Verify provider can be enabled/disabled."""
         provider = await notification_provider_factory(enabled=True)
         provider = await notification_provider_factory(enabled=True)
 
 
         # Disable
         # Disable
-        response = await async_client.patch(
-            f"/api/v1/notifications/{provider.id}",
-            json={"enabled": False}
-        )
+        response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"enabled": False})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["enabled"] is False
         assert response.json()["enabled"] is False
 
 
         # Enable
         # Enable
-        response = await async_client.patch(
-            f"/api/v1/notifications/{provider.id}",
-            json={"enabled": True}
-        )
+        response = await async_client.patch(f"/api/v1/notifications/{provider.id}", json={"enabled": True})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["enabled"] is True
         assert response.json()["enabled"] is True
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_quiet_hours(
-        self, async_client: AsyncClient, notification_provider_factory, db_session
-    ):
+    async def test_update_quiet_hours(self, async_client: AsyncClient, notification_provider_factory, db_session):
         """Verify quiet hours can be configured."""
         """Verify quiet hours can be configured."""
         provider = await notification_provider_factory(quiet_hours_enabled=False)
         provider = await notification_provider_factory(quiet_hours_enabled=False)
 
 
@@ -237,7 +216,7 @@ class TestNotificationsAPI:
                 "quiet_hours_enabled": True,
                 "quiet_hours_enabled": True,
                 "quiet_hours_start": "22:00",
                 "quiet_hours_start": "22:00",
                 "quiet_hours_end": "07:00",
                 "quiet_hours_end": "07:00",
-            }
+            },
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -248,9 +227,7 @@ class TestNotificationsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_daily_digest(
-        self, async_client: AsyncClient, notification_provider_factory, db_session
-    ):
+    async def test_update_daily_digest(self, async_client: AsyncClient, notification_provider_factory, db_session):
         """Verify daily digest can be configured."""
         """Verify daily digest can be configured."""
         provider = await notification_provider_factory(daily_digest_enabled=False)
         provider = await notification_provider_factory(daily_digest_enabled=False)
 
 
@@ -259,7 +236,7 @@ class TestNotificationsAPI:
             json={
             json={
                 "daily_digest_enabled": True,
                 "daily_digest_enabled": True,
                 "daily_digest_time": "09:00",
                 "daily_digest_time": "09:00",
-            }
+            },
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -287,7 +264,7 @@ class TestNotificationsAPI:
                 "on_print_start": False,
                 "on_print_start": False,
                 "on_print_stopped": True,
                 "on_print_stopped": True,
                 "on_printer_offline": True,
                 "on_printer_offline": True,
-            }
+            },
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -306,15 +283,12 @@ class TestNotificationsAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_test_notification(
     async def test_test_notification(
-        self, async_client: AsyncClient, notification_provider_factory,
-        mock_httpx_client, db_session
+        self, async_client: AsyncClient, notification_provider_factory, mock_httpx_client, db_session
     ):
     ):
         """Verify test notification can be sent."""
         """Verify test notification can be sent."""
         provider = await notification_provider_factory()
         provider = await notification_provider_factory()
 
 
-        response = await async_client.post(
-            f"/api/v1/notifications/{provider.id}/test"
-        )
+        response = await async_client.post(f"/api/v1/notifications/{provider.id}/test")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -328,9 +302,7 @@ class TestNotificationsAPI:
         """Verify test notification works even for disabled provider."""
         """Verify test notification works even for disabled provider."""
         provider = await notification_provider_factory(enabled=False)
         provider = await notification_provider_factory(enabled=False)
 
 
-        response = await async_client.post(
-            f"/api/v1/notifications/{provider.id}/test"
-        )
+        response = await async_client.post(f"/api/v1/notifications/{provider.id}/test")
 
 
         # Test should still work for disabled providers
         # Test should still work for disabled providers
         assert response.status_code == 200
         assert response.status_code == 200
@@ -371,7 +343,7 @@ class TestNotificationTemplatesAPI:
     @pytest.fixture
     @pytest.fixture
     async def seeded_templates(self, db_session):
     async def seeded_templates(self, db_session):
         """Seed notification templates for tests."""
         """Seed notification templates for tests."""
-        from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+        from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
 
 
         templates = []
         templates = []
         for template_data in DEFAULT_TEMPLATES:
         for template_data in DEFAULT_TEMPLATES:
@@ -401,9 +373,7 @@ class TestNotificationTemplatesAPI:
         # Get first template ID from seeded data
         # Get first template ID from seeded data
         template_id = seeded_templates[0].id
         template_id = seeded_templates[0].id
 
 
-        response = await async_client.get(
-            f"/api/v1/notification-templates/{template_id}"
-        )
+        response = await async_client.get(f"/api/v1/notification-templates/{template_id}")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         template = response.json()
         template = response.json()
@@ -422,7 +392,7 @@ class TestNotificationTemplatesAPI:
             json={
             json={
                 "title_template": "Custom Title: {printer}",
                 "title_template": "Custom Title: {printer}",
                 "body_template": "Custom body for {filename}",
                 "body_template": "Custom body for {filename}",
-            }
+            },
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -436,9 +406,7 @@ class TestNotificationTemplatesAPI:
         """Verify template can be reset to default."""
         """Verify template can be reset to default."""
         template_id = seeded_templates[0].id
         template_id = seeded_templates[0].id
 
 
-        response = await async_client.post(
-            f"/api/v1/notification-templates/{template_id}/reset"
-        )
+        response = await async_client.post(f"/api/v1/notification-templates/{template_id}/reset")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()

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

@@ -0,0 +1,446 @@
+"""Integration tests for Print Queue API endpoints."""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestPrintQueueAPI:
+    """Integration tests for /api/v1/queue endpoints."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Test Printer {counter}",
+                "ip_address": f"192.168.1.{100 + counter}",
+                "serial_number": f"TESTSERIAL{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"test_print_{counter}.3mf",
+                "print_name": f"Test Print {counter}",
+                "file_path": f"/tmp/test_print_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"testhash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            # Create printer and archive if not provided
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_queue_empty(self, async_client: AsyncClient):
+        """Verify empty list when no queue items exist."""
+        response = await async_client.get("/api/v1/queue/")
+        assert response.status_code == 200
+        assert isinstance(response.json(), list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify item can be added to queue."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["archive_id"] == archive.id
+        assert result["status"] == "pending"
+        assert result["manual_start"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_manual_start(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added to queue with manual_start=True."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "manual_start": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["archive_id"] == archive.id
+        assert result["status"] == "pending"
+        assert result["manual_start"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify single queue item can be retrieved."""
+        item = await queue_item_factory()
+        response = await async_client.get(f"/api/v1/queue/{item.id}")
+        assert response.status_code == 200
+        assert response.json()["id"] == item.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_queue_item_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent queue item."""
+        response = await async_client.get("/api/v1/queue/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify queue item can be updated."""
+        item = await queue_item_factory()
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"auto_off_after": True})
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auto_off_after"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_manual_start(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify queue item manual_start can be updated."""
+        item = await queue_item_factory(manual_start=False)
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
+        assert response.status_code == 200
+        result = response.json()
+        assert result["manual_start"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify queue item can be deleted."""
+        item = await queue_item_factory()
+        response = await async_client.delete(f"/api/v1/queue/{item.id}")
+        assert response.status_code == 200
+        assert response.json()["message"] == "Queue item deleted"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
+        """Verify 404 for deleting non-existent queue item."""
+        response = await async_client.delete("/api/v1/queue/9999")
+        assert response.status_code == 404
+
+
+class TestQueueStartEndpoint:
+    """Tests for the /queue/{item_id}/start endpoint."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Test Printer {counter}",
+                "ip_address": f"192.168.1.{100 + counter}",
+                "serial_number": f"TESTSERIAL{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"test_print_{counter}.3mf",
+                "print_name": f"Test Print {counter}",
+                "file_path": f"/tmp/test_print_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"testhash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify starting a staged (manual_start=True) queue item clears the flag."""
+        item = await queue_item_factory(manual_start=True)
+        assert item.manual_start is True
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["manual_start"] is False
+        assert result["status"] == "pending"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify starting a non-staged queue item still works (idempotent)."""
+        item = await queue_item_factory(manual_start=False)
+        assert item.manual_start is False
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["manual_start"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_queue_item_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent queue item."""
+        response = await async_client.post("/api/v1/queue/9999/start")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify 400 error when trying to start a non-pending queue item."""
+        item = await queue_item_factory(status="printing", manual_start=True)
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 400
+        assert "pending" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_completed_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify 400 error when trying to start a completed queue item."""
+        item = await queue_item_factory(status="completed", manual_start=True)
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 400
+
+
+class TestQueueCancelEndpoint:
+    """Tests for the /queue/{item_id}/cancel endpoint."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            defaults = {
+                "name": "Cancel Test Printer",
+                "ip_address": "192.168.1.200",
+                "serial_number": "TESTCANCEL001",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            defaults = {
+                "filename": "cancel_test.3mf",
+                "print_name": "Cancel Test Print",
+                "file_path": "/tmp/cancel_test.3mf",
+                "file_size": 1024,
+                "content_hash": "cancelhash001",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": 1,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify cancelling a pending queue item."""
+        item = await queue_item_factory(status="pending")
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
+        assert response.status_code == 200
+        assert response.json()["message"] == "Queue item cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify 400 error when trying to cancel a non-pending queue item."""
+        item = await queue_item_factory(status="printing")
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
+        assert response.status_code == 400

+ 7 - 17
backend/tests/integration/test_projects_api.py

@@ -43,9 +43,7 @@ class TestProjectsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_list_projects_with_data(
-        self, async_client: AsyncClient, project_factory, db_session
-    ):
+    async def test_list_projects_with_data(self, async_client: AsyncClient, project_factory, db_session):
         """Verify list returns existing projects."""
         """Verify list returns existing projects."""
         await project_factory(name="My Project")
         await project_factory(name="My Project")
         response = await async_client.get("/api/v1/projects/")
         response = await async_client.get("/api/v1/projects/")
@@ -70,9 +68,7 @@ class TestProjectsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_project(
-        self, async_client: AsyncClient, project_factory, db_session
-    ):
+    async def test_get_project(self, async_client: AsyncClient, project_factory, db_session):
         """Verify single project can be retrieved."""
         """Verify single project can be retrieved."""
         project = await project_factory(name="Get Test Project")
         project = await project_factory(name="Get Test Project")
         response = await async_client.get(f"/api/v1/projects/{project.id}")
         response = await async_client.get(f"/api/v1/projects/{project.id}")
@@ -88,14 +84,11 @@ class TestProjectsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_project(
-        self, async_client: AsyncClient, project_factory, db_session
-    ):
+    async def test_update_project(self, async_client: AsyncClient, project_factory, db_session):
         """Verify project can be updated."""
         """Verify project can be updated."""
         project = await project_factory(name="Original")
         project = await project_factory(name="Original")
         response = await async_client.patch(
         response = await async_client.patch(
-            f"/api/v1/projects/{project.id}",
-            json={"name": "Updated", "description": "Updated description"}
+            f"/api/v1/projects/{project.id}", json={"name": "Updated", "description": "Updated description"}
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -104,9 +97,7 @@ class TestProjectsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_delete_project(
-        self, async_client: AsyncClient, project_factory, db_session
-    ):
+    async def test_delete_project(self, async_client: AsyncClient, project_factory, db_session):
         """Verify project can be deleted."""
         """Verify project can be deleted."""
         project = await project_factory()
         project = await project_factory()
         response = await async_client.delete(f"/api/v1/projects/{project.id}")
         response = await async_client.delete(f"/api/v1/projects/{project.id}")
@@ -128,6 +119,7 @@ class TestProjectArchivesAPI:
     @pytest.fixture
     @pytest.fixture
     async def project_factory(self, db_session):
     async def project_factory(self, db_session):
         """Factory to create test projects."""
         """Factory to create test projects."""
+
         async def _create_project(**kwargs):
         async def _create_project(**kwargs):
             from backend.app.models.project import Project
             from backend.app.models.project import Project
 
 
@@ -148,9 +140,7 @@ class TestProjectArchivesAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_project_with_archives(
-        self, async_client: AsyncClient, project_factory, db_session
-    ):
+    async def test_get_project_with_archives(self, async_client: AsyncClient, project_factory, db_session):
         """Verify project can be retrieved with archive count."""
         """Verify project can be retrieved with archive count."""
         project = await project_factory()
         project = await project_factory()
         response = await async_client.get(f"/api/v1/projects/{project.id}")
         response = await async_client.get(f"/api/v1/projects/{project.id}")

+ 19 - 20
backend/tests/integration/test_system_api.py

@@ -3,8 +3,9 @@
 Tests the full request/response cycle for /api/v1/system/ endpoints.
 Tests the full request/response cycle for /api/v1/system/ endpoints.
 """
 """
 
 
+from unittest.mock import MagicMock, patch
+
 import pytest
 import pytest
-from unittest.mock import patch, MagicMock
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
 
 
@@ -20,18 +21,12 @@ class TestSystemAPI:
     async def test_get_system_info(self, async_client: AsyncClient):
     async def test_get_system_info(self, async_client: AsyncClient):
         """Verify system info endpoint returns expected structure."""
         """Verify system info endpoint returns expected structure."""
         # Mock psutil to avoid system-specific values
         # Mock psutil to avoid system-specific values
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil:
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
-                total=500000000000,
-                used=250000000000,
-                free=250000000000,
-                percent=50.0
+                total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )
             mock_psutil.virtual_memory.return_value = MagicMock(
             mock_psutil.virtual_memory.return_value = MagicMock(
-                total=16000000000,
-                available=8000000000,
-                used=8000000000,
-                percent=50.0
+                total=16000000000, available=8000000000, used=8000000000, percent=50.0
             )
             )
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.boot_time.return_value = 1700000000.0
             mock_psutil.cpu_count.return_value = 4
             mock_psutil.cpu_count.return_value = 4
@@ -55,7 +50,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_system_info_app_section(self, async_client: AsyncClient):
     async def test_system_info_app_section(self, async_client: AsyncClient):
         """Verify app section contains version and directory info."""
         """Verify app section contains version and directory info."""
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil:
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )
@@ -79,7 +74,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_system_info_database_section(self, async_client: AsyncClient):
     async def test_system_info_database_section(self, async_client: AsyncClient):
         """Verify database section contains counts and statistics."""
         """Verify database section contains counts and statistics."""
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil:
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )
@@ -111,7 +106,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_system_info_storage_section(self, async_client: AsyncClient):
     async def test_system_info_storage_section(self, async_client: AsyncClient):
         """Verify storage section contains disk usage info."""
         """Verify storage section contains disk usage info."""
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil:
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )
@@ -141,7 +136,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_system_info_memory_section(self, async_client: AsyncClient):
     async def test_system_info_memory_section(self, async_client: AsyncClient):
         """Verify memory section contains RAM usage info."""
         """Verify memory section contains RAM usage info."""
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil:
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )
@@ -167,7 +162,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_system_info_cpu_section(self, async_client: AsyncClient):
     async def test_system_info_cpu_section(self, async_client: AsyncClient):
         """Verify CPU section contains processor info."""
         """Verify CPU section contains processor info."""
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil:
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )
@@ -192,10 +187,12 @@ class TestSystemAPI:
     async def test_system_info_printers_section(self, async_client: AsyncClient, printer_factory):
     async def test_system_info_printers_section(self, async_client: AsyncClient, printer_factory):
         """Verify printers section contains connected printer info."""
         """Verify printers section contains connected printer info."""
         # Create a test printer
         # Create a test printer
-        printer = await printer_factory(name="Test Printer", model="X1C")
+        _printer = await printer_factory(name="Test Printer", model="X1C")
 
 
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil, \
-             patch('backend.app.api.routes.system.printer_manager') as mock_pm:
+        with (
+            patch("backend.app.api.routes.system.psutil") as mock_psutil,
+            patch("backend.app.api.routes.system.printer_manager") as mock_pm,
+        ):
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )
@@ -227,8 +224,10 @@ class TestSystemAPI:
         await archive_factory(printer.id, status="completed", print_time_seconds=3600)
         await archive_factory(printer.id, status="completed", print_time_seconds=3600)
         await archive_factory(printer.id, status="failed", print_time_seconds=1800)
         await archive_factory(printer.id, status="failed", print_time_seconds=1800)
 
 
-        with patch('backend.app.api.routes.system.psutil') as mock_psutil, \
-             patch('backend.app.api.routes.system.printer_manager') as mock_pm:
+        with (
+            patch("backend.app.api.routes.system.psutil") as mock_psutil,
+            patch("backend.app.api.routes.system.printer_manager") as mock_pm,
+        ):
             mock_psutil.disk_usage.return_value = MagicMock(
             mock_psutil.disk_usage.return_value = MagicMock(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
             )

+ 15 - 14
backend/tests/unit/services/test_archive_service.py

@@ -1,8 +1,9 @@
 """Unit tests for the archive service."""
 """Unit tests for the archive service."""
 
 
-import pytest
 from datetime import datetime
 from datetime import datetime
-from unittest.mock import MagicMock, AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
 
 
 
 
 class TestArchiveServiceHelpers:
 class TestArchiveServiceHelpers:
@@ -12,7 +13,7 @@ class TestArchiveServiceHelpers:
         """Test parsing print time to seconds."""
         """Test parsing print time to seconds."""
         # Import the actual function if available, otherwise test the logic
         # Import the actual function if available, otherwise test the logic
         # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
         # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
-        time_str = "2h 30m 15s"
+        _time_str = "2h 30m 15s"  # Example format
         # Parse hours
         # Parse hours
         hours = 2
         hours = 2
         minutes = 30
         minutes = 30
@@ -24,7 +25,7 @@ class TestArchiveServiceHelpers:
         """Test parsing filament usage to grams."""
         """Test parsing filament usage to grams."""
         # Example: "150.5g" -> 150.5
         # Example: "150.5g" -> 150.5
         filament_str = "150.5g"
         filament_str = "150.5g"
-        grams = float(filament_str.replace('g', ''))
+        grams = float(filament_str.replace("g", ""))
         assert grams == 150.5
         assert grams == 150.5
 
 
     def test_format_duration(self):
     def test_format_duration(self):
@@ -95,7 +96,7 @@ class TestArchiveFilePaths:
     def test_generate_archive_path(self):
     def test_generate_archive_path(self):
         """Test generating archive file paths."""
         """Test generating archive file paths."""
         printer_name = "X1C_01"
         printer_name = "X1C_01"
-        print_name = "benchy"
+        _print_name = "benchy"  # Example print name
         timestamp = datetime(2024, 1, 15, 14, 30, 0)
         timestamp = datetime(2024, 1, 15, 14, 30, 0)
 
 
         # Expected pattern: archives/{printer}/{year}/{month}/{filename}
         # Expected pattern: archives/{printer}/{year}/{month}/{filename}
@@ -110,25 +111,25 @@ class TestArchiveFilePaths:
     def test_sanitize_filename(self):
     def test_sanitize_filename(self):
         """Test filename sanitization."""
         """Test filename sanitization."""
         # Characters to remove: / \ : * ? " < > |
         # Characters to remove: / \ : * ? " < > |
-        dirty_name = 'test:file<name>.3mf'
+        dirty_name = "test:file<name>.3mf"
         # Simple sanitization
         # Simple sanitization
         safe_chars = []
         safe_chars = []
         for c in dirty_name:
         for c in dirty_name:
             if c not in '\\/:*?"<>|':
             if c not in '\\/:*?"<>|':
                 safe_chars.append(c)
                 safe_chars.append(c)
-        clean_name = ''.join(safe_chars)
-        assert ':' not in clean_name
-        assert '<' not in clean_name
-        assert '>' not in clean_name
+        clean_name = "".join(safe_chars)
+        assert ":" not in clean_name
+        assert "<" not in clean_name
+        assert ">" not in clean_name
 
 
     def test_thumbnail_path(self):
     def test_thumbnail_path(self):
         """Test thumbnail path generation."""
         """Test thumbnail path generation."""
         archive_path = "archives/X1C_01/2024/01/benchy.3mf"
         archive_path = "archives/X1C_01/2024/01/benchy.3mf"
         # Thumbnail typically has same path with _thumb.png suffix
         # Thumbnail typically has same path with _thumb.png suffix
-        base_path = archive_path.rsplit('.', 1)[0]
+        base_path = archive_path.rsplit(".", 1)[0]
         thumbnail_path = f"{base_path}_thumb.png"
         thumbnail_path = f"{base_path}_thumb.png"
-        assert thumbnail_path.endswith('_thumb.png')
-        assert 'benchy' in thumbnail_path
+        assert thumbnail_path.endswith("_thumb.png")
+        assert "benchy" in thumbnail_path
 
 
 
 
 class TestArchiveStatus:
 class TestArchiveStatus:
@@ -199,7 +200,7 @@ class TestArchiveThumbnails:
         """Test supported thumbnail file types."""
         """Test supported thumbnail file types."""
         supported_types = [".png", ".jpg", ".jpeg"]
         supported_types = [".png", ".jpg", ".jpeg"]
         for ext in supported_types:
         for ext in supported_types:
-            assert ext.startswith('.')
+            assert ext.startswith(".")
             assert ext.lower() in [".png", ".jpg", ".jpeg"]
             assert ext.lower() in [".png", ".jpg", ".jpeg"]
 
 
     def test_extract_thumbnail_from_3mf(self):
     def test_extract_thumbnail_from_3mf(self):

+ 64 - 49
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4,9 +4,10 @@ Tests for the BambuMQTTClient service.
 These tests focus on timelapse tracking during prints.
 These tests focus on timelapse tracking during prints.
 """
 """
 
 
-import pytest
 from unittest.mock import MagicMock, patch
 from unittest.mock import MagicMock, patch
 
 
+import pytest
+
 
 
 class TestTimelapseTracking:
 class TestTimelapseTracking:
     """Tests for timelapse state tracking during prints."""
     """Tests for timelapse state tracking during prints."""
@@ -140,12 +141,14 @@ class TestPrintCompletionWithTimelapse:
             mqtt_client._completion_triggered = True
             mqtt_client._completion_triggered = True
             mqtt_client._was_running = False
             mqtt_client._was_running = False
             mqtt_client._timelapse_during_print = False
             mqtt_client._timelapse_during_print = False
-            mqtt_client.on_print_complete({
-                "status": status,
-                "filename": mqtt_client._previous_gcode_file,
-                "subtask_name": mqtt_client.state.subtask_name,
-                "timelapse_was_active": timelapse_was_active,
-            })
+            mqtt_client.on_print_complete(
+                {
+                    "status": status,
+                    "filename": mqtt_client._previous_gcode_file,
+                    "subtask_name": mqtt_client.state.subtask_name,
+                    "timelapse_was_active": timelapse_was_active,
+                }
+            )
 
 
         assert "timelapse_was_active" in callback_data
         assert "timelapse_was_active" in callback_data
         assert callback_data["timelapse_was_active"] is True
         assert callback_data["timelapse_was_active"] is True
@@ -170,12 +173,14 @@ class TestPrintCompletionWithTimelapse:
 
 
         # Trigger completion
         # Trigger completion
         timelapse_was_active = mqtt_client._timelapse_during_print
         timelapse_was_active = mqtt_client._timelapse_during_print
-        mqtt_client.on_print_complete({
-            "status": "completed",
-            "filename": mqtt_client._previous_gcode_file,
-            "subtask_name": mqtt_client.state.subtask_name,
-            "timelapse_was_active": timelapse_was_active,
-        })
+        mqtt_client.on_print_complete(
+            {
+                "status": "completed",
+                "filename": mqtt_client._previous_gcode_file,
+                "subtask_name": mqtt_client.state.subtask_name,
+                "timelapse_was_active": timelapse_was_active,
+            }
+        )
 
 
         assert callback_data["timelapse_was_active"] is False
         assert callback_data["timelapse_was_active"] is False
 
 
@@ -254,9 +259,9 @@ class TestRealisticMessageFlow:
         # Verify timelapse was detected even though xcam is parsed before state
         # Verify timelapse was detected even though xcam is parsed before state
         assert mqtt_client._was_running is True, "_was_running should be True after RUNNING state"
         assert mqtt_client._was_running is True, "_was_running should be True after RUNNING state"
         assert mqtt_client.state.timelapse is True, "state.timelapse should be True"
         assert mqtt_client.state.timelapse is True, "state.timelapse should be True"
-        assert mqtt_client._timelapse_during_print is True, (
-            "timelapse_during_print should be True when timelapse is in the same message as RUNNING state"
-        )
+        assert (
+            mqtt_client._timelapse_during_print is True
+        ), "timelapse_during_print should be True when timelapse is in the same message as RUNNING state"
 
 
     def test_timelapse_not_detected_when_disabled(self, mqtt_client):
     def test_timelapse_not_detected_when_disabled(self, mqtt_client):
         """Test that timelapse is NOT detected when disabled in xcam data."""
         """Test that timelapse is NOT detected when disabled in xcam data."""
@@ -334,40 +339,46 @@ class TestRealisticMessageFlow:
         mqtt_client.on_print_complete = on_complete
         mqtt_client.on_print_complete = on_complete
 
 
         # 1. Print starts with timelapse
         # 1. Print starts with timelapse
-        mqtt_client._process_message({
-            "print": {
-                "gcode_state": "RUNNING",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
-                "xcam": {"timelapse": "enable"},
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                    "xcam": {"timelapse": "enable"},
+                }
             }
             }
-        })
+        )
 
 
         assert mqtt_client._timelapse_during_print is True
         assert mqtt_client._timelapse_during_print is True
         assert "subtask_name" in start_data
         assert "subtask_name" in start_data
 
 
         # 2. Print continues (multiple messages)
         # 2. Print continues (multiple messages)
         for _ in range(3):
         for _ in range(3):
-            mqtt_client._process_message({
-                "print": {
-                    "gcode_state": "RUNNING",
-                    "gcode_file": "/data/Metadata/test.gcode",
-                    "subtask_name": "Test",
-                    "mc_percent": 50,
+            mqtt_client._process_message(
+                {
+                    "print": {
+                        "gcode_state": "RUNNING",
+                        "gcode_file": "/data/Metadata/test.gcode",
+                        "subtask_name": "Test",
+                        "mc_percent": 50,
+                    }
                 }
                 }
-            })
+            )
 
 
         # Timelapse flag should still be True
         # Timelapse flag should still be True
         assert mqtt_client._timelapse_during_print is True
         assert mqtt_client._timelapse_during_print is True
 
 
         # 3. Print completes
         # 3. Print completes
-        mqtt_client._process_message({
-            "print": {
-                "gcode_state": "FINISH",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
             }
             }
-        })
+        )
 
 
         # Verify completion callback received timelapse flag
         # Verify completion callback received timelapse flag
         assert "timelapse_was_active" in complete_data
         assert "timelapse_was_active" in complete_data
@@ -389,23 +400,27 @@ class TestRealisticMessageFlow:
         mqtt_client.on_print_complete = on_complete
         mqtt_client.on_print_complete = on_complete
 
 
         # Start with timelapse
         # Start with timelapse
-        mqtt_client._process_message({
-            "print": {
-                "gcode_state": "RUNNING",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
-                "xcam": {"timelapse": "enable"},
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                    "xcam": {"timelapse": "enable"},
+                }
             }
             }
-        })
+        )
 
 
         # Print fails
         # Print fails
-        mqtt_client._process_message({
-            "print": {
-                "gcode_state": "FAILED",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FAILED",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
             }
             }
-        })
+        )
 
 
         assert complete_data["timelapse_was_active"] is True
         assert complete_data["timelapse_was_active"] is True
         assert complete_data["status"] == "failed"
         assert complete_data["status"] == "failed"

+ 149 - 279
backend/tests/unit/services/test_notification_service.py

@@ -3,11 +3,12 @@
 Tests event-based notifications and toggle behavior.
 Tests event-based notifications and toggle behavior.
 """
 """
 
 
-import pytest
 import json
 import json
 from datetime import datetime
 from datetime import datetime
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
+import pytest
+
 from backend.app.services.notification_service import NotificationService
 from backend.app.services.notification_service import NotificationService
 
 
 
 
@@ -59,20 +60,13 @@ class TestNotificationService:
     # ========================================================================
     # ========================================================================
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_start_sends_notification(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_on_print_start_sends_notification(self, service, mock_provider, mock_db):
         """Verify notification is sent when print starts."""
         """Verify notification is sent when print starts."""
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send, \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("Print Started", "Test Printer: test.3mf")
             mock_build.return_value = ("Print Started", "Test Printer: test.3mf")
 
 
@@ -87,17 +81,12 @@ class TestNotificationService:
             mock_send.assert_called_once()
             mock_send.assert_called_once()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_start_skipped_when_no_providers(
-        self, service, mock_db
-    ):
+    async def test_on_print_start_skipped_when_no_providers(self, service, mock_db):
         """Verify no error when no providers are configured for event."""
         """Verify no error when no providers are configured for event."""
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
             mock_get.return_value = []
             mock_get.return_value = []
 
 
             await service.on_print_start(
             await service.on_print_start(
@@ -114,20 +103,13 @@ class TestNotificationService:
     # ========================================================================
     # ========================================================================
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_routes_completed_status(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_on_print_complete_routes_completed_status(self, service, mock_provider, mock_db):
         """Verify completed status uses on_print_complete field."""
         """Verify completed status uses on_print_complete field."""
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("Test", "Test")
             mock_build.return_value = ("Test", "Test")
 
 
@@ -144,20 +126,13 @@ class TestNotificationService:
             assert call_args[0][1] == "on_print_complete"
             assert call_args[0][1] == "on_print_complete"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_routes_failed_status(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_on_print_complete_routes_failed_status(self, service, mock_provider, mock_db):
         """Verify failed status uses on_print_failed field."""
         """Verify failed status uses on_print_failed field."""
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("Test", "Test")
             mock_build.return_value = ("Test", "Test")
 
 
@@ -173,20 +148,13 @@ class TestNotificationService:
             assert call_args[0][1] == "on_print_failed"
             assert call_args[0][1] == "on_print_failed"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_routes_stopped_status(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_on_print_complete_routes_stopped_status(self, service, mock_provider, mock_db):
         """Verify stopped status uses on_print_stopped field."""
         """Verify stopped status uses on_print_stopped field."""
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("Test", "Test")
             mock_build.return_value = ("Test", "Test")
 
 
@@ -202,20 +170,13 @@ class TestNotificationService:
             assert call_args[0][1] == "on_print_stopped"
             assert call_args[0][1] == "on_print_stopped"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_routes_aborted_status(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_on_print_complete_routes_aborted_status(self, service, mock_provider, mock_db):
         """Verify aborted status uses on_print_stopped field."""
         """Verify aborted status uses on_print_stopped field."""
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("Test", "Test")
             mock_build.return_value = ("Test", "Test")
 
 
@@ -241,15 +202,11 @@ class TestNotificationService:
 
 
         # The actual filtering happens in _get_providers_for_event
         # The actual filtering happens in _get_providers_for_event
         # which queries only enabled providers
         # which queries only enabled providers
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get:
+        with patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get:
             # Simulate the query filtering out disabled providers
             # Simulate the query filtering out disabled providers
             mock_get.return_value = []
             mock_get.return_value = []
 
 
-            result = await service._get_providers_for_event(
-                mock_db, "on_print_start", printer_id=1
-            )
+            result = await service._get_providers_for_event(mock_db, "on_print_start", printer_id=1)
 
 
             assert len(result) == 0
             assert len(result) == 0
 
 
@@ -258,15 +215,11 @@ class TestNotificationService:
         """Verify providers can be filtered by specific printer."""
         """Verify providers can be filtered by specific printer."""
         mock_provider.printer_id = 2  # Linked to printer 2
         mock_provider.printer_id = 2  # Linked to printer 2
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get:
+        with patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get:
             # When querying for printer 1, provider linked to printer 2 is excluded
             # When querying for printer 1, provider linked to printer 2 is excluded
             mock_get.return_value = []
             mock_get.return_value = []
 
 
-            result = await service._get_providers_for_event(
-                mock_db, "on_print_start", printer_id=1
-            )
+            result = await service._get_providers_for_event(mock_db, "on_print_start", printer_id=1)
 
 
             assert len(result) == 0
             assert len(result) == 0
 
 
@@ -280,9 +233,7 @@ class TestNotificationService:
         mock_provider.quiet_hours_start = "22:00"
         mock_provider.quiet_hours_start = "22:00"
         mock_provider.quiet_hours_end = "07:00"
         mock_provider.quiet_hours_end = "07:00"
 
 
-        with patch(
-            'backend.app.services.notification_service.datetime'
-        ) as mock_datetime:
+        with patch("backend.app.services.notification_service.datetime") as mock_datetime:
             # Test during quiet hours (23:00)
             # Test during quiet hours (23:00)
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.hour = 23
             mock_now.hour = 23
@@ -299,9 +250,7 @@ class TestNotificationService:
         mock_provider.quiet_hours_start = "22:00"
         mock_provider.quiet_hours_start = "22:00"
         mock_provider.quiet_hours_end = "07:00"
         mock_provider.quiet_hours_end = "07:00"
 
 
-        with patch(
-            'backend.app.services.notification_service.datetime'
-        ) as mock_datetime:
+        with patch("backend.app.services.notification_service.datetime") as mock_datetime:
             # Test outside quiet hours (12:00)
             # Test outside quiet hours (12:00)
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.hour = 12
             mock_now.hour = 12
@@ -326,9 +275,7 @@ class TestNotificationService:
         mock_provider.quiet_hours_start = "22:00"
         mock_provider.quiet_hours_start = "22:00"
         mock_provider.quiet_hours_end = "07:00"
         mock_provider.quiet_hours_end = "07:00"
 
 
-        with patch(
-            'backend.app.services.notification_service.datetime'
-        ) as mock_datetime:
+        with patch("backend.app.services.notification_service.datetime") as mock_datetime:
             # Test early morning (03:00) - should be in quiet hours
             # Test early morning (03:00) - should be in quiet hours
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.hour = 3
             mock_now.hour = 3
@@ -344,22 +291,15 @@ class TestNotificationService:
     # ========================================================================
     # ========================================================================
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_ams_humidity_high_sends_notification(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_on_ams_humidity_high_sends_notification(self, service, mock_provider, mock_db):
         """Verify AMS humidity alarm sends notification."""
         """Verify AMS humidity alarm sends notification."""
         mock_provider.on_ams_humidity_high = True
         mock_provider.on_ams_humidity_high = True
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send, \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("AMS Humidity Alert", "High humidity detected")
             mock_build.return_value = ("AMS Humidity Alert", "High humidity detected")
 
 
@@ -375,25 +315,18 @@ class TestNotificationService:
             mock_send.assert_called_once()
             mock_send.assert_called_once()
             # Verify force_immediate is True for alarms
             # Verify force_immediate is True for alarms
             call_kwargs = mock_send.call_args[1]
             call_kwargs = mock_send.call_args[1]
-            assert call_kwargs.get('force_immediate') is True
+            assert call_kwargs.get("force_immediate") is True
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_ams_temperature_high_sends_notification(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db):
         """Verify AMS temperature alarm sends notification."""
         """Verify AMS temperature alarm sends notification."""
         mock_provider.on_ams_temperature_high = True
         mock_provider.on_ams_temperature_high = True
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send, \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("AMS Temperature Alert", "High temp detected")
             mock_build.return_value = ("AMS Temperature Alert", "High temp detected")
 
 
@@ -409,22 +342,17 @@ class TestNotificationService:
             mock_send.assert_called_once()
             mock_send.assert_called_once()
             # Verify force_immediate is True for alarms
             # Verify force_immediate is True for alarms
             call_kwargs = mock_send.call_args[1]
             call_kwargs = mock_send.call_args[1]
-            assert call_kwargs.get('force_immediate') is True
+            assert call_kwargs.get("force_immediate") is True
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_ams_alarm_skipped_when_toggle_disabled(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db):
         """CRITICAL: Verify AMS alarms respect toggle setting."""
         """CRITICAL: Verify AMS alarms respect toggle setting."""
         mock_provider.on_ams_humidity_high = False
         mock_provider.on_ams_humidity_high = False
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
             # Provider with toggle disabled won't be returned
             # Provider with toggle disabled won't be returned
             mock_get.return_value = []
             mock_get.return_value = []
 
 
@@ -444,23 +372,16 @@ class TestNotificationService:
     # ========================================================================
     # ========================================================================
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_daily_digest_queues_notification(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_daily_digest_queues_notification(self, service, mock_provider, mock_db):
         """Verify notifications are queued when digest mode is enabled."""
         """Verify notifications are queued when digest mode is enabled."""
         mock_provider.daily_digest_enabled = True
         mock_provider.daily_digest_enabled = True
         mock_provider.daily_digest_time = "09:00"
         mock_provider.daily_digest_time = "09:00"
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send, \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("Test", "Test")
             mock_build.return_value = ("Test", "Test")
 
 
@@ -477,23 +398,16 @@ class TestNotificationService:
             mock_send.assert_called_once()
             mock_send.assert_called_once()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_force_immediate_bypasses_digest(
-        self, service, mock_provider, mock_db
-    ):
+    async def test_force_immediate_bypasses_digest(self, service, mock_provider, mock_db):
         """Verify force_immediate=True bypasses digest mode."""
         """Verify force_immediate=True bypasses digest mode."""
         mock_provider.daily_digest_enabled = True
         mock_provider.daily_digest_enabled = True
         mock_provider.on_ams_humidity_high = True
         mock_provider.on_ams_humidity_high = True
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send, \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
             mock_build.return_value = ("Alert", "Alert message")
             mock_build.return_value = ("Alert", "Alert message")
 
 
@@ -508,7 +422,7 @@ class TestNotificationService:
 
 
             # Verify force_immediate is passed
             # Verify force_immediate is passed
             call_kwargs = mock_send.call_args[1]
             call_kwargs = mock_send.call_args[1]
-            assert call_kwargs.get('force_immediate') is True
+            assert call_kwargs.get("force_immediate") is True
 
 
 
 
 class TestDigestModeAlwaysSendsImmediately:
 class TestDigestModeAlwaysSendsImmediately:
@@ -534,25 +448,27 @@ class TestDigestModeAlwaysSendsImmediately:
         mock_db = AsyncMock()
         mock_db = AsyncMock()
 
 
         # Mock the _send_to_provider method
         # Mock the _send_to_provider method
-        with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
+        with (
+            patch.object(service, "_send_to_provider", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_queue_for_digest", new_callable=AsyncMock) as mock_queue,
+            patch.object(service, "_update_provider_status", new_callable=AsyncMock),
+            patch.object(service, "_log_notification", new_callable=AsyncMock),
+        ):
             mock_send.return_value = (True, None)
             mock_send.return_value = (True, None)
 
 
-            with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
-                with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
-                    with patch.object(service, '_log_notification', new_callable=AsyncMock):
-                        await service._send_to_providers(
-                            providers=[mock_provider],
-                            title="Print Started",
-                            message="Your print has started",
-                            db=mock_db,
-                            event_type="print_start",
-                        )
+            await service._send_to_providers(
+                providers=[mock_provider],
+                title="Print Started",
+                message="Your print has started",
+                db=mock_db,
+                event_type="print_start",
+            )
 
 
-                        # CRITICAL: _send_to_provider MUST be called (immediate send)
-                        mock_send.assert_called_once()
+            # CRITICAL: _send_to_provider MUST be called (immediate send)
+            mock_send.assert_called_once()
 
 
-                        # Digest queue should also be called (for daily summary)
-                        mock_queue.assert_called_once()
+            # Digest queue should also be called (for daily summary)
+            mock_queue.assert_called_once()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_notification_sends_without_digest_queue_when_disabled(self, service):
     async def test_notification_sends_without_digest_queue_when_disabled(self, service):
@@ -568,25 +484,27 @@ class TestDigestModeAlwaysSendsImmediately:
 
 
         mock_db = AsyncMock()
         mock_db = AsyncMock()
 
 
-        with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
+        with (
+            patch.object(service, "_send_to_provider", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_queue_for_digest", new_callable=AsyncMock) as mock_queue,
+            patch.object(service, "_update_provider_status", new_callable=AsyncMock),
+            patch.object(service, "_log_notification", new_callable=AsyncMock),
+        ):
             mock_send.return_value = (True, None)
             mock_send.return_value = (True, None)
 
 
-            with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
-                with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
-                    with patch.object(service, '_log_notification', new_callable=AsyncMock):
-                        await service._send_to_providers(
-                            providers=[mock_provider],
-                            title="Print Started",
-                            message="Your print has started",
-                            db=mock_db,
-                            event_type="print_start",
-                        )
+            await service._send_to_providers(
+                providers=[mock_provider],
+                title="Print Started",
+                message="Your print has started",
+                db=mock_db,
+                event_type="print_start",
+            )
 
 
-                        # Notification must still be sent immediately
-                        mock_send.assert_called_once()
+            # Notification must still be sent immediately
+            mock_send.assert_called_once()
 
 
-                        # Digest queue should NOT be called when digest is disabled
-                        mock_queue.assert_not_called()
+            # Digest queue should NOT be called when digest is disabled
+            mock_queue.assert_not_called()
 
 
 
 
 class TestNotificationProviderTypes:
 class TestNotificationProviderTypes:
@@ -613,12 +531,10 @@ class TestNotificationProviderTypes:
         mock_client = AsyncMock()
         mock_client = AsyncMock()
         mock_client.post = AsyncMock(return_value=mock_response)
         mock_client.post = AsyncMock(return_value=mock_response)
 
 
-        with patch.object(service, '_get_client', new_callable=AsyncMock) as mock_get_client:
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
             mock_get_client.return_value = mock_client
             mock_get_client.return_value = mock_client
 
 
-            success, message = await service._send_webhook(
-                config, "Test Title", "Test Message"
-            )
+            success, message = await service._send_webhook(config, "Test Title", "Test Message")
 
 
             assert success is True
             assert success is True
             mock_client.post.assert_called_once()
             mock_client.post.assert_called_once()
@@ -630,17 +546,13 @@ class TestNotificationProviderTypes:
             "webhook_url": "http://test.local/webhook",
             "webhook_url": "http://test.local/webhook",
         }
         }
 
 
-        with patch('httpx.AsyncClient') as mock_client_class:
+        with patch("httpx.AsyncClient") as mock_client_class:
             mock_instance = AsyncMock()
             mock_instance = AsyncMock()
             mock_instance.post.side_effect = Exception("Connection failed")
             mock_instance.post.side_effect = Exception("Connection failed")
-            mock_client_class.return_value.__aenter__ = AsyncMock(
-                return_value=mock_instance
-            )
+            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
             mock_client_class.return_value.__aexit__ = AsyncMock()
             mock_client_class.return_value.__aexit__ = AsyncMock()
 
 
-            success, message = await service._send_webhook(
-                config, "Test", "Test"
-            )
+            success, message = await service._send_webhook(config, "Test", "Test")
 
 
             assert success is False
             assert success is False
             assert "Connection failed" in message or "error" in message.lower()
             assert "Connection failed" in message or "error" in message.lower()
@@ -686,16 +598,11 @@ class TestNotificationVariableFallbacks:
         """CRITICAL: Verify fallback values when archive_data is missing."""
         """CRITICAL: Verify fallback values when archive_data is missing."""
         mock_db = AsyncMock()
         mock_db = AsyncMock()
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ) as mock_send, \
-             patch.object(
-            service, '_build_message_from_template', new_callable=AsyncMock
-        ) as mock_build:
-
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
             mock_get.return_value = []  # No providers, just testing variable setup
             mock_get.return_value = []  # No providers, just testing variable setup
             mock_build.return_value = ("Test", "Test")
             mock_build.return_value = ("Test", "Test")
 
 
@@ -721,16 +628,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             captured_variables.update(variables)
             return ("Test", "Test")
             return ("Test", "Test")
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
         ):
         ):
-
             mock_get.return_value = []
             mock_get.return_value = []
 
 
             await service.on_print_complete(
             await service.on_print_complete(
@@ -762,16 +664,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             captured_variables.update(variables)
             return ("Test", "Test")
             return ("Test", "Test")
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
         ):
         ):
-
             # Need at least one provider to trigger message building
             # Need at least one provider to trigger message building
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
 
 
@@ -801,16 +698,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             captured_variables.update(variables)
             return ("Test", "Test")
             return ("Test", "Test")
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
         ):
         ):
-
             # Need at least one provider to trigger message building
             # Need at least one provider to trigger message building
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
 
 
@@ -839,16 +731,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             captured_variables.update(variables)
             return ("Test", "Test")
             return ("Test", "Test")
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
         ):
         ):
-
             # Need at least one provider to trigger message building
             # Need at least one provider to trigger message building
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
 
 
@@ -876,16 +763,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             captured_variables.update(variables)
             return ("Test", "Test")
             return ("Test", "Test")
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
         ):
         ):
-
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
 
 
             # Pass archive_data with print_time_seconds (7200 seconds = 2 hours)
             # Pass archive_data with print_time_seconds (7200 seconds = 2 hours)
@@ -913,16 +795,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             captured_variables.update(variables)
             return ("Test", "Test")
             return ("Test", "Test")
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
         ):
         ):
-
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
 
 
             # Both archive_data and MQTT remaining_time provided
             # Both archive_data and MQTT remaining_time provided
@@ -954,16 +831,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             captured_variables.update(variables)
             return ("Test", "Test")
             return ("Test", "Test")
 
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
         ):
         ):
-
             mock_get.return_value = [mock_provider]
             mock_get.return_value = [mock_provider]
 
 
             # Only MQTT remaining_time provided (1800 seconds = 30 minutes)
             # Only MQTT remaining_time provided (1800 seconds = 30 minutes)
@@ -1018,9 +890,7 @@ class TestNotificationTemplates:
 
 
         # Should handle gracefully - either leave placeholder or skip
         # Should handle gracefully - either leave placeholder or skip
         try:
         try:
-            result = template.format_map(
-                {**variables, "unknown_var": "{unknown_var}"}
-            )
+            result = template.format_map({**variables, "unknown_var": "{unknown_var}"})
             assert "Test" in result
             assert "Test" in result
         except KeyError:
         except KeyError:
             pytest.fail("Template should handle missing variables gracefully")
             pytest.fail("Template should handle missing variables gracefully")

+ 100 - 180
backend/tests/unit/services/test_smart_plug_manager.py

@@ -4,11 +4,12 @@ These tests specifically target the auto-off behavior and toggle functionality
 that were identified as common regression points.
 that were identified as common regression points.
 """
 """
 
 
-import pytest
 import asyncio
 import asyncio
 from datetime import datetime
 from datetime import datetime
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
+import pytest
+
 from backend.app.services.smart_plug_manager import SmartPlugManager
 from backend.app.services.smart_plug_manager import SmartPlugManager
 
 
 
 
@@ -57,11 +58,10 @@ class TestSmartPlugManager:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
     async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
         """Verify plug is turned ON when print starts with auto_on enabled."""
         """Verify plug is turned ON when print starts with auto_on enabled."""
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
             mock_tasmota.turn_on = AsyncMock(return_value=True)
             mock_tasmota.turn_on = AsyncMock(return_value=True)
 
 
@@ -70,17 +70,14 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_called_once_with(mock_plug)
             mock_tasmota.turn_on.assert_called_once_with(mock_plug)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_start_skipped_when_auto_on_disabled(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_start_skipped_when_auto_on_disabled(self, manager, mock_plug, mock_db):
         """Verify plug is NOT turned on when auto_on is disabled."""
         """Verify plug is NOT turned on when auto_on is disabled."""
         mock_plug.auto_on = False
         mock_plug.auto_on = False
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
             mock_tasmota.turn_on = AsyncMock()
             mock_tasmota.turn_on = AsyncMock()
 
 
@@ -89,17 +86,14 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_not_called()
             mock_tasmota.turn_on.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_start_skipped_when_plug_disabled(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_start_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
         """Verify plug is NOT turned on when plug.enabled is False."""
         """Verify plug is NOT turned on when plug.enabled is False."""
         mock_plug.enabled = False
         mock_plug.enabled = False
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
             mock_tasmota.turn_on = AsyncMock()
             mock_tasmota.turn_on = AsyncMock()
 
 
@@ -108,15 +102,12 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_not_called()
             mock_tasmota.turn_on.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_start_skipped_when_no_plug_found(
-        self, manager, mock_db
-    ):
+    async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db):
         """Verify graceful handling when no plug is linked to printer."""
         """Verify graceful handling when no plug is linked to printer."""
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             mock_get_plug.return_value = None
             mock_get_plug.return_value = None
             mock_tasmota.turn_on = AsyncMock()
             mock_tasmota.turn_on = AsyncMock()
 
 
@@ -126,22 +117,17 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_not_called()
             mock_tasmota.turn_on.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_start_cancels_pending_off(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_start_cancels_pending_off(self, manager, mock_plug, mock_db):
         """Verify starting a new print cancels any pending auto-off."""
         """Verify starting a new print cancels any pending auto-off."""
         # Set up a pending task
         # Set up a pending task
         mock_task = MagicMock()
         mock_task = MagicMock()
         manager._pending_off[mock_plug.id] = mock_task
         manager._pending_off[mock_plug.id] = mock_task
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch.object(
-            manager, '_mark_auto_off_pending', new_callable=AsyncMock
-        ), \
-             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
             mock_tasmota.turn_on = AsyncMock(return_value=True)
             mock_tasmota.turn_on = AsyncMock(return_value=True)
 
 
@@ -151,17 +137,14 @@ class TestSmartPlugManager:
             assert mock_plug.id not in manager._pending_off
             assert mock_plug.id not in manager._pending_off
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_start_resets_auto_off_executed_flag(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_start_resets_auto_off_executed_flag(self, manager, mock_plug, mock_db):
         """Verify auto_off_executed flag is reset when turning on."""
         """Verify auto_off_executed flag is reset when turning on."""
         mock_plug.auto_off_executed = True
         mock_plug.auto_off_executed = True
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
             mock_tasmota.turn_on = AsyncMock(return_value=True)
             mock_tasmota.turn_on = AsyncMock(return_value=True)
 
 
@@ -174,125 +157,95 @@ class TestSmartPlugManager:
     # ========================================================================
     # ========================================================================
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_schedules_time_based_off(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_complete_schedules_time_based_off(self, manager, mock_plug, mock_db):
         """Verify time-based auto-off is scheduled when print completes."""
         """Verify time-based auto-off is scheduled when print completes."""
         mock_plug.off_delay_mode = "time"
         mock_plug.off_delay_mode = "time"
         mock_plug.off_delay_minutes = 5
         mock_plug.off_delay_minutes = 5
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
 
 
-            await manager.on_print_complete(
-                printer_id=1, status="completed", db=mock_db
-            )
+            await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
 
             mock_schedule.assert_called_once_with(mock_plug, 1, 300)  # 5 min * 60 sec
             mock_schedule.assert_called_once_with(mock_plug, 1, 300)  # 5 min * 60 sec
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_schedules_temp_based_off(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_complete_schedules_temp_based_off(self, manager, mock_plug, mock_db):
         """Verify temperature-based auto-off is scheduled when print completes."""
         """Verify temperature-based auto-off is scheduled when print completes."""
         mock_plug.off_delay_mode = "temperature"
         mock_plug.off_delay_mode = "temperature"
         mock_plug.off_temp_threshold = 70
         mock_plug.off_temp_threshold = 70
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch.object(manager, '_schedule_temp_based_off') as mock_schedule:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
 
 
-            await manager.on_print_complete(
-                printer_id=1, status="completed", db=mock_db
-            )
+            await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
 
             mock_schedule.assert_called_once_with(mock_plug, 1, 70)
             mock_schedule.assert_called_once_with(mock_plug, 1, 70)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_skipped_when_auto_off_disabled(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_complete_skipped_when_auto_off_disabled(self, manager, mock_plug, mock_db):
         """CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
         """CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
 
 
         This is a key regression test - the toggle must respect the setting.
         This is a key regression test - the toggle must respect the setting.
         """
         """
         mock_plug.auto_off = False
         mock_plug.auto_off = False
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch.object(manager, '_schedule_delayed_off') as mock_schedule, \
-             patch.object(manager, '_schedule_temp_based_off') as mock_temp:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+            patch.object(manager, "_schedule_temp_based_off") as mock_temp,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
 
 
-            await manager.on_print_complete(
-                printer_id=1, status="completed", db=mock_db
-            )
+            await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
 
             mock_schedule.assert_not_called()
             mock_schedule.assert_not_called()
             mock_temp.assert_not_called()
             mock_temp.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_skipped_when_plug_disabled(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
         """Verify auto-off does NOT trigger when plug is disabled."""
         """Verify auto-off does NOT trigger when plug is disabled."""
         mock_plug.enabled = False
         mock_plug.enabled = False
 
 
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
 
 
-            await manager.on_print_complete(
-                printer_id=1, status="completed", db=mock_db
-            )
+            await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
 
 
             mock_schedule.assert_not_called()
             mock_schedule.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_skipped_on_failed_print(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db):
         """Verify auto-off does NOT trigger on failed prints for investigation."""
         """Verify auto-off does NOT trigger on failed prints for investigation."""
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
 
 
-            await manager.on_print_complete(
-                printer_id=1, status="failed", db=mock_db
-            )
+            await manager.on_print_complete(printer_id=1, status="failed", db=mock_db)
 
 
             mock_schedule.assert_not_called()
             mock_schedule.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_on_print_complete_skipped_on_aborted_print(
-        self, manager, mock_plug, mock_db
-    ):
+    async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db):
         """Verify auto-off does NOT trigger on aborted prints."""
         """Verify auto-off does NOT trigger on aborted prints."""
-        with patch.object(
-            manager, '_get_plug_for_printer', new_callable=AsyncMock
-        ) as mock_get_plug, \
-             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
-
+        with (
+            patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
             mock_get_plug.return_value = mock_plug
             mock_get_plug.return_value = mock_plug
 
 
-            await manager.on_print_complete(
-                printer_id=1, status="aborted", db=mock_db
-            )
+            await manager.on_print_complete(printer_id=1, status="aborted", db=mock_db)
 
 
             mock_schedule.assert_not_called()
             mock_schedule.assert_not_called()
 
 
@@ -306,9 +259,7 @@ class TestSmartPlugManager:
         mock_task = MagicMock()
         mock_task = MagicMock()
         manager._pending_off[mock_plug.id] = mock_task
         manager._pending_off[mock_plug.id] = mock_task
 
 
-        with patch.object(
-            manager, '_mark_auto_off_pending', new_callable=AsyncMock
-        ):
+        with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
             manager._cancel_pending_off(mock_plug.id)
             manager._cancel_pending_off(mock_plug.id)
 
 
         assert mock_plug.id not in manager._pending_off
         assert mock_plug.id not in manager._pending_off
@@ -318,9 +269,7 @@ class TestSmartPlugManager:
     async def test_cancel_pending_off_handles_missing_task(self, manager):
     async def test_cancel_pending_off_handles_missing_task(self, manager):
         """Verify no error when cancelling non-existent task."""
         """Verify no error when cancelling non-existent task."""
         # Should not raise any exception
         # Should not raise any exception
-        with patch.object(
-            manager, '_mark_auto_off_pending', new_callable=AsyncMock
-        ):
+        with patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock):
             manager._cancel_pending_off(999)  # Non-existent plug ID
             manager._cancel_pending_off(999)  # Non-existent plug ID
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -331,7 +280,7 @@ class TestSmartPlugManager:
         manager._pending_off[1] = mock_task1
         manager._pending_off[1] = mock_task1
         manager._pending_off[2] = mock_task2
         manager._pending_off[2] = mock_task2
 
 
-        with patch('asyncio.create_task') as mock_create:
+        with patch("asyncio.create_task"):
             manager.cancel_all_pending()
             manager.cancel_all_pending()
 
 
         assert len(manager._pending_off) == 0
         assert len(manager._pending_off) == 0
@@ -347,8 +296,7 @@ class TestSmartPlugManager:
         assert manager._scheduler_task is None
         assert manager._scheduler_task is None
 
 
         # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
         # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
-        with patch.object(manager, '_schedule_loop') as mock_loop, \
-             patch('asyncio.create_task') as mock_create:
+        with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
             mock_create.return_value = MagicMock()
             mock_create.return_value = MagicMock()
             manager.start_scheduler()
             manager.start_scheduler()
 
 
@@ -371,8 +319,7 @@ class TestSmartPlugManager:
         manager._scheduler_task = mock_task
         manager._scheduler_task = mock_task
 
 
         # Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called)
         # Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called)
-        with patch.object(manager, '_schedule_loop') as mock_loop, \
-             patch('asyncio.create_task') as mock_create:
+        with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
             manager.start_scheduler()
             manager.start_scheduler()
 
 
             mock_create.assert_not_called()  # Should not create new task
             mock_create.assert_not_called()  # Should not create new task
@@ -399,16 +346,11 @@ class TestScheduleLoop:
         mock_plug.printer_id = None
         mock_plug.printer_id = None
         mock_plug.last_state = "OFF"
         mock_plug.last_state = "OFF"
 
 
-        with patch(
-            'backend.app.services.smart_plug_manager.datetime'
-        ) as mock_datetime, \
-             patch(
-            'backend.app.core.database.async_session'
-        ) as mock_session_ctx, \
-             patch(
-            'backend.app.services.smart_plug_manager.tasmota_service'
-        ) as mock_tasmota:
-
+        with (
+            patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             # Set current time to 08:00
             # Set current time to 08:00
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.strftime.return_value = "08:00"
             mock_now.strftime.return_value = "08:00"
@@ -444,19 +386,12 @@ class TestScheduleLoop:
         mock_plug.printer_id = 1
         mock_plug.printer_id = 1
         mock_plug.last_state = "ON"
         mock_plug.last_state = "ON"
 
 
-        with patch(
-            'backend.app.services.smart_plug_manager.datetime'
-        ) as mock_datetime, \
-             patch(
-            'backend.app.core.database.async_session'
-        ) as mock_session_ctx, \
-             patch(
-            'backend.app.services.smart_plug_manager.tasmota_service'
-        ) as mock_tasmota, \
-             patch(
-            'backend.app.services.smart_plug_manager.printer_manager'
-        ) as mock_pm:
-
+        with (
+            patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+        ):
             # Set current time to 22:00
             # Set current time to 22:00
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.strftime.return_value = "22:00"
             mock_now.strftime.return_value = "22:00"
@@ -488,16 +423,11 @@ class TestScheduleLoop:
         mock_plug.enabled = True
         mock_plug.enabled = True
         mock_plug.schedule_enabled = False  # Disabled
         mock_plug.schedule_enabled = False  # Disabled
 
 
-        with patch(
-            'backend.app.services.smart_plug_manager.datetime'
-        ) as mock_datetime, \
-             patch(
-            'backend.app.core.database.async_session'
-        ) as mock_session_ctx, \
-             patch(
-            'backend.app.services.smart_plug_manager.tasmota_service'
-        ) as mock_tasmota:
-
+        with (
+            patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.strftime.return_value = "08:00"
             mock_now.strftime.return_value = "08:00"
             mock_datetime.now.return_value = mock_now
             mock_datetime.now.return_value = mock_now
@@ -541,13 +471,10 @@ class TestPendingAutoOffPersistence:
         mock_plug.off_delay_mode = "temperature"
         mock_plug.off_delay_mode = "temperature"
         mock_plug.off_temp_threshold = 70
         mock_plug.off_temp_threshold = 70
 
 
-        with patch(
-            'backend.app.core.database.async_session'
-        ) as mock_session_ctx, \
-             patch.object(
-            manager, '_schedule_temp_based_off'
-        ) as mock_schedule:
-
+        with (
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch.object(manager, "_schedule_temp_based_off") as mock_schedule,
+        ):
             mock_db = AsyncMock()
             mock_db = AsyncMock()
             mock_result = MagicMock()
             mock_result = MagicMock()
             mock_result.scalars.return_value.all.return_value = [mock_plug]
             mock_result.scalars.return_value.all.return_value = [mock_plug]
@@ -574,19 +501,12 @@ class TestPendingAutoOffPersistence:
         mock_plug.auto_off_pending_since = datetime.utcnow()
         mock_plug.auto_off_pending_since = datetime.utcnow()
         mock_plug.off_delay_mode = "time"
         mock_plug.off_delay_mode = "time"
 
 
-        with patch(
-            'backend.app.core.database.async_session'
-        ) as mock_session_ctx, \
-             patch(
-            'backend.app.services.smart_plug_manager.tasmota_service'
-        ) as mock_tasmota, \
-             patch.object(
-            manager, '_mark_auto_off_executed', new_callable=AsyncMock
-        ) as mock_mark, \
-             patch(
-            'backend.app.services.smart_plug_manager.printer_manager'
-        ) as mock_pm:
-
+        with (
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark,
+            patch("backend.app.services.smart_plug_manager.printer_manager"),
+        ):
             mock_db = AsyncMock()
             mock_db = AsyncMock()
             mock_result = MagicMock()
             mock_result = MagicMock()
             mock_result.scalars.return_value.all.return_value = [mock_plug]
             mock_result.scalars.return_value.all.return_value = [mock_plug]

+ 30 - 75
backend/tests/unit/services/test_tasmota.py

@@ -3,9 +3,10 @@
 Tests smart plug HTTP communication and error handling.
 Tests smart plug HTTP communication and error handling.
 """
 """
 
 
-import pytest
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
+
 import httpx
 import httpx
+import pytest
 
 
 from backend.app.services.tasmota import TasmotaService
 from backend.app.services.tasmota import TasmotaService
 
 
@@ -39,9 +40,7 @@ class TestTasmotaService:
 
 
     def test_build_url_with_auth(self, service):
     def test_build_url_with_auth(self, service):
         """Verify URL includes credentials when provided."""
         """Verify URL includes credentials when provided."""
-        url = service._build_url(
-            "192.168.1.100", "Power On", username="admin", password="secret"
-        )
+        url = service._build_url("192.168.1.100", "Power On", username="admin", password="secret")
         assert url == "http://admin:secret@192.168.1.100/cm?cmnd=Power%20On"
         assert url == "http://admin:secret@192.168.1.100/cm?cmnd=Power%20On"
 
 
     def test_build_url_encodes_special_characters(self, service):
     def test_build_url_encodes_special_characters(self, service):
@@ -56,24 +55,18 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_turn_on_success(self, service, mock_plug):
     async def test_turn_on_success(self, service, mock_plug):
         """Verify turn_on returns True on success."""
         """Verify turn_on returns True on success."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = {"POWER": "ON"}
             mock_send.return_value = {"POWER": "ON"}
 
 
             result = await service.turn_on(mock_plug)
             result = await service.turn_on(mock_plug)
 
 
             assert result is True
             assert result is True
-            mock_send.assert_called_once_with(
-                "192.168.1.100", "Power On", None, None
-            )
+            mock_send.assert_called_once_with("192.168.1.100", "Power On", None, None)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_turn_on_failure(self, service, mock_plug):
     async def test_turn_on_failure(self, service, mock_plug):
         """Verify turn_on returns False on failure."""
         """Verify turn_on returns False on failure."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = None
             mock_send.return_value = None
 
 
             result = await service.turn_on(mock_plug)
             result = await service.turn_on(mock_plug)
@@ -86,16 +79,12 @@ class TestTasmotaService:
         mock_plug.username = "admin"
         mock_plug.username = "admin"
         mock_plug.password = "secret"
         mock_plug.password = "secret"
 
 
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = {"POWER": "ON"}
             mock_send.return_value = {"POWER": "ON"}
 
 
             await service.turn_on(mock_plug)
             await service.turn_on(mock_plug)
 
 
-            mock_send.assert_called_once_with(
-                "192.168.1.100", "Power On", "admin", "secret"
-            )
+            mock_send.assert_called_once_with("192.168.1.100", "Power On", "admin", "secret")
 
 
     # ========================================================================
     # ========================================================================
     # Tests for turn_off
     # Tests for turn_off
@@ -104,9 +93,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_turn_off_success(self, service, mock_plug):
     async def test_turn_off_success(self, service, mock_plug):
         """Verify turn_off returns True on success."""
         """Verify turn_off returns True on success."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = {"POWER": "OFF"}
             mock_send.return_value = {"POWER": "OFF"}
 
 
             result = await service.turn_off(mock_plug)
             result = await service.turn_off(mock_plug)
@@ -116,9 +103,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_turn_off_failure(self, service, mock_plug):
     async def test_turn_off_failure(self, service, mock_plug):
         """Verify turn_off returns False on failure."""
         """Verify turn_off returns False on failure."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = None
             mock_send.return_value = None
 
 
             result = await service.turn_off(mock_plug)
             result = await service.turn_off(mock_plug)
@@ -132,17 +117,13 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_toggle_success(self, service, mock_plug):
     async def test_toggle_success(self, service, mock_plug):
         """Verify toggle returns True on success."""
         """Verify toggle returns True on success."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = {"POWER": "ON"}
             mock_send.return_value = {"POWER": "ON"}
 
 
             result = await service.toggle(mock_plug)
             result = await service.toggle(mock_plug)
 
 
             assert result is True
             assert result is True
-            mock_send.assert_called_once_with(
-                "192.168.1.100", "Power Toggle", None, None
-            )
+            mock_send.assert_called_once_with("192.168.1.100", "Power Toggle", None, None)
 
 
     # ========================================================================
     # ========================================================================
     # Tests for get_status
     # Tests for get_status
@@ -151,9 +132,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_status_returns_on(self, service, mock_plug):
     async def test_get_status_returns_on(self, service, mock_plug):
         """Verify get_status returns correct state when ON."""
         """Verify get_status returns correct state when ON."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             # Tasmota returns {"POWER": "ON"} for Power command
             # Tasmota returns {"POWER": "ON"} for Power command
             mock_send.return_value = {"POWER": "ON"}
             mock_send.return_value = {"POWER": "ON"}
 
 
@@ -166,9 +145,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_status_returns_off(self, service, mock_plug):
     async def test_get_status_returns_off(self, service, mock_plug):
         """Verify get_status returns correct state when OFF."""
         """Verify get_status returns correct state when OFF."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             # Tasmota returns {"POWER": "OFF"} for Power command
             # Tasmota returns {"POWER": "OFF"} for Power command
             mock_send.return_value = {"POWER": "OFF"}
             mock_send.return_value = {"POWER": "OFF"}
 
 
@@ -180,9 +157,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_status_unreachable(self, service, mock_plug):
     async def test_get_status_unreachable(self, service, mock_plug):
         """Verify get_status handles unreachable device."""
         """Verify get_status handles unreachable device."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = None
             mock_send.return_value = None
 
 
             result = await service.get_status(mock_plug)
             result = await service.get_status(mock_plug)
@@ -197,9 +172,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_energy_returns_data(self, service, mock_plug):
     async def test_get_energy_returns_data(self, service, mock_plug):
         """Verify get_energy parses energy data correctly."""
         """Verify get_energy parses energy data correctly."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = {
             mock_send.return_value = {
                 "StatusSNS": {
                 "StatusSNS": {
                     "ENERGY": {
                     "ENERGY": {
@@ -226,9 +199,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_energy_handles_missing_data(self, service, mock_plug):
     async def test_get_energy_handles_missing_data(self, service, mock_plug):
         """Verify get_energy handles devices without energy monitoring."""
         """Verify get_energy handles devices without energy monitoring."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = {"StatusSNS": {}}
             mock_send.return_value = {"StatusSNS": {}}
 
 
             result = await service.get_energy(mock_plug)
             result = await service.get_energy(mock_plug)
@@ -238,9 +209,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_energy_handles_unreachable(self, service, mock_plug):
     async def test_get_energy_handles_unreachable(self, service, mock_plug):
         """Verify get_energy handles unreachable device."""
         """Verify get_energy handles unreachable device."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = None
             mock_send.return_value = None
 
 
             result = await service.get_energy(mock_plug)
             result = await service.get_energy(mock_plug)
@@ -250,9 +219,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_energy_handles_partial_data(self, service, mock_plug):
     async def test_get_energy_handles_partial_data(self, service, mock_plug):
         """Verify get_energy handles partial energy data."""
         """Verify get_energy handles partial energy data."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = {
             mock_send.return_value = {
                 "StatusSNS": {
                 "StatusSNS": {
                     "ENERGY": {
                     "ENERGY": {
@@ -276,13 +243,11 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_test_connection_success(self, service):
     async def test_test_connection_success(self, service):
         """Verify test_connection returns success on reachable device."""
         """Verify test_connection returns success on reachable device."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             # First call (Power) returns state, second call (Status 0) returns device info
             # First call (Power) returns state, second call (Status 0) returns device info
             mock_send.side_effect = [
             mock_send.side_effect = [
                 {"POWER": "ON"},  # Power command response
                 {"POWER": "ON"},  # Power command response
-                {"Status": {"DeviceName": "Test Plug"}}  # Status 0 response
+                {"Status": {"DeviceName": "Test Plug"}},  # Status 0 response
             ]
             ]
 
 
             result = await service.test_connection("192.168.1.100")
             result = await service.test_connection("192.168.1.100")
@@ -294,9 +259,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_test_connection_failure(self, service):
     async def test_test_connection_failure(self, service):
         """Verify test_connection returns failure on unreachable device."""
         """Verify test_connection returns failure on unreachable device."""
-        with patch.object(
-            service, '_send_command', new_callable=AsyncMock
-        ) as mock_send:
+        with patch.object(service, "_send_command", new_callable=AsyncMock) as mock_send:
             mock_send.return_value = None
             mock_send.return_value = None
 
 
             result = await service.test_connection("192.168.1.100")
             result = await service.test_connection("192.168.1.100")
@@ -310,12 +273,10 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_send_command_handles_timeout(self, service):
     async def test_send_command_handles_timeout(self, service):
         """Verify timeout is handled gracefully."""
         """Verify timeout is handled gracefully."""
-        with patch('httpx.AsyncClient') as mock_client_class:
+        with patch("httpx.AsyncClient") as mock_client_class:
             mock_client = AsyncMock()
             mock_client = AsyncMock()
             mock_client.get.side_effect = httpx.TimeoutException("Timeout")
             mock_client.get.side_effect = httpx.TimeoutException("Timeout")
-            mock_client_class.return_value.__aenter__ = AsyncMock(
-                return_value=mock_client
-            )
+            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
             mock_client_class.return_value.__aexit__ = AsyncMock()
             mock_client_class.return_value.__aexit__ = AsyncMock()
 
 
             result = await service._send_command("192.168.1.100", "Power")
             result = await service._send_command("192.168.1.100", "Power")
@@ -325,12 +286,10 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_send_command_handles_connection_error(self, service):
     async def test_send_command_handles_connection_error(self, service):
         """Verify connection error is handled gracefully."""
         """Verify connection error is handled gracefully."""
-        with patch('httpx.AsyncClient') as mock_client_class:
+        with patch("httpx.AsyncClient") as mock_client_class:
             mock_client = AsyncMock()
             mock_client = AsyncMock()
             mock_client.get.side_effect = httpx.ConnectError("Connection refused")
             mock_client.get.side_effect = httpx.ConnectError("Connection refused")
-            mock_client_class.return_value.__aenter__ = AsyncMock(
-                return_value=mock_client
-            )
+            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
             mock_client_class.return_value.__aexit__ = AsyncMock()
             mock_client_class.return_value.__aexit__ = AsyncMock()
 
 
             result = await service._send_command("192.168.1.100", "Power")
             result = await service._send_command("192.168.1.100", "Power")
@@ -340,14 +299,12 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_send_command_handles_invalid_json(self, service):
     async def test_send_command_handles_invalid_json(self, service):
         """Verify invalid JSON response is handled gracefully."""
         """Verify invalid JSON response is handled gracefully."""
-        with patch('httpx.AsyncClient') as mock_client_class:
+        with patch("httpx.AsyncClient") as mock_client_class:
             mock_client = AsyncMock()
             mock_client = AsyncMock()
             mock_response = MagicMock()
             mock_response = MagicMock()
             mock_response.json.side_effect = ValueError("Invalid JSON")
             mock_response.json.side_effect = ValueError("Invalid JSON")
             mock_client.get.return_value = mock_response
             mock_client.get.return_value = mock_response
-            mock_client_class.return_value.__aenter__ = AsyncMock(
-                return_value=mock_client
-            )
+            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
             mock_client_class.return_value.__aexit__ = AsyncMock()
             mock_client_class.return_value.__aexit__ = AsyncMock()
 
 
             result = await service._send_command("192.168.1.100", "Power")
             result = await service._send_command("192.168.1.100", "Power")
@@ -357,14 +314,12 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_send_command_success(self, service):
     async def test_send_command_success(self, service):
         """Verify successful command returns parsed JSON."""
         """Verify successful command returns parsed JSON."""
-        with patch('httpx.AsyncClient') as mock_client_class:
+        with patch("httpx.AsyncClient") as mock_client_class:
             mock_client = AsyncMock()
             mock_client = AsyncMock()
             mock_response = MagicMock()
             mock_response = MagicMock()
             mock_response.json.return_value = {"POWER": "ON"}
             mock_response.json.return_value = {"POWER": "ON"}
             mock_client.get.return_value = mock_response
             mock_client.get.return_value = mock_response
-            mock_client_class.return_value.__aenter__ = AsyncMock(
-                return_value=mock_client
-            )
+            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
             mock_client_class.return_value.__aexit__ = AsyncMock()
             mock_client_class.return_value.__aexit__ = AsyncMock()
 
 
             result = await service._send_command("192.168.1.100", "Power")
             result = await service._send_command("192.168.1.100", "Power")

+ 18 - 16
backend/tests/unit/services/test_telemetry.py

@@ -3,20 +3,21 @@
 Tests the anonymous telemetry/stats collection functionality.
 Tests the anonymous telemetry/stats collection functionality.
 """
 """
 
 
-import pytest
-from unittest.mock import patch, AsyncMock, MagicMock
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
 
 
+from backend.app.models.settings import Settings
 from backend.app.services.telemetry import (
 from backend.app.services.telemetry import (
-    get_or_create_installation_id,
-    is_telemetry_enabled,
-    get_telemetry_url,
-    send_heartbeat,
     DEFAULT_TELEMETRY_URL,
     DEFAULT_TELEMETRY_URL,
     HEARTBEAT_INTERVAL,
     HEARTBEAT_INTERVAL,
     _last_heartbeat,
     _last_heartbeat,
+    get_or_create_installation_id,
+    get_telemetry_url,
+    is_telemetry_enabled,
+    send_heartbeat,
 )
 )
-from backend.app.models.settings import Settings
 
 
 
 
 class TestTelemetryService:
 class TestTelemetryService:
@@ -134,7 +135,7 @@ class TestTelemetryService:
         db_session.add(setting)
         db_session.add(setting)
         await db_session.commit()
         await db_session.commit()
 
 
-        with patch('httpx.AsyncClient') as mock_client:
+        with patch("httpx.AsyncClient") as mock_client:
             result = await send_heartbeat(db_session)
             result = await send_heartbeat(db_session)
 
 
         assert result is False
         assert result is False
@@ -145,6 +146,7 @@ class TestTelemetryService:
         """Verify heartbeat is sent successfully when enabled."""
         """Verify heartbeat is sent successfully when enabled."""
         # Reset the last heartbeat to allow sending
         # Reset the last heartbeat to allow sending
         import backend.app.services.telemetry as telemetry_module
         import backend.app.services.telemetry as telemetry_module
+
         telemetry_module._last_heartbeat = None
         telemetry_module._last_heartbeat = None
 
 
         result = await send_heartbeat(db_session)
         result = await send_heartbeat(db_session)
@@ -159,7 +161,7 @@ class TestTelemetryService:
         # Set last heartbeat to recent time
         # Set last heartbeat to recent time
         telemetry_module._last_heartbeat = datetime.now()
         telemetry_module._last_heartbeat = datetime.now()
 
 
-        with patch('httpx.AsyncClient') as mock_client:
+        with patch("httpx.AsyncClient") as mock_client:
             result = await send_heartbeat(db_session)
             result = await send_heartbeat(db_session)
 
 
         # Should return True (already sent) without making HTTP request
         # Should return True (already sent) without making HTTP request
@@ -193,14 +195,14 @@ class TestTelemetryService:
 
 
         captured_data = {}
         captured_data = {}
 
 
-        with patch('httpx.AsyncClient') as mock_class:
+        with patch("httpx.AsyncClient") as mock_class:
             mock_instance = AsyncMock()
             mock_instance = AsyncMock()
             mock_response = MagicMock()
             mock_response = MagicMock()
             mock_response.raise_for_status = MagicMock()
             mock_response.raise_for_status = MagicMock()
 
 
             async def capture_post(url, json=None):
             async def capture_post(url, json=None):
-                captured_data['url'] = url
-                captured_data['json'] = json
+                captured_data["url"] = url
+                captured_data["json"] = json
                 return mock_response
                 return mock_response
 
 
             mock_instance.post = capture_post
             mock_instance.post = capture_post
@@ -210,9 +212,9 @@ class TestTelemetryService:
 
 
             await send_heartbeat(db_session)
             await send_heartbeat(db_session)
 
 
-        assert "heartbeat" in captured_data['url']
-        assert "installation_id" in captured_data['json']
-        assert captured_data['json']['version'] == APP_VERSION
+        assert "heartbeat" in captured_data["url"]
+        assert "installation_id" in captured_data["json"]
+        assert captured_data["json"]["version"] == APP_VERSION
 
 
 
 
 class TestHeartbeatInterval:
 class TestHeartbeatInterval:
@@ -220,7 +222,7 @@ class TestHeartbeatInterval:
 
 
     def test_heartbeat_interval_is_24_hours(self):
     def test_heartbeat_interval_is_24_hours(self):
         """Verify heartbeat interval is set to 24 hours."""
         """Verify heartbeat interval is set to 24 hours."""
-        assert HEARTBEAT_INTERVAL == timedelta(hours=24)
+        assert timedelta(hours=24) == HEARTBEAT_INTERVAL
 
 
     def test_default_telemetry_url(self):
     def test_default_telemetry_url(self):
         """Verify default telemetry URL is correct."""
         """Verify default telemetry URL is correct."""

+ 52 - 1
backend/tests/unit/services/test_virtual_printer.py

@@ -59,6 +59,54 @@ class TestVirtualPrinterManager:
         with pytest.raises(ValueError, match="Access code is required"):
         with pytest.raises(ValueError, match="Access code is required"):
             await manager.configure(enabled=True)
             await manager.configure(enabled=True)
 
 
+    @pytest.mark.asyncio
+    async def test_configure_sets_model(self, manager):
+        """Verify configure stores model correctly."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            mode="immediate",
+            model="C11",  # P1S model code
+        )
+
+        assert manager._model == "C11"
+
+    @pytest.mark.asyncio
+    async def test_configure_ignores_invalid_model(self, manager):
+        """Verify configure ignores invalid model codes."""
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            model="INVALID",
+        )
+
+        # Should keep default model (3DPrinter-X1-Carbon = X1C)
+        assert manager._model == "3DPrinter-X1-Carbon"
+
+    @pytest.mark.asyncio
+    async def test_configure_restarts_on_model_change(self, manager):
+        """Verify model change restarts services when running."""
+        # Simulate running state
+        manager._enabled = True
+        manager._model = "3DPrinter-X1-Carbon"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+        manager._stop = AsyncMock()
+        manager._start = AsyncMock()
+
+        await manager.configure(
+            enabled=True,
+            access_code="12345678",
+            model="C11",  # P1P
+        )
+
+        # Should have stopped and started
+        manager._stop.assert_called_once()
+        manager._start.assert_called_once()
+
     # ========================================================================
     # ========================================================================
     # Tests for status
     # Tests for status
     # ========================================================================
     # ========================================================================
@@ -67,6 +115,7 @@ class TestVirtualPrinterManager:
         """Verify get_status returns expected fields."""
         """Verify get_status returns expected fields."""
         manager._enabled = True
         manager._enabled = True
         manager._mode = "immediate"
         manager._mode = "immediate"
+        manager._model = "C11"  # P1P
         manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
         manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
         # Simulate running tasks
         # Simulate running tasks
         manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
         manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
@@ -77,7 +126,9 @@ class TestVirtualPrinterManager:
         assert status["running"] is True
         assert status["running"] is True
         assert status["mode"] == "immediate"
         assert status["mode"] == "immediate"
         assert status["name"] == "Bambuddy"
         assert status["name"] == "Bambuddy"
-        assert status["serial"] == "00M09A391800001"
+        assert status["serial"] == "01S00A391800001"  # C11 (P1P) serial prefix
+        assert status["model"] == "C11"
+        assert status["model_name"] == "P1P"
         assert status["pending_files"] == 1
         assert status["pending_files"] == 1
 
 
     def test_get_status_when_stopped(self, manager):
     def test_get_status_when_stopped(self, manager):

+ 22 - 14
backend/tests/unit/test_code_quality.py

@@ -7,9 +7,9 @@ that could cause runtime errors but aren't caught by normal tests.
 
 
 import ast
 import ast
 import os
 import os
-import pytest
 from pathlib import Path
 from pathlib import Path
 
 
+import pytest
 
 
 # Get the backend source directory
 # Get the backend source directory
 BACKEND_DIR = Path(__file__).parent.parent.parent / "app"
 BACKEND_DIR = Path(__file__).parent.parent.parent / "app"
@@ -18,8 +18,22 @@ BACKEND_DIR = Path(__file__).parent.parent.parent / "app"
 # Safe imports that are commonly re-imported in functions without issues
 # Safe imports that are commonly re-imported in functions without issues
 # These are typically imported at the START of a function, not midway through
 # These are typically imported at the START of a function, not midway through
 SAFE_REIMPORT_NAMES = {
 SAFE_REIMPORT_NAMES = {
-    'logging', 're', 'os', 'sys', 'json', 'Path', 'datetime', 'timedelta',
-    'asyncio', 'time', 'typing', 'Optional', 'List', 'Dict', 'Any', 'Union',
+    "logging",
+    "re",
+    "os",
+    "sys",
+    "json",
+    "Path",
+    "datetime",
+    "timedelta",
+    "asyncio",
+    "time",
+    "typing",
+    "Optional",
+    "List",
+    "Dict",
+    "Any",
+    "Union",
 }
 }
 
 
 
 
@@ -68,16 +82,10 @@ class DangerousImportVisitor(ast.NodeVisitor):
         for child in ast.walk(node):
         for child in ast.walk(node):
             # Find local imports
             # Find local imports
             if isinstance(child, (ast.Import, ast.ImportFrom)):
             if isinstance(child, (ast.Import, ast.ImportFrom)):
-                if isinstance(child, ast.Import):
-                    for alias in child.names:
-                        name = alias.asname or alias.name
-                        if name in self.module_imports and name not in SAFE_REIMPORT_NAMES:
-                            local_imports[name] = child.lineno
-                elif isinstance(child, ast.ImportFrom):
-                    for alias in child.names:
-                        name = alias.asname or alias.name
-                        if name in self.module_imports and name not in SAFE_REIMPORT_NAMES:
-                            local_imports[name] = child.lineno
+                for alias in child.names:
+                    name = alias.asname or alias.name
+                    if name in self.module_imports and name not in SAFE_REIMPORT_NAMES:
+                        local_imports[name] = child.lineno
 
 
             # Find name uses
             # Find name uses
             if isinstance(child, ast.Name):
             if isinstance(child, ast.Name):
@@ -130,7 +138,7 @@ def find_import_shadowing(file_path: Path) -> list[tuple[str, int, str]]:
     Returns list of (name, line_number, function_name) tuples.
     Returns list of (name, line_number, function_name) tuples.
     """
     """
     try:
     try:
-        with open(file_path, 'r') as f:
+        with open(file_path) as f:
             source = f.read()
             source = f.read()
         tree = ast.parse(source)
         tree = ast.parse(source)
         visitor = DangerousImportVisitor()
         visitor = DangerousImportVisitor()

+ 78 - 68
backend/tests/unit/test_log_error_detection.py

@@ -5,9 +5,10 @@ These tests use the capture_logs fixture to detect runtime errors
 that might not cause test failures but indicate problems.
 that might not cause test failures but indicate problems.
 """
 """
 
 
-import pytest
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
+import pytest
+
 
 
 class TestMQTTMessageProcessingNoErrors:
 class TestMQTTMessageProcessingNoErrors:
     """Verify MQTT message processing doesn't log errors."""
     """Verify MQTT message processing doesn't log errors."""
@@ -39,8 +40,7 @@ class TestMQTTMessageProcessingNoErrors:
 
 
         client._process_message(message)
         client._process_message(message)
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors during message processing: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors during message processing: {capture_logs.format_errors()}"
 
 
     def test_process_xcam_data(self, capture_logs):
     def test_process_xcam_data(self, capture_logs):
         """Test processing xcam (camera/AI) data."""
         """Test processing xcam (camera/AI) data."""
@@ -66,8 +66,7 @@ class TestMQTTMessageProcessingNoErrors:
 
 
         client._process_message(message)
         client._process_message(message)
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors during xcam processing: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors during xcam processing: {capture_logs.format_errors()}"
 
 
     def test_process_ams_data(self, capture_logs):
     def test_process_ams_data(self, capture_logs):
         """Test processing AMS (Automatic Material System) data."""
         """Test processing AMS (Automatic Material System) data."""
@@ -94,7 +93,7 @@ class TestMQTTMessageProcessingNoErrors:
                                     "tray_color": "FF0000",
                                     "tray_color": "FF0000",
                                     "remain": 80,
                                     "remain": 80,
                                 }
                                 }
-                            ]
+                            ],
                         }
                         }
                     ]
                     ]
                 }
                 }
@@ -103,8 +102,7 @@ class TestMQTTMessageProcessingNoErrors:
 
 
         client._process_message(message)
         client._process_message(message)
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors during AMS processing: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors during AMS processing: {capture_logs.format_errors()}"
 
 
     def test_process_hms_errors(self, capture_logs):
     def test_process_hms_errors(self, capture_logs):
         """Test processing HMS (Health Management System) errors."""
         """Test processing HMS (Health Management System) errors."""
@@ -129,8 +127,7 @@ class TestMQTTMessageProcessingNoErrors:
 
 
         client._process_message(message)
         client._process_message(message)
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors during HMS processing: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors during HMS processing: {capture_logs.format_errors()}"
 
 
 
 
 class TestPrintLifecycleNoErrors:
 class TestPrintLifecycleNoErrors:
@@ -149,36 +146,41 @@ class TestPrintLifecycleNoErrors:
         client.on_print_complete = lambda data: None
         client.on_print_complete = lambda data: None
 
 
         # Start print
         # Start print
-        client._process_message({
-            "print": {
-                "gcode_state": "RUNNING",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
-                "mc_percent": 0,
+        client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                    "mc_percent": 0,
+                }
             }
             }
-        })
+        )
 
 
         # Progress updates
         # Progress updates
         for percent in [25, 50, 75]:
         for percent in [25, 50, 75]:
-            client._process_message({
-                "print": {
-                    "gcode_state": "RUNNING",
-                    "gcode_file": "/data/Metadata/test.gcode",
-                    "mc_percent": percent,
+            client._process_message(
+                {
+                    "print": {
+                        "gcode_state": "RUNNING",
+                        "gcode_file": "/data/Metadata/test.gcode",
+                        "mc_percent": percent,
+                    }
                 }
                 }
-            })
+            )
 
 
         # Complete
         # Complete
-        client._process_message({
-            "print": {
-                "gcode_state": "FINISH",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
+        client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FINISH",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
             }
             }
-        })
+        )
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors during print lifecycle: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors during print lifecycle: {capture_logs.format_errors()}"
 
 
     def test_print_failure_handling(self, capture_logs):
     def test_print_failure_handling(self, capture_logs):
         """Test print failure is handled without errors."""
         """Test print failure is handled without errors."""
@@ -193,26 +195,29 @@ class TestPrintLifecycleNoErrors:
         client.on_print_complete = lambda data: None
         client.on_print_complete = lambda data: None
 
 
         # Start print
         # Start print
-        client._process_message({
-            "print": {
-                "gcode_state": "RUNNING",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
+        client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                }
             }
             }
-        })
+        )
 
 
         # Fail
         # Fail
-        client._process_message({
-            "print": {
-                "gcode_state": "FAILED",
-                "gcode_file": "/data/Metadata/test.gcode",
-                "subtask_name": "Test",
-                "print_error": 117506052,
+        client._process_message(
+            {
+                "print": {
+                    "gcode_state": "FAILED",
+                    "gcode_file": "/data/Metadata/test.gcode",
+                    "subtask_name": "Test",
+                    "print_error": 117506052,
+                }
             }
             }
-        })
+        )
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors during print failure: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors during print failure: {capture_logs.format_errors()}"
 
 
 
 
 class TestServiceImports:
 class TestServiceImports:
@@ -221,18 +226,21 @@ class TestServiceImports:
     def test_archive_service_import(self, capture_logs):
     def test_archive_service_import(self, capture_logs):
         """Verify ArchiveService can be imported without errors."""
         """Verify ArchiveService can be imported without errors."""
         from backend.app.services.archive import ArchiveService
         from backend.app.services.archive import ArchiveService
+
         assert ArchiveService is not None
         assert ArchiveService is not None
         assert not capture_logs.has_errors()
         assert not capture_logs.has_errors()
 
 
     def test_notification_service_import(self, capture_logs):
     def test_notification_service_import(self, capture_logs):
         """Verify NotificationService can be imported without errors."""
         """Verify NotificationService can be imported without errors."""
         from backend.app.services.notification_service import notification_service
         from backend.app.services.notification_service import notification_service
+
         assert notification_service is not None
         assert notification_service is not None
         assert not capture_logs.has_errors()
         assert not capture_logs.has_errors()
 
 
     def test_printer_manager_import(self, capture_logs):
     def test_printer_manager_import(self, capture_logs):
         """Verify PrinterManager can be imported without errors."""
         """Verify PrinterManager can be imported without errors."""
         from backend.app.services.printer_manager import printer_manager
         from backend.app.services.printer_manager import printer_manager
+
         assert printer_manager is not None
         assert printer_manager is not None
         assert not capture_logs.has_errors()
         assert not capture_logs.has_errors()
 
 
@@ -240,11 +248,12 @@ class TestServiceImports:
         """Verify main module imports cleanly."""
         """Verify main module imports cleanly."""
         # This will fail if there are import shadowing issues
         # This will fail if there are import shadowing issues
         from backend.app import main
         from backend.app import main
+
         assert main is not None
         assert main is not None
 
 
         # Verify key functions exist
         # Verify key functions exist
-        assert hasattr(main, 'on_print_start')
-        assert hasattr(main, 'on_print_complete')
+        assert hasattr(main, "on_print_start")
+        assert hasattr(main, "on_print_complete")
         assert not capture_logs.has_errors()
         assert not capture_logs.has_errors()
 
 
 
 
@@ -263,8 +272,7 @@ class TestEdgeCases:
 
 
         client._process_message({})
         client._process_message({})
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors with empty message: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors with empty message: {capture_logs.format_errors()}"
 
 
     def test_message_with_unknown_fields(self, capture_logs):
     def test_message_with_unknown_fields(self, capture_logs):
         """Test handling of message with unknown fields."""
         """Test handling of message with unknown fields."""
@@ -276,17 +284,18 @@ class TestEdgeCases:
             access_code="12345678",
             access_code="12345678",
         )
         )
 
 
-        client._process_message({
-            "print": {
-                "gcode_state": "RUNNING",
-                "unknown_field_1": "value1",
-                "unknown_field_2": 12345,
-                "unknown_nested": {"a": 1, "b": 2},
+        client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "unknown_field_1": "value1",
+                    "unknown_field_2": 12345,
+                    "unknown_nested": {"a": 1, "b": 2},
+                }
             }
             }
-        })
+        )
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors with unknown fields: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors with unknown fields: {capture_logs.format_errors()}"
 
 
     def test_message_with_null_values(self, capture_logs):
     def test_message_with_null_values(self, capture_logs):
         """Test handling of message with null values for optional fields."""
         """Test handling of message with null values for optional fields."""
@@ -300,14 +309,15 @@ class TestEdgeCases:
 
 
         # Only test null values for fields that should handle them gracefully
         # Only test null values for fields that should handle them gracefully
         # mc_percent is expected to be a number when present
         # mc_percent is expected to be a number when present
-        client._process_message({
-            "print": {
-                "gcode_state": "IDLE",
-                "gcode_file": None,
-                "subtask_name": None,
-                "bed_temper": 0.0,  # Use 0 instead of None
+        client._process_message(
+            {
+                "print": {
+                    "gcode_state": "IDLE",
+                    "gcode_file": None,
+                    "subtask_name": None,
+                    "bed_temper": 0.0,  # Use 0 instead of None
+                }
             }
             }
-        })
+        )
 
 
-        assert not capture_logs.has_errors(), \
-            f"Errors with null values: {capture_logs.format_errors()}"
+        assert not capture_logs.has_errors(), f"Errors with null values: {capture_logs.format_errors()}"

+ 4 - 0
demo-video/.gitignore

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

+ 50 - 0
demo-video/README.md

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

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

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

+ 15 - 0
demo-video/package.json

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

+ 566 - 0
demo-video/record-demo.ts

@@ -0,0 +1,566 @@
+import { chromium, Page, Browser, BrowserContext } from 'playwright';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// Configuration
+const CONFIG = {
+  baseUrl: process.env.DEMO_URL || 'http://localhost:8000',
+  headless: process.env.HEADLESS === 'true',
+  slowMo: 50, // Slow down actions for visibility
+  viewportWidth: 1920,
+  viewportHeight: 1080,
+  outputDir: path.join(__dirname, 'output'),
+};
+
+// Timing helpers (in ms)
+const TIMING = {
+  pageLoad: 1500,      // Wait after page navigation
+  shortPause: 500,     // Brief pause between actions
+  mediumPause: 1000,   // Standard pause for visibility
+  longPause: 2000,     // Longer pause for important features
+  modalOpen: 800,      // Wait for modal animations
+  scrollPause: 600,    // Pause after scrolling
+};
+
+async function wait(ms: number): Promise<void> {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function scrollDown(page: Page, pixels: number = 300): Promise<void> {
+  await page.mouse.wheel(0, pixels);
+  await wait(TIMING.scrollPause);
+}
+
+async function scrollToTop(page: Page): Promise<void> {
+  await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
+  await wait(TIMING.scrollPause);
+}
+
+async function hoverElement(page: Page, selector: string): Promise<void> {
+  const element = page.locator(selector).first();
+  if (await element.isVisible()) {
+    await element.hover();
+    await wait(TIMING.shortPause);
+  }
+}
+
+async function clickIfVisible(page: Page, selector: string): Promise<boolean> {
+  const element = page.locator(selector).first();
+  if (await element.isVisible()) {
+    await element.click();
+    return true;
+  }
+  return false;
+}
+
+async function closeModalIfOpen(page: Page): Promise<void> {
+  // Try to close any open modal by pressing Escape
+  await page.keyboard.press('Escape');
+  await wait(TIMING.shortPause);
+}
+
+async function blurSensitiveContent(page: Page): Promise<void> {
+  // Use JavaScript to find and blur email addresses
+  await page.evaluate(() => {
+    // Find all spans and check for email patterns
+    document.querySelectorAll('span').forEach(el => {
+      const text = el.textContent || '';
+      // Check if this specific element (not children) contains an email
+      if (el.childNodes.length === 1 && el.childNodes[0].nodeType === Node.TEXT_NODE) {
+        if (text.includes('@') && text.includes('.')) {
+          (el as HTMLElement).style.filter = 'blur(6px)';
+          (el as HTMLElement).style.userSelect = 'none';
+        }
+      }
+    });
+
+    // Also find "Connected as" text and blur the next sibling span
+    document.querySelectorAll('span').forEach(el => {
+      if (el.textContent?.includes('Connected as')) {
+        const emailSpan = el.querySelector('span');
+        if (emailSpan) {
+          (emailSpan as HTMLElement).style.filter = 'blur(6px)';
+          (emailSpan as HTMLElement).style.userSelect = 'none';
+        }
+      }
+    });
+  });
+}
+
+// ============================================================================
+// Page Scenarios
+// ============================================================================
+
+async function demoPrintersPage(page: Page): Promise<void> {
+  console.log('📷 Demonstrating Printers page...');
+  await page.goto(CONFIG.baseUrl);
+  await wait(TIMING.pageLoad);
+
+  // Hover over printer cards to show interactions
+  const printerCards = page.locator('.group').filter({ has: page.locator('img') });
+  const cardCount = await printerCards.count();
+  console.log(`   Found ${cardCount} printer cards`);
+
+  for (let i = 0; i < Math.min(cardCount, 2); i++) {
+    const card = printerCards.nth(i);
+    if (await card.isVisible()) {
+      await card.hover();
+      await wait(TIMING.mediumPause);
+
+      // Try clicking on card to expand/show details
+      await card.click();
+      await wait(TIMING.mediumPause);
+    }
+  }
+
+  // Look for AMS section and hover over slots
+  const amsSlots = page.locator('[class*="ams"], [class*="AMS"]').first();
+  if (await amsSlots.isVisible()) {
+    await amsSlots.hover();
+    await wait(TIMING.mediumPause);
+  }
+
+  // Try to open camera modal
+  const cameraIcon = page.locator('svg[class*="lucide-video"], button:has(svg)').first();
+  if (await cameraIcon.isVisible()) {
+    await cameraIcon.click();
+    await wait(TIMING.longPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Try to open MQTT debug modal
+  const debugButton = page.locator('button:has-text("Debug"), button:has-text("MQTT")').first();
+  if (await debugButton.isVisible()) {
+    await debugButton.click();
+    await wait(TIMING.longPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Scroll to show more printers
+  await scrollDown(page, 400);
+  await wait(TIMING.mediumPause);
+  await scrollToTop(page);
+}
+
+async function demoArchivesPage(page: Page): Promise<void> {
+  console.log('📁 Demonstrating Archives page...');
+  await page.goto(`${CONFIG.baseUrl}/archives`);
+  await wait(TIMING.pageLoad);
+
+  // Show view mode toggle (grid/list/calendar)
+  const viewToggle = page.locator('button:has(svg[class*="grid"]), button:has(svg[class*="list"])');
+  if (await viewToggle.first().isVisible()) {
+    await viewToggle.first().click();
+    await wait(TIMING.mediumPause);
+    await viewToggle.first().click(); // Toggle back
+    await wait(TIMING.shortPause);
+  }
+
+  // Use search
+  const searchInput = page.locator('input[placeholder*="Search"], input[type="search"]').first();
+  if (await searchInput.isVisible()) {
+    await searchInput.click();
+    await searchInput.fill('engine');
+    await wait(TIMING.longPause);
+    await searchInput.clear();
+    await wait(TIMING.shortPause);
+  }
+
+  // Show filter dropdowns
+  const filterButtons = page.locator('button:has-text("Printer"), button:has-text("Material"), button:has-text("Filter")');
+  if (await filterButtons.first().isVisible()) {
+    await filterButtons.first().click();
+    await wait(TIMING.mediumPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Right-click to show context menu
+  const archiveCard = page.locator('.group').filter({ has: page.locator('img') }).first();
+  if (await archiveCard.isVisible()) {
+    await archiveCard.click({ button: 'right' });
+    await wait(TIMING.longPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Click on archive to open edit modal
+  if (await archiveCard.isVisible()) {
+    await archiveCard.dblclick();
+    await wait(TIMING.longPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Scroll to show more archives
+  await scrollDown(page, 500);
+  await wait(TIMING.mediumPause);
+  await scrollToTop(page);
+}
+
+async function demoQueuePage(page: Page): Promise<void> {
+  console.log('📋 Demonstrating Queue page...');
+  await page.goto(`${CONFIG.baseUrl}/queue`);
+  await wait(TIMING.pageLoad);
+
+  // Show filter dropdowns
+  const printerFilter = page.locator('button:has-text("Printer"), select').first();
+  if (await printerFilter.isVisible()) {
+    await printerFilter.click();
+    await wait(TIMING.mediumPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Show sort controls
+  const sortButton = page.locator('button:has-text("Sort"), button:has(svg[class*="arrow"])').first();
+  if (await sortButton.isVisible()) {
+    await sortButton.click();
+    await wait(TIMING.mediumPause);
+  }
+
+  // Hover over queue items to show drag handles
+  const queueItems = page.locator('[draggable="true"], .group').first();
+  if (await queueItems.isVisible()) {
+    await queueItems.hover();
+    await wait(TIMING.mediumPause);
+  }
+
+  // Scroll through queue
+  await scrollDown(page, 300);
+  await wait(TIMING.mediumPause);
+  await scrollToTop(page);
+}
+
+async function demoStatsPage(page: Page): Promise<void> {
+  console.log('📊 Demonstrating Stats page...');
+  await page.goto(`${CONFIG.baseUrl}/stats`);
+  await wait(TIMING.pageLoad);
+
+  // Let charts animate
+  await wait(TIMING.longPause);
+
+  // Show export dropdown
+  const exportButton = page.locator('button:has-text("Export"), button:has(svg[class*="download"])').first();
+  if (await exportButton.isVisible()) {
+    await exportButton.click();
+    await wait(TIMING.mediumPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Scroll through stats widgets
+  await scrollDown(page, 400);
+  await wait(TIMING.mediumPause);
+  await scrollDown(page, 400);
+  await wait(TIMING.mediumPause);
+  await scrollDown(page, 400);
+  await wait(TIMING.mediumPause);
+  await scrollToTop(page);
+}
+
+async function demoProfilesPage(page: Page): Promise<void> {
+  console.log('⚙️ Demonstrating Profiles page...');
+
+  // Start blur loop BEFORE navigating
+  let blurring = true;
+  const blurLoop = async () => {
+    while (blurring) {
+      try {
+        await page.evaluate(() => {
+          document.querySelectorAll('span').forEach(el => {
+            if (el.textContent?.includes('Connected as')) {
+              const emailSpan = el.querySelector('span');
+              if (emailSpan) {
+                (emailSpan as HTMLElement).style.filter = 'blur(6px)';
+              }
+            }
+          });
+        });
+      } catch { /* page might be navigating */ }
+      await new Promise(r => setTimeout(r, 30));
+    }
+  };
+
+  // Start blur loop in background
+  const blurPromise = blurLoop();
+
+  await page.goto(`${CONFIG.baseUrl}/profiles`);
+  await wait(TIMING.pageLoad);
+
+  // Show Cloud Profiles section
+  await wait(TIMING.mediumPause);
+
+  // Click on K-Profiles tab if available
+  try {
+    const kProfilesTab = page.locator('button:has-text("K-Profile"), button:has-text("K Profile")').first();
+    if (await kProfilesTab.isVisible({ timeout: 1000 })) {
+      await kProfilesTab.click({ timeout: 2000 });
+      await wait(TIMING.mediumPause);
+      await scrollDown(page, 300);
+      await wait(TIMING.shortPause);
+      await scrollToTop(page);
+    }
+  } catch { /* skip */ }
+
+  // Click back to Cloud Profiles
+  try {
+    const cloudTab = page.locator('button:has-text("Cloud")').first();
+    if (await cloudTab.isVisible({ timeout: 1000 })) {
+      await cloudTab.click({ timeout: 2000 });
+      await wait(TIMING.mediumPause);
+    }
+  } catch { /* skip */ }
+
+  // Show preset filter types (if visible) - use force to bypass overlays
+  const presetFilters = page.locator('button:has-text("Filament"), button:has-text("Process"), button:has-text("Machine")');
+  for (let i = 0; i < 3; i++) {
+    try {
+      const filter = presetFilters.nth(i);
+      if (await filter.isVisible({ timeout: 1000 })) {
+        await filter.click({ force: true, timeout: 2000 });
+        await wait(TIMING.shortPause);
+      }
+    } catch { /* skip if not visible or blocked */ }
+  }
+
+  await scrollDown(page, 300);
+  await wait(TIMING.shortPause);
+  await scrollToTop(page);
+
+  // Stop blur loop
+  blurring = false;
+  await blurPromise;
+}
+
+async function demoMaintenancePage(page: Page): Promise<void> {
+  console.log('🔧 Demonstrating Maintenance page...');
+  await page.goto(`${CONFIG.baseUrl}/maintenance`);
+  await wait(TIMING.pageLoad);
+
+  // Show status tab (default)
+  await wait(TIMING.mediumPause);
+
+  // Expand a printer section if available
+  const expandButton = page.locator('button:has(svg[class*="chevron"])').first();
+  if (await expandButton.isVisible()) {
+    await expandButton.click();
+    await wait(TIMING.mediumPause);
+  }
+
+  // Scroll through status
+  await scrollDown(page, 300);
+  await wait(TIMING.shortPause);
+  await scrollToTop(page);
+
+  // Click Settings tab
+  const settingsTab = page.locator('button:has-text("Settings"), [role="tab"]:has-text("Settings")').first();
+  if (await settingsTab.isVisible()) {
+    await settingsTab.click();
+    await wait(TIMING.mediumPause);
+
+    // Scroll through settings
+    await scrollDown(page, 300);
+    await wait(TIMING.shortPause);
+    await scrollToTop(page);
+  }
+
+  // Go back to Status tab
+  const statusTab = page.locator('button:has-text("Status"), [role="tab"]:has-text("Status")').first();
+  if (await statusTab.isVisible()) {
+    await statusTab.click();
+    await wait(TIMING.shortPause);
+  }
+}
+
+async function demoProjectsPage(page: Page): Promise<void> {
+  console.log('📂 Demonstrating Projects page...');
+  await page.goto(`${CONFIG.baseUrl}/projects`);
+  await wait(TIMING.pageLoad);
+
+  // Click through status filter buttons
+  const statusFilters = ['Active', 'Completed', 'Archived', 'All'];
+  for (const status of statusFilters) {
+    const filterBtn = page.locator(`button:has-text("${status}")`).first();
+    if (await filterBtn.isVisible()) {
+      await filterBtn.click();
+      await wait(TIMING.shortPause);
+    }
+  }
+
+  // Click on a project to go to detail page
+  const projectCard = page.locator('.group, [class*="project"]').filter({ has: page.locator('h3, h2') }).first();
+  if (await projectCard.isVisible()) {
+    await projectCard.click();
+    await wait(TIMING.pageLoad);
+
+    // Scroll through project detail
+    await scrollDown(page, 300);
+    await wait(TIMING.mediumPause);
+
+    // Look for tabs in project detail (BOM, Attachments, Prints)
+    const detailTabs = ['BOM', 'Attachments', 'Prints', 'Notes'];
+    for (const tabName of detailTabs) {
+      const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first();
+      if (await tab.isVisible()) {
+        await tab.click();
+        await wait(TIMING.mediumPause);
+      }
+    }
+
+    await scrollToTop(page);
+  }
+}
+
+async function demoSettingsPage(page: Page): Promise<void> {
+  console.log('⚙️ Demonstrating Settings page...');
+  await page.goto(`${CONFIG.baseUrl}/settings`);
+  await wait(TIMING.pageLoad);
+
+  // Define the 6 tabs to click through
+  const tabs = ['General', 'Plugs', 'Notifications', 'Filament', 'API', 'Virtual'];
+
+  for (const tabName of tabs) {
+    const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first();
+    if (await tab.isVisible()) {
+      await tab.click();
+      await wait(TIMING.mediumPause);
+
+      // Scroll through tab content
+      await scrollDown(page, 300);
+      await wait(TIMING.shortPause);
+      await scrollToTop(page);
+    }
+  }
+
+  // Go back to General tab and show a modal
+  const generalTab = page.locator('button:has-text("General")').first();
+  if (await generalTab.isVisible()) {
+    await generalTab.click();
+    await wait(TIMING.shortPause);
+  }
+
+  // Try to open backup modal
+  const backupButton = page.locator('button:has-text("Backup")').first();
+  if (await backupButton.isVisible()) {
+    await backupButton.click();
+    await wait(TIMING.longPause);
+    await page.keyboard.press('Escape');
+    await wait(TIMING.shortPause);
+  }
+
+  // Go to Plugs tab and show add modal
+  const plugsTab = page.locator('button:has-text("Plugs")').first();
+  if (await plugsTab.isVisible()) {
+    await plugsTab.click();
+    await wait(TIMING.shortPause);
+
+    const addPlugButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first();
+    if (await addPlugButton.isVisible()) {
+      await addPlugButton.click();
+      await wait(TIMING.longPause);
+      await page.keyboard.press('Escape');
+      await wait(TIMING.shortPause);
+    }
+  }
+
+  // Go to Notifications tab and show add modal
+  const notifTab = page.locator('button:has-text("Notifications")').first();
+  if (await notifTab.isVisible()) {
+    await notifTab.click();
+    await wait(TIMING.shortPause);
+
+    const addNotifButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first();
+    if (await addNotifButton.isVisible()) {
+      await addNotifButton.click();
+      await wait(TIMING.longPause);
+      await page.keyboard.press('Escape');
+      await wait(TIMING.shortPause);
+    }
+  }
+
+  await scrollToTop(page);
+}
+
+async function demoSystemPage(page: Page): Promise<void> {
+  console.log('💻 Demonstrating System page...');
+  await page.goto(`${CONFIG.baseUrl}/system`);
+  await wait(TIMING.pageLoad);
+
+  // Show system info
+  await wait(TIMING.mediumPause);
+  await scrollDown(page, 300);
+  await wait(TIMING.shortPause);
+  await scrollToTop(page);
+}
+
+// ============================================================================
+// Main Recording Function
+// ============================================================================
+
+async function recordDemo(): Promise<void> {
+  console.log('🎬 Starting Bambuddy demo recording...');
+  console.log(`   URL: ${CONFIG.baseUrl}`);
+  console.log(`   Resolution: ${CONFIG.viewportWidth}x${CONFIG.viewportHeight}`);
+  console.log(`   Headless: ${CONFIG.headless}`);
+  console.log('');
+
+  const browser: Browser = await chromium.launch({
+    headless: CONFIG.headless,
+    slowMo: CONFIG.slowMo,
+  });
+
+  const context: BrowserContext = await browser.newContext({
+    viewport: {
+      width: CONFIG.viewportWidth,
+      height: CONFIG.viewportHeight,
+    },
+    recordVideo: {
+      dir: CONFIG.outputDir,
+      size: {
+        width: CONFIG.viewportWidth,
+        height: CONFIG.viewportHeight,
+      },
+    },
+  });
+
+  const page: Page = await context.newPage();
+
+  try {
+    // Run through all page demos
+    await demoPrintersPage(page);
+    await demoArchivesPage(page);
+    await demoQueuePage(page);
+    await demoStatsPage(page);
+    await demoProfilesPage(page);
+    await demoMaintenancePage(page);
+    await demoProjectsPage(page);
+    await demoSettingsPage(page);
+    await demoSystemPage(page);
+
+    // Return to home page for closing shot
+    console.log('🏠 Returning to home page...');
+    await page.goto(CONFIG.baseUrl);
+    await wait(TIMING.longPause);
+
+    console.log('✅ Demo recording completed!');
+  } catch (error) {
+    console.error('❌ Error during recording:', error);
+    throw error;
+  } finally {
+    await page.close();
+    await context.close();
+    await browser.close();
+  }
+
+  console.log(`\n📹 Video saved to: ${CONFIG.outputDir}/`);
+  console.log('   (Playwright saves as .webm, convert with ffmpeg if needed)');
+  console.log('   Example: ffmpeg -i video.webm -c:v libx264 demo.mp4');
+}
+
+// Run the recording
+recordDemo().catch(console.error);

+ 9 - 7
docker-compose.yml

@@ -6,19 +6,22 @@ services:
     #   docker compose up -d          → pulls pre-built image from ghcr.io
     #   docker compose up -d          → pulls pre-built image from ghcr.io
     #   docker compose up -d --build  → builds locally from source
     #   docker compose up -d --build  → builds locally from source
     container_name: bambuddy
     container_name: bambuddy
-    # Network mode options:
-    # - Default (bridge): Works for basic usage, but printer discovery and
-    #   camera streaming may not work since container can't reach LAN directly.
-    # - Host mode: Required for printer discovery scanning and camera streaming.
-    #   Uncomment "network_mode: host" and remove "ports:" section below.
     #
     #
+    # LINUX: Use host mode for printer discovery and camera streaming
     network_mode: host
     network_mode: host
+    #
+    # macOS/WINDOWS: Docker Desktop doesn't support host mode.
+    # Comment out "network_mode: host" above and uncomment "ports:" below.
+    # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
     #ports:
     #  - "8000:8000"
     #  - "8000:8000"
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs
-      - bambuddy_vprinter:/app/virtual_printer
+      #
+      # Share virtual printer certs with native installation
+      # This ensures the slicer only needs to trust one CA certificate.
+      - ./virtual_printer:/app/data/virtual_printer
     environment:
     environment:
       - TZ=Europe/Berlin
       - TZ=Europe/Berlin
     restart: unless-stopped
     restart: unless-stopped
@@ -26,4 +29,3 @@ services:
 volumes:
 volumes:
   bambuddy_data:
   bambuddy_data:
   bambuddy_logs:
   bambuddy_logs:
-  bambuddy_vprinter:

+ 24 - 10
docker-publish.sh

@@ -82,22 +82,27 @@ if ! docker info 2>/dev/null | grep -q "Username"; then
     echo ""
     echo ""
 fi
 fi
 
 
-# Determine if this is a release version (no pre-release suffix)
+# Determine if this is a release version (includes betas for now)
 IS_RELEASE=false
 IS_RELEASE=false
-if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(b[0-9]+)?$ ]]; then
     IS_RELEASE=true
     IS_RELEASE=true
 fi
 fi
 
 
 # Setup buildx builder if not exists
 # Setup buildx builder if not exists
 echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
 echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
 if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
 if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
-    echo "Creating new buildx builder: $BUILDER_NAME"
+    echo "Creating new buildx builder: $BUILDER_NAME (optimized for ${CPU_COUNT} cores)"
     docker buildx create \
     docker buildx create \
         --name "$BUILDER_NAME" \
         --name "$BUILDER_NAME" \
         --driver docker-container \
         --driver docker-container \
         --driver-opt network=host \
         --driver-opt network=host \
-        --buildkitd-flags '--allow-insecure-entitlement network.host' \
-        --bootstrap
+        --driver-opt "env.BUILDKIT_STEP_LOG_MAX_SIZE=10000000" \
+        --buildkitd-flags "--allow-insecure-entitlement network.host --oci-worker-gc=false" \
+        --config /dev/stdin <<EOF
+[worker.oci]
+  max-parallelism = ${CPU_COUNT}
+EOF
+    docker buildx inspect --bootstrap "$BUILDER_NAME"
 fi
 fi
 docker buildx use "$BUILDER_NAME"
 docker buildx use "$BUILDER_NAME"
 
 
@@ -117,9 +122,13 @@ else
     echo -e "${BLUE}[3/4] Building and pushing (version only, no latest)...${NC}"
     echo -e "${BLUE}[3/4] Building and pushing (version only, no latest)...${NC}"
 fi
 fi
 
 
+# Common build args for speed
+BUILD_ARGS="--provenance=false --sbom=false"
+CACHE_ARGS="--cache-from type=registry,ref=${FULL_IMAGE}:buildcache --cache-to type=registry,ref=${FULL_IMAGE}:buildcache,mode=max"
+
 if [ "$PARALLEL" = true ]; then
 if [ "$PARALLEL" = true ]; then
     # Parallel build: Build each architecture separately then combine
     # Parallel build: Build each architecture separately then combine
-    echo -e "${YELLOW}Building amd64 and arm64 in parallel...${NC}"
+    echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each)...${NC}"
 
 
     # Build amd64 in background
     # Build amd64 in background
     (
     (
@@ -127,7 +136,9 @@ if [ "$PARALLEL" = true ]; then
         docker buildx build \
         docker buildx build \
             --platform linux/amd64 \
             --platform linux/amd64 \
             -t "${FULL_IMAGE}:${VERSION}-amd64" \
             -t "${FULL_IMAGE}:${VERSION}-amd64" \
-            --provenance=false \
+            ${BUILD_ARGS} \
+            --cache-from type=registry,ref=${FULL_IMAGE}:cache-amd64 \
+            --cache-to type=registry,ref=${FULL_IMAGE}:cache-amd64,mode=max \
             --push \
             --push \
             . 2>&1 | sed 's/^/[amd64] /'
             . 2>&1 | sed 's/^/[amd64] /'
         echo -e "${GREEN}[amd64] Complete!${NC}"
         echo -e "${GREEN}[amd64] Complete!${NC}"
@@ -140,7 +151,9 @@ if [ "$PARALLEL" = true ]; then
         docker buildx build \
         docker buildx build \
             --platform linux/arm64 \
             --platform linux/arm64 \
             -t "${FULL_IMAGE}:${VERSION}-arm64" \
             -t "${FULL_IMAGE}:${VERSION}-arm64" \
-            --provenance=false \
+            ${BUILD_ARGS} \
+            --cache-from type=registry,ref=${FULL_IMAGE}:cache-arm64 \
+            --cache-to type=registry,ref=${FULL_IMAGE}:cache-arm64,mode=max \
             --push \
             --push \
             . 2>&1 | sed 's/^/[arm64] /'
             . 2>&1 | sed 's/^/[arm64] /'
         echo -e "${GREEN}[arm64] Complete!${NC}"
         echo -e "${GREEN}[arm64] Complete!${NC}"
@@ -167,10 +180,11 @@ if [ "$PARALLEL" = true ]; then
     fi
     fi
 else
 else
     # Sequential build (default): Build both platforms in one command
     # Sequential build (default): Build both platforms in one command
+    echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores...${NC}"
     DOCKER_BUILDKIT=1 docker buildx build \
     DOCKER_BUILDKIT=1 docker buildx build \
         --platform "$PLATFORMS" \
         --platform "$PLATFORMS" \
-        --build-arg BUILDKIT_INLINE_CACHE=1 \
-        --provenance=false \
+        ${BUILD_ARGS} \
+        ${CACHE_ARGS} \
         $TAGS \
         $TAGS \
         --push \
         --push \
         .
         .

+ 0 - 1
frontend/package-lock.json

@@ -4678,7 +4678,6 @@
       "version": "2.18.0",
       "version": "2.18.0",
       "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
       "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
       "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
       "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
-      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "lil-gui": "^0.19.2",
         "lil-gui": "^0.19.2",
         "three": "^0.159.0"
         "three": "^0.159.0"

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

@@ -1 +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>
+<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>

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

@@ -1 +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>
+<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>

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

@@ -1 +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>
+<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


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

@@ -1 +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>
+<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>

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

@@ -1 +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>
+<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>

BIN
frontend/public/img/bambuddy_logo_dark_transparent.png


+ 1 - 1
frontend/public/vite.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 28 - 7
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -23,6 +23,13 @@ vi.mock('../../api/client', () => ({
   virtualPrinterApi: {
   virtualPrinterApi: {
     getSettings: vi.fn(),
     getSettings: vi.fn(),
     updateSettings: vi.fn(),
     updateSettings: vi.fn(),
+    getModels: vi.fn().mockResolvedValue({
+      models: {
+        '3DPrinter-X1-Carbon': 'X1C',
+        'C12': 'P1S',
+        'N7': 'P2S',
+      },
+    }),
   },
   },
 }));
 }));
 
 
@@ -34,12 +41,15 @@ const createMockSettings = (overrides = {}) => ({
   enabled: false,
   enabled: false,
   access_code_set: false,
   access_code_set: false,
   mode: 'immediate' as const,
   mode: 'immediate' as const,
+  model: '3DPrinter-X1-Carbon',
   status: {
   status: {
     enabled: false,
     enabled: false,
     running: false,
     running: false,
     mode: 'immediate',
     mode: 'immediate',
     name: 'Bambuddy',
     name: 'Bambuddy',
-    serial: '00M09A391800001',
+    serial: '00M00A391800001',
+    model: '3DPrinter-X1-Carbon',
+    model_name: 'X1C',
     pending_files: 0,
     pending_files: 0,
   },
   },
   ...overrides,
   ...overrides,
@@ -143,7 +153,9 @@ describe('VirtualPrinterSettings', () => {
             running: true,
             running: true,
             mode: 'immediate',
             mode: 'immediate',
             name: 'Bambuddy',
             name: 'Bambuddy',
-            serial: '00M09A391800001',
+            serial: '00M00A391800001',
+            model: '3DPrinter-X1-Carbon',
+            model_name: 'X1C',
             pending_files: 0,
             pending_files: 0,
           },
           },
         })
         })
@@ -154,7 +166,7 @@ describe('VirtualPrinterSettings', () => {
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('Status Details')).toBeInTheDocument();
         expect(screen.getByText('Status Details')).toBeInTheDocument();
         expect(screen.getByText('Bambuddy')).toBeInTheDocument();
         expect(screen.getByText('Bambuddy')).toBeInTheDocument();
-        expect(screen.getByText('00M09A391800001')).toBeInTheDocument();
+        expect(screen.getByText('00M00A391800001')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });
@@ -393,19 +405,28 @@ describe('VirtualPrinterSettings', () => {
   });
   });
 
 
   describe('info section', () => {
   describe('info section', () => {
-    it('shows required ports warning', async () => {
+    it('shows setup required warning', async () => {
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText(/Required ports: 2021.*8883.*990/)).toBeInTheDocument();
+        expect(screen.getByText('Setup Required')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('shows iptables instructions', async () => {
+    it('shows link to setup guide', async () => {
       render(<VirtualPrinterSettings />);
       render(<VirtualPrinterSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText(/iptables -t nat -A PREROUTING/)).toBeInTheDocument();
+        expect(screen.getByText('Read the setup guide before enabling')).toBeInTheDocument();
+      });
+    });
+
+    it('shows how it works section', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('How it works:')).toBeInTheDocument();
+        expect(screen.getByText(/Complete the setup guide for your platform/)).toBeInTheDocument();
       });
       });
     });
     });
   });
   });

+ 24 - 0
frontend/src/api/client.ts

@@ -165,6 +165,11 @@ export interface PrinterStatus {
   last_ams_update: number;
   last_ams_update: number;
   // Number of printable objects in current print (for skip objects feature)
   // Number of printable objects in current print (for skip objects feature)
   printable_objects_count: number;
   printable_objects_count: number;
+  // Fan speeds (0-100 percentage, null if not available for this model)
+  cooling_fan_speed: number | null;  // Part cooling fan
+  big_fan1_speed: number | null;     // Auxiliary fan
+  big_fan2_speed: number | null;     // Chamber/exhaust fan
+  heatbreak_fan_speed: number | null; // Hotend heatbreak fan
 }
 }
 
 
 export interface PrinterCreate {
 export interface PrinterCreate {
@@ -784,6 +789,7 @@ export interface PrintQueueItem {
   scheduled_time: string | null;
   scheduled_time: string | null;
   require_previous_success: boolean;
   require_previous_success: boolean;
   auto_off_after: boolean;
   auto_off_after: boolean;
+  manual_start: boolean;  // Requires manual trigger to start (staged)
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   started_at: string | null;
   started_at: string | null;
   completed_at: string | null;
   completed_at: string | null;
@@ -801,6 +807,7 @@ export interface PrintQueueItemCreate {
   scheduled_time?: string | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   auto_off_after?: boolean;
+  manual_start?: boolean;  // Requires manual trigger to start (staged)
 }
 }
 
 
 export interface PrintQueueItemUpdate {
 export interface PrintQueueItemUpdate {
@@ -809,6 +816,7 @@ export interface PrintQueueItemUpdate {
   scheduled_time?: string | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   auto_off_after?: boolean;
+  manual_start?: boolean;
 }
 }
 
 
 // MQTT Logging types
 // MQTT Logging types
@@ -1321,6 +1329,7 @@ export const api = {
       total: number;
       total: number;
       skipped_count: number;
       skipped_count: number;
       is_printing: boolean;
       is_printing: boolean;
+      bbox_all: [number, number, number, number] | null;
     }>(`/printers/${printerId}/print/objects`),
     }>(`/printers/${printerId}/print/objects`),
 
 
   skipObjects: (printerId: number, objectIds: number[]) =>
   skipObjects: (printerId: number, objectIds: number[]) =>
@@ -1507,6 +1516,7 @@ export const api = {
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
+  getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
   getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
   getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
   scanArchiveTimelapse: (id: number) =>
   scanArchiveTimelapse: (id: number) =>
     request<{
     request<{
@@ -1635,6 +1645,7 @@ export const api = {
     request<{
     request<{
       has_model: boolean;
       has_model: boolean;
       has_gcode: boolean;
       has_gcode: boolean;
+      has_source: boolean;
       build_volume: { x: number; y: number; z: number };
       build_volume: { x: number; y: number; z: number };
       filament_colors: string[];
       filament_colors: string[];
     }>(`/archives/${id}/capabilities`),
     }>(`/archives/${id}/capabilities`),
@@ -1906,6 +1917,8 @@ export const api = {
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
   stopQueueItem: (id: number) =>
   stopQueueItem: (id: number) =>
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
+  startQueueItem: (id: number) =>
+    request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
 
 
   // K-Profiles
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
@@ -2437,6 +2450,7 @@ export interface VirtualPrinterStatus {
   name: string;
   name: string;
   serial: string;
   serial: string;
   model: string;
   model: string;
+  model_name: string;
   pending_files: number;
   pending_files: number;
 }
 }
 
 
@@ -2444,9 +2458,15 @@ export interface VirtualPrinterSettings {
   enabled: boolean;
   enabled: boolean;
   access_code_set: boolean;
   access_code_set: boolean;
   mode: 'immediate' | 'queue';
   mode: 'immediate' | 'queue';
+  model: string;
   status: VirtualPrinterStatus;
   status: VirtualPrinterStatus;
 }
 }
 
 
+export interface VirtualPrinterModels {
+  models: Record<string, string>;  // SSDP code -> display name
+  default: string;
+}
+
 export interface PendingUpload {
 export interface PendingUpload {
   id: number;
   id: number;
   filename: string;
   filename: string;
@@ -2463,15 +2483,19 @@ export interface PendingUpload {
 export const virtualPrinterApi = {
 export const virtualPrinterApi = {
   getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),
   getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),
 
 
+  getModels: () => request<VirtualPrinterModels>('/settings/virtual-printer/models'),
+
   updateSettings: (data: {
   updateSettings: (data: {
     enabled?: boolean;
     enabled?: boolean;
     access_code?: string;
     access_code?: string;
     mode?: 'immediate' | 'queue';
     mode?: 'immediate' | 'queue';
+    model?: string;
   }) => {
   }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.access_code !== undefined) params.set('access_code', data.access_code);
     if (data.access_code !== undefined) params.set('access_code', data.access_code);
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.mode !== undefined) params.set('mode', data.mode);
+    if (data.model !== undefined) params.set('model', data.model);
 
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',
       method: 'PUT',

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