Browse Source

- Add interactive API Browser to Settings > API Keys

  - New APIBrowser component with full OpenAPI schema integration
    - Fetches and parses /openapi.json automatically
    - Groups endpoints by API tags (printers, archives, settings, etc.)
    - Expandable endpoint sections with color-coded method badges
    - Path parameter, query parameter, and JSON body editors
    - Auto-populates request body with schema examples
    - Live API request execution with response display
    - Response shows status code, timing, and formatted JSON
    - Copy response button with clipboard fallback
    - Search to filter endpoints across all categories
    - Expand All / Collapse All buttons
    - Link to Swagger UI (/docs)

  - Two-column layout for API Keys tab
    - Left: API key management + webhook documentation
    - Right: API Browser with dedicated test key input

  - Parameter validation
    - Shows warning for missing required parameters
    - Validates before sending requests to avoid 422 errors

  - UX improvements
    - "Use in API Browser" button on newly created keys
    - Responsive layout (stacked on mobile, side-by-side on xl+)
maziggy 4 months ago
parent
commit
83cbac04b7
100 changed files with 2208 additions and 1941 deletions
  1. 11 0
      CHANGELOG.md
  2. 1 0
      README.md
  3. 5 6
      backend/app/api/routes/ams_history.py
  4. 6 7
      backend/app/api/routes/api_keys.py
  5. 14 38
      backend/app/api/routes/external_links.py
  6. 103 33
      backend/app/api/routes/filaments.py
  7. 5 9
      backend/app/api/routes/kprofiles.py
  8. 9 16
      backend/app/api/routes/notification_templates.py
  9. 40 53
      backend/app/api/routes/notifications.py
  10. 16 29
      backend/app/api/routes/print_queue.py
  11. 28 33
      backend/app/api/routes/system.py
  12. 33 35
      backend/app/api/routes/webhook.py
  13. 15 10
      backend/app/api/routes/websocket.py
  14. 10 19
      backend/app/core/auth.py
  15. 34 23
      backend/app/core/websocket.py
  16. 0 8
      backend/app/i18n/__init__.py
  17. 5 7
      backend/app/models/ams_history.py
  18. 2 1
      backend/app/models/api_key.py
  19. 5 9
      backend/app/models/external_link.py
  20. 4 7
      backend/app/models/filament.py
  21. 5 10
      backend/app/models/kprofile_note.py
  22. 9 13
      backend/app/models/maintenance.py
  23. 1 1
      backend/app/models/notification.py
  24. 1 3
      backend/app/models/notification_template.py
  25. 6 11
      backend/app/models/print_queue.py
  26. 4 7
      backend/app/models/settings.py
  27. 4 7
      backend/app/models/slot_preset.py
  28. 10 10
      backend/app/schemas/__init__.py
  29. 5 0
      backend/app/schemas/api_key.py
  30. 29 18
      backend/app/schemas/cloud.py
  31. 1 0
      backend/app/schemas/external_link.py
  32. 1 0
      backend/app/schemas/filament.py
  33. 4 0
      backend/app/schemas/maintenance.py
  34. 3 1
      backend/app/schemas/notification.py
  35. 3 2
      backend/app/schemas/print_queue.py
  36. 61 55
      backend/app/services/archive_comparison.py
  37. 29 53
      backend/app/services/bambu_cloud.py
  38. 30 28
      backend/app/services/notification_service.py
  39. 5 11
      backend/app/services/print_scheduler.py
  40. 18 42
      backend/app/services/smart_plug_manager.py
  41. 8 26
      backend/app/services/tasmota.py
  42. 3 9
      backend/app/services/telemetry.py
  43. 68 62
      backend/tests/conftest.py
  44. 13 37
      backend/tests/integration/test_ams_history_api.py
  45. 10 34
      backend/tests/integration/test_archives_api.py
  46. 16 17
      backend/tests/integration/test_camera_api.py
  47. 10 25
      backend/tests/integration/test_external_links_api.py
  48. 6 14
      backend/tests/integration/test_filaments_api.py
  49. 17 52
      backend/tests/integration/test_maintenance_api.py
  50. 22 54
      backend/tests/integration/test_notifications_api.py
  51. 7 17
      backend/tests/integration/test_projects_api.py
  52. 19 20
      backend/tests/integration/test_system_api.py
  53. 15 14
      backend/tests/unit/services/test_archive_service.py
  54. 64 49
      backend/tests/unit/services/test_bambu_mqtt.py
  55. 149 279
      backend/tests/unit/services/test_notification_service.py
  56. 100 180
      backend/tests/unit/services/test_smart_plug_manager.py
  57. 30 75
      backend/tests/unit/services/test_tasmota.py
  58. 18 16
      backend/tests/unit/services/test_telemetry.py
  59. 22 14
      backend/tests/unit/test_code_quality.py
  60. 78 68
      backend/tests/unit/test_log_error_detection.py
  61. 1 1
      frontend/public/icons/chamber.svg
  62. 1 1
      frontend/public/icons/heatbed.svg
  63. 1 1
      frontend/public/icons/reload.svg
  64. 0 0
      frontend/public/icons/settings.svg
  65. 1 1
      frontend/public/icons/skip-objects.svg
  66. 1 1
      frontend/public/icons/video-camera.svg
  67. 1 1
      frontend/public/vite.svg
  68. 0 0
      frontend/src/assets/react.svg
  69. 648 0
      frontend/src/components/APIBrowser.tsx
  70. 286 239
      frontend/src/pages/SettingsPage.tsx
  71. 0 0
      icons/27ca5e207eb045a7949048ab41fda285.svg
  72. 0 0
      icons/57eeee2303f848be9d6159c1079f100d.svg
  73. 0 0
      icons/7a3afd1aa53c47e38ea7e55356403f99.svg
  74. 0 0
      icons/df3231e72d3b4bc0a08c47e95599e64d.svg
  75. 1 1
      mockup/icons/ams-settings.svg
  76. 0 0
      mockup/icons/chamber.svg
  77. 1 1
      mockup/icons/heatbed.svg
  78. 1 1
      mockup/icons/hotend.svg
  79. 1 1
      mockup/icons/lamp.svg
  80. 1 1
      mockup/icons/micro-sd.svg
  81. 1 1
      mockup/icons/reload.svg
  82. 0 0
      mockup/icons/settings.svg
  83. 1 1
      mockup/icons/skip-objects.svg
  84. 0 0
      mockup/icons/speed.svg
  85. 1 1
      mockup/icons/temperature.svg
  86. 0 0
      mockup/icons/ventilation.svg
  87. 1 1
      mockup/icons/video-camera.svg
  88. 1 2
      scripts/mqtt_sniffer.py
  89. 0 0
      static/assets/index-Bs58vo0R.css
  90. 0 0
      static/assets/index-D9x3e2g2.js
  91. 0 0
      static/assets/index-Da3qKIoX.css
  92. 1 1
      static/icons/chamber.svg
  93. 1 1
      static/icons/heatbed.svg
  94. 1 1
      static/icons/reload.svg
  95. 0 0
      static/icons/settings.svg
  96. 1 1
      static/icons/skip-objects.svg
  97. 1 1
      static/icons/video-camera.svg
  98. BIN
      static/img/bambuddy_logo_dark_transparent.png
  99. 2 2
      static/index.html
  100. 1 1
      static/vite.svg

+ 11 - 0
CHANGELOG.md

@@ -41,6 +41,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

+ 1 - 0
README.md

@@ -95,6 +95,7 @@
 - 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

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

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

+ 16 - 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,
 )
 
@@ -124,9 +124,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 +136,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 +153,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 +175,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 +188,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 +210,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 +246,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 +255,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}")

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

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

+ 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

+ 6 - 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,15 +14,9 @@ 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
@@ -50,6 +45,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")

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

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

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

+ 5 - 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
@@ -128,9 +127,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 +219,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 +231,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 +289,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

+ 18 - 42
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').
@@ -169,8 +163,7 @@ class SmartPlugManager:
             return
 
         logger.info(
-            f"Print completed successfully on printer {printer_id}, "
-            f"scheduling turn-off for plug '{plug.name}'"
+            f"Print completed successfully on printer {printer_id}, " f"scheduling turn-off for plug '{plug.name}'"
         )
 
         if plug.off_delay_mode == "time":
@@ -183,9 +176,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 +222,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}' " f"(threshold: {temp_threshold}°C)")
 
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -296,8 +282,7 @@ class SmartPlugManager:
                         )
                     else:
                         logger.info(
-                            f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, "
-                            f"threshold={temp_threshold}°C"
+                            f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, " f"threshold={temp_threshold}°C"
                         )
 
                     if max_nozzle_temp < temp_threshold:
@@ -328,9 +313,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 +327,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 +344,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 +386,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()
@@ -427,10 +406,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}' " f"(printer {plug.printer_id})")
 
                     # Resume the appropriate off mode
                     if plug.off_delay_mode == "temperature":

+ 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

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

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

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

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

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

File diff suppressed because it is too large
+ 0 - 0
frontend/src/assets/react.svg


+ 648 - 0
frontend/src/components/APIBrowser.tsx

@@ -0,0 +1,648 @@
+import { useState, useEffect } from 'react';
+import { ChevronDown, ChevronRight, Play, Copy, Loader2, ExternalLink, AlertCircle, CheckCircle } from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+
+interface OpenAPISchema {
+  paths: Record<string, Record<string, EndpointSpec>>;
+  components?: {
+    schemas?: Record<string, SchemaSpec>;
+  };
+}
+
+interface EndpointSpec {
+  summary?: string;
+  description?: string;
+  tags?: string[];
+  parameters?: ParameterSpec[];
+  requestBody?: {
+    content?: {
+      'application/json'?: {
+        schema?: SchemaSpec;
+      };
+    };
+  };
+  responses?: Record<string, ResponseSpec>;
+}
+
+interface ParameterSpec {
+  name: string;
+  in: 'path' | 'query' | 'header';
+  required?: boolean;
+  description?: string;
+  schema?: {
+    type?: string;
+    default?: unknown;
+    enum?: string[];
+  };
+}
+
+interface SchemaSpec {
+  type?: string;
+  properties?: Record<string, SchemaSpec>;
+  required?: string[];
+  items?: SchemaSpec;
+  $ref?: string;
+  allOf?: SchemaSpec[];
+  anyOf?: SchemaSpec[];
+  oneOf?: SchemaSpec[];
+  default?: unknown;
+  description?: string;
+  enum?: string[];
+  example?: unknown;
+}
+
+interface ResponseSpec {
+  description?: string;
+  content?: {
+    'application/json'?: {
+      schema?: SchemaSpec;
+    };
+  };
+}
+
+interface APIResponse {
+  status: number;
+  statusText: string;
+  headers: Record<string, string>;
+  body: unknown;
+  duration: number;
+}
+
+const METHOD_COLORS: Record<string, string> = {
+  get: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+  post: 'bg-green-500/20 text-green-400 border-green-500/30',
+  put: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
+  patch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
+  delete: 'bg-red-500/20 text-red-400 border-red-500/30',
+};
+
+function resolveRef(schema: OpenAPISchema, ref: string): SchemaSpec {
+  // Parse $ref like "#/components/schemas/PrinterCreate"
+  const parts = ref.replace('#/', '').split('/');
+  let current: unknown = schema;
+  for (const part of parts) {
+    current = (current as Record<string, unknown>)[part];
+  }
+  return current as SchemaSpec;
+}
+
+function getSchemaExample(schema: OpenAPISchema, spec: SchemaSpec, depth = 0): unknown {
+  if (depth > 5) return '...';
+
+  if (spec.$ref) {
+    return getSchemaExample(schema, resolveRef(schema, spec.$ref), depth + 1);
+  }
+
+  if (spec.allOf) {
+    const merged: Record<string, unknown> = {};
+    for (const sub of spec.allOf) {
+      const subExample = getSchemaExample(schema, sub, depth + 1);
+      if (typeof subExample === 'object' && subExample !== null) {
+        Object.assign(merged, subExample);
+      }
+    }
+    return merged;
+  }
+
+  if (spec.example !== undefined) return spec.example;
+  if (spec.default !== undefined) return spec.default;
+
+  switch (spec.type) {
+    case 'string':
+      if (spec.enum) return spec.enum[0];
+      return 'string';
+    case 'integer':
+    case 'number':
+      return 0;
+    case 'boolean':
+      return false;
+    case 'array':
+      return spec.items ? [getSchemaExample(schema, spec.items, depth + 1)] : [];
+    case 'object':
+      if (spec.properties) {
+        const obj: Record<string, unknown> = {};
+        for (const [key, propSpec] of Object.entries(spec.properties)) {
+          obj[key] = getSchemaExample(schema, propSpec, depth + 1);
+        }
+        return obj;
+      }
+      return {};
+    default:
+      return null;
+  }
+}
+
+interface EndpointItemProps {
+  path: string;
+  method: string;
+  spec: EndpointSpec;
+  schema: OpenAPISchema;
+  apiKey: string;
+}
+
+function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps) {
+  const [expanded, setExpanded] = useState(false);
+  const [params, setParams] = useState<Record<string, string>>({});
+  const [bodyText, setBodyText] = useState('');
+  const [response, setResponse] = useState<APIResponse | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [copied, setCopied] = useState(false);
+
+  // Initialize params with defaults
+  useEffect(() => {
+    if (expanded && spec.parameters) {
+      const defaults: Record<string, string> = {};
+      for (const param of spec.parameters) {
+        if (param.schema?.default !== undefined) {
+          defaults[param.name] = String(param.schema.default);
+        }
+      }
+      setParams(prev => ({ ...defaults, ...prev }));
+    }
+  }, [expanded, spec.parameters]);
+
+  // Initialize body with example
+  useEffect(() => {
+    if (expanded && spec.requestBody?.content?.['application/json']?.schema && !bodyText) {
+      const bodySchema = spec.requestBody.content['application/json'].schema;
+      const example = getSchemaExample(schema, bodySchema);
+      setBodyText(JSON.stringify(example, null, 2));
+    }
+  }, [expanded, spec.requestBody, schema, bodyText]);
+
+  // Check for missing required parameters
+  const getMissingParams = () => {
+    const missing: string[] = [];
+    for (const param of spec.parameters || []) {
+      if (param.in === 'path' || param.required) {
+        const value = params[param.name];
+        if (value === undefined || value === '') {
+          missing.push(param.name);
+        }
+      }
+    }
+    return missing;
+  };
+
+  const missingParams = getMissingParams();
+
+  const executeRequest = async () => {
+    if (missingParams.length > 0) {
+      setResponse({
+        status: 0,
+        statusText: 'Validation Error',
+        headers: {},
+        body: `Missing required parameters: ${missingParams.join(', ')}`,
+        duration: 0,
+      });
+      return;
+    }
+
+    setLoading(true);
+    setResponse(null);
+
+    try {
+      // Build URL with path and query params
+      let url = path;
+      const queryParams = new URLSearchParams();
+
+      for (const param of spec.parameters || []) {
+        const value = params[param.name];
+        if (value !== undefined && value !== '') {
+          if (param.in === 'path') {
+            url = url.replace(`{${param.name}}`, encodeURIComponent(value));
+          } else if (param.in === 'query') {
+            queryParams.append(param.name, value);
+          }
+        }
+      }
+
+      const queryString = queryParams.toString();
+      // OpenAPI paths already include /api/v1 prefix
+      const fullUrl = `${url}${queryString ? `?${queryString}` : ''}`;
+
+      const headers: Record<string, string> = {
+        'Content-Type': 'application/json',
+      };
+
+      if (apiKey) {
+        headers['X-API-Key'] = apiKey;
+      }
+
+      const options: RequestInit = {
+        method: method.toUpperCase(),
+        headers,
+      };
+
+      if (['post', 'put', 'patch'].includes(method) && bodyText) {
+        options.body = bodyText;
+      }
+
+      const startTime = performance.now();
+      const res = await fetch(fullUrl, options);
+      const duration = Math.round(performance.now() - startTime);
+
+      const responseHeaders: Record<string, string> = {};
+      res.headers.forEach((value, key) => {
+        responseHeaders[key] = value;
+      });
+
+      let body: unknown;
+      const contentType = res.headers.get('content-type');
+      if (contentType?.includes('application/json')) {
+        body = await res.json();
+      } else {
+        body = await res.text();
+      }
+
+      setResponse({
+        status: res.status,
+        statusText: res.statusText,
+        headers: responseHeaders,
+        body,
+        duration,
+      });
+    } catch (err) {
+      setResponse({
+        status: 0,
+        statusText: 'Network Error',
+        headers: {},
+        body: err instanceof Error ? err.message : 'Unknown error',
+        duration: 0,
+      });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const copyResponse = async () => {
+    if (response) {
+      const text = typeof response.body === 'string'
+        ? response.body
+        : JSON.stringify(response.body, null, 2);
+      try {
+        await navigator.clipboard.writeText(text);
+        setCopied(true);
+        setTimeout(() => setCopied(false), 2000);
+      } catch {
+        // Fallback for non-HTTPS
+        const textArea = document.createElement('textarea');
+        textArea.value = text;
+        textArea.style.position = 'fixed';
+        textArea.style.left = '-999999px';
+        document.body.appendChild(textArea);
+        textArea.select();
+        document.execCommand('copy');
+        document.body.removeChild(textArea);
+        setCopied(true);
+        setTimeout(() => setCopied(false), 2000);
+      }
+    }
+  };
+
+  const pathParams = (spec.parameters || []).filter(p => p.in === 'path');
+  const queryParamsSpec = (spec.parameters || []).filter(p => p.in === 'query');
+  const hasBody = ['post', 'put', 'patch'].includes(method) && spec.requestBody;
+
+  return (
+    <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+      <button
+        onClick={() => setExpanded(!expanded)}
+        className="w-full flex items-center gap-3 p-3 hover:bg-bambu-dark-tertiary/50 transition-colors text-left"
+      >
+        {expanded ? (
+          <ChevronDown className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+        ) : (
+          <ChevronRight className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+        )}
+        <span className={`px-2 py-0.5 text-xs font-mono font-semibold uppercase rounded border ${METHOD_COLORS[method] || 'bg-gray-500/20 text-gray-400'}`}>
+          {method}
+        </span>
+        <code className="text-sm text-white font-mono flex-1 truncate">{path}</code>
+        {spec.summary && (
+          <span className="text-sm text-bambu-gray truncate max-w-[40%]">{spec.summary}</span>
+        )}
+      </button>
+
+      {expanded && (
+        <div className="border-t border-bambu-dark-tertiary p-4 space-y-4 bg-bambu-dark/50">
+          {spec.description && (
+            <p className="text-sm text-bambu-gray">{spec.description}</p>
+          )}
+
+          {/* Path Parameters */}
+          {pathParams.length > 0 && (
+            <div className="space-y-2">
+              <h4 className="text-sm font-medium text-white">Path Parameters</h4>
+              <div className="space-y-2">
+                {pathParams.map(param => (
+                  <div key={param.name} className="flex items-center gap-2">
+                    <label className="text-sm text-bambu-gray w-32 flex-shrink-0">
+                      {param.name}
+                      {param.required && <span className="text-red-400 ml-1">*</span>}
+                    </label>
+                    <input
+                      type="text"
+                      value={params[param.name] || ''}
+                      onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
+                      placeholder={param.description || param.schema?.type || 'value'}
+                      className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Query Parameters */}
+          {queryParamsSpec.length > 0 && (
+            <div className="space-y-2">
+              <h4 className="text-sm font-medium text-white">Query Parameters</h4>
+              <div className="space-y-2">
+                {queryParamsSpec.map(param => (
+                  <div key={param.name} className="flex items-center gap-2">
+                    <label className="text-sm text-bambu-gray w-32 flex-shrink-0">
+                      {param.name}
+                      {param.required && <span className="text-red-400 ml-1">*</span>}
+                    </label>
+                    {param.schema?.enum ? (
+                      <select
+                        value={params[param.name] || ''}
+                        onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
+                        className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                      >
+                        <option value="">-- Select --</option>
+                        {param.schema.enum.map(opt => (
+                          <option key={opt} value={opt}>{opt}</option>
+                        ))}
+                      </select>
+                    ) : (
+                      <input
+                        type="text"
+                        value={params[param.name] || ''}
+                        onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
+                        placeholder={param.description || param.schema?.type || 'value'}
+                        className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none"
+                      />
+                    )}
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Request Body */}
+          {hasBody && (
+            <div className="space-y-2">
+              <h4 className="text-sm font-medium text-white">Request Body</h4>
+              <textarea
+                value={bodyText}
+                onChange={(e) => setBodyText(e.target.value)}
+                rows={8}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono focus:border-bambu-green focus:outline-none resize-y"
+                placeholder="JSON request body..."
+              />
+            </div>
+          )}
+
+          {/* Execute Button */}
+          <div className="flex items-center gap-2">
+            <Button onClick={executeRequest} disabled={loading}>
+              {loading ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Play className="w-4 h-4" />
+              )}
+              Execute
+            </Button>
+            {missingParams.length > 0 && (
+              <span className="text-xs text-yellow-400 flex items-center gap-1">
+                <AlertCircle className="w-3 h-3" />
+                Fill in: {missingParams.join(', ')}
+              </span>
+            )}
+          </div>
+
+          {/* Response */}
+          {response && (
+            <div className="space-y-2">
+              <div className="flex items-center justify-between">
+                <h4 className="text-sm font-medium text-white flex items-center gap-2">
+                  Response
+                  <span className={`px-2 py-0.5 text-xs rounded ${
+                    response.status >= 200 && response.status < 300
+                      ? 'bg-green-500/20 text-green-400'
+                      : response.status >= 400
+                        ? 'bg-red-500/20 text-red-400'
+                        : 'bg-yellow-500/20 text-yellow-400'
+                  }`}>
+                    {response.status} {response.statusText}
+                  </span>
+                  <span className="text-xs text-bambu-gray">{response.duration}ms</span>
+                </h4>
+                <Button variant="secondary" size="sm" onClick={copyResponse}>
+                  {copied ? (
+                    <CheckCircle className="w-3 h-3 text-green-400" />
+                  ) : (
+                    <Copy className="w-3 h-3" />
+                  )}
+                </Button>
+              </div>
+              <pre className="p-3 bg-bambu-dark rounded-lg text-sm font-mono text-white overflow-auto max-h-96 border border-bambu-dark-tertiary">
+                {typeof response.body === 'string'
+                  ? response.body
+                  : JSON.stringify(response.body, null, 2)}
+              </pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface APIBrowserProps {
+  apiKey?: string;
+}
+
+export function APIBrowser({ apiKey = '' }: APIBrowserProps) {
+  const [schema, setSchema] = useState<OpenAPISchema | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
+  const [searchQuery, setSearchQuery] = useState('');
+
+  useEffect(() => {
+    async function fetchSchema() {
+      try {
+        const res = await fetch('/openapi.json');
+        if (!res.ok) throw new Error('Failed to fetch OpenAPI schema');
+        const data = await res.json();
+        setSchema(data);
+      } catch (err) {
+        setError(err instanceof Error ? err.message : 'Unknown error');
+      } finally {
+        setLoading(false);
+      }
+    }
+    fetchSchema();
+  }, []);
+
+  if (loading) {
+    return (
+      <div className="flex justify-center py-12">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  if (error || !schema) {
+    return (
+      <Card>
+        <CardContent className="py-8">
+          <div className="text-center text-red-400">
+            <AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-50" />
+            <p>Failed to load API schema</p>
+            <p className="text-sm text-bambu-gray mt-1">{error}</p>
+          </div>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  // Group endpoints by tag
+  const endpointsByTag: Record<string, Array<{ path: string; method: string; spec: EndpointSpec }>> = {};
+
+  for (const [path, methods] of Object.entries(schema.paths)) {
+    for (const [method, spec] of Object.entries(methods)) {
+      if (method === 'parameters') continue; // Skip path-level parameters
+
+      const tags = spec.tags || ['Other'];
+      for (const tag of tags) {
+        if (!endpointsByTag[tag]) {
+          endpointsByTag[tag] = [];
+        }
+        endpointsByTag[tag].push({ path, method, spec });
+      }
+    }
+  }
+
+  // Filter endpoints based on search
+  const filteredTags = Object.entries(endpointsByTag)
+    .map(([tag, endpoints]) => {
+      if (!searchQuery) return { tag, endpoints };
+
+      const filtered = endpoints.filter(({ path, method, spec }) => {
+        const searchLower = searchQuery.toLowerCase();
+        return (
+          path.toLowerCase().includes(searchLower) ||
+          method.toLowerCase().includes(searchLower) ||
+          (spec.summary?.toLowerCase() || '').includes(searchLower) ||
+          (spec.description?.toLowerCase() || '').includes(searchLower)
+        );
+      });
+
+      return { tag, endpoints: filtered };
+    })
+    .filter(({ endpoints }) => endpoints.length > 0)
+    .sort((a, b) => a.tag.localeCompare(b.tag));
+
+  const toggleTag = (tag: string) => {
+    setExpandedTags(prev => {
+      const next = new Set(prev);
+      if (next.has(tag)) {
+        next.delete(tag);
+      } else {
+        next.add(tag);
+      }
+      return next;
+    });
+  };
+
+  const expandAll = () => {
+    setExpandedTags(new Set(filteredTags.map(t => t.tag)));
+  };
+
+  const collapseAll = () => {
+    setExpandedTags(new Set());
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Header */}
+      <div className="flex items-center justify-between gap-4">
+        <div className="flex-1">
+          <input
+            type="text"
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            placeholder="Search endpoints..."
+            className="w-full max-w-md px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          />
+        </div>
+        <div className="flex items-center gap-2">
+          <Button variant="secondary" size="sm" onClick={expandAll}>
+            Expand All
+          </Button>
+          <Button variant="secondary" size="sm" onClick={collapseAll}>
+            Collapse All
+          </Button>
+          <a
+            href="/docs"
+            target="_blank"
+            rel="noopener noreferrer"
+            className="flex items-center gap-1 text-sm text-bambu-green hover:underline"
+          >
+            <ExternalLink className="w-4 h-4" />
+            Swagger UI
+          </a>
+        </div>
+      </div>
+
+      {/* Endpoint count */}
+      <p className="text-sm text-bambu-gray">
+        {filteredTags.reduce((acc, t) => acc + t.endpoints.length, 0)} endpoints in {filteredTags.length} categories
+      </p>
+
+      {/* Endpoints by Tag */}
+      <div className="space-y-3">
+        {filteredTags.map(({ tag, endpoints }) => (
+          <Card key={tag}>
+            <button
+              onClick={() => toggleTag(tag)}
+              className="w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/30 transition-colors text-left"
+            >
+              <div className="flex items-center gap-2">
+                {expandedTags.has(tag) ? (
+                  <ChevronDown className="w-5 h-5 text-bambu-gray" />
+                ) : (
+                  <ChevronRight className="w-5 h-5 text-bambu-gray" />
+                )}
+                <h3 className="text-base font-semibold text-white capitalize">{tag.replace(/-/g, ' ')}</h3>
+                <span className="text-xs bg-bambu-dark-tertiary px-2 py-0.5 rounded-full text-bambu-gray">
+                  {endpoints.length}
+                </span>
+              </div>
+            </button>
+
+            {expandedTags.has(tag) && (
+              <CardContent className="pt-0 space-y-2">
+                {endpoints.map(({ path, method, spec }) => (
+                  <EndpointItem
+                    key={`${method}-${path}`}
+                    path={path}
+                    method={method}
+                    spec={spec}
+                    schema={schema}
+                    apiKey={apiKey}
+                  />
+                ))}
+              </CardContent>
+            )}
+          </Card>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 286 - 239
frontend/src/pages/SettingsPage.tsx

@@ -17,6 +17,7 @@ import { RestoreModal } from '../components/RestoreModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
+import { APIBrowser } from '../components/APIBrowser';
 import { virtualPrinterApi } from '../api/client';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
@@ -54,6 +55,7 @@ export function SettingsPage() {
   });
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
+  const [testApiKey, setTestApiKey] = useState('');
 
   // Confirm modal states
   const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
@@ -1653,265 +1655,310 @@ export function SettingsPage() {
 
       {/* API Keys Tab */}
       {activeTab === 'apikeys' && (
-        <div className="max-w-3xl">
-          <div className="flex items-start justify-between gap-4 mb-6">
-            <div className="flex-1">
-              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                <Key className="w-5 h-5 text-bambu-green" />
-                API Keys
-              </h2>
-              <p className="text-sm text-bambu-gray mt-1">
-                Create API keys for external integrations and webhooks. Use these keys to control your printers from automation tools like Home Assistant.
-              </p>
+        <div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
+          {/* Left Column - API Keys Management */}
+          <div>
+            <div className="flex items-start justify-between gap-4 mb-6">
+              <div className="flex-1">
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <Key className="w-5 h-5 text-bambu-green" />
+                  API Keys
+                </h2>
+                <p className="text-sm text-bambu-gray mt-1">
+                  Create API keys for external integrations and webhooks.
+                </p>
+              </div>
+              <Button size="sm" onClick={() => setShowCreateAPIKey(true)} className="flex-shrink-0">
+                <Plus className="w-4 h-4" />
+                Create Key
+              </Button>
             </div>
-            <Button size="sm" onClick={() => setShowCreateAPIKey(true)} className="flex-shrink-0">
-              <Plus className="w-4 h-4" />
-              Create Key
-            </Button>
-          </div>
 
-          {/* Created Key Display */}
-          {createdAPIKey && (
-            <Card className="mb-6 border-bambu-green">
-              <CardContent className="py-4">
-                <div className="flex items-start gap-3">
-                  <CheckCircle className="w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5" />
-                  <div className="flex-1">
-                    <p className="text-white font-medium mb-1">API Key Created Successfully</p>
-                    <p className="text-sm text-bambu-gray mb-2">
-                      Copy this key now - it won't be shown again!
-                    </p>
-                    <div className="flex items-center gap-2 bg-bambu-dark rounded-lg p-2">
-                      <code className="flex-1 text-sm text-bambu-green font-mono break-all">
-                        {createdAPIKey}
-                      </code>
-                      <Button
-                        variant="secondary"
-                        size="sm"
-                        onClick={async () => {
-                          try {
-                            if (navigator.clipboard && navigator.clipboard.writeText) {
-                              await navigator.clipboard.writeText(createdAPIKey);
-                            } else {
-                              // Fallback for non-HTTPS contexts
-                              const textArea = document.createElement('textarea');
-                              textArea.value = createdAPIKey;
-                              textArea.style.position = 'fixed';
-                              textArea.style.left = '-999999px';
-                              document.body.appendChild(textArea);
-                              textArea.select();
-                              document.execCommand('copy');
-                              document.body.removeChild(textArea);
+            {/* Created Key Display */}
+            {createdAPIKey && (
+              <Card className="mb-6 border-bambu-green">
+                <CardContent className="py-4">
+                  <div className="flex items-start gap-3">
+                    <CheckCircle className="w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5" />
+                    <div className="flex-1">
+                      <p className="text-white font-medium mb-1">API Key Created Successfully</p>
+                      <p className="text-sm text-bambu-gray mb-2">
+                        Copy this key now - it won't be shown again!
+                      </p>
+                      <div className="flex items-center gap-2 bg-bambu-dark rounded-lg p-2">
+                        <code className="flex-1 text-sm text-bambu-green font-mono break-all">
+                          {createdAPIKey}
+                        </code>
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={async () => {
+                            try {
+                              if (navigator.clipboard && navigator.clipboard.writeText) {
+                                await navigator.clipboard.writeText(createdAPIKey);
+                              } else {
+                                const textArea = document.createElement('textarea');
+                                textArea.value = createdAPIKey;
+                                textArea.style.position = 'fixed';
+                                textArea.style.left = '-999999px';
+                                document.body.appendChild(textArea);
+                                textArea.select();
+                                document.execCommand('copy');
+                                document.body.removeChild(textArea);
+                              }
+                              showToast('Key copied to clipboard');
+                            } catch {
+                              showToast('Failed to copy key', 'error');
                             }
-                            showToast('Key copied to clipboard');
-                          } catch {
-                            showToast('Failed to copy key', 'error');
-                          }
-                        }}
-                      >
-                        <Copy className="w-4 h-4" />
-                      </Button>
+                          }}
+                        >
+                          <Copy className="w-4 h-4" />
+                        </Button>
+                      </div>
+                      <div className="flex gap-2 mt-3">
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={() => {
+                            setTestApiKey(createdAPIKey);
+                            showToast('Key added to API Browser');
+                          }}
+                        >
+                          Use in API Browser
+                        </Button>
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={() => setCreatedAPIKey(null)}
+                        >
+                          Dismiss
+                        </Button>
+                      </div>
                     </div>
+                  </div>
+                </CardContent>
+              </Card>
+            )}
+
+            {/* Create Key Form */}
+            {showCreateAPIKey && (
+              <Card className="mb-6">
+                <CardHeader>
+                  <h3 className="text-base font-semibold text-white">Create New API Key</h3>
+                </CardHeader>
+                <CardContent className="space-y-4">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">Key Name</label>
+                    <input
+                      type="text"
+                      value={newAPIKeyName}
+                      onChange={(e) => setNewAPIKeyName(e.target.value)}
+                      placeholder="e.g., Home Assistant, OctoPrint"
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-2">Permissions</label>
+                    <div className="space-y-2">
+                      <label className="flex items-center gap-3 cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={newAPIKeyPermissions.can_read_status}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}
+                          className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                        />
+                        <div>
+                          <span className="text-white">Read Status</span>
+                          <p className="text-xs text-bambu-gray">View printer status and queue</p>
+                        </div>
+                      </label>
+                      <label className="flex items-center gap-3 cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={newAPIKeyPermissions.can_queue}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}
+                          className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                        />
+                        <div>
+                          <span className="text-white">Manage Queue</span>
+                          <p className="text-xs text-bambu-gray">Add and remove items from print queue</p>
+                        </div>
+                      </label>
+                      <label className="flex items-center gap-3 cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={newAPIKeyPermissions.can_control_printer}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}
+                          className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                        />
+                        <div>
+                          <span className="text-white">Control Printer</span>
+                          <p className="text-xs text-bambu-gray">Pause, resume, and stop prints</p>
+                        </div>
+                      </label>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-2 pt-2">
                     <Button
-                      variant="secondary"
-                      size="sm"
-                      className="mt-3"
-                      onClick={() => setCreatedAPIKey(null)}
+                      onClick={() => createAPIKeyMutation.mutate({
+                        name: newAPIKeyName || 'Unnamed Key',
+                        ...newAPIKeyPermissions,
+                      })}
+                      disabled={createAPIKeyMutation.isPending}
                     >
-                      Dismiss
+                      {createAPIKeyMutation.isPending ? (
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                      ) : (
+                        <Plus className="w-4 h-4" />
+                      )}
+                      Create Key
+                    </Button>
+                    <Button variant="secondary" onClick={() => setShowCreateAPIKey(false)}>
+                      Cancel
                     </Button>
                   </div>
-                </div>
-              </CardContent>
-            </Card>
-          )}
+                </CardContent>
+              </Card>
+            )}
 
-          {/* Create Key Form */}
-          {showCreateAPIKey && (
-            <Card className="mb-6">
+            {/* Existing Keys List */}
+            {apiKeysLoading ? (
+              <div className="flex justify-center py-12">
+                <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+              </div>
+            ) : apiKeys && apiKeys.length > 0 ? (
+              <div className="space-y-3">
+                {apiKeys.map((key) => (
+                  <Card key={key.id}>
+                    <CardContent className="py-3">
+                      <div className="flex items-center justify-between">
+                        <div className="flex items-center gap-3">
+                          <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+                          <div>
+                            <p className="text-white font-medium">{key.name}</p>
+                            <p className="text-xs text-bambu-gray">
+                              {key.key_prefix}••••••••
+                              {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
+                            </p>
+                          </div>
+                        </div>
+                        <div className="flex items-center gap-2">
+                          <div className="flex gap-1 text-xs">
+                            {key.can_read_status && (
+                              <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">Read</span>
+                            )}
+                            {key.can_queue && (
+                              <span className="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded">Queue</span>
+                            )}
+                            {key.can_control_printer && (
+                              <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">Control</span>
+                            )}
+                          </div>
+                          <Button
+                            variant="secondary"
+                            size="sm"
+                            onClick={() => setShowDeleteAPIKeyConfirm(key.id)}
+                          >
+                            <Trash2 className="w-4 h-4 text-red-400" />
+                          </Button>
+                        </div>
+                      </div>
+                    </CardContent>
+                  </Card>
+                ))}
+              </div>
+            ) : (
+              <Card>
+                <CardContent className="py-12">
+                  <div className="text-center text-bambu-gray">
+                    <Key className="w-16 h-16 mx-auto mb-4 opacity-30" />
+                    <p className="text-lg font-medium text-white mb-2">No API keys</p>
+                    <p className="text-sm mb-4">Create an API key to integrate with external services.</p>
+                    <Button onClick={() => setShowCreateAPIKey(true)}>
+                      <Plus className="w-4 h-4" />
+                      Create Your First Key
+                    </Button>
+                  </div>
+                </CardContent>
+              </Card>
+            )}
+
+            {/* Webhook Documentation */}
+            <Card className="mt-6">
               <CardHeader>
-                <h3 className="text-base font-semibold text-white">Create New API Key</h3>
+                <h3 className="text-base font-semibold text-white">Webhook Endpoints</h3>
               </CardHeader>
-              <CardContent className="space-y-4">
-                <div>
-                  <label className="block text-sm text-bambu-gray mb-1">Key Name</label>
-                  <input
-                    type="text"
-                    value={newAPIKeyName}
-                    onChange={(e) => setNewAPIKeyName(e.target.value)}
-                    placeholder="e.g., Home Assistant, OctoPrint"
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm text-bambu-gray mb-2">Permissions</label>
-                  <div className="space-y-2">
-                    <label className="flex items-center gap-3 cursor-pointer">
-                      <input
-                        type="checkbox"
-                        checked={newAPIKeyPermissions.can_read_status}
-                        onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}
-                        className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
-                      />
-                      <div>
-                        <span className="text-white">Read Status</span>
-                        <p className="text-xs text-bambu-gray">View printer status and queue</p>
-                      </div>
-                    </label>
-                    <label className="flex items-center gap-3 cursor-pointer">
-                      <input
-                        type="checkbox"
-                        checked={newAPIKeyPermissions.can_queue}
-                        onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}
-                        className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
-                      />
-                      <div>
-                        <span className="text-white">Manage Queue</span>
-                        <p className="text-xs text-bambu-gray">Add and remove items from print queue</p>
-                      </div>
-                    </label>
-                    <label className="flex items-center gap-3 cursor-pointer">
-                      <input
-                        type="checkbox"
-                        checked={newAPIKeyPermissions.can_control_printer}
-                        onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}
-                        className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
-                      />
-                      <div>
-                        <span className="text-white">Control Printer</span>
-                        <p className="text-xs text-bambu-gray">Pause, resume, and stop prints</p>
-                      </div>
-                    </label>
+              <CardContent className="space-y-3 text-sm">
+                <p className="text-bambu-gray">
+                  Use your API key in the <code className="text-bambu-green">X-API-Key</code> header.
+                </p>
+                <div className="space-y-2 font-mono text-xs">
+                  <div className="p-2 bg-bambu-dark rounded">
+                    <span className="text-blue-400">GET</span>{' '}
+                    <span className="text-white">/api/v1/webhook/status</span>
+                    <span className="text-bambu-gray"> - Get all printer status</span>
+                  </div>
+                  <div className="p-2 bg-bambu-dark rounded">
+                    <span className="text-blue-400">GET</span>{' '}
+                    <span className="text-white">/api/v1/webhook/status/:id</span>
+                    <span className="text-bambu-gray"> - Get specific printer status</span>
+                  </div>
+                  <div className="p-2 bg-bambu-dark rounded">
+                    <span className="text-green-400">POST</span>{' '}
+                    <span className="text-white">/api/v1/webhook/queue</span>
+                    <span className="text-bambu-gray"> - Add to print queue</span>
+                  </div>
+                  <div className="p-2 bg-bambu-dark rounded">
+                    <span className="text-orange-400">POST</span>{' '}
+                    <span className="text-white">/api/v1/webhook/printer/:id/pause</span>
+                    <span className="text-bambu-gray"> - Pause print</span>
+                  </div>
+                  <div className="p-2 bg-bambu-dark rounded">
+                    <span className="text-orange-400">POST</span>{' '}
+                    <span className="text-white">/api/v1/webhook/printer/:id/resume</span>
+                    <span className="text-bambu-gray"> - Resume print</span>
+                  </div>
+                  <div className="p-2 bg-bambu-dark rounded">
+                    <span className="text-red-400">POST</span>{' '}
+                    <span className="text-white">/api/v1/webhook/printer/:id/stop</span>
+                    <span className="text-bambu-gray"> - Stop print</span>
                   </div>
-                </div>
-                <div className="flex items-center gap-2 pt-2">
-                  <Button
-                    onClick={() => createAPIKeyMutation.mutate({
-                      name: newAPIKeyName || 'Unnamed Key',
-                      ...newAPIKeyPermissions,
-                    })}
-                    disabled={createAPIKeyMutation.isPending}
-                  >
-                    {createAPIKeyMutation.isPending ? (
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                    ) : (
-                      <Plus className="w-4 h-4" />
-                    )}
-                    Create Key
-                  </Button>
-                  <Button variant="secondary" onClick={() => setShowCreateAPIKey(false)}>
-                    Cancel
-                  </Button>
                 </div>
               </CardContent>
             </Card>
-          )}
+          </div>
 
-          {/* Existing Keys List */}
-          {apiKeysLoading ? (
-            <div className="flex justify-center py-12">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
-          ) : apiKeys && apiKeys.length > 0 ? (
-            <div className="space-y-3">
-              {apiKeys.map((key) => (
-                <Card key={key.id}>
-                  <CardContent className="py-3">
-                    <div className="flex items-center justify-between">
-                      <div className="flex items-center gap-3">
-                        <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
-                        <div>
-                          <p className="text-white font-medium">{key.name}</p>
-                          <p className="text-xs text-bambu-gray">
-                            {key.key_prefix}••••••••
-                            {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
-                          </p>
-                        </div>
-                      </div>
-                      <div className="flex items-center gap-2">
-                        <div className="flex gap-1 text-xs">
-                          {key.can_read_status && (
-                            <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">Read</span>
-                          )}
-                          {key.can_queue && (
-                            <span className="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded">Queue</span>
-                          )}
-                          {key.can_control_printer && (
-                            <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">Control</span>
-                          )}
-                        </div>
-                        <Button
-                          variant="secondary"
-                          size="sm"
-                          onClick={() => setShowDeleteAPIKeyConfirm(key.id)}
-                        >
-                          <Trash2 className="w-4 h-4 text-red-400" />
-                        </Button>
-                      </div>
-                    </div>
-                  </CardContent>
-                </Card>
-              ))}
+          {/* Right Column - API Browser */}
+          <div>
+            <div className="mb-6">
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Globe className="w-5 h-5 text-bambu-green" />
+                API Browser
+              </h2>
+              <p className="text-sm text-bambu-gray mt-1">
+                Explore and test all available API endpoints.
+              </p>
             </div>
-          ) : (
-            <Card>
-              <CardContent className="py-12">
-                <div className="text-center text-bambu-gray">
-                  <Key className="w-16 h-16 mx-auto mb-4 opacity-30" />
-                  <p className="text-lg font-medium text-white mb-2">No API keys</p>
-                  <p className="text-sm mb-4">Create an API key to integrate with external services.</p>
-                  <Button onClick={() => setShowCreateAPIKey(true)}>
-                    <Plus className="w-4 h-4" />
-                    Create Your First Key
-                  </Button>
-                </div>
+
+            {/* API Key Input for Testing */}
+            <Card className="mb-4">
+              <CardContent className="py-3">
+                <label className="block text-sm text-bambu-gray mb-2">API Key for Testing</label>
+                <input
+                  type="text"
+                  value={testApiKey}
+                  onChange={(e) => setTestApiKey(e.target.value)}
+                  placeholder="Paste your API key here to test authenticated endpoints..."
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm focus:border-bambu-green focus:outline-none"
+                />
+                <p className="text-xs text-bambu-gray mt-2">
+                  This key will be sent as <code className="text-bambu-green">X-API-Key</code> header with requests.
+                </p>
               </CardContent>
             </Card>
-          )}
 
-          {/* Webhook Documentation */}
-          <Card className="mt-6">
-            <CardHeader>
-              <h3 className="text-base font-semibold text-white">Webhook Endpoints</h3>
-            </CardHeader>
-            <CardContent className="space-y-3 text-sm">
-              <p className="text-bambu-gray">
-                Use your API key in the <code className="text-bambu-green">X-API-Key</code> header.
-              </p>
-              <div className="space-y-2 font-mono text-xs">
-                <div className="p-2 bg-bambu-dark rounded">
-                  <span className="text-blue-400">GET</span>{' '}
-                  <span className="text-white">/api/v1/webhook/status</span>
-                  <span className="text-bambu-gray"> - Get all printer status</span>
-                </div>
-                <div className="p-2 bg-bambu-dark rounded">
-                  <span className="text-blue-400">GET</span>{' '}
-                  <span className="text-white">/api/v1/webhook/status/:id</span>
-                  <span className="text-bambu-gray"> - Get specific printer status</span>
-                </div>
-                <div className="p-2 bg-bambu-dark rounded">
-                  <span className="text-green-400">POST</span>{' '}
-                  <span className="text-white">/api/v1/webhook/queue</span>
-                  <span className="text-bambu-gray"> - Add to print queue</span>
-                </div>
-                <div className="p-2 bg-bambu-dark rounded">
-                  <span className="text-orange-400">POST</span>{' '}
-                  <span className="text-white">/api/v1/webhook/printer/:id/pause</span>
-                  <span className="text-bambu-gray"> - Pause print</span>
-                </div>
-                <div className="p-2 bg-bambu-dark rounded">
-                  <span className="text-orange-400">POST</span>{' '}
-                  <span className="text-white">/api/v1/webhook/printer/:id/resume</span>
-                  <span className="text-bambu-gray"> - Resume print</span>
-                </div>
-                <div className="p-2 bg-bambu-dark rounded">
-                  <span className="text-red-400">POST</span>{' '}
-                  <span className="text-white">/api/v1/webhook/printer/:id/stop</span>
-                  <span className="text-bambu-gray"> - Stop print</span>
-                </div>
-              </div>
-            </CardContent>
-          </Card>
+            <APIBrowser apiKey={testApiKey} />
+          </div>
         </div>
       )}
 

File diff suppressed because it is too large
+ 0 - 0
icons/27ca5e207eb045a7949048ab41fda285.svg


File diff suppressed because it is too large
+ 0 - 0
icons/57eeee2303f848be9d6159c1079f100d.svg


File diff suppressed because it is too large
+ 0 - 0
icons/7a3afd1aa53c47e38ea7e55356403f99.svg


File diff suppressed because it is too large
+ 0 - 0
icons/df3231e72d3b4bc0a08c47e95599e64d.svg


+ 1 - 1
mockup/icons/ams-settings.svg

@@ -1 +1 @@
-<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill="#000" fill-rule="evenodd"><path d="m2.25 6c0-.41421.33579-.75.75-.75h3.5c.41421 0 .75.33579.75.75s-.33579.75-.75.75h-3.5c-.41421 0-.75-.33579-.75-.75zm8.5 0c0-.41421.3358-.75.75-.75h9.5c.4142 0 .75.33579.75.75s-.3358.75-.75.75h-9.5c-.4142 0-.75-.33579-.75-.75z"/><path d="m9 4.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.79493 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.45507 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.45507-3.25-3.25z"/><path d="m21.75 12c0-.4142-.3358-.75-.75-.75h-2c-.4142 0-.75.3358-.75.75s.3358.75.75.75h2c.4142 0 .75-.3358.75-.75zm-7 0c0-.4142-.3358-.75-.75-.75h-5c-.41421 0-.75.3358-.75.75s.33579.75.75.75h5c.4142 0 .75-.3358.75-.75zm-9 0c0-.4142-.33579-.75-.75-.75h-2c-.41421 0-.75.3358-.75.75s.33579.75.75.75h2c.41421 0 .75-.3358.75-.75z"/><path d="m16.5 10.25c.9665 0 1.75.7835 1.75 1.75s-.7835 1.75-1.75 1.75-1.75-.7835-1.75-1.75.7835-1.75 1.75-1.75zm3.25 1.75c0-1.7949-1.4551-3.25-3.25-3.25s-3.25 1.4551-3.25 3.25 1.4551 3.25 3.25 3.25 3.25-1.4551 3.25-3.25z"/><path d="m2.25 18c0-.4142.33579-.75.75-.75h5c.41421 0 .75.3358.75.75s-.33579.75-.75.75h-5c-.41421 0-.75-.3358-.75-.75zm10 0c0-.4142.3358-.75.75-.75h8c.4142 0 .75.3358.75.75s-.3358.75-.75.75h-8c-.4142 0-.75-.3358-.75-.75z"/><path d="m10.5 16.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.7949 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.4551 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.4551-3.25-3.25z"/></g></svg>
+<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill="#000" fill-rule="evenodd"><path d="m2.25 6c0-.41421.33579-.75.75-.75h3.5c.41421 0 .75.33579.75.75s-.33579.75-.75.75h-3.5c-.41421 0-.75-.33579-.75-.75zm8.5 0c0-.41421.3358-.75.75-.75h9.5c.4142 0 .75.33579.75.75s-.3358.75-.75.75h-9.5c-.4142 0-.75-.33579-.75-.75z"/><path d="m9 4.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.79493 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.45507 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.45507-3.25-3.25z"/><path d="m21.75 12c0-.4142-.3358-.75-.75-.75h-2c-.4142 0-.75.3358-.75.75s.3358.75.75.75h2c.4142 0 .75-.3358.75-.75zm-7 0c0-.4142-.3358-.75-.75-.75h-5c-.41421 0-.75.3358-.75.75s.33579.75.75.75h5c.4142 0 .75-.3358.75-.75zm-9 0c0-.4142-.33579-.75-.75-.75h-2c-.41421 0-.75.3358-.75.75s.33579.75.75.75h2c.41421 0 .75-.3358.75-.75z"/><path d="m16.5 10.25c.9665 0 1.75.7835 1.75 1.75s-.7835 1.75-1.75 1.75-1.75-.7835-1.75-1.75.7835-1.75 1.75-1.75zm3.25 1.75c0-1.7949-1.4551-3.25-3.25-3.25s-3.25 1.4551-3.25 3.25 1.4551 3.25 3.25 3.25 3.25-1.4551 3.25-3.25z"/><path d="m2.25 18c0-.4142.33579-.75.75-.75h5c.41421 0 .75.3358.75.75s-.33579.75-.75.75h-5c-.41421 0-.75-.3358-.75-.75zm10 0c0-.4142.3358-.75.75-.75h8c.4142 0 .75.3358.75.75s-.3358.75-.75.75h-8c-.4142 0-.75-.3358-.75-.75z"/><path d="m10.5 16.25c-.9665 0-1.75.7835-1.75 1.75s.7835 1.75 1.75 1.75 1.75-.7835 1.75-1.75-.7835-1.75-1.75-1.75zm-3.25 1.75c0-1.7949 1.45507-3.25 3.25-3.25 1.7949 0 3.25 1.4551 3.25 3.25s-1.4551 3.25-3.25 3.25c-1.79493 0-3.25-1.4551-3.25-3.25z"/></g></svg>

File diff suppressed because it is too large
+ 0 - 0
mockup/icons/chamber.svg


+ 1 - 1
mockup/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
mockup/icons/hotend.svg

@@ -1 +1 @@
-<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g id="Front_Heater" data-name="Front Heater"><path d="m33.14 29h2.86l-4-7-4 7h3l1.5 3.7a8.38 8.38 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m23.22 29h2.78l-4-7-4 7h3.06l1.5 3.7a8.35 8.35 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .24-1.39 8.4 8.4 0 0 1 .2-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m57.88 20.09a3 3 0 0 0 -1.49-1.84l-7.73-4.1a35.48 35.48 0 0 0 -33.32 0l-7.72 4.1a3 3 0 0 0 -1.22 4.2l9.72 17a3 3 0 0 0 2.6 1.55 3 3 0 0 0 1.39-.34 1 1 0 0 0 .42-1.35 1 1 0 0 0 -1.34-.42 1 1 0 0 1 -1.33-.39l-9.72-17a1 1 0 0 1 -.1-.81 1 1 0 0 1 .52-.69l7.72-4.1a33.46 33.46 0 0 1 31.44 0l7.73 4.1a1 1 0 0 1 .51.63 1 1 0 0 1 -.1.81l-9.72 17a1 1 0 0 1 -1.33.39l-.2-.11a10.82 10.82 0 0 0 -.36-6.72l-1.19-3h2.94l-4-7-4 7h2.9l1.5 3.7a8.38 8.38 0 0 1 -.93 8l-.66.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.64-.78a8.27 8.27 0 0 0 .84-1.3 3 3 0 0 0 4-1.17l9.72-17a3 3 0 0 0 .32-2.44z"/></g></svg>
+<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g id="Front_Heater" data-name="Front Heater"><path d="m33.14 29h2.86l-4-7-4 7h3l1.5 3.7a8.38 8.38 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m23.22 29h2.78l-4-7-4 7h3.06l1.5 3.7a8.35 8.35 0 0 1 -.92 8l-.67.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .24-1.39 8.4 8.4 0 0 1 .2-9.32l.67-.82a10.32 10.32 0 0 0 1.22-10z"/><path d="m57.88 20.09a3 3 0 0 0 -1.49-1.84l-7.73-4.1a35.48 35.48 0 0 0 -33.32 0l-7.72 4.1a3 3 0 0 0 -1.22 4.2l9.72 17a3 3 0 0 0 2.6 1.55 3 3 0 0 0 1.39-.34 1 1 0 0 0 .42-1.35 1 1 0 0 0 -1.34-.42 1 1 0 0 1 -1.33-.39l-9.72-17a1 1 0 0 1 -.1-.81 1 1 0 0 1 .52-.69l7.72-4.1a33.46 33.46 0 0 1 31.44 0l7.73 4.1a1 1 0 0 1 .51.63 1 1 0 0 1 -.1.81l-9.72 17a1 1 0 0 1 -1.33.39l-.2-.11a10.82 10.82 0 0 0 -.36-6.72l-1.19-3h2.94l-4-7-4 7h2.9l1.5 3.7a8.38 8.38 0 0 1 -.93 8l-.66.82a10.43 10.43 0 0 0 -.29 11.74 1 1 0 0 0 .82.42 1 1 0 0 0 .58-.19 1 1 0 0 0 .23-1.39 8.4 8.4 0 0 1 .21-9.32l.64-.78a8.27 8.27 0 0 0 .84-1.3 3 3 0 0 0 4-1.17l9.72-17a3 3 0 0 0 .32-2.44z"/></g></svg>

+ 1 - 1
mockup/icons/lamp.svg

@@ -1 +1 @@
-<svg id="Ecommerce" enable-background="new 0 0 48 48" height="512" viewBox="0 0 48 48" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m25 7.03c0-.552-.447-1-1-1-5.514 0-10 4.486-10 10 0 .552.447 1 1 1s1-.448 1-1c0-4.411 3.589-8 8-8 .553 0 1-.448 1-1z"/><path d="m22.246 45.79h1.754 1.754c1.652 0 2.999-1.345 3-3v-1.099c1.032-.475 1.755-1.512 1.755-2.721v-3.72c.176-.382.281-.802.281-1.25 0-1.016.226-2 .671-2.927.441-.919 1.086-1.752 1.864-2.409 3.746-3.165 5.709-7.989 5.25-12.909-.7-7.375-6.813-13.189-14.265-13.525l-.31-.01-.354.011c-7.408.335-13.521 6.149-14.222 13.526-.458 4.917 1.505 9.742 5.251 12.906.778.658 1.423 1.491 1.864 2.41.445.927.671 1.911.671 2.927 0 .447.105.868.281 1.25v3.721c0 1.209.722 2.247 1.755 2.721v1.1c.001 1.653 1.348 2.998 3 2.998zm4.508-3c0 .552-.449 1-1 1h-1.754-1.754c-.551 0-1-.449-1-1v-.82h2.754 2.754zm1.755-3.819c0 .551-.448 1-1 1h-3.509-3.509c-.552 0-1-.449-1-1v-2.068c.232.058.47.097.719.097h3.79 3.79c.249 0 .487-.039.719-.097zm-9.299-4.971c0-1.318-.292-2.595-.868-3.793-.563-1.171-1.385-2.233-2.376-3.071-3.247-2.742-4.948-6.926-4.551-11.191.607-6.389 5.903-11.426 12.275-11.715l.31-.01.265.009c6.417.29 11.713 5.327 12.319 11.714.398 4.267-1.303 8.451-4.55 11.193-.991.838-1.813 1.9-2.376 3.071-.576 1.198-.868 2.475-.868 3.793 0 .551-.448 1-1 1h-3.79-3.79c-.552 0-1-.449-1-1z"/></g></svg>
+<svg id="Ecommerce" enable-background="new 0 0 48 48" height="512" viewBox="0 0 48 48" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m25 7.03c0-.552-.447-1-1-1-5.514 0-10 4.486-10 10 0 .552.447 1 1 1s1-.448 1-1c0-4.411 3.589-8 8-8 .553 0 1-.448 1-1z"/><path d="m22.246 45.79h1.754 1.754c1.652 0 2.999-1.345 3-3v-1.099c1.032-.475 1.755-1.512 1.755-2.721v-3.72c.176-.382.281-.802.281-1.25 0-1.016.226-2 .671-2.927.441-.919 1.086-1.752 1.864-2.409 3.746-3.165 5.709-7.989 5.25-12.909-.7-7.375-6.813-13.189-14.265-13.525l-.31-.01-.354.011c-7.408.335-13.521 6.149-14.222 13.526-.458 4.917 1.505 9.742 5.251 12.906.778.658 1.423 1.491 1.864 2.41.445.927.671 1.911.671 2.927 0 .447.105.868.281 1.25v3.721c0 1.209.722 2.247 1.755 2.721v1.1c.001 1.653 1.348 2.998 3 2.998zm4.508-3c0 .552-.449 1-1 1h-1.754-1.754c-.551 0-1-.449-1-1v-.82h2.754 2.754zm1.755-3.819c0 .551-.448 1-1 1h-3.509-3.509c-.552 0-1-.449-1-1v-2.068c.232.058.47.097.719.097h3.79 3.79c.249 0 .487-.039.719-.097zm-9.299-4.971c0-1.318-.292-2.595-.868-3.793-.563-1.171-1.385-2.233-2.376-3.071-3.247-2.742-4.948-6.926-4.551-11.191.607-6.389 5.903-11.426 12.275-11.715l.31-.01.265.009c6.417.29 11.713 5.327 12.319 11.714.398 4.267-1.303 8.451-4.55 11.193-.991.838-1.813 1.9-2.376 3.071-.576 1.198-.868 2.475-.868 3.793 0 .551-.448 1-1 1h-3.79-3.79c-.552 0-1-.449-1-1z"/></g></svg>

+ 1 - 1
mockup/icons/micro-sd.svg

@@ -1 +1 @@
-<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m379.887 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.4 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.344 4.345 4.344z"/><path d="m325.578 128.204h23.896c2.4 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.946 4.344 4.345 4.344z"/><path d="m271.27 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.399 0-4.344 1.945-4.344 4.345v69.515c-.001 2.399 1.944 4.344 4.344 4.344z"/><path d="m216.961 128.204h23.896c2.399 0 4.344-1.945 4.344-4.345v-69.514c0-2.4-1.945-4.345-4.344-4.345h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c.001 2.399 1.946 4.344 4.345 4.344z"/><path d="m162.653 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.4 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.344 4.345 4.344z"/><path d="m132.24 92.797h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.345 4.345 4.345h23.896c2.4 0 4.345-1.945 4.345-4.345v-69.515c0-2.4-1.945-4.345-4.345-4.345z"/><path d="m448 141.899c5.522 0 10-4.477 10-10v-121.899c0-5.523-4.478-10-10-10h-315.571c-2.652 0-5.195 1.054-7.071 2.929l-68.429 68.428c-1.875 1.876-2.929 4.419-2.929 7.072v131.688c0 5.523 4.478 10 10 10h6.293v22.909h-6.293c-5.522 0-10 4.477-10 10v248.974c0 5.523 4.478 10 10 10h384c5.522 0 10-4.477 10-10v-256.351c0-5.523-4.478-10-10-10h-6.293v-93.75zm-10-20h-6.293c-5.522 0-10 4.477-10 10v113.75c0 5.523 4.478 10 10 10h6.293v236.351h-364v-228.974h6.293c5.522 0 10-4.477 10-10v-42.909c0-5.523-4.478-10-10-10h-6.293v-117.546l62.571-62.571h301.429z"/></g></svg>
+<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m379.887 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.4 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.344 4.345 4.344z"/><path d="m325.578 128.204h23.896c2.4 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.946 4.344 4.345 4.344z"/><path d="m271.27 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.399 0-4.344 1.945-4.344 4.345v69.515c-.001 2.399 1.944 4.344 4.344 4.344z"/><path d="m216.961 128.204h23.896c2.399 0 4.344-1.945 4.344-4.345v-69.514c0-2.4-1.945-4.345-4.344-4.345h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c.001 2.399 1.946 4.344 4.345 4.344z"/><path d="m162.653 128.204h23.896c2.399 0 4.345-1.945 4.345-4.345v-69.514c0-2.4-1.945-4.345-4.345-4.345h-23.896c-2.4 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.344 4.345 4.344z"/><path d="m132.24 92.797h-23.896c-2.399 0-4.345 1.945-4.345 4.345v69.515c0 2.399 1.945 4.345 4.345 4.345h23.896c2.4 0 4.345-1.945 4.345-4.345v-69.515c0-2.4-1.945-4.345-4.345-4.345z"/><path d="m448 141.899c5.522 0 10-4.477 10-10v-121.899c0-5.523-4.478-10-10-10h-315.571c-2.652 0-5.195 1.054-7.071 2.929l-68.429 68.428c-1.875 1.876-2.929 4.419-2.929 7.072v131.688c0 5.523 4.478 10 10 10h6.293v22.909h-6.293c-5.522 0-10 4.477-10 10v248.974c0 5.523 4.478 10 10 10h384c5.522 0 10-4.477 10-10v-256.351c0-5.523-4.478-10-10-10h-6.293v-93.75zm-10-20h-6.293c-5.522 0-10 4.477-10 10v113.75c0 5.523 4.478 10 10 10h6.293v236.351h-364v-228.974h6.293c5.522 0 10-4.477 10-10v-42.909c0-5.523-4.478-10-10-10h-6.293v-117.546l62.571-62.571h301.429z"/></g></svg>

+ 1 - 1
mockup/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
mockup/icons/settings.svg


+ 1 - 1
mockup/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>

File diff suppressed because it is too large
+ 0 - 0
mockup/icons/speed.svg


+ 1 - 1
mockup/icons/temperature.svg

@@ -1 +1 @@
-<svg id="Layer_1" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m36 39.47v-32.47a7 7 0 1 0 -14 0v32.47a13.26 13.26 0 1 0 14 0zm-7 22.59a11.32 11.32 0 0 1 -5.53-21.19 1 1 0 0 0 .53-.87v-33a5 5 0 1 1 10 0v33a1 1 0 0 0 .49.84 11.32 11.32 0 0 1 -5.49 21.22z"/><path d="m30 44.89v-30.25a1 1 0 1 0 -1.94 0v30.25a5.94 5.94 0 1 0 1.94 0zm-1 9.85a4 4 0 1 1 4-4 4 4 0 0 1 -4 4z"/><path d="m40.32 9.64h7a1 1 0 0 0 0-1.94h-7a1 1 0 1 0 0 1.94z"/><path d="m40.32 16.06h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 20.55h-7a1 1 0 1 0 0 1.93h7a1 1 0 1 0 0-1.93z"/><path d="m40.32 28.91h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 33.39h-7a1 1 0 0 0 0 1.94h7a1 1 0 1 0 0-1.94z"/></svg>
+<svg id="Layer_1" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m36 39.47v-32.47a7 7 0 1 0 -14 0v32.47a13.26 13.26 0 1 0 14 0zm-7 22.59a11.32 11.32 0 0 1 -5.53-21.19 1 1 0 0 0 .53-.87v-33a5 5 0 1 1 10 0v33a1 1 0 0 0 .49.84 11.32 11.32 0 0 1 -5.49 21.22z"/><path d="m30 44.89v-30.25a1 1 0 1 0 -1.94 0v30.25a5.94 5.94 0 1 0 1.94 0zm-1 9.85a4 4 0 1 1 4-4 4 4 0 0 1 -4 4z"/><path d="m40.32 9.64h7a1 1 0 0 0 0-1.94h-7a1 1 0 1 0 0 1.94z"/><path d="m40.32 16.06h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 20.55h-7a1 1 0 1 0 0 1.93h7a1 1 0 1 0 0-1.93z"/><path d="m40.32 28.91h4.06a1 1 0 0 0 0-1.94h-4.06a1 1 0 0 0 0 1.94z"/><path d="m47.29 33.39h-7a1 1 0 0 0 0 1.94h7a1 1 0 1 0 0-1.94z"/></svg>

File diff suppressed because it is too large
+ 0 - 0
mockup/icons/ventilation.svg


+ 1 - 1
mockup/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>

+ 1 - 2
scripts/mqtt_sniffer.py

@@ -14,7 +14,6 @@ Example:
 import json
 import ssl
 import sys
-import time
 from datetime import datetime
 
 import paho.mqtt.client as mqtt
@@ -56,7 +55,7 @@ def on_message(client, userdata, msg):
             print(f"\n{'='*80}")
             print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
             print(f"Topic: {msg.topic}")
-            print(f"Full payload:")
+            print("Full payload:")
             print(json.dumps(payload, indent=2))
             print(f"{'='*80}\n")
         else:

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Bs58vo0R.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D9x3e2g2.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Da3qKIoX.css


+ 1 - 1
static/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
static/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
static/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
static/icons/settings.svg


+ 1 - 1
static/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
static/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
static/img/bambuddy_logo_dark_transparent.png


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DMT-rASM.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Da3qKIoX.css">
+    <script type="module" crossorigin src="/assets/index-D9x3e2g2.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bs58vo0R.css">
   </head>
   <body>
     <div id="root"></div>

+ 1 - 1
static/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>

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