Browse Source

Merge pull request #32 from maziggy/0.1.6b

  New Features (0.1.6b)

  Smart Plugs

  - Tasmota device discovery - Auto-detect network and scan for Tasmota devices. Supports devices with/without authentication.
  - Switchbar quick access - Sidebar widget for controlling smart plugs from anywhere. Shows real-time status and power consumption.

  Timelapse

  - Timelapse editor - Trim, speed adjustment (0.25x-4x), and music overlay with FFmpeg server-side processing.

  Printer Discovery & Cards

  - Docker subnet scanning - Auto-detect Docker environment and enable subnet input for discovering printers
  - Printer model mapping - Show friendly names (X1C, H2D, P1S) instead of raw SSDP codes
  - Detailed status stages - Show heater states and print status stages on printer cards
  - Dual nozzle temperature - Fixed display for H2D printers

  Archives & Projects

  - AMS filament preview - Reprint modal shows filament comparison (type + color match indicators)
  - File type badges - GCODE (green) or SOURCE (orange) badges on archive cards
  - Project filament colors - Display filament swatches from assigned archives
  - BOM filter - "Hide done" toggle for completed items
  - Projects in backup/restore - Include projects, BOM items, and attachments
  - Attachment validation - File type validation for project attachments

  Maintenance

  - Custom maintenance types - No longer auto-assign to all printers; manual per-printer assignment
  - Delete printer options - Choose to keep or delete archives when deleting a printer

  UI Improvements

  - Project card design - Enhanced with gradients, shadows, and glow effects
  - Confirmation modals - Replace browser confirm dialogs with styled modals
  - Default timelapse speed - Changed from 2x to 1x

  ---
  Bug Fixes

  - Notifications sent when printer offline - Fixed notification module
  - Camera stream stopping - Increased timeouts, added auto-reconnection with exponential backoff
  - A1/P1 camera streaming - Fixed with extended timeouts and lower FPS cap; removed deprecated -stimeout option
  - Attachment uploads not persisting - Fixed SQLAlchemy JSON column mutation detection
  - Total print hours calculation - Now includes all prints, not just completed
  - Mock state bug - Fixed in test fixtures
MartinNYHC 5 months ago
parent
commit
ee704b4158
50 changed files with 7798 additions and 724 deletions
  1. 1 0
      .pre-commit-config.yaml
  2. 29 0
      CHANGELOG.md
  3. 5 10
      README.md
  4. 239 1
      backend/app/api/routes/archives.py
  5. 150 13
      backend/app/api/routes/camera.py
  6. 169 0
      backend/app/api/routes/discovery.py
  7. 142 85
      backend/app/api/routes/maintenance.py
  8. 24 6
      backend/app/api/routes/printers.py
  9. 884 38
      backend/app/api/routes/projects.py
  10. 283 115
      backend/app/api/routes/settings.py
  11. 132 20
      backend/app/api/routes/smart_plugs.py
  12. 1 1
      backend/app/core/config.py
  13. 157 90
      backend/app/core/database.py
  14. 10 3
      backend/app/main.py
  15. 40 4
      backend/app/models/project.py
  16. 50 0
      backend/app/models/project_bom.py
  17. 9 5
      backend/app/models/smart_plug.py
  18. 112 0
      backend/app/schemas/project.py
  19. 6 0
      backend/app/schemas/smart_plug.py
  20. 30 0
      backend/app/schemas/timelapse.py
  21. 31 16
      backend/app/services/bambu_mqtt.py
  22. 264 35
      backend/app/services/camera.py
  23. 678 0
      backend/app/services/discovery.py
  24. 55 1
      backend/app/services/printer_manager.py
  25. 264 0
      backend/app/services/timelapse_processor.py
  26. 144 0
      backend/tests/integration/test_discovery_api.py
  27. 90 69
      backend/tests/integration/test_smart_plugs_api.py
  28. 44 0
      backend/tests/unit/services/test_printer_manager.py
  29. 9 2
      docker-compose.yml
  30. 2 0
      frontend/src/App.tsx
  31. 327 4
      frontend/src/api/client.ts
  32. 178 5
      frontend/src/components/AddSmartPlugModal.tsx
  33. 17 1
      frontend/src/components/BackupModal.tsx
  34. 44 1
      frontend/src/components/Layout.tsx
  35. 205 2
      frontend/src/components/ReprintModal.tsx
  36. 21 1
      frontend/src/components/SmartPlugCard.tsx
  37. 142 0
      frontend/src/components/SwitchbarPopover.tsx
  38. 546 0
      frontend/src/components/TimelapseEditorModal.tsx
  39. 30 3
      frontend/src/components/TimelapseViewer.tsx
  40. 21 1
      frontend/src/pages/ArchivesPage.tsx
  41. 120 6
      frontend/src/pages/CameraPage.tsx
  42. 176 23
      frontend/src/pages/MaintenancePage.tsx
  43. 425 59
      frontend/src/pages/PrintersPage.tsx
  44. 1169 0
      frontend/src/pages/ProjectDetailPage.tsx
  45. 321 102
      frontend/src/pages/ProjectsPage.tsx
  46. 0 0
      static/assets/index-BGnYjiV-.js
  47. 0 0
      static/assets/index-CbCN6LSA.css
  48. 0 0
      static/assets/index-CiDcmh7W.css
  49. 0 0
      static/assets/index-Dszw0rVS.js
  50. 2 2
      static/index.html

+ 1 - 0
.pre-commit-config.yaml

@@ -27,6 +27,7 @@ repos:
         exclude: ^static/
         exclude: ^static/
       - id: check-added-large-files
       - id: check-added-large-files
         args: ['--maxkb=1000']
         args: ['--maxkb=1000']
+        exclude: ^static/assets/
       - id: check-merge-conflict
       - id: check-merge-conflict
       - id: debug-statements
       - id: debug-statements
       - id: detect-private-key
       - id: detect-private-key

+ 29 - 0
CHANGELOG.md

@@ -2,6 +2,35 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.6b] - 2025-12-28
+
+### Added
+- **Tasmota device discovery** - Automatically discover Tasmota smart plugs on your network. Click "Discover Tasmota Devices" in the Add Smart Plug modal to scan your local subnet. Supports devices with and without authentication.
+- **Switchbar for quick smart plug access** - New sidebar widget for controlling smart plugs without leaving the current page. Enable "Show in Switchbar" for any plug to add it to the quick access panel. Shows real-time status, power consumption, and on/off controls.
+- **Timelapse editor** - Edit timelapse videos with trim, speed adjustment (0.25x-4x), and music overlay. Uses FFmpeg for server-side processing with browser-based preview.
+- **AMS filament preview** - Reprint modal shows filament comparison between what the print requires and what's currently loaded in the AMS. Compares both type and color with visual indicators (green=match, yellow=color mismatch, orange=type mismatch).
+- **File type badge** - Archive cards now show GCODE (green) or SOURCE (orange) badge to indicate whether the file is a sliced print-ready file or source-only.
+- **Docker printer discovery** - Subnet scanning for discovering printers when running in Docker with `network_mode: host`. Automatically detects Docker environment and shows subnet input field in Add Printer dialog.
+- **Printer model mapping** - Discovery now shows friendly model names (X1C, H2D, P1S) instead of raw SSDP codes (BL-P001, O1D, C11).
+- **Discovery API tests** - Comprehensive test coverage for discovery endpoints.
+- **Project filament colors** - Project cards now display filament color swatches from assigned archives.
+- **BOM filter** - Hide completed BOM items with "Hide done" toggle on project detail page.
+- **Projects in backup/restore** - Projects, BOM items, and attachments now included in database backup/restore.
+- **Attachment file validation** - File type validation for project attachments (images, documents, 3D files, archives, scripts, configs).
+
+### Changed
+- **Timelapse viewer** - Default playback speed changed from 2x to 1x.
+- **GitHub issue template** - Added mandatory printer firmware version field and LAN-only mode checkbox for better bug reports.
+- **Docker compose** - Clearer comments explaining `network_mode: host` requirement for printer discovery and camera streaming.
+- **Project card design** - Enhanced visual polish with gradients, shadows, and glow effects on hover.
+- **Project page layout** - Improved spacing and padding on project list and detail pages.
+- **Delete confirmations** - Replaced browser confirm dialogs with styled confirmation modals.
+
+### Fixed
+- **Notification module** - Fixed bug where notifications were sent even when printer was offline.
+- **Attachment uploads** - Fixed file attachments not persisting due to SQLAlchemy JSON column mutation detection.
+- **Camera stream stability** - Fixed stream stopping after a few minutes by increasing ffmpeg read timeout (10s→30s), adding buffer options, and implementing auto-reconnection with exponential backoff in the frontend.
+
 ## [0.1.5] - 2025-12-19
 ## [0.1.5] - 2025-12-19
 
 
 ### Fixed
 ### Fixed

+ 5 - 10
README.md

@@ -48,7 +48,8 @@
 - 3D model preview (Three.js)
 - 3D model preview (Three.js)
 - Duplicate detection & full-text search
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Photo attachments & failure analysis
-- Re-print to any connected printer
+- Timelapse editor (trim, speed, music)
+- Re-print to any connected printer with AMS filament preview
 - Archive comparison (side-by-side diff)
 - Archive comparison (side-by-side diff)
 
 
 ### 📊 Monitoring & Stats
 ### 📊 Monitoring & Stats
@@ -97,14 +98,6 @@
 - Print time accuracy stats
 - Print time accuracy stats
 - File manager for printer storage
 - File manager for printer storage
 
 
-### 🎛️ Printer Control
-- AMS/AMS-HT temperature & humidity monitoring
-- Chamber temperature & light control
-- Speed profiles & fan controls
-- AI detection modules (spaghetti, first layer)
-- Automated calibration (bed level, vibration)
-- Dual nozzle support
-
 </td>
 </td>
 </tr>
 </tr>
 </table>
 </table>
@@ -288,7 +281,7 @@ server {
 
 
 > **Note:** WebSocket support is required for real-time printer updates.
 > **Note:** WebSocket support is required for real-time printer updates.
 
 
-**Network Mode Host** (for easier printer discovery):
+**Network Mode Host** (required for printer discovery and camera streaming):
 
 
 ```yaml
 ```yaml
 services:
 services:
@@ -297,6 +290,8 @@ services:
     network_mode: host
     network_mode: host
 ```
 ```
 
 
+> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy can discover printers via subnet scanning - enter your network range (e.g., `192.168.1.0/24`) in the Add Printer dialog.
+
 </details>
 </details>
 
 
 #### Manual Installation
 #### Manual Installation

+ 239 - 1
backend/app/api/routes/archives.py

@@ -3,7 +3,7 @@ import logging
 import zipfile
 import zipfile
 from pathlib import Path
 from pathlib import Path
 
 
-from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import FileResponse, Response
 from fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -875,10 +875,17 @@ async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
     if not timelapse_path.exists():
     if not timelapse_path.exists():
         raise HTTPException(404, "Timelapse file not found")
         raise HTTPException(404, "Timelapse file not found")
 
 
+    # Use file modification time as ETag to bust cache after processing
+    mtime = int(timelapse_path.stat().st_mtime)
+
     return FileResponse(
     return FileResponse(
         path=timelapse_path,
         path=timelapse_path,
         media_type="video/mp4",
         media_type="video/mp4",
         filename=f"{archive.print_name or 'timelapse'}.mp4",
         filename=f"{archive.print_name or 'timelapse'}.mp4",
+        headers={
+            "Cache-Control": "no-cache, must-revalidate",
+            "ETag": f'"{mtime}"',
+        },
     )
     )
 
 
 
 
@@ -1160,6 +1167,168 @@ async def upload_timelapse(
     return {"status": "attached", "filename": file.filename}
     return {"status": "attached", "filename": file.filename}
 
 
 
 
+@router.get("/{archive_id}/timelapse/info")
+async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get timelapse video metadata for editor."""
+    from backend.app.schemas.timelapse import TimelapseInfoResponse
+    from backend.app.services.timelapse_processor import TimelapseProcessor
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not timelapse_path.exists():
+        raise HTTPException(404, "Timelapse file not found")
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+        info = await processor.get_info()
+        return TimelapseInfoResponse(**info)
+    except Exception as e:
+        logger.error(f"Failed to get timelapse info: {e}")
+        raise HTTPException(500, f"Failed to get video info: {str(e)}")
+
+
+@router.get("/{archive_id}/timelapse/thumbnails")
+async def get_timelapse_thumbnails(
+    archive_id: int,
+    count: int = Query(10, ge=1, le=30),
+    width: int = Query(160, ge=80, le=320),
+    db: AsyncSession = Depends(get_db),
+):
+    """Generate timeline thumbnail frames for visual scrubbing."""
+    import base64
+
+    from backend.app.schemas.timelapse import ThumbnailResponse
+    from backend.app.services.timelapse_processor import TimelapseProcessor
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not timelapse_path.exists():
+        raise HTTPException(404, "Timelapse file not found")
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+        thumbnails = await processor.generate_thumbnails(count, width)
+
+        return ThumbnailResponse(
+            thumbnails=[base64.b64encode(data).decode() for _, data in thumbnails],
+            timestamps=[ts for ts, _ in thumbnails],
+        )
+    except Exception as e:
+        logger.error(f"Failed to generate thumbnails: {e}")
+        raise HTTPException(500, f"Failed to generate thumbnails: {str(e)}")
+
+
+@router.post("/{archive_id}/timelapse/process")
+async def process_timelapse(
+    archive_id: int,
+    trim_start: float = Form(0),
+    trim_end: float = Form(None),
+    speed: float = Form(1.0),
+    save_mode: str = Form("new"),
+    output_filename: str = Form(None),
+    audio: UploadFile = File(None),
+    db: AsyncSession = Depends(get_db),
+):
+    """Process timelapse with trim, speed, and optional audio overlay."""
+    import shutil
+    import tempfile
+
+    from backend.app.schemas.timelapse import ProcessResponse
+    from backend.app.services.timelapse_processor import TimelapseProcessor
+
+    # Validate speed
+    if not 0.25 <= speed <= 4.0:
+        raise HTTPException(400, "Speed must be between 0.25 and 4.0")
+
+    if save_mode not in ("replace", "new"):
+        raise HTTPException(400, "save_mode must be 'replace' or 'new'")
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not timelapse_path.exists():
+        raise HTTPException(404, "Timelapse file not found")
+
+    archive_dir = timelapse_path.parent
+
+    # Handle audio file
+    audio_temp_path = None
+    if audio and audio.filename:
+        # Validate audio file extension
+        if not audio.filename.lower().endswith((".mp3", ".wav", ".m4a", ".aac", ".ogg")):
+            raise HTTPException(400, "Audio must be .mp3, .wav, .m4a, .aac, or .ogg")
+
+        audio_content = await audio.read()
+        suffix = Path(audio.filename).suffix
+        audio_temp_path = Path(tempfile.gettempdir()) / f"audio_{archive_id}{suffix}"
+        audio_temp_path.write_bytes(audio_content)
+
+    try:
+        processor = TimelapseProcessor(timelapse_path)
+
+        # Determine output path
+        if save_mode == "replace":
+            # Process to temp file first, then replace
+            temp_output = Path(tempfile.gettempdir()) / f"processed_{archive_id}.mp4"
+            output_path = temp_output
+        else:
+            # Save as new file alongside original
+            filename = output_filename or f"{archive.print_name or 'timelapse'}_edited.mp4"
+            # Sanitize filename
+            filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
+            if not filename.endswith(".mp4"):
+                filename += ".mp4"
+            output_path = archive_dir / filename
+
+        success = await processor.process(
+            output_path=output_path,
+            trim_start=trim_start,
+            trim_end=trim_end,
+            speed=speed,
+            audio_path=audio_temp_path,
+        )
+
+        if not success:
+            raise HTTPException(500, "Video processing failed")
+
+        # Handle save mode
+        if save_mode == "replace":
+            # Replace original file
+            shutil.move(str(output_path), str(timelapse_path))
+            final_path = archive.timelapse_path
+            message = "Timelapse replaced successfully"
+        else:
+            final_path = str(output_path.relative_to(settings.base_dir))
+            message = f"Saved as {output_path.name}"
+
+        return ProcessResponse(
+            status="completed",
+            output_path=final_path,
+            message=message,
+        )
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Timelapse processing failed: {e}")
+        raise HTTPException(500, f"Processing failed: {str(e)}")
+    finally:
+        # Cleanup temp audio file
+        if audio_temp_path and audio_temp_path.exists():
+            audio_temp_path.unlink()
+
+
 # ============================================
 # ============================================
 # Photo Endpoints
 # Photo Endpoints
 # ============================================
 # ============================================
@@ -1564,6 +1733,75 @@ async def upload_archives_bulk(
     }
     }
 
 
 
 
+@router.get("/{archive_id}/filament-requirements")
+async def get_filament_requirements(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get filament requirements from the archived 3MF file.
+
+    Returns the filaments used in this print with their slot IDs, types, colors,
+    and usage amounts. This can be compared with current AMS state before reprinting.
+    """
+    import xml.etree.ElementTree as ET
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "Archive file not found")
+
+    filaments = []
+
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            # Parse slice_info.config for filament requirements
+            if "Metadata/slice_info.config" in zf.namelist():
+                content = zf.read("Metadata/slice_info.config").decode()
+                root = ET.fromstring(content)
+
+                # Extract filament elements
+                # Format: <filament id="1" type="PLA" color="#FFFFFF" used_g="100" used_m="10" />
+                for filament_elem in root.findall(".//filament"):
+                    filament_id = filament_elem.get("id")
+                    filament_type = filament_elem.get("type", "")
+                    filament_color = filament_elem.get("color", "")
+                    used_g = filament_elem.get("used_g", "0")
+                    used_m = filament_elem.get("used_m", "0")
+
+                    # Only include filaments that are actually used
+                    try:
+                        used_grams = float(used_g)
+                    except (ValueError, TypeError):
+                        used_grams = 0
+
+                    if used_grams > 0 and filament_id:
+                        filaments.append(
+                            {
+                                "slot_id": int(filament_id),
+                                "type": filament_type,
+                                "color": filament_color,
+                                "used_grams": round(used_grams, 1),
+                                "used_meters": float(used_m) if used_m else 0,
+                            }
+                        )
+
+            # Sort by slot ID
+            filaments.sort(key=lambda x: x["slot_id"])
+
+    except Exception as e:
+        logger.warning(f"Failed to parse filament requirements from archive {archive_id}: {e}")
+
+    return {
+        "archive_id": archive_id,
+        "filename": archive.filename,
+        "filaments": filaments,
+    }
+
+
 @router.post("/{archive_id}/reprint")
 @router.post("/{archive_id}/reprint")
 async def reprint_archive(
 async def reprint_archive(
     archive_id: int,
     archive_id: int,

+ 150 - 13
backend/app/api/routes/camera.py

@@ -13,8 +13,11 @@ from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.services.camera import (
 from backend.app.services.camera import (
     capture_camera_frame,
     capture_camera_frame,
+    generate_chamber_image_stream,
     get_camera_port,
     get_camera_port,
     get_ffmpeg_path,
     get_ffmpeg_path,
+    is_chamber_image_model,
+    read_next_chamber_frame,
     test_camera_connection,
     test_camera_connection,
 )
 )
 
 
@@ -24,6 +27,9 @@ router = APIRouter(prefix="/printers", tags=["camera"])
 # Track active ffmpeg processes for cleanup
 # Track active ffmpeg processes for cleanup
 _active_streams: dict[str, asyncio.subprocess.Process] = {}
 _active_streams: dict[str, asyncio.subprocess.Process] = {}
 
 
+# Track active chamber image connections for cleanup
+_active_chamber_streams: dict[str, tuple] = {}
+
 # Store last frame for each printer (for photo capture from active stream)
 # Store last frame for each printer (for photo capture from active stream)
 _last_frames: dict[int, bytes] = {}
 _last_frames: dict[int, bytes] = {}
 
 
@@ -45,7 +51,96 @@ async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
     return printer
     return printer
 
 
 
 
-async def generate_mjpeg_stream(
+async def generate_chamber_mjpeg_stream(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    fps: int = 5,
+    stream_id: str | None = None,
+    disconnect_event: asyncio.Event | None = None,
+    printer_id: int | None = None,
+) -> AsyncGenerator[bytes, None]:
+    """Generate MJPEG stream from A1/P1 printer using chamber image protocol.
+
+    This connects to port 6000 and reads JPEG frames using the Bambu binary protocol.
+    """
+    logger.info(f"Starting chamber image stream for {ip_address} (stream_id={stream_id}, model={model})")
+
+    connection = await generate_chamber_image_stream(ip_address, access_code, fps)
+    if connection is None:
+        logger.error(f"Failed to connect to chamber image stream for {ip_address}")
+        yield (
+            b"--frame\r\n"
+            b"Content-Type: text/plain\r\n\r\n"
+            b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n"
+        )
+        return
+
+    reader, writer = connection
+
+    # Track active connection for cleanup
+    if stream_id:
+        _active_chamber_streams[stream_id] = (reader, writer)
+
+    try:
+        frame_interval = 1.0 / fps if fps > 0 else 0.2
+        last_frame_time = 0.0
+
+        while True:
+            # Check if client disconnected
+            if disconnect_event and disconnect_event.is_set():
+                logger.info(f"Client disconnected, stopping chamber stream {stream_id}")
+                break
+
+            # Read next frame
+            frame = await read_next_chamber_frame(reader, timeout=30.0)
+            if frame is None:
+                logger.warning(f"Chamber image stream ended for {stream_id}")
+                break
+
+            # Save frame to buffer for photo capture
+            if printer_id is not None:
+                _last_frames[printer_id] = frame
+
+            # Rate limiting - skip frames if needed to maintain target FPS
+            current_time = asyncio.get_event_loop().time()
+            if current_time - last_frame_time < frame_interval:
+                continue
+            last_frame_time = current_time
+
+            # Yield frame in MJPEG format
+            yield (
+                b"--frame\r\n"
+                b"Content-Type: image/jpeg\r\n"
+                b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
+                b"\r\n" + frame + b"\r\n"
+            )
+
+    except asyncio.CancelledError:
+        logger.info(f"Chamber image stream cancelled (stream_id={stream_id})")
+    except GeneratorExit:
+        logger.info(f"Chamber image stream generator exit (stream_id={stream_id})")
+    except Exception as e:
+        logger.exception(f"Chamber image stream error: {e}")
+    finally:
+        # Remove from active streams
+        if stream_id and stream_id in _active_chamber_streams:
+            del _active_chamber_streams[stream_id]
+
+        # Clean up frame buffer
+        if printer_id is not None and printer_id in _last_frames:
+            del _last_frames[printer_id]
+
+        # Close the connection
+        try:
+            writer.close()
+            await writer.wait_closed()
+        except Exception:
+            pass
+        logger.info(f"Chamber image stream stopped for {ip_address} (stream_id={stream_id})")
+
+
+async def generate_rtsp_mjpeg_stream(
     ip_address: str,
     ip_address: str,
     access_code: str,
     access_code: str,
     model: str | None,
     model: str | None,
@@ -54,9 +149,9 @@ async def generate_mjpeg_stream(
     disconnect_event: asyncio.Event | None = None,
     disconnect_event: asyncio.Event | None = None,
     printer_id: int | None = None,
     printer_id: int | None = None,
 ) -> AsyncGenerator[bytes, None]:
 ) -> AsyncGenerator[bytes, None]:
-    """Generate MJPEG stream from printer camera using ffmpeg.
+    """Generate MJPEG stream from printer camera using ffmpeg/RTSP.
 
 
-    This captures frames continuously and yields them in MJPEG format.
+    This is for X1/H2/P2 models that support RTSP streaming.
     """
     """
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
@@ -70,6 +165,9 @@ async def generate_mjpeg_stream(
     # ffmpeg command to output MJPEG stream to stdout
     # ffmpeg command to output MJPEG stream to stdout
     # -rtsp_transport tcp: Use TCP for reliability
     # -rtsp_transport tcp: Use TCP for reliability
     # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
     # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
+    # -timeout: Connection timeout in microseconds (30 seconds)
+    # -buffer_size: Larger buffer for network jitter
+    # -max_delay: Maximum demuxing delay
     # -f mjpeg: Output as MJPEG
     # -f mjpeg: Output as MJPEG
     # -q:v 5: Quality (lower = better, 2-10 is good range)
     # -q:v 5: Quality (lower = better, 2-10 is good range)
     # -r: Output framerate
     # -r: Output framerate
@@ -79,6 +177,12 @@ async def generate_mjpeg_stream(
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
         "prefer_tcp",
         "prefer_tcp",
+        "-timeout",
+        "30000000",  # 30 seconds in microseconds
+        "-buffer_size",
+        "1024000",  # 1MB buffer
+        "-max_delay",
+        "500000",  # 0.5 seconds max delay
         "-i",
         "-i",
         camera_url,
         camera_url,
         "-f",
         "-f",
@@ -91,7 +195,7 @@ async def generate_mjpeg_stream(
         "-",  # Output to stdout
         "-",  # Output to stdout
     ]
     ]
 
 
-    logger.info(f"Starting camera stream for {ip_address} (stream_id={stream_id})")
+    logger.info(f"Starting RTSP camera stream for {ip_address} (stream_id={stream_id}, model={model}, fps={fps})")
     logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
     logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
 
 
     process = None
     process = None
@@ -131,8 +235,8 @@ async def generate_mjpeg_stream(
                 break
                 break
 
 
             try:
             try:
-                # Read chunk from ffmpeg
-                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=10.0)
+                # Read chunk from ffmpeg - use longer timeout for network hiccups
+                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
 
 
                 if not chunk:
                 if not chunk:
                     logger.warning("Camera stream ended (no more data)")
                     logger.warning("Camera stream ended (no more data)")
@@ -230,6 +334,10 @@ async def camera_stream(
     This endpoint returns a multipart MJPEG stream that can be used directly
     This endpoint returns a multipart MJPEG stream that can be used directly
     in an <img> tag or video player.
     in an <img> tag or video player.
 
 
+    Uses the appropriate protocol based on printer model:
+    - A1/P1: Chamber image protocol (port 6000)
+    - X1/H2/P2: RTSP via ffmpeg (port 322)
+
     Args:
     Args:
         printer_id: Printer ID
         printer_id: Printer ID
         fps: Target frames per second (default: 10, max: 30)
         fps: Target frames per second (default: 10, max: 30)
@@ -238,8 +346,11 @@ async def camera_stream(
 
 
     printer = await get_printer_or_404(printer_id, db)
     printer = await get_printer_or_404(printer_id, db)
 
 
-    # Validate FPS
-    fps = min(max(fps, 1), 30)
+    # Validate FPS - A1/P1 models max out at ~5 FPS
+    if is_chamber_image_model(printer.model):
+        fps = min(max(fps, 1), 5)
+    else:
+        fps = min(max(fps, 1), 30)
 
 
     # Generate unique stream ID for tracking
     # Generate unique stream ID for tracking
     stream_id = f"{printer_id}-{uuid.uuid4().hex[:8]}"
     stream_id = f"{printer_id}-{uuid.uuid4().hex[:8]}"
@@ -247,10 +358,18 @@ async def camera_stream(
     # Create disconnect event that will be set when client disconnects
     # Create disconnect event that will be set when client disconnects
     disconnect_event = asyncio.Event()
     disconnect_event = asyncio.Event()
 
 
+    # Choose the appropriate stream generator based on model
+    if is_chamber_image_model(printer.model):
+        stream_generator = generate_chamber_mjpeg_stream
+        logger.info(f"Using chamber image protocol for {printer.model}")
+    else:
+        stream_generator = generate_rtsp_mjpeg_stream
+        logger.info(f"Using RTSP protocol for {printer.model}")
+
     async def stream_with_disconnect_check():
     async def stream_with_disconnect_check():
         """Wrapper generator that monitors for client disconnect."""
         """Wrapper generator that monitors for client disconnect."""
         try:
         try:
-            async for chunk in generate_mjpeg_stream(
+            async for chunk in stream_generator(
                 ip_address=printer.ip_address,
                 ip_address=printer.ip_address,
                 access_code=printer.access_code,
                 access_code=printer.access_code,
                 model=printer.model,
                 model=printer.model,
@@ -295,6 +414,8 @@ async def stop_camera_stream(printer_id: int):
     Accepts both GET and POST (POST for sendBeacon compatibility).
     Accepts both GET and POST (POST for sendBeacon compatibility).
     """
     """
     stopped = 0
     stopped = 0
+
+    # Stop ffmpeg/RTSP streams
     to_remove = []
     to_remove = []
     for stream_id, process in list(_active_streams.items()):
     for stream_id, process in list(_active_streams.items()):
         if stream_id.startswith(f"{printer_id}-"):
         if stream_id.startswith(f"{printer_id}-"):
@@ -310,9 +431,22 @@ async def stop_camera_stream(printer_id: int):
     for stream_id in to_remove:
     for stream_id in to_remove:
         _active_streams.pop(stream_id, None)
         _active_streams.pop(stream_id, None)
 
 
-    logger.info(
-        f"Stopped {stopped} camera stream(s) for printer {printer_id}, active streams remaining: {list(_active_streams.keys())}"
-    )
+    # Stop chamber image streams
+    to_remove_chamber = []
+    for stream_id, (_reader, writer) in list(_active_chamber_streams.items()):
+        if stream_id.startswith(f"{printer_id}-"):
+            to_remove_chamber.append(stream_id)
+            try:
+                writer.close()
+                stopped += 1
+                logger.info(f"Closed chamber image connection for stream {stream_id}")
+            except Exception as e:
+                logger.warning(f"Error stopping chamber stream {stream_id}: {e}")
+
+    for stream_id in to_remove_chamber:
+        _active_chamber_streams.pop(stream_id, None)
+
+    logger.info(f"Stopped {stopped} camera stream(s) for printer {printer_id}")
     return {"stopped": stopped}
     return {"stopped": stopped}
 
 
 
 
@@ -344,7 +478,10 @@ async def camera_snapshot(
         )
         )
 
 
         if not success:
         if not success:
-            raise HTTPException(status_code=503, detail="Failed to capture camera frame. Is the printer powered on?")
+            raise HTTPException(
+                status_code=503,
+                detail="Failed to capture camera frame. Ensure printer is on and camera is enabled.",
+            )
 
 
         # Read and return the image
         # Read and return the image
         with open(temp_path, "rb") as f:
         with open(temp_path, "rb") as f:

+ 169 - 0
backend/app/api/routes/discovery.py

@@ -0,0 +1,169 @@
+"""
+Printer discovery API endpoints.
+
+Provides endpoints for discovering Bambu Lab printers on the local network.
+Supports both SSDP discovery (for native installs) and subnet scanning (for Docker).
+"""
+
+import logging
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from backend.app.services.discovery import (
+    discovery_service,
+    is_running_in_docker,
+    subnet_scanner,
+)
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/discovery", tags=["discovery"])
+
+
+class DiscoveryStatus(BaseModel):
+    """Discovery status response."""
+
+    running: bool
+
+
+class DiscoveryInfo(BaseModel):
+    """Discovery environment info."""
+
+    is_docker: bool
+    ssdp_running: bool
+    scan_running: bool
+
+
+class SubnetScanRequest(BaseModel):
+    """Request to scan a subnet."""
+
+    subnet: str  # CIDR notation, e.g., "192.168.1.0/24"
+    timeout: float = 1.0  # Connection timeout per host
+
+
+class SubnetScanStatus(BaseModel):
+    """Subnet scan status response."""
+
+    running: bool
+    scanned: int
+    total: int
+
+
+class DiscoveredPrinterResponse(BaseModel):
+    """Discovered printer response."""
+
+    serial: str
+    name: str
+    ip_address: str
+    model: str | None = None
+    discovered_at: str | None = None
+
+
+@router.get("/info", response_model=DiscoveryInfo)
+async def get_discovery_info():
+    """Get discovery environment info (Docker detection, etc.)."""
+    return DiscoveryInfo(
+        is_docker=is_running_in_docker(),
+        ssdp_running=discovery_service.is_running,
+        scan_running=subnet_scanner.is_running,
+    )
+
+
+@router.get("/status", response_model=DiscoveryStatus)
+async def get_discovery_status():
+    """Get the current SSDP discovery status."""
+    return DiscoveryStatus(running=discovery_service.is_running)
+
+
+@router.post("/start", response_model=DiscoveryStatus)
+async def start_discovery(duration: float = 10.0):
+    """Start SSDP printer discovery.
+
+    Args:
+        duration: Discovery duration in seconds (default 10)
+    """
+    await discovery_service.start(duration=duration)
+    return DiscoveryStatus(running=discovery_service.is_running)
+
+
+@router.post("/stop", response_model=DiscoveryStatus)
+async def stop_discovery():
+    """Stop SSDP printer discovery."""
+    await discovery_service.stop()
+    return DiscoveryStatus(running=discovery_service.is_running)
+
+
+@router.get("/printers", response_model=list[DiscoveredPrinterResponse])
+async def get_discovered_printers():
+    """Get list of discovered printers (from both SSDP and subnet scan)."""
+    # Combine results from both discovery methods
+    printers = {}
+
+    # Add SSDP discovered printers
+    for p in discovery_service.discovered_printers:
+        printers[p.ip_address] = p
+
+    # Add subnet scan discovered printers (may override if same IP)
+    for p in subnet_scanner.discovered_printers:
+        if p.ip_address not in printers:
+            printers[p.ip_address] = p
+
+    return [
+        DiscoveredPrinterResponse(
+            serial=p.serial,
+            name=p.name,
+            ip_address=p.ip_address,
+            model=p.model,
+            discovered_at=p.discovered_at,
+        )
+        for p in printers.values()
+    ]
+
+
+# Subnet scanning endpoints (for Docker environments)
+
+
+@router.post("/scan", response_model=SubnetScanStatus)
+async def start_subnet_scan(request: SubnetScanRequest):
+    """Start a subnet scan for Bambu printers.
+
+    Use this when running in Docker where SSDP multicast doesn't work.
+
+    Args:
+        request: Subnet to scan in CIDR notation (e.g., "192.168.1.0/24")
+    """
+    # Start scan in background
+    import asyncio
+
+    asyncio.create_task(subnet_scanner.scan_subnet(request.subnet, request.timeout))
+
+    # Return immediate status
+    scanned, total = subnet_scanner.progress
+    return SubnetScanStatus(
+        running=subnet_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.get("/scan/status", response_model=SubnetScanStatus)
+async def get_scan_status():
+    """Get the current subnet scan status."""
+    scanned, total = subnet_scanner.progress
+    return SubnetScanStatus(
+        running=subnet_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.post("/scan/stop", response_model=SubnetScanStatus)
+async def stop_subnet_scan():
+    """Stop the current subnet scan."""
+    subnet_scanner.stop()
+    scanned, total = subnet_scanner.progress
+    return SubnetScanStatus(
+        running=subnet_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )

+ 142 - 85
backend/app/api/routes/maintenance.py

@@ -2,30 +2,28 @@
 
 
 import logging
 import logging
 from datetime import datetime
 from datetime import datetime
-from typing import List
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select, func
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 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.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
-from backend.app.models.printer import Printer
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
-from backend.app.services.notification_service import notification_service
+from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
+from backend.app.models.printer import Printer
 from backend.app.schemas.maintenance import (
 from backend.app.schemas.maintenance import (
-    MaintenanceTypeCreate,
-    MaintenanceTypeUpdate,
-    MaintenanceTypeResponse,
-    PrinterMaintenanceCreate,
-    PrinterMaintenanceUpdate,
-    PrinterMaintenanceResponse,
     MaintenanceHistoryResponse,
     MaintenanceHistoryResponse,
     MaintenanceStatus,
     MaintenanceStatus,
-    PrinterMaintenanceOverview,
+    MaintenanceTypeCreate,
+    MaintenanceTypeResponse,
+    MaintenanceTypeUpdate,
     PerformMaintenanceRequest,
     PerformMaintenanceRequest,
+    PrinterMaintenanceOverview,
+    PrinterMaintenanceResponse,
+    PrinterMaintenanceUpdate,
 )
 )
+from backend.app.services.notification_service import notification_service
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -73,20 +71,20 @@ DEFAULT_MAINTENANCE_TYPES = [
 
 
 
 
 async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
 async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
-    """Calculate total print hours for a printer from archives plus offset."""
-    # Get archive hours
+    """Calculate total print hours for a printer from archives plus offset.
+
+    Includes ALL prints (completed, failed, cancelled) since the printer
+    components are being used regardless of print outcome.
+    """
+    # Get archive hours (all prints, not just completed)
     result = await db.execute(
     result = await db.execute(
-        select(func.sum(PrintArchive.print_time_seconds))
-        .where(PrintArchive.printer_id == printer_id)
-        .where(PrintArchive.status == "completed")
+        select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id)
     )
     )
     total_seconds = result.scalar() or 0
     total_seconds = result.scalar() or 0
     archive_hours = total_seconds / 3600.0
     archive_hours = total_seconds / 3600.0
 
 
     # Get printer offset
     # Get printer offset
-    result = await db.execute(
-        select(Printer.print_hours_offset).where(Printer.id == printer_id)
-    )
+    result = await db.execute(select(Printer.print_hours_offset).where(Printer.id == printer_id))
     offset = result.scalar() or 0.0
     offset = result.scalar() or 0.0
 
 
     return archive_hours + offset
     return archive_hours + offset
@@ -94,9 +92,7 @@ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
 
 
 async def ensure_default_types(db: AsyncSession) -> None:
 async def ensure_default_types(db: AsyncSession) -> None:
     """Ensure default maintenance types exist."""
     """Ensure default maintenance types exist."""
-    result = await db.execute(
-        select(MaintenanceType).where(MaintenanceType.is_system == True)
-    )
+    result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_system.is_(True)))
     existing = result.scalars().all()
     existing = result.scalars().all()
     existing_names = {t.name for t in existing}
     existing_names = {t.name for t in existing}
 
 
@@ -116,13 +112,12 @@ async def ensure_default_types(db: AsyncSession) -> None:
 
 
 # ============== Maintenance Types ==============
 # ============== Maintenance Types ==============
 
 
-@router.get("/types", response_model=List[MaintenanceTypeResponse])
+
+@router.get("/types", response_model=list[MaintenanceTypeResponse])
 async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
 async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
     """Get all maintenance types."""
     """Get all maintenance types."""
     await ensure_default_types(db)
     await ensure_default_types(db)
-    result = await db.execute(
-        select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
-    )
+    result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
     return result.scalars().all()
     return result.scalars().all()
 
 
 
 
@@ -153,9 +148,7 @@ async def update_maintenance_type(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a maintenance type."""
     """Update a maintenance type."""
-    result = await db.execute(
-        select(MaintenanceType).where(MaintenanceType.id == type_id)
-    )
+    result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
     maint_type = result.scalar_one_or_none()
     maint_type = result.scalar_one_or_none()
     if not maint_type:
     if not maint_type:
         raise HTTPException(status_code=404, detail="Maintenance type not found")
         raise HTTPException(status_code=404, detail="Maintenance type not found")
@@ -175,9 +168,7 @@ async def delete_maintenance_type(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a custom maintenance type."""
     """Delete a custom maintenance type."""
-    result = await db.execute(
-        select(MaintenanceType).where(MaintenanceType.id == type_id)
-    )
+    result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
     maint_type = result.scalar_one_or_none()
     maint_type = result.scalar_one_or_none()
     if not maint_type:
     if not maint_type:
         raise HTTPException(status_code=404, detail="Maintenance type not found")
         raise HTTPException(status_code=404, detail="Maintenance type not found")
@@ -192,6 +183,7 @@ async def delete_maintenance_type(
 
 
 # ============== Printer Maintenance ==============
 # ============== Printer Maintenance ==============
 
 
+
 async def _get_printer_maintenance_internal(
 async def _get_printer_maintenance_internal(
     printer_id: int,
     printer_id: int,
     db: AsyncSession,
     db: AsyncSession,
@@ -201,9 +193,7 @@ async def _get_printer_maintenance_internal(
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
     # Get printer
     # Get printer
-    result = await db.execute(
-        select(Printer).where(Printer.id == printer_id)
-    )
+    result = await db.execute(select(Printer).where(Printer.id == 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")
@@ -230,17 +220,22 @@ async def _get_printer_maintenance_internal(
 
 
     for maint_type in all_types:
     for maint_type in all_types:
         item = existing_items.get(maint_type.id)
         item = existing_items.get(maint_type.id)
-        default_interval_type = getattr(maint_type, 'interval_type', 'hours') or 'hours'
+        default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
 
 
         if item:
         if item:
             interval = item.custom_interval_hours or maint_type.default_interval_hours
             interval = item.custom_interval_hours or maint_type.default_interval_hours
             # Use custom interval type if set, otherwise use type's default
             # Use custom interval type if set, otherwise use type's default
-            interval_type = getattr(item, 'custom_interval_type', None) or default_interval_type
+            interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
             enabled = item.enabled
             enabled = item.enabled
             last_performed_hours = item.last_performed_hours
             last_performed_hours = item.last_performed_hours
             last_performed_at = item.last_performed_at
             last_performed_at = item.last_performed_at
             item_id = item.id
             item_id = item.id
         else:
         else:
+            # Only auto-create maintenance items for system types
+            # Custom types need to be manually assigned per printer
+            if not maint_type.is_system:
+                continue
+
             # Create default entry for this printer/type
             # Create default entry for this printer/type
             item = PrinterMaintenance(
             item = PrinterMaintenance(
                 printer_id=printer_id,
                 printer_id=printer_id,
@@ -294,25 +289,27 @@ async def _get_printer_maintenance_internal(
             elif is_warning:
             elif is_warning:
                 warning_count += 1
                 warning_count += 1
 
 
-        maintenance_items.append(MaintenanceStatus(
-            id=item_id,
-            printer_id=printer_id,
-            printer_name=printer.name,
-            maintenance_type_id=maint_type.id,
-            maintenance_type_name=maint_type.name,
-            maintenance_type_icon=maint_type.icon,
-            enabled=enabled,
-            interval_hours=interval,
-            interval_type=interval_type,
-            current_hours=total_hours,
-            hours_since_maintenance=hours_since,
-            hours_until_due=hours_until,
-            days_since_maintenance=days_since if interval_type == "days" else None,
-            days_until_due=days_until if interval_type == "days" else None,
-            is_due=is_due,
-            is_warning=is_warning,
-            last_performed_at=last_performed_at,
-        ))
+        maintenance_items.append(
+            MaintenanceStatus(
+                id=item_id,
+                printer_id=printer_id,
+                printer_name=printer.name,
+                maintenance_type_id=maint_type.id,
+                maintenance_type_name=maint_type.name,
+                maintenance_type_icon=maint_type.icon,
+                enabled=enabled,
+                interval_hours=interval,
+                interval_type=interval_type,
+                current_hours=total_hours,
+                hours_since_maintenance=hours_since,
+                hours_until_due=hours_until,
+                days_since_maintenance=days_since if interval_type == "days" else None,
+                days_until_due=days_until if interval_type == "days" else None,
+                is_due=is_due,
+                is_warning=is_warning,
+                last_performed_at=last_performed_at,
+            )
+        )
 
 
     if commit:
     if commit:
         await db.commit()
         await db.commit()
@@ -336,14 +333,12 @@ async def get_printer_maintenance(
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
 
 
 
 
-@router.get("/overview", response_model=List[PrinterMaintenanceOverview])
+@router.get("/overview", response_model=list[PrinterMaintenanceOverview])
 async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
 async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
     """Get maintenance overview for all active printers."""
     """Get maintenance overview for all active printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
-    result = await db.execute(
-        select(Printer).where(Printer.is_active == True)
-    )
+    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     printers = result.scalars().all()
     printers = result.scalars().all()
 
 
     overviews = []
     overviews = []
@@ -383,6 +378,75 @@ async def update_printer_maintenance(
     return item
     return item
 
 
 
 
+@router.post("/printers/{printer_id}/assign/{type_id}", response_model=PrinterMaintenanceResponse)
+async def assign_maintenance_type(
+    printer_id: int,
+    type_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Assign a maintenance type to a specific printer (for custom types)."""
+    # Verify printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Verify maintenance type exists
+    result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
+    maint_type = result.scalar_one_or_none()
+    if not maint_type:
+        raise HTTPException(status_code=404, detail="Maintenance type not found")
+
+    # Check if already assigned
+    result = await db.execute(
+        select(PrinterMaintenance).where(
+            PrinterMaintenance.printer_id == printer_id,
+            PrinterMaintenance.maintenance_type_id == type_id,
+        )
+    )
+    existing = result.scalar_one_or_none()
+    if existing:
+        raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer")
+
+    # Create the assignment
+    item = PrinterMaintenance(
+        printer_id=printer_id,
+        maintenance_type_id=type_id,
+        enabled=True,
+        last_performed_hours=0.0,
+    )
+    db.add(item)
+    await db.commit()
+    await db.refresh(item)
+
+    return item
+
+
+@router.delete("/items/{item_id}")
+async def remove_maintenance_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Remove a maintenance item (unassign a custom type from a printer)."""
+    result = await db.execute(
+        select(PrinterMaintenance)
+        .where(PrinterMaintenance.id == item_id)
+        .options(selectinload(PrinterMaintenance.maintenance_type))
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="Maintenance item not found")
+
+    # Only allow removing custom (non-system) types
+    if item.maintenance_type.is_system:
+        raise HTTPException(status_code=400, detail="Cannot remove system maintenance types")
+
+    await db.delete(item)
+    await db.commit()
+
+    return {"status": "removed"}
+
+
 @router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
 @router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
 async def perform_maintenance(
 async def perform_maintenance(
     item_id: int,
     item_id: int,
@@ -400,9 +464,7 @@ async def perform_maintenance(
         raise HTTPException(status_code=404, detail="Maintenance item not found")
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
 
     # Get printer for name
     # Get printer for name
-    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()
     printer = result.scalar_one()
 
 
     # Get current hours
     # Get current hours
@@ -424,7 +486,7 @@ async def perform_maintenance(
 
 
     # Calculate status
     # Calculate status
     interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
     interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
-    interval_type = getattr(item.maintenance_type, 'interval_type', 'hours') or 'hours'
+    interval_type = getattr(item.maintenance_type, "interval_type", "hours") or "hours"
     hours_since = current_hours - item.last_performed_hours
     hours_since = current_hours - item.last_performed_hours
     hours_until = interval - hours_since
     hours_until = interval - hours_since
 
 
@@ -449,7 +511,7 @@ async def perform_maintenance(
     )
     )
 
 
 
 
-@router.get("/items/{item_id}/history", response_model=List[MaintenanceHistoryResponse])
+@router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
 async def get_maintenance_history(
 async def get_maintenance_history(
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
@@ -468,9 +530,7 @@ async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
     """Get a summary of maintenance status across all printers."""
     """Get a summary of maintenance status across all printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
-    result = await db.execute(
-        select(Printer).where(Printer.is_active == True)
-    )
+    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     printers = result.scalars().all()
     printers = result.scalars().all()
 
 
     total_due = 0
     total_due = 0
@@ -482,12 +542,14 @@ async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
         total_due += overview.due_count
         total_due += overview.due_count
         total_warning += overview.warning_count
         total_warning += overview.warning_count
         if overview.due_count > 0 or overview.warning_count > 0:
         if overview.due_count > 0 or overview.warning_count > 0:
-            printers_with_issues.append({
-                "printer_id": printer.id,
-                "printer_name": printer.name,
-                "due_count": overview.due_count,
-                "warning_count": overview.warning_count,
-            })
+            printers_with_issues.append(
+                {
+                    "printer_id": printer.id,
+                    "printer_name": printer.name,
+                    "due_count": overview.due_count,
+                    "warning_count": overview.warning_count,
+                }
+            )
 
 
     return {
     return {
         "total_due": total_due,
         "total_due": total_due,
@@ -504,18 +566,15 @@ async def set_printer_hours(
 ):
 ):
     """Set the total print hours for a printer (adjusts offset to match)."""
     """Set the total print hours for a printer (adjusts offset to match)."""
     # Get printer
     # Get printer
-    result = await db.execute(
-        select(Printer).where(Printer.id == printer_id)
-    )
+    result = await db.execute(select(Printer).where(Printer.id == 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")
 
 
-    # Get current archive hours
+    # Get current archive hours (all prints, not just completed)
+    # Must match get_printer_total_hours() which includes all prints
     result = await db.execute(
     result = await db.execute(
-        select(func.sum(PrintArchive.print_time_seconds))
-        .where(PrintArchive.printer_id == printer_id)
-        .where(PrintArchive.status == "completed")
+        select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id)
     )
     )
     total_seconds = result.scalar() or 0
     total_seconds = result.scalar() or 0
     archive_hours = total_seconds / 3600.0
     archive_hours = total_seconds / 3600.0
@@ -541,9 +600,7 @@ async def set_printer_hours(
         ]
         ]
 
 
         if items_needing_attention:
         if items_needing_attention:
-            await notification_service.on_maintenance_due(
-                printer_id, printer.name, items_needing_attention, db
-            )
+            await notification_service.on_maintenance_due(printer_id, printer.name, items_needing_attention, db)
             logger.info(
             logger.info(
                 f"Sent maintenance notification for printer {printer_id}: "
                 f"Sent maintenance notification for printer {printer_id}: "
                 f"{len(items_needing_attention)} items need attention"
                 f"{len(items_needing_attention)} items need attention"

+ 24 - 6
backend/app/api/routes/printers.py

@@ -28,8 +28,7 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     get_storage_info_async,
     list_files_async,
     list_files_async,
 )
 )
-from backend.app.services.bambu_mqtt import get_stage_name
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.printer_manager import get_derived_status_name, printer_manager
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -104,18 +103,37 @@ async def update_printer(
 
 
 
 
 @router.delete("/{printer_id}")
 @router.delete("/{printer_id}")
-async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Delete a printer."""
+async def delete_printer(
+    printer_id: int,
+    delete_archives: bool = True,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a printer.
+
+    Args:
+        printer_id: ID of the printer to delete
+        delete_archives: If True (default), delete all print archives for this printer.
+                        If False, keep archives but remove their printer association.
+    """
+    from backend.app.models.archive import PrintArchive
+
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
         raise HTTPException(404, "Printer not found")
         raise HTTPException(404, "Printer not found")
 
 
     printer_manager.disconnect_printer(printer_id)
     printer_manager.disconnect_printer(printer_id)
+
+    if not delete_archives:
+        # Orphan the archives instead of deleting them
+        from sqlalchemy import update
+
+        await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
+
     await db.delete(printer)
     await db.delete(printer)
     await db.commit()
     await db.commit()
 
 
-    return {"status": "deleted"}
+    return {"status": "deleted", "archives_deleted": delete_archives}
 
 
 
 
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
@@ -300,7 +318,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         nozzles=nozzles,
         nozzles=nozzles,
         print_options=print_options,
         print_options=print_options,
         stg_cur=state.stg_cur,
         stg_cur=state.stg_cur,
-        stg_cur_name=get_stage_name(state.stg_cur) if state.stg_cur >= 0 else None,
+        stg_cur_name=get_derived_status_name(state),
         stg=state.stg,
         stg=state.stg,
         airduct_mode=state.airduct_mode,
         airduct_mode=state.airduct_mode,
         speed_level=state.speed_level,
         speed_level=state.speed_level,

+ 884 - 38
backend/app/api/routes/projects.py

@@ -1,21 +1,35 @@
 import logging
 import logging
-from fastapi import APIRouter, Depends, HTTPException
+import os
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi.responses import FileResponse
+from sqlalchemy import case, 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 backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
-from backend.app.models.project import Project
 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
+from backend.app.models.project import Project
+from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.schemas.project import (
 from backend.app.schemas.project import (
+    ArchivePreview,
+    BatchAddArchives,
+    BatchAddQueueItems,
+    BOMItemCreate,
+    BOMItemResponse,
+    BOMItemUpdate,
+    ProjectChildPreview,
     ProjectCreate,
     ProjectCreate,
-    ProjectUpdate,
-    ProjectResponse,
     ProjectListResponse,
     ProjectListResponse,
+    ProjectResponse,
     ProjectStats,
     ProjectStats,
-    BatchAddArchives,
-    BatchAddQueueItems,
-    ArchivePreview,
+    ProjectUpdate,
+    TimelineEvent,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -23,21 +37,16 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/projects", tags=["projects"])
 router = APIRouter(prefix="/projects", tags=["projects"])
 
 
 
 
-async def compute_project_stats(
-    db: AsyncSession, project_id: int, target_count: int | None = None
-) -> ProjectStats:
+async def compute_project_stats(db: AsyncSession, project_id: int, target_count: int | None = None) -> ProjectStats:
     """Compute statistics for a project."""
     """Compute statistics for a project."""
     # Count total archives
     # Count total archives
-    total_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)
-    )
+    total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
     total_archives = total_result.scalar() or 0
     total_archives = total_result.scalar() or 0
 
 
     # Count completed archives
     # Count completed archives
     completed_result = await db.execute(
     completed_result = await db.execute(
         select(func.count(PrintArchive.id)).where(
         select(func.count(PrintArchive.id)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status == "completed"
+            PrintArchive.project_id == project_id, PrintArchive.status == "completed"
         )
         )
     )
     )
     completed_prints = completed_result.scalar() or 0
     completed_prints = completed_result.scalar() or 0
@@ -45,17 +54,19 @@ async def compute_project_stats(
     # Count failed archives
     # Count failed archives
     failed_result = await db.execute(
     failed_result = await db.execute(
         select(func.count(PrintArchive.id)).where(
         select(func.count(PrintArchive.id)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status == "failed"
+            PrintArchive.project_id == project_id, PrintArchive.status == "failed"
         )
         )
     )
     )
     failed_prints = failed_result.scalar() or 0
     failed_prints = failed_result.scalar() or 0
 
 
-    # Sum print time and filament
+    # Sum print time, filament, and energy
     sums_result = await db.execute(
     sums_result = await db.execute(
         select(
         select(
             func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
             func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
             func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
             func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
+            func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
+            func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
+            func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
         ).where(PrintArchive.project_id == project_id)
         ).where(PrintArchive.project_id == project_id)
     )
     )
     sums = sums_result.first()
     sums = sums_result.first()
@@ -63,8 +74,7 @@ async def compute_project_stats(
     # Count queued items
     # Count queued items
     queued_result = await db.execute(
     queued_result = await db.execute(
         select(func.count(PrintQueueItem.id)).where(
         select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id,
-            PrintQueueItem.status == "pending"
+            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
         )
         )
     )
     )
     queued_prints = queued_result.scalar() or 0
     queued_prints = queued_result.scalar() or 0
@@ -72,16 +82,28 @@ async def compute_project_stats(
     # Count in-progress items
     # Count in-progress items
     in_progress_result = await db.execute(
     in_progress_result = await db.execute(
         select(func.count(PrintQueueItem.id)).where(
         select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id,
-            PrintQueueItem.status == "printing"
+            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
         )
         )
     )
     )
     in_progress_prints = in_progress_result.scalar() or 0
     in_progress_prints = in_progress_result.scalar() or 0
 
 
     # Calculate progress
     # Calculate progress
     progress_percent = None
     progress_percent = None
+    remaining_prints = None
     if target_count and target_count > 0:
     if target_count and target_count > 0:
         progress_percent = round((completed_prints / target_count) * 100, 1)
         progress_percent = round((completed_prints / target_count) * 100, 1)
+        remaining_prints = max(0, target_count - completed_prints)
+
+    # BOM stats
+    bom_result = await db.execute(
+        select(
+            func.count(ProjectBOMItem.id).label("total"),
+            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
+                "completed"
+            ),
+        ).where(ProjectBOMItem.project_id == project_id)
+    )
+    bom_stats = bom_result.first()
 
 
     return ProjectStats(
     return ProjectStats(
         total_archives=total_archives,
         total_archives=total_archives,
@@ -92,6 +114,12 @@ async def compute_project_stats(
         total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
         total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
         total_filament_grams=round(sums.total_filament or 0, 2),
         total_filament_grams=round(sums.total_filament or 0, 2),
         progress_percent=progress_percent,
         progress_percent=progress_percent,
+        estimated_cost=round((sums.total_filament_cost or 0), 2),
+        total_energy_kwh=round((sums.total_energy or 0), 3),
+        total_energy_cost=round((sums.total_energy_cost or 0), 2),
+        remaining_prints=remaining_prints,
+        bom_total_items=bom_stats.total or 0,
+        bom_completed_items=int(bom_stats.completed or 0),
     )
     )
 
 
 
 
@@ -115,9 +143,7 @@ async def list_projects(
     for project in projects:
     for project in projects:
         # Get archive count
         # Get archive count
         archive_count_result = await db.execute(
         archive_count_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(
-                PrintArchive.project_id == project.id
-            )
+            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
         )
         )
         archive_count = archive_count_result.scalar() or 0
         archive_count = archive_count_result.scalar() or 0
 
 
@@ -157,6 +183,8 @@ async def list_projects(
                 print_name=a.print_name,
                 print_name=a.print_name,
                 thumbnail_path=a.thumbnail_path,
                 thumbnail_path=a.thumbnail_path,
                 status=a.status,
                 status=a.status,
+                filament_type=a.filament_type,
+                filament_color=a.filament_color,
             )
             )
             for a in archives
             for a in archives
         ]
         ]
@@ -186,14 +214,146 @@ async def create_project(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Create a new project."""
     """Create a new project."""
+    # Verify parent exists if specified
+    parent_name = None
+    if data.parent_id:
+        parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
+        parent = parent_result.scalar_one_or_none()
+        if not parent:
+            raise HTTPException(status_code=400, detail="Parent project not found")
+        parent_name = parent.name
+
     project = Project(
     project = Project(
         name=data.name,
         name=data.name,
         description=data.description,
         description=data.description,
         color=data.color,
         color=data.color,
         target_count=data.target_count,
         target_count=data.target_count,
+        notes=data.notes,
+        tags=data.tags,
+        due_date=data.due_date,
+        priority=data.priority,
+        budget=data.budget,
+        parent_id=data.parent_id,
+    )
+    db.add(project)
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=parent_name,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+# ============ Phase 8: Template Endpoints (Static routes BEFORE dynamic {project_id}) ============
+
+
+@router.get("/templates", response_model=list[ProjectListResponse])
+async def list_templates(
+    db: AsyncSession = Depends(get_db),
+):
+    """List all project templates."""
+    result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
+    templates = result.scalars().all()
+
+    response = []
+    for project in templates:
+        # Get archive count
+        archive_count_result = await db.execute(
+            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
+        )
+        archive_count = archive_count_result.scalar() or 0
+
+        response.append(
+            ProjectListResponse(
+                id=project.id,
+                name=project.name,
+                description=project.description,
+                color=project.color,
+                status=project.status,
+                target_count=project.target_count,
+                created_at=project.created_at,
+                archive_count=archive_count,
+                queue_count=0,
+                progress_percent=None,
+                archives=[],
+            )
+        )
+
+    return response
+
+
+@router.post("/from-template/{template_id}", response_model=ProjectResponse)
+async def create_project_from_template(
+    template_id: int,
+    name: str = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new project from a template."""
+    result = await db.execute(select(Project).where(Project.id == template_id))
+    template = result.scalar_one_or_none()
+
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    if not template.is_template:
+        raise HTTPException(status_code=400, detail="Project is not a template")
+
+    # Create new project
+    project = Project(
+        name=name or template.name.replace(" (Template)", ""),
+        description=template.description,
+        color=template.color,
+        target_count=template.target_count,
+        notes=template.notes,
+        tags=template.tags,
+        priority=template.priority,
+        budget=template.budget,
+        is_template=False,
+        template_source_id=template.id,
     )
     )
     db.add(project)
     db.add(project)
     await db.flush()
     await db.flush()
+
+    # Copy BOM items
+    bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == template_id))
+    bom_items = bom_result.scalars().all()
+
+    for item in bom_items:
+        new_item = ProjectBOMItem(
+            project_id=project.id,
+            name=item.name,
+            quantity_needed=item.quantity_needed,
+            quantity_acquired=0,
+            unit_price=item.unit_price,
+            sourcing_url=item.sourcing_url,
+            stl_filename=item.stl_filename,
+            remarks=item.remarks,
+            sort_order=item.sort_order,
+        )
+        db.add(new_item)
+
+    await db.flush()
     await db.refresh(project)
     await db.refresh(project)
 
 
     stats = await compute_project_stats(db, project.id, project.target_count)
     stats = await compute_project_stats(db, project.id, project.target_count)
@@ -205,12 +365,57 @@ async def create_project(
         color=project.color,
         color=project.color,
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
         created_at=project.created_at,
         created_at=project.created_at,
         updated_at=project.updated_at,
         updated_at=project.updated_at,
         stats=stats,
         stats=stats,
     )
     )
 
 
 
 
+# ============ Dynamic {project_id} Routes ============
+
+
+async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
+    """Get preview info for child projects."""
+    result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
+    children = result.scalars().all()
+
+    previews = []
+    for child in children:
+        # Get completed count for progress
+        completed_result = await db.execute(
+            select(func.count(PrintArchive.id)).where(
+                PrintArchive.project_id == child.id,
+                PrintArchive.status == "completed",
+            )
+        )
+        completed_count = completed_result.scalar() or 0
+        progress = None
+        if child.target_count and child.target_count > 0:
+            progress = round((completed_count / child.target_count) * 100, 1)
+
+        previews.append(
+            ProjectChildPreview(
+                id=child.id,
+                name=child.name,
+                color=child.color,
+                status=child.status,
+                progress_percent=progress,
+            )
+        )
+    return previews
+
+
 @router.get("/{project_id}", response_model=ProjectResponse)
 @router.get("/{project_id}", response_model=ProjectResponse)
 async def get_project(
 async def get_project(
     project_id: int,
     project_id: int,
@@ -223,6 +428,15 @@ async def get_project(
     if not project:
     if not project:
         raise HTTPException(status_code=404, detail="Project not found")
         raise HTTPException(status_code=404, detail="Project not found")
 
 
+    # Get parent name
+    parent_name = None
+    if project.parent_id:
+        parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
+        parent_name = parent_result.scalar()
+
+    # Get children
+    children = await get_child_previews(db, project.id)
+
     stats = await compute_project_stats(db, project.id, project.target_count)
     stats = await compute_project_stats(db, project.id, project.target_count)
 
 
     return ProjectResponse(
     return ProjectResponse(
@@ -232,6 +446,17 @@ async def get_project(
         color=project.color,
         color=project.color,
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=parent_name,
+        children=children,
         created_at=project.created_at,
         created_at=project.created_at,
         updated_at=project.updated_at,
         updated_at=project.updated_at,
         stats=stats,
         stats=stats,
@@ -264,10 +489,42 @@ async def update_project(
         project.status = data.status
         project.status = data.status
     if data.target_count is not None:
     if data.target_count is not None:
         project.target_count = data.target_count
         project.target_count = data.target_count
+    if data.notes is not None:
+        project.notes = data.notes
+    if data.tags is not None:
+        project.tags = data.tags
+    if data.due_date is not None:
+        project.due_date = data.due_date
+    if data.priority is not None:
+        if data.priority not in ["low", "normal", "high", "urgent"]:
+            raise HTTPException(status_code=400, detail="Invalid priority")
+        project.priority = data.priority
+    if data.budget is not None:
+        project.budget = data.budget
+    if data.parent_id is not None:
+        # Verify parent exists and prevent circular reference
+        if data.parent_id == project_id:
+            raise HTTPException(status_code=400, detail="Project cannot be its own parent")
+        if data.parent_id != 0:  # 0 means remove parent
+            parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
+            if not parent_result.scalar_one_or_none():
+                raise HTTPException(status_code=400, detail="Parent project not found")
+            project.parent_id = data.parent_id
+        else:
+            project.parent_id = None
 
 
     await db.flush()
     await db.flush()
     await db.refresh(project)
     await db.refresh(project)
 
 
+    # Get parent name
+    parent_name = None
+    if project.parent_id:
+        parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
+        parent_name = parent_result.scalar()
+
+    # Get children
+    children = await get_child_previews(db, project.id)
+
     stats = await compute_project_stats(db, project.id, project.target_count)
     stats = await compute_project_stats(db, project.id, project.target_count)
 
 
     return ProjectResponse(
     return ProjectResponse(
@@ -277,6 +534,17 @@ async def update_project(
         color=project.color,
         color=project.color,
         status=project.status,
         status=project.status,
         target_count=project.target_count,
         target_count=project.target_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=parent_name,
+        children=children,
         created_at=project.created_at,
         created_at=project.created_at,
         updated_at=project.updated_at,
         updated_at=project.updated_at,
         stats=stats,
         stats=stats,
@@ -313,9 +581,10 @@ async def list_project_archives(
     if not result.scalar_one_or_none():
     if not result.scalar_one_or_none():
         raise HTTPException(status_code=404, detail="Project not found")
         raise HTTPException(status_code=404, detail="Project not found")
 
 
-    # Get archives
+    # Get archives with project relationship eagerly loaded
     query = (
     query = (
         select(PrintArchive)
         select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
         .where(PrintArchive.project_id == project_id)
         .where(PrintArchive.project_id == project_id)
         .order_by(PrintArchive.created_at.desc())
         .order_by(PrintArchive.created_at.desc())
         .limit(limit)
         .limit(limit)
@@ -342,11 +611,7 @@ async def list_project_queue(
         raise HTTPException(status_code=404, detail="Project not found")
         raise HTTPException(status_code=404, detail="Project not found")
 
 
     # Get queue items
     # Get queue items
-    query = (
-        select(PrintQueueItem)
-        .where(PrintQueueItem.project_id == project_id)
-        .order_by(PrintQueueItem.position)
-    )
+    query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
     result = await db.execute(query)
     result = await db.execute(query)
     items = result.scalars().all()
     items = result.scalars().all()
 
 
@@ -368,9 +633,7 @@ async def add_archives_to_project(
     # Update archives
     # Update archives
     updated = 0
     updated = 0
     for archive_id in data.archive_ids:
     for archive_id in data.archive_ids:
-        result = await db.execute(
-            select(PrintArchive).where(PrintArchive.id == archive_id)
-        )
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         archive = result.scalar_one_or_none()
         archive = result.scalar_one_or_none()
         if archive:
         if archive:
             archive.project_id = project_id
             archive.project_id = project_id
@@ -394,9 +657,7 @@ async def add_queue_items_to_project(
     # Update queue items
     # Update queue items
     updated = 0
     updated = 0
     for item_id in data.queue_item_ids:
     for item_id in data.queue_item_ids:
-        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 item:
         if item:
             item.project_id = project_id
             item.project_id = project_id
@@ -426,3 +687,588 @@ async def remove_archives_from_project(
             updated += 1
             updated += 1
 
 
     return {"message": f"Removed {updated} archives from project"}
     return {"message": f"Removed {updated} archives from project"}
+
+
+def get_project_attachments_dir(project_id: int) -> Path:
+    """Get the attachments directory for a project."""
+    base_dir = Path(settings.archive_dir)
+    return base_dir / "projects" / str(project_id) / "attachments"
+
+
+# Allowed file extensions for attachments
+ALLOWED_ATTACHMENT_EXTENSIONS = {
+    # Images
+    ".jpg",
+    ".jpeg",
+    ".png",
+    ".gif",
+    ".webp",
+    ".svg",
+    ".bmp",
+    ".ico",
+    # Documents
+    ".pdf",
+    ".doc",
+    ".docx",
+    ".xls",
+    ".xlsx",
+    ".ppt",
+    ".pptx",
+    ".odt",
+    ".ods",
+    ".odp",
+    ".txt",
+    ".rtf",
+    ".csv",
+    ".md",
+    # 3D/CAD files
+    ".stl",
+    ".obj",
+    ".3mf",
+    ".step",
+    ".stp",
+    ".iges",
+    ".igs",
+    ".f3d",
+    ".scad",
+    # Archives
+    ".zip",
+    ".rar",
+    ".7z",
+    ".tar",
+    ".gz",
+    # Code/scripts (for Klipper macros, scripts, etc.)
+    ".py",
+    ".sh",
+    ".cfg",
+    ".conf",
+    ".gcode",
+    ".ini",
+    # Other common formats
+    ".json",
+    ".xml",
+    ".yaml",
+    ".yml",
+}
+
+
+@router.post("/{project_id}/attachments")
+async def upload_attachment(
+    project_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload an attachment to a project."""
+    logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
+
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Validate file extension
+    original_name = file.filename or "unknown"
+    ext = os.path.splitext(original_name)[1].lower()
+    if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
+        )
+
+    # Create attachments directory
+    attachments_dir = get_project_attachments_dir(project_id)
+    attachments_dir.mkdir(parents=True, exist_ok=True)
+
+    # Generate unique filename
+    unique_filename = f"{uuid.uuid4().hex}{ext}"
+    file_path = attachments_dir / unique_filename
+
+    # Save file
+    try:
+        with open(file_path, "wb") as f:
+            content = await file.read()
+            f.write(content)
+        logger.info(f"=== FILE SAVED: {file_path}, size: {len(content)} ===")
+    except Exception as e:
+        logger.error(f"Failed to save attachment: {e}")
+        raise HTTPException(status_code=500, detail="Failed to save attachment")
+
+    # Update project attachments JSON
+    attachments = list(project.attachments or [])
+    new_attachment = {
+        "filename": unique_filename,
+        "original_name": original_name,
+        "size": len(content),
+        "uploaded_at": datetime.now().isoformat(),
+    }
+    attachments.append(new_attachment)
+
+    # Simple ORM update
+    project.attachments = attachments
+    db.add(project)  # Explicitly add to session
+
+    logger.info(f"=== BEFORE COMMIT: {len(attachments)} attachments ===")
+
+    await db.flush()
+    await db.commit()
+
+    logger.info("=== AFTER COMMIT ===")
+
+    # Verify by re-querying
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    fresh_project = result.scalar_one()
+
+    logger.info(f"=== VERIFIED: {len(fresh_project.attachments or [])} attachments ===")
+
+    return {
+        "status": "success",
+        "filename": unique_filename,
+        "original_name": original_name,
+        "attachments": fresh_project.attachments,
+    }
+
+
+@router.get("/{project_id}/attachments/{filename}")
+async def download_attachment(
+    project_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download an attachment from a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Verify attachment exists in project
+    attachments = project.attachments or []
+    attachment = next((a for a in attachments if a.get("filename") == filename), None)
+    if not attachment:
+        raise HTTPException(status_code=404, detail="Attachment not found")
+
+    # Check file exists
+    file_path = get_project_attachments_dir(project_id) / filename
+    if not file_path.exists():
+        raise HTTPException(status_code=404, detail="Attachment file not found")
+
+    return FileResponse(
+        file_path,
+        filename=attachment.get("original_name", filename),
+        media_type="application/octet-stream",
+    )
+
+
+@router.delete("/{project_id}/attachments/{filename}")
+async def delete_attachment(
+    project_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete an attachment from a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Find and remove attachment from list
+    attachments = project.attachments or []
+    attachment = next((a for a in attachments if a.get("filename") == filename), None)
+    if not attachment:
+        raise HTTPException(status_code=404, detail="Attachment not found")
+
+    # Remove from list
+    attachments = [a for a in attachments if a.get("filename") != filename]
+    project.attachments = attachments if attachments else None
+
+    # Delete file
+    file_path = get_project_attachments_dir(project_id) / filename
+    if file_path.exists():
+        try:
+            os.remove(file_path)
+        except Exception as e:
+            logger.warning(f"Failed to delete attachment file: {e}")
+
+    await db.flush()
+    await db.refresh(project)
+
+    return {
+        "status": "success",
+        "message": "Attachment deleted",
+        "attachments": project.attachments,
+    }
+
+
+# ============ Phase 7: BOM Endpoints ============
+
+
+@router.get("/{project_id}/bom", response_model=list[BOMItemResponse])
+async def list_bom_items(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """List all BOM items for a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get BOM items
+    result = await db.execute(
+        select(ProjectBOMItem)
+        .where(ProjectBOMItem.project_id == project_id)
+        .order_by(ProjectBOMItem.sort_order, ProjectBOMItem.id)
+    )
+    items = result.scalars().all()
+
+    response = []
+    for item in items:
+        # Get archive name if linked
+        archive_name = None
+        if item.archive_id:
+            archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
+            archive_name = archive_result.scalar()
+
+        response.append(
+            BOMItemResponse(
+                id=item.id,
+                project_id=item.project_id,
+                name=item.name,
+                quantity_needed=item.quantity_needed,
+                quantity_acquired=item.quantity_acquired,
+                unit_price=item.unit_price,
+                sourcing_url=item.sourcing_url,
+                archive_id=item.archive_id,
+                archive_name=archive_name,
+                stl_filename=item.stl_filename,
+                remarks=item.remarks,
+                sort_order=item.sort_order,
+                is_complete=item.quantity_acquired >= item.quantity_needed,
+                created_at=item.created_at,
+                updated_at=item.updated_at,
+            )
+        )
+
+    return response
+
+
+@router.post("/{project_id}/bom", response_model=BOMItemResponse)
+async def create_bom_item(
+    project_id: int,
+    data: BOMItemCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Add a BOM item to a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get max sort order
+    max_order_result = await db.execute(
+        select(func.max(ProjectBOMItem.sort_order)).where(ProjectBOMItem.project_id == project_id)
+    )
+    max_order = max_order_result.scalar() or 0
+
+    item = ProjectBOMItem(
+        project_id=project_id,
+        name=data.name,
+        quantity_needed=data.quantity_needed,
+        unit_price=data.unit_price,
+        sourcing_url=data.sourcing_url,
+        archive_id=data.archive_id,
+        stl_filename=data.stl_filename,
+        remarks=data.remarks,
+        sort_order=max_order + 1,
+    )
+    db.add(item)
+    await db.flush()
+    await db.refresh(item)
+
+    # Get archive name if linked
+    archive_name = None
+    if item.archive_id:
+        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
+        archive_name = archive_result.scalar()
+
+    return BOMItemResponse(
+        id=item.id,
+        project_id=item.project_id,
+        name=item.name,
+        quantity_needed=item.quantity_needed,
+        quantity_acquired=item.quantity_acquired,
+        unit_price=item.unit_price,
+        sourcing_url=item.sourcing_url,
+        archive_id=item.archive_id,
+        archive_name=archive_name,
+        stl_filename=item.stl_filename,
+        remarks=item.remarks,
+        sort_order=item.sort_order,
+        is_complete=item.quantity_acquired >= item.quantity_needed,
+        created_at=item.created_at,
+        updated_at=item.updated_at,
+    )
+
+
+@router.patch("/{project_id}/bom/{item_id}", response_model=BOMItemResponse)
+async def update_bom_item(
+    project_id: int,
+    item_id: int,
+    data: BOMItemUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a BOM item."""
+    result = await db.execute(
+        select(ProjectBOMItem).where(
+            ProjectBOMItem.id == item_id,
+            ProjectBOMItem.project_id == project_id,
+        )
+    )
+    item = result.scalar_one_or_none()
+
+    if not item:
+        raise HTTPException(status_code=404, detail="BOM item not found")
+
+    if data.name is not None:
+        item.name = data.name
+    if data.quantity_needed is not None:
+        item.quantity_needed = data.quantity_needed
+    if data.quantity_acquired is not None:
+        item.quantity_acquired = data.quantity_acquired
+    if data.unit_price is not None:
+        item.unit_price = data.unit_price if data.unit_price != 0 else None
+    if data.sourcing_url is not None:
+        item.sourcing_url = data.sourcing_url if data.sourcing_url else None
+    if data.archive_id is not None:
+        item.archive_id = data.archive_id if data.archive_id != 0 else None
+    if data.stl_filename is not None:
+        item.stl_filename = data.stl_filename if data.stl_filename else None
+    if data.remarks is not None:
+        item.remarks = data.remarks if data.remarks else None
+
+    await db.flush()
+    await db.refresh(item)
+
+    # Get archive name if linked
+    archive_name = None
+    if item.archive_id:
+        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
+        archive_name = archive_result.scalar()
+
+    return BOMItemResponse(
+        id=item.id,
+        project_id=item.project_id,
+        name=item.name,
+        quantity_needed=item.quantity_needed,
+        quantity_acquired=item.quantity_acquired,
+        unit_price=item.unit_price,
+        sourcing_url=item.sourcing_url,
+        archive_id=item.archive_id,
+        archive_name=archive_name,
+        stl_filename=item.stl_filename,
+        remarks=item.remarks,
+        sort_order=item.sort_order,
+        is_complete=item.quantity_acquired >= item.quantity_needed,
+        created_at=item.created_at,
+        updated_at=item.updated_at,
+    )
+
+
+@router.delete("/{project_id}/bom/{item_id}")
+async def delete_bom_item(
+    project_id: int,
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a BOM item."""
+    result = await db.execute(
+        select(ProjectBOMItem).where(
+            ProjectBOMItem.id == item_id,
+            ProjectBOMItem.project_id == project_id,
+        )
+    )
+    item = result.scalar_one_or_none()
+
+    if not item:
+        raise HTTPException(status_code=404, detail="BOM item not found")
+
+    await db.delete(item)
+
+    return {"status": "success", "message": "BOM item deleted"}
+
+
+@router.post("/{project_id}/create-template", response_model=ProjectResponse)
+async def create_template_from_project(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a template from an existing project."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    source = result.scalar_one_or_none()
+
+    if not source:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Create template
+    template = Project(
+        name=f"{source.name} (Template)",
+        description=source.description,
+        color=source.color,
+        target_count=source.target_count,
+        notes=source.notes,
+        tags=source.tags,
+        priority=source.priority,
+        budget=source.budget,
+        is_template=True,
+        template_source_id=source.id,
+    )
+    db.add(template)
+    await db.flush()
+
+    # Copy BOM items
+    bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id))
+    bom_items = bom_result.scalars().all()
+
+    for item in bom_items:
+        new_item = ProjectBOMItem(
+            project_id=template.id,
+            name=item.name,
+            quantity_needed=item.quantity_needed,
+            quantity_acquired=0,
+            unit_price=item.unit_price,
+            sourcing_url=item.sourcing_url,
+            stl_filename=item.stl_filename,
+            remarks=item.remarks,
+            sort_order=item.sort_order,
+        )
+        db.add(new_item)
+
+    await db.flush()
+    await db.refresh(template)
+
+    stats = await compute_project_stats(db, template.id, template.target_count)
+
+    return ProjectResponse(
+        id=template.id,
+        name=template.name,
+        description=template.description,
+        color=template.color,
+        status=template.status,
+        target_count=template.target_count,
+        notes=template.notes,
+        attachments=template.attachments,
+        tags=template.tags,
+        due_date=template.due_date,
+        priority=template.priority,
+        budget=template.budget,
+        is_template=template.is_template,
+        template_source_id=template.template_source_id,
+        parent_id=template.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=template.created_at,
+        updated_at=template.updated_at,
+        stats=stats,
+    )
+
+
+# ============ Phase 9: Timeline Endpoint ============
+
+
+@router.get("/{project_id}/timeline", response_model=list[TimelineEvent])
+async def get_project_timeline(
+    project_id: int,
+    limit: int = 50,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get timeline of events for a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    events = []
+
+    # Project creation event
+    events.append(
+        TimelineEvent(
+            event_type="project_created",
+            timestamp=project.created_at,
+            title="Project created",
+            description=f"Project '{project.name}' was created",
+        )
+    )
+
+    # Get archives and add events
+    archives_result = await db.execute(
+        select(PrintArchive)
+        .where(PrintArchive.project_id == project_id)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(limit)
+    )
+    archives = archives_result.scalars().all()
+
+    for archive in archives:
+        if archive.status == "completed":
+            events.append(
+                TimelineEvent(
+                    event_type="print_completed",
+                    timestamp=archive.completed_at or archive.created_at,
+                    title="Print completed",
+                    description=archive.print_name,
+                    metadata={
+                        "archive_id": archive.id,
+                        "print_time_hours": round((archive.print_time_seconds or 0) / 3600, 2),
+                        "filament_grams": round(archive.filament_used_grams or 0, 1),
+                    },
+                )
+            )
+        elif archive.status == "failed":
+            events.append(
+                TimelineEvent(
+                    event_type="print_failed",
+                    timestamp=archive.completed_at or archive.created_at,
+                    title="Print failed",
+                    description=archive.print_name,
+                    metadata={"archive_id": archive.id},
+                )
+            )
+
+    # Get queue items
+    queue_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.project_id == project_id)
+        .order_by(PrintQueueItem.created_at.desc())
+        .limit(limit)
+    )
+    queue_items = queue_result.scalars().all()
+
+    for item in queue_items:
+        if item.status == "printing":
+            events.append(
+                TimelineEvent(
+                    event_type="print_started",
+                    timestamp=item.started_at or item.created_at,
+                    title="Print started",
+                    description=item.print_name,
+                    metadata={"queue_item_id": item.id},
+                )
+            )
+        elif item.status == "pending":
+            events.append(
+                TimelineEvent(
+                    event_type="queued",
+                    timestamp=item.created_at,
+                    title="Added to queue",
+                    description=item.print_name,
+                    metadata={"queue_item_id": item.id},
+                )
+            )
+
+    # Sort by timestamp descending
+    events.sort(key=lambda e: e.timestamp, reverse=True)
+
+    return events[:limit]

+ 283 - 115
backend/app/api/routes/settings.py

@@ -3,28 +3,28 @@ import json
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
-from typing import Optional
 
 
-from fastapi import APIRouter, Depends, UploadFile, File, Query
+from fastapi import APIRouter, Depends, File, Query, UploadFile
 from fastapi.responses import JSONResponse, StreamingResponse
 from fastapi.responses import JSONResponse, StreamingResponse
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
-from backend.app.models.settings import Settings
+from backend.app.models.archive import PrintArchive
+from backend.app.models.external_link import ExternalLink
+from backend.app.models.filament import Filament
+from backend.app.models.maintenance import MaintenanceType
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
-from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
-from backend.app.models.filament import Filament
-from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
-from backend.app.models.archive import PrintArchive
-from backend.app.models.external_link import ExternalLink
+from backend.app.models.project import Project
+from backend.app.models.project_bom import ProjectBOMItem
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
-from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
-
+from backend.app.services.spoolman import init_spoolman_client
 
 
 router = APIRouter(prefix="/settings", tags=["settings"])
 router = APIRouter(prefix="/settings", tags=["settings"])
 
 
@@ -63,7 +63,14 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
     for setting in db_settings:
         if setting.key in settings_dict:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates", "telemetry_enabled"]:
+            if setting.key in [
+                "auto_archive",
+                "save_thumbnails",
+                "capture_finish_photo",
+                "spoolman_enabled",
+                "check_updates",
+                "telemetry_enabled",
+            ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 settings_dict[setting.key] = float(setting.value)
                 settings_dict[setting.key] = float(setting.value)
@@ -173,6 +180,7 @@ async def export_backup(
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
     include_archives: bool = Query(False, description="Include print archive metadata"),
     include_archives: bool = Query(False, description="Include print archive metadata"),
+    include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
 ):
 ):
     """Export selected data as JSON backup."""
     """Export selected data as JSON backup."""
@@ -195,27 +203,29 @@ async def export_backup(
         providers = result.scalars().all()
         providers = result.scalars().all()
         backup["notification_providers"] = []
         backup["notification_providers"] = []
         for p in providers:
         for p in providers:
-            backup["notification_providers"].append({
-                "name": p.name,
-                "provider_type": p.provider_type,
-                "enabled": p.enabled,
-                "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
-                "on_print_start": p.on_print_start,
-                "on_print_complete": p.on_print_complete,
-                "on_print_failed": p.on_print_failed,
-                "on_print_stopped": p.on_print_stopped,
-                "on_print_progress": p.on_print_progress,
-                "on_printer_offline": p.on_printer_offline,
-                "on_printer_error": p.on_printer_error,
-                "on_filament_low": p.on_filament_low,
-                "on_maintenance_due": p.on_maintenance_due,
-                "quiet_hours_enabled": p.quiet_hours_enabled,
-                "quiet_hours_start": p.quiet_hours_start,
-                "quiet_hours_end": p.quiet_hours_end,
-                "daily_digest_enabled": getattr(p, 'daily_digest_enabled', False),
-                "daily_digest_time": getattr(p, 'daily_digest_time', None),
-                "printer_id": getattr(p, 'printer_id', None),
-            })
+            backup["notification_providers"].append(
+                {
+                    "name": p.name,
+                    "provider_type": p.provider_type,
+                    "enabled": p.enabled,
+                    "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
+                    "on_print_start": p.on_print_start,
+                    "on_print_complete": p.on_print_complete,
+                    "on_print_failed": p.on_print_failed,
+                    "on_print_stopped": p.on_print_stopped,
+                    "on_print_progress": p.on_print_progress,
+                    "on_printer_offline": p.on_printer_offline,
+                    "on_printer_error": p.on_printer_error,
+                    "on_filament_low": p.on_filament_low,
+                    "on_maintenance_due": p.on_maintenance_due,
+                    "quiet_hours_enabled": p.quiet_hours_enabled,
+                    "quiet_hours_start": p.quiet_hours_start,
+                    "quiet_hours_end": p.quiet_hours_end,
+                    "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
+                    "daily_digest_time": getattr(p, "daily_digest_time", None),
+                    "printer_id": getattr(p, "printer_id", None),
+                }
+            )
         backup["included"].append("notification_providers")
         backup["included"].append("notification_providers")
 
 
     # Notification templates
     # Notification templates
@@ -224,13 +234,15 @@ async def export_backup(
         templates = result.scalars().all()
         templates = result.scalars().all()
         backup["notification_templates"] = []
         backup["notification_templates"] = []
         for t in templates:
         for t in templates:
-            backup["notification_templates"].append({
-                "event_type": t.event_type,
-                "name": t.name,
-                "title_template": t.title_template,
-                "body_template": t.body_template,
-                "is_default": t.is_default,
-            })
+            backup["notification_templates"].append(
+                {
+                    "event_type": t.event_type,
+                    "name": t.name,
+                    "title_template": t.title_template,
+                    "body_template": t.body_template,
+                    "is_default": t.is_default,
+                }
+            )
         backup["included"].append("notification_templates")
         backup["included"].append("notification_templates")
 
 
     # Smart plugs
     # Smart plugs
@@ -239,25 +251,28 @@ async def export_backup(
         plugs = result.scalars().all()
         plugs = result.scalars().all()
         backup["smart_plugs"] = []
         backup["smart_plugs"] = []
         for plug in plugs:
         for plug in plugs:
-            backup["smart_plugs"].append({
-                "name": plug.name,
-                "ip_address": plug.ip_address,
-                "printer_id": plug.printer_id,
-                "enabled": plug.enabled,
-                "auto_on": plug.auto_on,
-                "auto_off": plug.auto_off,
-                "off_delay_mode": plug.off_delay_mode,
-                "off_delay_minutes": plug.off_delay_minutes,
-                "off_temp_threshold": plug.off_temp_threshold,
-                "username": plug.username,
-                "password": plug.password,
-                "power_alert_enabled": plug.power_alert_enabled,
-                "power_alert_high": plug.power_alert_high,
-                "power_alert_low": plug.power_alert_low,
-                "schedule_enabled": plug.schedule_enabled,
-                "schedule_on_time": plug.schedule_on_time,
-                "schedule_off_time": plug.schedule_off_time,
-            })
+            backup["smart_plugs"].append(
+                {
+                    "name": plug.name,
+                    "ip_address": plug.ip_address,
+                    "printer_id": plug.printer_id,
+                    "enabled": plug.enabled,
+                    "auto_on": plug.auto_on,
+                    "auto_off": plug.auto_off,
+                    "off_delay_mode": plug.off_delay_mode,
+                    "off_delay_minutes": plug.off_delay_minutes,
+                    "off_temp_threshold": plug.off_temp_threshold,
+                    "username": plug.username,
+                    "password": plug.password,
+                    "power_alert_enabled": plug.power_alert_enabled,
+                    "power_alert_high": plug.power_alert_high,
+                    "power_alert_low": plug.power_alert_low,
+                    "schedule_enabled": plug.schedule_enabled,
+                    "schedule_on_time": plug.schedule_on_time,
+                    "schedule_off_time": plug.schedule_off_time,
+                    "show_in_switchbar": plug.show_in_switchbar,
+                }
+            )
         backup["included"].append("smart_plugs")
         backup["included"].append("smart_plugs")
 
 
     # External links
     # External links
@@ -312,21 +327,23 @@ async def export_backup(
         filaments = result.scalars().all()
         filaments = result.scalars().all()
         backup["filaments"] = []
         backup["filaments"] = []
         for f in filaments:
         for f in filaments:
-            backup["filaments"].append({
-                "name": f.name,
-                "type": f.type,
-                "brand": f.brand,
-                "color": f.color,
-                "color_hex": f.color_hex,
-                "cost_per_kg": f.cost_per_kg,
-                "spool_weight_g": f.spool_weight_g,
-                "currency": f.currency,
-                "density": f.density,
-                "print_temp_min": f.print_temp_min,
-                "print_temp_max": f.print_temp_max,
-                "bed_temp_min": f.bed_temp_min,
-                "bed_temp_max": f.bed_temp_max,
-            })
+            backup["filaments"].append(
+                {
+                    "name": f.name,
+                    "type": f.type,
+                    "brand": f.brand,
+                    "color": f.color,
+                    "color_hex": f.color_hex,
+                    "cost_per_kg": f.cost_per_kg,
+                    "spool_weight_g": f.spool_weight_g,
+                    "currency": f.currency,
+                    "density": f.density,
+                    "print_temp_min": f.print_temp_min,
+                    "print_temp_max": f.print_temp_max,
+                    "bed_temp_min": f.bed_temp_min,
+                    "bed_temp_max": f.bed_temp_max,
+                }
+            )
         backup["included"].append("filaments")
         backup["included"].append("filaments")
 
 
     # Maintenance types and records
     # Maintenance types and records
@@ -336,14 +353,16 @@ async def export_backup(
         types = result.scalars().all()
         types = result.scalars().all()
         backup["maintenance_types"] = []
         backup["maintenance_types"] = []
         for mt in types:
         for mt in types:
-            backup["maintenance_types"].append({
-                "name": mt.name,
-                "description": mt.description,
-                "default_interval_hours": mt.default_interval_hours,
-                "interval_type": mt.interval_type,
-                "icon": mt.icon,
-                "is_system": mt.is_system,
-            })
+            backup["maintenance_types"].append(
+                {
+                    "name": mt.name,
+                    "description": mt.description,
+                    "default_interval_hours": mt.default_interval_hours,
+                    "interval_type": mt.interval_type,
+                    "icon": mt.icon,
+                    "is_system": mt.is_system,
+                }
+            )
         backup["included"].append("maintenance_types")
         backup["included"].append("maintenance_types")
 
 
     # Collect files for ZIP (icons + archives)
     # Collect files for ZIP (icons + archives)
@@ -365,9 +384,17 @@ async def export_backup(
         backup["archives"] = []
         backup["archives"] = []
         base_dir = app_settings.base_dir
         base_dir = app_settings.base_dir
 
 
+        # Build project ID to name mapping for archive export
+        project_id_to_name: dict[int, str] = {}
+        if include_projects:
+            proj_result = await db.execute(select(Project))
+            for proj in proj_result.scalars().all():
+                project_id_to_name[proj.id] = proj.name
+
         for a in archives:
         for a in archives:
             archive_data = {
             archive_data = {
                 "filename": a.filename,
                 "filename": a.filename,
+                "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
                 "file_size": a.file_size,
                 "file_size": a.file_size,
                 "content_hash": a.content_hash,
                 "content_hash": a.content_hash,
                 "print_name": a.print_name,
                 "print_name": a.print_name,
@@ -432,10 +459,61 @@ async def export_backup(
             backup["archives"].append(archive_data)
             backup["archives"].append(archive_data)
         backup["included"].append("archives")
         backup["included"].append("archives")
 
 
+    # Projects with BOM items
+    if include_projects:
+        result = await db.execute(select(Project))
+        projects = result.scalars().all()
+        backup["projects"] = []
+
+        for p in projects:
+            # Get BOM items for this project
+            bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
+            bom_items = bom_result.scalars().all()
+
+            project_data = {
+                "name": p.name,
+                "description": p.description,
+                "color": p.color,
+                "status": p.status,
+                "target_count": p.target_count,
+                "notes": p.notes,
+                "tags": p.tags,
+                "due_date": p.due_date.isoformat() if p.due_date else None,
+                "priority": p.priority,
+                "budget": p.budget,
+                "is_template": p.is_template,
+                "bom_items": [
+                    {
+                        "name": item.name,
+                        "quantity_needed": item.quantity_needed,
+                        "quantity_acquired": item.quantity_acquired,
+                        "unit_price": item.unit_price,
+                        "sourcing_url": item.sourcing_url,
+                        "stl_filename": item.stl_filename,
+                        "remarks": item.remarks,
+                        "sort_order": item.sort_order,
+                    }
+                    for item in bom_items
+                ],
+            }
+
+            # Include attachment files for ZIP
+            if p.attachments:
+                project_data["attachments"] = p.attachments
+                attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
+                for att in p.attachments:
+                    att_path = attachments_dir / att.get("filename", "")
+                    if att_path.exists():
+                        zip_path = f"projects/{p.id}/attachments/{att['filename']}"
+                        backup_files.append((zip_path, att_path))
+
+            backup["projects"].append(project_data)
+        backup["included"].append("projects")
+
     # If there are files to include (icons or archives), create ZIP file
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
     if backup_files:
         zip_buffer = io.BytesIO()
         zip_buffer = io.BytesIO()
-        with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
             # Add backup.json
             # Add backup.json
             zf.writestr("backup.json", json.dumps(backup, indent=2))
             zf.writestr("backup.json", json.dumps(backup, indent=2))
 
 
@@ -454,7 +532,7 @@ async def export_backup(
         return StreamingResponse(
         return StreamingResponse(
             zip_buffer,
             zip_buffer,
             media_type="application/zip",
             media_type="application/zip",
-            headers={"Content-Disposition": f"attachment; filename={filename}"}
+            headers={"Content-Disposition": f"attachment; filename={filename}"},
         )
         )
 
 
     # Otherwise return JSON
     # Otherwise return JSON
@@ -462,7 +540,7 @@ async def export_backup(
         content=backup,
         content=backup,
         headers={
         headers={
             "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
             "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
-        }
+        },
     )
     )
 
 
 
 
@@ -479,27 +557,27 @@ async def import_backup(
         files_restored = 0
         files_restored = 0
 
 
         # Check if it's a ZIP file
         # Check if it's a ZIP file
-        if file.filename and file.filename.endswith('.zip'):
+        if file.filename and file.filename.endswith(".zip"):
             try:
             try:
                 zip_buffer = io.BytesIO(content)
                 zip_buffer = io.BytesIO(content)
-                with zipfile.ZipFile(zip_buffer, 'r') as zf:
+                with zipfile.ZipFile(zip_buffer, "r") as zf:
                     # Extract backup.json
                     # Extract backup.json
-                    if 'backup.json' not in zf.namelist():
+                    if "backup.json" not in zf.namelist():
                         return {"success": False, "message": "Invalid ZIP: missing backup.json"}
                         return {"success": False, "message": "Invalid ZIP: missing backup.json"}
 
 
-                    backup_content = zf.read('backup.json')
+                    backup_content = zf.read("backup.json")
                     backup = json.loads(backup_content.decode("utf-8"))
                     backup = json.loads(backup_content.decode("utf-8"))
 
 
                     # Extract all other files to base_dir
                     # Extract all other files to base_dir
                     for zip_path in zf.namelist():
                     for zip_path in zf.namelist():
-                        if zip_path == 'backup.json':
+                        if zip_path == "backup.json":
                             continue
                             continue
                         # Ensure path is safe (no path traversal)
                         # Ensure path is safe (no path traversal)
-                        if '..' in zip_path or zip_path.startswith('/'):
+                        if ".." in zip_path or zip_path.startswith("/"):
                             continue
                             continue
                         target_path = base_dir / zip_path
                         target_path = base_dir / zip_path
                         target_path.parent.mkdir(parents=True, exist_ok=True)
                         target_path.parent.mkdir(parents=True, exist_ok=True)
-                        with zf.open(zip_path) as src, open(target_path, 'wb') as dst:
+                        with zf.open(zip_path) as src, open(target_path, "wb") as dst:
                             dst.write(src.read())
                             dst.write(src.read())
                             files_restored += 1
                             files_restored += 1
             except zipfile.BadZipFile:
             except zipfile.BadZipFile:
@@ -520,6 +598,7 @@ async def import_backup(
         "printers": 0,
         "printers": 0,
         "filaments": 0,
         "filaments": 0,
         "maintenance_types": 0,
         "maintenance_types": 0,
+        "projects": 0,
     }
     }
     skipped = {
     skipped = {
         "settings": 0,
         "settings": 0,
@@ -531,6 +610,7 @@ async def import_backup(
         "filaments": 0,
         "filaments": 0,
         "maintenance_types": 0,
         "maintenance_types": 0,
         "archives": 0,
         "archives": 0,
+        "projects": 0,
     }
     }
     skipped_details = {
     skipped_details = {
         "notification_providers": [],
         "notification_providers": [],
@@ -540,6 +620,7 @@ async def import_backup(
         "filaments": [],
         "filaments": [],
         "maintenance_types": [],
         "maintenance_types": [],
         "archives": [],
         "archives": [],
+        "projects": [],
     }
     }
 
 
     # Restore settings (always overwrites)
     # Restore settings (always overwrites)
@@ -616,9 +697,7 @@ async def import_backup(
     if "notification_templates" in backup:
     if "notification_templates" in backup:
         for template_data in backup["notification_templates"]:
         for template_data in backup["notification_templates"]:
             result = await db.execute(
             result = await db.execute(
-                select(NotificationTemplate).where(
-                    NotificationTemplate.event_type == template_data["event_type"]
-                )
+                select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
             if existing:
             if existing:
@@ -641,9 +720,7 @@ async def import_backup(
     # Restore smart plugs (skip or overwrite duplicates by IP)
     # Restore smart plugs (skip or overwrite duplicates by IP)
     if "smart_plugs" in backup:
     if "smart_plugs" in backup:
         for plug_data in backup["smart_plugs"]:
         for plug_data in backup["smart_plugs"]:
-            result = await db.execute(
-                select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
-            )
+            result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
             if existing:
             if existing:
                 if overwrite:
                 if overwrite:
@@ -663,6 +740,7 @@ async def import_backup(
                     existing.schedule_enabled = plug_data.get("schedule_enabled", False)
                     existing.schedule_enabled = plug_data.get("schedule_enabled", False)
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
+                    existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
                     restored["smart_plugs"] += 1
                     restored["smart_plugs"] += 1
                 else:
                 else:
                     skipped["smart_plugs"] += 1
                     skipped["smart_plugs"] += 1
@@ -686,6 +764,7 @@ async def import_backup(
                     schedule_enabled=plug_data.get("schedule_enabled", False),
                     schedule_enabled=plug_data.get("schedule_enabled", False),
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
+                    show_in_switchbar=plug_data.get("show_in_switchbar", False),
                 )
                 )
                 db.add(plug)
                 db.add(plug)
                 restored["smart_plugs"] += 1
                 restored["smart_plugs"] += 1
@@ -697,10 +776,7 @@ async def import_backup(
 
 
         for link_data in backup["external_links"]:
         for link_data in backup["external_links"]:
             result = await db.execute(
             result = await db.execute(
-                select(ExternalLink).where(
-                    ExternalLink.name == link_data["name"],
-                    ExternalLink.url == link_data["url"]
-                )
+                select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
             if existing:
             if existing:
@@ -728,9 +804,7 @@ async def import_backup(
     # Restore printers (skip or overwrite duplicates by serial_number)
     # Restore printers (skip or overwrite duplicates by serial_number)
     if "printers" in backup:
     if "printers" in backup:
         for printer_data in backup["printers"]:
         for printer_data in backup["printers"]:
-            result = await db.execute(
-                select(Printer).where(Printer.serial_number == printer_data["serial_number"])
-            )
+            result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
             if existing:
             if existing:
                 if overwrite:
                 if overwrite:
@@ -805,7 +879,9 @@ async def import_backup(
                     restored["filaments"] += 1
                     restored["filaments"] += 1
                 else:
                 else:
                     skipped["filaments"] += 1
                     skipped["filaments"] += 1
-                    skipped_details["filaments"].append(f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})")
+                    skipped_details["filaments"].append(
+                        f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
+                    )
             else:
             else:
                 filament = Filament(
                 filament = Filament(
                     name=filament_data["name"],
                     name=filament_data["name"],
@@ -828,9 +904,7 @@ async def import_backup(
     # Restore maintenance types (skip or overwrite duplicates by name)
     # Restore maintenance types (skip or overwrite duplicates by name)
     if "maintenance_types" in backup:
     if "maintenance_types" in backup:
         for mt_data in backup["maintenance_types"]:
         for mt_data in backup["maintenance_types"]:
-            result = await db.execute(
-                select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
-            )
+            result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
             if existing:
             if existing:
                 if overwrite:
                 if overwrite:
@@ -861,9 +935,7 @@ async def import_backup(
             # Skip if no content_hash or already exists
             # Skip if no content_hash or already exists
             content_hash = archive_data.get("content_hash")
             content_hash = archive_data.get("content_hash")
             if content_hash:
             if content_hash:
-                result = await db.execute(
-                    select(PrintArchive).where(PrintArchive.content_hash == content_hash)
-                )
+                result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
                 existing = result.scalar_one_or_none()
                 existing = result.scalar_one_or_none()
                 if existing:
                 if existing:
                     skipped["archives"] += 1
                     skipped["archives"] += 1
@@ -907,15 +979,111 @@ async def import_backup(
                 db.add(archive)
                 db.add(archive)
                 restored["archives"] = restored.get("archives", 0) + 1
                 restored["archives"] = restored.get("archives", 0) + 1
 
 
+    # Restore projects (skip or overwrite duplicates by name)
+    if "projects" in backup:
+        for project_data in backup["projects"]:
+            result = await db.execute(select(Project).where(Project.name == project_data["name"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    # Update existing project
+                    existing.description = project_data.get("description")
+                    existing.color = project_data.get("color")
+                    existing.status = project_data.get("status", "active")
+                    existing.target_count = project_data.get("target_count")
+                    existing.notes = project_data.get("notes")
+                    existing.tags = project_data.get("tags")
+                    existing.priority = project_data.get("priority", "normal")
+                    existing.budget = project_data.get("budget")
+                    existing.is_template = project_data.get("is_template", False)
+                    existing.attachments = project_data.get("attachments")
+                    if project_data.get("due_date"):
+                        existing.due_date = datetime.fromisoformat(project_data["due_date"])
+
+                    # Delete existing BOM items and re-add
+                    await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
+                    for bom_data in project_data.get("bom_items", []):
+                        bom_item = ProjectBOMItem(
+                            project_id=existing.id,
+                            name=bom_data["name"],
+                            quantity_needed=bom_data.get("quantity_needed", 1),
+                            quantity_acquired=bom_data.get("quantity_acquired", 0),
+                            unit_price=bom_data.get("unit_price"),
+                            sourcing_url=bom_data.get("sourcing_url"),
+                            stl_filename=bom_data.get("stl_filename"),
+                            remarks=bom_data.get("remarks"),
+                            sort_order=bom_data.get("sort_order", 0),
+                        )
+                        db.add(bom_item)
+
+                    restored["projects"] += 1
+                else:
+                    skipped["projects"] += 1
+                    skipped_details["projects"].append(project_data["name"])
+            else:
+                # Create new project
+                project = Project(
+                    name=project_data["name"],
+                    description=project_data.get("description"),
+                    color=project_data.get("color"),
+                    status=project_data.get("status", "active"),
+                    target_count=project_data.get("target_count"),
+                    notes=project_data.get("notes"),
+                    tags=project_data.get("tags"),
+                    priority=project_data.get("priority", "normal"),
+                    budget=project_data.get("budget"),
+                    is_template=project_data.get("is_template", False),
+                    attachments=project_data.get("attachments"),
+                )
+                if project_data.get("due_date"):
+                    project.due_date = datetime.fromisoformat(project_data["due_date"])
+
+                db.add(project)
+                await db.flush()  # Get the project ID
+
+                # Add BOM items
+                for bom_data in project_data.get("bom_items", []):
+                    bom_item = ProjectBOMItem(
+                        project_id=project.id,
+                        name=bom_data["name"],
+                        quantity_needed=bom_data.get("quantity_needed", 1),
+                        quantity_acquired=bom_data.get("quantity_acquired", 0),
+                        unit_price=bom_data.get("unit_price"),
+                        sourcing_url=bom_data.get("sourcing_url"),
+                        stl_filename=bom_data.get("stl_filename"),
+                        remarks=bom_data.get("remarks"),
+                        sort_order=bom_data.get("sort_order", 0),
+                    )
+                    db.add(bom_item)
+
+                restored["projects"] += 1
+
+    # Link archives to projects by name (after both are restored)
+    if "archives" in backup and "projects" in backup:
+        # Build project name to ID mapping
+        proj_result = await db.execute(select(Project))
+        project_name_to_id: dict[str, int] = {}
+        for proj in proj_result.scalars().all():
+            project_name_to_id[proj.name] = proj.id
+
+        # Update archives with project_id
+        for archive_data in backup["archives"]:
+            project_name = archive_data.get("project_name")
+            if project_name and project_name in project_name_to_id:
+                content_hash = archive_data.get("content_hash")
+                if content_hash:
+                    result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
+                    archive = result.scalar_one_or_none()
+                    if archive:
+                        archive.project_id = project_name_to_id[project_name]
+
     await db.commit()
     await db.commit()
 
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
     # This ensures connections are re-established after restore, even if printers were skipped
     # This ensures connections are re-established after restore, even if printers were skipped
     if "printers" in backup:
     if "printers" in backup:
         # Need fresh query after commit to get proper IDs for newly created printers
         # Need fresh query after commit to get proper IDs for newly created printers
-        result = await db.execute(
-            select(Printer).where(Printer.is_active == True)
-        )
+        result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
         active_printers = result.scalars().all()
         active_printers = result.scalars().all()
         for printer in active_printers:
         for printer in active_printers:
             # This will disconnect existing connection (if any) and reconnect
             # This will disconnect existing connection (if any) and reconnect

+ 132 - 20
backend/app/api/routes/smart_plugs.py

@@ -3,25 +3,27 @@
 import logging
 import logging
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 
 
-from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy.ext.asyncio import AsyncSession
+from fastapi import APIRouter, Body, Depends, HTTPException
+from pydantic import BaseModel
 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.smart_plug import SmartPlug
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.smart_plug import SmartPlug
 from backend.app.schemas.smart_plug import (
 from backend.app.schemas.smart_plug import (
+    SmartPlugControl,
     SmartPlugCreate,
     SmartPlugCreate,
-    SmartPlugUpdate,
+    SmartPlugEnergy,
     SmartPlugResponse,
     SmartPlugResponse,
-    SmartPlugControl,
     SmartPlugStatus,
     SmartPlugStatus,
     SmartPlugTestConnection,
     SmartPlugTestConnection,
-    SmartPlugEnergy,
+    SmartPlugUpdate,
 )
 )
-from backend.app.services.tasmota import tasmota_service
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.tasmota import tasmota_service
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -43,16 +45,12 @@ async def create_smart_plug(
     """Create a new smart plug."""
     """Create a new smart plug."""
     # Validate printer_id if provided
     # Validate printer_id if provided
     if data.printer_id:
     if data.printer_id:
-        result = await db.execute(
-            select(Printer).where(Printer.id == data.printer_id)
-        )
+        result = await db.execute(select(Printer).where(Printer.id == 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")
 
 
         # Check if printer already has a plug assigned
         # Check if printer already has a plug assigned
-        result = await db.execute(
-            select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)
-        )
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
         if result.scalar_one_or_none():
         if result.scalar_one_or_none():
             raise HTTPException(400, "This printer already has a smart plug assigned")
             raise HTTPException(400, "This printer already has a smart plug assigned")
 
 
@@ -68,15 +66,131 @@ async def create_smart_plug(
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
     """Get the smart plug assigned to a printer."""
     """Get the smart plug assigned to 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))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
     if not plug:
     if not plug:
         return None
         return None
     return plug
     return plug
 
 
 
 
+# Tasmota Discovery Endpoints
+# NOTE: These must be defined BEFORE /{plug_id} routes to avoid path conflicts
+
+
+class TasmotaScanRequest(BaseModel):
+    """Request to scan for Tasmota devices."""
+
+    from_ip: str | None = None  # Starting IP (auto-detected if not provided)
+    to_ip: str | None = None  # Ending IP (auto-detected if not provided)
+    timeout: float = 1.0  # Connection timeout per host
+
+
+def get_local_network_range() -> tuple[str, str]:
+    """Auto-detect local network and return IP range to scan."""
+    import socket
+
+    try:
+        # Get local IP by connecting to a public DNS (doesn't actually send data)
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        s.connect(("8.8.8.8", 80))
+        local_ip = s.getsockname()[0]
+        s.close()
+
+        # Parse IP and create range (assume /24 subnet)
+        parts = local_ip.split(".")
+        base = ".".join(parts[:3])
+        from_ip = f"{base}.1"
+        to_ip = f"{base}.254"
+
+        logger.info(f"Auto-detected network: {from_ip} - {to_ip} (local IP: {local_ip})")
+        return from_ip, to_ip
+
+    except Exception as e:
+        logger.error(f"Failed to detect local network: {e}")
+        # Fallback to common home network
+        return "192.168.1.1", "192.168.1.254"
+
+
+class TasmotaScanStatus(BaseModel):
+    """Tasmota scan status response."""
+
+    running: bool
+    scanned: int
+    total: int
+
+
+class DiscoveredTasmotaDevice(BaseModel):
+    """Discovered Tasmota device."""
+
+    ip_address: str
+    name: str
+    module: int | None = None
+    state: str | None = None
+    discovered_at: str | None = None
+
+
+@router.post("/discover/scan", response_model=TasmotaScanStatus)
+async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
+    """Start an IP range scan for Tasmota devices.
+
+    Auto-detects local network if no IP range provided.
+    """
+    import asyncio
+
+    # Auto-detect network
+    from_ip, to_ip = get_local_network_range()
+    timeout = request.timeout if request else 1.0
+
+    # Start scan in background
+    asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))
+
+    # Return immediate status
+    scanned, total = tasmota_scanner.progress
+    return TasmotaScanStatus(
+        running=tasmota_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.get("/discover/status", response_model=TasmotaScanStatus)
+async def get_tasmota_scan_status():
+    """Get the current Tasmota scan status."""
+    scanned, total = tasmota_scanner.progress
+    return TasmotaScanStatus(
+        running=tasmota_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.post("/discover/stop", response_model=TasmotaScanStatus)
+async def stop_tasmota_scan():
+    """Stop the current Tasmota scan."""
+    tasmota_scanner.stop()
+    scanned, total = tasmota_scanner.progress
+    return TasmotaScanStatus(
+        running=tasmota_scanner.is_running,
+        scanned=scanned,
+        total=total,
+    )
+
+
+@router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
+async def get_discovered_tasmota_devices():
+    """Get list of discovered Tasmota devices."""
+    return [
+        DiscoveredTasmotaDevice(
+            ip_address=d["ip_address"],
+            name=d["name"],
+            module=d.get("module"),
+            state=d.get("state"),
+            discovered_at=d.get("discovered_at"),
+        )
+        for d in tasmota_scanner.discovered_devices
+    ]
+
+
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific smart plug."""
     """Get a specific smart plug."""
@@ -106,9 +220,7 @@ async def update_smart_plug(
         new_printer_id = update_data["printer_id"]
         new_printer_id = update_data["printer_id"]
 
 
         # Check printer exists
         # Check printer exists
-        result = await db.execute(
-            select(Printer).where(Printer.id == new_printer_id)
-        )
+        result = await db.execute(select(Printer).where(Printer.id == new_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")
 
 

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

@@ -4,7 +4,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.5"
+APP_VERSION = "0.1.6b"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # Base directory for path calculations
 # Base directory for path calculations

+ 157 - 90
backend/app/core/database.py

@@ -1,9 +1,8 @@
-from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import DeclarativeBase
 from sqlalchemy.orm import DeclarativeBase
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 
 
-
 engine = create_async_engine(
 engine = create_async_engine(
     settings.database_url,
     settings.database_url,
     echo=settings.debug,
     echo=settings.debug,
@@ -34,7 +33,22 @@ async def get_db() -> AsyncSession:
 
 
 async def init_db():
 async def init_db():
     # Import models to register them with SQLAlchemy
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link, project, api_key  # noqa: F401
+    from backend.app.models import (  # noqa: F401
+        api_key,
+        archive,
+        external_link,
+        filament,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        smart_plug,
+    )
 
 
     async with engine.begin() as conn:
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
         await conn.run_sync(Base.metadata.create_all)
@@ -52,164 +66,129 @@ async def run_migrations(conn):
 
 
     # Migration: Add is_favorite column to print_archives
     # Migration: Add is_favorite column to print_archives
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add content_hash column to print_archives for duplicate detection
     # Migration: Add content_hash column to print_archives for duplicate detection
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"
-        ))
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add auto_off_executed column to smart_plugs
     # Migration: Add auto_off_executed column to smart_plugs
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add on_print_stopped column to notification_providers
     # Migration: Add on_print_stopped column to notification_providers
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add source_3mf_path column to print_archives
     # Migration: Add source_3mf_path column to print_archives
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"
-        ))
+        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add on_maintenance_due column to notification_providers
     # Migration: Add on_maintenance_due column to notification_providers
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add location column to printers for grouping
     # Migration: Add location column to printers for grouping
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE printers ADD COLUMN location VARCHAR(100)"
-        ))
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN location VARCHAR(100)"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add interval_type column to maintenance_types
     # Migration: Add interval_type column to maintenance_types
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"
-        ))
+        await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add custom_interval_type column to printer_maintenance
     # Migration: Add custom_interval_type column to printer_maintenance
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"
-        ))
+        await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         pass
 
 
     # Migration: Add power alert columns to smart_plugs
     # Migration: Add power alert columns to smart_plugs
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"))
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Add schedule columns to smart_plugs
     # Migration: Add schedule columns to smart_plugs
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"))
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Add daily digest columns to notification_providers
     # Migration: Add daily digest columns to notification_providers
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"))
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Add project_id column to print_archives
     # Migration: Add project_id column to print_archives
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
-        ))
+        await conn.execute(
+            text("ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Add project_id column to print_queue
     # Migration: Add project_id column to print_queue
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
-        ))
+        await conn.execute(
+            text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Create FTS5 virtual table for archive full-text search
     # Migration: Create FTS5 virtual table for archive full-text search
     try:
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
             CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
                 print_name,
                 print_name,
                 filename,
                 filename,
@@ -220,82 +199,169 @@ async def run_migrations(conn):
                 content='print_archives',
                 content='print_archives',
                 content_rowid='id'
                 content_rowid='id'
             )
             )
-        """))
+        """)
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Create triggers to keep FTS index in sync
     # Migration: Create triggers to keep FTS index in sync
     try:
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
             CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
                 INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
                 INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
             END
             END
-        """))
+        """)
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
     try:
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
             CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
                 INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
                 INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
                 VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
             END
             END
-        """))
+        """)
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
     try:
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
             CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
                 INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
                 INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
                 VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
                 INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
                 INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
             END
             END
-        """))
+        """)
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
     # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"
-        ))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"))
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Add AMS alarm notification columns to notification_providers
     # Migration: Add AMS alarm notification columns to notification_providers
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         pass
         pass
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
     # Migration: Add AMS-HT alarm notification columns to notification_providers
     # Migration: Add AMS-HT alarm notification columns to notification_providers
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
+        )
+    except Exception:
+        pass
+    try:
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
+        )
+    except Exception:
+        pass
+
+    # Migration: Add notes column to projects (Phase 2)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT"))
+    except Exception:
+        pass
+
+    # Migration: Add attachments column to projects (Phase 3)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN attachments JSON"))
+    except Exception:
+        pass
+
+    # Migration: Add tags column to projects (Phase 4)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN tags TEXT"))
+    except Exception:
+        pass
+
+    # Migration: Add due_date column to projects (Phase 5)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN due_date DATETIME"))
+    except Exception:
+        pass
+
+    # Migration: Add priority column to projects (Phase 5)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'"))
+    except Exception:
+        pass
+
+    # Migration: Add budget column to projects (Phase 6)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN budget REAL"))
+    except Exception:
+        pass
+
+    # Migration: Add is_template column to projects (Phase 8)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
+    # Migration: Add template_source_id column to projects (Phase 8)
+    try:
+        await conn.execute(text("ALTER TABLE projects ADD COLUMN template_source_id INTEGER"))
     except Exception:
     except Exception:
         pass
         pass
+
+    # Migration: Add parent_id column to projects (Phase 10)
     try:
     try:
-        await conn.execute(text(
-            "ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0"
-        ))
+        await conn.execute(
+            text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
+    # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"))
+    except Exception:
+        pass
+
+    # Migration: Add unit_price column to project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN unit_price REAL"))
+    except Exception:
+        pass
+
+    # Migration: Add sourcing_url column to project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)"))
+    except Exception:
+        pass
+
+    # Migration: Rename notes to remarks in project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks"))
+    except Exception:
+        pass
+
+    # Migration: Add show_in_switchbar column to smart_plugs
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0"))
     except Exception:
     except Exception:
         pass
         pass
 
 
@@ -303,7 +369,8 @@ async def run_migrations(conn):
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""
     from sqlalchemy import select
     from sqlalchemy import select
-    from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+
+    from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
 
 
     async with async_session() as session:
     async with async_session() as session:
         # Check if templates already exist
         # Check if templates already exist

+ 10 - 3
backend/app/main.py

@@ -56,6 +56,7 @@ from backend.app.api.routes import (
     archives,
     archives,
     camera,
     camera,
     cloud,
     cloud,
+    discovery,
     external_links,
     external_links,
     filaments,
     filaments,
     kprofiles,
     kprofiles,
@@ -228,9 +229,14 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                     f"Auto-detected dual-nozzle printer {printer_id}, updated nozzle_count=2"
                     f"Auto-detected dual-nozzle printer {printer_id}, updated nozzle_count=2"
                 )
                 )
 
 
+    # Include target temps for heating phase detection
+    bed_target = round(temps.get("bed_target", 0))
+    nozzle_target = round(temps.get("nozzle_target", 0))
+
     status_key = (
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
-        f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}"
+        f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
+        f"{state.stg_cur}:{bed_target}:{nozzle_target}"
     )
     )
     if _last_status_broadcast.get(printer_id) == status_key:
     if _last_status_broadcast.get(printer_id) == status_key:
         return  # No change, skip broadcast
         return  # No change, skip broadcast
@@ -1273,8 +1279,8 @@ async def record_ams_history():
                 for printer in printers:
                 for printer in printers:
                     # Get current state from printer manager
                     # Get current state from printer manager
                     state = printer_manager.get_status(printer.id)
                     state = printer_manager.get_status(printer.id)
-                    if not state or not state.raw_data:
-                        continue
+                    if not state or not state.connected or not state.raw_data:
+                        continue  # Skip disconnected printers - don't use stale data
 
 
                     raw_data = state.raw_data
                     raw_data = state.raw_data
                     if "ams" not in raw_data or not isinstance(raw_data["ams"], list):
                     if "ams" not in raw_data or not isinstance(raw_data["ams"], list):
@@ -1525,6 +1531,7 @@ app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
+app.include_router(discovery.router, prefix=app_settings.api_prefix)
 
 
 
 
 # Serve static files (React build)
 # Serve static files (React build)

+ 40 - 4
backend/app/models/project.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Integer, DateTime, Text, func
+
+from sqlalchemy import JSON, Boolean, DateTime, Float, 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
@@ -17,16 +18,51 @@ class Project(Base):
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
     target_count: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Optional target number of prints
     target_count: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Optional target number of prints
 
 
+    # Phase 2: Rich text notes (HTML from WYSIWYG editor)
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Phase 3: File attachments stored as JSON array
+    # Format: [{"filename": "x.stl", "original_name": "part.stl", "size": 1234, "uploaded_at": "..."}]
+    attachments: Mapped[list | None] = mapped_column(JSON, nullable=True)
+
+    # Phase 4: Tags (comma-separated)
+    tags: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Phase 5: Due dates and priority
+    due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    priority: Mapped[str] = mapped_column(String(20), default="normal")  # low, normal, high, urgent
+
+    # Phase 6: Budget tracking
+    budget: Mapped[float | None] = mapped_column(Float, nullable=True)
+
+    # Phase 8: Templates
+    is_template: Mapped[bool] = mapped_column(Boolean, default=False)
+    template_source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+    # Phase 10: Sub-projects (hierarchical)
+    parent_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id"), nullable=True)
+
     # Timestamps
     # Timestamps
     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())
 
 
     # Relationships
     # Relationships
     archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project")
     archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project")
     queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project")
     queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project")
+    children: Mapped[list["Project"]] = relationship(
+        "Project",
+        back_populates="parent",
+        foreign_keys="Project.parent_id",
+    )
+    parent: Mapped["Project | None"] = relationship(
+        "Project",
+        back_populates="children",
+        remote_side="Project.id",
+        foreign_keys="Project.parent_id",
+    )
+    bom_items: Mapped[list["ProjectBOMItem"]] = relationship(back_populates="project", cascade="all, delete-orphan")
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.print_queue import PrintQueueItem  # noqa: E402
 from backend.app.models.print_queue import PrintQueueItem  # noqa: E402
+from backend.app.models.project_bom import ProjectBOMItem  # noqa: E402

+ 50 - 0
backend/app/models/project_bom.py

@@ -0,0 +1,50 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class ProjectBOMItem(Base):
+    """Bill of Materials item for a project.
+
+    Tracks sourced/purchased parts (hardware, electronics, screws, etc.)
+    that need to be acquired for a project.
+    """
+
+    __tablename__ = "project_bom_items"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    name: Mapped[str] = mapped_column(String(255))
+    quantity_needed: Mapped[int] = mapped_column(Integer, default=1)
+    quantity_acquired: Mapped[int] = mapped_column(Integer, default=0)
+
+    # Sourcing information
+    unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+    sourcing_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+
+    # Optional link to archive (for reference)
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
+
+    # Reference to attachment filename
+    stl_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+    # Remarks about this part
+    remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Sort order
+    sort_order: Mapped[int] = mapped_column(Integer, default=0)
+
+    # Timestamps
+    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
+    project: Mapped["Project"] = relationship(back_populates="bom_items")
+    archive: Mapped["PrintArchive | None"] = relationship()
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.project import Project  # noqa: E402

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

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import String, Boolean, Integer, Float, DateTime, ForeignKey, func
+
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, 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
@@ -44,18 +45,21 @@ class SmartPlug(Base):
     schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
     schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
     schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
     schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # "HH:MM" format
 
 
+    # Switchbar visibility
+    show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Status tracking
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False)  # True when auto-off was triggered
     auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False)  # True when auto-off was triggered
     auto_off_pending: Mapped[bool] = mapped_column(Boolean, default=False)  # True when waiting for cooldown
     auto_off_pending: Mapped[bool] = mapped_column(Boolean, default=False)  # True when waiting for cooldown
-    auto_off_pending_since: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # When auto-off was scheduled
+    auto_off_pending_since: Mapped[datetime | None] = mapped_column(
+        DateTime, nullable=True
+    )  # When auto-off was scheduled
 
 
     # Timestamps
     # Timestamps
     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(back_populates="smart_plug")
     printer: Mapped["Printer"] = relationship(back_populates="smart_plug")

+ 112 - 0
backend/app/schemas/project.py

@@ -1,26 +1,42 @@
 from datetime import datetime
 from datetime import datetime
+
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
 
 
 class ProjectCreate(BaseModel):
 class ProjectCreate(BaseModel):
     """Schema for creating a new project."""
     """Schema for creating a new project."""
+
     name: str
     name: str
     description: str | None = None
     description: str | None = None
     color: str | None = None
     color: str | None = None
     target_count: int | None = None
     target_count: int | None = None
+    notes: str | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
+    budget: float | None = None
+    parent_id: int | None = None  # For sub-projects
 
 
 
 
 class ProjectUpdate(BaseModel):
 class ProjectUpdate(BaseModel):
     """Schema for updating a project."""
     """Schema for updating a project."""
+
     name: str | None = None
     name: str | None = None
     description: str | None = None
     description: str | None = None
     color: str | None = None
     color: str | None = None
     status: str | None = None  # active, completed, archived
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
     target_count: int | None = None
+    notes: str | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str | None = None
+    budget: float | None = None
+    parent_id: int | None = None
 
 
 
 
 class ProjectStats(BaseModel):
 class ProjectStats(BaseModel):
     """Statistics for a project."""
     """Statistics for a project."""
+
     total_archives: int = 0
     total_archives: int = 0
     completed_prints: int = 0
     completed_prints: int = 0
     failed_prints: int = 0
     failed_prints: int = 0
@@ -29,16 +45,46 @@ class ProjectStats(BaseModel):
     total_print_time_hours: float = 0.0
     total_print_time_hours: float = 0.0
     total_filament_grams: float = 0.0
     total_filament_grams: float = 0.0
     progress_percent: float | None = None  # Based on target_count
     progress_percent: float | None = None  # Based on target_count
+    # Cost tracking (Phase 6)
+    estimated_cost: float = 0.0  # Based on filament cost
+    total_energy_kwh: float = 0.0
+    total_energy_cost: float = 0.0
+    remaining_prints: int | None = None  # target_count - completed_prints
+    # BOM stats (Phase 7)
+    bom_total_items: int = 0
+    bom_completed_items: int = 0
+
+
+class ProjectChildPreview(BaseModel):
+    """Minimal project data for child preview."""
+
+    id: int
+    name: str
+    color: str | None
+    status: str
+    progress_percent: float | None = None
 
 
 
 
 class ProjectResponse(BaseModel):
 class ProjectResponse(BaseModel):
     """Schema for project response."""
     """Schema for project response."""
+
     id: int
     id: int
     name: str
     name: str
     description: str | None
     description: str | None
     color: str | None
     color: str | None
     status: str
     status: str
     target_count: int | None
     target_count: int | None
+    notes: str | None = None
+    attachments: list | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
+    budget: float | None = None
+    is_template: bool = False
+    template_source_id: int | None = None
+    parent_id: int | None = None
+    parent_name: str | None = None  # For display
+    children: list[ProjectChildPreview] = []
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
     stats: ProjectStats | None = None
     stats: ProjectStats | None = None
@@ -49,14 +95,18 @@ class ProjectResponse(BaseModel):
 
 
 class ArchivePreview(BaseModel):
 class ArchivePreview(BaseModel):
     """Minimal archive data for project preview."""
     """Minimal archive data for project preview."""
+
     id: int
     id: int
     print_name: str | None
     print_name: str | None
     thumbnail_path: str | None
     thumbnail_path: str | None
     status: str
     status: str
+    filament_type: str | None = None
+    filament_color: str | None = None
 
 
 
 
 class ProjectListResponse(BaseModel):
 class ProjectListResponse(BaseModel):
     """Schema for project list item (lighter weight)."""
     """Schema for project list item (lighter weight)."""
+
     id: int
     id: int
     name: str
     name: str
     description: str | None
     description: str | None
@@ -77,9 +127,71 @@ class ProjectListResponse(BaseModel):
 
 
 class BatchAddArchives(BaseModel):
 class BatchAddArchives(BaseModel):
     """Schema for batch adding archives to a project."""
     """Schema for batch adding archives to a project."""
+
     archive_ids: list[int]
     archive_ids: list[int]
 
 
 
 
 class BatchAddQueueItems(BaseModel):
 class BatchAddQueueItems(BaseModel):
     """Schema for batch adding queue items to a project."""
     """Schema for batch adding queue items to a project."""
+
     queue_item_ids: list[int]
     queue_item_ids: list[int]
+
+
+# Phase 7: BOM Schemas - Tracks sourced/purchased parts
+class BOMItemCreate(BaseModel):
+    """Schema for creating a BOM item."""
+
+    name: str
+    quantity_needed: int = 1
+    unit_price: float | None = None
+    sourcing_url: str | None = None
+    archive_id: int | None = None
+    stl_filename: str | None = None
+    remarks: str | None = None
+
+
+class BOMItemUpdate(BaseModel):
+    """Schema for updating a BOM item."""
+
+    name: str | None = None
+    quantity_needed: int | None = None
+    quantity_acquired: int | None = None
+    unit_price: float | None = None
+    sourcing_url: str | None = None
+    archive_id: int | None = None
+    stl_filename: str | None = None
+    remarks: str | None = None
+
+
+class BOMItemResponse(BaseModel):
+    """Schema for BOM item response."""
+
+    id: int
+    project_id: int
+    name: str
+    quantity_needed: int
+    quantity_acquired: int
+    unit_price: float | None
+    sourcing_url: str | None
+    archive_id: int | None
+    archive_name: str | None = None
+    stl_filename: str | None
+    remarks: str | None
+    sort_order: int
+    is_complete: bool = False
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+# Phase 9: Timeline Schemas
+class TimelineEvent(BaseModel):
+    """Schema for a timeline event."""
+
+    event_type: str  # archive_added, queue_started, queue_completed, status_changed, note_updated
+    timestamp: datetime
+    title: str
+    description: str | None = None
+    metadata: dict | None = None  # Additional event-specific data

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

@@ -1,5 +1,6 @@
 from datetime import datetime
 from datetime import datetime
 from typing import Literal
 from typing import Literal
+
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 
 
 
 
@@ -23,6 +24,8 @@ class SmartPlugBase(BaseModel):
     schedule_enabled: bool = False
     schedule_enabled: bool = False
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
+    # Switchbar visibility
+    show_in_switchbar: bool = False
 
 
 
 
 class SmartPlugCreate(SmartPlugBase):
 class SmartPlugCreate(SmartPlugBase):
@@ -49,6 +52,8 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
+    # Switchbar visibility
+    show_in_switchbar: bool | None = None
 
 
 
 
 class SmartPlugResponse(SmartPlugBase):
 class SmartPlugResponse(SmartPlugBase):
@@ -70,6 +75,7 @@ class SmartPlugControl(BaseModel):
 
 
 class SmartPlugEnergy(BaseModel):
 class SmartPlugEnergy(BaseModel):
     """Energy monitoring data from a smart plug."""
     """Energy monitoring data from a smart plug."""
+
     power: float | None = None  # Current watts
     power: float | None = None  # Current watts
     voltage: float | None = None  # Volts
     voltage: float | None = None  # Volts
     current: float | None = None  # Amps
     current: float | None = None  # Amps

+ 30 - 0
backend/app/schemas/timelapse.py

@@ -0,0 +1,30 @@
+"""Schemas for timelapse video processing."""
+
+from pydantic import BaseModel, Field
+
+
+class TimelapseInfoResponse(BaseModel):
+    """Video metadata response."""
+
+    duration: float = Field(description="Video duration in seconds")
+    width: int = Field(description="Video width in pixels")
+    height: int = Field(description="Video height in pixels")
+    fps: float = Field(description="Frames per second")
+    codec: str = Field(description="Video codec name")
+    file_size: int = Field(description="File size in bytes")
+    has_audio: bool = Field(description="Whether video has audio track")
+
+
+class ThumbnailResponse(BaseModel):
+    """Timeline thumbnail response."""
+
+    thumbnails: list[str] = Field(description="Base64 encoded JPEG thumbnails")
+    timestamps: list[float] = Field(description="Timestamp for each thumbnail in seconds")
+
+
+class ProcessResponse(BaseModel):
+    """Processing result response."""
+
+    status: str = Field(description="Processing status: completed, error")
+    output_path: str | None = Field(default=None, description="Relative path to output file")
+    message: str = Field(description="Status message")

+ 31 - 16
backend/app/services/bambu_mqtt.py

@@ -974,9 +974,10 @@ class BambuMQTTClient:
 
 
         # Standard nozzle fields: these are for the RIGHT/default nozzle on H2D
         # Standard nozzle fields: these are for the RIGHT/default nozzle on H2D
         # For H2D, we use these for nozzle_2 (RIGHT), for others use as nozzle (primary)
         # For H2D, we use these for nozzle_2 (RIGHT), for others use as nozzle (primary)
+        # NOTE: On H2D, nozzle_temper seems to mirror left nozzle - we override with extruder_info[0] later
         if "nozzle_temper" in data:
         if "nozzle_temper" in data:
             if has_h2d_extruder_info:
             if has_h2d_extruder_info:
-                temps["nozzle_2"] = float(data["nozzle_temper"])  # RIGHT nozzle on H2D
+                temps["nozzle_2"] = float(data["nozzle_temper"])  # Will be overridden by extruder_info[0]
             else:
             else:
                 temps["nozzle"] = float(data["nozzle_temper"])
                 temps["nozzle"] = float(data["nozzle_temper"])
         if "nozzle_target_temper" in data:
         if "nozzle_target_temper" in data:
@@ -1089,21 +1090,28 @@ class BambuMQTTClient:
                 extruder_info = extruder_data.get("info", [])
                 extruder_info = extruder_data.get("info", [])
                 if isinstance(extruder_info, list) and len(extruder_info) >= 1:
                 if isinstance(extruder_info, list) and len(extruder_info) >= 1:
                     # H2D nozzle mapping: id=0 is RIGHT nozzle (default), id=1 is LEFT nozzle
                     # H2D nozzle mapping: id=0 is RIGHT nozzle (default), id=1 is LEFT nozzle
-                    # RIGHT nozzle uses standard nozzle_temper/nozzle_target_temper fields (already parsed above)
-                    # LEFT nozzle uses extruder_info[1] - no standard fields available
-                    # Note: hnow/htar flags are unreliable (static values, not actual heating state)
-                    # Real heating indicator: temp > 500 means encoded (target*65536+current)
-                    # Heating = target > 0 AND current < target
-                    # Right nozzle (extruder 0)
-                    if len(extruder_info) >= 1 and "temp" in extruder_info[0]:
-                        temp_val = extruder_info[0]["temp"]
-                        if temp_val > 500:
-                            target = temp_val // 65536
-                            current = temp_val % 65536
-                            temps["nozzle_2_heating"] = target > 0 and current < target
-                        else:
-                            temps["nozzle_2_heating"] = False
-                    # Left nozzle (extruder 1)
+                    # Only parse dual nozzle temps if this is actually a dual nozzle printer (H2D)
+                    # has_h2d_extruder_info requires len(extruder_info) >= 2
+                    if has_h2d_extruder_info:
+                        # Right nozzle (extruder 0) - use extruder_info for actual temp, not nozzle_temper
+                        # nozzle_temper field seems to mirror left nozzle on H2D, so use extruder_info[0]
+                        if "temp" in extruder_info[0]:
+                            temp_val = extruder_info[0]["temp"]
+                            if temp_val > 500:
+                                # Encoded format: temp = target * 65536 + current
+                                target = temp_val // 65536
+                                current = temp_val % 65536
+                                if -50 < current < 500:
+                                    temps["nozzle_2"] = float(current)
+                                if 0 < target < 500:
+                                    temps["nozzle_2_target"] = float(target)
+                                temps["nozzle_2_heating"] = target > 0 and current < target
+                            elif -50 < temp_val < 500:
+                                # Direct Celsius value = heater is OFF
+                                temps["nozzle_2"] = float(temp_val)
+                                temps["nozzle_2_target"] = 0.0
+                                temps["nozzle_2_heating"] = False
+                    # Left nozzle (extruder 1) - only for dual nozzle printers
                     # H2D protocol: temp field encoding depends on value
                     # H2D protocol: temp field encoding depends on value
                     # - When > 500: encoded as (target * 65536 + current) - heater is ON
                     # - When > 500: encoded as (target * 65536 + current) - heater is ON
                     # - When < 500: direct Celsius current temp only - heater is OFF
                     # - When < 500: direct Celsius current temp only - heater is OFF
@@ -1255,6 +1263,13 @@ class BambuMQTTClient:
                     f"[{self.serial_number}] Chamber temp updated to: {self.state.temperatures.get('chamber')}, target: {self.state.temperatures.get('chamber_target')}, heating: {self.state.temperatures.get('chamber_heating')}"
                     f"[{self.serial_number}] Chamber temp updated to: {self.state.temperatures.get('chamber')}, target: {self.state.temperatures.get('chamber_target')}, heating: {self.state.temperatures.get('chamber_heating')}"
                 )
                 )
 
 
+            # Calculate nozzle_heating for single nozzle printers (not set by H2D parsing)
+            # For H2D, nozzle_heating is set in temps dict; for single nozzle, calculate here
+            if "nozzle" in temps and "nozzle_heating" not in temps:
+                current = self.state.temperatures.get("nozzle", 0)
+                target = self.state.temperatures.get("nozzle_target", 0)
+                self.state.temperatures["nozzle_heating"] = target > 0 and current < target
+
         # Parse HMS (Health Management System) errors
         # Parse HMS (Health Management System) errors
         if "hms" in data:
         if "hms" in data:
             hms_list = data["hms"]
             hms_list = data["hms"]

+ 264 - 35
backend/app/services/camera.py

@@ -1,19 +1,25 @@
 """Camera capture service for Bambu Lab printers.
 """Camera capture service for Bambu Lab printers.
 
 
-Captures images from the printer's RTSPS camera stream using ffmpeg.
+Supports two camera protocols:
+- RTSP: Used by X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S (port 322)
+- Chamber Image: Used by A1, A1MINI, P1P, P1S (port 6000, custom binary protocol)
 """
 """
 
 
 import asyncio
 import asyncio
 import logging
 import logging
 import shutil
 import shutil
-from pathlib import Path
-from datetime import datetime
+import ssl
+import struct
 import uuid
 import uuid
-
-from backend.app.core.config import settings
+from datetime import datetime
+from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# JPEG markers
+JPEG_START = b"\xff\xd8"
+JPEG_END = b"\xff\xd9"
+
 # Cache the ffmpeg path after first lookup
 # Cache the ffmpeg path after first lookup
 _ffmpeg_path: str | None = None
 _ffmpeg_path: str | None = None
 
 
@@ -55,26 +61,226 @@ def get_ffmpeg_path() -> str | None:
     return ffmpeg_path
     return ffmpeg_path
 
 
 
 
-def get_camera_port(model: str | None) -> int:
-    """Get the RTSPS port based on printer model.
+def supports_rtsp(model: str | None) -> bool:
+    """Check if printer model supports RTSP camera streaming.
 
 
-    X1 and H2D series use port 322.
-    P1 and A1 series use port 6000.
+    RTSP supported: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
+    Chamber image only: A1, A1MINI, P1P, P1S
     """
     """
     if model:
     if model:
         model_upper = model.upper()
         model_upper = model.upper()
-        if model_upper.startswith(("X1", "H2")):
-            return 322
-    # Default to 6000 for P1/A1 or unknown models
+        # These models support RTSP on port 322
+        if model_upper.startswith(("X1", "H2", "P2")):
+            return True
+    # A1/P1 and unknown models use chamber image protocol
+    return False
+
+
+def get_camera_port(model: str | None) -> int:
+    """Get the camera port based on printer model.
+
+    X1/H2/P2 series use RTSP on port 322.
+    A1/P1 series use chamber image protocol on port 6000.
+    """
+    if supports_rtsp(model):
+        return 322
     return 6000
     return 6000
 
 
 
 
+def is_chamber_image_model(model: str | None) -> bool:
+    """Check if printer uses chamber image protocol instead of RTSP.
+
+    A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000.
+    """
+    return not supports_rtsp(model)
+
+
 def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
 def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
-    """Build the RTSPS URL for the printer camera."""
+    """Build the RTSPS URL for the printer camera (RTSP models only)."""
     port = get_camera_port(model)
     port = get_camera_port(model)
     return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
     return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
 
 
 
 
+def _create_chamber_auth_payload(access_code: str) -> bytes:
+    """Create the 80-byte authentication payload for chamber image protocol.
+
+    Format:
+    - Bytes 0-3: 0x40 0x00 0x00 0x00 (magic)
+    - Bytes 4-7: 0x00 0x30 0x00 0x00 (command)
+    - Bytes 8-15: zeros (padding)
+    - Bytes 16-47: username "bblp" (32 bytes, null-padded)
+    - Bytes 48-79: access code (32 bytes, null-padded)
+    """
+    username = b"bblp"
+    access_code_bytes = access_code.encode("utf-8")
+
+    # Build the 80-byte payload
+    payload = struct.pack(
+        "<II8s32s32s",
+        0x40,  # Magic header
+        0x3000,  # Command
+        b"\x00" * 8,  # Padding
+        username.ljust(32, b"\x00"),  # Username padded to 32 bytes
+        access_code_bytes.ljust(32, b"\x00"),  # Access code padded to 32 bytes
+    )
+    return payload
+
+
+def _create_ssl_context() -> ssl.SSLContext:
+    """Create an SSL context for chamber image connection.
+
+    Bambu printers use self-signed certificates, so we disable verification.
+    """
+    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    return ctx
+
+
+async def read_chamber_image_frame(
+    ip_address: str,
+    access_code: str,
+    timeout: float = 10.0,
+) -> bytes | None:
+    """Read a single JPEG frame from the chamber image protocol.
+
+    This is used by A1/P1 printers which don't support RTSP.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        timeout: Connection timeout in seconds
+
+    Returns:
+        JPEG image data or None if failed
+    """
+    port = 6000
+    ssl_context = _create_ssl_context()
+
+    try:
+        # Connect with SSL
+        reader, writer = await asyncio.wait_for(
+            asyncio.open_connection(ip_address, port, ssl=ssl_context),
+            timeout=timeout,
+        )
+
+        try:
+            # Send authentication payload
+            auth_payload = _create_chamber_auth_payload(access_code)
+            writer.write(auth_payload)
+            await writer.drain()
+
+            # Read the 16-byte header
+            header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
+            if len(header) < 16:
+                logger.error("Chamber image: incomplete header received")
+                return None
+
+            # Parse payload size from header (little-endian uint32 at offset 0)
+            payload_size = struct.unpack("<I", header[0:4])[0]
+
+            if payload_size == 0 or payload_size > 10_000_000:  # Sanity check: max 10MB
+                logger.error(f"Chamber image: invalid payload size {payload_size}")
+                return None
+
+            # Read the JPEG data
+            jpeg_data = await asyncio.wait_for(
+                reader.readexactly(payload_size),
+                timeout=timeout,
+            )
+
+            # Validate JPEG markers
+            if not jpeg_data.startswith(JPEG_START):
+                logger.error("Chamber image: data is not a valid JPEG (missing start marker)")
+                return None
+
+            if not jpeg_data.endswith(JPEG_END):
+                logger.warning("Chamber image: JPEG missing end marker, may be truncated")
+
+            logger.debug(f"Chamber image: received {len(jpeg_data)} bytes")
+            return jpeg_data
+
+        finally:
+            writer.close()
+            try:
+                await writer.wait_closed()
+            except Exception:
+                pass
+
+    except TimeoutError:
+        logger.error(f"Chamber image: connection timeout to {ip_address}:{port}")
+        return None
+    except ConnectionRefusedError:
+        logger.error(f"Chamber image: connection refused by {ip_address}:{port}")
+        return None
+    except Exception as e:
+        logger.exception(f"Chamber image: error connecting to {ip_address}:{port}: {e}")
+        return None
+
+
+async def generate_chamber_image_stream(
+    ip_address: str,
+    access_code: str,
+    fps: int = 5,
+) -> asyncio.StreamReader | None:
+    """Create a persistent connection for streaming chamber images.
+
+    Returns a connected reader or None if connection failed.
+    """
+    port = 6000
+    ssl_context = _create_ssl_context()
+
+    try:
+        reader, writer = await asyncio.wait_for(
+            asyncio.open_connection(ip_address, port, ssl=ssl_context),
+            timeout=10.0,
+        )
+
+        # Send authentication payload
+        auth_payload = _create_chamber_auth_payload(access_code)
+        writer.write(auth_payload)
+        await writer.drain()
+
+        logger.info(f"Chamber image: connected to {ip_address}:{port}")
+        return reader, writer
+
+    except Exception as e:
+        logger.error(f"Chamber image: failed to connect to {ip_address}:{port}: {e}")
+        return None
+
+
+async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes | None:
+    """Read the next JPEG frame from an established chamber image connection."""
+    try:
+        # Read the 16-byte header
+        header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
+
+        # Parse payload size from header (little-endian uint32 at offset 0)
+        payload_size = struct.unpack("<I", header[0:4])[0]
+
+        if payload_size == 0 or payload_size > 10_000_000:
+            logger.error(f"Chamber image: invalid payload size {payload_size}")
+            return None
+
+        # Read the JPEG data
+        jpeg_data = await asyncio.wait_for(
+            reader.readexactly(payload_size),
+            timeout=timeout,
+        )
+
+        return jpeg_data
+
+    except asyncio.IncompleteReadError:
+        logger.warning("Chamber image: connection closed by printer")
+        return None
+    except TimeoutError:
+        logger.warning("Chamber image: read timeout")
+        return None
+    except Exception as e:
+        logger.error(f"Chamber image: error reading frame: {e}")
+        return None
+
+
 async def capture_camera_frame(
 async def capture_camera_frame(
     ip_address: str,
     ip_address: str,
     access_code: str,
     access_code: str,
@@ -84,6 +290,10 @@ async def capture_camera_frame(
 ) -> bool:
 ) -> bool:
     """Capture a single frame from the printer's camera stream.
     """Capture a single frame from the printer's camera stream.
 
 
+    Uses the appropriate protocol based on printer model:
+    - A1/P1: Chamber image protocol (port 6000)
+    - X1/H2/P2: RTSP via ffmpeg (port 322)
+
     Args:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
         access_code: Printer access code
         access_code: Printer access code
@@ -94,39 +304,54 @@ async def capture_camera_frame(
     Returns:
     Returns:
         True if capture was successful, False otherwise
         True if capture was successful, False otherwise
     """
     """
-    camera_url = build_camera_url(ip_address, access_code, model)
-
     # Ensure output directory exists
     # Ensure output directory exists
     output_path.parent.mkdir(parents=True, exist_ok=True)
     output_path.parent.mkdir(parents=True, exist_ok=True)
 
 
+    # Use chamber image protocol for A1/P1 models
+    if is_chamber_image_model(model):
+        logger.info(f"Capturing camera frame from {ip_address} using chamber image protocol (model: {model})")
+        jpeg_data = await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
+        if jpeg_data:
+            try:
+                with open(output_path, "wb") as f:
+                    f.write(jpeg_data)
+                logger.info(f"Successfully captured camera frame: {output_path}")
+                return True
+            except Exception as e:
+                logger.error(f"Failed to write camera frame: {e}")
+                return False
+        return False
+
+    # Use RTSP/ffmpeg for X1/H2/P2 models
+    camera_url = build_camera_url(ip_address, access_code, model)
+
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
         logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
         logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
         return False
         return False
 
 
     # ffmpeg command to capture a single frame from RTSPS stream
     # ffmpeg command to capture a single frame from RTSPS stream
-    # -rtsp_transport tcp: Use TCP for RTSP (more reliable)
-    # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
-    # -y: Overwrite output file
-    # -frames:v 1: Capture only 1 frame
-    # -update 1: Allow writing single image without sequence pattern
-    # -q:v 2: High quality JPEG (1-31, lower is better)
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
         "-y",  # Overwrite output
         "-y",  # Overwrite output
-        "-rtsp_transport", "tcp",
-        "-rtsp_flags", "prefer_tcp",
-        "-i", camera_url,
-        "-frames:v", "1",
-        "-update", "1",
-        "-q:v", "2",
+        "-rtsp_transport",
+        "tcp",
+        "-rtsp_flags",
+        "prefer_tcp",
+        "-i",
+        camera_url,
+        "-frames:v",
+        "1",
+        "-update",
+        "1",
+        "-q:v",
+        "2",
         str(output_path),
         str(output_path),
     ]
     ]
 
 
-    logger.info(f"Capturing camera frame from {ip_address} (model: {model})")
+    logger.info(f"Capturing camera frame from {ip_address} using RTSP (model: {model})")
 
 
     try:
     try:
-        # Run ffmpeg asynchronously with timeout
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
             *cmd,
             *cmd,
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
@@ -134,11 +359,8 @@ async def capture_camera_frame(
         )
         )
 
 
         try:
         try:
-            stdout, stderr = await asyncio.wait_for(
-                process.communicate(),
-                timeout=timeout
-            )
-        except asyncio.TimeoutError:
+            stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+        except TimeoutError:
             process.kill()
             process.kill()
             await process.wait()
             await process.wait()
             logger.error(f"Camera capture timed out after {timeout}s")
             logger.error(f"Camera capture timed out after {timeout}s")
@@ -234,7 +456,14 @@ async def test_camera_connection(
         if success:
         if success:
             return {"success": True, "message": "Camera connection successful"}
             return {"success": True, "message": "Camera connection successful"}
         else:
         else:
-            return {"success": False, "error": "Failed to capture frame from camera"}
+            return {
+                "success": False,
+                "error": (
+                    "Failed to capture frame from camera. "
+                    "Ensure the printer is powered on, camera is enabled, and LAN mode is active. "
+                    "If running in Docker, try 'network_mode: host' in docker-compose.yml."
+                ),
+            }
     finally:
     finally:
         # Clean up test file
         # Clean up test file
         if test_path.exists():
         if test_path.exists():

+ 678 - 0
backend/app/services/discovery.py

@@ -0,0 +1,678 @@
+"""
+Bambu Lab printer discovery service using SSDP and subnet scanning.
+
+Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
+on the local network. This service listens for these advertisements and provides
+a list of discovered printers.
+
+For Docker environments where SSDP multicast doesn't work, subnet scanning is
+available as an alternative discovery method.
+"""
+
+import asyncio
+import ipaddress
+import logging
+import os
+import re
+import socket
+import struct
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def is_running_in_docker() -> bool:
+    """Detect if we're running inside a Docker container."""
+    # Check for .dockerenv file
+    if Path("/.dockerenv").exists():
+        return True
+
+    # Check cgroup for docker/containerd
+    try:
+        with open("/proc/1/cgroup") as f:
+            content = f.read()
+            if "docker" in content or "containerd" in content or "kubepods" in content:
+                return True
+    except (FileNotFoundError, PermissionError):
+        pass
+
+    # Check for container environment variable
+    return bool(os.environ.get("CONTAINER") or os.environ.get("DOCKER_CONTAINER"))
+
+
+# SSDP multicast address - Bambu uses port 2021, not standard 1900
+SSDP_ADDR = "239.255.255.250"
+SSDP_PORT = 2021  # Bambu Lab uses non-standard port
+
+# Bambu Lab SSDP search target
+BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
+
+# SSDP M-SEARCH message
+SSDP_MSEARCH = (
+    "M-SEARCH * HTTP/1.1\r\n"
+    f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
+    'MAN: "ssdp:discover"\r\n'
+    "MX: 3\r\n"
+    f"ST: {BAMBU_SEARCH_TARGET}\r\n"
+    "\r\n"
+)
+
+
+@dataclass
+class DiscoveredPrinter:
+    """Represents a discovered Bambu Lab printer."""
+
+    serial: str
+    name: str
+    ip_address: str
+    model: str | None = None
+    discovered_at: str | None = None
+
+    def to_dict(self) -> dict:
+        return {
+            "serial": self.serial,
+            "name": self.name,
+            "ip_address": self.ip_address,
+            "model": self.model,
+            "discovered_at": self.discovered_at,
+        }
+
+
+class PrinterDiscoveryService:
+    """Service for discovering Bambu Lab printers on the network."""
+
+    def __init__(self):
+        self._discovered: dict[str, DiscoveredPrinter] = {}
+        self._running = False
+        self._task: asyncio.Task | None = None
+
+    @property
+    def is_running(self) -> bool:
+        return self._running
+
+    @property
+    def discovered_printers(self) -> list[DiscoveredPrinter]:
+        return list(self._discovered.values())
+
+    def clear(self):
+        """Clear discovered printers."""
+        self._discovered.clear()
+
+    async def start(self, duration: float = 10.0):
+        """Start discovery for a specified duration."""
+        if self._running:
+            return
+
+        self._running = True
+        self._discovered.clear()
+        self._task = asyncio.create_task(self._discover(duration))
+
+    async def stop(self):
+        """Stop discovery."""
+        self._running = False
+        if self._task and not self._task.done():
+            self._task.cancel()
+            try:
+                await self._task
+            except asyncio.CancelledError:
+                pass
+        self._task = None
+
+    async def _discover(self, duration: float):
+        """Run discovery for the specified duration.
+
+        Bambu printers broadcast NOTIFY messages periodically on port 2021.
+        We need to bind to that port and listen for broadcasts.
+        """
+        sock = None
+        try:
+            # Create UDP socket for SSDP
+            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+            # Try to set SO_REUSEPORT if available (Linux/macOS)
+            try:
+                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+            except (AttributeError, OSError):
+                pass
+
+            # Set non-blocking mode
+            sock.setblocking(False)
+
+            # Bind to the SSDP port to receive NOTIFY broadcasts from printers
+            sock.bind(("", SSDP_PORT))
+
+            # Join multicast group to receive multicast messages
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+
+            # Enable broadcast
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info(f"Starting SSDP discovery on port {SSDP_PORT} for Bambu Lab printers...")
+
+            # Send initial M-SEARCH request to trigger responses
+            try:
+                sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
+            except Exception as e:
+                logger.debug(f"M-SEARCH send error: {e}")
+
+            start_time = asyncio.get_event_loop().time()
+            last_send = start_time
+
+            while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
+                # Try to receive data
+                try:
+                    data, addr = sock.recvfrom(4096)
+                    message = data.decode("utf-8", errors="ignore")
+                    logger.debug(f"Received from {addr[0]}: {message[:100]}...")
+                    self._handle_response(message, addr[0])
+                except BlockingIOError:
+                    # No data available, that's fine
+                    pass
+                except Exception as e:
+                    logger.debug(f"SSDP receive error: {e}")
+
+                # Re-send M-SEARCH every 3 seconds
+                now = asyncio.get_event_loop().time()
+                if now - last_send >= 3.0:
+                    try:
+                        sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
+                        last_send = now
+                    except Exception as e:
+                        logger.debug(f"SSDP send error: {e}")
+
+                await asyncio.sleep(0.1)
+
+            logger.info(f"Discovery complete. Found {len(self._discovered)} printers.")
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.warning(f"Port {SSDP_PORT} is in use, trying alternative discovery...")
+                await self._discover_alternative(duration)
+            else:
+                logger.error(f"Discovery error: {e}")
+        except Exception as e:
+            logger.error(f"Discovery error: {e}")
+        finally:
+            self._running = False
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+
+    async def _discover_alternative(self, duration: float):
+        """Alternative discovery using a random port (less reliable)."""
+        sock = None
+        try:
+            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            sock.setblocking(False)
+            sock.bind(("", 0))
+
+            # Join multicast group
+            mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
+            sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+            logger.info("Using alternative discovery method...")
+
+            start_time = asyncio.get_event_loop().time()
+            last_send = start_time
+
+            while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
+                try:
+                    data, addr = sock.recvfrom(4096)
+                    self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
+                except BlockingIOError:
+                    pass
+                except Exception as e:
+                    logger.debug(f"SSDP receive error: {e}")
+
+                now = asyncio.get_event_loop().time()
+                if now - last_send >= 2.0:
+                    try:
+                        sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
+                        last_send = now
+                    except Exception:
+                        pass
+
+                await asyncio.sleep(0.1)
+
+            logger.info(f"Alternative discovery complete. Found {len(self._discovered)} printers.")
+        except Exception as e:
+            logger.error(f"Alternative discovery error: {e}")
+        finally:
+            if sock:
+                try:
+                    sock.close()
+                except Exception:
+                    pass
+
+    def _handle_response(self, response: str, ip_address: str):
+        """Parse SSDP response and extract printer info."""
+        # Check if it's a Bambu Lab printer response
+        if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
+            logger.debug(f"Ignoring non-Bambu response from {ip_address}")
+            return
+
+        # Extract USN (Unique Service Name) which contains the serial
+        # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
+        usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
+        if not usn_match:
+            logger.debug(f"No USN found in response from {ip_address}")
+            return
+
+        serial = usn_match.group(1).strip()
+
+        # Extract device name from LOCATION or DevName header
+        name = serial  # Default to serial if no name found
+        name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+        if name_match:
+            name = name_match.group(1).strip()
+
+        # Try to extract model from DevModel header
+        model = None
+        model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+        if model_match:
+            model = model_match.group(1).strip()
+
+        # Also try NT header for model
+        if not model:
+            nt_match = re.search(r"NT:\s*urn:bambulab-com:device:([^:]+)", response, re.IGNORECASE)
+            if nt_match:
+                model = nt_match.group(1).strip()
+
+        # Skip if already discovered
+        if serial in self._discovered:
+            return
+
+        printer = DiscoveredPrinter(
+            serial=serial,
+            name=name,
+            ip_address=ip_address,
+            model=model,
+            discovered_at=datetime.now().isoformat(),
+        )
+
+        self._discovered[serial] = printer
+        logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
+
+
+class SubnetScanner:
+    """Scanner for discovering Bambu printers by probing IP addresses."""
+
+    # Bambu printer ports
+    MQTT_PORT = 8883
+    FTP_PORT = 990
+
+    def __init__(self):
+        self._discovered: dict[str, DiscoveredPrinter] = {}
+        self._running = False
+        self._scanned = 0
+        self._total = 0
+
+    @property
+    def is_running(self) -> bool:
+        return self._running
+
+    @property
+    def discovered_printers(self) -> list[DiscoveredPrinter]:
+        return list(self._discovered.values())
+
+    @property
+    def progress(self) -> tuple[int, int]:
+        """Return (scanned, total) counts."""
+        return self._scanned, self._total
+
+    async def scan_subnet(self, subnet: str, timeout: float = 1.0) -> list[DiscoveredPrinter]:
+        """Scan a subnet for Bambu printers.
+
+        Args:
+            subnet: CIDR notation subnet (e.g., "192.168.1.0/24")
+            timeout: Connection timeout per host in seconds
+
+        Returns:
+            List of discovered printers
+        """
+        if self._running:
+            return []
+
+        self._running = True
+        self._discovered.clear()
+        self._scanned = 0
+
+        try:
+            network = ipaddress.ip_network(subnet, strict=False)
+            hosts = list(network.hosts())
+            self._total = len(hosts)
+
+            if self._total > 1024:
+                logger.warning(f"Subnet {subnet} has {self._total} hosts, limiting to /22 (1024 hosts)")
+                self._total = 1024
+                hosts = hosts[:1024]
+
+            logger.info(f"Starting subnet scan of {subnet} ({self._total} hosts)")
+
+            # Scan in batches to avoid overwhelming the network
+            batch_size = 50
+            for i in range(0, len(hosts), batch_size):
+                if not self._running:
+                    break
+
+                batch = hosts[i : i + batch_size]
+                tasks = [self._probe_host(str(ip), timeout) for ip in batch]
+                await asyncio.gather(*tasks, return_exceptions=True)
+                self._scanned = min(i + batch_size, len(hosts))
+
+            logger.info(f"Subnet scan complete. Found {len(self._discovered)} printers.")
+            return self.discovered_printers
+
+        except ValueError as e:
+            logger.error(f"Invalid subnet format: {e}")
+            return []
+        finally:
+            self._running = False
+
+    async def _probe_host(self, ip: str, timeout: float):
+        """Probe a single host for Bambu printer ports."""
+        # Check FTP port (990) - more reliable indicator
+        ftp_open = await self._check_port(ip, self.FTP_PORT, timeout)
+        if not ftp_open:
+            return
+
+        # Also check MQTT port (8883) for confirmation
+        mqtt_open = await self._check_port(ip, self.MQTT_PORT, timeout)
+        if not mqtt_open:
+            return
+
+        # Both ports open - likely a Bambu printer
+        logger.info(f"Found potential Bambu printer at {ip}")
+
+        # Try to get printer info via SSDP unicast
+        serial, name, model = await self._get_printer_info_ssdp(ip, timeout)
+
+        printer = DiscoveredPrinter(
+            serial=serial or f"unknown-{ip.replace('.', '-')}",
+            name=name or f"Printer at {ip}",
+            ip_address=ip,
+            model=model,
+            discovered_at=datetime.now().isoformat(),
+        )
+        self._discovered[ip] = printer
+
+    async def _get_printer_info_ssdp(self, ip: str, timeout: float) -> tuple[str | None, str | None, str | None]:
+        """Try to get printer info via SSDP unicast query."""
+        loop = asyncio.get_event_loop()
+
+        def _query():
+            try:
+                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+                sock.settimeout(timeout)
+                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+                # Send M-SEARCH directly to the printer
+                msearch = (
+                    "M-SEARCH * HTTP/1.1\r\n"
+                    f"HOST: {ip}:{SSDP_PORT}\r\n"
+                    'MAN: "ssdp:discover"\r\n'
+                    "MX: 1\r\n"
+                    f"ST: {BAMBU_SEARCH_TARGET}\r\n"
+                    "\r\n"
+                )
+                sock.sendto(msearch.encode(), (ip, SSDP_PORT))
+
+                # Wait for response
+                data, _ = sock.recvfrom(4096)
+                response = data.decode("utf-8", errors="ignore")
+                sock.close()
+
+                # Parse response
+                serial = None
+                name = None
+                model = None
+
+                usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
+                if usn_match:
+                    serial = usn_match.group(1).strip()
+
+                name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+                if name_match:
+                    name = name_match.group(1).strip()
+
+                model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
+                if model_match:
+                    model = model_match.group(1).strip()
+
+                logger.debug(f"SSDP info from {ip}: serial={serial}, name={name}, model={model}")
+                return serial, name, model
+
+            except Exception as e:
+                logger.debug(f"SSDP query to {ip} failed: {e}")
+                return None, None, None
+
+        return await loop.run_in_executor(None, _query)
+
+    async def _check_port(self, ip: str, port: int, timeout: float) -> bool:
+        """Check if a port is open on the given IP."""
+        try:
+            _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
+            writer.close()
+            await writer.wait_closed()
+            logger.debug(f"Port {port} open on {ip}")
+            return True
+        except TimeoutError:
+            return False
+        except ConnectionRefusedError:
+            return False
+        except OSError as e:
+            # Log first few errors to help debug network issues
+            if self._scanned < 5:
+                logger.debug(f"OSError checking {ip}:{port}: {e}")
+            return False
+
+    def stop(self):
+        """Stop the current scan."""
+        self._running = False
+
+
+class TasmotaScanner:
+    """Scanner for discovering Tasmota devices by probing IP addresses."""
+
+    HTTP_PORT = 80
+
+    def __init__(self):
+        self._discovered: dict[str, dict] = {}
+        self._running = False
+        self._scanned = 0
+        self._total = 0
+
+    @property
+    def is_running(self) -> bool:
+        return self._running
+
+    @property
+    def discovered_devices(self) -> list[dict]:
+        return list(self._discovered.values())
+
+    @property
+    def progress(self) -> tuple[int, int]:
+        """Return (scanned, total) counts."""
+        return self._scanned, self._total
+
+    async def scan_range(self, from_ip: str, to_ip: str, timeout: float = 1.0) -> list[dict]:
+        """Scan an IP range for Tasmota devices.
+
+        Args:
+            from_ip: Starting IP address (e.g., "192.168.1.1")
+            to_ip: Ending IP address (e.g., "192.168.1.254")
+            timeout: Connection timeout per host in seconds
+
+        Returns:
+            List of discovered Tasmota devices
+        """
+        if self._running:
+            return []
+
+        self._running = True
+        self._discovered.clear()
+        self._scanned = 0
+
+        try:
+            start = ipaddress.ip_address(from_ip)
+            end = ipaddress.ip_address(to_ip)
+
+            # Generate list of IPs in range
+            hosts = []
+            current = start
+            while current <= end:
+                hosts.append(str(current))
+                current = ipaddress.ip_address(int(current) + 1)
+
+            self._total = len(hosts)
+
+            if self._total > 1024:
+                logger.warning(f"IP range has {self._total} hosts, limiting to 1024")
+                self._total = 1024
+                hosts = hosts[:1024]
+
+            logger.info(f"Starting Tasmota scan from {from_ip} to {to_ip} ({self._total} hosts)")
+
+            # Scan in batches to avoid overwhelming the network
+            batch_size = 50
+            for i in range(0, len(hosts), batch_size):
+                if not self._running:
+                    logger.info("Tasmota scan stopped by user")
+                    break
+
+                batch = hosts[i : i + batch_size]
+                tasks = [self._probe_host(ip) for ip in batch]
+                try:
+                    await asyncio.gather(*tasks, return_exceptions=True)
+                except Exception as e:
+                    logger.warning(f"Batch {i//batch_size} error: {e}")
+                self._scanned = min(i + batch_size, len(hosts))
+
+            logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
+            return self.discovered_devices
+
+        except ValueError as e:
+            logger.error(f"Invalid IP address format: {e}")
+            return []
+        finally:
+            self._running = False
+
+    async def _probe_host(self, ip: str):
+        """Probe a single host for Tasmota HTTP API."""
+        try:
+            # Hard timeout of 5 seconds max per host
+            await asyncio.wait_for(self._do_probe(ip), timeout=5.0)
+        except TimeoutError:
+            pass
+        except Exception:
+            pass
+
+    async def _do_probe(self, ip: str):
+        """Actually probe the host."""
+        import httpx
+
+        try:
+            # Reasonable timeouts for network scanning
+            client_timeout = httpx.Timeout(3.0, connect=1.0)
+            async with httpx.AsyncClient(timeout=client_timeout, follow_redirects=False) as client:
+                # First try simple Power command - most reliable indicator of Tasmota
+                power_url = f"http://{ip}/cm?cmnd=Power"
+                try:
+                    power_response = await client.get(power_url)
+                    if power_response.status_code == 401:
+                        # Device requires auth - still a Tasmota device!
+                        logger.info(f"Discovered Tasmota at {ip} (requires auth - 401)")
+                        device = {
+                            "ip_address": ip,
+                            "name": f"Tasmota ({ip})",
+                            "module": None,
+                            "state": "UNKNOWN",
+                            "discovered_at": datetime.now().isoformat(),
+                        }
+                        self._discovered[ip] = device
+                        return
+
+                    if power_response.status_code != 200:
+                        return
+
+                    power_data = power_response.json()
+
+                    # Check for Tasmota auth warning (returns 200 with WARNING)
+                    if "WARNING" in power_data:
+                        logger.info(f"Discovered Tasmota at {ip} (requires auth)")
+                        device = {
+                            "ip_address": ip,
+                            "name": f"Tasmota ({ip})",
+                            "module": None,
+                            "state": "UNKNOWN",
+                            "discovered_at": datetime.now().isoformat(),
+                        }
+                        self._discovered[ip] = device
+                        return
+
+                    # Check if response looks like Tasmota (has POWER or POWER1 key)
+                    power_state = power_data.get("POWER") or power_data.get("POWER1")
+                    if power_state is None:
+                        return
+
+                except Exception as e:
+                    logger.debug(f"Error probing {ip}: {e}")
+                    return
+
+                # It's a Tasmota device! Now get more info
+                device_name = f"Tasmota ({ip})"
+                module = None
+
+                # Try to get device name from Status 0
+                try:
+                    status_url = f"http://{ip}/cm?cmnd=Status%200"
+                    status_response = await client.get(status_url)
+                    if status_response.status_code == 200:
+                        status_data = status_response.json()
+                        if "Status" in status_data:
+                            status = status_data["Status"]
+                            device_name = status.get("DeviceName") or device_name
+                            if not device_name or device_name == f"Tasmota ({ip})":
+                                # Try FriendlyName
+                                friendly = status.get("FriendlyName")
+                                if friendly and isinstance(friendly, list) and friendly[0]:
+                                    device_name = friendly[0]
+                            module = status.get("Module")
+                except Exception:
+                    pass
+
+                device = {
+                    "ip_address": ip,
+                    "name": device_name,
+                    "module": module,
+                    "state": power_state,
+                    "discovered_at": datetime.now().isoformat(),
+                }
+
+                self._discovered[ip] = device
+                logger.info(f"Discovered Tasmota device: {device_name} at {ip}")
+
+        except httpx.TimeoutException:
+            pass
+        except httpx.ConnectError:
+            pass
+        except Exception:
+            pass
+
+    def stop(self):
+        """Stop the current scan."""
+        self._running = False
+
+
+# Global instances
+discovery_service = PrinterDiscoveryService()
+subnet_scanner = SubnetScanner()
+tasmota_scanner = TasmotaScanner()

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

@@ -5,7 +5,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
-from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState
+from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState, get_stage_name
 
 
 
 
 class PrinterManager:
 class PrinterManager:
@@ -280,6 +280,57 @@ class PrinterManager:
         return result
         return result
 
 
 
 
+def get_derived_status_name(state: PrinterState) -> str | None:
+    """
+    Compute a human-readable status name based on printer state.
+
+    Uses stg_cur when available, otherwise derives status from temperature data
+    when the printer is heating before a print starts.
+    """
+    # If we have a valid calibration stage, use it
+    # X1 models use -1 for idle, A1/P1 models use 255 for idle
+    # Valid stage numbers are 0-254
+    if 0 <= state.stg_cur < 255:
+        return get_stage_name(state.stg_cur)
+
+    # If not in RUNNING state, no derived status needed
+    if state.state != "RUNNING":
+        return None
+
+    # Check if we're in an early phase where temperatures are heating
+    temps = state.temperatures or {}
+    progress = state.progress or 0
+
+    # Only derive heating status when progress is very low (< 2%)
+    # This indicates we're in the preparation phase, not actually printing
+    if progress >= 2:
+        return None
+
+    # Check bed temperature - if target is set and current is significantly below
+    bed_temp = temps.get("bed", 0)
+    bed_target = temps.get("bed_target", 0)
+
+    # Check nozzle temperature
+    nozzle_temp = temps.get("nozzle", 0)
+    nozzle_target = temps.get("nozzle_target", 0)
+
+    # Temperature thresholds: consider "heating" if more than 10°C below target
+    TEMP_THRESHOLD = 10
+
+    # Determine what's heating (prioritize bed since it takes longer)
+    if bed_target > 30 and (bed_target - bed_temp) > TEMP_THRESHOLD:
+        return "Heating heatbed"
+    elif nozzle_target > 30 and (nozzle_target - nozzle_temp) > TEMP_THRESHOLD:
+        return "Heating nozzle"
+
+    # If targets are set but we're close to them, we might be in final prep
+    if bed_target > 30 or nozzle_target > 30:
+        if progress == 0 and state.layer_num == 0:
+            return "Preparing"
+
+    return None
+
+
 def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
 def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
     """Convert PrinterState to a JSON-serializable dict."""
     """Convert PrinterState to a JSON-serializable dict."""
     # Parse AMS data from raw_data
     # Parse AMS data from raw_data
@@ -383,6 +434,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "ams_extruder_map": ams_extruder_map,
         "ams_extruder_map": ams_extruder_map,
         # WiFi signal strength
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
         "wifi_signal": state.wifi_signal,
+        # Calibration stage tracking
+        "stg_cur": state.stg_cur,
+        "stg_cur_name": get_derived_status_name(state),
     }
     }
     # Add cover URL if there's an active print and printer_id is provided
     # Add cover URL if there's an active print and printer_id is provided
     if printer_id and state.state == "RUNNING" and state.gcode_file:
     if printer_id and state.state == "RUNNING" and state.gcode_file:

+ 264 - 0
backend/app/services/timelapse_processor.py

@@ -0,0 +1,264 @@
+"""Timelapse video processing service using FFmpeg."""
+
+import asyncio
+import json
+import logging
+import tempfile
+from pathlib import Path
+
+from backend.app.services.camera import get_ffmpeg_path
+
+logger = logging.getLogger(__name__)
+
+
+class TimelapseProcessor:
+    """Service for processing timelapse videos with FFmpeg."""
+
+    def __init__(self, input_path: Path):
+        self.input_path = input_path
+        self.ffmpeg = get_ffmpeg_path()
+        if not self.ffmpeg:
+            raise RuntimeError("FFmpeg not found")
+        # Derive ffprobe path from ffmpeg path
+        self.ffprobe = self.ffmpeg.replace("ffmpeg", "ffprobe")
+
+    async def get_info(self) -> dict:
+        """Get video metadata using ffprobe."""
+        cmd = [
+            self.ffprobe,
+            "-v",
+            "quiet",
+            "-print_format",
+            "json",
+            "-show_format",
+            "-show_streams",
+            str(self.input_path),
+        ]
+
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.error(f"ffprobe failed: {stderr.decode()}")
+            raise RuntimeError(f"ffprobe failed: {stderr.decode()}")
+
+        data = json.loads(stdout.decode())
+        video_stream = next(
+            (s for s in data.get("streams", []) if s.get("codec_type") == "video"),
+            {},
+        )
+        audio_stream = next(
+            (s for s in data.get("streams", []) if s.get("codec_type") == "audio"),
+            None,
+        )
+
+        # Parse frame rate (can be "30/1" or "29.97")
+        fps = 30.0
+        r_frame_rate = video_stream.get("r_frame_rate", "30/1")
+        try:
+            if "/" in r_frame_rate:
+                num, den = r_frame_rate.split("/")
+                fps = float(num) / float(den)
+            else:
+                fps = float(r_frame_rate)
+        except (ValueError, ZeroDivisionError):
+            pass
+
+        return {
+            "duration": float(data.get("format", {}).get("duration", 0)),
+            "width": video_stream.get("width", 0),
+            "height": video_stream.get("height", 0),
+            "fps": fps,
+            "codec": video_stream.get("codec_name", "unknown"),
+            "file_size": int(data.get("format", {}).get("size", 0)),
+            "has_audio": audio_stream is not None,
+        }
+
+    async def generate_thumbnails(
+        self,
+        count: int = 10,
+        width: int = 160,
+    ) -> list[tuple[float, bytes]]:
+        """Generate evenly-spaced thumbnail frames."""
+        info = await self.get_info()
+        duration = info["duration"]
+
+        if duration <= 0:
+            return []
+
+        interval = duration / max(count, 1)
+        thumbnails = []
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            for i in range(count):
+                timestamp = i * interval
+                output_path = Path(tmpdir) / f"thumb_{i:03d}.jpg"
+
+                cmd = [
+                    self.ffmpeg,
+                    "-y",
+                    "-ss",
+                    str(timestamp),
+                    "-i",
+                    str(self.input_path),
+                    "-vframes",
+                    "1",
+                    "-vf",
+                    f"scale={width}:-1",
+                    "-q:v",
+                    "5",
+                    str(output_path),
+                ]
+
+                process = await asyncio.create_subprocess_exec(
+                    *cmd,
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.PIPE,
+                )
+                await process.communicate()
+
+                if output_path.exists():
+                    thumbnails.append((timestamp, output_path.read_bytes()))
+
+        return thumbnails
+
+    async def process(
+        self,
+        output_path: Path,
+        trim_start: float = 0,
+        trim_end: float | None = None,
+        speed: float = 1.0,
+        audio_path: Path | None = None,
+        audio_volume: float = 1.0,
+    ) -> bool:
+        """Process video with trim, speed, and optional audio overlay.
+
+        Args:
+            output_path: Where to save the processed video
+            trim_start: Start time in seconds
+            trim_end: End time in seconds (None = full duration)
+            speed: Speed multiplier (0.25 to 4.0)
+            audio_path: Optional music file to overlay
+            audio_volume: Volume for audio overlay (0.0 to 1.0)
+
+        Returns:
+            True if processing succeeded, False otherwise
+        """
+        # Build FFmpeg command
+        cmd = [self.ffmpeg, "-y"]
+
+        # Input seeking (fast seek before input)
+        if trim_start > 0:
+            cmd.extend(["-ss", str(trim_start)])
+
+        cmd.extend(["-i", str(self.input_path)])
+
+        # Add audio input if provided
+        if audio_path:
+            cmd.extend(["-i", str(audio_path)])
+
+        # Duration limit
+        if trim_end is not None and trim_end > trim_start:
+            duration = trim_end - trim_start
+            cmd.extend(["-t", str(duration)])
+
+        # Build filters - use filter_complex when we have audio overlay
+        video_filter = ""
+        if speed != 1.0:
+            # setpts changes video speed: PTS/speed = faster, PTS*speed = slower
+            setpts_value = 1.0 / speed
+            video_filter = f"setpts={setpts_value}*PTS"
+
+        if audio_path:
+            # Use filter_complex for audio overlay (can't mix with -vf/-af)
+            filter_parts = []
+
+            # Video filter
+            if video_filter:
+                filter_parts.append(f"[0:v]{video_filter}[v]")
+                video_out = "[v]"
+            else:
+                video_out = "0:v"
+
+            # Audio filter with volume
+            filter_parts.append(f"[1:a]volume={audio_volume}[a]")
+
+            cmd.extend(["-filter_complex", ";".join(filter_parts)])
+            cmd.extend(["-map", video_out, "-map", "[a]"])
+            cmd.extend(["-shortest"])
+        elif speed != 1.0:
+            # No audio overlay - use simple -vf and -af
+            if video_filter:
+                cmd.extend(["-vf", video_filter])
+            # Adjust original audio speed with atempo
+            atempo_chain = self._build_atempo_chain(speed)
+            if atempo_chain:
+                cmd.extend(["-af", atempo_chain])
+
+        # Output settings
+        cmd.extend(
+            [
+                "-c:v",
+                "libx264",
+                "-preset",
+                "fast",
+                "-crf",
+                "23",
+                "-c:a",
+                "aac",
+                "-b:a",
+                "128k",
+                "-movflags",
+                "+faststart",  # Enable streaming
+                str(output_path),
+            ]
+        )
+
+        logger.info(f"Processing timelapse: {' '.join(cmd)}")
+
+        # Run FFmpeg
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        _, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.error(f"FFmpeg processing failed: {stderr.decode()}")
+            return False
+
+        return output_path.exists()
+
+    def _build_atempo_chain(self, speed: float) -> str:
+        """Build atempo filter chain.
+
+        atempo filter only supports values between 0.5 and 2.0,
+        so we chain multiple filters for extreme speeds.
+        """
+        if speed == 1.0:
+            return ""
+
+        filters = []
+        remaining_speed = speed
+
+        # Handle speeds > 2.0 by chaining atempo=2.0
+        while remaining_speed > 2.0:
+            filters.append("atempo=2.0")
+            remaining_speed /= 2.0
+
+        # Handle speeds < 0.5 by chaining atempo=0.5
+        while remaining_speed < 0.5:
+            filters.append("atempo=0.5")
+            remaining_speed *= 2.0
+
+        # Add final atempo for remaining adjustment
+        if 0.5 <= remaining_speed <= 2.0 and remaining_speed != 1.0:
+            filters.append(f"atempo={remaining_speed:.4f}")
+
+        return ",".join(filters)

+ 144 - 0
backend/tests/integration/test_discovery_api.py

@@ -0,0 +1,144 @@
+"""Integration tests for Discovery API endpoints.
+
+Tests the full request/response cycle for /api/v1/discovery/ endpoints.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestDiscoveryAPI:
+    """Integration tests for /api/v1/discovery/ endpoints."""
+
+    # ========================================================================
+    # Info endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_discovery_info(self, async_client: AsyncClient):
+        """Verify discovery info endpoint returns expected fields."""
+        response = await async_client.get("/api/v1/discovery/info")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "is_docker" in data
+        assert "ssdp_running" in data
+        assert "scan_running" in data
+        assert isinstance(data["is_docker"], bool)
+        assert isinstance(data["ssdp_running"], bool)
+        assert isinstance(data["scan_running"], bool)
+
+    # ========================================================================
+    # SSDP Discovery endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_discovery_status(self, async_client: AsyncClient):
+        """Verify SSDP discovery status endpoint works."""
+        response = await async_client.get("/api/v1/discovery/status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert isinstance(data["running"], bool)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_discovery(self, async_client: AsyncClient):
+        """Verify SSDP discovery can be started."""
+        response = await async_client.post("/api/v1/discovery/start?duration=1.0")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_discovery(self, async_client: AsyncClient):
+        """Verify SSDP discovery can be stopped."""
+        response = await async_client.post("/api/v1/discovery/stop")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert data["running"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_discovered_printers_empty(self, async_client: AsyncClient):
+        """Verify empty list when no printers discovered."""
+        response = await async_client.get("/api/v1/discovery/printers")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+    # ========================================================================
+    # Subnet scanning endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_subnet_scan(self, async_client: AsyncClient):
+        """Verify subnet scan can be started."""
+        response = await async_client.post(
+            "/api/v1/discovery/scan",
+            json={"subnet": "192.168.1.0/30", "timeout": 0.1},  # Small subnet for testing
+        )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_scan_status(self, async_client: AsyncClient):
+        """Verify subnet scan status endpoint works."""
+        response = await async_client.get("/api/v1/discovery/scan/status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_subnet_scan(self, async_client: AsyncClient):
+        """Verify subnet scan can be stopped."""
+        response = await async_client.post("/api/v1/discovery/scan/stop")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_subnet_scan_invalid_subnet(self, async_client: AsyncClient):
+        """Verify invalid subnet format is rejected."""
+        response = await async_client.post("/api/v1/discovery/scan", json={"subnet": "invalid-subnet", "timeout": 1.0})
+
+        # Should return 422 validation error or 200 with empty results
+        assert response.status_code in [200, 422]
+
+
+class TestDiscoveryService:
+    """Unit tests for discovery service functionality."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_docker_detection_fields(self, async_client: AsyncClient):
+        """Verify Docker detection returns consistent response."""
+        # Call multiple times to ensure consistency
+        response1 = await async_client.get("/api/v1/discovery/info")
+        response2 = await async_client.get("/api/v1/discovery/info")
+
+        assert response1.status_code == 200
+        assert response2.status_code == 200
+        assert response1.json()["is_docker"] == response2.json()["is_docker"]

+ 90 - 69
backend/tests/integration/test_smart_plugs_api.py

@@ -25,11 +25,9 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_list_smart_plugs_with_data(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_list_smart_plugs_with_data(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify list returns existing plugs."""
         """Verify list returns existing plugs."""
-        plug = await smart_plug_factory(name="Test Plug 1")
+        await smart_plug_factory(name="Test Plug 1")
 
 
         response = await async_client.get("/api/v1/smart-plugs/")
         response = await async_client.get("/api/v1/smart-plugs/")
 
 
@@ -64,9 +62,7 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_create_smart_plug_with_printer(
-        self, async_client: AsyncClient, printer_factory, db_session
-    ):
+    async def test_create_smart_plug_with_printer(self, async_client: AsyncClient, printer_factory, db_session):
         """Verify smart plug can be linked to a printer."""
         """Verify smart plug can be linked to a printer."""
         printer = await printer_factory(name="Test Printer")
         printer = await printer_factory(name="Test Printer")
 
 
@@ -84,9 +80,7 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_create_plug_with_invalid_printer_id(
-        self, async_client: AsyncClient
-    ):
+    async def test_create_plug_with_invalid_printer_id(self, async_client: AsyncClient):
         """Verify creating plug with non-existent printer fails."""
         """Verify creating plug with non-existent printer fails."""
         data = {
         data = {
             "name": "Test Plug",
             "name": "Test Plug",
@@ -105,9 +99,7 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_get_smart_plug(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_get_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify single plug can be retrieved."""
         """Verify single plug can be retrieved."""
         plug = await smart_plug_factory(name="Get Test Plug")
         plug = await smart_plug_factory(name="Get Test Plug")
 
 
@@ -132,9 +124,7 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_auto_off_toggle(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_auto_off_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """CRITICAL: Verify auto_off toggle persists correctly.
         """CRITICAL: Verify auto_off toggle persists correctly.
 
 
         This tests the regression scenario where toggling auto_off
         This tests the regression scenario where toggling auto_off
@@ -149,10 +139,7 @@ class TestSmartPlugsAPI:
         assert response.json()["auto_off"] is True
         assert response.json()["auto_off"] is True
 
 
         # Toggle auto_off to False
         # Toggle auto_off to False
-        response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"auto_off": False}
-        )
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"auto_off": False})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["auto_off"] is False
         assert response.json()["auto_off"] is False
@@ -163,16 +150,11 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_auto_on_toggle(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_auto_on_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify auto_on toggle persists correctly."""
         """Verify auto_on toggle persists correctly."""
         plug = await smart_plug_factory(auto_on=True)
         plug = await smart_plug_factory(auto_on=True)
 
 
-        response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"auto_on": False}
-        )
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"auto_on": False})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["auto_on"] is False
         assert response.json()["auto_on"] is False
@@ -183,31 +165,23 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_enabled_toggle(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_enabled_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify enabled toggle persists correctly."""
         """Verify enabled toggle persists correctly."""
         plug = await smart_plug_factory(enabled=True)
         plug = await smart_plug_factory(enabled=True)
 
 
-        response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"enabled": False}
-        )
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.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_update_off_delay_mode(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_off_delay_mode(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify off_delay_mode can be changed."""
         """Verify off_delay_mode can be changed."""
         plug = await smart_plug_factory(off_delay_mode="time")
         plug = await smart_plug_factory(off_delay_mode="time")
 
 
         response = await async_client.patch(
         response = await async_client.patch(
-            f"/api/v1/smart-plugs/{plug.id}",
-            json={"off_delay_mode": "temperature", "off_temp_threshold": 50}
+            f"/api/v1/smart-plugs/{plug.id}", json={"off_delay_mode": "temperature", "off_temp_threshold": 50}
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -217,9 +191,7 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_schedule_settings(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_schedule_settings(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify schedule settings can be updated."""
         """Verify schedule settings can be updated."""
         plug = await smart_plug_factory(schedule_enabled=False)
         plug = await smart_plug_factory(schedule_enabled=False)
 
 
@@ -229,7 +201,7 @@ class TestSmartPlugsAPI:
                 "schedule_enabled": True,
                 "schedule_enabled": True,
                 "schedule_on_time": "08:00",
                 "schedule_on_time": "08:00",
                 "schedule_off_time": "22:00",
                 "schedule_off_time": "22:00",
-            }
+            },
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -240,9 +212,7 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_update_multiple_fields(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_update_multiple_fields(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify multiple fields can be updated at once."""
         """Verify multiple fields can be updated at once."""
         plug = await smart_plug_factory(
         plug = await smart_plug_factory(
             name="Old Name",
             name="Old Name",
@@ -256,7 +226,7 @@ class TestSmartPlugsAPI:
                 "name": "New Name",
                 "name": "New Name",
                 "auto_on": False,
                 "auto_on": False,
                 "auto_off": False,
                 "auto_off": False,
-            }
+            },
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -277,10 +247,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be turned on."""
         """Verify smart plug can be turned on."""
         plug = await smart_plug_factory()
         plug = await smart_plug_factory()
 
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "on"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -295,10 +262,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be turned off."""
         """Verify smart plug can be turned off."""
         plug = await smart_plug_factory()
         plug = await smart_plug_factory()
 
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "off"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "off"})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -313,10 +277,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be toggled."""
         """Verify smart plug can be toggled."""
         plug = await smart_plug_factory()
         plug = await smart_plug_factory()
 
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "toggle"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "toggle"})
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -325,16 +286,11 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_control_invalid_action(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_control_invalid_action(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify invalid action returns error."""
         """Verify invalid action returns error."""
         plug = await smart_plug_factory()
         plug = await smart_plug_factory()
 
 
-        response = await async_client.post(
-            f"/api/v1/smart-plugs/{plug.id}/control",
-            json={"action": "invalid"}
-        )
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "invalid"})
 
 
         # FastAPI returns 422 for pydantic validation errors
         # FastAPI returns 422 for pydantic validation errors
         assert response.status_code == 422
         assert response.status_code == 422
@@ -364,9 +320,7 @@ class TestSmartPlugsAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_delete_smart_plug(
-        self, async_client: AsyncClient, smart_plug_factory, db_session
-    ):
+    async def test_delete_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):
         """Verify smart plug can be deleted."""
         """Verify smart plug can be deleted."""
         plug = await smart_plug_factory()
         plug = await smart_plug_factory()
         plug_id = plug.id
         plug_id = plug.id
@@ -386,3 +340,70 @@ class TestSmartPlugsAPI:
         response = await async_client.delete("/api/v1/smart-plugs/9999")
         response = await async_client.delete("/api/v1/smart-plugs/9999")
 
 
         assert response.status_code == 404
         assert response.status_code == 404
+
+    # ========================================================================
+    # Switchbar visibility
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_show_in_switchbar(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify show_in_switchbar toggle persists correctly."""
+        plug = await smart_plug_factory(show_in_switchbar=False)
+
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug.id}", json={"show_in_switchbar": True})
+
+        assert response.status_code == 200
+        assert response.json()["show_in_switchbar"] is True
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
+        assert response.json()["show_in_switchbar"] is True
+
+    # ========================================================================
+    # Tasmota Discovery endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_scan(self, async_client: AsyncClient):
+        """Verify Tasmota discovery scan can be started."""
+        response = await async_client.post("/api/v1/smart-plugs/discover/scan")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_status(self, async_client: AsyncClient):
+        """Verify Tasmota discovery status endpoint works."""
+        response = await async_client.get("/api/v1/smart-plugs/discover/status")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data
+        assert "scanned" in data
+        assert "total" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_devices(self, async_client: AsyncClient):
+        """Verify Tasmota discovered devices endpoint works."""
+        response = await async_client.get("/api/v1/smart-plugs/discover/devices")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tasmota_discovery_stop(self, async_client: AsyncClient):
+        """Verify Tasmota discovery can be stopped."""
+        response = await async_client.post("/api/v1/smart-plugs/discover/stop")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "running" in data

+ 44 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -11,6 +11,7 @@ import pytest
 
 
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     PrinterManager,
     PrinterManager,
+    get_derived_status_name,
     init_printer_connections,
     init_printer_connections,
     printer_state_to_dict,
     printer_state_to_dict,
 )
 )
@@ -576,6 +577,7 @@ class TestPrinterStateToDict:
         state.tray_now = "1"
         state.tray_now = "1"
         state.wifi_signal = -50
         state.wifi_signal = -50
         state.raw_data = {}
         state.raw_data = {}
+        state.stg_cur = -1  # No calibration stage active
         return state
         return state
 
 
     def test_basic_conversion(self, mock_state):
     def test_basic_conversion(self, mock_state):
@@ -733,6 +735,48 @@ class TestPrinterStateToDict:
         assert result["ams"][0]["is_ams_ht"] is False
         assert result["ams"][0]["is_ams_ht"] is False
 
 
 
 
+class TestGetDerivedStatusName:
+    """Tests for get_derived_status_name function."""
+
+    def test_stg_cur_255_returns_none(self):
+        """Verify stg_cur=255 (A1/P1 idle) returns None, not 'Unknown stage (255)'."""
+        state = MagicMock()
+        state.stg_cur = 255
+        state.state = "IDLE"
+
+        result = get_derived_status_name(state)
+
+        assert result is None
+
+    def test_stg_cur_negative_one_returns_none_when_idle(self):
+        """Verify stg_cur=-1 (X1 idle) returns None."""
+        state = MagicMock()
+        state.stg_cur = -1
+        state.state = "IDLE"
+
+        result = get_derived_status_name(state)
+
+        assert result is None
+
+    def test_valid_stage_returns_name(self):
+        """Verify valid stg_cur values return stage name."""
+        state = MagicMock()
+        state.stg_cur = 1  # Auto bed leveling
+
+        result = get_derived_status_name(state)
+
+        assert result == "Auto bed leveling"
+
+    def test_stg_cur_zero_returns_printing(self):
+        """Verify stg_cur=0 returns 'Printing'."""
+        state = MagicMock()
+        state.stg_cur = 0
+
+        result = get_derived_status_name(state)
+
+        assert result == "Printing"
+
+
 class TestInitPrinterConnections:
 class TestInitPrinterConnections:
     """Tests for init_printer_connections function."""
     """Tests for init_printer_connections function."""
 
 

+ 9 - 2
docker-compose.yml

@@ -2,8 +2,15 @@ services:
   bambuddy:
   bambuddy:
     build: .
     build: .
     container_name: bambuddy
     container_name: bambuddy
-    ports:
-      - "8000:8000"
+    # Network mode options:
+    # - Default (bridge): Works for basic usage, but printer discovery and
+    #   camera streaming may not work since container can't reach LAN directly.
+    # - Host mode: Required for printer discovery scanning and camera streaming.
+    #   Uncomment "network_mode: host" and remove "ports:" section below.
+    #
+    network_mode: host
+    #ports:
+    #  - "8000:8000"
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs

+ 2 - 0
frontend/src/App.tsx

@@ -9,6 +9,7 @@ import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { ProjectsPage } from './pages/ProjectsPage';
 import { ProjectsPage } from './pages/ProjectsPage';
+import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { CameraPage } from './pages/CameraPage';
 import { CameraPage } from './pages/CameraPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
@@ -49,6 +50,7 @@ function App() {
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
                   <Route path="projects" element={<ProjectsPage />} />
+                  <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />

+ 327 - 4
frontend/src/api/client.ts

@@ -14,7 +14,11 @@ async function request<T>(
 
 
   if (!response.ok) {
   if (!response.ok) {
     const error = await response.json().catch(() => ({}));
     const error = await response.json().catch(() => ({}));
-    throw new Error(error.detail || `HTTP ${response.status}`);
+    const detail = error.detail;
+    const message = typeof detail === 'string'
+      ? detail
+      : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
+    throw new Error(message);
   }
   }
 
 
   return response.json();
   return response.json();
@@ -106,11 +110,16 @@ export interface PrinterStatus {
   temperatures: {
   temperatures: {
     bed?: number;
     bed?: number;
     bed_target?: number;
     bed_target?: number;
+    bed_heating?: boolean;  // Actual heater state from MQTT
     nozzle?: number;
     nozzle?: number;
     nozzle_target?: number;
     nozzle_target?: number;
+    nozzle_heating?: boolean;  // Actual heater state from MQTT
     nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)
     nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)
     nozzle_2_target?: number;
     nozzle_2_target?: number;
+    nozzle_2_heating?: boolean;  // Actual heater state from MQTT
     chamber?: number;
     chamber?: number;
+    chamber_target?: number;
+    chamber_heating?: boolean;  // Actual heater state from MQTT
   } | null;
   } | null;
   cover_url: string | null;
   cover_url: string | null;
   hms_errors: HMSError[];
   hms_errors: HMSError[];
@@ -328,6 +337,20 @@ export interface ProjectStats {
   total_print_time_hours: number;
   total_print_time_hours: number;
   total_filament_grams: number;
   total_filament_grams: number;
   progress_percent: number | null;
   progress_percent: number | null;
+  estimated_cost: number;
+  total_energy_kwh: number;
+  total_energy_cost: number;
+  remaining_prints: number | null;
+  bom_total_items: number;
+  bom_completed_items: number;
+}
+
+export interface ProjectChildPreview {
+  id: number;
+  name: string;
+  color: string | null;
+  status: string;
+  progress_percent: number | null;
 }
 }
 
 
 export interface Project {
 export interface Project {
@@ -337,16 +360,36 @@ export interface Project {
   color: string | null;
   color: string | null;
   status: string;  // active, completed, archived
   status: string;  // active, completed, archived
   target_count: number | null;
   target_count: number | null;
+  notes: string | null;
+  attachments: ProjectAttachment[] | null;
+  tags: string | null;
+  due_date: string | null;
+  priority: string;  // low, normal, high, urgent
+  budget: number | null;
+  is_template: boolean;
+  template_source_id: number | null;
+  parent_id: number | null;
+  parent_name: string | null;
+  children: ProjectChildPreview[];
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
   stats?: ProjectStats;
   stats?: ProjectStats;
 }
 }
 
 
+export interface ProjectAttachment {
+  filename: string;
+  original_name: string;
+  size: number;
+  uploaded_at: string;
+}
+
 export interface ArchivePreview {
 export interface ArchivePreview {
   id: number;
   id: number;
   print_name: string | null;
   print_name: string | null;
   thumbnail_path: string | null;
   thumbnail_path: string | null;
   status: string;
   status: string;
+  filament_type: string | null;
+  filament_color: string | null;
 }
 }
 
 
 export interface ProjectListItem {
 export interface ProjectListItem {
@@ -368,6 +411,12 @@ export interface ProjectCreate {
   description?: string;
   description?: string;
   color?: string;
   color?: string;
   target_count?: number;
   target_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  parent_id?: number;
 }
 }
 
 
 export interface ProjectUpdate {
 export interface ProjectUpdate {
@@ -376,6 +425,61 @@ export interface ProjectUpdate {
   color?: string;
   color?: string;
   status?: string;
   status?: string;
   target_count?: number;
   target_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  parent_id?: number;
+}
+
+// BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)
+export interface BOMItem {
+  id: number;
+  project_id: number;
+  name: string;
+  quantity_needed: number;
+  quantity_acquired: number;
+  unit_price: number | null;
+  sourcing_url: string | null;
+  archive_id: number | null;
+  archive_name: string | null;
+  stl_filename: string | null;
+  remarks: string | null;
+  sort_order: number;
+  is_complete: boolean;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface BOMItemCreate {
+  name: string;
+  quantity_needed?: number;
+  unit_price?: number;
+  sourcing_url?: string;
+  archive_id?: number;
+  stl_filename?: string;
+  remarks?: string;
+}
+
+export interface BOMItemUpdate {
+  name?: string;
+  quantity_needed?: number;
+  quantity_acquired?: number;
+  unit_price?: number;
+  sourcing_url?: string;
+  archive_id?: number;
+  stl_filename?: string;
+  remarks?: string;
+}
+
+// Timeline Types
+export interface TimelineEvent {
+  event_type: string;
+  timestamp: string;
+  title: string;
+  description: string | null;
+  metadata: Record<string, unknown> | null;
 }
 }
 
 
 // API Key types
 // API Key types
@@ -559,6 +663,8 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
   schedule_off_time: string | null;
+  // Switchbar visibility
+  show_in_switchbar: boolean;
   // Status
   // Status
   last_state: string | null;
   last_state: string | null;
   last_checked: string | null;
   last_checked: string | null;
@@ -587,6 +693,8 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
+  // Switchbar visibility
+  show_in_switchbar?: boolean;
 }
 }
 
 
 export interface SmartPlugUpdate {
 export interface SmartPlugUpdate {
@@ -609,6 +717,8 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
+  // Switchbar visibility
+  show_in_switchbar?: boolean;
 }
 }
 
 
 export interface SmartPlugEnergy {
 export interface SmartPlugEnergy {
@@ -636,6 +746,21 @@ export interface SmartPlugTestResult {
   device_name: string | null;
   device_name: string | null;
 }
 }
 
 
+// Tasmota Discovery types
+export interface TasmotaScanStatus {
+  running: boolean;
+  scanned: number;
+  total: number;
+}
+
+export interface DiscoveredTasmotaDevice {
+  ip_address: string;
+  name: string;
+  module: number | null;
+  state: string | null;
+  discovered_at: string | null;
+}
+
 // Print Queue types
 // Print Queue types
 export interface PrintQueueItem {
 export interface PrintQueueItem {
   id: number;
   id: number;
@@ -1121,8 +1246,11 @@ export const api = {
       method: 'PATCH',
       method: 'PATCH',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
-  deletePrinter: (id: number) =>
-    request<void>(`/printers/${id}`, { method: 'DELETE' }),
+  deletePrinter: (id: number, deleteArchives: boolean = true) =>
+    request<{ status: string; archives_deleted: boolean }>(
+      `/printers/${id}?delete_archives=${deleteArchives}`,
+      { method: 'DELETE' }
+    ),
   getPrinterStatus: (id: number) =>
   getPrinterStatus: (id: number) =>
     request<PrinterStatus>(`/printers/${id}/status`),
     request<PrinterStatus>(`/printers/${id}/status`),
   connectPrinter: (id: number) =>
   connectPrinter: (id: number) =>
@@ -1301,7 +1429,7 @@ export const api = {
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
-  getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse`,
+  getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
   scanArchiveTimelapse: (id: number) =>
   scanArchiveTimelapse: (id: number) =>
     request<{
     request<{
       status: string;
       status: string;
@@ -1329,6 +1457,56 @@ export const api = {
     }
     }
     return response.json();
     return response.json();
   },
   },
+  // Timelapse Editor
+  getTimelapseInfo: (archiveId: number) =>
+    request<{
+      duration: number;
+      width: number;
+      height: number;
+      fps: number;
+      codec: string;
+      file_size: number;
+      has_audio: boolean;
+    }>(`/archives/${archiveId}/timelapse/info`),
+  getTimelapseThumbnails: (archiveId: number, count: number = 10) =>
+    request<{
+      thumbnails: string[];
+      timestamps: number[];
+    }>(`/archives/${archiveId}/timelapse/thumbnails?count=${count}`),
+  processTimelapse: async (
+    archiveId: number,
+    params: {
+      trimStart?: number;
+      trimEnd?: number;
+      speed?: number;
+      saveMode: 'replace' | 'new';
+      outputFilename?: string;
+    },
+    audioFile?: File
+  ): Promise<{ status: string; output_path: string | null; message: string }> => {
+    const formData = new FormData();
+    formData.append('trim_start', String(params.trimStart ?? 0));
+    if (params.trimEnd !== undefined) {
+      formData.append('trim_end', String(params.trimEnd));
+    }
+    formData.append('speed', String(params.speed ?? 1));
+    formData.append('save_mode', params.saveMode);
+    if (params.outputFilename) {
+      formData.append('output_filename', params.outputFilename);
+    }
+    if (audioFile) {
+      formData.append('audio', audioFile);
+    }
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
   // Photos
   // Photos
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,
     `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,
@@ -1423,6 +1601,18 @@ export const api = {
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  getArchiveFilamentRequirements: (archiveId: number) =>
+    request<{
+      archive_id: number;
+      filename: string;
+      filaments: Array<{
+        slot_id: number;
+        type: string;
+        color: string;
+        used_grams: number;
+        used_meters: number;
+      }>;
+    }>(`/archives/${archiveId}/filament-requirements`),
   reprintArchive: (archiveId: number, printerId: number) =>
   reprintArchive: (archiveId: number, printerId: number) =>
     request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
     request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
       `/archives/${archiveId}/reprint?printer_id=${printerId}`,
       `/archives/${archiveId}/reprint?printer_id=${printerId}`,
@@ -1477,10 +1667,12 @@ export const api = {
       if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));
       if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));
       if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));
       if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));
       if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
       if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
+      if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links));
       if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
       if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
       if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
       if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
       if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
       if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
       if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
       if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
+      if (categories.projects !== undefined) params.set('include_projects', String(categories.projects));
       if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
       if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
     }
     }
     const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
     const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
@@ -1590,6 +1782,18 @@ export const api = {
       body: JSON.stringify({ ip_address, username, password }),
       body: JSON.stringify({ ip_address, username, password }),
     }),
     }),
 
 
+  // Tasmota Discovery (auto-detects network)
+  startTasmotaScan: () =>
+    fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
+      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+  getTasmotaScanStatus: () =>
+    request<TasmotaScanStatus>('/smart-plugs/discover/status'),
+  stopTasmotaScan: () =>
+    fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
+      .then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
+  getDiscoveredTasmotaDevices: () =>
+    request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
+
   // Print Queue
   // Print Queue
   getQueue: (printerId?: number, status?: string) => {
   getQueue: (printerId?: number, status?: string) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
@@ -1819,6 +2023,14 @@ export const api = {
       `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
       `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
       { method: 'PATCH' }
       { method: 'PATCH' }
     ),
     ),
+  assignMaintenanceType: (printerId: number, typeId: number) =>
+    request<MaintenanceStatus>(`/maintenance/printers/${printerId}/assign/${typeId}`, {
+      method: 'POST',
+    }),
+  removeMaintenanceItem: (itemId: number) =>
+    request<{ status: string }>(`/maintenance/items/${itemId}`, {
+      method: 'DELETE',
+    }),
 
 
   // Camera
   // Camera
   getCameraStreamUrl: (printerId: number, fps = 10) =>
   getCameraStreamUrl: (printerId: number, fps = 10) =>
@@ -1902,6 +2114,64 @@ export const api = {
       body: JSON.stringify({ queue_item_ids: queueItemIds }),
       body: JSON.stringify({ queue_item_ids: queueItemIds }),
     }),
     }),
 
 
+  // Project Attachments
+  uploadProjectAttachment: async (projectId: number, file: File): Promise<{
+    status: string;
+    filename: string;
+    original_name: string;
+    attachments: ProjectAttachment[];
+  }> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  getProjectAttachmentUrl: (projectId: number, filename: string) =>
+    `${API_BASE}/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
+  deleteProjectAttachment: (projectId: number, filename: string) =>
+    request<{ status: string; message: string; attachments: ProjectAttachment[] | null }>(
+      `/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
+      { method: 'DELETE' }
+    ),
+
+  // BOM (Bill of Materials)
+  getProjectBOM: (projectId: number) =>
+    request<BOMItem[]>(`/projects/${projectId}/bom`),
+  createBOMItem: (projectId: number, data: BOMItemCreate) =>
+    request<BOMItem>(`/projects/${projectId}/bom`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateBOMItem: (projectId: number, itemId: number, data: BOMItemUpdate) =>
+    request<BOMItem>(`/projects/${projectId}/bom/${itemId}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteBOMItem: (projectId: number, itemId: number) =>
+    request<{ status: string; message: string }>(`/projects/${projectId}/bom/${itemId}`, {
+      method: 'DELETE',
+    }),
+
+  // Templates
+  getTemplates: () => request<ProjectListItem[]>('/projects/templates/'),
+  createTemplateFromProject: (projectId: number) =>
+    request<Project>(`/projects/${projectId}/create-template`, { method: 'POST' }),
+  createProjectFromTemplate: (templateId: number, name?: string) =>
+    request<Project>(`/projects/from-template/${templateId}${name ? `?name=${encodeURIComponent(name)}` : ''}`, {
+      method: 'POST',
+    }),
+
+  // Timeline
+  getProjectTimeline: (projectId: number, limit = 50) =>
+    request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
+
   // API Keys
   // API Keys
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   createAPIKey: (data: APIKeyCreate) =>
   createAPIKey: (data: APIKeyCreate) =>
@@ -2015,3 +2285,56 @@ export interface SystemInfo {
     percent: number;
     percent: number;
   };
   };
 }
 }
+
+// Discovery types
+export interface DiscoveredPrinter {
+  serial: string;
+  name: string;
+  ip_address: string;
+  model: string | null;
+  discovered_at: string | null;
+}
+
+export interface DiscoveryStatus {
+  running: boolean;
+}
+
+export interface DiscoveryInfo {
+  is_docker: boolean;
+  ssdp_running: boolean;
+  scan_running: boolean;
+}
+
+export interface SubnetScanStatus {
+  running: boolean;
+  scanned: number;
+  total: number;
+}
+
+// Discovery API
+export const discoveryApi = {
+  getInfo: () => request<DiscoveryInfo>('/discovery/info'),
+
+  getStatus: () => request<DiscoveryStatus>('/discovery/status'),
+
+  startDiscovery: (duration: number = 10) =>
+    request<DiscoveryStatus>(`/discovery/start?duration=${duration}`, { method: 'POST' }),
+
+  stopDiscovery: () =>
+    request<DiscoveryStatus>('/discovery/stop', { method: 'POST' }),
+
+  getDiscoveredPrinters: () =>
+    request<DiscoveredPrinter[]>('/discovery/printers'),
+
+  // Subnet scanning (for Docker environments)
+  startSubnetScan: (subnet: string, timeout: number = 1.0) =>
+    request<SubnetScanStatus>('/discovery/scan', {
+      method: 'POST',
+      body: JSON.stringify({ subnet, timeout }),
+    }),
+
+  getScanStatus: () => request<SubnetScanStatus>('/discovery/scan/status'),
+
+  stopSubnetScan: () =>
+    request<SubnetScanStatus>('/discovery/scan/stop', { method: 'POST' }),
+};

+ 178 - 5
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,8 +1,8 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
+import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 
 
 interface AddSmartPlugModalProps {
 interface AddSmartPlugModalProps {
@@ -32,6 +32,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
 
+  // Switchbar visibility
+  const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
+
+  // Discovery state
+  const [isScanning, setIsScanning] = useState(false);
+  const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
+  const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredTasmotaDevice[]>([]);
+  const scanPollRef = useRef<NodeJS.Timeout | null>(null);
+
   // Fetch printers for linking
   // Fetch printers for linking
   const { data: printers } = useQuery({
   const { data: printers } = useQuery({
     queryKey: ['printers'],
     queryKey: ['printers'],
@@ -44,15 +53,82 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     queryFn: api.getSmartPlugs,
     queryFn: api.getSmartPlugs,
   });
   });
 
 
-  // Close on Escape key
+  // Close on Escape key and cleanup scan polling
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
       if (e.key === 'Escape') onClose();
       if (e.key === 'Escape') onClose();
     };
     };
     window.addEventListener('keydown', handleKeyDown);
     window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
+    return () => {
+      window.removeEventListener('keydown', handleKeyDown);
+      if (scanPollRef.current) {
+        clearInterval(scanPollRef.current);
+      }
+    };
   }, [onClose]);
   }, [onClose]);
 
 
+  // Start scanning for Tasmota devices (auto-detects network)
+  const startScan = async () => {
+    setIsScanning(true);
+    setDiscoveredDevices([]);
+    setScanProgress({ scanned: 0, total: 0 });
+    setError(null);
+
+    try {
+      await api.startTasmotaScan();
+
+      // Poll function to fetch status and devices
+      const pollStatus = async () => {
+        try {
+          const status = await api.getTasmotaScanStatus();
+          setScanProgress({ scanned: status.scanned, total: status.total });
+
+          const devices = await api.getDiscoveredTasmotaDevices();
+          setDiscoveredDevices(devices);
+
+          if (!status.running) {
+            setIsScanning(false);
+            if (scanPollRef.current) {
+              clearInterval(scanPollRef.current);
+              scanPollRef.current = null;
+            }
+          }
+        } catch (e) {
+          console.error('Polling error:', e);
+        }
+      };
+
+      // Poll immediately, then every 500ms
+      await pollStatus();
+      scanPollRef.current = setInterval(pollStatus, 500);
+    } catch (err) {
+      setIsScanning(false);
+      const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
+      setError(errorMsg || 'Failed to start scan');
+    }
+  };
+
+  // Stop scanning
+  const stopScan = async () => {
+    try {
+      await api.stopTasmotaScan();
+    } catch {
+      // Ignore stop errors
+    }
+    setIsScanning(false);
+    if (scanPollRef.current) {
+      clearInterval(scanPollRef.current);
+      scanPollRef.current = null;
+    }
+  };
+
+  // Select a discovered device
+  const selectDevice = (device: DiscoveredTasmotaDevice) => {
+    setIpAddress(device.ip_address);
+    setName(device.name);
+    setTestResult(null);
+  };
+
   // Test connection mutation
   // Test connection mutation
   const testMutation = useMutation({
   const testMutation = useMutation({
     mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
     mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
@@ -127,6 +203,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
       schedule_off_time: scheduleOffTime || null,
+      // Switchbar
+      show_in_switchbar: showInSwitchbar,
     };
     };
 
 
     if (isEditing) {
     if (isEditing) {
@@ -168,6 +246,79 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
             </div>
           )}
           )}
 
 
+          {/* Discovery Section - only show when not editing */}
+          {!isEditing && (
+            <div className="space-y-3">
+              {/* Scan button - auto-detects network */}
+              {isScanning ? (
+                <Button type="button" variant="secondary" onClick={stopScan} className="w-full">
+                  <X className="w-4 h-4" />
+                  Stop Scanning
+                </Button>
+              ) : (
+                <Button type="button" variant="primary" onClick={startScan} className="w-full">
+                  <Search className="w-4 h-4" />
+                  Discover Tasmota Devices
+                </Button>
+              )}
+
+              {/* Progress bar */}
+              {isScanning && scanProgress.total > 0 && (
+                <div className="space-y-1">
+                  <div className="flex justify-between text-xs text-bambu-gray">
+                    <span>Scanning network...</span>
+                    <span>{scanProgress.scanned} / {scanProgress.total}</span>
+                  </div>
+                  <div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
+                    <div
+                      className="bg-bambu-green h-2 rounded-full transition-all duration-300"
+                      style={{ width: `${(scanProgress.scanned / scanProgress.total) * 100}%` }}
+                    />
+                  </div>
+                </div>
+              )}
+
+              {/* Discovered devices */}
+              {discoveredDevices.length > 0 && (
+                <div className="space-y-2">
+                  <p className="text-xs text-bambu-gray">Found {discoveredDevices.length} device(s) - click to select:</p>
+                  <div className="max-h-40 overflow-y-auto space-y-1">
+                    {discoveredDevices.map((device) => (
+                      <button
+                        key={device.ip_address}
+                        type="button"
+                        onClick={() => selectDevice(device)}
+                        className="w-full flex items-center justify-between p-2 bg-bambu-dark hover:bg-bambu-dark-tertiary rounded-lg transition-colors text-left border border-bambu-dark-tertiary"
+                      >
+                        <div className="flex items-center gap-2">
+                          <Plug className="w-4 h-4 text-bambu-green" />
+                          <div>
+                            <p className="text-sm text-white">{device.name}</p>
+                            <p className="text-xs text-bambu-gray">{device.ip_address}</p>
+                          </div>
+                        </div>
+                        {device.state && (
+                          <span className={`flex items-center gap-1 text-xs ${
+                            device.state === 'ON' ? 'text-bambu-green' : 'text-bambu-gray'
+                          }`}>
+                            <Power className="w-3 h-3" />
+                            {device.state}
+                          </span>
+                        )}
+                      </button>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {!isScanning && discoveredDevices.length === 0 && scanProgress.total > 0 && (
+                <p className="text-xs text-bambu-gray text-center py-2">
+                  No Tasmota devices found on your network
+                </p>
+              )}
+            </div>
+          )}
+
           {/* IP Address */}
           {/* IP Address */}
           <div>
           <div>
             <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
             <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
@@ -382,6 +533,28 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             )}
             )}
           </div>
           </div>
 
 
+          {/* Switchbar Visibility */}
+          <div className="border-t border-bambu-dark-tertiary pt-4">
+            <div className="flex items-center justify-between">
+              <div className="flex items-center gap-2">
+                <LayoutGrid className="w-4 h-4 text-bambu-green" />
+                <div>
+                  <span className="text-white font-medium">Show in Switchbar</span>
+                  <p className="text-xs text-bambu-gray">Quick access from sidebar</p>
+                </div>
+              </div>
+              <label className="relative inline-flex items-center cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={showInSwitchbar}
+                  onChange={(e) => setShowInSwitchbar(e.target.checked)}
+                  className="sr-only peer"
+                />
+                <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+              </label>
+            </div>
+          </div>
+
           {/* Actions */}
           {/* Actions */}
           <div className="flex gap-3 pt-2">
           <div className="flex gap-3 pt-2">
             <Button
             <Button

+ 17 - 1
frontend/src/components/BackupModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
-import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle } from 'lucide-react';
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -47,6 +47,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: true,
     default: true,
     description: 'Tasmota plug configurations',
     description: 'Tasmota plug configurations',
   },
   },
+  {
+    id: 'external_links',
+    labelKey: 'backup.categories.externalLinks',
+    defaultLabel: 'External Links',
+    icon: <Link className="w-4 h-4" />,
+    default: true,
+    description: 'Sidebar links to external services',
+  },
   {
   {
     id: 'printers',
     id: 'printers',
     labelKey: 'backup.categories.printers',
     labelKey: 'backup.categories.printers',
@@ -79,6 +87,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: false,
     default: false,
     description: 'All print data + files (3MF, thumbnails, photos)',
     description: 'All print data + files (3MF, thumbnails, photos)',
   },
   },
+  {
+    id: 'projects',
+    labelKey: 'backup.categories.projects',
+    defaultLabel: 'Projects',
+    icon: <FolderKanban className="w-4 h-4" />,
+    default: false,
+    description: 'Projects, BOM items, and attachments',
+  },
 ];
 ];
 
 
 interface BackupModalProps {
 interface BackupModalProps {

+ 44 - 1
frontend/src/components/Layout.tsx

@@ -1,9 +1,10 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
+import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { getIconByName } from './IconPicker';
@@ -72,6 +73,7 @@ export function Layout() {
   });
   });
   const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
   const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
   const [showShortcuts, setShowShortcuts] = useState(false);
   const [showShortcuts, setShowShortcuts] = useState(false);
+  const [showSwitchbar, setShowSwitchbar] = useState(false);
   const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
   const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
   const [draggedId, setDraggedId] = useState<string | null>(null);
   const [draggedId, setDraggedId] = useState<string | null>(null);
   const [dragOverId, setDragOverId] = useState<string | null>(null);
   const [dragOverId, setDragOverId] = useState<string | null>(null);
@@ -107,6 +109,15 @@ export function Layout() {
     queryFn: api.getExternalLinks,
     queryFn: api.getExternalLinks,
   });
   });
 
 
+  // Fetch smart plugs to check for switchbar items
+  const { data: smartPlugs } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+    staleTime: 30 * 1000, // 30 seconds
+  });
+
+  const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
+
   // Build the unified sidebar items list
   // Build the unified sidebar items list
   const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
   const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
   const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
   const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
@@ -457,6 +468,22 @@ export function Layout() {
                 )}
                 )}
               </div>
               </div>
               <div className="flex items-center gap-1">
               <div className="flex items-center gap-1">
+                {hasSwitchbarPlugs && (
+                  <div className="relative">
+                    <button
+                      onMouseEnter={() => setShowSwitchbar(true)}
+                      className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                        showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                      }`}
+                      title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
+                    >
+                      <Plug className="w-5 h-5" />
+                    </button>
+                    {showSwitchbar && (
+                      <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
+                    )}
+                  </div>
+                )}
                 <NavLink
                 <NavLink
                   to="/system"
                   to="/system"
                   className={({ isActive }) =>
                   className={({ isActive }) =>
@@ -504,6 +531,22 @@ export function Layout() {
                   <ArrowUpCircle className="w-5 h-5" />
                   <ArrowUpCircle className="w-5 h-5" />
                 </button>
                 </button>
               )}
               )}
+              {hasSwitchbarPlugs && (
+                <div className="relative">
+                  <button
+                    onMouseEnter={() => setShowSwitchbar(true)}
+                    className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                      showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                    }`}
+                    title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
+                  >
+                    <Plug className="w-5 h-5" />
+                  </button>
+                  {showSwitchbar && (
+                    <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
+                  )}
+                </div>
+              )}
               <NavLink
               <NavLink
                 to="/system"
                 to="/system"
                 className={({ isActive }) =>
                 className={({ isActive }) =>

+ 205 - 2
frontend/src/components/ReprintModal.tsx

@@ -1,6 +1,6 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useQuery, useMutation } from '@tanstack/react-query';
-import { X, Printer, Loader2 } from 'lucide-react';
+import { X, Printer, Loader2, AlertTriangle, Check, Circle } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -29,6 +29,19 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
+  // Fetch filament requirements from the archived 3MF
+  const { data: filamentReqs } = useQuery({
+    queryKey: ['archive-filaments', archiveId],
+    queryFn: () => api.getArchiveFilamentRequirements(archiveId),
+  });
+
+  // Fetch printer status when a printer is selected
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', selectedPrinter],
+    queryFn: () => api.getPrinterStatus(selectedPrinter!),
+    enabled: !!selectedPrinter,
+  });
+
   const reprintMutation = useMutation({
   const reprintMutation = useMutation({
     mutationFn: () => {
     mutationFn: () => {
       if (!selectedPrinter) throw new Error('No printer selected');
       if (!selectedPrinter) throw new Error('No printer selected');
@@ -42,6 +55,109 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
 
 
   const activePrinters = printers?.filter((p) => p.is_active) || [];
   const activePrinters = printers?.filter((p) => p.is_active) || [];
 
 
+  // Build a map of AMS slot positions to loaded filaments
+  // Bambu Lab slot numbering: slot = amsId * 4 + trayId + 1 (for regular AMS)
+  // AMS-HT (id >= 128) is special - uses its position in the array
+  // External spool: slot 254, No filament: slot 255
+  const loadedFilaments = useMemo(() => {
+    if (!printerStatus?.ams) return new Map<number, { type: string; color: string }>();
+
+    const map = new Map<number, { type: string; color: string }>();
+
+    // Sort AMS units by ID to get consistent ordering, filter out AMS-HT for now
+    const regularAms = printerStatus.ams
+      .filter((ams) => ams.id < 128)
+      .sort((a, b) => a.id - b.id);
+
+    // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
+    const normalizeColor = (color: string | null | undefined): string => {
+      if (!color) return '#808080';
+      // Remove alpha channel if present (8-char hex to 6-char)
+      const hex = color.replace('#', '').substring(0, 6);
+      return `#${hex}`;
+    };
+
+    regularAms.forEach((amsUnit) => {
+      amsUnit.tray.forEach((tray) => {
+        // Calculate global slot ID (1-based to match 3MF)
+        // AMS 0 tray 0 = slot 1, AMS 0 tray 1 = slot 2, etc.
+        const globalSlotId = amsUnit.id * 4 + tray.id + 1;
+        if (tray.tray_type) {
+          map.set(globalSlotId, {
+            type: tray.tray_type,
+            color: normalizeColor(tray.tray_color),
+          });
+        }
+      });
+    });
+
+    // AMS-HT units get slots after regular AMS slots
+    const amsHtUnits = printerStatus.ams.filter((ams) => ams.id >= 128);
+    let htSlotBase = regularAms.length * 4 + 1;
+    amsHtUnits.forEach((amsUnit) => {
+      amsUnit.tray.forEach((tray) => {
+        if (tray.tray_type) {
+          map.set(htSlotBase + tray.id, {
+            type: tray.tray_type,
+            color: normalizeColor(tray.tray_color),
+          });
+        }
+      });
+      htSlotBase += amsUnit.tray.length;
+    });
+
+    // Add virtual tray (external spool) as slot 254 (Bambu standard)
+    if (printerStatus.vt_tray?.tray_type) {
+      map.set(254, {
+        type: printerStatus.vt_tray.tray_type,
+        color: normalizeColor(printerStatus.vt_tray.tray_color),
+      });
+    }
+    return map;
+  }, [printerStatus]);
+
+  // Compare required filaments with loaded filaments
+  const filamentComparison = useMemo(() => {
+    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
+
+    // Helper to normalize color for comparison (case-insensitive, strip #)
+    const normalizeColorForCompare = (color: string | undefined): string => {
+      if (!color) return '';
+      return color.replace('#', '').toLowerCase();
+    };
+
+    return filamentReqs.filaments.map((req) => {
+      const loaded = loadedFilaments.get(req.slot_id);
+      const hasFilament = !!loaded;
+      const typeMatch = hasFilament && loaded?.type?.toUpperCase() === req.type?.toUpperCase();
+      const colorMatch = hasFilament && normalizeColorForCompare(loaded?.color) === normalizeColorForCompare(req.color);
+
+      // Status: match (both), type_only (type ok, color different), mismatch (type wrong), empty
+      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
+      if (!hasFilament) {
+        status = 'empty';
+      } else if (typeMatch && colorMatch) {
+        status = 'match';
+      } else if (typeMatch) {
+        status = 'type_only'; // Same type, different color
+      } else {
+        status = 'mismatch'; // Different type
+      }
+
+      return {
+        ...req,
+        loaded,
+        hasFilament,
+        typeMatch,
+        colorMatch,
+        status,
+      };
+    });
+  }, [filamentReqs, loadedFilaments]);
+
+  const hasAnyMismatch = filamentComparison.some((f) => f.status !== 'match');
+  const hasEmptySlots = filamentComparison.some((f) => f.status === 'empty');
+
   return (
   return (
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
       <Card className="w-full max-w-md">
       <Card className="w-full max-w-md">
@@ -105,6 +221,93 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             </div>
             </div>
           )}
           )}
 
 
+          {/* Filament comparison - show when printer selected and has filament requirements */}
+          {selectedPrinter && filamentComparison.length > 0 && (
+            <div className="mb-4">
+              <div className="flex items-center gap-2 mb-2">
+                <span className="text-sm text-bambu-gray">Filament Check</span>
+                {hasEmptySlots ? (
+                  <span className="text-xs text-orange-400 flex items-center gap-1">
+                    <AlertTriangle className="w-3 h-3" />
+                    Empty slots
+                  </span>
+                ) : filamentComparison.some((f) => f.status === 'mismatch') ? (
+                  <span className="text-xs text-orange-400 flex items-center gap-1">
+                    <AlertTriangle className="w-3 h-3" />
+                    Type mismatch
+                  </span>
+                ) : filamentComparison.some((f) => f.status === 'type_only') ? (
+                  <span className="text-xs text-yellow-400 flex items-center gap-1">
+                    <AlertTriangle className="w-3 h-3" />
+                    Color mismatch
+                  </span>
+                ) : (
+                  <span className="text-xs text-bambu-green flex items-center gap-1">
+                    <Check className="w-3 h-3" />
+                    Ready
+                  </span>
+                )}
+              </div>
+              <div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
+                {filamentComparison.map((item) => (
+                  <div
+                    key={item.slot_id}
+                    className="grid items-center gap-2"
+                    style={{ gridTemplateColumns: '48px 16px 1fr auto 16px 56px 16px' }}
+                  >
+                    {/* Slot label */}
+                    <span className="text-bambu-gray">Slot {item.slot_id}</span>
+                    {/* Required color */}
+                    <Circle
+                      className="w-3 h-3 flex-shrink-0"
+                      fill={item.color}
+                      stroke={item.color}
+                    />
+                    {/* Required type + grams */}
+                    <span className="text-white truncate">
+                      {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                    </span>
+                    {/* Arrow */}
+                    <span className="text-bambu-gray">→</span>
+                    {/* Loaded color */}
+                    {item.loaded ? (
+                      <Circle
+                        className="w-3 h-3 flex-shrink-0"
+                        fill={item.loaded.color}
+                        stroke={item.loaded.color}
+                      />
+                    ) : (
+                      <span />
+                    )}
+                    {/* Loaded type */}
+                    <span className={
+                      item.status === 'match' ? 'text-bambu-green' :
+                      item.status === 'type_only' ? 'text-yellow-400' :
+                      'text-orange-400'
+                    }>
+                      {item.loaded?.type || 'Empty'}
+                    </span>
+                    {/* Status icon */}
+                    {item.status === 'match' ? (
+                      <Check className="w-3 h-3 text-bambu-green" />
+                    ) : item.status === 'type_only' ? (
+                      <span title="Color mismatch">
+                        <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                      </span>
+                    ) : (
+                      <AlertTriangle className="w-3 h-3 text-orange-400" />
+                    )}
+                  </div>
+                ))}
+              </div>
+              {(hasAnyMismatch || hasEmptySlots) && (
+                <p className="text-xs text-orange-400 mt-2">
+                  The printer may load different filaments than expected.
+                </p>
+              )}
+            </div>
+          )}
+
           {/* Error message */}
           {/* Error message */}
           {reprintMutation.isError && (
           {reprintMutation.isError && (
             <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
             <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">

+ 21 - 1
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
@@ -169,6 +169,26 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Expanded Settings */}
           {/* Expanded Settings */}
           {isExpanded && (
           {isExpanded && (
             <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
             <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
+              {/* Show in Switchbar Toggle */}
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <LayoutGrid className="w-4 h-4 text-bambu-green" />
+                  <div>
+                    <p className="text-sm text-white">Show in Switchbar</p>
+                    <p className="text-xs text-bambu-gray">Quick access from sidebar</p>
+                  </div>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.show_in_switchbar}
+                    onChange={(e) => updateMutation.mutate({ show_in_switchbar: e.target.checked })}
+                    className="sr-only peer"
+                  />
+                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
               {/* Enabled Toggle */}
               {/* Enabled Toggle */}
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>

+ 142 - 0
frontend/src/components/SwitchbarPopover.tsx

@@ -0,0 +1,142 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
+import { api } from '../api/client';
+import type { SmartPlug } from '../api/client';
+
+interface SwitchbarPopoverProps {
+  onClose: () => void;
+}
+
+function SwitchItem({ plug }: { plug: SmartPlug }) {
+  const queryClient = useQueryClient();
+
+  // Fetch current status
+  const { data: status, isLoading: statusLoading } = useQuery({
+    queryKey: ['smart-plug-status', plug.id],
+    queryFn: () => api.getSmartPlugStatus(plug.id),
+    refetchInterval: 10000, // Refresh every 10 seconds when popover is open
+  });
+
+  // Control mutation
+  const controlMutation = useMutation({
+    mutationFn: (action: 'on' | 'off') => api.controlSmartPlug(plug.id, action),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
+    },
+  });
+
+  const isOn = status?.state === 'ON';
+  const isReachable = status?.reachable ?? false;
+  const isPending = controlMutation.isPending;
+
+  return (
+    <div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
+      <div className="flex items-center gap-2">
+        <div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
+          <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+        </div>
+        <div>
+          <p className="text-sm text-white font-medium">{plug.name}</p>
+          <div className="flex items-center gap-1 text-xs">
+            {statusLoading ? (
+              <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
+            ) : isReachable ? (
+              <>
+                <Wifi className="w-3 h-3 text-bambu-green" />
+                <span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
+                {status?.energy?.power !== null && status?.energy?.power !== undefined && (
+                  <>
+                    <span className="text-bambu-gray mx-1">|</span>
+                    <Zap className="w-3 h-3 text-yellow-400" />
+                    <span className="text-yellow-400">{Math.round(status.energy.power)}W</span>
+                  </>
+                )}
+              </>
+            ) : (
+              <>
+                <WifiOff className="w-3 h-3 text-red-400" />
+                <span className="text-red-400">Offline</span>
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+
+      <div className="flex gap-1">
+        <button
+          onClick={() => controlMutation.mutate('on')}
+          disabled={!isReachable || isPending}
+          className={`p-1.5 rounded transition-colors ${
+            isOn
+              ? 'bg-bambu-green text-white'
+              : 'bg-bambu-dark text-bambu-gray hover:text-white'
+          } disabled:opacity-50 disabled:cursor-not-allowed`}
+          title="Turn On"
+        >
+          {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+        </button>
+        <button
+          onClick={() => controlMutation.mutate('off')}
+          disabled={!isReachable || isPending}
+          className={`p-1.5 rounded transition-colors ${
+            !isOn && isReachable
+              ? 'bg-bambu-dark-tertiary text-white'
+              : 'bg-bambu-dark text-bambu-gray hover:text-white'
+          } disabled:opacity-50 disabled:cursor-not-allowed`}
+          title="Turn Off"
+        >
+          {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+        </button>
+      </div>
+    </div>
+  );
+}
+
+export function SwitchbarPopover({ onClose }: SwitchbarPopoverProps) {
+  // Fetch all smart plugs
+  const { data: plugs, isLoading } = useQuery({
+    queryKey: ['smart-plugs'],
+    queryFn: api.getSmartPlugs,
+  });
+
+  // Filter to only show plugs with show_in_switchbar enabled
+  const switchbarPlugs = plugs?.filter(p => p.show_in_switchbar) || [];
+
+  return (
+    <div
+      className="absolute bottom-full left-0 mb-2 w-72 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-xl z-50"
+      onMouseLeave={onClose}
+    >
+      {/* Header */}
+      <div className="px-4 py-3 border-b border-bambu-dark-tertiary">
+        <h3 className="text-sm font-semibold text-white flex items-center gap-2">
+          <Plug className="w-4 h-4 text-bambu-green" />
+          Smart Switches
+        </h3>
+      </div>
+
+      {/* Content */}
+      <div className="p-2 max-h-80 overflow-y-auto">
+        {isLoading ? (
+          <div className="flex items-center justify-center py-8">
+            <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
+          </div>
+        ) : switchbarPlugs.length === 0 ? (
+          <div className="text-center py-6 px-4">
+            <Plug className="w-8 h-8 text-bambu-gray mx-auto mb-2" />
+            <p className="text-sm text-bambu-gray">No switches in switchbar</p>
+            <p className="text-xs text-bambu-gray mt-1">
+              Enable "Show in Switchbar" in Settings &gt; Smart Plugs
+            </p>
+          </div>
+        ) : (
+          <div className="space-y-1">
+            {switchbarPlugs.map(plug => (
+              <SwitchItem key={plug.id} plug={plug} />
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 546 - 0
frontend/src/components/TimelapseEditorModal.tsx

@@ -0,0 +1,546 @@
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import {
+  X,
+  Save,
+  Film,
+  Play,
+  Pause,
+  Scissors,
+  Gauge,
+  Music,
+  Upload,
+  Trash2,
+  Volume2,
+  VolumeX,
+  Loader2,
+} from 'lucide-react';
+import { Button } from './Button';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+
+interface TimelapseEditorModalProps {
+  archiveId: number;
+  timelapseSrc: string;
+  onClose: () => void;
+  onSave?: () => void;
+}
+
+const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
+
+function formatTime(seconds: number): string {
+  const mins = Math.floor(seconds / 60);
+  const secs = Math.floor(seconds % 60);
+  return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+export function TimelapseEditorModal({
+  archiveId,
+  timelapseSrc,
+  onClose,
+  onSave,
+}: TimelapseEditorModalProps) {
+  const { showToast } = useToast();
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const audioRef = useRef<HTMLAudioElement>(null);
+
+  // Video state
+  const [isPlaying, setIsPlaying] = useState(false);
+  const [currentTime, setCurrentTime] = useState(0);
+  const [duration, setDuration] = useState(0);
+
+  // Editor state
+  const [trimStart, setTrimStart] = useState(0);
+  const [trimEnd, setTrimEnd] = useState(0);
+  const [speed, setSpeed] = useState(1);
+  const [audioFile, setAudioFile] = useState<File | null>(null);
+  const [audioUrl, setAudioUrl] = useState<string | null>(null);
+  const [audioVolume, setAudioVolume] = useState(0.8);
+  const [audioMuted, setAudioMuted] = useState(false);
+
+
+  // Fetch video info
+  const { data: videoInfo, isLoading: isLoadingInfo } = useQuery({
+    queryKey: ['timelapse-info', archiveId],
+    queryFn: () => api.getTimelapseInfo(archiveId),
+  });
+
+  // Fetch thumbnails
+  const { data: thumbnailData } = useQuery({
+    queryKey: ['timelapse-thumbnails', archiveId],
+    queryFn: () => api.getTimelapseThumbnails(archiveId, 15),
+  });
+
+  // Process mutation
+  const processMutation = useMutation({
+    mutationFn: () =>
+      api.processTimelapse(
+        archiveId,
+        {
+          trimStart,
+          trimEnd,
+          speed,
+          saveMode: 'replace',
+        },
+        audioFile || undefined
+      ),
+    onSuccess: (data) => {
+      showToast(data.message, 'success');
+      onSave?.();
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Processing failed', 'error');
+    },
+  });
+
+  // Initialize trimEnd when duration is available
+  useEffect(() => {
+    if (videoInfo?.duration && trimEnd === 0) {
+      setTrimEnd(videoInfo.duration);
+    }
+  }, [videoInfo?.duration, trimEnd]);
+
+  // Close on Escape
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        onClose();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Video event handlers
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    const handleTimeUpdate = () => {
+      const time = video.currentTime;
+      setCurrentTime(time);
+
+      // Loop within trim region
+      if (time >= trimEnd) {
+        video.currentTime = trimStart;
+      }
+    };
+
+    const handleDurationChange = () => {
+      setDuration(video.duration);
+      if (trimEnd === 0) {
+        setTrimEnd(video.duration);
+      }
+    };
+
+    const handlePlay = () => setIsPlaying(true);
+    const handlePause = () => setIsPlaying(false);
+
+    video.addEventListener('timeupdate', handleTimeUpdate);
+    video.addEventListener('durationchange', handleDurationChange);
+    video.addEventListener('play', handlePlay);
+    video.addEventListener('pause', handlePause);
+
+    return () => {
+      video.removeEventListener('timeupdate', handleTimeUpdate);
+      video.removeEventListener('durationchange', handleDurationChange);
+      video.removeEventListener('play', handlePlay);
+      video.removeEventListener('pause', handlePause);
+    };
+  }, [trimStart, trimEnd]);
+
+  // Sync audio with video
+  useEffect(() => {
+    const audio = audioRef.current;
+    const video = videoRef.current;
+    if (!audio || !video || !audioUrl) return;
+
+    audio.currentTime = video.currentTime;
+    audio.playbackRate = video.playbackRate;
+
+    if (isPlaying && !audioMuted) {
+      audio.play().catch(() => {});
+    } else {
+      audio.pause();
+    }
+  }, [isPlaying, audioUrl, audioMuted]);
+
+  // Update audio volume
+  useEffect(() => {
+    if (audioRef.current) {
+      audioRef.current.volume = audioMuted ? 0 : audioVolume;
+    }
+  }, [audioVolume, audioMuted]);
+
+  // Update playback rate
+  useEffect(() => {
+    if (videoRef.current) {
+      videoRef.current.playbackRate = speed;
+    }
+    if (audioRef.current) {
+      audioRef.current.playbackRate = speed;
+    }
+  }, [speed]);
+
+  const togglePlay = useCallback(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    if (isPlaying) {
+      video.pause();
+    } else {
+      // Start from trim start if before it
+      if (video.currentTime < trimStart) {
+        video.currentTime = trimStart;
+      }
+      video.play();
+    }
+  }, [isPlaying, trimStart]);
+
+  const handleSeek = (time: number) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = Math.max(trimStart, Math.min(trimEnd, time));
+  };
+
+  const handleAudioUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    // Cleanup previous URL
+    if (audioUrl) {
+      URL.revokeObjectURL(audioUrl);
+    }
+
+    setAudioFile(file);
+    setAudioUrl(URL.createObjectURL(file));
+  };
+
+  const removeAudio = () => {
+    if (audioUrl) {
+      URL.revokeObjectURL(audioUrl);
+    }
+    setAudioFile(null);
+    setAudioUrl(null);
+  };
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (audioUrl) {
+        URL.revokeObjectURL(audioUrl);
+      }
+    };
+  }, [audioUrl]);
+
+  const trimmedDuration = trimEnd - trimStart;
+  const outputDuration = trimmedDuration / speed;
+
+  if (isLoadingInfo) {
+    return (
+      <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
+        <div className="flex items-center gap-3 text-white">
+          <Loader2 className="w-6 h-6 animate-spin" />
+          Loading video info...
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
+      <div className="relative bg-bambu-dark-secondary rounded-xl max-w-5xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
+          <h3 className="text-lg font-semibold text-white flex items-center gap-2">
+            <Film className="w-5 h-5 text-bambu-green" />
+            Edit Timelapse
+          </h3>
+          <div className="flex items-center gap-2">
+            <Button
+              variant="primary"
+              size="sm"
+              onClick={() => processMutation.mutate()}
+              disabled={processMutation.isPending}
+            >
+              {processMutation.isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  Processing...
+                </>
+              ) : (
+                <>
+                  <Save className="w-4 h-4" />
+                  Save
+                </>
+              )}
+            </Button>
+            <button
+              onClick={onClose}
+              className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+            >
+              <X className="w-5 h-5 text-bambu-gray" />
+            </button>
+          </div>
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto p-4 space-y-4">
+          {/* Video Preview */}
+          <div className="relative">
+            <video
+              ref={videoRef}
+              src={timelapseSrc}
+              className="w-full rounded-lg bg-black"
+              onClick={togglePlay}
+              muted={!!audioUrl}
+            />
+
+            {/* Play overlay */}
+            {!isPlaying && (
+              <button
+                onClick={togglePlay}
+                className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition-colors"
+              >
+                <div className="p-4 bg-bambu-green rounded-full">
+                  <Play className="w-8 h-8 text-white" />
+                </div>
+              </button>
+            )}
+
+            {/* Hidden audio element for music overlay preview */}
+            {audioUrl && (
+              <audio ref={audioRef} src={audioUrl} loop />
+            )}
+          </div>
+
+          {/* Timeline with Thumbnails */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-sm text-bambu-gray">
+              <Scissors className="w-4 h-4" />
+              <span>Trim</span>
+              <span className="ml-auto">
+                {formatTime(trimStart)} - {formatTime(trimEnd)} ({formatTime(trimmedDuration)})
+              </span>
+            </div>
+
+            {/* Thumbnail strip */}
+            <div className="relative h-16 bg-bambu-dark rounded-lg overflow-hidden">
+              {/* Thumbnails background */}
+              <div className="absolute inset-0 flex">
+                {thumbnailData?.thumbnails.map((thumb, i) => (
+                  <div
+                    key={i}
+                    className="flex-1 bg-cover bg-center"
+                    style={{
+                      backgroundImage: `url(data:image/jpeg;base64,${thumb})`,
+                    }}
+                  />
+                ))}
+              </div>
+
+              {/* Trim overlay - grayed out areas */}
+              <div
+                className="absolute inset-y-0 left-0 bg-black/60"
+                style={{ width: `${(trimStart / duration) * 100}%` }}
+              />
+              <div
+                className="absolute inset-y-0 right-0 bg-black/60"
+                style={{ width: `${((duration - trimEnd) / duration) * 100}%` }}
+              />
+
+              {/* Selected region border */}
+              <div
+                className="absolute inset-y-0 border-2 border-bambu-green"
+                style={{
+                  left: `${(trimStart / duration) * 100}%`,
+                  right: `${((duration - trimEnd) / duration) * 100}%`,
+                }}
+              />
+
+              {/* Current time indicator */}
+              <div
+                className="absolute top-0 bottom-0 w-0.5 bg-white shadow-lg"
+                style={{ left: `${(currentTime / duration) * 100}%` }}
+              />
+
+              {/* Trim handles */}
+              <input
+                type="range"
+                min={0}
+                max={duration}
+                step={0.1}
+                value={trimStart}
+                onChange={(e) => {
+                  const val = parseFloat(e.target.value);
+                  if (val < trimEnd - 1) {
+                    setTrimStart(val);
+                    if (videoRef.current && videoRef.current.currentTime < val) {
+                      videoRef.current.currentTime = val;
+                    }
+                  }
+                }}
+                className="absolute inset-0 w-full opacity-0 cursor-ew-resize"
+                style={{ clipPath: 'inset(0 50% 0 0)' }}
+              />
+              <input
+                type="range"
+                min={0}
+                max={duration}
+                step={0.1}
+                value={trimEnd}
+                onChange={(e) => {
+                  const val = parseFloat(e.target.value);
+                  if (val > trimStart + 1) {
+                    setTrimEnd(val);
+                  }
+                }}
+                className="absolute inset-0 w-full opacity-0 cursor-ew-resize"
+                style={{ clipPath: 'inset(0 0 0 50%)' }}
+              />
+            </div>
+
+            {/* Playback scrubber */}
+            <input
+              type="range"
+              min={0}
+              max={duration}
+              step={0.1}
+              value={currentTime}
+              onChange={(e) => handleSeek(parseFloat(e.target.value))}
+              className="w-full h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer
+                [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
+                [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full
+                [&::-webkit-slider-thumb]:cursor-pointer"
+            />
+
+            {/* Play controls */}
+            <div className="flex items-center justify-center gap-2">
+              <button
+                onClick={togglePlay}
+                className="p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors"
+              >
+                {isPlaying ? (
+                  <Pause className="w-5 h-5 text-white" />
+                ) : (
+                  <Play className="w-5 h-5 text-white" />
+                )}
+              </button>
+            </div>
+          </div>
+
+          {/* Speed Control */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-sm text-bambu-gray">
+              <Gauge className="w-4 h-4" />
+              <span>Speed</span>
+              <span className="ml-auto">{speed}x (output: {formatTime(outputDuration)})</span>
+            </div>
+            <div className="flex gap-1">
+              {SPEED_OPTIONS.map((s) => (
+                <button
+                  key={s}
+                  onClick={() => setSpeed(s)}
+                  className={`flex-1 px-2 py-2 text-sm rounded transition-colors ${
+                    speed === s
+                      ? 'bg-bambu-green text-white'
+                      : 'bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  {s}x
+                </button>
+              ))}
+            </div>
+          </div>
+
+          {/* Audio Upload */}
+          <div className="space-y-2">
+            <div className="flex items-center gap-2 text-sm text-bambu-gray">
+              <Music className="w-4 h-4" />
+              <span>Music Overlay</span>
+            </div>
+
+            {audioFile ? (
+              <div className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg">
+                <Music className="w-5 h-5 text-bambu-green" />
+                <div className="flex-1 min-w-0">
+                  <p className="text-sm text-white truncate">{audioFile.name}</p>
+                  <p className="text-xs text-bambu-gray">
+                    {(audioFile.size / 1024 / 1024).toFixed(1)} MB
+                  </p>
+                </div>
+
+                {/* Volume control */}
+                <button
+                  onClick={() => setAudioMuted(!audioMuted)}
+                  className="p-2 hover:bg-bambu-dark-tertiary rounded transition-colors"
+                >
+                  {audioMuted ? (
+                    <VolumeX className="w-4 h-4 text-bambu-gray" />
+                  ) : (
+                    <Volume2 className="w-4 h-4 text-bambu-green" />
+                  )}
+                </button>
+                <input
+                  type="range"
+                  min={0}
+                  max={1}
+                  step={0.1}
+                  value={audioVolume}
+                  onChange={(e) => setAudioVolume(parseFloat(e.target.value))}
+                  className="w-20 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer
+                    [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
+                    [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full"
+                />
+
+                <button
+                  onClick={removeAudio}
+                  className="p-2 hover:bg-red-500/20 rounded transition-colors"
+                >
+                  <Trash2 className="w-4 h-4 text-red-400" />
+                </button>
+              </div>
+            ) : (
+              <label className="flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green/50 transition-colors">
+                <Upload className="w-8 h-8 text-bambu-gray" />
+                <span className="text-sm text-bambu-gray">
+                  Drop audio file or click to upload
+                </span>
+                <span className="text-xs text-bambu-gray/60">
+                  MP3, WAV, M4A, AAC, OGG
+                </span>
+                <input
+                  type="file"
+                  accept=".mp3,.wav,.m4a,.aac,.ogg,audio/*"
+                  onChange={handleAudioUpload}
+                  className="hidden"
+                />
+              </label>
+            )}
+          </div>
+
+          {/* Summary */}
+          <div className="p-3 bg-bambu-dark rounded-lg text-sm space-y-1">
+            <p className="text-bambu-gray">
+              <span className="text-white">Original:</span> {formatTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
+            </p>
+            <p className="text-bambu-gray">
+              <span className="text-white">Output:</span> {formatTime(outputDuration)} @ {speed}x speed
+              {audioFile && ` + music overlay`}
+            </p>
+          </div>
+        </div>
+
+        {/* Processing overlay */}
+        {processMutation.isPending && (
+          <div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center gap-4">
+            <Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
+            <p className="text-white text-lg">Processing timelapse...</p>
+            <p className="text-bambu-gray text-sm">This may take a few moments</p>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 30 - 3
frontend/src/components/TimelapseViewer.tsx

@@ -1,22 +1,33 @@
 import { useState, useRef, useEffect } from 'react';
 import { useState, useRef, useEffect } from 'react';
-import { X, Download, Film, Play, Pause, SkipBack, SkipForward } from 'lucide-react';
+import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
 import { Button } from './Button';
 import { Button } from './Button';
+import { TimelapseEditorModal } from './TimelapseEditorModal';
 
 
 interface TimelapseViewerProps {
 interface TimelapseViewerProps {
   src: string;
   src: string;
   title: string;
   title: string;
   downloadFilename: string;
   downloadFilename: string;
+  archiveId?: number;
   onClose: () => void;
   onClose: () => void;
+  onEdit?: () => void;
 }
 }
 
 
 const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
 const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
 
 
-export function TimelapseViewer({ src, title, downloadFilename, onClose }: TimelapseViewerProps) {
+export function TimelapseViewer({
+  src,
+  title,
+  downloadFilename,
+  archiveId,
+  onClose,
+  onEdit,
+}: TimelapseViewerProps) {
   const videoRef = useRef<HTMLVideoElement>(null);
   const videoRef = useRef<HTMLVideoElement>(null);
   const [isPlaying, setIsPlaying] = useState(true);
   const [isPlaying, setIsPlaying] = useState(true);
-  const [playbackRate, setPlaybackRate] = useState(2); // Default to 2x for timelapse
+  const [playbackRate, setPlaybackRate] = useState(1); // Default to 1x
   const [currentTime, setCurrentTime] = useState(0);
   const [currentTime, setCurrentTime] = useState(0);
   const [duration, setDuration] = useState(0);
   const [duration, setDuration] = useState(0);
+  const [showEditor, setShowEditor] = useState(false);
 
 
   useEffect(() => {
   useEffect(() => {
     const video = videoRef.current;
     const video = videoRef.current;
@@ -109,6 +120,12 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
             {title}
             {title}
           </h3>
           </h3>
           <div className="flex items-center gap-2">
           <div className="flex items-center gap-2">
+            {archiveId && (
+              <Button variant="secondary" size="sm" onClick={() => setShowEditor(true)}>
+                <Pencil className="w-4 h-4" />
+                Edit
+              </Button>
+            )}
             <Button variant="secondary" size="sm" onClick={handleDownload}>
             <Button variant="secondary" size="sm" onClick={handleDownload}>
               <Download className="w-4 h-4" />
               <Download className="w-4 h-4" />
               Download
               Download
@@ -208,6 +225,16 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
+
+      {/* Timelapse Editor Modal */}
+      {showEditor && archiveId && (
+        <TimelapseEditorModal
+          archiveId={archiveId}
+          timelapseSrc={src}
+          onClose={() => setShowEditor(false)}
+          onSave={onEdit}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 21 - 1
frontend/src/pages/ArchivesPage.tsx

@@ -526,8 +526,23 @@ function ArchiveCard({
         <h3 className="font-medium text-white mb-1 truncate">
         <h3 className="font-medium text-white mb-1 truncate">
           {archive.print_name || archive.filename}
           {archive.print_name || archive.filename}
         </h3>
         </h3>
-        <div className="flex items-center gap-2 mb-3">
+        <div className="flex items-center gap-2 mb-3 flex-wrap">
           <p className="text-xs text-bambu-gray">{printerName}</p>
           <p className="text-xs text-bambu-gray">{printerName}</p>
+          {/* File type badge */}
+          <span
+            className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
+              archive.filename?.toLowerCase().includes('.gcode.')
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'bg-orange-500/20 text-orange-400'
+            }`}
+            title={
+              archive.filename?.toLowerCase().includes('.gcode.')
+                ? 'Sliced file - ready to print'
+                : 'Source file only - no AMS mapping available'
+            }
+          >
+            {archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
+          </span>
           {archive.project_name && (
           {archive.project_name && (
             <span
             <span
               className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"
               className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"
@@ -785,7 +800,12 @@ function ArchiveCard({
           src={api.getArchiveTimelapse(archive.id)}
           src={api.getArchiveTimelapse(archive.id)}
           title={`${archive.print_name || archive.filename} - Timelapse`}
           title={`${archive.print_name || archive.filename} - Timelapse`}
           downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
           downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
+          archiveId={archive.id}
           onClose={() => setShowTimelapse(false)}
           onClose={() => setShowTimelapse(false)}
+          onEdit={() => {
+            queryClient.invalidateQueries({ queryKey: ['archives'] });
+            setShowTimelapse(false);  // Close viewer to reload fresh video
+          }}
         />
         />
       )}
       )}
 
 

+ 120 - 6
frontend/src/pages/CameraPage.tsx

@@ -1,9 +1,13 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
-import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize } from 'lucide-react';
+import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 
 
+const MAX_RECONNECT_ATTEMPTS = 5;
+const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
+const MAX_RECONNECT_DELAY = 30000; // 30 seconds
+
 export function CameraPage() {
 export function CameraPage() {
   const { printerId } = useParams<{ printerId: string }>();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
   const id = parseInt(printerId || '0', 10);
@@ -14,8 +18,13 @@ export function CameraPage() {
   const [imageKey, setImageKey] = useState(Date.now());
   const [imageKey, setImageKey] = useState(Date.now());
   const [transitioning, setTransitioning] = useState(false);
   const [transitioning, setTransitioning] = useState(false);
   const [isFullscreen, setIsFullscreen] = useState(false);
   const [isFullscreen, setIsFullscreen] = useState(false);
+  const [reconnectAttempts, setReconnectAttempts] = useState(0);
+  const [isReconnecting, setIsReconnecting] = useState(false);
+  const [reconnectCountdown, setReconnectCountdown] = useState(0);
   const imgRef = useRef<HTMLImageElement>(null);
   const imgRef = useRef<HTMLImageElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
+  const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
 
   // Fetch printer info for the title
   // Fetch printer info for the title
   const { data: printer } = useQuery({
   const { data: printer } = useQuery({
@@ -91,14 +100,84 @@ export function CameraPage() {
     return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
     return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
   }, []);
   }, []);
 
 
+  // Clean up reconnect timers on unmount
+  useEffect(() => {
+    return () => {
+      if (reconnectTimerRef.current) {
+        clearTimeout(reconnectTimerRef.current);
+      }
+      if (countdownIntervalRef.current) {
+        clearInterval(countdownIntervalRef.current);
+      }
+    };
+  }, []);
+
+  // Auto-reconnect logic
+  const attemptReconnect = useCallback(() => {
+    if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+      setIsReconnecting(false);
+      setStreamError(true);
+      return;
+    }
+
+    // Calculate delay with exponential backoff
+    const delay = Math.min(
+      INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),
+      MAX_RECONNECT_DELAY
+    );
+
+    setIsReconnecting(true);
+    setReconnectCountdown(Math.ceil(delay / 1000));
+
+    // Countdown timer
+    countdownIntervalRef.current = setInterval(() => {
+      setReconnectCountdown((prev) => {
+        if (prev <= 1) {
+          if (countdownIntervalRef.current) {
+            clearInterval(countdownIntervalRef.current);
+          }
+          return 0;
+        }
+        return prev - 1;
+      });
+    }, 1000);
+
+    // Reconnect after delay
+    reconnectTimerRef.current = setTimeout(() => {
+      setReconnectAttempts((prev) => prev + 1);
+      setIsReconnecting(false);
+      setStreamLoading(true);
+      setStreamError(false);
+      if (imgRef.current) {
+        imgRef.current.src = '';
+      }
+      setImageKey(Date.now());
+    }, delay);
+  }, [reconnectAttempts]);
+
   const handleStreamError = () => {
   const handleStreamError = () => {
-    setStreamError(true);
     setStreamLoading(false);
     setStreamLoading(false);
+
+    // Only auto-reconnect for live stream mode
+    if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
+      attemptReconnect();
+    } else {
+      setStreamError(true);
+    }
   };
   };
 
 
   const handleStreamLoad = () => {
   const handleStreamLoad = () => {
     setStreamLoading(false);
     setStreamLoading(false);
     setStreamError(false);
     setStreamError(false);
+    // Reset reconnect attempts on successful connection
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) {
+      clearTimeout(reconnectTimerRef.current);
+    }
+    if (countdownIntervalRef.current) {
+      clearInterval(countdownIntervalRef.current);
+    }
   };
   };
 
 
   const stopStream = () => {
   const stopStream = () => {
@@ -112,6 +191,15 @@ export function CameraPage() {
     setTransitioning(true);
     setTransitioning(true);
     setStreamLoading(true);
     setStreamLoading(true);
     setStreamError(false);
     setStreamError(false);
+    // Reset reconnect state on mode switch
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) {
+      clearTimeout(reconnectTimerRef.current);
+    }
+    if (countdownIntervalRef.current) {
+      clearInterval(countdownIntervalRef.current);
+    }
 
 
     if (imgRef.current) {
     if (imgRef.current) {
       imgRef.current.src = '';
       imgRef.current.src = '';
@@ -134,6 +222,15 @@ export function CameraPage() {
     setTransitioning(true);
     setTransitioning(true);
     setStreamLoading(true);
     setStreamLoading(true);
     setStreamError(false);
     setStreamError(false);
+    // Reset reconnect state on manual refresh
+    setReconnectAttempts(0);
+    setIsReconnecting(false);
+    if (reconnectTimerRef.current) {
+      clearTimeout(reconnectTimerRef.current);
+    }
+    if (countdownIntervalRef.current) {
+      clearInterval(countdownIntervalRef.current);
+    }
 
 
     if (imgRef.current) {
     if (imgRef.current) {
       imgRef.current.src = '';
       imgRef.current.src = '';
@@ -165,7 +262,7 @@ export function CameraPage() {
       ? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
       ? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
       : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
       : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
 
 
-  const isDisabled = streamLoading || transitioning;
+  const isDisabled = streamLoading || transitioning || isReconnecting;
 
 
   if (!id) {
   if (!id) {
     return (
     return (
@@ -234,7 +331,7 @@ export function CameraPage() {
       {/* Video area */}
       {/* Video area */}
       <div className="flex-1 flex items-center justify-center p-2">
       <div className="flex-1 flex items-center justify-center p-2">
         <div className="relative w-full h-full flex items-center justify-center">
         <div className="relative w-full h-full flex items-center justify-center">
-          {(streamLoading || transitioning) && (
+          {(streamLoading || transitioning) && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
               <div className="text-center">
               <div className="text-center">
                 <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
                 <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
@@ -244,7 +341,24 @@ export function CameraPage() {
               </div>
               </div>
             </div>
             </div>
           )}
           )}
-          {streamError && (
+          {isReconnecting && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
+              <div className="text-center p-4">
+                <WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
+                <p className="text-white mb-2">Connection lost</p>
+                <p className="text-sm text-bambu-gray mb-3">
+                  Reconnecting in {reconnectCountdown}s... (attempt {reconnectAttempts + 1}/{MAX_RECONNECT_ATTEMPTS})
+                </p>
+                <button
+                  onClick={refresh}
+                  className="px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors"
+                >
+                  Reconnect now
+                </button>
+              </div>
+            </div>
+          )}
+          {streamError && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
               <div className="text-center p-4">
               <div className="text-center p-4">
                 <AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
                 <AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />

+ 176 - 23
frontend/src/pages/MaintenancePage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
 import {
   Wrench,
   Wrench,
@@ -32,6 +32,7 @@ import {
   Settings,
   Settings,
   Filter,
   Filter,
   CircleDot,
   CircleDot,
+  Printer,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
 import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
@@ -401,13 +402,17 @@ function SettingsSection({
   onAddType,
   onAddType,
   onUpdateType,
   onUpdateType,
   onDeleteType,
   onDeleteType,
+  onAssignType,
+  onRemoveItem,
 }: {
 }: {
   overview: PrinterMaintenanceOverview[] | undefined;
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
   types: MaintenanceType[];
   onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;
   onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;
-  onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }) => void;
+  onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }, printerIds: number[]) => void;
   onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void;
   onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void;
   onDeleteType: (id: number) => void;
   onDeleteType: (id: number) => void;
+  onAssignType: (printerId: number, typeId: number) => void;
+  onRemoveItem: (itemId: number) => void;
 }) {
 }) {
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [intervalInput, setIntervalInput] = useState('');
   const [intervalInput, setIntervalInput] = useState('');
@@ -417,6 +422,33 @@ function SettingsSection({
   const [newTypeInterval, setNewTypeInterval] = useState('100');
   const [newTypeInterval, setNewTypeInterval] = useState('100');
   const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
   const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
   const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
   const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
+  const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
+  const [expandedType, setExpandedType] = useState<number | null>(null);
+
+  // Get unique printers from overview
+  const printers = useMemo(() => {
+    if (!overview) return [];
+    return overview.map(o => ({ id: o.printer_id, name: o.printer_name }));
+  }, [overview]);
+
+  // Get which printers have a specific maintenance type assigned
+  const getAssignedPrinters = (typeId: number) => {
+    if (!overview) return [];
+    return overview
+      .filter(p => p.maintenance_items.some(item => item.maintenance_type_id === typeId))
+      .map(p => ({
+        printerId: p.printer_id,
+        printerName: p.printer_name,
+        itemId: p.maintenance_items.find(item => item.maintenance_type_id === typeId)?.id,
+      }));
+  };
+
+  // Get printers that DON'T have a specific type assigned
+  const getUnassignedPrinters = (typeId: number) => {
+    if (!overview) return [];
+    const assignedIds = new Set(getAssignedPrinters(typeId).map(p => p.printerId));
+    return printers.filter(p => !assignedIds.has(p.id));
+  };
 
 
   // Edit type state
   // Edit type state
   const [editingType, setEditingType] = useState<MaintenanceType | null>(null);
   const [editingType, setEditingType] = useState<MaintenanceType | null>(null);
@@ -460,20 +492,33 @@ function SettingsSection({
 
 
   const handleAddType = (e: React.FormEvent) => {
   const handleAddType = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
-    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0) {
+    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0 && selectedPrinters.size > 0) {
       onAddType({
       onAddType({
         name: newTypeName.trim(),
         name: newTypeName.trim(),
         default_interval_hours: parseFloat(newTypeInterval),
         default_interval_hours: parseFloat(newTypeInterval),
         interval_type: newTypeIntervalType,
         interval_type: newTypeIntervalType,
         icon: newTypeIcon,
         icon: newTypeIcon,
-      });
+      }, Array.from(selectedPrinters));
       setNewTypeName('');
       setNewTypeName('');
       setNewTypeInterval('100');
       setNewTypeInterval('100');
       setNewTypeIntervalType('hours');
       setNewTypeIntervalType('hours');
+      setSelectedPrinters(new Set());
       setShowAddType(false);
       setShowAddType(false);
     }
     }
   };
   };
 
 
+  const togglePrinterSelection = (printerId: number) => {
+    setSelectedPrinters(prev => {
+      const next = new Set(prev);
+      if (next.has(printerId)) {
+        next.delete(printerId);
+      } else {
+        next.add(printerId);
+      }
+      return next;
+    });
+  };
+
   const printerItems = overview?.map(p => ({
   const printerItems = overview?.map(p => ({
     printerId: p.printer_id,
     printerId: p.printer_id,
     printerName: p.printer_name,
     printerName: p.printer_name,
@@ -570,14 +615,37 @@ function SettingsSection({
                       })}
                       })}
                     </div>
                     </div>
                   </div>
                   </div>
-                  <div className="flex gap-2">
-                    <Button type="button" variant="secondary" onClick={() => setShowAddType(false)}>
-                      Cancel
-                    </Button>
-                    <Button type="submit" disabled={!newTypeName.trim()}>
-                      Add Type
-                    </Button>
+                </div>
+                {/* Printer selection */}
+                <div className="mt-4">
+                  <label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
+                  <div className="flex flex-wrap gap-2">
+                    {printers.map(p => (
+                      <button
+                        key={p.id}
+                        type="button"
+                        onClick={() => togglePrinterSelection(p.id)}
+                        className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
+                          selectedPrinters.has(p.id)
+                            ? 'bg-bambu-green text-white'
+                            : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                        }`}
+                      >
+                        {p.name}
+                      </button>
+                    ))}
                   </div>
                   </div>
+                  {selectedPrinters.size === 0 && (
+                    <p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
+                  )}
+                </div>
+                <div className="mt-4 flex justify-end gap-2">
+                  <Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
+                    Cancel
+                  </Button>
+                  <Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
+                    Add Type
+                  </Button>
                 </div>
                 </div>
               </form>
               </form>
             </CardContent>
             </CardContent>
@@ -674,6 +742,10 @@ function SettingsSection({
               );
               );
             }
             }
 
 
+            const assignedPrinters = getAssignedPrinters(type.id);
+            const unassignedPrinters = getUnassignedPrinters(type.id);
+            const isExpanded = expandedType === type.id;
+
             return (
             return (
               <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30">
               <div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30">
                 <div className="flex items-center gap-3">
                 <div className="flex items-center gap-3">
@@ -692,6 +764,19 @@ function SettingsSection({
                       {formatIntervalLabel(type.default_interval_hours, intervalType)}
                       {formatIntervalLabel(type.default_interval_hours, intervalType)}
                     </div>
                     </div>
                   </div>
                   </div>
+                  <button
+                    onClick={() => setExpandedType(isExpanded ? null : type.id)}
+                    className={`px-2 py-1 rounded-lg border transition-colors flex items-center gap-1 ${
+                      assignedPrinters.length > 0
+                        ? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
+                        : 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
+                    }`}
+                    title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
+                  >
+                    <Printer className="w-3 h-3" />
+                    <span className="text-xs font-medium">{assignedPrinters.length}</span>
+                    <ChevronDown className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
+                  </button>
                   <button
                   <button
                     onClick={() => startEditType(type)}
                     onClick={() => startEditType(type)}
                     className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
                     className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
@@ -709,6 +794,48 @@ function SettingsSection({
                     <Trash2 className="w-4 h-4" />
                     <Trash2 className="w-4 h-4" />
                   </button>
                   </button>
                 </div>
                 </div>
+
+                {/* Printer assignment management */}
+                {isExpanded && (
+                  <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
+                    <p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
+                    {assignedPrinters.length === 0 ? (
+                      <p className="text-xs text-orange-400">No printers assigned</p>
+                    ) : (
+                      <div className="flex flex-wrap gap-1 mb-2">
+                        {assignedPrinters.map(p => (
+                          <span
+                            key={p.printerId}
+                            className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark rounded text-xs text-white"
+                          >
+                            {p.printerName}
+                            <button
+                              onClick={() => p.itemId && onRemoveItem(p.itemId)}
+                              className="hover:text-red-400 ml-1"
+                              title="Remove from this printer"
+                            >
+                              ×
+                            </button>
+                          </span>
+                        ))}
+                      </div>
+                    )}
+                    {unassignedPrinters.length > 0 && (
+                      <div className="flex flex-wrap gap-1">
+                        <span className="text-xs text-bambu-gray mr-1">Add:</span>
+                        {unassignedPrinters.map(p => (
+                          <button
+                            key={p.id}
+                            onClick={() => onAssignType(p.id, type.id)}
+                            className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
+                          >
+                            + {p.name}
+                          </button>
+                        ))}
+                      </div>
+                    )}
+                  </div>
+                )}
               </div>
               </div>
             );
             );
           })}
           })}
@@ -850,17 +977,8 @@ export function MaintenancePage() {
     },
     },
   });
   });
 
 
-  const addTypeMutation = useMutation({
-    mutationFn: api.createMaintenanceType,
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
-      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
-      showToast('Maintenance type added');
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
+  // addTypeMutation removed - we now handle type creation with printer assignment
+  // directly in onAddType callback
 
 
   const updateTypeMutation = useMutation({
   const updateTypeMutation = useMutation({
     mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>
     mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>
@@ -900,6 +1018,29 @@ export function MaintenancePage() {
     },
     },
   });
   });
 
 
+  const assignTypeMutation = useMutation({
+    mutationFn: ({ printerId, typeId }: { printerId: number; typeId: number }) =>
+      api.assignMaintenanceType(printerId, typeId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      showToast('Printer assigned');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const removeItemMutation = useMutation({
+    mutationFn: api.removeMaintenanceItem,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      showToast('Printer removed');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
   const handlePerform = (id: number) => {
   const handlePerform = (id: number) => {
     performMutation.mutate({ id });
     performMutation.mutate({ id });
   };
   };
@@ -1002,9 +1143,21 @@ export function MaintenancePage() {
           onUpdateInterval={(id, data) =>
           onUpdateInterval={(id, data) =>
             updateMutation.mutate({ id, data })
             updateMutation.mutate({ id, data })
           }
           }
-          onAddType={(data) => addTypeMutation.mutate(data)}
+          onAddType={async (data, printerIds) => {
+            // Create the type first, then assign to selected printers
+            const newType = await api.createMaintenanceType(data);
+            // Assign to each selected printer
+            for (const printerId of printerIds) {
+              await api.assignMaintenanceType(printerId, newType.id);
+            }
+            queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
+            queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+            showToast('Maintenance type added');
+          }}
           onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
           onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
+          onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
+          onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
         />
         />
       )}
       )}
     </div>
     </div>

+ 425 - 59
frontend/src/pages/PrintersPage.tsx

@@ -6,7 +6,6 @@ import {
   Link,
   Link,
   Unlink,
   Unlink,
   Signal,
   Signal,
-  Thermometer,
   Clock,
   Clock,
   MoreVertical,
   MoreVertical,
   Trash2,
   Trash2,
@@ -27,10 +26,12 @@ import {
   LayoutList,
   LayoutList,
   Layers,
   Layers,
   Video,
   Video,
+  Search,
+  Loader2,
 } from 'lucide-react';
 } from 'lucide-react';
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
-import { api } from '../api/client';
-import type { Printer, PrinterCreate, AMSUnit } from '../api/client';
+import { api, discoveryApi } from '../api/client';
+import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -198,6 +199,47 @@ function ThermometerFull({ className }: { className?: string }) {
   );
   );
 }
 }
 
 
+// Heater thermometer icon - filled when heating, outline when off
+interface HeaterThermometerProps {
+  className?: string;
+  color: string;  // The color class (e.g., "text-orange-400")
+  isHeating: boolean;
+}
+
+function HeaterThermometer({ className, color, isHeating }: HeaterThermometerProps) {
+  // Extract the actual color from Tailwind class for SVG fill
+  const colorMap: Record<string, string> = {
+    'text-orange-400': '#fb923c',
+    'text-blue-400': '#60a5fa',
+    'text-green-400': '#4ade80',
+  };
+  const fillColor = colorMap[color] || '#888';
+
+  // Glow style when heating
+  const glowStyle = isHeating ? {
+    filter: `drop-shadow(0 0 4px ${fillColor}) drop-shadow(0 0 8px ${fillColor})`,
+  } : {};
+
+  if (isHeating) {
+    // Filled thermometer with glow - heater is ON
+    return (
+      <svg className={className} style={glowStyle} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+        <rect x="4.5" y="3" width="3" height="9.5" fill={fillColor} rx="0.5"/>
+        <circle cx="6" cy="15" r="2" fill={fillColor}/>
+        <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke={fillColor} strokeWidth="1" fill="none"/>
+      </svg>
+    );
+  }
+
+  // Empty thermometer - heater is OFF
+  return (
+    <svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke={fillColor} strokeWidth="1" fill="none"/>
+      <circle cx="6" cy="15" r="2.5" stroke={fillColor} strokeWidth="1" fill="none"/>
+    </svg>
+  );
+}
+
 // Humidity indicator with water drop that fills based on level (Bambu Lab style)
 // Humidity indicator with water drop that fills based on level (Bambu Lab style)
 // Reference: https://github.com/theicedmango/bambu-humidity
 // Reference: https://github.com/theicedmango/bambu-humidity
 interface HumidityIndicatorProps {
 interface HumidityIndicatorProps {
@@ -504,6 +546,34 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
 type SortOption = 'name' | 'status' | 'model' | 'location';
 type SortOption = 'name' | 'status' | 'model' | 'location';
 type ViewMode = 'expanded' | 'compact';
 type ViewMode = 'expanded' | 'compact';
 
 
+/**
+ * Get human-readable status display text for a printer.
+ * Uses stg_cur_name for detailed calibration/preparation stages,
+ * otherwise formats the gcode_state nicely.
+ */
+function getStatusDisplay(state: string | null | undefined, stg_cur_name: string | null | undefined): string {
+  // If we have a specific stage name (calibration, heating, etc.), use it
+  if (stg_cur_name) {
+    return stg_cur_name;
+  }
+
+  // Format the gcode_state nicely
+  switch (state) {
+    case 'RUNNING':
+      return 'Printing';
+    case 'PAUSE':
+      return 'Paused';
+    case 'FINISH':
+      return 'Finished';
+    case 'FAILED':
+      return 'Failed';
+    case 'IDLE':
+      return 'Idle';
+    default:
+      return state ? state.charAt(0) + state.slice(1).toLowerCase() : 'Idle';
+  }
+}
+
 function PrinterCard({
 function PrinterCard({
   printer,
   printer,
   hideIfDisconnected,
   hideIfDisconnected,
@@ -526,6 +596,7 @@ function PrinterCard({
   const navigate = useNavigate();
   const navigate = useNavigate();
   const [showMenu, setShowMenu] = useState(false);
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [deleteArchives, setDeleteArchives] = useState(true);
   const [showEditModal, setShowEditModal] = useState(false);
   const [showEditModal, setShowEditModal] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showMQTTDebug, setShowMQTTDebug] = useState(false);
   const [showMQTTDebug, setShowMQTTDebug] = useState(false);
@@ -615,9 +686,11 @@ function PrinterCard({
   const shouldHide = hideIfDisconnected && isConnected === false;
   const shouldHide = hideIfDisconnected && isConnected === false;
 
 
   const deleteMutation = useMutation({
   const deleteMutation = useMutation({
-    mutationFn: () => api.deletePrinter(printer.id),
+    mutationFn: (options: { deleteArchives: boolean }) =>
+      api.deletePrinter(printer.id, options.deleteArchives),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
       queryClient.invalidateQueries({ queryKey: ['printers'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
     },
     },
   });
   });
 
 
@@ -849,17 +922,64 @@ function PrinterCard({
 
 
         {/* Delete Confirmation */}
         {/* Delete Confirmation */}
         {showDeleteConfirm && (
         {showDeleteConfirm && (
-          <ConfirmModal
-            title="Delete Printer"
-            message={`Are you sure you want to delete "${printer.name}"? This will also remove all connection settings.`}
-            confirmText="Delete"
-            variant="danger"
-            onConfirm={() => {
-              deleteMutation.mutate();
-              setShowDeleteConfirm(false);
-            }}
-            onCancel={() => setShowDeleteConfirm(false)}
-          />
+          <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+            <Card className="w-full max-w-md mx-4">
+              <CardContent>
+                <div className="flex items-start gap-3 mb-4">
+                  <div className="p-2 rounded-full bg-red-500/20">
+                    <AlertTriangle className="w-5 h-5 text-red-400" />
+                  </div>
+                  <div>
+                    <h3 className="text-lg font-semibold text-white">Delete Printer</h3>
+                    <p className="text-sm text-bambu-gray mt-1">
+                      Are you sure you want to delete "{printer.name}"? This will remove all connection settings.
+                    </p>
+                  </div>
+                </div>
+
+                <div className="bg-bambu-dark rounded-lg p-3 mb-4">
+                  <label className="flex items-start gap-3 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={deleteArchives}
+                      onChange={(e) => setDeleteArchives(e.target.checked)}
+                      className="mt-0.5 w-4 h-4 rounded border-bambu-gray bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                    />
+                    <div>
+                      <span className="text-sm text-white">Delete print archives</span>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {deleteArchives
+                          ? 'All print history for this printer will be permanently deleted.'
+                          : 'Print history will be kept but no longer associated with this printer.'}
+                      </p>
+                    </div>
+                  </label>
+                </div>
+
+                <div className="flex justify-end gap-2">
+                  <Button
+                    variant="secondary"
+                    onClick={() => {
+                      setShowDeleteConfirm(false);
+                      setDeleteArchives(true);
+                    }}
+                  >
+                    Cancel
+                  </Button>
+                  <Button
+                    variant="danger"
+                    onClick={() => {
+                      deleteMutation.mutate({ deleteArchives });
+                      setShowDeleteConfirm(false);
+                      setDeleteArchives(true);
+                    }}
+                  >
+                    Delete
+                  </Button>
+                </div>
+              </CardContent>
+            </Card>
+          </div>
         )}
         )}
 
 
         {/* Status */}
         {/* Status */}
@@ -879,7 +999,7 @@ function PrinterCard({
                     <span className="text-xs text-white">{Math.round(status.progress || 0)}%</span>
                     <span className="text-xs text-white">{Math.round(status.progress || 0)}%</span>
                   </div>
                   </div>
                 ) : (
                 ) : (
-                  <p className="text-xs text-bambu-gray capitalize">{status.state?.toLowerCase() || 'Idle'}</p>
+                  <p className="text-xs text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
                 )}
                 )}
               </div>
               </div>
             ) : (
             ) : (
@@ -897,7 +1017,7 @@ function PrinterCard({
                     <div className="flex-1 min-w-0">
                     <div className="flex-1 min-w-0">
                       {status.current_print && status.state === 'RUNNING' ? (
                       {status.current_print && status.state === 'RUNNING' ? (
                         <>
                         <>
-                          <p className="text-sm text-bambu-gray mb-1">Printing</p>
+                          <p className="text-sm text-bambu-gray mb-1">{status.stg_cur_name || 'Printing'}</p>
                           <p className="text-white text-sm mb-2 truncate">
                           <p className="text-white text-sm mb-2 truncate">
                             {status.subtask_name || status.current_print}
                             {status.subtask_name || status.current_print}
                           </p>
                           </p>
@@ -933,8 +1053,8 @@ function PrinterCard({
                       ) : (
                       ) : (
                         <>
                         <>
                           <p className="text-sm text-bambu-gray mb-1">Status</p>
                           <p className="text-sm text-bambu-gray mb-1">Status</p>
-                          <p className="text-white text-sm mb-2 capitalize">
-                            {status.state?.toLowerCase() || 'Idle'}
+                          <p className="text-white text-sm mb-2">
+                            {getStatusDisplay(status.state, status.stg_cur_name)}
                           </p>
                           </p>
                           <div className="flex items-center justify-between text-sm">
                           <div className="flex items-center justify-between text-sm">
                             <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
                             <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
@@ -968,45 +1088,52 @@ function PrinterCard({
             )}
             )}
 
 
             {/* Temperatures */}
             {/* Temperatures */}
-            {status.temperatures && viewMode === 'expanded' && (
-              <div className="grid grid-cols-3 gap-3">
-                {/* Nozzle temp - combined for dual nozzle */}
-                <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                  <Thermometer className="w-4 h-4 mx-auto mb-1 text-orange-400" />
-                  {status.temperatures.nozzle_2 !== undefined ? (
-                    <>
-                      <p className="text-xs text-bambu-gray">Left / Right</p>
-                      <p className="text-sm text-white">
-                        {Math.round(status.temperatures.nozzle || 0)}°C / {Math.round(status.temperatures.nozzle_2 || 0)}°C
-                      </p>
-                    </>
-                  ) : (
-                    <>
-                      <p className="text-xs text-bambu-gray">Nozzle</p>
-                      <p className="text-sm text-white">
-                        {Math.round(status.temperatures.nozzle || 0)}°C
-                      </p>
-                    </>
-                  )}
-                </div>
-                <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                  <Thermometer className="w-4 h-4 mx-auto mb-1 text-blue-400" />
-                  <p className="text-xs text-bambu-gray">Bed</p>
-                  <p className="text-sm text-white">
-                    {Math.round(status.temperatures.bed || 0)}°C
-                  </p>
-                </div>
-                {status.temperatures.chamber !== undefined && (
+            {status.temperatures && viewMode === 'expanded' && (() => {
+              // Use actual heater states from MQTT stream
+              const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
+              const bedHeating = status.temperatures.bed_heating || false;
+              const chamberHeating = status.temperatures.chamber_heating || false;
+
+              return (
+                <div className="grid grid-cols-3 gap-3">
+                  {/* Nozzle temp - combined for dual nozzle */}
+                  <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                    <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-orange-400" isHeating={nozzleHeating} />
+                    {status.temperatures.nozzle_2 !== undefined ? (
+                      <>
+                        <p className="text-xs text-bambu-gray">Left / Right</p>
+                        <p className="text-sm text-white">
+                          {Math.round(status.temperatures.nozzle || 0)}°C / {Math.round(status.temperatures.nozzle_2 || 0)}°C
+                        </p>
+                      </>
+                    ) : (
+                      <>
+                        <p className="text-xs text-bambu-gray">Nozzle</p>
+                        <p className="text-sm text-white">
+                          {Math.round(status.temperatures.nozzle || 0)}°C
+                        </p>
+                      </>
+                    )}
+                  </div>
                   <div className="text-center p-2 bg-bambu-dark rounded-lg">
                   <div className="text-center p-2 bg-bambu-dark rounded-lg">
-                    <Thermometer className="w-4 h-4 mx-auto mb-1 text-green-400" />
-                    <p className="text-xs text-bambu-gray">Chamber</p>
+                    <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-blue-400" isHeating={bedHeating} />
+                    <p className="text-xs text-bambu-gray">Bed</p>
                     <p className="text-sm text-white">
                     <p className="text-sm text-white">
-                      {Math.round(status.temperatures.chamber || 0)}°C
+                      {Math.round(status.temperatures.bed || 0)}°C
                     </p>
                     </p>
                   </div>
                   </div>
-                )}
-              </div>
-            )}
+                  {status.temperatures.chamber !== undefined && (
+                    <div className="text-center p-2 bg-bambu-dark rounded-lg">
+                      <HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-green-400" isHeating={chamberHeating} />
+                      <p className="text-xs text-bambu-gray">Chamber</p>
+                      <p className="text-sm text-white">
+                        {Math.round(status.temperatures.chamber || 0)}°C
+                      </p>
+                    </div>
+                  )}
+                </div>
+              );
+            })()}
 
 
             {/* AMS Units with Device Icons, Humidity & Temperature */}
             {/* AMS Units with Device Icons, Humidity & Temperature */}
             {amsData && amsData.length > 0 && viewMode === 'expanded' && (
             {amsData && amsData.length > 0 && viewMode === 'expanded' && (
@@ -1034,9 +1161,9 @@ function PrinterCard({
 
 
                   return (
                   return (
                     <div key={ams.id} className="p-2 bg-bambu-dark rounded-lg">
                     <div key={ams.id} className="p-2 bg-bambu-dark rounded-lg">
-                      <div className="flex items-center gap-3">
+                      <div className="flex flex-wrap items-center gap-2 sm:gap-3">
                         {/* Nozzle badge + AMS device icon */}
                         {/* Nozzle badge + AMS device icon */}
-                        <div className="flex items-center gap-1">
+                        <div className="flex items-center gap-1 flex-shrink-0">
                           {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                           {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                             <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                             <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                           )}
                           )}
@@ -1063,7 +1190,7 @@ function PrinterCard({
                             {ams.tray.map((tray, i) => (
                             {ams.tray.map((tray, i) => (
                               <div key={i} className="flex items-start">
                               <div key={i} className="flex items-start">
                                 <div className="flex flex-col">
                                 <div className="flex flex-col">
-                                  <span className="text-bambu-gray/70 truncate">
+                                  <span className="text-bambu-gray/70 truncate max-w-[60px] sm:max-w-none">
                                     {tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
                                     {tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
                                   </span>
                                   </span>
                                   <span className="text-bambu-gray/50 truncate">
                                   <span className="text-bambu-gray/50 truncate">
@@ -1080,9 +1207,9 @@ function PrinterCard({
                             ))}
                             ))}
                           </div>
                           </div>
                         </div>
                         </div>
-                        {/* Humidity/temp - vertically centered */}
+                        {/* Humidity/temp - responsive positioning */}
                         {(ams.humidity != null || ams.temp != null) && (
                         {(ams.humidity != null || ams.temp != null) && (
-                          <div className="flex items-center gap-2 text-xs flex-shrink-0">
+                          <div className="flex items-center gap-2 text-xs flex-shrink-0 ml-auto">
                             {ams.humidity != null && (
                             {ams.humidity != null && (
                               <HumidityIndicator
                               <HumidityIndicator
                                 humidity={ams.humidity}
                                 humidity={ams.humidity}
@@ -1349,9 +1476,11 @@ function PrinterCard({
 function AddPrinterModal({
 function AddPrinterModal({
   onClose,
   onClose,
   onAdd,
   onAdd,
+  existingSerials,
 }: {
 }: {
   onClose: () => void;
   onClose: () => void;
   onAdd: (data: PrinterCreate) => void;
   onAdd: (data: PrinterCreate) => void;
+  existingSerials: string[];
 }) {
 }) {
   const [form, setForm] = useState<PrinterCreate>({
   const [form, setForm] = useState<PrinterCreate>({
     name: '',
     name: '',
@@ -1362,6 +1491,153 @@ function AddPrinterModal({
     auto_archive: true,
     auto_archive: true,
   });
   });
 
 
+  // Discovery state
+  const [discovering, setDiscovering] = useState(false);
+  const [discovered, setDiscovered] = useState<DiscoveredPrinter[]>([]);
+  const [discoveryError, setDiscoveryError] = useState('');
+  const [hasScanned, setHasScanned] = useState(false);
+  const [isDocker, setIsDocker] = useState(false);
+  const [subnet, setSubnet] = useState('192.168.1.0/24');
+  const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
+
+  // Fetch discovery info on mount
+  useEffect(() => {
+    discoveryApi.getInfo().then(info => {
+      setIsDocker(info.is_docker);
+    }).catch(() => {
+      // Ignore errors, assume not Docker
+    });
+  }, []);
+
+  // Filter out already-added printers
+  const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));
+
+  const startDiscovery = async () => {
+    setDiscoveryError('');
+    setDiscovered([]);
+    setDiscovering(true);
+    setHasScanned(false);
+    setScanProgress({ scanned: 0, total: 0 });
+
+    try {
+      if (isDocker) {
+        // Use subnet scanning for Docker
+        await discoveryApi.startSubnetScan(subnet);
+
+        // Poll for scan status and results
+        const pollInterval = setInterval(async () => {
+          try {
+            const status = await discoveryApi.getScanStatus();
+            setScanProgress({ scanned: status.scanned, total: status.total });
+
+            const printers = await discoveryApi.getDiscoveredPrinters();
+            setDiscovered(printers);
+
+            if (!status.running) {
+              clearInterval(pollInterval);
+              setDiscovering(false);
+              setHasScanned(true);
+            }
+          } catch (e) {
+            console.error('Failed to get scan status:', e);
+          }
+        }, 500);
+      } else {
+        // Use SSDP discovery for native installs
+        await discoveryApi.startDiscovery(10);
+
+        // Poll for discovered printers every second
+        const pollInterval = setInterval(async () => {
+          try {
+            const printers = await discoveryApi.getDiscoveredPrinters();
+            setDiscovered(printers);
+          } catch (e) {
+            console.error('Failed to get discovered printers:', e);
+          }
+        }, 1000);
+
+        // Stop after 10 seconds
+        setTimeout(async () => {
+          clearInterval(pollInterval);
+          try {
+            await discoveryApi.stopDiscovery();
+          } catch (e) {
+            // Ignore stop errors
+          }
+          setDiscovering(false);
+          setHasScanned(true);
+          // Final fetch
+          try {
+            const printers = await discoveryApi.getDiscoveredPrinters();
+            setDiscovered(printers);
+          } catch (e) {
+            console.error('Failed to get final discovered printers:', e);
+          }
+        }, 10000);
+      }
+    } catch (e) {
+      console.error('Failed to start discovery:', e);
+      setDiscoveryError(e instanceof Error ? e.message : 'Failed to start discovery');
+      setDiscovering(false);
+      setHasScanned(true);
+    }
+  };
+
+  // Map SSDP model codes to dropdown values
+  const mapModelCode = (ssdpModel: string | null): string => {
+    if (!ssdpModel) return '';
+    const modelMap: Record<string, string> = {
+      // H2 Series
+      'O1D': 'H2D',
+      'O1C': 'H2C',
+      'O1S': 'H2S',
+      // X1 Series
+      'BL-P001': 'X1C',
+      'BL-P002': 'X1',
+      'BL-P003': 'X1E',
+      // P Series
+      'C11': 'P1S',
+      'C12': 'P1P',
+      'C13': 'P2S',
+      // A1 Series
+      'N2S': 'A1',
+      'N1': 'A1 Mini',
+      // Direct matches
+      'X1C': 'X1C',
+      'X1': 'X1',
+      'X1E': 'X1E',
+      'P1S': 'P1S',
+      'P1P': 'P1P',
+      'P2S': 'P2S',
+      'A1': 'A1',
+      'A1 Mini': 'A1 Mini',
+      'H2D': 'H2D',
+      'H2C': 'H2C',
+      'H2S': 'H2S',
+    };
+    return modelMap[ssdpModel] || ssdpModel;
+  };
+
+  const selectPrinter = (printer: DiscoveredPrinter) => {
+    setForm({
+      ...form,
+      name: printer.name || '',
+      serial_number: printer.serial,
+      ip_address: printer.ip_address,
+      model: mapModelCode(printer.model),
+    });
+    // Clear discovery results after selection
+    setDiscovered([]);
+  };
+
+  // Cleanup discovery on unmount
+  useEffect(() => {
+    return () => {
+      discoveryApi.stopDiscovery().catch(() => {});
+      discoveryApi.stopSubnetScan().catch(() => {});
+    };
+  }, []);
+
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -1379,6 +1655,95 @@ function AddPrinterModal({
       <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
       <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
         <CardContent>
         <CardContent>
           <h2 className="text-xl font-semibold mb-4">Add Printer</h2>
           <h2 className="text-xl font-semibold mb-4">Add Printer</h2>
+
+          {/* Discovery Section */}
+          <div className="mb-4 pb-4 border-b border-bambu-dark-tertiary">
+            {isDocker && (
+              <div className="mb-3">
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Subnet to scan
+                </label>
+                <input
+                  type="text"
+                  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 text-sm"
+                  value={subnet}
+                  onChange={(e) => setSubnet(e.target.value)}
+                  placeholder="192.168.1.0/24"
+                  disabled={discovering}
+                />
+                <p className="mt-1 text-xs text-bambu-gray">
+                  Docker detected. Enter your printer's subnet in CIDR notation.
+                  Requires <code className="text-bambu-green">network_mode: host</code> in docker-compose.yml.
+                </p>
+              </div>
+            )}
+
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={startDiscovery}
+              disabled={discovering}
+              className="w-full"
+            >
+              {discovering ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {isDocker && scanProgress.total > 0
+                    ? `Scanning... ${scanProgress.scanned}/${scanProgress.total}`
+                    : 'Scanning...'}
+                </>
+              ) : (
+                <>
+                  <Search className="w-4 h-4" />
+                  {isDocker ? 'Scan Subnet for Printers' : 'Discover Printers on Network'}
+                </>
+              )}
+            </Button>
+
+            {discoveryError && (
+              <div className="mt-2 text-sm text-red-400">{discoveryError}</div>
+            )}
+
+            {newPrinters.length > 0 && (
+              <div className="mt-3 space-y-2 max-h-40 overflow-y-auto">
+                {newPrinters.map((printer) => (
+                  <div
+                    key={printer.serial}
+                    className="flex items-center justify-between p-2 bg-bambu-dark rounded-lg hover:bg-bambu-dark-secondary cursor-pointer transition-colors"
+                    onClick={() => selectPrinter(printer)}
+                  >
+                    <div className="min-w-0 flex-1">
+                      <p className="font-medium text-white text-sm truncate">
+                        {printer.name || printer.serial}
+                      </p>
+                      <p className="text-xs text-bambu-gray truncate">
+                        {mapModelCode(printer.model) || 'Unknown'} • {printer.ip_address}
+                      </p>
+                    </div>
+                    <ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />
+                  </div>
+                ))}
+              </div>
+            )}
+
+            {discovering && (
+              <p className="mt-2 text-sm text-bambu-gray text-center">
+                {isDocker ? 'Scanning subnet for Bambu printers...' : 'Scanning network...'}
+              </p>
+            )}
+
+            {hasScanned && !discovering && discovered.length === 0 && (
+              <p className="mt-2 text-sm text-bambu-gray text-center">
+                No printers found{isDocker ? ' in the specified subnet' : ' on the network'}.
+              </p>
+            )}
+
+            {hasScanned && !discovering && discovered.length > 0 && newPrinters.length === 0 && (
+              <p className="mt-2 text-sm text-bambu-gray text-center">
+                All discovered printers are already configured.
+              </p>
+            )}
+          </div>
           <form
           <form
             onSubmit={(e) => {
             onSubmit={(e) => {
               e.preventDefault();
               e.preventDefault();
@@ -2076,6 +2441,7 @@ export function PrintersPage() {
         <AddPrinterModal
         <AddPrinterModal
           onClose={() => setShowAddModal(false)}
           onClose={() => setShowAddModal(false)}
           onAdd={(data) => addMutation.mutate(data)}
           onAdd={(data) => addMutation.mutate(data)}
+          existingSerials={printers?.map(p => p.serial_number) || []}
         />
         />
       )}
       )}
     </div>
     </div>

+ 1169 - 0
frontend/src/pages/ProjectDetailPage.tsx

@@ -0,0 +1,1169 @@
+import { useState, useRef } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  ArrowLeft,
+  Edit3,
+  Loader2,
+  Package,
+  Clock,
+  CheckCircle,
+  XCircle,
+  ListTodo,
+  Printer,
+  ChevronRight,
+  FileText,
+  Tag,
+  Calendar,
+  AlertTriangle,
+  Save,
+  X,
+  Paperclip,
+  Upload,
+  Download,
+  Trash2,
+  File,
+  Plus,
+  History,
+  FolderTree,
+  Copy,
+  Layers,
+  ExternalLink,
+  ShoppingCart,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
+import { Card, CardContent } from '../components/Card';
+import { Button } from '../components/Button';
+import { useToast } from '../contexts/ToastContext';
+import { RichTextEditor } from '../components/RichTextEditor';
+import { ConfirmModal } from '../components/ConfirmModal';
+
+// Project edit modal (reused from ProjectsPage)
+import { ProjectModal } from './ProjectsPage';
+
+function formatDuration(hours: number): string {
+  if (hours < 1) {
+    return `${Math.round(hours * 60)}m`;
+  }
+  const h = Math.floor(hours);
+  const m = Math.round((hours - h) * 60);
+  return m > 0 ? `${h}h ${m}m` : `${h}h`;
+}
+
+function formatFilament(grams: number): string {
+  if (grams >= 1000) {
+    return `${(grams / 1000).toFixed(2)}kg`;
+  }
+  return `${Math.round(grams)}g`;
+}
+
+function StatusBadge({ status }: { status: string }) {
+  const colors = {
+    active: 'bg-bambu-green/20 text-bambu-green',
+    completed: 'bg-blue-500/20 text-blue-400',
+    archived: 'bg-bambu-gray/20 text-bambu-gray',
+  };
+  const color = colors[status as keyof typeof colors] || colors.active;
+
+  return (
+    <span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
+      {status.charAt(0).toUpperCase() + status.slice(1)}
+    </span>
+  );
+}
+
+function StatCard({
+  icon: Icon,
+  label,
+  value,
+  subValue,
+  color = 'text-bambu-gray',
+}: {
+  icon: React.ElementType;
+  label: string;
+  value: string | number;
+  subValue?: string;
+  color?: string;
+}) {
+  return (
+    <Card>
+      <CardContent className="p-4">
+        <div className="flex items-center gap-3">
+          <div className={`p-2 rounded-lg bg-bambu-dark ${color}`}>
+            <Icon className="w-5 h-5" />
+          </div>
+          <div>
+            <p className="text-sm text-bambu-gray">{label}</p>
+            <p className="text-xl font-semibold text-white">{value}</p>
+            {subValue && <p className="text-xs text-bambu-gray/70">{subValue}</p>}
+          </div>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+function ArchiveGrid({ archives }: { archives: Archive[] }) {
+  if (archives.length === 0) {
+    return (
+      <div className="text-center py-8 text-bambu-gray">
+        <Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
+        <p>No prints in this project yet</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
+      {archives.map((archive) => (
+        <Link
+          key={archive.id}
+          to={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
+          className="group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors"
+        >
+          {archive.thumbnail_path ? (
+            <img
+              src={`/api/v1/archives/${archive.id}/thumbnail`}
+              alt={archive.print_name || 'Print'}
+              className="w-full h-full object-cover"
+            />
+          ) : (
+            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+              <Package className="w-8 h-8" />
+            </div>
+          )}
+
+          {/* Status overlay */}
+          {archive.status === 'failed' && (
+            <div className="absolute inset-0 bg-red-500/30 flex items-center justify-center">
+              <XCircle className="w-8 h-8 text-white" />
+            </div>
+          )}
+          {archive.status === 'completed' && (
+            <div className="absolute top-1 right-1">
+              <CheckCircle className="w-4 h-4 text-bambu-green" />
+            </div>
+          )}
+
+          {/* Name overlay on hover */}
+          <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
+            <p className="text-xs text-white truncate">{archive.print_name || 'Unknown'}</p>
+          </div>
+        </Link>
+      ))}
+    </div>
+  );
+}
+
+function PriorityBadge({ priority }: { priority: string }) {
+  const config = {
+    low: { color: 'bg-gray-500/20 text-gray-400', label: 'Low' },
+    normal: { color: 'bg-blue-500/20 text-blue-400', label: 'Normal' },
+    high: { color: 'bg-orange-500/20 text-orange-400', label: 'High' },
+    urgent: { color: 'bg-red-500/20 text-red-400', label: 'Urgent' },
+  };
+  const { color, label } = config[priority as keyof typeof config] || config.normal;
+
+  return (
+    <span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 ${color}`}>
+      {priority === 'urgent' && <AlertTriangle className="w-3 h-3" />}
+      {label}
+    </span>
+  );
+}
+
+function formatDate(dateString: string | null): string {
+  if (!dateString) return '';
+  const date = new Date(dateString);
+  return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
+  if (!dateString) return null;
+  const dueDate = new Date(dateString);
+  const now = new Date();
+  const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
+
+  if (diffDays < 0) return { color: 'text-red-400', label: 'Overdue' };
+  if (diffDays === 0) return { color: 'text-orange-400', label: 'Due today' };
+  if (diffDays <= 3) return { color: 'text-yellow-400', label: `${diffDays} days left` };
+  return { color: 'text-bambu-gray', label: `${diffDays} days left` };
+}
+
+export function ProjectDetailPage() {
+  const { id } = useParams<{ id: string }>();
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [showEditModal, setShowEditModal] = useState(false);
+  const [editingNotes, setEditingNotes] = useState(false);
+  const [notesContent, setNotesContent] = useState('');
+  const [uploadingAttachment, setUploadingAttachment] = useState(false);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const projectId = parseInt(id || '0', 10);
+
+  const { data: project, isLoading: projectLoading, error: projectError } = useQuery({
+    queryKey: ['project', projectId],
+    queryFn: () => api.getProject(projectId),
+    enabled: projectId > 0,
+  });
+
+  const { data: archives, isLoading: archivesLoading } = useQuery({
+    queryKey: ['project-archives', projectId],
+    queryFn: () => api.getProjectArchives(projectId),
+    enabled: projectId > 0,
+  });
+
+  const { data: bomItems, isLoading: bomLoading } = useQuery({
+    queryKey: ['project-bom', projectId],
+    queryFn: () => api.getProjectBOM(projectId),
+    enabled: projectId > 0,
+  });
+
+  const { data: timeline, isLoading: timelineLoading } = useQuery({
+    queryKey: ['project-timeline', projectId],
+    queryFn: () => api.getProjectTimeline(projectId, 20),
+    enabled: projectId > 0,
+  });
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const currency = settings?.currency || '$';
+
+  const updateMutation = useMutation({
+    mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      setShowEditModal(false);
+      setEditingNotes(false);
+      showToast('Project updated', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleStartEditNotes = () => {
+    setNotesContent(project?.notes || '');
+    setEditingNotes(true);
+  };
+
+  const handleSaveNotes = () => {
+    updateMutation.mutate({ notes: notesContent });
+  };
+
+  const handleCancelNotes = () => {
+    setEditingNotes(false);
+    setNotesContent('');
+  };
+
+  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    setUploadingAttachment(true);
+    try {
+      const result = await api.uploadProjectAttachment(projectId, file);
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      showToast(`Uploaded: ${result.original_name}`, 'success');
+    } catch (error) {
+      showToast((error as Error).message, 'error');
+    } finally {
+      setUploadingAttachment(false);
+      if (fileInputRef.current) {
+        fileInputRef.current.value = '';
+      }
+    }
+  };
+
+  const handleDeleteAttachment = (filename: string, originalName: string) => {
+    setConfirmModal({
+      isOpen: true,
+      title: 'Delete Attachment',
+      message: `Are you sure you want to delete "${originalName}"?`,
+      onConfirm: async () => {
+        setConfirmModal(prev => ({ ...prev, isOpen: false }));
+        try {
+          await api.deleteProjectAttachment(projectId, filename);
+          queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+          showToast('Attachment deleted', 'success');
+        } catch (error) {
+          showToast((error as Error).message, 'error');
+        }
+      },
+    });
+  };
+
+  const formatFileSize = (bytes: number): string => {
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+  };
+
+  // BOM handlers
+  const [newBomName, setNewBomName] = useState('');
+  const [newBomQty, setNewBomQty] = useState(1);
+  const [newBomPrice, setNewBomPrice] = useState('');
+  const [newBomUrl, setNewBomUrl] = useState('');
+  const [newBomRemarks, setNewBomRemarks] = useState('');
+  const [showBomForm, setShowBomForm] = useState(false);
+  const [hideBomCompleted, setHideBomCompleted] = useState(false);
+
+  // Confirm modal state
+  const [confirmModal, setConfirmModal] = useState<{
+    isOpen: boolean;
+    title: string;
+    message: string;
+    onConfirm: () => void;
+  }>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
+
+  const createBomMutation = useMutation({
+    mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      setNewBomName('');
+      setNewBomQty(1);
+      setNewBomPrice('');
+      setNewBomUrl('');
+      setNewBomRemarks('');
+      setShowBomForm(false);
+      showToast('Part added', 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const updateBomMutation = useMutation({
+    mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_acquired?: number } }) =>
+      api.updateBOMItem(projectId, itemId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const deleteBomMutation = useMutation({
+    mutationFn: (itemId: number) => api.deleteBOMItem(projectId, itemId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
+      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      showToast('Part removed', 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const handleAddBomItem = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!newBomName.trim()) return;
+    createBomMutation.mutate({
+      name: newBomName.trim(),
+      quantity_needed: newBomQty,
+      unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined,
+      sourcing_url: newBomUrl.trim() || undefined,
+      remarks: newBomRemarks.trim() || undefined,
+    });
+  };
+
+  const handleToggleAcquired = (item: BOMItem) => {
+    const newQty = item.is_complete ? 0 : item.quantity_needed;
+    updateBomMutation.mutate({
+      itemId: item.id,
+      data: { quantity_acquired: newQty },
+    });
+  };
+
+  const handleDeleteBomItem = (itemId: number, itemName: string) => {
+    setConfirmModal({
+      isOpen: true,
+      title: 'Delete Part',
+      message: `Are you sure you want to delete "${itemName}"?`,
+      onConfirm: () => {
+        setConfirmModal(prev => ({ ...prev, isOpen: false }));
+        deleteBomMutation.mutate(itemId);
+      },
+    });
+  };
+
+  // Template handlers
+  const createTemplateMutation = useMutation({
+    mutationFn: () => api.createTemplateFromProject(projectId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      showToast('Template created', 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const formatTimelineDate = (timestamp: string) => {
+    const date = new Date(timestamp);
+    return date.toLocaleDateString(undefined, {
+      month: 'short',
+      day: 'numeric',
+      hour: '2-digit',
+      minute: '2-digit',
+    });
+  };
+
+  if (projectLoading) {
+    return (
+      <div className="flex items-center justify-center py-24">
+        <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  if (projectError || !project) {
+    return (
+      <div className="text-center py-24">
+        <p className="text-bambu-gray">
+          {projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'}
+        </p>
+        <Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
+          Back to Projects
+        </Button>
+      </div>
+    );
+  }
+
+  const stats = project.stats;
+  const progressPercent = stats?.progress_percent ?? 0;
+  const successRate = stats && stats.total_archives > 0
+    ? ((stats.completed_prints / stats.total_archives) * 100).toFixed(0)
+    : null;
+
+  return (
+    <div className="p-4 md:p-8 space-y-8">
+      {/* Breadcrumb */}
+      <div className="flex items-center gap-2 text-sm text-bambu-gray">
+        <Link to="/projects" className="hover:text-white transition-colors">
+          Projects
+        </Link>
+        <ChevronRight className="w-4 h-4" />
+        <span className="text-white">{project.name}</span>
+      </div>
+
+      {/* Header */}
+      <div className="flex items-start justify-between">
+        <div className="flex items-center gap-4">
+          <button
+            onClick={() => navigate('/projects')}
+            className="p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors"
+          >
+            <ArrowLeft className="w-5 h-5 text-bambu-gray" />
+          </button>
+          <div className="flex items-center gap-3">
+            <div
+              className="w-4 h-4 rounded-full flex-shrink-0"
+              style={{ backgroundColor: project.color || '#6b7280' }}
+            />
+            <div>
+              <h1 className="text-2xl font-bold text-white">{project.name}</h1>
+              {project.description && (
+                <p className="text-bambu-gray mt-1">{project.description}</p>
+              )}
+            </div>
+          </div>
+          <StatusBadge status={project.status} />
+        </div>
+        <Button onClick={() => setShowEditModal(true)}>
+          <Edit3 className="w-4 h-4 mr-2" />
+          Edit
+        </Button>
+      </div>
+
+      {/* Progress bar (if target set) */}
+      {project.target_count && (
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center justify-between mb-2">
+              <span className="text-sm text-bambu-gray">Progress</span>
+              <span className="text-sm font-medium text-white">
+                {stats?.completed_prints || 0} / {project.target_count} prints
+              </span>
+            </div>
+            <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+              <div
+                className="h-full transition-all duration-500"
+                style={{
+                  width: `${Math.min(progressPercent, 100)}%`,
+                  backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
+                }}
+              />
+            </div>
+            <div className="flex justify-between mt-1">
+              <span className="text-xs text-bambu-gray/70">
+                {progressPercent.toFixed(0)}% complete
+              </span>
+              {project.target_count - (stats?.completed_prints || 0) > 0 && (
+                <span className="text-xs text-bambu-gray/70">
+                  {project.target_count - (stats?.completed_prints || 0)} remaining
+                </span>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Stats grid */}
+      {stats && (
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+          <StatCard
+            icon={Package}
+            label="Total Prints"
+            value={stats.total_archives}
+            subValue={successRate ? `${successRate}% success rate` : undefined}
+            color="text-bambu-green"
+          />
+          <StatCard
+            icon={CheckCircle}
+            label="Completed"
+            value={stats.completed_prints}
+            subValue={stats.failed_prints > 0 ? `${stats.failed_prints} failed` : undefined}
+            color="text-blue-400"
+          />
+          <StatCard
+            icon={Clock}
+            label="Print Time"
+            value={formatDuration(stats.total_print_time_hours)}
+            color="text-yellow-400"
+          />
+          <StatCard
+            icon={Printer}
+            label="Filament Used"
+            value={formatFilament(stats.total_filament_grams)}
+            color="text-purple-400"
+          />
+        </div>
+      )}
+
+      {/* Cost tracking */}
+      {stats && (stats.estimated_cost > 0 || project.budget) && (
+        <Card>
+          <CardContent className="p-4">
+            <h2 className="text-lg font-semibold text-white mb-3">
+              Cost Tracking
+            </h2>
+            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+              <div>
+                <p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
+                <p className="text-lg font-semibold text-white">
+                  {currency}{stats.estimated_cost.toFixed(2)}
+                </p>
+              </div>
+              {stats.total_energy_kwh > 0 && (
+                <div>
+                  <p className="text-xs text-bambu-gray uppercase">Energy</p>
+                  <p className="text-lg font-semibold text-white">
+                    {stats.total_energy_kwh.toFixed(2)} kWh
+                    {stats.total_energy_cost > 0 && (
+                      <span className="text-sm text-bambu-gray ml-1">
+                        ({currency}{stats.total_energy_cost.toFixed(2)})
+                      </span>
+                    )}
+                  </p>
+                </div>
+              )}
+              {project.budget && (
+                <>
+                  <div>
+                    <p className="text-xs text-bambu-gray uppercase">Budget</p>
+                    <p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
+                  </div>
+                  <div>
+                    <p className="text-xs text-bambu-gray uppercase">Remaining</p>
+                    <p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
+                      {currency}{(project.budget - stats.estimated_cost).toFixed(2)}
+                    </p>
+                  </div>
+                </>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Sub-projects */}
+      {project.children && project.children.length > 0 && (
+        <Card>
+          <CardContent className="p-4">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
+              <FolderTree className="w-5 h-5" />
+              Sub-projects ({project.children.length})
+            </h2>
+            <div className="space-y-2">
+              {project.children.map((child) => (
+                <Link
+                  key={child.id}
+                  to={`/projects/${child.id}`}
+                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+                >
+                  <div className="flex items-center gap-3">
+                    <div
+                      className="w-3 h-3 rounded-full"
+                      style={{ backgroundColor: child.color || '#6b7280' }}
+                    />
+                    <span className="text-white">{child.name}</span>
+                    <span className={`text-xs px-2 py-0.5 rounded ${
+                      child.status === 'completed' ? 'bg-bambu-green/20 text-bambu-green' :
+                      child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
+                      'bg-blue-500/20 text-blue-400'
+                    }`}>
+                      {child.status}
+                    </span>
+                  </div>
+                  {child.progress_percent !== null && (
+                    <span className="text-sm text-bambu-gray">
+                      {child.progress_percent.toFixed(0)}%
+                    </span>
+                  )}
+                </Link>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Parent project link */}
+      {project.parent_id && project.parent_name && (
+        <div className="flex items-center gap-2 text-sm">
+          <Layers className="w-4 h-4 text-bambu-gray" />
+          <span className="text-bambu-gray">Part of:</span>
+          <Link
+            to={`/projects/${project.parent_id}`}
+            className="text-bambu-green hover:underline"
+          >
+            {project.parent_name}
+          </Link>
+        </div>
+      )}
+
+      {/* Meta info row - Tags, Due Date, Priority */}
+      {(project.tags || project.due_date || project.priority !== 'normal') && (
+        <div className="flex flex-wrap items-center gap-4">
+          {/* Priority */}
+          {project.priority && project.priority !== 'normal' && (
+            <div className="flex items-center gap-2">
+              <span className="text-xs text-bambu-gray uppercase">Priority:</span>
+              <PriorityBadge priority={project.priority} />
+            </div>
+          )}
+
+          {/* Due Date */}
+          {project.due_date && (
+            <div className="flex items-center gap-2">
+              <Calendar className="w-4 h-4 text-bambu-gray" />
+              <span className="text-sm text-white">{formatDate(project.due_date)}</span>
+              {getDueDateStatus(project.due_date) && (
+                <span className={`text-xs ${getDueDateStatus(project.due_date)!.color}`}>
+                  ({getDueDateStatus(project.due_date)!.label})
+                </span>
+              )}
+            </div>
+          )}
+
+          {/* Tags */}
+          {project.tags && (
+            <div className="flex items-center gap-2">
+              <Tag className="w-4 h-4 text-bambu-gray" />
+              <div className="flex flex-wrap gap-1">
+                {project.tags.split(',').map((tag, index) => (
+                  <span
+                    key={index}
+                    className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded"
+                  >
+                    {tag.trim()}
+                  </span>
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Notes section */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <FileText className="w-5 h-5" />
+              Notes
+            </h2>
+            {!editingNotes ? (
+              <Button variant="secondary" size="sm" onClick={handleStartEditNotes}>
+                <Edit3 className="w-4 h-4 mr-1" />
+                Edit
+              </Button>
+            ) : (
+              <div className="flex gap-2">
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={handleCancelNotes}
+                  disabled={updateMutation.isPending}
+                >
+                  <X className="w-4 h-4 mr-1" />
+                  Cancel
+                </Button>
+                <Button
+                  size="sm"
+                  onClick={handleSaveNotes}
+                  disabled={updateMutation.isPending}
+                >
+                  {updateMutation.isPending ? (
+                    <Loader2 className="w-4 h-4 animate-spin mr-1" />
+                  ) : (
+                    <Save className="w-4 h-4 mr-1" />
+                  )}
+                  Save
+                </Button>
+              </div>
+            )}
+          </div>
+
+          {editingNotes ? (
+            <RichTextEditor
+              content={notesContent}
+              onChange={setNotesContent}
+              placeholder="Add notes about this project..."
+            />
+          ) : project.notes ? (
+            <div
+              className="prose prose-invert prose-sm max-w-none"
+              dangerouslySetInnerHTML={{ __html: project.notes }}
+            />
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No notes yet. Click Edit to add notes.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Attachments section */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <Paperclip className="w-5 h-5" />
+              Attachments ({project.attachments?.length || 0})
+            </h2>
+            <div>
+              <input
+                ref={fileInputRef}
+                type="file"
+                onChange={handleFileSelect}
+                className="hidden"
+              />
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => fileInputRef.current?.click()}
+                disabled={uploadingAttachment}
+              >
+                {uploadingAttachment ? (
+                  <Loader2 className="w-4 h-4 animate-spin mr-1" />
+                ) : (
+                  <Upload className="w-4 h-4 mr-1" />
+                )}
+                Upload
+              </Button>
+            </div>
+          </div>
+
+          <p className="text-xs text-bambu-gray mb-3">
+            Upload any file: images (PNG, JPG), PDFs, STL files, or documents.
+          </p>
+
+          {project.attachments && project.attachments.length > 0 ? (
+            <div className="space-y-2">
+              {project.attachments.map((attachment) => (
+                <div
+                  key={attachment.filename}
+                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
+                >
+                  <div className="flex items-center gap-3 min-w-0">
+                    <File className="w-5 h-5 text-bambu-gray flex-shrink-0" />
+                    <div className="min-w-0">
+                      <p className="text-sm text-white truncate">
+                        {attachment.original_name}
+                      </p>
+                      <p className="text-xs text-bambu-gray">
+                        {formatFileSize(attachment.size)}
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-1 flex-shrink-0">
+                    <a
+                      href={api.getProjectAttachmentUrl(projectId, attachment.filename)}
+                      download={attachment.original_name}
+                      className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                      title="Download"
+                    >
+                      <Download className="w-4 h-4" />
+                    </a>
+                    <button
+                      onClick={() => handleDeleteAttachment(attachment.filename, attachment.original_name)}
+                      className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-red-400"
+                      title="Delete"
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No attachments yet. Click Upload to add files.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* BOM Section - Parts to source/purchase */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-4">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <ShoppingCart className="w-5 h-5" />
+              Bill of Materials
+              {stats && stats.bom_total_items > 0 && (
+                <span className="text-sm font-normal text-bambu-gray">
+                  ({stats.bom_completed_items}/{stats.bom_total_items} acquired)
+                </span>
+              )}
+            </h2>
+            <div className="flex items-center gap-2">
+              {bomItems && bomItems.some(item => item.is_complete) && (
+                <button
+                  onClick={() => setHideBomCompleted(!hideBomCompleted)}
+                  className={`text-xs px-2 py-1 rounded transition-colors ${
+                    hideBomCompleted
+                      ? 'bg-bambu-green/20 text-bambu-green'
+                      : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  {hideBomCompleted ? 'Show all' : 'Hide done'}
+                </button>
+              )}
+              {!showBomForm && (
+                <Button variant="secondary" size="sm" onClick={() => setShowBomForm(true)}>
+                  <Plus className="w-4 h-4 mr-1" />
+                  Add Part
+                </Button>
+              )}
+            </div>
+          </div>
+
+          {/* Add BOM item form */}
+          {showBomForm && (
+            <form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+                <input
+                  type="text"
+                  value={newBomName}
+                  onChange={(e) => setNewBomName(e.target.value)}
+                  className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                  placeholder="Part name (e.g., M3x8 screws)"
+                  autoFocus
+                />
+                <div className="flex gap-2">
+                  <input
+                    type="number"
+                    value={newBomQty}
+                    onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
+                    className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
+                    min="1"
+                    placeholder="Qty"
+                  />
+                  <input
+                    type="number"
+                    step="0.01"
+                    value={newBomPrice}
+                    onChange={(e) => setNewBomPrice(e.target.value)}
+                    className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                    placeholder={`Price (${currency})`}
+                  />
+                </div>
+              </div>
+              <input
+                type="url"
+                value={newBomUrl}
+                onChange={(e) => setNewBomUrl(e.target.value)}
+                className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="Sourcing URL (optional)"
+              />
+              <input
+                type="text"
+                value={newBomRemarks}
+                onChange={(e) => setNewBomRemarks(e.target.value)}
+                className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="Remarks (optional)"
+              />
+              <div className="flex justify-end gap-2">
+                <Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
+                  Cancel
+                </Button>
+                <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
+                  {createBomMutation.isPending ? (
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                  ) : (
+                    'Add Part'
+                  )}
+                </Button>
+              </div>
+            </form>
+          )}
+
+          {bomLoading ? (
+            <div className="flex items-center justify-center py-4">
+              <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+            </div>
+          ) : bomItems && bomItems.length > 0 ? (
+            <div className="space-y-2">
+              {bomItems
+                .filter(item => !hideBomCompleted || !item.is_complete)
+                .map((item) => (
+                <div
+                  key={item.id}
+                  className={`p-3 rounded-lg transition-colors ${
+                    item.is_complete ? 'bg-bambu-green/10' : 'bg-bambu-dark'
+                  }`}
+                >
+                  <div className="flex items-start gap-3">
+                    <button
+                      onClick={() => handleToggleAcquired(item)}
+                      disabled={updateBomMutation.isPending}
+                      className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
+                        item.is_complete
+                          ? 'bg-bambu-green border-bambu-green text-white'
+                          : 'border-bambu-gray hover:border-bambu-green'
+                      }`}
+                    >
+                      {item.is_complete && <CheckCircle className="w-3 h-3" />}
+                    </button>
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center justify-between gap-2">
+                        <div className="flex items-center gap-2 min-w-0">
+                          <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
+                            {item.name}
+                            <span className="text-bambu-gray font-normal ml-2">
+                              x{item.quantity_needed}
+                            </span>
+                          </p>
+                          {item.unit_price !== null && (
+                            <span className="text-xs text-bambu-green whitespace-nowrap">
+                              {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
+                            </span>
+                          )}
+                        </div>
+                        <button
+                          onClick={() => handleDeleteBomItem(item.id, item.name)}
+                          className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
+                          title="Delete"
+                        >
+                          <Trash2 className="w-4 h-4" />
+                        </button>
+                      </div>
+                      {/* Sourcing URL */}
+                      {item.sourcing_url && (
+                        <a
+                          href={item.sourcing_url}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
+                          onClick={(e) => e.stopPropagation()}
+                        >
+                          <ExternalLink className="w-3 h-3 flex-shrink-0" />
+                          <span className="truncate">
+                            {(() => {
+                              try {
+                                return new URL(item.sourcing_url).hostname.replace('www.', '');
+                              } catch {
+                                return item.sourcing_url;
+                              }
+                            })()}
+                          </span>
+                        </a>
+                      )}
+                      {/* Remarks */}
+                      {item.remarks && (
+                        <p className="mt-1 text-xs text-bambu-gray/80 italic">
+                          {item.remarks}
+                        </p>
+                      )}
+                    </div>
+                  </div>
+                </div>
+              ))}
+              {/* BOM Total */}
+              {bomItems.some(item => item.unit_price !== null) && (
+                <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
+                  <span className="text-bambu-gray">Total cost:</span>
+                  <span className="text-white font-medium">
+                    {currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
+                  </span>
+                </div>
+              )}
+            </div>
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Timeline Section */}
+      <Card>
+        <CardContent className="p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+              <History className="w-5 h-5" />
+              Activity Timeline
+            </h2>
+          </div>
+
+          {timelineLoading ? (
+            <div className="flex items-center justify-center py-4">
+              <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+            </div>
+          ) : timeline && timeline.length > 0 ? (
+            <div className="space-y-3">
+              {timeline.slice(0, 10).map((event, index) => (
+                <div key={index} className="flex gap-3">
+                  <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
+                    event.event_type === 'print_completed' ? 'bg-bambu-green/20 text-bambu-green' :
+                    event.event_type === 'print_failed' ? 'bg-red-500/20 text-red-400' :
+                    event.event_type === 'print_started' ? 'bg-yellow-500/20 text-yellow-400' :
+                    'bg-bambu-dark-tertiary text-bambu-gray'
+                  }`}>
+                    {event.event_type === 'print_completed' && <CheckCircle className="w-4 h-4" />}
+                    {event.event_type === 'print_failed' && <XCircle className="w-4 h-4" />}
+                    {event.event_type === 'print_started' && <Printer className="w-4 h-4" />}
+                    {event.event_type === 'queued' && <ListTodo className="w-4 h-4" />}
+                    {event.event_type === 'project_created' && <Plus className="w-4 h-4" />}
+                  </div>
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm text-white">{event.title}</p>
+                    {event.description && (
+                      <p className="text-xs text-bambu-gray truncate">{event.description}</p>
+                    )}
+                    <p className="text-xs text-bambu-gray/70">{formatTimelineDate(event.timestamp)}</p>
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <p className="text-bambu-gray/70 text-sm italic">
+              No activity yet.
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Template action */}
+      {!project.is_template && (
+        <div className="flex justify-end">
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => createTemplateMutation.mutate()}
+            disabled={createTemplateMutation.isPending}
+          >
+            {createTemplateMutation.isPending ? (
+              <Loader2 className="w-4 h-4 animate-spin mr-2" />
+            ) : (
+              <Copy className="w-4 h-4 mr-2" />
+            )}
+            Save as Template
+          </Button>
+        </div>
+      )}
+
+      {/* Queue section */}
+      {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center justify-between mb-3">
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <ListTodo className="w-5 h-5" />
+                Queue
+              </h2>
+              <Link
+                to={`/queue?project=${projectId}`}
+                className="text-sm text-bambu-green hover:underline"
+              >
+                View all
+              </Link>
+            </div>
+            <div className="flex items-center gap-4 text-sm">
+              {stats.in_progress_prints > 0 && (
+                <span className="text-yellow-400">
+                  {stats.in_progress_prints} printing
+                </span>
+              )}
+              {stats.queued_prints > 0 && (
+                <span className="text-bambu-gray">
+                  {stats.queued_prints} queued
+                </span>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Archives section */}
+      <div>
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+            <Package className="w-5 h-5" />
+            Prints ({archives?.length || 0})
+          </h2>
+        </div>
+        {archivesLoading ? (
+          <div className="flex items-center justify-center py-8">
+            <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+          </div>
+        ) : (
+          <ArchiveGrid archives={archives || []} />
+        )}
+      </div>
+
+      {/* Edit Modal */}
+      {showEditModal && (
+        <ProjectModal
+          project={{
+            ...project,
+            archive_count: stats?.total_archives || 0,
+            queue_count: stats?.queued_prints || 0,
+            progress_percent: stats?.progress_percent || null,
+            archives: [],
+          }}
+          onClose={() => setShowEditModal(false)}
+          onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}
+          isLoading={updateMutation.isPending}
+        />
+      )}
+
+      {/* Confirm Modal */}
+      {confirmModal.isOpen && (
+        <ConfirmModal
+          title={confirmModal.title}
+          message={confirmModal.message}
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={confirmModal.onConfirm}
+          onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
+        />
+      )}
+    </div>
+  );
+}

+ 321 - 102
frontend/src/pages/ProjectsPage.tsx

@@ -1,4 +1,5 @@
 import { useState } from 'react';
 import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
 import {
   FolderKanban,
   FolderKanban,
@@ -9,10 +10,14 @@ import {
   Archive,
   Archive,
   ListTodo,
   ListTodo,
   Package,
   Package,
+  Clock,
+  CheckCircle2,
+  AlertTriangle,
+  ChevronRight,
+  MoreVertical,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
 import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
-import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
@@ -36,12 +41,15 @@ interface ProjectModalProps {
   isLoading: boolean;
   isLoading: boolean;
 }
 }
 
 
-function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
+export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
   const [name, setName] = useState(project?.name || '');
   const [name, setName] = useState(project?.name || '');
   const [description, setDescription] = useState(project?.description || '');
   const [description, setDescription] = useState(project?.description || '');
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   const [status, setStatus] = useState(project?.status || 'active');
   const [status, setStatus] = useState(project?.status || 'active');
+  const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
+  const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
+  const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
 
 
   const handleSubmit = (e: React.FormEvent) => {
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
@@ -50,6 +58,9 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
       description: description.trim() || undefined,
       description: description.trim() || undefined,
       color,
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
+      tags: tags.trim() || undefined,
+      due_date: dueDate || undefined,
+      priority,
       ...(project && { status }),
       ...(project && { status }),
     });
     });
   };
   };
@@ -124,6 +135,50 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
             />
             />
           </div>
           </div>
 
 
+          {/* Tags */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Tags (comma-separated)
+            </label>
+            <input
+              type="text"
+              value={tags}
+              onChange={(e) => setTags(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              placeholder="e.g., voron, functional, gift"
+            />
+          </div>
+
+          {/* Due Date and Priority in a row */}
+          <div className="grid grid-cols-2 gap-4">
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Due Date
+              </label>
+              <input
+                type="date"
+                value={dueDate}
+                onChange={(e) => setDueDate(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
+              />
+            </div>
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                Priority
+              </label>
+              <select
+                value={priority}
+                onChange={(e) => setPriority(e.target.value)}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
+              >
+                <option value="low">Low</option>
+                <option value="normal">Normal</option>
+                <option value="high">High</option>
+                <option value="urgent">Urgent</option>
+              </select>
+            </div>
+          </div>
+
           {project && (
           {project && (
             <div>
             <div>
               <label className="block text-sm font-medium text-white mb-1">
               <label className="block text-sm font-medium text-white mb-1">
@@ -172,74 +227,191 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
   const progressPercent = project.progress_percent ?? 0;
   const progressPercent = project.progress_percent ?? 0;
   const isCompleted = project.status === 'completed';
   const isCompleted = project.status === 'completed';
   const isArchived = project.status === 'archived';
   const isArchived = project.status === 'archived';
+  const [showActions, setShowActions] = useState(false);
+
+  // Status icon and color
+  const getStatusConfig = () => {
+    if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
+    if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
+    if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' };
+    return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
+  };
+  const statusConfig = getStatusConfig();
 
 
   return (
   return (
-    <Card className="hover:border-bambu-gray/30 transition-colors cursor-pointer" onClick={onClick}>
-      <CardContent className="p-4">
-        <div className="flex items-start justify-between mb-3">
-          <div className="flex items-center gap-3">
-            <div
-              className="w-3 h-3 rounded-full flex-shrink-0"
-              style={{ backgroundColor: project.color || '#6b7280' }}
-            />
-            <div>
-              <h3 className="font-medium text-white">{project.name}</h3>
+    <div
+      className="group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden"
+      onClick={onClick}
+    >
+      {/* Color accent bar with glow */}
+      <div
+        className="absolute top-0 left-0 w-1.5 h-full"
+        style={{
+          backgroundColor: project.color || '#6b7280',
+          boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
+        }}
+      />
+
+      <div className="p-5 pl-6">
+        {/* Header */}
+        <div className="flex items-start justify-between mb-4">
+          <div className="flex items-center gap-3 min-w-0 flex-1">
+            <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
+              <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
+            </div>
+            <div className="min-w-0 flex-1">
+              <div className="flex items-center gap-2 flex-wrap">
+                <h3 className="font-semibold text-white truncate">{project.name}</h3>
+                {project.target_count ? (
+                  <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
+                    progressPercent >= 100
+                      ? 'bg-bambu-green/20 text-bambu-green'
+                      : 'bg-bambu-dark text-bambu-gray'
+                  }`}>
+                    {project.archive_count}/{project.target_count} parts
+                  </span>
+                ) : project.archive_count > 0 ? (
+                  <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
+                    {project.archive_count} print{project.archive_count !== 1 ? 's' : ''}
+                  </span>
+                ) : null}
+                {isCompleted && (
+                  <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
+                    Done
+                  </span>
+                )}
+                {isArchived && (
+                  <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
+                    Archived
+                  </span>
+                )}
+              </div>
               {project.description && (
               {project.description && (
-                <p className="text-sm text-bambu-gray/70 mt-0.5 line-clamp-1">
+                <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
                   {project.description}
                   {project.description}
                 </p>
                 </p>
               )}
               )}
+              {/* Filament materials/colors */}
+              {project.archives && project.archives.length > 0 && (() => {
+                const materials = [...new Set(project.archives.map(a => a.filament_type).filter(Boolean))];
+                const colors = [...new Set(project.archives.map(a => a.filament_color).filter(Boolean))] as string[];
+                if (materials.length === 0 && colors.length === 0) return null;
+                return (
+                  <div className="flex items-center gap-2 mt-1.5">
+                    {/* Material types as text badges */}
+                    {materials.slice(0, 3).map((mat) => (
+                      <span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
+                        {mat}
+                      </span>
+                    ))}
+                    {/* Colors as swatches */}
+                    {colors.length > 0 && (
+                      <div className="flex items-center gap-0.5">
+                        {colors.slice(0, 5).map((col) => (
+                          <div
+                            key={col}
+                            className="w-3 h-3 rounded-full border border-white/20"
+                            style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
+                            title={col}
+                          />
+                        ))}
+                        {colors.length > 5 && (
+                          <span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
+                        )}
+                      </div>
+                    )}
+                  </div>
+                );
+              })()}
             </div>
             </div>
           </div>
           </div>
-          <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
-            {isCompleted && (
-              <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded">
-                Completed
-              </span>
-            )}
-            {isArchived && (
-              <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded">
-                Archived
-              </span>
+
+          {/* Actions menu */}
+          <div className="relative" onClick={(e) => e.stopPropagation()}>
+            <button
+              className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
+              onClick={() => setShowActions(!showActions)}
+            >
+              <MoreVertical className="w-4 h-4" />
+            </button>
+            {showActions && (
+              <>
+                <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
+                <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
+                  <button
+                    className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                    onClick={() => { onEdit(); setShowActions(false); }}
+                  >
+                    <Edit3 className="w-4 h-4" />
+                    Edit
+                  </button>
+                  <button
+                    className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
+                    onClick={() => { onDelete(); setShowActions(false); }}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                    Delete
+                  </button>
+                </div>
+              </>
             )}
             )}
-            <Button variant="ghost" size="sm" onClick={onEdit} className="p-1">
-              <Edit3 className="w-4 h-4" />
-            </Button>
-            <Button variant="ghost" size="sm" onClick={onDelete} className="p-1 text-red-400 hover:text-red-300">
-              <Trash2 className="w-4 h-4" />
-            </Button>
           </div>
           </div>
         </div>
         </div>
 
 
-        {/* Progress bar */}
-        {project.target_count && (
-          <div className="mb-3">
-            <div className="flex justify-between text-xs text-bambu-gray mb-1">
-              <span>{project.archive_count} / {project.target_count} prints</span>
-              <span>{progressPercent.toFixed(0)}%</span>
+        {/* Progress section - show for all projects */}
+        <div className="mb-4">
+          {project.target_count ? (
+            <>
+              <div className="flex items-center justify-between text-xs mb-2">
+                <span className="text-bambu-gray">Progress</span>
+                <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
+                  {project.archive_count} / {project.target_count}
+                </span>
+              </div>
+              <div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
+                <div
+                  className="h-full transition-all duration-500 ease-out rounded-full relative"
+                  style={{
+                    width: `${Math.min(progressPercent, 100)}%`,
+                    background: progressPercent >= 100
+                      ? 'linear-gradient(90deg, #22c55e, #4ade80)'
+                      : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
+                    boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
+                  }}
+                />
+              </div>
+              <div className="text-right text-xs text-bambu-gray/60 mt-1">
+                {progressPercent.toFixed(0)}% complete
+              </div>
+            </>
+          ) : project.archive_count > 0 ? (
+            <div className="flex items-center gap-4 text-xs">
+              <div className="flex items-center gap-1.5 text-bambu-gray">
+                <Archive className="w-3.5 h-3.5" />
+                <span>{project.archive_count} print{project.archive_count !== 1 ? 's' : ''} completed</span>
+              </div>
+              {project.queue_count > 0 && (
+                <div className="flex items-center gap-1.5 text-blue-400">
+                  <Clock className="w-3.5 h-3.5" />
+                  <span>{project.queue_count} in queue</span>
+                </div>
+              )}
             </div>
             </div>
-            <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
-              <div
-                className="h-full transition-all duration-300"
-                style={{
-                  width: `${Math.min(progressPercent, 100)}%`,
-                  backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
-                }}
-              />
+          ) : (
+            <div className="text-xs text-bambu-gray/60 italic">
+              No prints yet
             </div>
             </div>
-          </div>
-        )}
+          )}
+        </div>
 
 
-        {/* Archive thumbnails */}
+        {/* Archive thumbnails - compact 4-column grid */}
         {project.archives && project.archives.length > 0 && (
         {project.archives && project.archives.length > 0 && (
-          <div className="mb-3">
-            <div className="flex gap-2">
-              {project.archives.slice(0, 5).map((archive) => (
-                <a
+          <div className="mb-4">
+            <div className="grid grid-cols-4 gap-1.5">
+              {project.archives.slice(0, 4).map((archive) => (
+                <div
                   key={archive.id}
                   key={archive.id}
-                  href={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
-                  onClick={(e) => e.stopPropagation()}
-                  className="relative w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
+                  className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
                   title={archive.print_name || 'Unknown'}
                   title={archive.print_name || 'Unknown'}
                 >
                 >
                   {archive.thumbnail_path ? (
                   {archive.thumbnail_path ? (
@@ -249,43 +421,49 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                       className="w-full h-full object-cover"
                       className="w-full h-full object-cover"
                     />
                     />
                   ) : (
                   ) : (
-                    <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+                    <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
                       <Package className="w-6 h-6" />
                       <Package className="w-6 h-6" />
                     </div>
                     </div>
                   )}
                   )}
                   {archive.status === 'failed' && (
                   {archive.status === 'failed' && (
                     <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
                     <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
-                      <span className="text-white text-xs font-bold">✗</span>
+                      <AlertTriangle className="w-4 h-4 text-white" />
                     </div>
                     </div>
                   )}
                   )}
-                </a>
-              ))}
-              {project.archive_count > 5 && (
-                <div className="w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-sm text-bambu-gray border border-bambu-dark-tertiary">
-                  +{project.archive_count - 5}
                 </div>
                 </div>
-              )}
+              ))}
             </div>
             </div>
+            {project.archive_count > 4 && (
+              <p className="text-xs text-bambu-gray mt-1.5 text-center">
+                +{project.archive_count - 4} more
+              </p>
+            )}
           </div>
           </div>
         )}
         )}
 
 
-        {/* Stats */}
-        <div className="flex items-center gap-4 text-sm text-bambu-gray">
-          <div className="flex items-center gap-1" title="Archives">
-            <Archive className="w-4 h-4" />
-            <span>{project.archive_count}</span>
-          </div>
-          <div className="flex items-center gap-1" title="Queued">
-            <ListTodo className="w-4 h-4" />
-            <span>{project.queue_count}</span>
+        {/* Stats footer */}
+        <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
+          <div className="flex items-center gap-4 text-xs text-bambu-gray">
+            <div className="flex items-center gap-1.5" title="Completed prints">
+              <Archive className="w-3.5 h-3.5" />
+              <span>{project.archive_count}</span>
+            </div>
+            {project.queue_count > 0 && (
+              <div className="flex items-center gap-1.5 text-blue-400" title="In queue">
+                <ListTodo className="w-3.5 h-3.5" />
+                <span>{project.queue_count}</span>
+              </div>
+            )}
           </div>
           </div>
+          <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
         </div>
         </div>
-      </CardContent>
-    </Card>
+      </div>
+    </div>
   );
   );
 }
 }
 
 
 export function ProjectsPage() {
 export function ProjectsPage() {
+  const navigate = useNavigate();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [showModal, setShowModal] = useState(false);
   const [showModal, setShowModal] = useState(false);
@@ -350,8 +528,8 @@ export function ProjectsPage() {
   };
   };
 
 
   const handleClick = (project: ProjectListItem) => {
   const handleClick = (project: ProjectListItem) => {
-    // Open edit modal when clicking on card
-    handleEdit(project);
+    // Navigate to project detail page
+    navigate(`/projects/${project.id}`);
   };
   };
 
 
   const handleDeleteClick = (id: number) => {
   const handleDeleteClick = (id: number) => {
@@ -364,54 +542,95 @@ export function ProjectsPage() {
     }
     }
   };
   };
 
 
+  // Count projects by status for filter badges
+  const projectCounts = projects?.reduce((acc, p) => {
+    acc[p.status] = (acc[p.status] || 0) + 1;
+    acc.all = (acc.all || 0) + 1;
+    return acc;
+  }, {} as Record<string, number>) || {};
+
   return (
   return (
-    <div className="space-y-6">
+    <div className="p-4 md:p-8 space-y-8">
       {/* Header */}
       {/* Header */}
-      <div className="flex items-center justify-between">
-        <div className="flex items-center gap-3">
-          <FolderKanban className="w-6 h-6 text-bambu-green" />
-          <h1 className="text-2xl font-bold text-white">Projects</h1>
+      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
+        <div>
+          <h1 className="text-2xl font-bold text-white flex items-center gap-3">
+            <div className="p-2.5 bg-bambu-green/10 rounded-xl">
+              <FolderKanban className="w-6 h-6 text-bambu-green" />
+            </div>
+            Projects
+          </h1>
+          <p className="text-sm text-bambu-gray mt-2 ml-14">
+            Organize and track your 3D printing projects
+          </p>
         </div>
         </div>
-        <Button onClick={() => setShowModal(true)}>
+        <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
           <Plus className="w-4 h-4 mr-2" />
           <Plus className="w-4 h-4 mr-2" />
           New Project
           New Project
         </Button>
         </Button>
       </div>
       </div>
 
 
-      {/* Filters */}
-      <div className="flex gap-2">
-        {['active', 'completed', 'archived', 'all'].map((status) => (
+      {/* Filter tabs */}
+      <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
+        {[
+          { key: 'active', label: 'Active', icon: Clock },
+          { key: 'completed', label: 'Completed', icon: CheckCircle2 },
+          { key: 'archived', label: 'Archived', icon: Archive },
+          { key: 'all', label: 'All', icon: FolderKanban },
+        ].map(({ key, label, icon: Icon }) => (
           <button
           <button
-            key={status}
-            onClick={() => setStatusFilter(status)}
-            className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
-              statusFilter === status
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-card text-bambu-gray hover:bg-bambu-gray/20'
+            key={key}
+            onClick={() => setStatusFilter(key)}
+            className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
+              statusFilter === key
+                ? 'bg-bambu-card text-white shadow-sm'
+                : 'text-bambu-gray hover:text-white'
             }`}
             }`}
           >
           >
-            {status.charAt(0).toUpperCase() + status.slice(1)}
+            <Icon className="w-4 h-4" />
+            <span>{label}</span>
+            {projectCounts[key] > 0 && (
+              <span className={`text-xs px-1.5 py-0.5 rounded-full ${
+                statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}>
+                {projectCounts[key]}
+              </span>
+            )}
           </button>
           </button>
         ))}
         ))}
       </div>
       </div>
 
 
       {/* Content */}
       {/* Content */}
       {isLoading ? (
       {isLoading ? (
-        <div className="flex items-center justify-center py-12">
-          <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+        <div className="flex items-center justify-center py-20">
+          <div className="flex flex-col items-center gap-3">
+            <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+            <p className="text-sm text-bambu-gray">Loading projects...</p>
+          </div>
         </div>
         </div>
       ) : projects?.length === 0 ? (
       ) : projects?.length === 0 ? (
-        <Card>
-          <CardContent className="py-12 text-center">
-            <FolderKanban className="w-12 h-12 text-bambu-gray/50 mx-auto mb-4" />
-            <p className="text-bambu-gray">No projects found</p>
-            <p className="text-bambu-gray/70 text-sm mt-1">
-              Create a project to group related prints together
-            </p>
-          </CardContent>
-        </Card>
+        <div className="flex flex-col items-center justify-center py-20 px-4">
+          <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
+            <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
+          </div>
+          <h3 className="text-lg font-medium text-white mb-2">
+            {statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
+          </h3>
+          <p className="text-bambu-gray text-center max-w-md mb-6">
+            {statusFilter === 'all'
+              ? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
+              : `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
+            }
+          </p>
+          {statusFilter === 'all' && (
+            <Button onClick={() => setShowModal(true)}>
+              <Plus className="w-4 h-4 mr-2" />
+              Create Your First Project
+            </Button>
+          )}
+        </div>
       ) : (
       ) : (
-        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
           {projects?.map((project) => (
           {projects?.map((project) => (
             <ProjectCard
             <ProjectCard
               key={project.id}
               key={project.id}

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


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


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


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


+ 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-Dszw0rVS.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CbCN6LSA.css">
+    <script type="module" crossorigin src="/assets/index-BGnYjiV-.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CiDcmh7W.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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