Przeglądaj źródła

- 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 miesięcy temu
rodzic
commit
83cbac04b7
100 zmienionych plików z 2208 dodań i 1941 usunięć
  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
   - Light and dark theme support
   - Close with ESC key or click outside
   - Close with ESC key or click outside
   - Requires "Exclude Objects" option enabled in slicer
   - Requires "Exclude Objects" option enabled in slicer
+- **Interactive API Browser** - Explore and test all API endpoints directly in Bambuddy:
+  - Settings → API Keys now includes a full API browser
+  - Fetches OpenAPI schema automatically
+  - Endpoints grouped by category (printers, archives, settings, etc.)
+  - Expandable sections with color-coded method badges (GET, POST, PATCH, DELETE)
+  - Parameter inputs for path, query, and JSON body
+  - Auto-populates request body with schema examples
+  - Live API execution with response display (status, timing, formatted JSON)
+  - Paste API key to test authenticated endpoints
+  - Search to filter endpoints across all categories
+  - Two-column layout: API key management + API browser side-by-side
 - **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
 - **AMS slot RFID re-read** - Re-read RFID data for individual AMS slots:
   - Menu button (⋮) appears on hover over AMS slots
   - Menu button (⋮) appears on hover over AMS slots
   - "Re-read RFID" option triggers filament info refresh
   - "Re-read RFID" option triggers filament info refresh

+ 1 - 0
README.md

@@ -95,6 +95,7 @@
 - K-profiles (pressure advance)
 - K-profiles (pressure advance)
 - External sidebar links
 - External sidebar links
 - Webhooks & API keys
 - Webhooks & API keys
+- Interactive API browser with live testing
 
 
 ### 🖨️ Virtual Printer
 ### 🖨️ Virtual Printer
 - Emulates a Bambu Lab printer on your network
 - Emulates a Bambu Lab printer on your network

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -4,18 +4,18 @@ import logging
 from datetime import datetime
 from datetime import datetime
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
-from backend.app.models.archive import PrintArchive
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
     PrintQueueItemCreate,
     PrintQueueItemCreate,
-    PrintQueueItemUpdate,
     PrintQueueItemResponse,
     PrintQueueItemResponse,
+    PrintQueueItemUpdate,
     PrintQueueReorder,
     PrintQueueReorder,
 )
 )
 
 
@@ -124,9 +124,7 @@ async def update_queue_item(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a queue item."""
     """Update a queue item."""
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -138,9 +136,7 @@ async def update_queue_item(
 
 
     # Validate new printer_id if being changed
     # Validate new printer_id if being changed
     if "printer_id" in update_data:
     if "printer_id" in update_data:
-        result = await db.execute(
-            select(Printer).where(Printer.id == update_data["printer_id"])
-        )
+        result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
@@ -157,9 +153,7 @@ async def update_queue_item(
 @router.delete("/{item_id}")
 @router.delete("/{item_id}")
 async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
 async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Remove an item from the queue."""
     """Remove an item from the queue."""
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -181,9 +175,7 @@ async def reorder_queue(
 ):
 ):
     """Bulk update positions for queue items."""
     """Bulk update positions for queue items."""
     for reorder_item in data.items:
     for reorder_item in data.items:
-        result = await db.execute(
-            select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id)
-        )
+        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id))
         item = result.scalar_one_or_none()
         item = result.scalar_one_or_none()
         if item and item.status == "pending":
         if item and item.status == "pending":
             item.position = reorder_item.position
             item.position = reorder_item.position
@@ -196,9 +188,7 @@ async def reorder_queue(
 @router.post("/{item_id}/cancel")
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
 async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Cancel a pending queue item."""
     """Cancel a pending queue item."""
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -220,14 +210,13 @@ async def stop_queue_item(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Stop an actively printing queue item."""
     """Stop an actively printing queue item."""
+    import asyncio
+
+    from backend.app.models.smart_plug import SmartPlug
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.tasmota import tasmota_service
     from backend.app.services.tasmota import tasmota_service
-    from backend.app.models.smart_plug import SmartPlug
-    import asyncio
 
 
-    result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
-    )
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
@@ -257,9 +246,7 @@ async def stop_queue_item(
     # Get smart plug info if auto-off is enabled
     # Get smart plug info if auto-off is enabled
     plug_ip = None
     plug_ip = None
     if auto_off_after:
     if auto_off_after:
-        result = await db.execute(
-            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-        )
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         plug = result.scalar_one_or_none()
         plug = result.scalar_one_or_none()
         if plug and plug.enabled:
         if plug and plug.enabled:
             plug_ip = plug.ip_address
             plug_ip = plug.ip_address
@@ -268,15 +255,15 @@ async def stop_queue_item(
 
 
     # Schedule background task for cooldown + power off
     # Schedule background task for cooldown + power off
     if plug_ip:
     if plug_ip:
+
         async def cooldown_and_poweroff():
         async def cooldown_and_poweroff():
             logger.info(f"Auto-off: Waiting for printer {printer_id} to cool down before power off...")
             logger.info(f"Auto-off: Waiting for printer {printer_id} to cool down before power off...")
             await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
             await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
             # Re-fetch plug since we're in a new async context
             # Re-fetch plug since we're in a new async context
             from backend.app.core.database import async_session
             from backend.app.core.database import async_session
+
             async with async_session() as new_db:
             async with async_session() as new_db:
-                result = await new_db.execute(
-                    select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-                )
+                result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug and plug.enabled:
                 if plug and plug.enabled:
                     logger.info(f"Auto-off: Powering off printer {printer_id}")
                     logger.info(f"Auto-off: Powering off printer {printer_id}")

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Float, DateTime, func
+
+from sqlalchemy import DateTime, Float, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -29,9 +30,5 @@ class Filament(Base):
     bed_temp_min: Mapped[int | None] = mapped_column()
     bed_temp_min: Mapped[int | None] = mapped_column()
     bed_temp_max: Mapped[int | None] = mapped_column()
     bed_temp_max: Mapped[int | None] = mapped_column()
 
 
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now()
-    )
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

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

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

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

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

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

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

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

@@ -20,9 +20,7 @@ class NotificationTemplate(Base):
     body_template: Mapped[str] = mapped_column(Text, nullable=False)
     body_template: Mapped[str] = mapped_column(Text, nullable=False)
     is_default: Mapped[bool] = mapped_column(Boolean, default=True)
     is_default: Mapped[bool] = mapped_column(Boolean, default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
 
 
 # Default templates for seeding
 # Default templates for seeding

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

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

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

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Text, DateTime, func
+
+from sqlalchemy import DateTime, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -13,9 +14,5 @@ class Settings(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     value: Mapped[str] = mapped_column(Text)
     value: Mapped[str] = mapped_column(Text)
-    created_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now()
-    )
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -3,16 +3,15 @@
 import asyncio
 import asyncio
 import logging
 import logging
 from datetime import datetime
 from datetime import datetime
-from pathlib import Path
 
 
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
-from backend.app.models.archive import PrintArchive
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import upload_file_async
 from backend.app.services.bambu_ftp import upload_file_async
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
@@ -128,9 +127,7 @@ class PrintScheduler:
 
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""
         """Get the smart plug associated with a printer."""
-        result = await db.execute(
-            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-        )
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return result.scalar_one_or_none()
         return result.scalar_one_or_none()
 
 
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
@@ -222,9 +219,7 @@ class PrintScheduler:
         logger.info(f"Starting queue item {item.id}")
         logger.info(f"Starting queue item {item.id}")
 
 
         # Get archive
         # Get archive
-        result = await db.execute(
-            select(PrintArchive).where(PrintArchive.id == item.archive_id)
-        )
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
         archive = result.scalar_one_or_none()
         archive = result.scalar_one_or_none()
         if not archive:
         if not archive:
             item.status = "failed"
             item.status = "failed"
@@ -236,9 +231,7 @@ class PrintScheduler:
             return
             return
 
 
         # Get printer
         # Get printer
-        result = await db.execute(
-            select(Printer).where(Printer.id == item.printer_id)
-        )
+        result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
         if not printer:
         if not printer:
             item.status = "failed"
             item.status = "failed"
@@ -296,6 +289,7 @@ class PrintScheduler:
 
 
         # Register as expected print so we don't create a duplicate archive
         # Register as expected print so we don't create a duplicate archive
         from backend.app.main import register_expected_print
         from backend.app.main import register_expected_print
+
         register_expected_print(item.printer_id, remote_filename, archive.id)
         register_expected_print(item.printer_id, remote_filename, archive.id)
 
 
         # Start the print
         # Start the print

+ 18 - 42
backend/app/services/smart_plug_manager.py

@@ -5,11 +5,11 @@ import logging
 from datetime import datetime
 from datetime import datetime
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.services.tasmota import tasmota_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.tasmota import tasmota_service
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.models.smart_plug import SmartPlug
@@ -64,8 +64,8 @@ class SmartPlugManager:
         async with async_session() as db:
         async with async_session() as db:
             result = await db.execute(
             result = await db.execute(
                 select(SmartPlug).where(
                 select(SmartPlug).where(
-                    SmartPlug.enabled == True,
-                    SmartPlug.schedule_enabled == True,
+                    SmartPlug.enabled.is_(True),
+                    SmartPlug.schedule_enabled.is_(True),
                 )
                 )
             )
             )
             plugs = result.scalars().all()
             plugs = result.scalars().all()
@@ -98,15 +98,11 @@ class SmartPlugManager:
 
 
             await db.commit()
             await db.commit()
 
 
-    async def _get_plug_for_printer(
-        self, printer_id: int, db: AsyncSession
-    ) -> "SmartPlug | None":
+    async def _get_plug_for_printer(self, printer_id: int, db: AsyncSession) -> "SmartPlug | None":
         """Get the smart plug linked to a printer."""
         """Get the smart plug linked to a printer."""
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug import SmartPlug
 
 
-        result = await db.execute(
-            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
-        )
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return result.scalar_one_or_none()
         return result.scalar_one_or_none()
 
 
     async def on_print_start(self, printer_id: int, db: AsyncSession):
     async def on_print_start(self, printer_id: int, db: AsyncSession):
@@ -138,9 +134,7 @@ class SmartPlugManager:
             plug.auto_off_executed = False  # Reset flag when turning on
             plug.auto_off_executed = False  # Reset flag when turning on
             await db.commit()
             await db.commit()
 
 
-    async def on_print_complete(
-        self, printer_id: int, status: str, db: AsyncSession
-    ):
+    async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):
         """Called when a print completes - schedule turn off if configured.
         """Called when a print completes - schedule turn off if configured.
 
 
         Only triggers auto-off on successful completion (status='completed').
         Only triggers auto-off on successful completion (status='completed').
@@ -169,8 +163,7 @@ class SmartPlugManager:
             return
             return
 
 
         logger.info(
         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":
         if plug.off_delay_mode == "time":
@@ -183,9 +176,7 @@ class SmartPlugManager:
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
-        logger.info(
-            f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds"
-        )
+        logger.info(f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds")
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -231,17 +222,12 @@ class SmartPlugManager:
         finally:
         finally:
             self._pending_off.pop(plug_id, None)
             self._pending_off.pop(plug_id, None)
 
 
-    def _schedule_temp_based_off(
-        self, plug: "SmartPlug", printer_id: int, temp_threshold: int
-    ):
+    def _schedule_temp_based_off(self, plug: "SmartPlug", printer_id: int, temp_threshold: int):
         """Monitor temperature and turn off when below threshold."""
         """Monitor temperature and turn off when below threshold."""
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
-        logger.info(
-            f"Scheduling temperature-based turn-off for plug '{plug.name}' "
-            f"(threshold: {temp_threshold}°C)"
-        )
+        logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' " f"(threshold: {temp_threshold}°C)")
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -296,8 +282,7 @@ class SmartPlugManager:
                         )
                         )
                     else:
                     else:
                         logger.info(
                         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:
                     if max_nozzle_temp < temp_threshold:
@@ -328,9 +313,7 @@ class SmartPlugManager:
                 elapsed += check_interval
                 elapsed += check_interval
 
 
             if elapsed >= max_wait:
             if elapsed >= max_wait:
-                logger.warning(
-                    f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s"
-                )
+                logger.warning(f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s")
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
             logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}")
@@ -344,9 +327,7 @@ class SmartPlugManager:
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
 
 
             async with async_session() as db:
             async with async_session() as db:
-                result = await db.execute(
-                    select(SmartPlug).where(SmartPlug.id == plug_id)
-                )
+                result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug:
                 if plug:
                     plug.auto_off_pending = pending
                     plug.auto_off_pending = pending
@@ -363,9 +344,7 @@ class SmartPlugManager:
             from backend.app.models.smart_plug import SmartPlug
             from backend.app.models.smart_plug import SmartPlug
 
 
             async with async_session() as db:
             async with async_session() as db:
-                result = await db.execute(
-                    select(SmartPlug).where(SmartPlug.id == plug_id)
-                )
+                result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug:
                 if plug:
                     plug.auto_off = False  # Disable auto-off (one-shot behavior)
                     plug.auto_off = False  # Disable auto-off (one-shot behavior)
@@ -407,8 +386,8 @@ class SmartPlugManager:
                 # Find all plugs with pending auto-off
                 # Find all plugs with pending auto-off
                 result = await db.execute(
                 result = await db.execute(
                     select(SmartPlug).where(
                     select(SmartPlug).where(
-                        SmartPlug.auto_off_pending == True,
-                        SmartPlug.printer_id != None,
+                        SmartPlug.auto_off_pending.is_(True),
+                        SmartPlug.printer_id.isnot(None),
                     )
                     )
                 )
                 )
                 pending_plugs = result.scalars().all()
                 pending_plugs = result.scalars().all()
@@ -427,10 +406,7 @@ class SmartPlugManager:
                             await db.commit()
                             await db.commit()
                             continue
                             continue
 
 
-                    logger.info(
-                        f"Resuming pending auto-off for plug '{plug.name}' "
-                        f"(printer {plug.printer_id})"
-                    )
+                    logger.info(f"Resuming pending auto-off for plug '{plug.name}' " f"(printer {plug.printer_id})")
 
 
                     # Resume the appropriate off mode
                     # Resume the appropriate off mode
                     if plug.off_delay_mode == "temperature":
                     if plug.off_delay_mode == "temperature":

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

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

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

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

+ 68 - 62
backend/tests/conftest.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Plik diff jest za duży
+ 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>

Plik diff jest za duży
+ 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 { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
 import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
+import { APIBrowser } from '../components/APIBrowser';
 import { virtualPrinterApi } from '../api/client';
 import { virtualPrinterApi } from '../api/client';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
 import { availableLanguages } from '../i18n';
@@ -54,6 +55,7 @@ export function SettingsPage() {
   });
   });
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
+  const [testApiKey, setTestApiKey] = useState('');
 
 
   // Confirm modal states
   // Confirm modal states
   const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
   const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
@@ -1653,265 +1655,310 @@ export function SettingsPage() {
 
 
       {/* API Keys Tab */}
       {/* API Keys Tab */}
       {activeTab === 'apikeys' && (
       {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>
             </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>
+                  </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
                     <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>
                     </Button>
                   </div>
                   </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>
               <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>
               </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>
-                <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>
                 </div>
               </CardContent>
               </CardContent>
             </Card>
             </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>
             </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>
               </CardContent>
             </Card>
             </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>
         </div>
       )}
       )}
 
 

Plik diff jest za duży
+ 0 - 0
icons/27ca5e207eb045a7949048ab41fda285.svg


Plik diff jest za duży
+ 0 - 0
icons/57eeee2303f848be9d6159c1079f100d.svg


Plik diff jest za duży
+ 0 - 0
icons/7a3afd1aa53c47e38ea7e55356403f99.svg


Plik diff jest za duży
+ 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>

Plik diff jest za duży
+ 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>

Plik diff jest za duży
+ 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>

Plik diff jest za duży
+ 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>

Plik diff jest za duży
+ 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 json
 import ssl
 import ssl
 import sys
 import sys
-import time
 from datetime import datetime
 from datetime import datetime
 
 
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
@@ -56,7 +55,7 @@ def on_message(client, userdata, msg):
             print(f"\n{'='*80}")
             print(f"\n{'='*80}")
             print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
             print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
             print(f"Topic: {msg.topic}")
             print(f"Topic: {msg.topic}")
-            print(f"Full payload:")
+            print("Full payload:")
             print(json.dumps(payload, indent=2))
             print(json.dumps(payload, indent=2))
             print(f"{'='*80}\n")
             print(f"{'='*80}\n")
         else:
         else:

Plik diff jest za duży
+ 0 - 0
static/assets/index-Bs58vo0R.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-D9x3e2g2.js


Plik diff jest za duży
+ 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>

Plik diff jest za duży
+ 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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <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>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików