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 4 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.
 
+## [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
 
 ### Added
@@ -41,6 +89,17 @@ All notable changes to Bambuddy will be documented in this file.
   - Light and dark theme support
   - Close with ESC key or click outside
   - 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:
   - Menu button (⋮) appears on hover over AMS slots
   - "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
   - Useful for batch printing (e.g., 10 copies in one print = 10 items)
   - 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
-- **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
 - **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
 
+# Copy package files first for better caching
 COPY frontend/package*.json ./
-RUN npm ci
+
+# Use cache mount for npm
+RUN --mount=type=cache,target=/root/.npm \
+    npm ci
 
 COPY frontend/ ./
 RUN npm run build
@@ -21,9 +25,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     ffmpeg \
     && rm -rf /var/lib/apt/lists/*
 
-# Install Python dependencies
+# Install Python dependencies with cache mount
 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/ ./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
 
 # 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
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
+- Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume)
+- Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
 - HMS error monitoring with history
@@ -67,6 +69,7 @@
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Scheduled prints (date/time)
+- Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota)
 - Energy consumption tracking
 - Auto power-on before print
@@ -95,10 +98,12 @@
 - K-profiles (pressure advance)
 - External sidebar links
 - Webhooks & API keys
+- Interactive API browser with live testing
 
 ### 🖨️ Virtual Printer
 - Emulates a Bambu Lab printer on your network
 - Send prints directly from Bambu Studio/Orca Slicer
+- Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Queue mode or auto-start mode
 - SSDP discovery (appears in slicer automatically)
 - 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).
 
+> **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>
 <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."""
 
 from datetime import datetime, timedelta
+
 from fastapi import APIRouter, Depends, Query
-from sqlalchemy import select, func, and_
-from sqlalchemy.ext.asyncio import AsyncSession
 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.models.ams_history import AMSSensorHistory
@@ -64,8 +65,7 @@ async def get_ams_history(
             func.min(AMSSensorHistory.temperature).label("min_temp"),
             func.max(AMSSensorHistory.temperature).label("max_temp"),
             func.avg(AMSSensorHistory.temperature).label("avg_temp"),
-        )
-        .where(
+        ).where(
             and_(
                 AMSSensorHistory.printer_id == printer_id,
                 AMSSensorHistory.ams_id == ams_id,
@@ -106,8 +106,7 @@ async def delete_old_history(
     cutoff = datetime.now() - timedelta(days=days)
 
     result = await db.execute(
-        select(func.count(AMSSensorHistory.id))
-        .where(
+        select(func.count(AMSSensorHistory.id)).where(
             and_(
                 AMSSensorHistory.printer_id == printer_id,
                 AMSSensorHistory.recorded_at < cutoff,

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

@@ -1,16 +1,17 @@
 import logging
+
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.schemas.api_key import (
     APIKeyCreate,
-    APIKeyUpdate,
-    APIKeyResponse,
     APIKeyCreateResponse,
+    APIKeyResponse,
+    APIKeyUpdate,
 )
 
 logger = logging.getLogger(__name__)
@@ -21,9 +22,7 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
 @router.get("/", response_model=list[APIKeyResponse])
 async def list_api_keys(db: AsyncSession = Depends(get_db)):
     """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())
 
 

+ 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_gcode = False
+    has_source = False
     build_volume = {"x": 256, "y": 256, "z": 256}  # Default to X1/P1 size
     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:
         with zipfile.ZipFile(file_path, "r") as zf:
             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)
 
-            # 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
+            slice_colors: list[str] = []
             if "Metadata/slice_info.config" in names:
                 try:
                     slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
                     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")
                     filament_map: dict[int, str] = {}
                     for f in filaments:
@@ -1563,59 +1647,66 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
                         except (ValueError, TypeError):
                             used_amount = 0
 
-                        # Include all filaments, but mark unused ones
                         if fid is not None and fcolor:
                             try:
-                                # IDs are 1-based in slice_info, tools are 0-based
                                 tool_id = int(fid) - 1
                                 if tool_id >= 0 and used_amount > 0:
                                     filament_map[tool_id] = fcolor
                             except ValueError:
                                 pass
 
-                    # Convert to ordered list (tool 0, tool 1, etc.)
                     if filament_map:
                         max_tool = max(filament_map.keys())
                         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:
                     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:
         raise HTTPException(400, "Invalid 3MF file")
@@ -1623,6 +1714,7 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
     return {
         "has_model": has_model,
         "has_gcode": has_gcode,
+        "has_source": has_source,
         "build_volume": build_volume,
         "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)}")
 
 
+@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")
 async def upload_archive(
     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)
 _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:
     """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}")
                 break
 
-            # Save frame to buffer for photo capture
+            # Save frame to buffer for photo capture and track timestamp
             if printer_id is not None:
+                import time
+
                 _last_frames[printer_id] = frame
+                _last_frame_times[printer_id] = time.time()
 
             # Rate limiting - skip frames if needed to maintain target FPS
             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:
             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
         try:
@@ -156,7 +167,7 @@ async def generate_rtsp_mjpeg_stream(
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
-        yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
+        yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
         return
 
     port = get_camera_port(model)
@@ -265,9 +276,12 @@ async def generate_rtsp_mjpeg_stream(
                     frame = 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:
+                        import time
+
                         _last_frames[printer_id] = frame
+                        _last_frame_times[printer_id] = time.time()
 
                     # Yield frame in MJPEG format
                     yield (
@@ -289,7 +303,7 @@ async def generate_rtsp_mjpeg_stream(
 
     except FileNotFoundError:
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
-        yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
+        yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
     except asyncio.CancelledError:
         logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
     except GeneratorExit:
@@ -301,9 +315,11 @@ async def generate_rtsp_mjpeg_stream(
         if stream_id and stream_id in _active_streams:
             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:
             logger.info(f"Terminating ffmpeg process for stream {stream_id}")
@@ -366,6 +382,11 @@ async def camera_stream(
         stream_generator = generate_rtsp_mjpeg_stream
         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():
         """Wrapper generator that monitors for client disconnect."""
         try:
@@ -519,3 +540,60 @@ async def test_camera(
     )
 
     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."""
 
 import logging
-import os
 import uuid
 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 sqlalchemy import select
 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.schemas.external_link import (
     ExternalLinkCreate,
-    ExternalLinkUpdate,
-    ExternalLinkResponse,
     ExternalLinkReorder,
+    ExternalLinkResponse,
+    ExternalLinkUpdate,
 )
 
 # Directory for storing custom icons
@@ -32,9 +31,7 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
 @router.get("/", response_model=list[ExternalLinkResponse])
 async def list_external_links(db: AsyncSession = Depends(get_db)):
     """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()
     return links
 
@@ -46,9 +43,7 @@ async def create_external_link(
 ):
     """Create a new external link."""
     # 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()
     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),
 ):
     """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()
 
     if not link:
@@ -92,9 +85,7 @@ async def update_external_link(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not link:
@@ -119,9 +110,7 @@ async def delete_external_link(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not link:
@@ -144,9 +133,7 @@ async def reorder_external_links(
     """Update the sort order of external links."""
     # Update sort_order for each link based on position in the list
     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()
         if link:
             link.sort_order = index
@@ -154,9 +141,7 @@ async def reorder_external_links(
     await db.commit()
 
     # 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()
 
     logger.info(f"Reordered {len(reorder_data.ids)} external links")
@@ -171,9 +156,7 @@ async def upload_icon(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not link:
@@ -185,10 +168,7 @@ async def upload_icon(
 
     ext = Path(file.filename).suffix.lower()
     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
     ICONS_DIR.mkdir(parents=True, exist_ok=True)
@@ -224,9 +204,7 @@ async def delete_icon(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not link:
@@ -250,9 +228,7 @@ async def get_icon(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not link:

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

@@ -1,26 +1,23 @@
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
 from backend.app.models.filament import Filament
 from backend.app.schemas.filament import (
+    FilamentCostCalculation,
     FilamentCreate,
-    FilamentUpdate,
     FilamentResponse,
-    FilamentCostCalculation,
+    FilamentUpdate,
 )
 
-
 router = APIRouter(prefix="/filaments", tags=["filaments"])
 
 
 @router.get("/", response_model=list[FilamentResponse])
 async def list_filaments(db: AsyncSession = Depends(get_db)):
     """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())
 
 
@@ -40,9 +37,7 @@ async def create_filament(
 @router.get("/{filament_id}", response_model=FilamentResponse)
 async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
     """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()
     if not filament:
         raise HTTPException(404, "Filament not found")
@@ -56,9 +51,7 @@ async def update_filament(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
     if not filament:
         raise HTTPException(404, "Filament not found")
@@ -74,9 +67,7 @@ async def update_filament(
 @router.delete("/{filament_id}")
 async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
     """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()
     if not filament:
         raise HTTPException(404, "Filament not found")
@@ -93,9 +84,7 @@ async def calculate_cost(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
     if not filament:
         raise HTTPException(404, "Filament not found")
@@ -117,11 +106,7 @@ async def get_filaments_by_type(
     db: AsyncSession = Depends(get_db),
 ):
     """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())
 
 
@@ -129,15 +114,100 @@ async def get_filaments_by_type(
 async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
     """Seed the database with common filament types."""
     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

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

@@ -4,19 +4,19 @@ import asyncio
 import logging
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 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.printer import Printer
 from backend.app.schemas.kprofile import (
     KProfile,
     KProfileCreate,
     KProfileDelete,
-    KProfilesResponse,
     KProfileNote,
     KProfileNoteResponse,
+    KProfilesResponse,
 )
 from backend.app.services.printer_manager import printer_manager
 
@@ -293,15 +293,11 @@ async def get_kprofile_notes(
         raise HTTPException(404, "Printer not found")
 
     # 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()
 
     # 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)

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

@@ -5,15 +5,15 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
-from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
 from backend.app.schemas.notification_template import (
+    EVENT_VARIABLES,
+    SAMPLE_DATA,
+    EventVariablesResponse,
     NotificationTemplateResponse,
     NotificationTemplateUpdate,
-    EventVariablesResponse,
     TemplatePreviewRequest,
     TemplatePreviewResponse,
-    EVENT_VARIABLES,
-    SAMPLE_DATA,
 )
 from backend.app.services.notification_service import notification_service
 
@@ -38,9 +38,7 @@ EVENT_NAMES = {
 @router.get("", response_model=list[NotificationTemplateResponse])
 async def get_templates(db: AsyncSession = Depends(get_db)):
     """Get all notification templates."""
-    result = await db.execute(
-        select(NotificationTemplate).order_by(NotificationTemplate.id)
-    )
+    result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
     return result.scalars().all()
 
 
@@ -60,9 +58,7 @@ async def get_variables():
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
 async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
     """Get a single notification template."""
-    result = await db.execute(
-        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
-    )
+    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
     if not template:
         raise HTTPException(status_code=404, detail="Template not found")
@@ -76,9 +72,7 @@ async def update_template(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
     if not template:
         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)
 async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
     """Reset a notification template to its default values."""
-    result = await db.execute(
-        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
-    )
+    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
     if not template:
         raise HTTPException(status_code=404, detail="Template not found")
@@ -139,6 +131,7 @@ async def preview_template(request: TemplatePreviewRequest):
             result = result.replace("{" + key + "}", str(value))
         # Remove any remaining unreplaced placeholders
         import re
+
         result = re.sub(r"\{[a-z_]+\}", "", 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)
 # ============================================================================
 
+
 @router.get("/", response_model=list[NotificationProviderResponse])
 async def list_notification_providers(db: AsyncSession = Depends(get_db)):
     """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()
 
     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)
 # ============================================================================
 
+
 @router.post("/test-config", response_model=NotificationTestResponse)
 async def test_notification_config(
     test_request: NotificationTestRequest,
@@ -153,9 +153,7 @@ async def test_notification_config(
 @router.post("/test-all")
 async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
     """Send a test notification to all enabled providers."""
-    result = await db.execute(
-        select(NotificationProvider).where(NotificationProvider.enabled == True)
-    )
+    result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     providers = result.scalars().all()
 
     if not providers:
@@ -167,9 +165,7 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
 
     for provider in providers:
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
-        success, message = await notification_service.send_test_notification(
-            provider.provider_type, config, db
-        )
+        success, message = await notification_service.send_test_notification(provider.provider_type, config, db)
 
         # Update provider status
         if success:
@@ -180,13 +176,15 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
             provider.last_error_at = datetime.utcnow()
             failed_count += 1
 
-        results.append({
-            "provider_id": provider.id,
-            "provider_name": provider.name,
-            "provider_type": provider.provider_type,
-            "success": success,
-            "message": message,
-        })
+        results.append(
+            {
+                "provider_id": provider.id,
+                "provider_name": provider.name,
+                "provider_type": provider.provider_type,
+                "success": success,
+                "message": message,
+            }
+        )
 
     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)
 # ============================================================================
 
+
 @router.get("/logs", response_model=list[NotificationLogResponse])
 async def get_notification_logs(
     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()
 
         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
 
@@ -270,15 +271,12 @@ async def get_notification_log_stats(
     cutoff = datetime.utcnow() - timedelta(days=days)
 
     # 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
 
     success_result = await db.execute(
         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
@@ -317,9 +315,7 @@ async def clear_notification_logs(
     """Clear old notification logs."""
     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()
 
     deleted_count = result.rowcount
@@ -332,15 +328,14 @@ async def clear_notification_logs(
 # Provider Instance Routes (parameterized - must come LAST)
 # ============================================================================
 
+
 @router.get("/{provider_id}", response_model=NotificationProviderResponse)
 async def get_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not provider:
@@ -356,9 +351,7 @@ async def update_notification_provider(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not provider:
@@ -389,9 +382,7 @@ async def delete_notification_provider(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not provider:
@@ -412,18 +403,14 @@ async def test_notification_provider(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
 
     if not provider:
         raise HTTPException(status_code=404, detail="Notification provider not found")
 
     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
     if success:

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

@@ -4,18 +4,18 @@ import logging
 from datetime import datetime
 
 from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
-from backend.app.models.archive import PrintArchive
 from backend.app.schemas.print_queue import (
     PrintQueueItemCreate,
-    PrintQueueItemUpdate,
     PrintQueueItemResponse,
+    PrintQueueItemUpdate,
     PrintQueueReorder,
 )
 
@@ -89,6 +89,7 @@ async def add_to_queue(
         scheduled_time=data.scheduled_time,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
+        manual_start=data.manual_start,
         position=max_pos + 1,
         status="pending",
     )
@@ -124,9 +125,7 @@ async def update_queue_item(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
     if not item:
         raise HTTPException(404, "Queue item not found")
@@ -138,9 +137,7 @@ async def update_queue_item(
 
     # Validate new printer_id if being changed
     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():
             raise HTTPException(400, "Printer not found")
 
@@ -157,9 +154,7 @@ async def update_queue_item(
 @router.delete("/{item_id}")
 async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """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()
     if not item:
         raise HTTPException(404, "Queue item not found")
@@ -181,9 +176,7 @@ async def reorder_queue(
 ):
     """Bulk update positions for queue 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()
         if item and item.status == "pending":
             item.position = reorder_item.position
@@ -196,9 +189,7 @@ async def reorder_queue(
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """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()
     if not item:
         raise HTTPException(404, "Queue item not found")
@@ -220,14 +211,13 @@ async def stop_queue_item(
     db: AsyncSession = Depends(get_db),
 ):
     """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.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()
     if not item:
         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
     plug_ip = None
     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()
         if plug and plug.enabled:
             plug_ip = plug.ip_address
@@ -268,15 +256,15 @@ async def stop_queue_item(
 
     # Schedule background task for cooldown + power off
     if plug_ip:
+
         async def cooldown_and_poweroff():
             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)
             # Re-fetch plug since we're in a new async context
             from backend.app.core.database import async_session
+
             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()
                 if plug and plug.enabled:
                     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())
 
     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 re
 import zipfile
 
 from fastapi import APIRouter, Depends, HTTPException
@@ -447,12 +449,21 @@ async def get_printer_cover(
     if not subtask_name:
         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
     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:
-        cache_key = (subtask_name, view_key)
+        cache_key = (subtask_name, plate_num, view_key)
         if cache_key in _cover_cache[printer_id]:
             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}")
 
-    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:
         raise HTTPException(
@@ -513,21 +539,24 @@ async def get_printer_cover(
 
         try:
             # 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)
             if view == "top":
                 thumbnail_paths = [
+                    f"Metadata/top_{plate_num}.png",
+                    # Fall back to plate 1 if specific plate not found
                     "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/thumbnail.png",
                 ]
             else:
                 thumbnail_paths = [
+                    f"Metadata/plate_{plate_num}.png",
+                    # Fall back to plate 1 if specific plate not found
                     "Metadata/plate_1.png",
                     "Metadata/thumbnail.png",
+                    f"Metadata/plate_{plate_num}_small.png",
                     "Metadata/plate_1_small.png",
                     "Thumbnails/thumbnail.png",
                     "thumbnail.png",
@@ -536,10 +565,10 @@ async def get_printer_cover(
             for thumb_path in thumbnail_paths:
                 try:
                     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:
                         _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")
                 except KeyError:
                     continue
@@ -550,7 +579,7 @@ async def get_printer_cover(
                     image_data = zf.read(name)
                     if printer_id not in _cover_cache:
                         _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")
 
             raise HTTPException(404, "No thumbnail found in 3MF file")
@@ -1152,9 +1181,10 @@ async def get_printable_objects(
                 if downloaded and temp_path.exists():
                     with open(temp_path, "rb") as f:
                         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:
                         client.state.printable_objects = objects
+                        client.state.printable_objects_bbox_all = bbox_all
                         logger.info(f"Reloaded {len(objects)} objects for printer {printer_id}")
             except Exception as e:
                 logger.debug(f"Failed to reload objects from printer: {e}")
@@ -1190,6 +1220,7 @@ async def get_printable_objects(
         "total": len(objects),
         "skipped_count": len(client.state.skipped_objects),
         "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)
 
     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,
                     "require_previous_success": qi.require_previous_success,
                     "auto_off_after": qi.auto_off_after,
+                    "manual_start": qi.manual_start,
                     "status": qi.status,
                     "started_at": qi.started_at.isoformat() if qi.started_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),
                 require_previous_success=qi_data.get("require_previous_success", 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"),
                 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_access_code = await get_setting(db, "virtual_printer_access_code")
             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"
             access_code = vp_access_code or ""
             mode = vp_mode or "immediate"
+            model = vp_model or ""
 
             if enabled and access_code:
                 await virtual_printer_manager.configure(
                     enabled=True,
                     access_code=access_code,
                     mode=mode,
+                    model=model,
                 )
             elif not enabled and virtual_printer_manager.is_enabled:
                 await virtual_printer_manager.configure(
                     enabled=False,
                     access_code=access_code,
                     mode=mode,
+                    model=model,
                 )
         except Exception:
             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")
 async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
     """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")
     access_code = await get_setting(db, "virtual_printer_access_code")
     mode = await get_setting(db, "virtual_printer_mode")
+    model = await get_setting(db, "virtual_printer_model")
 
     return {
         "enabled": enabled == "true" if enabled else False,
         "access_code_set": bool(access_code),
         "mode": mode or "immediate",
+        "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -1669,20 +1694,27 @@ async def update_virtual_printer_settings(
     enabled: bool = None,
     access_code: str = None,
     mode: str = None,
+    model: str = None,
     db: AsyncSession = Depends(get_db),
 ):
     """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
     current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
     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
     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_mode = mode if mode is not None else current_mode
+    new_model = model if model is not None else current_model
 
     # Validate mode
     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'"},
         )
 
+    # 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
     if new_enabled and not new_access_code:
         return JSONResponse(
@@ -1710,6 +1749,8 @@ async def update_virtual_printer_settings(
     if access_code is not None:
         await set_setting(db, "virtual_printer_access_code", access_code)
     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()
 
     # Reconfigure virtual printer
@@ -1718,6 +1759,7 @@ async def update_virtual_printer_settings(
             enabled=new_enabled,
             access_code=new_access_code,
             mode=new_mode,
+            model=new_model,
         )
     except ValueError as e:
         return JSONResponse(

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

@@ -1,20 +1,19 @@
 """System information API routes."""
 
-import os
 import platform
-import psutil
 from datetime import datetime
 from pathlib import Path
 
+import psutil
 from fastapi import APIRouter, Depends
-from sqlalchemy import select, func
+from sqlalchemy import func, select
 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.models.archive import PrintArchive
-from backend.app.models.printer import Printer
 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.smart_plug import SmartPlug
 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."""
     total = 0
     try:
-        for entry in path.rglob('*'):
+        for entry in path.rglob("*"):
             if entry.is_file():
                 total += entry.stat().st_size
     except (PermissionError, OSError):
@@ -36,7 +35,7 @@ def get_directory_size(path: Path) -> int:
 
 def format_bytes(bytes_value: int) -> str:
     """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:
             return f"{bytes_value:.1f} {unit}"
         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)))
 
     # 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 = 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 = 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 = []
@@ -102,18 +97,18 @@ async def get_system_info(db: AsyncSession = Depends(get_db)):
         state = client.state
         if state and state.connected:
             # 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()
             name = row[0] if row else f"Printer {printer_id}"
             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
     archive_dir = settings.archive_dir

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

@@ -1,11 +1,12 @@
 import logging
+
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
 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.auth import get_api_key, check_permission, check_printer_access
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
@@ -56,6 +57,7 @@ class QueueStatusResponse(BaseModel):
 
 # Webhook endpoints
 
+
 @router.post("/queue/add", response_model=QueueAddResponse)
 async def webhook_add_to_queue(
     data: QueueAddRequest,
@@ -66,21 +68,17 @@ async def webhook_add_to_queue(
 
     Requires 'can_queue' permission.
     """
-    check_permission(api_key, 'queue')
+    check_permission(api_key, "queue")
     check_printer_access(api_key, data.printer_id)
 
     # 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()
     if not archive:
         raise HTTPException(status_code=404, detail="Archive not found")
 
     # 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()
     if not printer:
         raise HTTPException(status_code=404, detail="Printer not found")
@@ -102,8 +100,9 @@ async def webhook_add_to_queue(
     scheduled_time = None
     if data.scheduled_time:
         from datetime import datetime
+
         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:
             raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
 
@@ -141,7 +140,7 @@ async def webhook_start_print(
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, 'control_printer')
+    check_permission(api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     # Get printer
@@ -170,10 +169,7 @@ async def webhook_start_print(
         raise HTTPException(status_code=503, detail="Printer not connected")
 
     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
     try:
@@ -194,7 +190,7 @@ async def webhook_stop_print(
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, 'control_printer')
+    check_permission(api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
@@ -222,7 +218,7 @@ async def webhook_cancel_print(
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, 'control_printer')
+    check_permission(api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
@@ -251,7 +247,7 @@ async def webhook_get_printer_status(
 
     Requires 'can_read_status' permission.
     """
-    check_permission(api_key, 'read_status')
+    check_permission(api_key, "read_status")
     check_printer_access(api_key, printer_id)
 
     # Get printer
@@ -283,7 +279,7 @@ async def webhook_get_queue_status(
 
     Requires 'can_read_status' permission.
     """
-    check_permission(api_key, 'read_status')
+    check_permission(api_key, "read_status")
 
     # Get printers
     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")
         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

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

@@ -1,4 +1,5 @@
 import logging
+
 from fastapi import APIRouter, WebSocket, WebSocketDisconnect
 
 from backend.app.core.websocket import ws_manager
@@ -19,11 +20,13 @@ async def websocket_endpoint(websocket: WebSocket):
         # Send initial status of all printers
         statuses = printer_manager.get_all_statuses()
         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")
 
         # Keep connection alive and handle incoming messages
@@ -40,11 +43,13 @@ async def websocket_endpoint(websocket: WebSocket):
                 if printer_id:
                     state = printer_manager.get_status(printer_id)
                     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:
         logger.info("WebSocket client disconnected normally")

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

@@ -1,11 +1,10 @@
 import hashlib
 import secrets
 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.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
 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)
 
-    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()
 
     if not api_key:
@@ -60,9 +57,9 @@ async def get_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),
-) -> Optional[APIKey]:
+) -> APIKey | None:
     """Get API key if provided, return None otherwise."""
     if not x_api_key:
         return None
@@ -83,19 +80,16 @@ def check_permission(api_key: APIKey, permission: str) -> None:
     Raises HTTPException if permission is denied.
     """
     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:
         raise HTTPException(status_code=500, detail=f"Unknown permission: {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:
@@ -108,7 +102,4 @@ def check_printer_access(api_key: APIKey, printer_id: int) -> None:
     Raises HTTPException if access is denied.
     """
     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
 
 # Application version - single source of truth
-APP_VERSION = "0.1.6b4"
+APP_VERSION = "0.1.6b6"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # 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:
         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():
     """Seed default notification templates if they don't exist."""

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

@@ -1,6 +1,7 @@
 import asyncio
 import json
 from typing import Any
+
 from fastapi import WebSocket
 
 
@@ -44,41 +45,51 @@ class ConnectionManager:
 
     async def send_printer_status(self, printer_id: int, status: dict):
         """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):
         """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):
         """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):
         """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):
         """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

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

@@ -17,21 +17,17 @@ EN = {
         "filament": "Filament",
         "reason": "Reason",
         "unknown": "Unknown",
-
         # Printer events
         "printer_offline": "Printer Offline",
         "printer_disconnected": "{printer} has disconnected",
         "printer_error": "Printer Error: {error_type}",
-
         # Filament
         "filament_low": "Filament Low",
         "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
-
         # Maintenance
         "maintenance_due": "Maintenance Due",
         "overdue": "OVERDUE",
         "soon": "Soon",
-
         # Test notification
         "test_title": "Bambuddy Test",
         "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
@@ -53,21 +49,17 @@ DE = {
         "filament": "Filament",
         "reason": "Grund",
         "unknown": "Unbekannt",
-
         # Printer events
         "printer_offline": "Drucker offline",
         "printer_disconnected": "{printer} wurde getrennt",
         "printer_error": "Druckerfehler: {error_type}",
-
         # Filament
         "filament_low": "Wenig Filament",
         "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
-
         # Maintenance
         "maintenance_due": "Wartung fällig",
         "overdue": "ÜBERFÄLLIG",
         "soon": "Bald",
-
         # Test notification
         "test_title": "Bambuddy Test",
         "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 = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         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:
         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:
                 threemf_data = f.read()
             # 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:
                 client = printer_manager.get_client(printer_id)
                 if client:
                     client.state.printable_objects = printable_objects
+                    client.state.printable_objects_bbox_all = bbox_all
                     client.state.skipped_objects = []
                     logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
     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 not downloaded_filename and (filename or subtask_name):
             search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
+            logger.info(f"Direct FTP download failed, listing /cache to find '{search_term}'")
             try:
                 cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
+                threemf_files = [f.get("name") for f in cache_files if f.get("name", "").endswith(".3mf")]
+                logger.info(
+                    f"Found {len(threemf_files)} 3MF files in /cache: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
+                )
                 for f in cache_files:
                     if f.get("is_directory"):
                         continue
                     fname = f.get("name", "")
                     if fname.endswith(".3mf") and search_term in fname.lower():
+                        logger.info(f"Found matching file: {fname}")
                         temp_path = app_settings.archive_dir / "temp" / fname
                         temp_path.parent.mkdir(parents=True, exist_ok=True)
                         if 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:
             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:
             # 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:
                         threemf_data = f.read()
                     # 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:
                         # Store objects in printer state
                         client = printer_manager.get_client(printer_id)
                         if client:
                             client.state.printable_objects = printable_objects
+                            client.state.printable_objects_bbox_all = bbox_all
                             client.state.skipped_objects = []  # Reset skipped objects for new print
                             logger.info(f"Loaded {len(printable_objects)} printable objects for printer {printer_id}")
                 except Exception as e:
@@ -1515,7 +1597,11 @@ async def track_printer_runtime():
                 for printer in printers:
                     # Get current state from printer manager
                     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
 
                     # 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)
                                 updated_count += 1
                                 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:
                             # First time seeing printer active - need to commit to save timestamp
                             needs_commit = True
+                            logger.debug(f"[{printer.name}] Runtime tracking: first active detection")
 
                         printer.last_runtime_update = now
                     else:
                         # Printer is idle/offline - clear last_runtime_update
                         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
                             needs_commit = True
 
@@ -1638,6 +1737,7 @@ async def lifespan(app: FastAPI):
         if vp_enabled and vp_enabled.lower() == "true":
             vp_access_code = await get_setting(db, "virtual_printer_access_code") or ""
             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:
                 try:
@@ -1645,8 +1745,9 @@ async def lifespan(app: FastAPI):
                         enabled=True,
                         access_code=vp_access_code,
                         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:
                     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 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 backend.app.core.database import Base
@@ -7,6 +8,7 @@ from backend.app.core.database import Base
 
 class AMSSensorHistory(Base):
     """Historical sensor data from AMS units (humidity and temperature)."""
+
     __tablename__ = "ams_sensor_history"
 
     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_raw: Mapped[float | None] = mapped_column(Float)  # Raw humidity value
     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
-    __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
     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 sqlalchemy import String, Boolean, DateTime, Text, JSON, func
+
+from sqlalchemy import JSON, Boolean, DateTime, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base

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

@@ -1,6 +1,6 @@
 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 backend.app.core.database import Base
@@ -15,11 +15,7 @@ class ExternalLink(Base):
     name: Mapped[str] = mapped_column(String(50))
     url: Mapped[str] = mapped_column(String(500))
     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)
-    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 sqlalchemy import String, Float, DateTime, func
+
+from sqlalchemy import DateTime, Float, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -29,9 +30,5 @@ class Filament(Base):
     bed_temp_min: 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)."""
 
 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 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: Mapped[str] = mapped_column(String(100))
     note: Mapped[str] = mapped_column(Text, default="")
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now()
-    )
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     # Relationship to printer
     printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes")
 
     # Composite index for efficient lookups
-    __table_args__ = (
-        Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),
-    )
+    __table_args__ = (Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),)
 
 
 from backend.app.models.printer import Printer  # noqa: E402

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

@@ -1,7 +1,8 @@
 """Maintenance tracking models."""
 
 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 backend.app.core.database import Base
@@ -9,6 +10,7 @@ from backend.app.core.database import Base
 
 class MaintenanceType(Base):
     """Defines a type of maintenance task with default interval."""
+
     __tablename__ = "maintenance_types"
 
     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")
     icon: Mapped[str | None] = mapped_column(String(50))  # Icon name for UI
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)  # Pre-defined vs custom
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
     # Relationships
     printer_maintenance: Mapped[list["PrinterMaintenance"]] = relationship(
@@ -31,6 +31,7 @@ class MaintenanceType(Base):
 
 class PrinterMaintenance(Base):
     """Tracks maintenance status for a specific printer."""
+
     __tablename__ = "printer_maintenance"
 
     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_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
     printer: Mapped["Printer"] = relationship(back_populates="maintenance_items")
@@ -64,12 +61,11 @@ class PrinterMaintenance(Base):
 
 class MaintenanceHistory(Base):
     """Log of maintenance actions performed."""
+
     __tablename__ = "maintenance_history"
 
     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())
     hours_at_maintenance: Mapped[float] = mapped_column(Float, default=0.0)
     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 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 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)
     is_default: Mapped[bool] = mapped_column(Boolean, default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
 # Default templates for seeding

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

@@ -1,5 +1,6 @@
 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 backend.app.core.database import Base
@@ -13,19 +14,14 @@ class PrintQueueItem(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
 
     # 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
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
     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
     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")
 
 
-from backend.app.models.printer import Printer  # 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

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

@@ -1,5 +1,6 @@
 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 backend.app.core.database import Base
@@ -13,9 +14,5 @@ class Settings(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     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 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 backend.app.core.database import Base
@@ -15,9 +16,7 @@ class SlotPresetMapping(Base):
     """Maps an AMS slot to a cloud filament preset."""
 
     __tablename__ = "slot_preset_mappings"
-    __table_args__ = (
-        UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"),
-    )
+    __table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_preset"),)
 
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
@@ -26,9 +25,7 @@ class SlotPresetMapping(Base):
     preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
     preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     # 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 (
     PrinterBase,
     PrinterCreate,
-    PrinterUpdate,
     PrinterResponse,
     PrinterStatus,
-)
-from backend.app.schemas.archive import (
-    ArchiveBase,
-    ArchiveUpdate,
-    ArchiveResponse,
-    ProjectPageResponse,
-    ProjectPageImage,
+    PrinterUpdate,
 )
 from backend.app.schemas.smart_plug import (
     SmartPlugBase,
+    SmartPlugControl,
     SmartPlugCreate,
-    SmartPlugUpdate,
     SmartPlugResponse,
-    SmartPlugControl,
     SmartPlugStatus,
     SmartPlugTestConnection,
+    SmartPlugUpdate,
 )
 
 __all__ = [

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

@@ -1,9 +1,11 @@
 from datetime import datetime
+
 from pydantic import BaseModel
 
 
 class APIKeyCreate(BaseModel):
     """Schema for creating a new API key."""
+
     name: str
     can_queue: bool = True
     can_control_printer: bool = False
@@ -14,6 +16,7 @@ class APIKeyCreate(BaseModel):
 
 class APIKeyUpdate(BaseModel):
     """Schema for updating an API key."""
+
     name: str | None = None
     can_queue: bool | None = None
     can_control_printer: bool | None = None
@@ -25,6 +28,7 @@ class APIKeyUpdate(BaseModel):
 
 class APIKeyResponse(BaseModel):
     """Schema for API key response (without full key)."""
+
     id: int
     name: str
     key_prefix: str  # First 8 chars for identification
@@ -43,4 +47,5 @@ class APIKeyResponse(BaseModel):
 
 class APIKeyCreateResponse(APIKeyResponse):
     """Response when creating a key - includes full key (shown only once)."""
+
     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 typing import Optional
 
 
 class CloudLoginRequest(BaseModel):
     """Request to initiate cloud login."""
+
     email: str = Field(..., description="Bambu Lab account email")
     password: str = Field(..., description="Account password")
     region: str = Field(default="global", description="Region: 'global' or 'china'")
@@ -11,12 +11,14 @@ class CloudLoginRequest(BaseModel):
 
 class CloudVerifyRequest(BaseModel):
     """Request to verify login with 2FA code."""
+
     email: str = Field(..., description="Bambu Lab account email")
     code: str = Field(..., description="6-digit verification code from email")
 
 
 class CloudLoginResponse(BaseModel):
     """Response from login attempt."""
+
     success: bool
     needs_verification: bool = False
     message: str
@@ -24,27 +26,31 @@ class CloudLoginResponse(BaseModel):
 
 class CloudAuthStatus(BaseModel):
     """Current authentication status."""
+
     is_authenticated: bool
-    email: Optional[str] = None
+    email: str | None = None
 
 
 class CloudTokenRequest(BaseModel):
     """Request to set access token directly."""
+
     access_token: str = Field(..., description="Bambu Lab access token")
 
 
 class SlicerSetting(BaseModel):
     """A slicer setting/preset."""
+
     setting_id: str
     name: str
     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):
     """Response containing slicer settings."""
+
     filament: list[SlicerSetting] = []
     printer: list[SlicerSetting] = []
     process: list[SlicerSetting] = []
@@ -52,15 +58,17 @@ class SlicerSettingsResponse(BaseModel):
 
 class CloudDevice(BaseModel):
     """A bound printer device."""
+
     dev_id: 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
 
 
 class SlicerSettingCreate(BaseModel):
     """Request to create a new slicer preset."""
+
     type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'")
     name: str = Field(..., description="Display name for the preset")
     base_id: str = Field(..., description="Base preset ID to inherit from")
@@ -70,28 +78,31 @@ class SlicerSettingCreate(BaseModel):
 
 class SlicerSettingUpdate(BaseModel):
     """Request to update an existing slicer preset."""
-    name: Optional[str] = Field(None, description="New display name")
-    setting: Optional[dict] = Field(None, description="Setting key-value pairs to update")
+
+    name: str | None = Field(None, description="New display name")
+    setting: dict | None = Field(None, description="Setting key-value pairs to update")
 
 
 class SlicerSettingDetail(BaseModel):
     """Detailed slicer setting/preset response."""
-    message: Optional[str] = None
-    code: Optional[str] = None
-    error: Optional[str] = None
+
+    message: str | None = None
+    code: str | None = None
+    error: str | None = None
     public: bool = False
-    version: Optional[str] = None
+    version: str | None = None
     type: 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)
-    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):
     """Response from deleting a preset."""
+
     success: bool
     message: str

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

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

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

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

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

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

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

@@ -46,7 +46,9 @@ class NotificationProviderBase(BaseModel):
 
     # 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_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_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
 
 
@@ -20,6 +21,7 @@ class PrintQueueItemCreate(BaseModel):
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
+    manual_start: bool = False  # Requires manual trigger to start (staged)
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -28,6 +30,7 @@ class PrintQueueItemUpdate(BaseModel):
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
     auto_off_after: bool | None = None
+    manual_start: bool | None = None
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -38,6 +41,7 @@ class PrintQueueItemResponse(BaseModel):
     scheduled_time: UTCDatetime
     require_previous_success: bool
     auto_off_after: bool
+    manual_start: bool
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_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
     # Number of printable objects in current print (for skip objects feature)
     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(
     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.
 
     This is a lightweight function used during print start to get the list
@@ -351,16 +351,17 @@ def extract_printable_objects_from_3mf(
     Args:
         data: Raw bytes of the 3MF file
         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:
         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
     from io import BytesIO
 
     printable_objects: dict = {}
+    bbox_all: list | None = None
 
     try:
         with zipfile.ZipFile(BytesIO(data), "r") as zf:
@@ -371,7 +372,6 @@ def extract_printable_objects_from_3mf(
             root = ET.fromstring(content)
 
             # Find the correct plate
-            plate_idx = plate_number or 1
             if plate_number:
                 plate = root.find(f".//plate[@plate_idx='{plate_number}']")
                 if plate is None:
@@ -382,19 +382,35 @@ def extract_printable_objects_from_3mf(
             if plate is None:
                 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
-            bbox_objects = []
+            # Build a lookup by name since bbox_objects.id != slice_info identify_id
+            bbox_by_name: dict[str, list] = {}
             if include_positions:
                 plate_json_path = f"Metadata/plate_{plate_idx}.json"
                 if plate_json_path in zf.namelist():
                     try:
                         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):
                         pass
 
             # Extract objects from slice_info.config
-            objects_list = []
             for obj in plate.findall("object"):
                 identify_id = obj.get("identify_id")
                 name = obj.get("name")
@@ -403,27 +419,25 @@ def extract_printable_objects_from_3mf(
                 if identify_id and name and skipped.lower() != "true":
                     try:
                         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:
                         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:
         pass
 
+    if include_positions:
+        return printable_objects, bbox_all
     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.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.models.archive import PrintArchive
@@ -40,9 +40,7 @@ class ArchiveComparisonService:
 
         # Fetch archives
         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()}
 
@@ -90,7 +88,7 @@ class ArchiveComparisonService:
 
             # Check if values differ
             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": field_name,
@@ -130,7 +128,7 @@ class ArchiveComparisonService:
         # Find settings that differ between successful and failed
         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":
                 continue
 
@@ -147,26 +145,30 @@ class ArchiveComparisonService:
 
                 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"
-                    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:
                 # 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:
-                    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 {
             "has_both_outcomes": True,
@@ -190,9 +192,7 @@ class ArchiveComparisonService:
             List of similar archives with match reasons
         """
         # 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()
 
         if not reference:
@@ -213,16 +213,18 @@ class ArchiveComparisonService:
                 .limit(limit)
             )
             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
         if reference.content_hash and len(similar) < limit:
@@ -237,16 +239,18 @@ class ArchiveComparisonService:
             )
             for a in result.scalars().all():
                 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
         if reference.filament_type and len(similar) < limit:
@@ -261,16 +265,18 @@ class ArchiveComparisonService:
             )
             for a in result.scalars().all():
                 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
         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.
 """
 
-import httpx
-import json
 import logging
-from typing import Optional
-from pathlib import Path
 from datetime import datetime, timedelta
 
+import httpx
+
 logger = logging.getLogger(__name__)
 
 BAMBU_API_BASE = "https://api.bambulab.com"
@@ -19,11 +17,13 @@ BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 
 class BambuCloudError(Exception):
     """Base exception for Bambu Cloud errors."""
+
     pass
 
 
 class BambuCloudAuthError(BambuCloudError):
     """Authentication related errors."""
+
     pass
 
 
@@ -32,9 +32,9 @@ class BambuCloudService:
 
     def __init__(self, region: str = "global"):
         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)
 
     @property
@@ -42,9 +42,7 @@ class BambuCloudService:
         """Check if we have a valid token."""
         if not self.access_token:
             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:
         """Get headers for authenticated requests."""
@@ -69,7 +67,7 @@ class BambuCloudService:
                 json={
                     "account": email,
                     "password": password,
-                }
+                },
             )
 
             data = response.json()
@@ -78,28 +76,16 @@ class BambuCloudService:
                 # Check if we need verification code
                 # Bambu API returns loginType or may require tfaKey
                 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)
                 if "accessToken" in 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
             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:
             logger.error(f"Login request failed: {e}")
@@ -116,22 +102,16 @@ class BambuCloudService:
                 json={
                     "account": email,
                     "code": code,
-                }
+                },
             )
 
             data = response.json()
 
             if response.status_code == 200 and "accessToken" in 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:
             logger.error(f"Verification failed: {e}")
@@ -162,8 +142,7 @@ class BambuCloudService:
 
         try:
             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:
@@ -188,7 +167,7 @@ class BambuCloudService:
             response = await self._client.get(
                 f"{self.base_url}/v1/iot-service/api/slicer/setting",
                 headers=self._get_headers(),
-                params={"version": version}
+                params={"version": version},
             )
 
             data = response.json()
@@ -208,8 +187,7 @@ class BambuCloudService:
 
         try:
             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:
@@ -220,7 +198,9 @@ class BambuCloudService:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
 
-    async def create_setting(self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0") -> dict:
+    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.
 
@@ -240,6 +220,7 @@ class BambuCloudService:
         try:
             # Add timestamp if not present
             import time
+
             if "updated_time" not in setting:
                 setting["updated_time"] = str(int(time.time()))
 
@@ -252,9 +233,7 @@ class BambuCloudService:
             }
 
             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()
@@ -320,6 +299,7 @@ class BambuCloudService:
 
             # Update the timestamp
             import time
+
             updated_setting["updated_time"] = str(int(time.time()))
 
             # Ensure settings_id field matches the name
@@ -338,9 +318,7 @@ class BambuCloudService:
             }
 
             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()
@@ -369,8 +347,7 @@ class BambuCloudService:
 
         try:
             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):
@@ -390,8 +367,7 @@ class BambuCloudService:
 
         try:
             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:
@@ -408,7 +384,7 @@ class BambuCloudService:
 
 
 # Singleton instance
-_cloud_service: Optional[BambuCloudService] = None
+_cloud_service: BambuCloudService | None = None
 
 
 def get_cloud_service() -> BambuCloudService:

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

@@ -1,10 +1,10 @@
-import ssl
-import socket
 import asyncio
 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 pathlib import Path
 
 logger = logging.getLogger(__name__)
 
@@ -19,7 +19,7 @@ class ImplicitFTP_TLS(FTP_TLS):
         self.ssl_context.check_hostname = False
         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)."""
         if host:
             self.host = host
@@ -31,14 +31,10 @@ class ImplicitFTP_TLS(FTP_TLS):
             self.source_address = source_address
 
         # 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.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()
         return self.welcome
 
@@ -50,12 +46,11 @@ class ImplicitFTP_TLS(FTP_TLS):
             conn = self.ssl_context.wrap_socket(
                 conn,
                 server_hostname=self.host,
-                session=self.sock.session  # Reuse session!
+                session=self.sock.session,  # Reuse session!
             )
         return conn, size
 
 
-
 class BambuFTPClient:
     """FTP client for retrieving files from Bambu Lab printers."""
 
@@ -69,11 +64,15 @@ class BambuFTPClient:
     def connect(self) -> bool:
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
+            logger.debug(f"FTP connecting to {self.ip_address}:{self.FTP_PORT}")
             self._ftp = ImplicitFTP_TLS()
             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)
+            logger.debug("FTP logged in, setting prot_p and passive mode")
             self._ftp.prot_p()
             self._ftp.set_pasv(True)
+            logger.info(f"FTP connected successfully to {self.ip_address}")
             return True
         except Exception as e:
             logger.warning(f"FTP connection failed to {self.ip_address}: {e}")
@@ -112,6 +111,7 @@ class BambuFTPClient:
                     mtime = None
                     try:
                         from datetime import datetime
+
                         month = parts[5]
                         day = parts[6]
                         time_or_year = parts[7]
@@ -141,8 +141,9 @@ class BambuFTPClient:
                     if mtime:
                         file_entry["mtime"] = mtime
                     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
 
@@ -168,10 +169,12 @@ class BambuFTPClient:
             local_path.parent.mkdir(parents=True, exist_ok=True)
             with open(local_path, "wb") as f:
                 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
         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
             if local_path.exists():
                 try:
@@ -183,7 +186,7 @@ class BambuFTPClient:
     def upload_file(self, local_path: Path, remote_path: str) -> bool:
         """Upload a file to the printer."""
         if not self._ftp:
-            logger.warning(f"upload_file: FTP not connected")
+            logger.warning("upload_file: FTP not connected")
             return False
 
         try:
@@ -289,8 +292,9 @@ async def download_file_async(
     access_code: str,
     remote_path: str,
     local_path: Path,
+    timeout: float = 60.0,
 ) -> bool:
-    """Async wrapper for downloading a file."""
+    """Async wrapper for downloading a file with timeout."""
     loop = asyncio.get_event_loop()
 
     def _download():
@@ -302,7 +306,11 @@ async def download_file_async(
                 client.disconnect()
         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(
@@ -320,10 +328,7 @@ async def download_file_try_paths_async(
             return False
 
         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:
             client.disconnect()
 
@@ -358,8 +363,9 @@ async def list_files_async(
     ip_address: str,
     access_code: str,
     path: str = "/",
+    timeout: float = 30.0,
 ) -> list[dict]:
-    """Async wrapper for listing files."""
+    """Async wrapper for listing files with timeout."""
     loop = asyncio.get_event_loop()
 
     def _list():
@@ -371,7 +377,11 @@ async def list_files_async(
                 client.disconnect()
         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(

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

@@ -150,6 +150,11 @@ class PrinterState:
     printable_objects: dict = field(default_factory=dict)
     # Objects that have been skipped during the current print
     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
@@ -698,12 +703,15 @@ class BambuMQTTClient:
         import hashlib
 
         # 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
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             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
             # 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
+            # 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:
                 raw_tray_now = ams_data["tray_now"]
                 # Convert string to int if needed
@@ -767,33 +776,61 @@ class BambuMQTTClient:
                         active_ext = self.state.active_extruder  # 0=right, 1=left
                         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(
                                     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:
                                 # Fallback: use slot as-is
                                 logger.warning(
@@ -813,17 +850,36 @@ class BambuMQTTClient:
 
             # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct
             # 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):
             ams_list = ams_data
         else:
             logger.warning(f"[{self.serial_number}] Unexpected AMS data format: {type(ams_data)}")
             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")
         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
         # 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:
             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
         if "stg_cur" in data:
             new_stg = data["stg_cur"]

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

@@ -566,7 +566,7 @@ class TasmotaScanner:
                 try:
                     await asyncio.gather(*tasks, return_exceptions=True)
                 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))
 
             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.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.settings import Settings
 
 logger = logging.getLogger(__name__)
 
@@ -77,9 +76,7 @@ class NotificationService:
         if event_type in self._template_cache:
             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()
 
         if template:
@@ -109,6 +106,7 @@ class NotificationService:
     def _clean_filename(self, filename: str) -> str:
         """Extract filename and remove file extensions."""
         import os
+
         # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)
         filename = os.path.basename(filename)
         # Remove common extensions
@@ -334,11 +332,13 @@ class NotificationService:
 
         # Discord embed format for nicer messages
         data = {
-            "embeds": [{
-                "title": title,
-                "description": message,
-                "color": 0x00AE42,  # Bambu green
-            }]
+            "embeds": [
+                {
+                    "title": title,
+                    "description": message,
+                    "color": 0x00AE42,  # Bambu green
+                }
+            ]
         }
 
         client = await self._get_client()
@@ -422,9 +422,7 @@ class NotificationService:
         self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
     ):
         """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()
         if provider:
             if success:
@@ -443,13 +441,13 @@ class NotificationService:
         """Get all enabled providers that want a specific event type."""
         # Build the query dynamically based on event field
         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:
             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)
@@ -700,9 +698,7 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "print_progress", variables)
         await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name)
 
-    async def on_printer_offline(
-        self, printer_id: int, printer_name: str, db: AsyncSession
-    ):
+    async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
         if not providers:
@@ -814,7 +810,9 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # 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(
         self,
@@ -839,7 +837,9 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # 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(
         self,
@@ -865,7 +865,9 @@ class NotificationService:
         # 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)
         # 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(
         self,
@@ -891,7 +893,9 @@ class NotificationService:
         # 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)
         # 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):
         """Clear the template cache. Call this when templates are updated."""
@@ -929,9 +933,7 @@ class NotificationService:
 
         async with async_session() as db:
             # 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()
 
             if not provider or not provider.enabled:
@@ -1011,8 +1013,8 @@ class NotificationService:
             # Find all providers with digest enabled at this time
             result = await db.execute(
                 select(NotificationProvider).where(
-                    NotificationProvider.enabled == True,
-                    NotificationProvider.daily_digest_enabled == True,
+                    NotificationProvider.enabled.is_(True),
+                    NotificationProvider.daily_digest_enabled.is_(True),
                     NotificationProvider.daily_digest_time == current_time,
                 )
             )

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

@@ -3,16 +3,15 @@
 import asyncio
 import logging
 from datetime import datetime
-from pathlib import Path
 
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
 from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 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.services.bambu_ftp import upload_file_async
 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():
                     continue
 
+                # Skip items that require manual start
+                if item.manual_start:
+                    continue
+
                 # Check if printer is idle
                 printer_idle = self._is_printer_idle(item.printer_id)
                 printer_connected = printer_manager.is_connected(item.printer_id)
@@ -128,9 +131,7 @@ class PrintScheduler:
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """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()
 
     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}")
 
         # 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()
         if not archive:
             item.status = "failed"
@@ -236,9 +235,7 @@ class PrintScheduler:
             return
 
         # 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()
         if not printer:
             item.status = "failed"
@@ -296,6 +293,7 @@ class PrintScheduler:
 
         # Register as expected print so we don't create a duplicate archive
         from backend.app.main import register_expected_print
+
         register_expected_print(item.printer_id, remote_filename, archive.id)
 
         # 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),
         # Printable objects count for skip objects feature
         "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
     # 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 typing import TYPE_CHECKING
 
-from sqlalchemy.ext.asyncio import AsyncSession
 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.tasmota import tasmota_service
 
 if TYPE_CHECKING:
     from backend.app.models.smart_plug import SmartPlug
@@ -64,8 +64,8 @@ class SmartPlugManager:
         async with async_session() as db:
             result = await db.execute(
                 select(SmartPlug).where(
-                    SmartPlug.enabled == True,
-                    SmartPlug.schedule_enabled == True,
+                    SmartPlug.enabled.is_(True),
+                    SmartPlug.schedule_enabled.is_(True),
                 )
             )
             plugs = result.scalars().all()
@@ -98,15 +98,11 @@ class SmartPlugManager:
 
             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."""
         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()
 
     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
             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.
 
         Only triggers auto-off on successful completion (status='completed').
@@ -168,10 +162,7 @@ class SmartPlugManager:
             )
             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":
             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
         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)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -231,17 +220,12 @@ class SmartPlugManager:
         finally:
             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."""
         # Cancel any existing task for this plug
         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)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -295,10 +279,7 @@ class SmartPlugManager:
                             f"threshold={temp_threshold}°C"
                         )
                     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:
                         # All nozzles are below threshold, turn off
@@ -328,9 +309,7 @@ class SmartPlugManager:
                 elapsed += check_interval
 
             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:
             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
 
             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()
                 if plug:
                     plug.auto_off_pending = pending
@@ -363,9 +340,7 @@ class SmartPlugManager:
             from backend.app.models.smart_plug import SmartPlug
 
             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()
                 if plug:
                     plug.auto_off = False  # Disable auto-off (one-shot behavior)
@@ -407,8 +382,8 @@ class SmartPlugManager:
                 # Find all plugs with pending auto-off
                 result = await db.execute(
                     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()
@@ -419,7 +394,7 @@ class SmartPlugManager:
                         elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
                         if elapsed > 7200:  # 2 hours
                             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"
                             )
                             plug.auto_off_pending = False
@@ -427,10 +402,7 @@ class SmartPlugManager:
                             await db.commit()
                             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
                     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
-        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
         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:
         """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:
             tray: The AMSTray containing filament info
 
         Returns:
             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
 
+        # Search internal filaments - only match Bambu Lab vendor
+        filaments = await self.get_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)
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
@@ -628,11 +637,10 @@ class SpoolmanClient:
                 # Found in external library - need to create internal copy
                 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(
             name=tray.tray_sub_brands or tray.tray_type,
-            vendor_id=vendor_id,
+            vendor_id=bambu_vendor_id,
             material=tray.tray_type,
             color_hex=color_hex,
             weight=tray.tray_weight,

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

@@ -1,8 +1,6 @@
 """Service for communicating with Tasmota devices via HTTP API."""
 
-import asyncio
 import logging
-from datetime import datetime
 from typing import TYPE_CHECKING
 
 import httpx
@@ -70,9 +68,7 @@ class TasmotaService:
             - reachable: bool
             - 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:
             return {"state": None, "reachable": False, "device_name": None}
@@ -89,9 +85,7 @@ class TasmotaService:
 
     async def turn_on(self, plug: "SmartPlug") -> bool:
         """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:
             return False
@@ -103,17 +97,13 @@ class TasmotaService:
         if success:
             logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}")
         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
 
     async def turn_off(self, plug: "SmartPlug") -> bool:
         """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:
             return False
@@ -125,17 +115,13 @@ class TasmotaService:
         if success:
             logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}")
         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
 
     async def toggle(self, plug: "SmartPlug") -> bool:
         """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:
             return False
@@ -144,9 +130,7 @@ class TasmotaService:
         success = state in ["ON", "OFF"]
 
         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
 
@@ -161,9 +145,7 @@ class TasmotaService:
             - total: Total energy in kWh
             - 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:
             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:
     """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()
 
     if setting:
@@ -47,9 +45,7 @@ async def get_or_create_installation_id(db: AsyncSession) -> str:
 
 async def is_telemetry_enabled(db: AsyncSession) -> bool:
     """Check if telemetry is enabled (opt-out model)."""
-    result = await db.execute(
-        select(Settings).where(Settings.key == "telemetry_enabled")
-    )
+    result = await db.execute(select(Settings).where(Settings.key == "telemetry_enabled"))
     setting = result.scalar_one_or_none()
 
     # 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:
     """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()
 
     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."""
 
-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:
 - CA certificate mimics "BBL CA" from "BBL Technologies Co., Ltd"
 - 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
@@ -21,6 +24,9 @@ logger = logging.getLogger(__name__)
 # Default serial number for virtual printer (matches SSDP/MQTT config)
 DEFAULT_SERIAL = "00M09A391800001"
 
+# Minimum days remaining before CA is considered expired and needs regeneration
+CA_EXPIRY_THRESHOLD_DAYS = 30
+
 
 def _get_local_ip() -> str:
     """Get the local IP address."""
@@ -67,8 +73,70 @@ class CertificateService:
             return self.cert_path, self.key_path
         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]:
-        """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
         may specifically reject certificates claiming to be from BBL but
@@ -77,7 +145,7 @@ class CertificateService:
         Returns:
             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
         ca_key = rsa.generate_private_key(
@@ -126,11 +194,11 @@ class CertificateService:
         return ca_key, ca_cert
 
     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:
-        - 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:
             Tuple of (cert_path, key_path)
@@ -140,19 +208,8 @@ class CertificateService:
         # Ensure directory exists
         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
         printer_key = rsa.generate_private_key(
@@ -246,9 +303,30 @@ class CertificateService:
         logger.info(f"  Printer: CN={self.serial}")
         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():
                 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:
             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:
         """Handle PASV command - set up passive data connection."""
         if not self.authenticated:
@@ -244,6 +278,16 @@ class FTPSession:
 
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
         """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}")
         self._data_reader = reader
         self._data_writer = writer
@@ -252,6 +296,8 @@ class FTPSession:
 
     async def _close_data_connection(self) -> None:
         """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:
             try:
                 self._data_writer.close()
@@ -269,6 +315,10 @@ class FTPSession:
                 pass
             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:
         """Handle STOR command - receive file upload."""
         if not self.authenticated:
@@ -436,6 +486,7 @@ class VirtualPrinterFTPServer:
         self._server: asyncio.Server | None = None
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
+        self._active_sessions: list[asyncio.Task] = []
 
     async def start(self) -> None:
         """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.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:
             # Create server with SSL - TLS handshake happens before any FTP data
             self._server = await asyncio.start_server(
@@ -495,13 +551,31 @@ class VirtualPrinterFTPServer:
             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:
         """Stop the FTPS server."""
         logger.info("Stopping FTP server")
         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:
             try:
                 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__)
 
 
+# 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:
     """Manages the virtual printer lifecycle and coordinates all services."""
 
     # Fixed configuration
     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):
         """Initialize the virtual printer manager."""
@@ -29,6 +82,7 @@ class VirtualPrinterManager:
         self._enabled = False
         self._access_code = ""
         self._mode = "immediate"
+        self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
 
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
@@ -43,12 +97,29 @@ class VirtualPrinterManager:
         self._upload_dir = self._base_dir / "uploads"
         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
         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:
         """Set the database session factory.
 
@@ -72,6 +143,7 @@ class VirtualPrinterManager:
         enabled: bool,
         access_code: str = "",
         mode: str = "immediate",
+        model: str = "",
     ) -> None:
         """Configure and start/stop virtual printer.
 
@@ -79,17 +151,43 @@ class VirtualPrinterManager:
             enabled: Whether to enable the virtual printer
             access_code: Authentication password for slicer connections
             mode: Archive mode - 'immediate' or 'queue'
+            model: SSDP model code (e.g., 'BL-P001' for X1C)
         """
         if enabled and not access_code:
             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._mode = mode
+        self._model = new_model
 
         if enabled and not self._enabled:
+            logger.info("Starting virtual printer (was disabled)")
             await self._start()
         elif not enabled and self._enabled:
+            logger.info("Stopping virtual printer (was enabled)")
             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
 
@@ -97,8 +195,14 @@ class VirtualPrinterManager:
         """Start all 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
         self._upload_dir.mkdir(parents=True, exist_ok=True)
@@ -107,8 +211,8 @@ class VirtualPrinterManager:
         # Initialize services
         self._ssdp = VirtualPrinterSSDPServer(
             name=self.PRINTER_NAME,
-            serial=self.PRINTER_SERIAL,
-            model=self.PRINTER_MODEL,
+            serial=self.printer_serial,
+            model=self._model,
         )
 
         self._ftp = VirtualPrinterFTPServer(
@@ -120,7 +224,7 @@ class VirtualPrinterManager:
         )
 
         self._mqtt = SimpleMQTTServer(
-            serial=self.PRINTER_SERIAL,
+            serial=self.printer_serial,
             access_code=self._access_code,
             cert_path=cert_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"),
         ]
 
-        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:
         """Stop all 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:
             await self._ftp.stop()
             self._ftp = None
@@ -170,6 +260,22 @@ class VirtualPrinterManager:
             await self._mqtt.stop()
             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")
 
     async def _on_file_received(self, file_path: Path, source_ip: str) -> None:
@@ -313,8 +419,9 @@ class VirtualPrinterManager:
             "running": self.is_running,
             "mode": self._mode,
             "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),
         }
 

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

@@ -289,8 +289,8 @@ class SimpleMQTTServer:
                 pass
             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:
                 writer.close()
                 await writer.wait_closed()

+ 68 - 62
backend/tests/conftest.py

@@ -5,25 +5,26 @@ import json
 import logging
 import os
 import sys
-import pytest
-from typing import AsyncGenerator
+from collections.abc import AsyncGenerator
 from datetime import datetime
 from unittest.mock import AsyncMock, MagicMock, patch
 
+import pytest
+
 # IMPORTANT: Set environment variables BEFORE any app imports
 # This must happen before settings/config are loaded
 os.environ["LOG_TO_FILE"] = "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
-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
 TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@@ -44,10 +45,20 @@ async def test_engine():
 
     # Import all models to register them
     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:
@@ -63,9 +74,7 @@ async def test_engine():
 @pytest.fixture
 async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
     """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:
         yield session
 
@@ -73,13 +82,11 @@ async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
 @pytest.fixture
 async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, None]:
     """Create an async test client."""
+    from backend.app.core.database import async_session, get_db
     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
-    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 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
 
     # 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
 
     app.dependency_overrides.clear()
@@ -102,33 +106,30 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
 # Mock External Services
 # ============================================================================
 
+
 @pytest.fixture
 def mock_tasmota_service():
     """Mock the Tasmota service for smart plug tests."""
     # 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_off = 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
         mock2.turn_on = mock.turn_on
         mock2.turn_off = mock.turn_off
@@ -142,14 +143,9 @@ def mock_tasmota_service():
 @pytest.fixture
 def mock_mqtt_client():
     """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.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.disconnect = MagicMock()
         mock.return_value = instance
@@ -159,8 +155,10 @@ def mock_mqtt_client():
 @pytest.fixture
 def mock_ftp_client():
     """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
         list_mock.return_value = []
         yield {"download": download_mock, "list": list_mock}
@@ -169,7 +167,7 @@ def mock_ftp_client():
 @pytest.fixture
 def mock_httpx_client():
     """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_response = MagicMock()
         mock_response.status_code = 200
@@ -188,14 +186,16 @@ def mock_httpx_client():
 @pytest.fixture
 def mock_printer_manager():
     """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()
         yield mock
 
@@ -204,9 +204,11 @@ def mock_printer_manager():
 # Factory Fixtures for Test Data
 # ============================================================================
 
+
 @pytest.fixture
 def smart_plug_factory(db_session):
     """Factory to create test smart plugs."""
+
     async def _create_plug(**kwargs):
         from backend.app.models.smart_plug import SmartPlug
 
@@ -267,6 +269,7 @@ def printer_factory(db_session):
 @pytest.fixture
 def notification_provider_factory(db_session):
     """Factory to create test notification providers."""
+
     async def _create_provider(**kwargs):
         from backend.app.models.notification import NotificationProvider
 
@@ -307,6 +310,7 @@ def notification_provider_factory(db_session):
 @pytest.fixture
 def archive_factory(db_session):
     """Factory to create test archives."""
+
     async def _create_archive(printer_id: int, **kwargs):
         from backend.app.models.archive import PrintArchive
 
@@ -336,6 +340,7 @@ def archive_factory(db_session):
 # Sample Data Fixtures
 # ============================================================================
 
+
 @pytest.fixture
 def sample_mqtt_print_start():
     """Sample MQTT message for print start."""
@@ -385,6 +390,7 @@ def sample_printer_status():
 # Log Capture Fixtures for Error Detection
 # ============================================================================
 
+
 class LogCapture(logging.Handler):
     """Handler that captures log records for testing."""
 
@@ -415,7 +421,7 @@ class LogCapture(logging.Handler):
         errors = self.get_errors()
         if not 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)
 
 

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

@@ -1,7 +1,8 @@
 """Integration tests for AMS History API endpoints."""
 
-import pytest
 from datetime import datetime, timedelta
+
+import pytest
 from httpx import AsyncClient
 
 
@@ -11,6 +12,7 @@ class TestAMSHistoryAPI:
     @pytest.fixture
     async def ams_history_factory(self, db_session, printer_factory):
         """Factory to create test AMS history records."""
+
         async def _create_history(printer_id=None, ams_id=0, **kwargs):
             from backend.app.models.ams_history import AMSSensorHistory
 
@@ -38,9 +40,7 @@ class TestAMSHistoryAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory()
         response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
@@ -52,9 +52,7 @@ class TestAMSHistoryAPI:
 
     @pytest.mark.asyncio
     @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."""
         # Create history records
         history = await ams_history_factory()
@@ -95,15 +93,9 @@ class TestAMSHistoryAPI:
         """Verify hours parameter filters data."""
         printer = await printer_factory()
         # 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)
-        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)
         response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
@@ -114,15 +106,10 @@ class TestAMSHistoryAPI:
 
     @pytest.mark.asyncio
     @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."""
         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
         data = response.json()
         assert data["printer_id"] == printer.id
@@ -159,31 +146,20 @@ class TestAMSHistoryAPI:
         """Verify old history can be deleted."""
         printer = await printer_factory()
         # 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
-        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
         data = response.json()
         assert data["deleted"] >= 1
 
     @pytest.mark.asyncio
     @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."""
         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
         data = response.json()
         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(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
         data = response.json()
@@ -86,9 +84,7 @@ class TestArchivesAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory()
         archive = await archive_factory(printer.id, print_name="Get Test Archive")
@@ -114,34 +110,24 @@ class TestArchivesAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory()
         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.json()["print_name"] == "Updated Name"
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory()
         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.json()["notes"] == "Great print!"
@@ -155,10 +141,7 @@ class TestArchivesAPI:
         printer = await printer_factory()
         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.json()["is_favorite"] is True
@@ -169,9 +152,7 @@ class TestArchivesAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory()
         archive = await archive_factory(printer.id)
@@ -199,9 +180,7 @@ class TestArchivesAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory()
         await archive_factory(
@@ -282,10 +261,7 @@ class TestArchiveDataIntegrity:
         archive = await archive_factory(printer.id, notes="Original notes")
 
         # 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
         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.
 """
 
-import pytest
-from unittest.mock import patch, AsyncMock, MagicMock
 import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
 from httpx import AsyncClient
 
 
@@ -64,7 +65,7 @@ class TestCameraAPI:
         mock_process.returncode = None
         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")
 
         assert response.status_code == 200
@@ -92,7 +93,7 @@ class TestCameraAPI:
             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")
 
         assert response.status_code == 200
@@ -119,7 +120,7 @@ class TestCameraAPI:
         """Verify camera test returns success when camera is accessible."""
         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"}
 
             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."""
         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"}
 
             response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
@@ -162,18 +163,17 @@ class TestCameraAPI:
         printer = await printer_factory()
 
         # 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 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
 
-                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
         # In production tests, we'd mock more comprehensively
@@ -184,11 +184,10 @@ class TestCameraAPI:
         """Verify 503 when camera capture fails."""
         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
 
-            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")
 
         assert response.status_code == 503
@@ -216,11 +215,11 @@ class TestCameraAPI:
         # Testing that the endpoint accepts various FPS values without error
         # (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
             response = await async_client.get(
                 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
             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.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."""
         await link_factory(name="My Link")
         response = await async_client.get("/api/v1/external-links/")
@@ -71,9 +69,7 @@ class TestExternalLinksAPI:
 
     @pytest.mark.asyncio
     @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."""
         link = await link_factory(name="Get Test Link")
         response = await async_client.get(f"/api/v1/external-links/{link.id}")
@@ -89,14 +85,11 @@ class TestExternalLinksAPI:
 
     @pytest.mark.asyncio
     @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."""
         link = await link_factory(name="Original")
         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
         result = response.json()
@@ -105,9 +98,7 @@ class TestExternalLinksAPI:
 
     @pytest.mark.asyncio
     @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."""
         link = await link_factory()
         response = await async_client.delete(f"/api/v1/external-links/{link.id}")
@@ -118,9 +109,7 @@ class TestExternalLinksAPI:
 
     @pytest.mark.asyncio
     @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."""
         link1 = await link_factory(name="Link 1")
         link2 = await link_factory(name="Link 2")
@@ -128,8 +117,7 @@ class TestExternalLinksAPI:
 
         # Reorder: 3, 1, 2
         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
         data = response.json()
@@ -144,6 +132,7 @@ class TestExternalLinksIconAPI:
     @pytest.fixture
     async def link_factory(self, db_session):
         """Factory to create test external links."""
+
         async def _create_link(**kwargs):
             from backend.app.models.external_link import ExternalLink
 
@@ -165,9 +154,7 @@ class TestExternalLinksIconAPI:
 
     @pytest.mark.asyncio
     @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."""
         link = await link_factory()
         response = await async_client.get(f"/api/v1/external-links/{link.id}/icon")
@@ -175,9 +162,7 @@ class TestExternalLinksIconAPI:
 
     @pytest.mark.asyncio
     @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."""
         link = await link_factory()
         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
     async def filament_factory(self, db_session):
         """Factory to create test filaments."""
+
         async def _create_filament(**kwargs):
             from backend.app.models.filament import Filament
 
@@ -41,9 +42,7 @@ class TestFilamentsAPI:
 
     @pytest.mark.asyncio
     @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."""
         await filament_factory(name="Test Filament")
         response = await async_client.get("/api/v1/filaments/")
@@ -71,9 +70,7 @@ class TestFilamentsAPI:
 
     @pytest.mark.asyncio
     @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."""
         filament = await filament_factory(name="Get Test")
         response = await async_client.get(f"/api/v1/filaments/{filament.id}")
@@ -89,14 +86,11 @@ class TestFilamentsAPI:
 
     @pytest.mark.asyncio
     @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."""
         filament = await filament_factory(name="Original")
         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
         result = response.json()
@@ -105,9 +99,7 @@ class TestFilamentsAPI:
 
     @pytest.mark.asyncio
     @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."""
         filament = await filament_factory()
         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",
             "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
         type_id = create_response.json()["id"]
 
         # Update it
         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.json()["description"] == "Updated description"
 
@@ -80,9 +76,7 @@ class TestMaintenanceTypesAPI:
             "description": "To be deleted",
             "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"]
 
         # Delete it
@@ -102,9 +96,7 @@ class TestPrinterMaintenanceAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory(name="Maintenance Test Printer")
         response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}")
@@ -117,9 +109,7 @@ class TestPrinterMaintenanceAPI:
 
     @pytest.mark.asyncio
     @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."""
         await printer_factory(name="Overview Printer 1")
         await printer_factory(name="Overview Printer 2")
@@ -158,50 +148,39 @@ class TestMaintenanceItemsAPI:
 
     @pytest.mark.asyncio
     @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."""
         if not maintenance_item:
             pytest.skip("No maintenance items available")
 
         item_id = maintenance_item["id"]
         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
 
     @pytest.mark.asyncio
     @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."""
         if not maintenance_item:
             pytest.skip("No maintenance items available")
 
         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.json()["enabled"] is False
 
     @pytest.mark.asyncio
     @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."""
         if not maintenance_item:
             pytest.skip("No maintenance items available")
 
         item_id = maintenance_item["id"]
         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
         data = response.json()
@@ -209,19 +188,14 @@ class TestMaintenanceItemsAPI:
 
     @pytest.mark.asyncio
     @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."""
         if not maintenance_item:
             pytest.skip("No maintenance items available")
 
         item_id = maintenance_item["id"]
         # 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")
         assert response.status_code == 200
@@ -232,10 +206,7 @@ class TestMaintenanceItemsAPI:
     @pytest.mark.integration
     async def test_update_maintenance_item_not_found(self, async_client: AsyncClient):
         """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
 
 
@@ -244,14 +215,11 @@ class TestPrinterHoursAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory(name="Hours Test Printer")
         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
         data = response.json()
@@ -261,8 +229,5 @@ class TestPrinterHoursAPI:
     @pytest.mark.integration
     async def test_set_printer_hours_not_found(self, async_client: AsyncClient):
         """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

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

@@ -16,9 +16,7 @@ class TestNotificationsAPI:
 
     @pytest.mark.asyncio
     @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."""
         response = await async_client.get("/api/v1/notifications/")
 
@@ -31,7 +29,7 @@ class TestNotificationsAPI:
         self, async_client: AsyncClient, notification_provider_factory, db_session
     ):
         """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/")
 
@@ -91,9 +89,7 @@ class TestNotificationsAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory(name="Test Printer")
 
@@ -143,9 +139,7 @@ class TestNotificationsAPI:
 
     @pytest.mark.asyncio
     @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."""
         provider = await notification_provider_factory(
             on_print_start=True,
@@ -154,10 +148,7 @@ class TestNotificationsAPI:
         )
 
         # 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.json()["on_print_stopped"] is True
@@ -168,9 +159,7 @@ class TestNotificationsAPI:
 
     @pytest.mark.asyncio
     @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."""
         provider = await notification_provider_factory(
             on_ams_humidity_high=False,
@@ -183,7 +172,7 @@ class TestNotificationsAPI:
             json={
                 "on_ams_humidity_high": True,
                 "on_ams_temperature_high": True,
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -199,35 +188,25 @@ class TestNotificationsAPI:
 
     @pytest.mark.asyncio
     @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."""
         provider = await notification_provider_factory(enabled=True)
 
         # 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.json()["enabled"] is False
 
         # 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.json()["enabled"] is True
 
     @pytest.mark.asyncio
     @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."""
         provider = await notification_provider_factory(quiet_hours_enabled=False)
 
@@ -237,7 +216,7 @@ class TestNotificationsAPI:
                 "quiet_hours_enabled": True,
                 "quiet_hours_start": "22:00",
                 "quiet_hours_end": "07:00",
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -248,9 +227,7 @@ class TestNotificationsAPI:
 
     @pytest.mark.asyncio
     @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."""
         provider = await notification_provider_factory(daily_digest_enabled=False)
 
@@ -259,7 +236,7 @@ class TestNotificationsAPI:
             json={
                 "daily_digest_enabled": True,
                 "daily_digest_time": "09:00",
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -287,7 +264,7 @@ class TestNotificationsAPI:
                 "on_print_start": False,
                 "on_print_stopped": True,
                 "on_printer_offline": True,
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -306,15 +283,12 @@ class TestNotificationsAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     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."""
         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
         result = response.json()
@@ -328,9 +302,7 @@ class TestNotificationsAPI:
         """Verify test notification works even for disabled provider."""
         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
         assert response.status_code == 200
@@ -371,7 +343,7 @@ class TestNotificationTemplatesAPI:
     @pytest.fixture
     async def seeded_templates(self, db_session):
         """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 = []
         for template_data in DEFAULT_TEMPLATES:
@@ -401,9 +373,7 @@ class TestNotificationTemplatesAPI:
         # Get first template ID from seeded data
         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
         template = response.json()
@@ -422,7 +392,7 @@ class TestNotificationTemplatesAPI:
             json={
                 "title_template": "Custom Title: {printer}",
                 "body_template": "Custom body for {filename}",
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -436,9 +406,7 @@ class TestNotificationTemplatesAPI:
         """Verify template can be reset to default."""
         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
         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.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."""
         await project_factory(name="My Project")
         response = await async_client.get("/api/v1/projects/")
@@ -70,9 +68,7 @@ class TestProjectsAPI:
 
     @pytest.mark.asyncio
     @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."""
         project = await project_factory(name="Get Test Project")
         response = await async_client.get(f"/api/v1/projects/{project.id}")
@@ -88,14 +84,11 @@ class TestProjectsAPI:
 
     @pytest.mark.asyncio
     @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."""
         project = await project_factory(name="Original")
         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
         result = response.json()
@@ -104,9 +97,7 @@ class TestProjectsAPI:
 
     @pytest.mark.asyncio
     @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."""
         project = await project_factory()
         response = await async_client.delete(f"/api/v1/projects/{project.id}")
@@ -128,6 +119,7 @@ class TestProjectArchivesAPI:
     @pytest.fixture
     async def project_factory(self, db_session):
         """Factory to create test projects."""
+
         async def _create_project(**kwargs):
             from backend.app.models.project import Project
 
@@ -148,9 +140,7 @@ class TestProjectArchivesAPI:
 
     @pytest.mark.asyncio
     @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."""
         project = await project_factory()
         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.
 """
 
+from unittest.mock import MagicMock, patch
+
 import pytest
-from unittest.mock import patch, MagicMock
 from httpx import AsyncClient
 
 
@@ -20,18 +21,12 @@ class TestSystemAPI:
     async def test_get_system_info(self, async_client: AsyncClient):
         """Verify system info endpoint returns expected structure."""
         # 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(
-                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(
-                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.cpu_count.return_value = 4
@@ -55,7 +50,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     async def test_system_info_app_section(self, async_client: AsyncClient):
         """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(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
@@ -79,7 +74,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     async def test_system_info_database_section(self, async_client: AsyncClient):
         """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(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
@@ -111,7 +106,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     async def test_system_info_storage_section(self, async_client: AsyncClient):
         """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(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
@@ -141,7 +136,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     async def test_system_info_memory_section(self, async_client: AsyncClient):
         """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(
                 total=500000000000, used=250000000000, free=250000000000, percent=50.0
             )
@@ -167,7 +162,7 @@ class TestSystemAPI:
     @pytest.mark.integration
     async def test_system_info_cpu_section(self, async_client: AsyncClient):
         """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(
                 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):
         """Verify printers section contains connected printer info."""
         # 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(
                 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="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(
                 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."""
 
-import pytest
 from datetime import datetime
-from unittest.mock import MagicMock, AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
 
 
 class TestArchiveServiceHelpers:
@@ -12,7 +13,7 @@ class TestArchiveServiceHelpers:
         """Test parsing print time to seconds."""
         # Import the actual function if available, otherwise test the logic
         # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
-        time_str = "2h 30m 15s"
+        _time_str = "2h 30m 15s"  # Example format
         # Parse hours
         hours = 2
         minutes = 30
@@ -24,7 +25,7 @@ class TestArchiveServiceHelpers:
         """Test parsing filament usage to grams."""
         # Example: "150.5g" -> 150.5
         filament_str = "150.5g"
-        grams = float(filament_str.replace('g', ''))
+        grams = float(filament_str.replace("g", ""))
         assert grams == 150.5
 
     def test_format_duration(self):
@@ -95,7 +96,7 @@ class TestArchiveFilePaths:
     def test_generate_archive_path(self):
         """Test generating archive file paths."""
         printer_name = "X1C_01"
-        print_name = "benchy"
+        _print_name = "benchy"  # Example print name
         timestamp = datetime(2024, 1, 15, 14, 30, 0)
 
         # Expected pattern: archives/{printer}/{year}/{month}/{filename}
@@ -110,25 +111,25 @@ class TestArchiveFilePaths:
     def test_sanitize_filename(self):
         """Test filename sanitization."""
         # Characters to remove: / \ : * ? " < > |
-        dirty_name = 'test:file<name>.3mf'
+        dirty_name = "test:file<name>.3mf"
         # Simple sanitization
         safe_chars = []
         for c in dirty_name:
             if c not in '\\/:*?"<>|':
                 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):
         """Test thumbnail path generation."""
         archive_path = "archives/X1C_01/2024/01/benchy.3mf"
         # 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"
-        assert thumbnail_path.endswith('_thumb.png')
-        assert 'benchy' in thumbnail_path
+        assert thumbnail_path.endswith("_thumb.png")
+        assert "benchy" in thumbnail_path
 
 
 class TestArchiveStatus:
@@ -199,7 +200,7 @@ class TestArchiveThumbnails:
         """Test supported thumbnail file types."""
         supported_types = [".png", ".jpg", ".jpeg"]
         for ext in supported_types:
-            assert ext.startswith('.')
+            assert ext.startswith(".")
             assert ext.lower() in [".png", ".jpg", ".jpeg"]
 
     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.
 """
 
-import pytest
 from unittest.mock import MagicMock, patch
 
+import pytest
+
 
 class TestTimelapseTracking:
     """Tests for timelapse state tracking during prints."""
@@ -140,12 +141,14 @@ class TestPrintCompletionWithTimelapse:
             mqtt_client._completion_triggered = True
             mqtt_client._was_running = 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 callback_data["timelapse_was_active"] is True
@@ -170,12 +173,14 @@ class TestPrintCompletionWithTimelapse:
 
         # Trigger completion
         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
 
@@ -254,9 +259,9 @@ class TestRealisticMessageFlow:
         # 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.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):
         """Test that timelapse is NOT detected when disabled in xcam data."""
@@ -334,40 +339,46 @@ class TestRealisticMessageFlow:
         mqtt_client.on_print_complete = on_complete
 
         # 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 "subtask_name" in start_data
 
         # 2. Print continues (multiple messages)
         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
         assert mqtt_client._timelapse_during_print is True
 
         # 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
         assert "timelapse_was_active" in complete_data
@@ -389,23 +400,27 @@ class TestRealisticMessageFlow:
         mqtt_client.on_print_complete = on_complete
 
         # 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
-        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["status"] == "failed"

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

@@ -3,11 +3,12 @@
 Tests event-based notifications and toggle behavior.
 """
 
-import pytest
 import json
 from datetime import datetime
 from unittest.mock import AsyncMock, MagicMock, patch
 
+import pytest
+
 from backend.app.services.notification_service import NotificationService
 
 
@@ -59,20 +60,13 @@ class TestNotificationService:
     # ========================================================================
 
     @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."""
-        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_build.return_value = ("Print Started", "Test Printer: test.3mf")
 
@@ -87,17 +81,12 @@ class TestNotificationService:
             mock_send.assert_called_once()
 
     @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."""
-        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 = []
 
             await service.on_print_start(
@@ -114,20 +103,13 @@ class TestNotificationService:
     # ========================================================================
 
     @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."""
-        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_build.return_value = ("Test", "Test")
 
@@ -144,20 +126,13 @@ class TestNotificationService:
             assert call_args[0][1] == "on_print_complete"
 
     @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."""
-        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_build.return_value = ("Test", "Test")
 
@@ -173,20 +148,13 @@ class TestNotificationService:
             assert call_args[0][1] == "on_print_failed"
 
     @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."""
-        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_build.return_value = ("Test", "Test")
 
@@ -202,20 +170,13 @@ class TestNotificationService:
             assert call_args[0][1] == "on_print_stopped"
 
     @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."""
-        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_build.return_value = ("Test", "Test")
 
@@ -241,15 +202,11 @@ class TestNotificationService:
 
         # The actual filtering happens in _get_providers_for_event
         # 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
             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
 
@@ -258,15 +215,11 @@ class TestNotificationService:
         """Verify providers can be filtered by specific printer."""
         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
             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
 
@@ -280,9 +233,7 @@ class TestNotificationService:
         mock_provider.quiet_hours_start = "22: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)
             mock_now = MagicMock()
             mock_now.hour = 23
@@ -299,9 +250,7 @@ class TestNotificationService:
         mock_provider.quiet_hours_start = "22: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)
             mock_now = MagicMock()
             mock_now.hour = 12
@@ -326,9 +275,7 @@ class TestNotificationService:
         mock_provider.quiet_hours_start = "22: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
             mock_now = MagicMock()
             mock_now.hour = 3
@@ -344,22 +291,15 @@ class TestNotificationService:
     # ========================================================================
 
     @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."""
         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_build.return_value = ("AMS Humidity Alert", "High humidity detected")
 
@@ -375,25 +315,18 @@ class TestNotificationService:
             mock_send.assert_called_once()
             # Verify force_immediate is True for alarms
             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
-    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."""
         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_build.return_value = ("AMS Temperature Alert", "High temp detected")
 
@@ -409,22 +342,17 @@ class TestNotificationService:
             mock_send.assert_called_once()
             # Verify force_immediate is True for alarms
             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
-    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."""
         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
             mock_get.return_value = []
 
@@ -444,23 +372,16 @@ class TestNotificationService:
     # ========================================================================
 
     @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."""
         mock_provider.daily_digest_enabled = True
         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_build.return_value = ("Test", "Test")
 
@@ -477,23 +398,16 @@ class TestNotificationService:
             mock_send.assert_called_once()
 
     @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."""
         mock_provider.daily_digest_enabled = 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_build.return_value = ("Alert", "Alert message")
 
@@ -508,7 +422,7 @@ class TestNotificationService:
 
             # Verify force_immediate is passed
             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:
@@ -534,25 +448,27 @@ class TestDigestModeAlwaysSendsImmediately:
         mock_db = AsyncMock()
 
         # 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)
 
-            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
     async def test_notification_sends_without_digest_queue_when_disabled(self, service):
@@ -568,25 +484,27 @@ class TestDigestModeAlwaysSendsImmediately:
 
         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)
 
-            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:
@@ -613,12 +531,10 @@ class TestNotificationProviderTypes:
         mock_client = AsyncMock()
         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
 
-            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
             mock_client.post.assert_called_once()
@@ -630,17 +546,13 @@ class TestNotificationProviderTypes:
             "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.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()
 
-            success, message = await service._send_webhook(
-                config, "Test", "Test"
-            )
+            success, message = await service._send_webhook(config, "Test", "Test")
 
             assert success is False
             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."""
         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_build.return_value = ("Test", "Test")
 
@@ -721,16 +628,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             return ("Test", "Test")
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        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 = []
 
             await service.on_print_complete(
@@ -762,16 +664,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             return ("Test", "Test")
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        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
             mock_get.return_value = [mock_provider]
 
@@ -801,16 +698,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             return ("Test", "Test")
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        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
             mock_get.return_value = [mock_provider]
 
@@ -839,16 +731,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             return ("Test", "Test")
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        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
             mock_get.return_value = [mock_provider]
 
@@ -876,16 +763,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             return ("Test", "Test")
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        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]
 
             # Pass archive_data with print_time_seconds (7200 seconds = 2 hours)
@@ -913,16 +795,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             return ("Test", "Test")
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        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]
 
             # Both archive_data and MQTT remaining_time provided
@@ -954,16 +831,11 @@ class TestNotificationVariableFallbacks:
             captured_variables.update(variables)
             return ("Test", "Test")
 
-        with patch.object(
-            service, '_get_providers_for_event', new_callable=AsyncMock
-        ) as mock_get, \
-             patch.object(
-            service, '_send_to_providers', new_callable=AsyncMock
-        ), \
-             patch.object(
-            service, '_build_message_from_template', side_effect=capture_build
+        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]
 
             # Only MQTT remaining_time provided (1800 seconds = 30 minutes)
@@ -1018,9 +890,7 @@ class TestNotificationTemplates:
 
         # Should handle gracefully - either leave placeholder or skip
         try:
-            result = template.format_map(
-                {**variables, "unknown_var": "{unknown_var}"}
-            )
+            result = template.format_map({**variables, "unknown_var": "{unknown_var}"})
             assert "Test" in result
         except KeyError:
             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.
 """
 
-import pytest
 import asyncio
 from datetime import datetime
 from unittest.mock import AsyncMock, MagicMock, patch
 
+import pytest
+
 from backend.app.services.smart_plug_manager import SmartPlugManager
 
 
@@ -57,11 +58,10 @@ class TestSmartPlugManager:
     @pytest.mark.asyncio
     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."""
-        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_tasmota.turn_on = AsyncMock(return_value=True)
 
@@ -70,17 +70,14 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_called_once_with(mock_plug)
 
     @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."""
         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_tasmota.turn_on = AsyncMock()
 
@@ -89,17 +86,14 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_not_called()
 
     @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."""
         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_tasmota.turn_on = AsyncMock()
 
@@ -108,15 +102,12 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_not_called()
 
     @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."""
-        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_tasmota.turn_on = AsyncMock()
 
@@ -126,22 +117,17 @@ class TestSmartPlugManager:
             mock_tasmota.turn_on.assert_not_called()
 
     @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."""
         # Set up a pending task
         mock_task = MagicMock()
         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_tasmota.turn_on = AsyncMock(return_value=True)
 
@@ -151,17 +137,14 @@ class TestSmartPlugManager:
             assert mock_plug.id not in manager._pending_off
 
     @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."""
         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_tasmota.turn_on = AsyncMock(return_value=True)
 
@@ -174,125 +157,95 @@ class TestSmartPlugManager:
     # ========================================================================
 
     @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."""
         mock_plug.off_delay_mode = "time"
         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
 
-            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
 
     @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."""
         mock_plug.off_delay_mode = "temperature"
         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
 
-            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)
 
     @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.
 
         This is a key regression test - the toggle must respect the setting.
         """
         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
 
-            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_temp.assert_not_called()
 
     @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."""
         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
 
-            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()
 
     @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."""
-        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
 
-            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()
 
     @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."""
-        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
 
-            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()
 
@@ -306,9 +259,7 @@ class TestSmartPlugManager:
         mock_task = MagicMock()
         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)
 
         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):
         """Verify no error when cancelling non-existent task."""
         # 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
 
     @pytest.mark.asyncio
@@ -331,7 +280,7 @@ class TestSmartPlugManager:
         manager._pending_off[1] = mock_task1
         manager._pending_off[2] = mock_task2
 
-        with patch('asyncio.create_task') as mock_create:
+        with patch("asyncio.create_task"):
             manager.cancel_all_pending()
 
         assert len(manager._pending_off) == 0
@@ -347,8 +296,7 @@ class TestSmartPlugManager:
         assert manager._scheduler_task is None
 
         # 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()
             manager.start_scheduler()
 
@@ -371,8 +319,7 @@ class TestSmartPlugManager:
         manager._scheduler_task = mock_task
 
         # 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()
 
             mock_create.assert_not_called()  # Should not create new task
@@ -399,16 +346,11 @@ class TestScheduleLoop:
         mock_plug.printer_id = None
         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
             mock_now = MagicMock()
             mock_now.strftime.return_value = "08:00"
@@ -444,19 +386,12 @@ class TestScheduleLoop:
         mock_plug.printer_id = 1
         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
             mock_now = MagicMock()
             mock_now.strftime.return_value = "22:00"
@@ -488,16 +423,11 @@ class TestScheduleLoop:
         mock_plug.enabled = True
         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.strftime.return_value = "08:00"
             mock_datetime.now.return_value = mock_now
@@ -541,13 +471,10 @@ class TestPendingAutoOffPersistence:
         mock_plug.off_delay_mode = "temperature"
         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_result = MagicMock()
             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.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_result = MagicMock()
             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.
 """
 
-import pytest
 from unittest.mock import AsyncMock, MagicMock, patch
+
 import httpx
+import pytest
 
 from backend.app.services.tasmota import TasmotaService
 
@@ -39,9 +40,7 @@ class TestTasmotaService:
 
     def test_build_url_with_auth(self, service):
         """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"
 
     def test_build_url_encodes_special_characters(self, service):
@@ -56,24 +55,18 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_turn_on_success(self, service, mock_plug):
         """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"}
 
             result = await service.turn_on(mock_plug)
 
             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
     async def test_turn_on_failure(self, service, mock_plug):
         """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
 
             result = await service.turn_on(mock_plug)
@@ -86,16 +79,12 @@ class TestTasmotaService:
         mock_plug.username = "admin"
         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"}
 
             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
@@ -104,9 +93,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_turn_off_success(self, service, mock_plug):
         """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"}
 
             result = await service.turn_off(mock_plug)
@@ -116,9 +103,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_turn_off_failure(self, service, mock_plug):
         """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
 
             result = await service.turn_off(mock_plug)
@@ -132,17 +117,13 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_toggle_success(self, service, mock_plug):
         """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"}
 
             result = await service.toggle(mock_plug)
 
             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
@@ -151,9 +132,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_get_status_returns_on(self, service, mock_plug):
         """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
             mock_send.return_value = {"POWER": "ON"}
 
@@ -166,9 +145,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_get_status_returns_off(self, service, mock_plug):
         """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
             mock_send.return_value = {"POWER": "OFF"}
 
@@ -180,9 +157,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_get_status_unreachable(self, service, mock_plug):
         """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
 
             result = await service.get_status(mock_plug)
@@ -197,9 +172,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_get_energy_returns_data(self, service, mock_plug):
         """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 = {
                 "StatusSNS": {
                     "ENERGY": {
@@ -226,9 +199,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_get_energy_handles_missing_data(self, service, mock_plug):
         """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": {}}
 
             result = await service.get_energy(mock_plug)
@@ -238,9 +209,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_get_energy_handles_unreachable(self, service, mock_plug):
         """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
 
             result = await service.get_energy(mock_plug)
@@ -250,9 +219,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_get_energy_handles_partial_data(self, service, mock_plug):
         """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 = {
                 "StatusSNS": {
                     "ENERGY": {
@@ -276,13 +243,11 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_test_connection_success(self, service):
         """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
             mock_send.side_effect = [
                 {"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")
@@ -294,9 +259,7 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_test_connection_failure(self, service):
         """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
 
             result = await service.test_connection("192.168.1.100")
@@ -310,12 +273,10 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_send_command_handles_timeout(self, service):
         """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.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()
 
             result = await service._send_command("192.168.1.100", "Power")
@@ -325,12 +286,10 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_send_command_handles_connection_error(self, service):
         """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.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()
 
             result = await service._send_command("192.168.1.100", "Power")
@@ -340,14 +299,12 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_send_command_handles_invalid_json(self, service):
         """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_response = MagicMock()
             mock_response.json.side_effect = ValueError("Invalid JSON")
             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()
 
             result = await service._send_command("192.168.1.100", "Power")
@@ -357,14 +314,12 @@ class TestTasmotaService:
     @pytest.mark.asyncio
     async def test_send_command_success(self, service):
         """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_response = MagicMock()
             mock_response.json.return_value = {"POWER": "ON"}
             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()
 
             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.
 """
 
-import pytest
-from unittest.mock import patch, AsyncMock, MagicMock
 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 (
-    get_or_create_installation_id,
-    is_telemetry_enabled,
-    get_telemetry_url,
-    send_heartbeat,
     DEFAULT_TELEMETRY_URL,
     HEARTBEAT_INTERVAL,
     _last_heartbeat,
+    get_or_create_installation_id,
+    get_telemetry_url,
+    is_telemetry_enabled,
+    send_heartbeat,
 )
-from backend.app.models.settings import Settings
 
 
 class TestTelemetryService:
@@ -134,7 +135,7 @@ class TestTelemetryService:
         db_session.add(setting)
         await db_session.commit()
 
-        with patch('httpx.AsyncClient') as mock_client:
+        with patch("httpx.AsyncClient") as mock_client:
             result = await send_heartbeat(db_session)
 
         assert result is False
@@ -145,6 +146,7 @@ class TestTelemetryService:
         """Verify heartbeat is sent successfully when enabled."""
         # Reset the last heartbeat to allow sending
         import backend.app.services.telemetry as telemetry_module
+
         telemetry_module._last_heartbeat = None
 
         result = await send_heartbeat(db_session)
@@ -159,7 +161,7 @@ class TestTelemetryService:
         # Set last heartbeat to recent time
         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)
 
         # Should return True (already sent) without making HTTP request
@@ -193,14 +195,14 @@ class TestTelemetryService:
 
         captured_data = {}
 
-        with patch('httpx.AsyncClient') as mock_class:
+        with patch("httpx.AsyncClient") as mock_class:
             mock_instance = AsyncMock()
             mock_response = MagicMock()
             mock_response.raise_for_status = MagicMock()
 
             async def capture_post(url, json=None):
-                captured_data['url'] = url
-                captured_data['json'] = json
+                captured_data["url"] = url
+                captured_data["json"] = json
                 return mock_response
 
             mock_instance.post = capture_post
@@ -210,9 +212,9 @@ class TestTelemetryService:
 
             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:
@@ -220,7 +222,7 @@ class TestHeartbeatInterval:
 
     def test_heartbeat_interval_is_24_hours(self):
         """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):
         """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"):
             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
     # ========================================================================
@@ -67,6 +115,7 @@ class TestVirtualPrinterManager:
         """Verify get_status returns expected fields."""
         manager._enabled = True
         manager._mode = "immediate"
+        manager._model = "C11"  # P1P
         manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
         # Simulate running tasks
         manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
@@ -77,7 +126,9 @@ class TestVirtualPrinterManager:
         assert status["running"] is True
         assert status["mode"] == "immediate"
         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
 
     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 os
-import pytest
 from pathlib import Path
 
+import pytest
 
 # Get the backend source directory
 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
 # These are typically imported at the START of a function, not midway through
 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):
             # Find local imports
             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
             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.
     """
     try:
-        with open(file_path, 'r') as f:
+        with open(file_path) as f:
             source = f.read()
         tree = ast.parse(source)
         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.
 """
 
-import pytest
 from unittest.mock import AsyncMock, MagicMock, patch
 
+import pytest
+
 
 class TestMQTTMessageProcessingNoErrors:
     """Verify MQTT message processing doesn't log errors."""
@@ -39,8 +40,7 @@ class TestMQTTMessageProcessingNoErrors:
 
         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):
         """Test processing xcam (camera/AI) data."""
@@ -66,8 +66,7 @@ class TestMQTTMessageProcessingNoErrors:
 
         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):
         """Test processing AMS (Automatic Material System) data."""
@@ -94,7 +93,7 @@ class TestMQTTMessageProcessingNoErrors:
                                     "tray_color": "FF0000",
                                     "remain": 80,
                                 }
-                            ]
+                            ],
                         }
                     ]
                 }
@@ -103,8 +102,7 @@ class TestMQTTMessageProcessingNoErrors:
 
         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):
         """Test processing HMS (Health Management System) errors."""
@@ -129,8 +127,7 @@ class TestMQTTMessageProcessingNoErrors:
 
         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:
@@ -149,36 +146,41 @@ class TestPrintLifecycleNoErrors:
         client.on_print_complete = lambda data: None
 
         # 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
         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
-        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):
         """Test print failure is handled without errors."""
@@ -193,26 +195,29 @@ class TestPrintLifecycleNoErrors:
         client.on_print_complete = lambda data: None
 
         # 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
-        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:
@@ -221,18 +226,21 @@ class TestServiceImports:
     def test_archive_service_import(self, capture_logs):
         """Verify ArchiveService can be imported without errors."""
         from backend.app.services.archive import ArchiveService
+
         assert ArchiveService is not None
         assert not capture_logs.has_errors()
 
     def test_notification_service_import(self, capture_logs):
         """Verify NotificationService can be imported without errors."""
         from backend.app.services.notification_service import notification_service
+
         assert notification_service is not None
         assert not capture_logs.has_errors()
 
     def test_printer_manager_import(self, capture_logs):
         """Verify PrinterManager can be imported without errors."""
         from backend.app.services.printer_manager import printer_manager
+
         assert printer_manager is not None
         assert not capture_logs.has_errors()
 
@@ -240,11 +248,12 @@ class TestServiceImports:
         """Verify main module imports cleanly."""
         # This will fail if there are import shadowing issues
         from backend.app import main
+
         assert main is not None
 
         # 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()
 
 
@@ -263,8 +272,7 @@ class TestEdgeCases:
 
         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):
         """Test handling of message with unknown fields."""
@@ -276,17 +284,18 @@ class TestEdgeCases:
             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):
         """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
         # 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 --build  → builds locally from source
     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
+    #
+    # 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:
     #  - "8000:8000"
     volumes:
       - bambuddy_data:/app/data
       - 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:
       - TZ=Europe/Berlin
     restart: unless-stopped
@@ -26,4 +29,3 @@ services:
 volumes:
   bambuddy_data:
   bambuddy_logs:
-  bambuddy_vprinter:

+ 24 - 10
docker-publish.sh

@@ -82,22 +82,27 @@ if ! docker info 2>/dev/null | grep -q "Username"; then
     echo ""
 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
-if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(b[0-9]+)?$ ]]; then
     IS_RELEASE=true
 fi
 
 # Setup buildx builder if not exists
 echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
 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 \
         --name "$BUILDER_NAME" \
         --driver docker-container \
         --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
 docker buildx use "$BUILDER_NAME"
 
@@ -117,9 +122,13 @@ else
     echo -e "${BLUE}[3/4] Building and pushing (version only, no latest)...${NC}"
 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
     # 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
     (
@@ -127,7 +136,9 @@ if [ "$PARALLEL" = true ]; then
         docker buildx build \
             --platform linux/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 \
             . 2>&1 | sed 's/^/[amd64] /'
         echo -e "${GREEN}[amd64] Complete!${NC}"
@@ -140,7 +151,9 @@ if [ "$PARALLEL" = true ]; then
         docker buildx build \
             --platform linux/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 \
             . 2>&1 | sed 's/^/[arm64] /'
         echo -e "${GREEN}[arm64] Complete!${NC}"
@@ -167,10 +180,11 @@ if [ "$PARALLEL" = true ]; then
     fi
 else
     # 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 \
         --platform "$PLATFORMS" \
-        --build-arg BUILDKIT_INLINE_CACHE=1 \
-        --provenance=false \
+        ${BUILD_ARGS} \
+        ${CACHE_ARGS} \
         $TAGS \
         --push \
         .

+ 0 - 1
frontend/package-lock.json

@@ -4678,7 +4678,6 @@
       "version": "2.18.0",
       "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
       "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
-      "license": "MIT",
       "dependencies": {
         "lil-gui": "^0.19.2",
         "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: {
     getSettings: 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,
   access_code_set: false,
   mode: 'immediate' as const,
+  model: '3DPrinter-X1-Carbon',
   status: {
     enabled: false,
     running: false,
     mode: 'immediate',
     name: 'Bambuddy',
-    serial: '00M09A391800001',
+    serial: '00M00A391800001',
+    model: '3DPrinter-X1-Carbon',
+    model_name: 'X1C',
     pending_files: 0,
   },
   ...overrides,
@@ -143,7 +153,9 @@ describe('VirtualPrinterSettings', () => {
             running: true,
             mode: 'immediate',
             name: 'Bambuddy',
-            serial: '00M09A391800001',
+            serial: '00M00A391800001',
+            model: '3DPrinter-X1-Carbon',
+            model_name: 'X1C',
             pending_files: 0,
           },
         })
@@ -154,7 +166,7 @@ describe('VirtualPrinterSettings', () => {
       await waitFor(() => {
         expect(screen.getByText('Status Details')).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', () => {
-    it('shows required ports warning', async () => {
+    it('shows setup required warning', async () => {
       render(<VirtualPrinterSettings />);
 
       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 />);
 
       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;
   // Number of printable objects in current print (for skip objects feature)
   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 {
@@ -784,6 +789,7 @@ export interface PrintQueueItem {
   scheduled_time: string | null;
   require_previous_success: boolean;
   auto_off_after: boolean;
+  manual_start: boolean;  // Requires manual trigger to start (staged)
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   started_at: string | null;
   completed_at: string | null;
@@ -801,6 +807,7 @@ export interface PrintQueueItemCreate {
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
+  manual_start?: boolean;  // Requires manual trigger to start (staged)
 }
 
 export interface PrintQueueItemUpdate {
@@ -809,6 +816,7 @@ export interface PrintQueueItemUpdate {
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
+  manual_start?: boolean;
 }
 
 // MQTT Logging types
@@ -1321,6 +1329,7 @@ export const api = {
       total: number;
       skipped_count: number;
       is_printing: boolean;
+      bbox_all: [number, number, number, number] | null;
     }>(`/printers/${printerId}/print/objects`),
 
   skipObjects: (printerId: number, objectIds: number[]) =>
@@ -1507,6 +1516,7 @@ export const api = {
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
+  getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
   getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
   scanArchiveTimelapse: (id: number) =>
     request<{
@@ -1635,6 +1645,7 @@ export const api = {
     request<{
       has_model: boolean;
       has_gcode: boolean;
+      has_source: boolean;
       build_volume: { x: number; y: number; z: number };
       filament_colors: string[];
     }>(`/archives/${id}/capabilities`),
@@ -1906,6 +1917,8 @@ export const api = {
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
   stopQueueItem: (id: number) =>
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
+  startQueueItem: (id: number) =>
+    request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
 
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
@@ -2437,6 +2450,7 @@ export interface VirtualPrinterStatus {
   name: string;
   serial: string;
   model: string;
+  model_name: string;
   pending_files: number;
 }
 
@@ -2444,9 +2458,15 @@ export interface VirtualPrinterSettings {
   enabled: boolean;
   access_code_set: boolean;
   mode: 'immediate' | 'queue';
+  model: string;
   status: VirtualPrinterStatus;
 }
 
+export interface VirtualPrinterModels {
+  models: Record<string, string>;  // SSDP code -> display name
+  default: string;
+}
+
 export interface PendingUpload {
   id: number;
   filename: string;
@@ -2463,15 +2483,19 @@ export interface PendingUpload {
 export const virtualPrinterApi = {
   getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),
 
+  getModels: () => request<VirtualPrinterModels>('/settings/virtual-printer/models'),
+
   updateSettings: (data: {
     enabled?: boolean;
     access_code?: string;
     mode?: 'immediate' | 'queue';
+    model?: string;
   }) => {
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.access_code !== undefined) params.set('access_code', data.access_code);
     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()}`, {
       method: 'PUT',

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