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/
       - id: check-added-large-files
         args: ['--maxkb=1000']
+        exclude: ^static/assets/
       - id: check-merge-conflict
       - id: debug-statements
       - id: detect-private-key

+ 29 - 0
CHANGELOG.md

@@ -2,6 +2,35 @@
 
 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
 
 ### Fixed

+ 5 - 10
README.md

@@ -48,7 +48,8 @@
 - 3D model preview (Three.js)
 - Duplicate detection & full-text search
 - 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)
 
 ### 📊 Monitoring & Stats
@@ -97,14 +98,6 @@
 - Print time accuracy stats
 - 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>
 </tr>
 </table>
@@ -288,7 +281,7 @@ server {
 
 > **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
 services:
@@ -297,6 +290,8 @@ services:
     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>
 
 #### Manual Installation

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

@@ -3,7 +3,7 @@ import logging
 import zipfile
 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 sqlalchemy import func, select
 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():
         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(
         path=timelapse_path,
         media_type="video/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}
 
 
+@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
 # ============================================
@@ -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")
 async def reprint_archive(
     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.services.camera import (
     capture_camera_frame,
+    generate_chamber_image_stream,
     get_camera_port,
     get_ffmpeg_path,
+    is_chamber_image_model,
+    read_next_chamber_frame,
     test_camera_connection,
 )
 
@@ -24,6 +27,9 @@ router = APIRouter(prefix="/printers", tags=["camera"])
 # Track active ffmpeg processes for cleanup
 _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)
 _last_frames: dict[int, bytes] = {}
 
@@ -45,7 +51,96 @@ async def get_printer_or_404(printer_id: int, db: AsyncSession) -> 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,
     access_code: str,
     model: str | None,
@@ -54,9 +149,9 @@ async def generate_mjpeg_stream(
     disconnect_event: asyncio.Event | None = None,
     printer_id: int | None = 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()
     if not ffmpeg:
@@ -70,6 +165,9 @@ async def generate_mjpeg_stream(
     # ffmpeg command to output MJPEG stream to stdout
     # -rtsp_transport tcp: Use TCP for reliability
     # -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
     # -q:v 5: Quality (lower = better, 2-10 is good range)
     # -r: Output framerate
@@ -79,6 +177,12 @@ async def generate_mjpeg_stream(
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
+        "-timeout",
+        "30000000",  # 30 seconds in microseconds
+        "-buffer_size",
+        "1024000",  # 1MB buffer
+        "-max_delay",
+        "500000",  # 0.5 seconds max delay
         "-i",
         camera_url,
         "-f",
@@ -91,7 +195,7 @@ async def generate_mjpeg_stream(
         "-",  # 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)")
 
     process = None
@@ -131,8 +235,8 @@ async def generate_mjpeg_stream(
                 break
 
             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:
                     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
     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:
         printer_id: Printer ID
         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)
 
-    # 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
     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
     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():
         """Wrapper generator that monitors for client disconnect."""
         try:
-            async for chunk in generate_mjpeg_stream(
+            async for chunk in stream_generator(
                 ip_address=printer.ip_address,
                 access_code=printer.access_code,
                 model=printer.model,
@@ -295,6 +414,8 @@ async def stop_camera_stream(printer_id: int):
     Accepts both GET and POST (POST for sendBeacon compatibility).
     """
     stopped = 0
+
+    # Stop ffmpeg/RTSP streams
     to_remove = []
     for stream_id, process in list(_active_streams.items()):
         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:
         _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}
 
 
@@ -344,7 +478,10 @@ async def camera_snapshot(
         )
 
         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
         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
 from datetime import datetime
-from typing import List
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select, func
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 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.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 (
-    MaintenanceTypeCreate,
-    MaintenanceTypeUpdate,
-    MaintenanceTypeResponse,
-    PrinterMaintenanceCreate,
-    PrinterMaintenanceUpdate,
-    PrinterMaintenanceResponse,
     MaintenanceHistoryResponse,
     MaintenanceStatus,
-    PrinterMaintenanceOverview,
+    MaintenanceTypeCreate,
+    MaintenanceTypeResponse,
+    MaintenanceTypeUpdate,
     PerformMaintenanceRequest,
+    PrinterMaintenanceOverview,
+    PrinterMaintenanceResponse,
+    PrinterMaintenanceUpdate,
 )
+from backend.app.services.notification_service import notification_service
 
 logger = logging.getLogger(__name__)
 
@@ -73,20 +71,20 @@ DEFAULT_MAINTENANCE_TYPES = [
 
 
 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(
-        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
     archive_hours = total_seconds / 3600.0
 
     # 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
 
     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:
     """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_names = {t.name for t in existing}
 
@@ -116,13 +112,12 @@ async def ensure_default_types(db: AsyncSession) -> None:
 
 # ============== 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)):
     """Get all maintenance types."""
     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()
 
 
@@ -153,9 +148,7 @@ async def update_maintenance_type(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
     if not maint_type:
         raise HTTPException(status_code=404, detail="Maintenance type not found")
@@ -175,9 +168,7 @@ async def delete_maintenance_type(
     db: AsyncSession = Depends(get_db),
 ):
     """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()
     if not maint_type:
         raise HTTPException(status_code=404, detail="Maintenance type not found")
@@ -192,6 +183,7 @@ async def delete_maintenance_type(
 
 # ============== Printer Maintenance ==============
 
+
 async def _get_printer_maintenance_internal(
     printer_id: int,
     db: AsyncSession,
@@ -201,9 +193,7 @@ async def _get_printer_maintenance_internal(
     await ensure_default_types(db)
 
     # 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()
     if not printer:
         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:
         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:
             interval = item.custom_interval_hours or maint_type.default_interval_hours
             # 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
             last_performed_hours = item.last_performed_hours
             last_performed_at = item.last_performed_at
             item_id = item.id
         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
             item = PrinterMaintenance(
                 printer_id=printer_id,
@@ -294,25 +289,27 @@ async def _get_printer_maintenance_internal(
             elif is_warning:
                 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:
         await db.commit()
@@ -336,14 +333,12 @@ async def get_printer_maintenance(
     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)):
     """Get maintenance overview for all active printers."""
     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()
 
     overviews = []
@@ -383,6 +378,75 @@ async def update_printer_maintenance(
     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)
 async def perform_maintenance(
     item_id: int,
@@ -400,9 +464,7 @@ async def perform_maintenance(
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
     # 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()
 
     # Get current hours
@@ -424,7 +486,7 @@ async def perform_maintenance(
 
     # Calculate status
     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_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(
     item_id: int,
     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."""
     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()
 
     total_due = 0
@@ -482,12 +542,14 @@ async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
         total_due += overview.due_count
         total_warning += overview.warning_count
         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 {
         "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)."""
     # 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()
     if not printer:
         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(
-        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
     archive_hours = total_seconds / 3600.0
@@ -541,9 +600,7 @@ async def set_printer_hours(
         ]
 
         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(
                 f"Sent maintenance notification for printer {printer_id}: "
                 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,
     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__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -104,18 +103,37 @@ async def update_printer(
 
 
 @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))
     printer = result.scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
 
     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.commit()
 
-    return {"status": "deleted"}
+    return {"status": "deleted", "archives_deleted": delete_archives}
 
 
 @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,
         print_options=print_options,
         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,
         airduct_mode=state.airduct_mode,
         speed_level=state.speed_level,

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

@@ -1,21 +1,35 @@
 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 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.models.project import Project
 from backend.app.models.archive import PrintArchive
 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 (
+    ArchivePreview,
+    BatchAddArchives,
+    BatchAddQueueItems,
+    BOMItemCreate,
+    BOMItemResponse,
+    BOMItemUpdate,
+    ProjectChildPreview,
     ProjectCreate,
-    ProjectUpdate,
-    ProjectResponse,
     ProjectListResponse,
+    ProjectResponse,
     ProjectStats,
-    BatchAddArchives,
-    BatchAddQueueItems,
-    ArchivePreview,
+    ProjectUpdate,
+    TimelineEvent,
 )
 
 logger = logging.getLogger(__name__)
@@ -23,21 +37,16 @@ logger = logging.getLogger(__name__)
 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."""
     # 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
 
     # Count completed archives
     completed_result = await db.execute(
         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
@@ -45,17 +54,19 @@ async def compute_project_stats(
     # Count failed archives
     failed_result = await db.execute(
         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
 
-    # Sum print time and filament
+    # Sum print time, filament, and energy
     sums_result = await db.execute(
         select(
             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.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)
     )
     sums = sums_result.first()
@@ -63,8 +74,7 @@ async def compute_project_stats(
     # Count queued items
     queued_result = await db.execute(
         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
@@ -72,16 +82,28 @@ async def compute_project_stats(
     # Count in-progress items
     in_progress_result = await db.execute(
         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
 
     # Calculate progress
     progress_percent = None
+    remaining_prints = None
     if target_count and target_count > 0:
         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(
         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_filament_grams=round(sums.total_filament or 0, 2),
         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:
         # Get archive count
         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
 
@@ -157,6 +183,8 @@ async def list_projects(
                 print_name=a.print_name,
                 thumbnail_path=a.thumbnail_path,
                 status=a.status,
+                filament_type=a.filament_type,
+                filament_color=a.filament_color,
             )
             for a in archives
         ]
@@ -186,14 +214,146 @@ async def create_project(
     db: AsyncSession = Depends(get_db),
 ):
     """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(
         name=data.name,
         description=data.description,
         color=data.color,
         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)
     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)
 
     stats = await compute_project_stats(db, project.id, project.target_count)
@@ -205,12 +365,57 @@ async def create_project(
         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=None,
+        children=[],
         created_at=project.created_at,
         updated_at=project.updated_at,
         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)
 async def get_project(
     project_id: int,
@@ -223,6 +428,15 @@ async def get_project(
     if not project:
         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)
 
     return ProjectResponse(
@@ -232,6 +446,17 @@ async def get_project(
         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=children,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
@@ -264,10 +489,42 @@ async def update_project(
         project.status = data.status
     if data.target_count is not None:
         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.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)
 
     return ProjectResponse(
@@ -277,6 +534,17 @@ async def update_project(
         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=children,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
@@ -313,9 +581,10 @@ async def list_project_archives(
     if not result.scalar_one_or_none():
         raise HTTPException(status_code=404, detail="Project not found")
 
-    # Get archives
+    # Get archives with project relationship eagerly loaded
     query = (
         select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
         .where(PrintArchive.project_id == project_id)
         .order_by(PrintArchive.created_at.desc())
         .limit(limit)
@@ -342,11 +611,7 @@ async def list_project_queue(
         raise HTTPException(status_code=404, detail="Project not found")
 
     # 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)
     items = result.scalars().all()
 
@@ -368,9 +633,7 @@ async def add_archives_to_project(
     # Update archives
     updated = 0
     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()
         if archive:
             archive.project_id = project_id
@@ -394,9 +657,7 @@ async def add_queue_items_to_project(
     # Update queue items
     updated = 0
     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()
         if item:
             item.project_id = project_id
@@ -426,3 +687,588 @@ async def remove_archives_from_project(
             updated += 1
 
     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
 from datetime import datetime
 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 sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings as app_settings
 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_template import NotificationTemplate
-from backend.app.models.smart_plug import SmartPlug
 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.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"])
 
@@ -63,7 +63,14 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
         if setting.key in settings_dict:
             # 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"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 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_maintenance: bool = Query(False, description="Include maintenance types and records"),
     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!)"),
 ):
     """Export selected data as JSON backup."""
@@ -195,27 +203,29 @@ async def export_backup(
         providers = result.scalars().all()
         backup["notification_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")
 
     # Notification templates
@@ -224,13 +234,15 @@ async def export_backup(
         templates = result.scalars().all()
         backup["notification_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")
 
     # Smart plugs
@@ -239,25 +251,28 @@ async def export_backup(
         plugs = result.scalars().all()
         backup["smart_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")
 
     # External links
@@ -312,21 +327,23 @@ async def export_backup(
         filaments = result.scalars().all()
         backup["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")
 
     # Maintenance types and records
@@ -336,14 +353,16 @@ async def export_backup(
         types = result.scalars().all()
         backup["maintenance_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")
 
     # Collect files for ZIP (icons + archives)
@@ -365,9 +384,17 @@ async def export_backup(
         backup["archives"] = []
         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:
             archive_data = {
                 "filename": a.filename,
+                "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
                 "file_size": a.file_size,
                 "content_hash": a.content_hash,
                 "print_name": a.print_name,
@@ -432,10 +459,61 @@ async def export_backup(
             backup["archives"].append(archive_data)
         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 backup_files:
         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
             zf.writestr("backup.json", json.dumps(backup, indent=2))
 
@@ -454,7 +532,7 @@ async def export_backup(
         return StreamingResponse(
             zip_buffer,
             media_type="application/zip",
-            headers={"Content-Disposition": f"attachment; filename={filename}"}
+            headers={"Content-Disposition": f"attachment; filename={filename}"},
         )
 
     # Otherwise return JSON
@@ -462,7 +540,7 @@ async def export_backup(
         content=backup,
         headers={
             "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
 
         # Check if it's a ZIP file
-        if file.filename and file.filename.endswith('.zip'):
+        if file.filename and file.filename.endswith(".zip"):
             try:
                 zip_buffer = io.BytesIO(content)
-                with zipfile.ZipFile(zip_buffer, 'r') as zf:
+                with zipfile.ZipFile(zip_buffer, "r") as zf:
                     # 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"}
 
-                    backup_content = zf.read('backup.json')
+                    backup_content = zf.read("backup.json")
                     backup = json.loads(backup_content.decode("utf-8"))
 
                     # Extract all other files to base_dir
                     for zip_path in zf.namelist():
-                        if zip_path == 'backup.json':
+                        if zip_path == "backup.json":
                             continue
                         # Ensure path is safe (no path traversal)
-                        if '..' in zip_path or zip_path.startswith('/'):
+                        if ".." in zip_path or zip_path.startswith("/"):
                             continue
                         target_path = base_dir / zip_path
                         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())
                             files_restored += 1
             except zipfile.BadZipFile:
@@ -520,6 +598,7 @@ async def import_backup(
         "printers": 0,
         "filaments": 0,
         "maintenance_types": 0,
+        "projects": 0,
     }
     skipped = {
         "settings": 0,
@@ -531,6 +610,7 @@ async def import_backup(
         "filaments": 0,
         "maintenance_types": 0,
         "archives": 0,
+        "projects": 0,
     }
     skipped_details = {
         "notification_providers": [],
@@ -540,6 +620,7 @@ async def import_backup(
         "filaments": [],
         "maintenance_types": [],
         "archives": [],
+        "projects": [],
     }
 
     # Restore settings (always overwrites)
@@ -616,9 +697,7 @@ async def import_backup(
     if "notification_templates" in backup:
         for template_data in backup["notification_templates"]:
             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()
             if existing:
@@ -641,9 +720,7 @@ async def import_backup(
     # Restore smart plugs (skip or overwrite duplicates by IP)
     if "smart_plugs" in backup:
         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()
             if existing:
                 if overwrite:
@@ -663,6 +740,7 @@ async def import_backup(
                     existing.schedule_enabled = plug_data.get("schedule_enabled", False)
                     existing.schedule_on_time = plug_data.get("schedule_on_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
                 else:
                     skipped["smart_plugs"] += 1
@@ -686,6 +764,7 @@ async def import_backup(
                     schedule_enabled=plug_data.get("schedule_enabled", False),
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
+                    show_in_switchbar=plug_data.get("show_in_switchbar", False),
                 )
                 db.add(plug)
                 restored["smart_plugs"] += 1
@@ -697,10 +776,7 @@ async def import_backup(
 
         for link_data in backup["external_links"]:
             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()
             if existing:
@@ -728,9 +804,7 @@ async def import_backup(
     # Restore printers (skip or overwrite duplicates by serial_number)
     if "printers" in backup:
         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()
             if existing:
                 if overwrite:
@@ -805,7 +879,9 @@ async def import_backup(
                     restored["filaments"] += 1
                 else:
                     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:
                 filament = Filament(
                     name=filament_data["name"],
@@ -828,9 +904,7 @@ async def import_backup(
     # Restore maintenance types (skip or overwrite duplicates by name)
     if "maintenance_types" in backup:
         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()
             if existing:
                 if overwrite:
@@ -861,9 +935,7 @@ async def import_backup(
             # Skip if no content_hash or already exists
             content_hash = archive_data.get("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()
                 if existing:
                     skipped["archives"] += 1
@@ -907,15 +979,111 @@ async def import_backup(
                 db.add(archive)
                 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()
 
     # 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
     if "printers" in backup:
         # 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()
         for printer in active_printers:
             # This will disconnect existing connection (if any) and reconnect

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

@@ -3,25 +3,27 @@
 import logging
 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.ext.asyncio import AsyncSession
 
 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.smart_plug import SmartPlug
 from backend.app.schemas.smart_plug import (
+    SmartPlugControl,
     SmartPlugCreate,
-    SmartPlugUpdate,
+    SmartPlugEnergy,
     SmartPlugResponse,
-    SmartPlugControl,
     SmartPlugStatus,
     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.printer_manager import printer_manager
+from backend.app.services.tasmota import tasmota_service
 
 logger = logging.getLogger(__name__)
 
@@ -43,16 +45,12 @@ async def create_smart_plug(
     """Create a new smart plug."""
     # Validate printer_id if provided
     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():
             raise HTTPException(400, "Printer not found")
 
         # 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():
             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)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
     """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()
     if not plug:
         return None
     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)
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific smart plug."""
@@ -106,9 +220,7 @@ async def update_smart_plug(
         new_printer_id = update_data["printer_id"]
 
         # 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():
             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
 
 # Application version - single source of truth
-APP_VERSION = "0.1.5"
+APP_VERSION = "0.1.6b"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # 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 backend.app.core.config import settings
 
-
 engine = create_async_engine(
     settings.database_url,
     echo=settings.debug,
@@ -34,7 +33,22 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # 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:
         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
     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:
         # Column already exists
         pass
 
     # Migration: Add content_hash column to print_archives for duplicate detection
     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:
         # Column already exists
         pass
 
     # Migration: Add auto_off_executed column to smart_plugs
     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:
         # Column already exists
         pass
 
     # Migration: Add on_print_stopped column to notification_providers
     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:
         # Column already exists
         pass
 
     # Migration: Add source_3mf_path column to print_archives
     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:
         # Column already exists
         pass
 
     # Migration: Add on_maintenance_due column to notification_providers
     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:
         # Column already exists
         pass
 
     # Migration: Add location column to printers for grouping
     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:
         # Column already exists
         pass
 
     # Migration: Add interval_type column to maintenance_types
     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:
         # Column already exists
         pass
 
     # Migration: Add custom_interval_type column to printer_maintenance
     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:
         # Column already exists
         pass
 
     # Migration: Add power alert columns to smart_plugs
     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:
         pass
     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:
         pass
     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:
         pass
     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:
         pass
 
     # Migration: Add schedule columns to smart_plugs
     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:
         pass
     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:
         pass
     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:
         pass
 
     # Migration: Add daily digest columns to notification_providers
     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:
         pass
     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:
         pass
 
     # Migration: Add project_id column to print_archives
     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:
         pass
 
     # Migration: Add project_id column to print_queue
     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:
         pass
 
     # Migration: Create FTS5 virtual table for archive full-text search
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
                 print_name,
                 filename,
@@ -220,82 +199,169 @@ async def run_migrations(conn):
                 content='print_archives',
                 content_rowid='id'
             )
-        """))
+        """)
+        )
     except Exception:
         pass
 
     # Migration: Create triggers to keep FTS index in sync
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             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)
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
             END
-        """))
+        """)
+        )
     except Exception:
         pass
 
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             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)
                 VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
             END
-        """))
+        """)
+        )
     except Exception:
         pass
 
     try:
-        await conn.execute(text("""
+        await conn.execute(
+            text("""
             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)
                 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)
                 VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
             END
-        """))
+        """)
+        )
     except Exception:
         pass
 
     # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
     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:
         pass
     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:
         pass
 
     # Migration: Add AMS alarm notification columns to notification_providers
     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:
         pass
     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:
         pass
 
     # Migration: Add AMS-HT alarm notification columns to notification_providers
     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:
         pass
+
+    # Migration: Add parent_id column to projects (Phase 10)
     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:
         pass
 
@@ -303,7 +369,8 @@ async def run_migrations(conn):
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     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:
         # Check if templates already exist

+ 10 - 3
backend/app/main.py

@@ -56,6 +56,7 @@ from backend.app.api.routes import (
     archives,
     camera,
     cloud,
+    discovery,
     external_links,
     filaments,
     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"
                 )
 
+    # 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 = (
         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:
         return  # No change, skip broadcast
@@ -1273,8 +1279,8 @@ async def record_ams_history():
                 for printer in printers:
                     # Get current state from printer manager
                     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
                     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(system.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)

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

@@ -1,5 +1,6 @@
 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 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
     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
     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
     archives: Mapped[list["PrintArchive"]] = 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.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 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 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_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
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     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_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
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-    updated_at: Mapped[datetime] = mapped_column(
-        DateTime, server_default=func.now(), onupdate=func.now()
-    )
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     # Relationship
     printer: Mapped["Printer"] = relationship(back_populates="smart_plug")

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

@@ -1,26 +1,42 @@
 from datetime import datetime
+
 from pydantic import BaseModel
 
 
 class ProjectCreate(BaseModel):
     """Schema for creating a new project."""
+
     name: str
     description: str | None = None
     color: str | 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):
     """Schema for updating a project."""
+
     name: str | None = None
     description: str | None = None
     color: str | None = None
     status: str | None = None  # active, completed, archived
     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):
     """Statistics for a project."""
+
     total_archives: int = 0
     completed_prints: int = 0
     failed_prints: int = 0
@@ -29,16 +45,46 @@ class ProjectStats(BaseModel):
     total_print_time_hours: float = 0.0
     total_filament_grams: float = 0.0
     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):
     """Schema for project response."""
+
     id: int
     name: str
     description: str | None
     color: str | None
     status: str
     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
     updated_at: datetime
     stats: ProjectStats | None = None
@@ -49,14 +95,18 @@ class ProjectResponse(BaseModel):
 
 class ArchivePreview(BaseModel):
     """Minimal archive data for project preview."""
+
     id: int
     print_name: str | None
     thumbnail_path: str | None
     status: str
+    filament_type: str | None = None
+    filament_color: str | None = None
 
 
 class ProjectListResponse(BaseModel):
     """Schema for project list item (lighter weight)."""
+
     id: int
     name: str
     description: str | None
@@ -77,9 +127,71 @@ class ProjectListResponse(BaseModel):
 
 class BatchAddArchives(BaseModel):
     """Schema for batch adding archives to a project."""
+
     archive_ids: list[int]
 
 
 class BatchAddQueueItems(BaseModel):
     """Schema for batch adding queue items to a project."""
+
     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 typing import Literal
+
 from pydantic import BaseModel, Field
 
 
@@ -23,6 +24,8 @@ class SmartPlugBase(BaseModel):
     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_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):
@@ -49,6 +52,8 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     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$")
+    # Switchbar visibility
+    show_in_switchbar: bool | None = None
 
 
 class SmartPlugResponse(SmartPlugBase):
@@ -70,6 +75,7 @@ class SmartPlugControl(BaseModel):
 
 class SmartPlugEnergy(BaseModel):
     """Energy monitoring data from a smart plug."""
+
     power: float | None = None  # Current watts
     voltage: float | None = None  # Volts
     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
         # 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 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:
                 temps["nozzle"] = float(data["nozzle_temper"])
         if "nozzle_target_temper" in data:
@@ -1089,21 +1090,28 @@ class BambuMQTTClient:
                 extruder_info = extruder_data.get("info", [])
                 if isinstance(extruder_info, list) and len(extruder_info) >= 1:
                     # 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
                     # - When > 500: encoded as (target * 65536 + current) - heater is ON
                     # - 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')}"
                 )
 
+            # 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
         if "hms" in data:
             hms_list = data["hms"]

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

@@ -1,19 +1,25 @@
 """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 logging
 import shutil
-from pathlib import Path
-from datetime import datetime
+import ssl
+import struct
 import uuid
-
-from backend.app.core.config import settings
+from datetime import datetime
+from pathlib import Path
 
 logger = logging.getLogger(__name__)
 
+# JPEG markers
+JPEG_START = b"\xff\xd8"
+JPEG_END = b"\xff\xd9"
+
 # Cache the ffmpeg path after first lookup
 _ffmpeg_path: str | None = None
 
@@ -55,26 +61,226 @@ def get_ffmpeg_path() -> str | None:
     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:
         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
 
 
+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:
-    """Build the RTSPS URL for the printer camera."""
+    """Build the RTSPS URL for the printer camera (RTSP models only)."""
     port = get_camera_port(model)
     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(
     ip_address: str,
     access_code: str,
@@ -84,6 +290,10 @@ async def capture_camera_frame(
 ) -> bool:
     """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:
         ip_address: Printer IP address
         access_code: Printer access code
@@ -94,39 +304,54 @@ async def capture_camera_frame(
     Returns:
         True if capture was successful, False otherwise
     """
-    camera_url = build_camera_url(ip_address, access_code, model)
-
     # Ensure output directory exists
     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()
     if not ffmpeg:
         logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
         return False
 
     # 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 = [
         ffmpeg,
         "-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),
     ]
 
-    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:
-        # Run ffmpeg asynchronously with timeout
         process = await asyncio.create_subprocess_exec(
             *cmd,
             stdout=asyncio.subprocess.PIPE,
@@ -134,11 +359,8 @@ async def capture_camera_frame(
         )
 
         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()
             await process.wait()
             logger.error(f"Camera capture timed out after {timeout}s")
@@ -234,7 +456,14 @@ async def test_camera_connection(
         if success:
             return {"success": True, "message": "Camera connection successful"}
         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:
         # Clean up test file
         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 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:
@@ -280,6 +280,57 @@ class PrinterManager:
         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:
     """Convert PrinterState to a JSON-serializable dict."""
     # 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,
         # WiFi signal strength
         "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
     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.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."""
-        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/")
 
@@ -64,9 +62,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         printer = await printer_factory(name="Test Printer")
 
@@ -84,9 +80,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         data = {
             "name": "Test Plug",
@@ -105,9 +99,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         plug = await smart_plug_factory(name="Get Test Plug")
 
@@ -132,9 +124,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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.
 
         This tests the regression scenario where toggling auto_off
@@ -149,10 +139,7 @@ class TestSmartPlugsAPI:
         assert response.json()["auto_off"] is True
 
         # 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.json()["auto_off"] is False
@@ -163,16 +150,11 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         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.json()["auto_on"] is False
@@ -183,31 +165,23 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         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.json()["enabled"] is False
 
     @pytest.mark.asyncio
     @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."""
         plug = await smart_plug_factory(off_delay_mode="time")
 
         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
@@ -217,9 +191,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         plug = await smart_plug_factory(schedule_enabled=False)
 
@@ -229,7 +201,7 @@ class TestSmartPlugsAPI:
                 "schedule_enabled": True,
                 "schedule_on_time": "08:00",
                 "schedule_off_time": "22:00",
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -240,9 +212,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         plug = await smart_plug_factory(
             name="Old Name",
@@ -256,7 +226,7 @@ class TestSmartPlugsAPI:
                 "name": "New Name",
                 "auto_on": False,
                 "auto_off": False,
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -277,10 +247,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be turned on."""
         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
         result = response.json()
@@ -295,10 +262,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be turned off."""
         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
         result = response.json()
@@ -313,10 +277,7 @@ class TestSmartPlugsAPI:
         """Verify smart plug can be toggled."""
         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
         result = response.json()
@@ -325,16 +286,11 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         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
         assert response.status_code == 422
@@ -364,9 +320,7 @@ class TestSmartPlugsAPI:
 
     @pytest.mark.asyncio
     @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."""
         plug = await smart_plug_factory()
         plug_id = plug.id
@@ -386,3 +340,70 @@ class TestSmartPlugsAPI:
         response = await async_client.delete("/api/v1/smart-plugs/9999")
 
         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 (
     PrinterManager,
+    get_derived_status_name,
     init_printer_connections,
     printer_state_to_dict,
 )
@@ -576,6 +577,7 @@ class TestPrinterStateToDict:
         state.tray_now = "1"
         state.wifi_signal = -50
         state.raw_data = {}
+        state.stg_cur = -1  # No calibration stage active
         return state
 
     def test_basic_conversion(self, mock_state):
@@ -733,6 +735,48 @@ class TestPrinterStateToDict:
         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:
     """Tests for init_printer_connections function."""
 

+ 9 - 2
docker-compose.yml

@@ -2,8 +2,15 @@ services:
   bambuddy:
     build: .
     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:
       - bambuddy_data:/app/data
       - 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 { MaintenancePage } from './pages/MaintenancePage';
 import { ProjectsPage } from './pages/ProjectsPage';
+import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { CameraPage } from './pages/CameraPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
@@ -49,6 +50,7 @@ function App() {
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
+                  <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />

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

@@ -14,7 +14,11 @@ async function request<T>(
 
   if (!response.ok) {
     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();
@@ -106,11 +110,16 @@ export interface PrinterStatus {
   temperatures: {
     bed?: number;
     bed_target?: number;
+    bed_heating?: boolean;  // Actual heater state from MQTT
     nozzle?: number;
     nozzle_target?: number;
+    nozzle_heating?: boolean;  // Actual heater state from MQTT
     nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)
     nozzle_2_target?: number;
+    nozzle_2_heating?: boolean;  // Actual heater state from MQTT
     chamber?: number;
+    chamber_target?: number;
+    chamber_heating?: boolean;  // Actual heater state from MQTT
   } | null;
   cover_url: string | null;
   hms_errors: HMSError[];
@@ -328,6 +337,20 @@ export interface ProjectStats {
   total_print_time_hours: number;
   total_filament_grams: number;
   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 {
@@ -337,16 +360,36 @@ export interface Project {
   color: string | null;
   status: string;  // active, completed, archived
   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;
   updated_at: string;
   stats?: ProjectStats;
 }
 
+export interface ProjectAttachment {
+  filename: string;
+  original_name: string;
+  size: number;
+  uploaded_at: string;
+}
+
 export interface ArchivePreview {
   id: number;
   print_name: string | null;
   thumbnail_path: string | null;
   status: string;
+  filament_type: string | null;
+  filament_color: string | null;
 }
 
 export interface ProjectListItem {
@@ -368,6 +411,12 @@ export interface ProjectCreate {
   description?: string;
   color?: string;
   target_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  parent_id?: number;
 }
 
 export interface ProjectUpdate {
@@ -376,6 +425,61 @@ export interface ProjectUpdate {
   color?: string;
   status?: string;
   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
@@ -559,6 +663,8 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
+  // Switchbar visibility
+  show_in_switchbar: boolean;
   // Status
   last_state: string | null;
   last_checked: string | null;
@@ -587,6 +693,8 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
+  // Switchbar visibility
+  show_in_switchbar?: boolean;
 }
 
 export interface SmartPlugUpdate {
@@ -609,6 +717,8 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
+  // Switchbar visibility
+  show_in_switchbar?: boolean;
 }
 
 export interface SmartPlugEnergy {
@@ -636,6 +746,21 @@ export interface SmartPlugTestResult {
   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
 export interface PrintQueueItem {
   id: number;
@@ -1121,8 +1246,11 @@ export const api = {
       method: 'PATCH',
       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) =>
     request<PrinterStatus>(`/printers/${id}/status`),
   connectPrinter: (id: number) =>
@@ -1301,7 +1429,7 @@ export const api = {
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   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) =>
     request<{
       status: string;
@@ -1329,6 +1457,56 @@ export const api = {
     }
     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
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,
@@ -1423,6 +1601,18 @@ export const api = {
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
     `${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) =>
     request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
       `/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.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.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.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
       if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
       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));
     }
     const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
@@ -1590,6 +1782,18 @@ export const api = {
       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
   getQueue: (printerId?: number, status?: string) => {
     const params = new URLSearchParams();
@@ -1819,6 +2023,14 @@ export const api = {
       `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
       { 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
   getCameraStreamUrl: (printerId: number, fps = 10) =>
@@ -1902,6 +2114,64 @@ export const api = {
       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
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   createAPIKey: (data: APIKeyCreate) =>
@@ -2015,3 +2285,56 @@ export interface SystemInfo {
     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 { 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 type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
+import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
 
 interface AddSmartPlugModalProps {
@@ -32,6 +32,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_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
   const { data: printers } = useQuery({
     queryKey: ['printers'],
@@ -44,15 +53,82 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     queryFn: api.getSmartPlugs,
   });
 
-  // Close on Escape key
+  // Close on Escape key and cleanup scan polling
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
       if (e.key === 'Escape') onClose();
     };
     window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
+    return () => {
+      window.removeEventListener('keydown', handleKeyDown);
+      if (scanPollRef.current) {
+        clearInterval(scanPollRef.current);
+      }
+    };
   }, [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
   const testMutation = useMutation({
     mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
@@ -127,6 +203,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
+      // Switchbar
+      show_in_switchbar: showInSwitchbar,
     };
 
     if (isEditing) {
@@ -168,6 +246,79 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </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 */}
           <div>
             <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
@@ -382,6 +533,28 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             )}
           </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 */}
           <div className="flex gap-3 pt-2">
             <Button

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

@@ -1,5 +1,5 @@
 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 { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -47,6 +47,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: true,
     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',
     labelKey: 'backup.categories.printers',
@@ -79,6 +87,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: false,
     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 {

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

@@ -1,9 +1,10 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 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 { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
+import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
 import { api } from '../api/client';
 import { getIconByName } from './IconPicker';
@@ -72,6 +73,7 @@ export function Layout() {
   });
   const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
   const [showShortcuts, setShowShortcuts] = useState(false);
+  const [showSwitchbar, setShowSwitchbar] = useState(false);
   const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
   const [draggedId, setDraggedId] = useState<string | null>(null);
   const [dragOverId, setDragOverId] = useState<string | null>(null);
@@ -107,6 +109,15 @@ export function Layout() {
     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
   const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
   const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
@@ -457,6 +468,22 @@ export function Layout() {
                 )}
               </div>
               <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
                   to="/system"
                   className={({ isActive }) =>
@@ -504,6 +531,22 @@ export function Layout() {
                   <ArrowUpCircle className="w-5 h-5" />
                 </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
                 to="/system"
                 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 { X, Printer, Loader2 } from 'lucide-react';
+import { X, Printer, Loader2, AlertTriangle, Check, Circle } from 'lucide-react';
 import { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -29,6 +29,19 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
     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({
     mutationFn: () => {
       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) || [];
 
+  // 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 (
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
       <Card className="w-full max-w-md">
@@ -105,6 +221,93 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
             </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 */}
           {reprintMutation.isError && (
             <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 { 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 type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
@@ -169,6 +169,26 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           {/* Expanded Settings */}
           {isExpanded && (
             <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 */}
               <div className="flex items-center justify-between">
                 <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 { 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 { TimelapseEditorModal } from './TimelapseEditorModal';
 
 interface TimelapseViewerProps {
   src: string;
   title: string;
   downloadFilename: string;
+  archiveId?: number;
   onClose: () => void;
+  onEdit?: () => void;
 }
 
 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 [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 [duration, setDuration] = useState(0);
+  const [showEditor, setShowEditor] = useState(false);
 
   useEffect(() => {
     const video = videoRef.current;
@@ -109,6 +120,12 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
             {title}
           </h3>
           <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}>
               <Download className="w-4 h-4" />
               Download
@@ -208,6 +225,16 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
           </div>
         </div>
       </div>
+
+      {/* Timelapse Editor Modal */}
+      {showEditor && archiveId && (
+        <TimelapseEditorModal
+          archiveId={archiveId}
+          timelapseSrc={src}
+          onClose={() => setShowEditor(false)}
+          onSave={onEdit}
+        />
+      )}
     </div>
   );
 }

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

@@ -526,8 +526,23 @@ function ArchiveCard({
         <h3 className="font-medium text-white mb-1 truncate">
           {archive.print_name || archive.filename}
         </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>
+          {/* 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 && (
             <span
               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)}
           title={`${archive.print_name || archive.filename} - Timelapse`}
           downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
+          archiveId={archive.id}
           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 { 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';
 
+const MAX_RECONNECT_ATTEMPTS = 5;
+const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
+const MAX_RECONNECT_DELAY = 30000; // 30 seconds
+
 export function CameraPage() {
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
@@ -14,8 +18,13 @@ export function CameraPage() {
   const [imageKey, setImageKey] = useState(Date.now());
   const [transitioning, setTransitioning] = 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 containerRef = useRef<HTMLDivElement>(null);
+  const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
   // Fetch printer info for the title
   const { data: printer } = useQuery({
@@ -91,14 +100,84 @@ export function CameraPage() {
     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 = () => {
-    setStreamError(true);
     setStreamLoading(false);
+
+    // Only auto-reconnect for live stream mode
+    if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
+      attemptReconnect();
+    } else {
+      setStreamError(true);
+    }
   };
 
   const handleStreamLoad = () => {
     setStreamLoading(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 = () => {
@@ -112,6 +191,15 @@ export function CameraPage() {
     setTransitioning(true);
     setStreamLoading(true);
     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) {
       imgRef.current.src = '';
@@ -134,6 +222,15 @@ export function CameraPage() {
     setTransitioning(true);
     setStreamLoading(true);
     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) {
       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/snapshot?t=${imageKey}`;
 
-  const isDisabled = streamLoading || transitioning;
+  const isDisabled = streamLoading || transitioning || isReconnecting;
 
   if (!id) {
     return (
@@ -234,7 +331,7 @@ export function CameraPage() {
       {/* Video area */}
       <div className="flex-1 flex items-center justify-center p-2">
         <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="text-center">
                 <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
@@ -244,7 +341,24 @@ export function CameraPage() {
               </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="text-center p-4">
                 <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 {
   Wrench,
@@ -32,6 +32,7 @@ import {
   Settings,
   Filter,
   CircleDot,
+  Printer,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
@@ -401,13 +402,17 @@ function SettingsSection({
   onAddType,
   onUpdateType,
   onDeleteType,
+  onAssignType,
+  onRemoveItem,
 }: {
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
   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;
   onDeleteType: (id: number) => void;
+  onAssignType: (printerId: number, typeId: number) => void;
+  onRemoveItem: (itemId: number) => void;
 }) {
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [intervalInput, setIntervalInput] = useState('');
@@ -417,6 +422,33 @@ function SettingsSection({
   const [newTypeInterval, setNewTypeInterval] = useState('100');
   const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
   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
   const [editingType, setEditingType] = useState<MaintenanceType | null>(null);
@@ -460,20 +492,33 @@ function SettingsSection({
 
   const handleAddType = (e: React.FormEvent) => {
     e.preventDefault();
-    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0) {
+    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0 && selectedPrinters.size > 0) {
       onAddType({
         name: newTypeName.trim(),
         default_interval_hours: parseFloat(newTypeInterval),
         interval_type: newTypeIntervalType,
         icon: newTypeIcon,
-      });
+      }, Array.from(selectedPrinters));
       setNewTypeName('');
       setNewTypeInterval('100');
       setNewTypeIntervalType('hours');
+      setSelectedPrinters(new Set());
       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 => ({
     printerId: p.printer_id,
     printerName: p.printer_name,
@@ -570,14 +615,37 @@ function SettingsSection({
                       })}
                     </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>
+                  {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>
               </form>
             </CardContent>
@@ -674,6 +742,10 @@ function SettingsSection({
               );
             }
 
+            const assignedPrinters = getAssignedPrinters(type.id);
+            const unassignedPrinters = getUnassignedPrinters(type.id);
+            const isExpanded = expandedType === type.id;
+
             return (
               <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">
@@ -692,6 +764,19 @@ function SettingsSection({
                       {formatIntervalLabel(type.default_interval_hours, intervalType)}
                     </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
                     onClick={() => startEditType(type)}
                     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" />
                   </button>
                 </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>
             );
           })}
@@ -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({
     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) => {
     performMutation.mutate({ id });
   };
@@ -1002,9 +1143,21 @@ export function MaintenancePage() {
           onUpdateInterval={(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 })}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
+          onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
+          onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
         />
       )}
     </div>

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

@@ -6,7 +6,6 @@ import {
   Link,
   Unlink,
   Signal,
-  Thermometer,
   Clock,
   MoreVertical,
   Trash2,
@@ -27,10 +26,12 @@ import {
   LayoutList,
   Layers,
   Video,
+  Search,
+  Loader2,
 } from 'lucide-react';
 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 { Button } from '../components/Button';
 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)
 // Reference: https://github.com/theicedmango/bambu-humidity
 interface HumidityIndicatorProps {
@@ -504,6 +546,34 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
 type SortOption = 'name' | 'status' | 'model' | 'location';
 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({
   printer,
   hideIfDisconnected,
@@ -526,6 +596,7 @@ function PrinterCard({
   const navigate = useNavigate();
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [deleteArchives, setDeleteArchives] = useState(true);
   const [showEditModal, setShowEditModal] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showMQTTDebug, setShowMQTTDebug] = useState(false);
@@ -615,9 +686,11 @@ function PrinterCard({
   const shouldHide = hideIfDisconnected && isConnected === false;
 
   const deleteMutation = useMutation({
-    mutationFn: () => api.deletePrinter(printer.id),
+    mutationFn: (options: { deleteArchives: boolean }) =>
+      api.deletePrinter(printer.id, options.deleteArchives),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
     },
   });
 
@@ -849,17 +922,64 @@ function PrinterCard({
 
         {/* Delete Confirmation */}
         {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 */}
@@ -879,7 +999,7 @@ function PrinterCard({
                     <span className="text-xs text-white">{Math.round(status.progress || 0)}%</span>
                   </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>
             ) : (
@@ -897,7 +1017,7 @@ function PrinterCard({
                     <div className="flex-1 min-w-0">
                       {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">
                             {status.subtask_name || status.current_print}
                           </p>
@@ -933,8 +1053,8 @@ function PrinterCard({
                       ) : (
                         <>
                           <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>
                           <div className="flex items-center justify-between text-sm">
                             <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
@@ -968,45 +1088,52 @@ function PrinterCard({
             )}
 
             {/* 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">
-                    <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">
-                      {Math.round(status.temperatures.chamber || 0)}°C
+                      {Math.round(status.temperatures.bed || 0)}°C
                     </p>
                   </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 */}
             {amsData && amsData.length > 0 && viewMode === 'expanded' && (
@@ -1034,9 +1161,9 @@ function PrinterCard({
 
                   return (
                     <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 */}
-                        <div className="flex items-center gap-1">
+                        <div className="flex items-center gap-1 flex-shrink-0">
                           {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                             <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                           )}
@@ -1063,7 +1190,7 @@ function PrinterCard({
                             {ams.tray.map((tray, i) => (
                               <div key={i} className="flex items-start">
                                 <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) : '—'}
                                   </span>
                                   <span className="text-bambu-gray/50 truncate">
@@ -1080,9 +1207,9 @@ function PrinterCard({
                             ))}
                           </div>
                         </div>
-                        {/* Humidity/temp - vertically centered */}
+                        {/* Humidity/temp - responsive positioning */}
                         {(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 && (
                               <HumidityIndicator
                                 humidity={ams.humidity}
@@ -1349,9 +1476,11 @@ function PrinterCard({
 function AddPrinterModal({
   onClose,
   onAdd,
+  existingSerials,
 }: {
   onClose: () => void;
   onAdd: (data: PrinterCreate) => void;
+  existingSerials: string[];
 }) {
   const [form, setForm] = useState<PrinterCreate>({
     name: '',
@@ -1362,6 +1491,153 @@ function AddPrinterModal({
     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
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -1379,6 +1655,95 @@ function AddPrinterModal({
       <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
         <CardContent>
           <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
             onSubmit={(e) => {
               e.preventDefault();
@@ -2076,6 +2441,7 @@ export function PrintersPage() {
         <AddPrinterModal
           onClose={() => setShowAddModal(false)}
           onAdd={(data) => addMutation.mutate(data)}
+          existingSerials={printers?.map(p => p.serial_number) || []}
         />
       )}
     </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 { useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   FolderKanban,
@@ -9,10 +10,14 @@ import {
   Archive,
   ListTodo,
   Package,
+  Clock,
+  CheckCircle2,
+  AlertTriangle,
+  ChevronRight,
+  MoreVertical,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
-import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
@@ -36,12 +41,15 @@ interface ProjectModalProps {
   isLoading: boolean;
 }
 
-function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
+export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
   const [name, setName] = useState(project?.name || '');
   const [description, setDescription] = useState(project?.description || '');
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   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) => {
     e.preventDefault();
@@ -50,6 +58,9 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
       description: description.trim() || undefined,
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
+      tags: tags.trim() || undefined,
+      due_date: dueDate || undefined,
+      priority,
       ...(project && { status }),
     });
   };
@@ -124,6 +135,50 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
             />
           </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 && (
             <div>
               <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 isCompleted = project.status === 'completed';
   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 (
-    <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 && (
-                <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}
                 </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 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>
 
-        {/* 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 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>
 
-        {/* Archive thumbnails */}
+        {/* Archive thumbnails - compact 4-column grid */}
         {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}
-                  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'}
                 >
                   {archive.thumbnail_path ? (
@@ -249,43 +421,49 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                       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" />
                     </div>
                   )}
                   {archive.status === 'failed' && (
                     <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>
                   )}
-                </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>
+            {project.archive_count > 4 && (
+              <p className="text-xs text-bambu-gray mt-1.5 text-center">
+                +{project.archive_count - 4} more
+              </p>
+            )}
           </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>
+          <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
         </div>
-      </CardContent>
-    </Card>
+      </div>
+    </div>
   );
 }
 
 export function ProjectsPage() {
+  const navigate = useNavigate();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [showModal, setShowModal] = useState(false);
@@ -350,8 +528,8 @@ export function ProjectsPage() {
   };
 
   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) => {
@@ -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 (
-    <div className="space-y-6">
+    <div className="p-4 md:p-8 space-y-8">
       {/* 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>
-        <Button onClick={() => setShowModal(true)}>
+        <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
           <Plus className="w-4 h-4 mr-2" />
           New Project
         </Button>
       </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
-            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>
         ))}
       </div>
 
       {/* Content */}
       {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>
       ) : 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) => (
             <ProjectCard
               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 -->
     <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>
   <body>
     <div id="root"></div>

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