Browse Source

Merge pull request #2 from maziggy/0.1.2

Final 0.1.2
MartinNYHC 6 months ago
parent
commit
8d27e58393
40 changed files with 3279 additions and 196 deletions
  1. 81 11
      README.md
  2. 90 7
      backend/app/api/routes/archives.py
  3. 286 0
      backend/app/api/routes/print_queue.py
  4. 14 2
      backend/app/api/routes/settings.py
  5. 22 1
      backend/app/api/routes/smart_plugs.py
  6. 1 1
      backend/app/core/database.py
  7. 345 22
      backend/app/main.py
  8. 5 0
      backend/app/models/archive.py
  9. 50 0
      backend/app/models/print_queue.py
  10. 8 0
      backend/app/schemas/archive.py
  11. 62 0
      backend/app/schemas/print_queue.py
  12. 4 0
      backend/app/schemas/settings.py
  13. 14 0
      backend/app/schemas/smart_plug.py
  14. 44 0
      backend/app/services/archive.py
  15. 9 1
      backend/app/services/bambu_ftp.py
  16. 77 8
      backend/app/services/bambu_mqtt.py
  17. 192 0
      backend/app/services/camera.py
  18. 319 0
      backend/app/services/print_scheduler.py
  19. 50 0
      backend/app/services/printer_manager.py
  20. 19 6
      backend/app/services/smart_plug_manager.py
  21. 38 0
      backend/app/services/tasmota.py
  22. 2 0
      frontend/src/App.tsx
  23. 87 0
      frontend/src/api/client.ts
  24. 239 0
      frontend/src/components/AddToQueueModal.tsx
  25. 44 28
      frontend/src/components/FilamentTrends.tsx
  26. 125 0
      frontend/src/components/HMSErrorModal.tsx
  27. 4 3
      frontend/src/components/KeyboardShortcutsModal.tsx
  28. 8 3
      frontend/src/components/Layout.tsx
  29. 70 0
      frontend/src/components/PrinterQueueWidget.tsx
  30. 34 2
      frontend/src/components/SmartPlugCard.tsx
  31. 202 0
      frontend/src/components/TimelapseViewer.tsx
  32. 33 45
      frontend/src/pages/ArchivesPage.tsx
  33. 227 50
      frontend/src/pages/PrintersPage.tsx
  34. 392 0
      frontend/src/pages/QueuePage.tsx
  35. 57 2
      frontend/src/pages/SettingsPage.tsx
  36. 23 2
      frontend/src/pages/StatsPage.tsx
  37. 0 0
      static/assets/index-C7e8XOql.js
  38. 0 0
      static/assets/index-CJ8fGWbx.css
  39. 0 0
      static/assets/index-DUX4pLTn.css
  40. 2 2
      static/index.html

+ 81 - 11
README.md

@@ -1,4 +1,4 @@
-# Bambusy
+v∆v
 
 <p align="center">
   <img src="static/img/bambusy_logo_dark.png" alt="Bambusy Logo" width="300">
@@ -25,10 +25,17 @@ This is a first beta version and needs to be tested thoroughly.
 
 ## Features
 
-- **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (X1, X1C, P1P, P1S, A1, A1 Mini)
-- **Automatic Print Archiving** - Automatically saves 3MF files when prints complete
+- **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (H2C, H2D, H2S, X1, X1C, P1P, P1S, A1, A1 Mini)
+- **Automatic Print Archiving** - Automatically saves 3MF files with full metadata extraction
 - **3D Model Preview** - Interactive Three.js viewer for archived prints
-- **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, and more
+- **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, layer count, and more
+- **Print Queue & Scheduling** - Schedule prints for specific times with powerful automation:
+  - Queue multiple prints per printer
+  - Schedule prints for a specific date/time
+  - Auto power-on printer before scheduled print starts
+  - Auto power-off after print completes (with nozzle cooldown)
+  - Stop active prints with one click
+  - Drag-and-drop queue reordering
 - **Smart Plug Integration** - Control Tasmota-based smart plugs with automation:
   - Auto power-on when print starts
   - Auto power-off when print completes
@@ -60,6 +67,14 @@ This is a first beta version and needs to be tested thoroughly.
   - Expandable JSON payloads for detailed inspection
 - **Filament Cost Tracking** - Track costs per print with customizable filament database
 - **Photo Attachments** - Attach photos to archived prints for documentation
+  - Automatic finish photo capture when print completes (via printer camera)
+  - Manual photo uploads
+- **Print Metadata** - Rich metadata extracted from 3MF files:
+  - Print time estimate and actual time comparison
+  - Filament usage (grams) and type
+  - Total layer count and layer height
+  - Nozzle/bed temperatures
+  - Multi-color support with color swatches
 - **Failure Analysis** - Document failed prints with notes and photos
 - **Project Page Editor** - View and edit embedded MakerWorld project pages with images, descriptions, and designer info
 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
@@ -338,6 +353,38 @@ Prints are automatically archived when they complete. You can also:
 - Re-print any archived 3MF to a connected printer
 - Export archives for backup
 
+### Print Queue & Scheduling
+
+The print queue allows you to schedule prints for later execution with smart automation.
+
+#### Adding to Queue
+
+1. Go to the **Archives** page
+2. Right-click an archive and select **Schedule**, or click the calendar icon
+3. Choose a printer and optional scheduled time
+4. Configure options:
+   - **Scheduled Time**: Leave empty for "next available" or pick a specific date/time
+   - **Auto Power Off**: Automatically turn off the printer when the print completes
+
+#### Queue Management
+
+- **View Queue**: Click the queue icon on any printer card, or go to the Queue page
+- **Reorder**: Drag and drop queue items to change print order
+- **Cancel Pending**: Click the X button on any pending queue item
+- **Stop Active Print**: Click the stop button on an actively printing item (with confirmation)
+
+#### Automation Flow
+
+When a scheduled print is ready to start:
+1. **Power On**: If linked to a smart plug, the printer powers on automatically
+2. **Wait for Connection**: System waits for the printer to connect (up to 2 minutes)
+3. **Upload & Start**: The 3MF file is uploaded via FTP and the print starts
+4. **Monitor**: Progress is tracked in real-time
+5. **Completion**: When the print finishes:
+   - Queue item is marked complete/failed
+   - If "Auto Power Off" was enabled, waits for nozzle to cool below 50°C
+   - Printer powers off automatically
+
 ### Project Page
 
 3MF files downloaded from MakerWorld contain embedded project pages with model information. To view:
@@ -474,8 +521,13 @@ mv bambusy.db bambusy.db.backup
 
 ### View server logs
 
+Bambusy writes logs to `bambutrack.log` in the application directory (rotating, max 5MB × 3 files).
+
 ```bash
-# If running directly
+# View live log file
+tail -f bambutrack.log
+
+# If running directly with verbose output
 uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug
 
 # If running as systemd service
@@ -496,6 +548,21 @@ sudo journalctl -u bambusy -f
 2. **Verify automation is enabled** - Check the Enabled, Auto On, and Auto Off toggles
 3. **Temperature mode issues** - If using temperature mode, ensure the printer is still connected so Bambusy can read the nozzle temperature
 
+### Scheduled print not starting
+
+1. **Check printer connection** - The printer must be able to connect after power-on
+2. **Verify smart plug** - If using auto power-on, ensure the smart plug is configured and working
+3. **Check queue status** - Look at the queue page for error messages
+4. **Time zone issues** - Scheduled times are in your local time zone; ensure your system clock is correct
+5. **View logs** - Check `bambutrack.log` for detailed error messages
+
+### Print queue shows "Failed to start"
+
+Common causes:
+- **Printer not ready** - The printer needs to be idle and connected
+- **File upload failed** - Check FTP connectivity to the printer
+- **HMS errors** - Check the printer for any health system errors that prevent printing
+
 ## Known Issues / Roadmap
 
 ### Beta Limitations
@@ -504,17 +571,20 @@ sudo journalctl -u bambusy -f
 - [ ] Limited to local network printers
 
 ### Planned Features
-- [ ] Docker deployment
-- [ ] Multi-user support with authentication
-- [ ] Print queue management
-- [ ] Timelapse video integration
-- [ ] Mobile-responsive improvements
-- [ ] Printer groups/organization
+- [x] Timelapse video integration
 - [x] Smart plug integration (Tasmota)
 - [x] Print time accuracy tracking
 - [x] Duplicate detection
 - [x] HMS error monitoring
 - [x] MQTT debug logging
+- [x] Embedded project page editor
+- [x] QR code labels
+- [x] Energy monitoring and statistics
+- [x] Print scheduling and queuing
+- [x] Automatic finish photo capture
+- [ ] Maintenance tracker
+- [ ] Notifications (email, push)
+- [ ] Mobile-optimized UI
 
 ## License
 

+ 90 - 7
backend/app/api/routes/archives.py

@@ -1,8 +1,11 @@
 from pathlib import Path
 import zipfile
 import io
+import logging
 
 from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
+
+logger = logging.getLogger(__name__)
 from fastapi.responses import FileResponse, Response
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select, func
@@ -10,6 +13,7 @@ from sqlalchemy import select, func
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
+from backend.app.models.filament import Filament
 from backend.app.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats
 from backend.app.services.archive import ArchiveService
 
@@ -64,6 +68,7 @@ def archive_to_response(
         "filament_type": archive.filament_type,
         "filament_color": archive.filament_color,
         "layer_height": archive.layer_height,
+        "total_layers": archive.total_layers,
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
@@ -204,6 +209,17 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
         for printer_key, accs in printer_accuracies.items():
             accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
 
+    # Energy totals
+    energy_kwh_result = await db.execute(
+        select(func.sum(PrintArchive.energy_kwh))
+    )
+    total_energy_kwh = energy_kwh_result.scalar() or 0
+
+    energy_cost_result = await db.execute(
+        select(func.sum(PrintArchive.energy_cost))
+    )
+    total_energy_cost = energy_cost_result.scalar() or 0
+
     return ArchiveStats(
         total_prints=total_prints,
         successful_prints=successful_prints,
@@ -215,6 +231,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
         prints_by_printer=prints_by_printer,
         average_time_accuracy=average_accuracy,
         time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
+        total_energy_kwh=round(total_energy_kwh, 3),
+        total_energy_cost=round(total_energy_cost, 2),
     )
 
 
@@ -320,11 +338,48 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     if metadata.get("designer"):
         archive.designer = metadata["designer"]
 
+    # Calculate cost based on filament usage and type
+    if archive.filament_used_grams and archive.filament_type:
+        primary_type = archive.filament_type.split(",")[0].strip()
+        filament_result = await db.execute(
+            select(Filament).where(Filament.type == primary_type).limit(1)
+        )
+        filament = filament_result.scalar_one_or_none()
+        if filament:
+            archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2)
+        else:
+            archive.cost = round((archive.filament_used_grams / 1000) * 25.0, 2)
+
     await db.commit()
     await db.refresh(archive)
     return archive
 
 
+@router.post("/recalculate-costs")
+async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
+    """Recalculate costs for all archives based on filament usage and prices."""
+    result = await db.execute(select(PrintArchive))
+    archives = list(result.scalars().all())
+
+    # Load all filaments for lookup
+    filament_result = await db.execute(select(Filament))
+    filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}
+    default_cost_per_kg = 25.0
+
+    updated = 0
+    for archive in archives:
+        if archive.filament_used_grams and archive.filament_type:
+            primary_type = archive.filament_type.split(",")[0].strip()
+            cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
+            new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
+            if archive.cost != new_cost:
+                archive.cost = new_cost
+                updated += 1
+
+    await db.commit()
+    return {"message": f"Recalculated costs for {updated} archives", "updated": updated}
+
+
 @router.post("/rescan-all")
 async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
     """Rescan all archives and update their metadata."""
@@ -539,10 +594,17 @@ async def scan_timelapse(
     base_name = Path(archive.filename).stem
 
     # Scan timelapse directory on printer
-    try:
-        files = await list_files_async(printer.ip_address, printer.access_code, "/timelapse/video")
-    except Exception:
-        raise HTTPException(500, "Failed to connect to printer")
+    # Try both /timelapse and /timelapse/video (different printer models use different paths)
+    files = []
+    for timelapse_path in ["/timelapse", "/timelapse/video"]:
+        try:
+            files = await list_files_async(printer.ip_address, printer.access_code, timelapse_path)
+            if files:
+                break
+        except Exception:
+            continue
+    if not files:
+        raise HTTPException(500, "Failed to connect to printer or no timelapse directory found")
 
     # Look for matching timelapse
     matching_file = None
@@ -574,7 +636,12 @@ async def scan_timelapse(
                     # Timelapse is usually created at print end, so compare to completed_at or created_at
                     compare_time = archive.completed_at or archive.created_at
                     if compare_time:
-                        diff = abs(file_time - compare_time)
+                        # Bambu printers use China Standard Time (UTC+8) for filenames
+                        # Try matching with CST offset adjustment
+                        diff_direct = abs(file_time - compare_time)
+                        # Also try with 8-hour offset (CST to UTC-ish local times)
+                        diff_cst_adjusted = abs(file_time - timedelta(hours=8) - compare_time)
+                        diff = min(diff_direct, diff_cst_adjusted)
                         if diff < best_diff:
                             best_diff = diff
                             best_match = f
@@ -584,11 +651,23 @@ async def scan_timelapse(
         if best_match and best_diff < timedelta(hours=2):  # Within 2 hours
             matching_file = best_match
 
+    # Strategy 3: If only one timelapse exists and archive was recently completed, use it
+    # This handles cases where printer clock is wrong or timezone issues exist
+    if not matching_file and len(mp4_files) == 1:
+        from datetime import datetime, timedelta
+        archive_completed = archive.completed_at or archive.created_at
+        if archive_completed:
+            time_since_completion = datetime.now() - archive_completed
+            # If archive was completed within the last hour, assume the single timelapse is for it
+            if time_since_completion < timedelta(hours=1):
+                matching_file = mp4_files[0]
+                logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
+
     if not matching_file:
         return {"status": "not_found", "message": "No matching timelapse found on printer"}
 
-    # Download the timelapse
-    remote_path = f"/timelapse/video/{matching_file['name']}"
+    # Download the timelapse - use the full path from the file listing
+    remote_path = matching_file.get('path') or f"/timelapse/{matching_file['name']}"
     timelapse_data = await download_file_bytes_async(
         printer.ip_address, printer.access_code, remote_path
     )
@@ -1014,6 +1093,7 @@ async def reprint_archive(
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import upload_file_async
     from backend.app.services.printer_manager import printer_manager
+    from backend.app.main import register_expected_print
 
     # Get archive
     service = ArchiveService(db)
@@ -1050,6 +1130,9 @@ async def reprint_archive(
     if not uploaded:
         raise HTTPException(500, "Failed to upload file to printer")
 
+    # Register this as an expected print so we don't create a duplicate archive
+    register_expected_print(printer_id, remote_filename, archive_id)
+
     # Start the print
     started = printer_manager.start_print(printer_id, remote_filename)
 

+ 286 - 0
backend/app/api/routes/print_queue.py

@@ -0,0 +1,286 @@
+"""API routes for print queue management."""
+
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.database import get_db
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.archive import PrintArchive
+from backend.app.schemas.print_queue import (
+    PrintQueueItemCreate,
+    PrintQueueItemUpdate,
+    PrintQueueItemResponse,
+    PrintQueueReorder,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/queue", tags=["queue"])
+
+
+def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
+    """Add nested archive/printer info to response."""
+    response = PrintQueueItemResponse.model_validate(item)
+    if item.archive:
+        response.archive_name = item.archive.print_name or item.archive.filename
+        response.archive_thumbnail = item.archive.thumbnail_path
+    if item.printer:
+        response.printer_name = item.printer.name
+    return response
+
+
+@router.get("/", response_model=list[PrintQueueItemResponse])
+async def list_queue(
+    printer_id: int | None = Query(None, description="Filter by printer"),
+    status: str | None = Query(None, description="Filter by status"),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all queue items, optionally filtered by printer or status."""
+    query = (
+        select(PrintQueueItem)
+        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
+    )
+
+    if printer_id is not None:
+        query = query.where(PrintQueueItem.printer_id == printer_id)
+    if status:
+        query = query.where(PrintQueueItem.status == status)
+
+    result = await db.execute(query)
+    items = result.scalars().all()
+    return [_enrich_response(item) for item in items]
+
+
+@router.post("/", response_model=PrintQueueItemResponse)
+async def add_to_queue(
+    data: PrintQueueItemCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Add an item to the print queue."""
+    # Validate printer exists
+    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")
+
+    # Validate archive exists
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(400, "Archive not found")
+
+    # Get next position for this printer
+    result = await db.execute(
+        select(func.max(PrintQueueItem.position))
+        .where(PrintQueueItem.printer_id == data.printer_id)
+        .where(PrintQueueItem.status == "pending")
+    )
+    max_pos = result.scalar() or 0
+
+    item = PrintQueueItem(
+        printer_id=data.printer_id,
+        archive_id=data.archive_id,
+        scheduled_time=data.scheduled_time,
+        require_previous_success=data.require_previous_success,
+        auto_off_after=data.auto_off_after,
+        position=max_pos + 1,
+        status="pending",
+    )
+    db.add(item)
+    await db.commit()
+    await db.refresh(item)
+
+    # Load relationships for response
+    await db.refresh(item, ["archive", "printer"])
+
+    logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id}")
+    return _enrich_response(item)
+
+
+@router.get("/{item_id}", response_model=PrintQueueItemResponse)
+async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a specific queue item."""
+    result = await db.execute(
+        select(PrintQueueItem)
+        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .where(PrintQueueItem.id == item_id)
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Queue item not found")
+    return _enrich_response(item)
+
+
+@router.patch("/{item_id}", response_model=PrintQueueItemResponse)
+async def update_queue_item(
+    item_id: int,
+    data: PrintQueueItemUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a queue item."""
+    result = await db.execute(
+        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Queue item not found")
+
+    if item.status != "pending":
+        raise HTTPException(400, "Can only update pending items")
+
+    update_data = data.model_dump(exclude_unset=True)
+
+    # Validate new printer_id if being changed
+    if "printer_id" in update_data:
+        result = await db.execute(
+            select(Printer).where(Printer.id == update_data["printer_id"])
+        )
+        if not result.scalar_one_or_none():
+            raise HTTPException(400, "Printer not found")
+
+    for field, value in update_data.items():
+        setattr(item, field, value)
+
+    await db.commit()
+    await db.refresh(item, ["archive", "printer"])
+
+    logger.info(f"Updated queue item {item_id}")
+    return _enrich_response(item)
+
+
+@router.delete("/{item_id}")
+async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+    """Remove an item from the queue."""
+    result = await db.execute(
+        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Queue item not found")
+
+    if item.status == "printing":
+        raise HTTPException(400, "Cannot delete item that is currently printing")
+
+    await db.delete(item)
+    await db.commit()
+
+    logger.info(f"Deleted queue item {item_id}")
+    return {"message": "Queue item deleted"}
+
+
+@router.post("/reorder")
+async def reorder_queue(
+    data: PrintQueueReorder,
+    db: AsyncSession = Depends(get_db),
+):
+    """Bulk update positions for queue items."""
+    for reorder_item in data.items:
+        result = await db.execute(
+            select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id)
+        )
+        item = result.scalar_one_or_none()
+        if item and item.status == "pending":
+            item.position = reorder_item.position
+
+    await db.commit()
+    logger.info(f"Reordered {len(data.items)} queue items")
+    return {"message": f"Reordered {len(data.items)} items"}
+
+
+@router.post("/{item_id}/cancel")
+async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+    """Cancel a pending queue item."""
+    result = await db.execute(
+        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Queue item not found")
+
+    if item.status not in ("pending",):
+        raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
+
+    item.status = "cancelled"
+    item.completed_at = datetime.now()
+    await db.commit()
+
+    logger.info(f"Cancelled queue item {item_id}")
+    return {"message": "Queue item cancelled"}
+
+
+@router.post("/{item_id}/stop")
+async def stop_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Stop an actively printing queue item."""
+    from backend.app.services.printer_manager import printer_manager
+    from backend.app.services.tasmota import tasmota_service
+    from backend.app.models.smart_plug import SmartPlug
+    import asyncio
+
+    result = await db.execute(
+        select(PrintQueueItem).where(PrintQueueItem.id == item_id)
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Queue item not found")
+
+    if item.status != "printing":
+        raise HTTPException(400, f"Can only stop items that are printing, current status: '{item.status}'")
+
+    # Capture values we need for background task
+    printer_id = item.printer_id
+    auto_off_after = item.auto_off_after
+
+    # Try to send stop command to printer
+    stop_sent = False
+    try:
+        stop_sent = printer_manager.stop_print(printer_id)
+        if not stop_sent:
+            logger.warning(f"stop_print returned False for printer {printer_id} - printer may not be connected")
+    except Exception as e:
+        logger.error(f"Error sending stop command for queue item {item_id}: {e}")
+
+    # Update queue item status regardless - if printer is off, print is already stopped
+    item.status = "cancelled"
+    item.completed_at = datetime.now()
+    item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
+    await db.commit()
+
+    # Get smart plug info if auto-off is enabled
+    plug_ip = None
+    if auto_off_after:
+        result = await db.execute(
+            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+        )
+        plug = result.scalar_one_or_none()
+        if plug and plug.enabled:
+            plug_ip = plug.ip_address
+
+    logger.info(f"Stopped printing queue item {item_id} (stop command sent: {stop_sent})")
+
+    # Schedule background task for cooldown + power off
+    if plug_ip:
+        async def cooldown_and_poweroff():
+            logger.info(f"Auto-off: Waiting for printer {printer_id} to cool down before power off...")
+            await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
+            # Re-fetch plug since we're in a new async context
+            from backend.app.core.database import async_session
+            async with async_session() as new_db:
+                result = await new_db.execute(
+                    select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                )
+                plug = result.scalar_one_or_none()
+                if plug and plug.enabled:
+                    logger.info(f"Auto-off: Powering off printer {printer_id}")
+                    await tasmota_service.turn_off(plug)
+
+        asyncio.create_task(cooldown_and_poweroff())
+
+    return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}

+ 14 - 2
backend/app/api/routes/settings.py

@@ -1,3 +1,5 @@
+import shutil
+
 from fastapi import APIRouter, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
@@ -44,9 +46,9 @@ 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"]:
+            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
-            elif setting.key == "default_filament_cost":
+            elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
                 settings_dict[setting.key] = float(setting.value)
             else:
                 settings_dict[setting.key] = setting.value
@@ -87,3 +89,13 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
     await db.commit()
 
     return DEFAULT_SETTINGS
+
+
+@router.get("/check-ffmpeg")
+async def check_ffmpeg():
+    """Check if ffmpeg is installed and available."""
+    ffmpeg_path = shutil.which("ffmpeg")
+    return {
+        "installed": ffmpeg_path is not None,
+        "path": ffmpeg_path,
+    }

+ 22 - 1
backend/app/api/routes/smart_plugs.py

@@ -17,6 +17,7 @@ from backend.app.schemas.smart_plug import (
     SmartPlugControl,
     SmartPlugStatus,
     SmartPlugTestConnection,
+    SmartPlugEnergy,
 )
 from backend.app.services.tasmota import tasmota_service
 
@@ -62,6 +63,18 @@ async def create_smart_plug(
     return 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)
+    )
+    plug = result.scalar_one_or_none()
+    if not plug:
+        return None
+    return plug
+
+
 @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."""
@@ -171,7 +184,7 @@ async def control_smart_plug(
 
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
-    """Get current plug status from device."""
+    """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     if not plug:
@@ -185,10 +198,18 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
         plug.last_checked = datetime.utcnow()
         await db.commit()
 
+    # Fetch energy data if device is reachable
+    energy_data = None
+    if status["reachable"]:
+        energy = await tasmota_service.get_energy(plug)
+        if energy:
+            energy_data = SmartPlugEnergy(**energy)
+
     return SmartPlugStatus(
         state=status["state"],
         reachable=status["reachable"],
         device_name=status.get("device_name"),
+        energy=energy_data,
     )
 
 

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

@@ -34,7 +34,7 @@ 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  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)

+ 345 - 22
backend/app/main.py

@@ -1,31 +1,85 @@
 import asyncio
+import logging
 from datetime import datetime
 from contextlib import asynccontextmanager
 from pathlib import Path
+from logging.handlers import RotatingFileHandler
 
 from fastapi import FastAPI
+
+# Configure logging for all modules - console + file
+log_format = '%(asctime)s %(levelname)s [%(name)s] %(message)s'
+log_level = logging.INFO
+
+# Create root logger
+root_logger = logging.getLogger()
+root_logger.setLevel(log_level)
+
+# Console handler
+console_handler = logging.StreamHandler()
+console_handler.setLevel(log_level)
+console_handler.setFormatter(logging.Formatter(log_format))
+root_logger.addHandler(console_handler)
+
+# File handler - rotating log file (5MB max, keep 3 backups)
+log_file = Path(__file__).parent.parent.parent / "bambutrack.log"
+file_handler = RotatingFileHandler(
+    log_file,
+    maxBytes=5*1024*1024,  # 5MB
+    backupCount=3,
+    encoding='utf-8'
+)
+file_handler.setLevel(log_level)
+file_handler.setFormatter(logging.Formatter(log_format))
+root_logger.addHandler(file_handler)
+
+logging.info(f"Logging to file: {log_file}")
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import init_db, async_session
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.printer_manager import (
     printer_manager,
     printer_state_to_dict,
     init_printer_connections,
 )
+from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async
 from backend.app.services.smart_plug_manager import smart_plug_manager
+from backend.app.services.tasmota import tasmota_service
+from backend.app.models.smart_plug import SmartPlug
 
 
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
 
+# Track expected prints from reprint/scheduled (skip auto-archiving for these)
+# {(printer_id, filename): archive_id}
+_expected_prints: dict[tuple[int, str], int] = {}
+
+# Track starting energy for prints: {archive_id: starting_kwh}
+_print_energy_start: dict[int, float] = {}
+
+
+def register_expected_print(printer_id: int, filename: str, archive_id: int):
+    """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
+    # Store with multiple filename variations to catch different naming patterns
+    _expected_prints[(printer_id, filename)] = archive_id
+    # Also store without .3mf extension if present
+    if filename.endswith(".3mf"):
+        base = filename[:-4]
+        _expected_prints[(printer_id, base)] = archive_id
+        _expected_prints[(printer_id, f"{base}.gcode")] = archive_id
+    logging.getLogger(__name__).info(
+        f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}"
+    )
+
 
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
@@ -64,24 +118,135 @@ async def on_print_start(printer_id: int, data: dict):
         if not filename and not subtask_name:
             return
 
+        # Check if this is an expected print from reprint/scheduled
+        # Build list of possible keys to check
+        expected_keys = []
+        if subtask_name:
+            expected_keys.append((printer_id, subtask_name))
+            expected_keys.append((printer_id, f"{subtask_name}.3mf"))
+            expected_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
+        if filename:
+            fname = filename.split("/")[-1] if "/" in filename else filename
+            expected_keys.append((printer_id, fname))
+            # Strip extensions to match
+            base = fname.replace(".gcode", "").replace(".3mf", "")
+            expected_keys.append((printer_id, base))
+            expected_keys.append((printer_id, f"{base}.3mf"))
+
+        expected_archive_id = None
+        for key in expected_keys:
+            expected_archive_id = _expected_prints.pop(key, None)
+            if expected_archive_id:
+                # Clean up other possible keys for this print
+                for other_key in expected_keys:
+                    _expected_prints.pop(other_key, None)
+                break
+
+        if expected_archive_id:
+            # This is a reprint/scheduled print - use existing archive, don't create new one
+            logger.info(f"Using expected archive {expected_archive_id} for print (skipping duplicate)")
+            from backend.app.models.archive import PrintArchive
+            from datetime import datetime
+
+            result = await db.execute(
+                select(PrintArchive).where(PrintArchive.id == expected_archive_id)
+            )
+            archive = result.scalar_one_or_none()
+
+            if archive:
+                # Update archive status to printing
+                archive.status = "printing"
+                archive.started_at = datetime.now()
+                await db.commit()
+
+                # Track as active print
+                _active_prints[(printer_id, archive.filename)] = archive.id
+                if subtask_name:
+                    _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
+
+                # Set up energy tracking
+                try:
+                    plug_result = await db.execute(
+                        select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                    )
+                    plug = plug_result.scalar_one_or_none()
+                    if plug:
+                        energy = await tasmota_service.get_energy(plug)
+                        if energy and energy.get("total") is not None:
+                            _print_energy_start[archive.id] = energy["total"]
+                            logger.info(f"Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
+                except Exception as e:
+                    logger.warning(f"Failed to record starting energy: {e}")
+
+                await ws_manager.send_archive_updated({
+                    "id": archive.id,
+                    "status": "printing",
+                })
+
+            # Smart plug automation for expected prints too
+            try:
+                await smart_plug_manager.on_print_start(printer_id, db)
+            except Exception as e:
+                logger.warning(f"Smart plug on_print_start failed: {e}")
+
+            return  # Skip creating a new archive
+
+        # Check if there's already a "printing" archive for this printer/file
+        # This prevents duplicates when backend restarts during an active print
+        from backend.app.models.archive import PrintArchive
+        check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
+        existing = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.printer_id == printer_id)
+            .where(PrintArchive.status == "printing")
+            .where(PrintArchive.print_name.ilike(f"%{check_name}%"))
+            .order_by(PrintArchive.created_at.desc())
+            .limit(1)
+        )
+        existing_archive = existing.scalar_one_or_none()
+        if existing_archive:
+            logger.info(f"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}")
+            # Track this as the active print
+            _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id
+            # Also set up energy tracking if not already tracked
+            if existing_archive.id not in _print_energy_start:
+                try:
+                    plug_result = await db.execute(
+                        select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                    )
+                    plug = plug_result.scalar_one_or_none()
+                    if plug:
+                        energy = await tasmota_service.get_energy(plug)
+                        if energy and energy.get("total") is not None:
+                            _print_energy_start[existing_archive.id] = energy["total"]
+                            logger.info(f"Recorded starting energy for existing archive {existing_archive.id}: {energy['total']} kWh")
+                except Exception as e:
+                    logger.warning(f"Failed to record starting energy for existing archive: {e}")
+            return
+
         # Build list of possible 3MF filenames to try
         possible_names = []
 
+        # Bambu printers typically store files as "Name.gcode.3mf"
+        # The subtask_name is usually the best source for the filename
+        if subtask_name:
+            # Try common Bambu naming patterns
+            possible_names.append(f"{subtask_name}.gcode.3mf")
+            possible_names.append(f"{subtask_name}.3mf")
+
         # Try original filename with .3mf extension
         if filename:
-            if filename.endswith(".3mf"):
-                possible_names.append(filename)
-            elif filename.endswith(".gcode"):
-                base = filename.rsplit(".", 1)[0]
+            # Extract just the filename part, not the full path
+            fname = filename.split("/")[-1] if "/" in filename else filename
+            if fname.endswith(".3mf"):
+                possible_names.append(fname)
+            elif fname.endswith(".gcode"):
+                base = fname.rsplit(".", 1)[0]
+                possible_names.append(f"{base}.gcode.3mf")
                 possible_names.append(f"{base}.3mf")
             else:
-                # No extension - try adding .3mf
-                possible_names.append(f"{filename}.3mf")
-                possible_names.append(filename)
-
-        # Try subtask_name with .3mf extension
-        if subtask_name and subtask_name != filename:
-            possible_names.append(f"{subtask_name}.3mf")
+                possible_names.append(f"{fname}.gcode.3mf")
+                possible_names.append(f"{fname}.3mf")
 
         # Remove duplicates while preserving order
         seen = set()
@@ -107,15 +272,19 @@ async def on_print_start(printer_id: int, data: dict):
             temp_path.parent.mkdir(parents=True, exist_ok=True)
 
             for remote_path in remote_paths:
-                if await download_file_async(
-                    printer.ip_address,
-                    printer.access_code,
-                    remote_path,
-                    temp_path,
-                ):
-                    downloaded_filename = try_filename
-                    logger.info(f"Downloaded: {remote_path}")
-                    break
+                logger.debug(f"Trying FTP download: {remote_path}")
+                try:
+                    if await download_file_async(
+                        printer.ip_address,
+                        printer.access_code,
+                        remote_path,
+                        temp_path,
+                    ):
+                        downloaded_filename = try_filename
+                        logger.info(f"Downloaded: {remote_path}")
+                        break
+                except Exception as e:
+                    logger.debug(f"FTP download failed for {remote_path}: {e}")
 
             if downloaded_filename:
                 break
@@ -167,6 +336,20 @@ async def on_print_start(printer_id: int, data: dict):
 
                 logger.info(f"Created archive {archive.id} for {downloaded_filename}")
 
+                # Record starting energy from smart plug if available
+                try:
+                    plug_result = await db.execute(
+                        select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                    )
+                    plug = plug_result.scalar_one_or_none()
+                    if plug:
+                        energy = await tasmota_service.get_energy(plug)
+                        if energy and energy.get("total") is not None:
+                            _print_energy_start[archive.id] = energy["total"]
+                            logger.info(f"Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
+                except Exception as e:
+                    logger.warning(f"Failed to record starting energy: {e}")
+
                 await ws_manager.send_archive_created({
                     "id": archive.id,
                     "printer_id": archive.printer_id,
@@ -252,7 +435,7 @@ async def on_print_complete(printer_id: int, data: dict):
         await service.update_archive_status(
             archive_id,
             status=status,
-            completed_at=datetime.now() if status in ("completed", "failed") else None,
+            completed_at=datetime.now() if status in ("completed", "failed", "aborted") else None,
         )
 
         await ws_manager.send_archive_updated({
@@ -260,6 +443,91 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
         })
 
+    # Calculate energy used for this print
+    try:
+        starting_kwh = _print_energy_start.pop(archive_id, None)
+        if starting_kwh is not None:
+            async with async_session() as db:
+                # Get smart plug for this printer
+                plug_result = await db.execute(
+                    select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                )
+                plug = plug_result.scalar_one_or_none()
+
+                if plug:
+                    energy = await tasmota_service.get_energy(plug)
+                    if energy and energy.get("total") is not None:
+                        ending_kwh = energy["total"]
+                        energy_used = round(ending_kwh - starting_kwh, 4)
+
+                        # Get energy cost per kWh from settings (default to 0.15)
+                        from backend.app.api.routes.settings import get_setting
+                        energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
+                        cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
+                        energy_cost = round(energy_used * cost_per_kwh, 2)
+
+                        # Update archive with energy data
+                        from backend.app.models.archive import PrintArchive
+                        result = await db.execute(
+                            select(PrintArchive).where(PrintArchive.id == archive_id)
+                        )
+                        archive = result.scalar_one_or_none()
+                        if archive:
+                            archive.energy_kwh = energy_used
+                            archive.energy_cost = energy_cost
+                            await db.commit()
+                            logger.info(f"Recorded energy for archive {archive_id}: {energy_used} kWh (${energy_cost})")
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Failed to calculate energy: {e}")
+
+    # Capture finish photo from printer camera
+    try:
+        async with async_session() as db:
+            # Check if finish photo capture is enabled
+            from backend.app.api.routes.settings import get_setting
+            capture_enabled = await get_setting(db, "capture_finish_photo")
+            if capture_enabled is None or capture_enabled.lower() == "true":
+                # Get printer details
+                from backend.app.models.printer import Printer
+                from sqlalchemy import select
+                result = await db.execute(
+                    select(Printer).where(Printer.id == printer_id)
+                )
+                printer = result.scalar_one_or_none()
+
+                if printer and archive_id:
+                    # Get archive to find its directory
+                    from backend.app.models.archive import PrintArchive
+                    result = await db.execute(
+                        select(PrintArchive).where(PrintArchive.id == archive_id)
+                    )
+                    archive = result.scalar_one_or_none()
+
+                    if archive:
+                        from backend.app.services.camera import capture_finish_photo
+                        from pathlib import Path
+
+                        archive_dir = app_settings.base_dir / Path(archive.file_path).parent
+                        photo_filename = await capture_finish_photo(
+                            printer_id=printer_id,
+                            ip_address=printer.ip_address,
+                            access_code=printer.access_code,
+                            model=printer.model,
+                            archive_dir=archive_dir,
+                        )
+
+                        if photo_filename:
+                            # Add photo to archive's photos list
+                            photos = archive.photos or []
+                            photos.append(photo_filename)
+                            archive.photos = photos
+                            await db.commit()
+                            logger.info(f"Added finish photo to archive {archive_id}: {photo_filename}")
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Finish photo capture failed: {e}")
+
     # Smart plug automation: schedule turn off when print completes
     try:
         async with async_session() as db:
@@ -269,6 +537,56 @@ async def on_print_complete(printer_id: int, data: dict):
         import logging
         logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
 
+    # Update queue item if this was a scheduled print
+    try:
+        async with async_session() as db:
+            from backend.app.models.print_queue import PrintQueueItem
+            from backend.app.models.smart_plug import SmartPlug
+            from backend.app.services.tasmota import tasmota_service
+
+            result = await db.execute(
+                select(PrintQueueItem)
+                .where(PrintQueueItem.printer_id == printer_id)
+                .where(PrintQueueItem.status == "printing")
+            )
+            queue_item = result.scalar_one_or_none()
+            if queue_item:
+                status = data.get("status", "completed")
+                queue_item.status = status
+                queue_item.completed_at = datetime.now()
+                await db.commit()
+                logger.info(f"Updated queue item {queue_item.id} status to {status}")
+
+                # Handle auto_off_after - power off printer if requested (after cooldown)
+                if queue_item.auto_off_after:
+                    result = await db.execute(
+                        select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+                    )
+                    plug = result.scalar_one_or_none()
+                    if plug and plug.enabled:
+                        logger.info(f"Auto-off requested for printer {printer_id}, waiting for cooldown...")
+
+                        async def cooldown_and_poweroff(pid: int, plug_id: int):
+                            # Wait for nozzle to cool down
+                            await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)
+                            # Re-fetch plug in new session
+                            async with async_session() as new_db:
+                                result = await new_db.execute(
+                                    select(SmartPlug).where(SmartPlug.id == plug_id)
+                                )
+                                p = result.scalar_one_or_none()
+                                if p and p.enabled:
+                                    success = await tasmota_service.turn_off(p)
+                                    if success:
+                                        logger.info(f"Powered off printer {pid} via smart plug '{p.name}'")
+                                    else:
+                                        logger.warning(f"Failed to power off printer {pid} via smart plug")
+
+                        asyncio.create_task(cooldown_and_poweroff(printer_id, plug.id))
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
+
 
 @asynccontextmanager
 async def lifespan(app: FastAPI):
@@ -286,9 +604,13 @@ async def lifespan(app: FastAPI):
     async with async_session() as db:
         await init_printer_connections(db)
 
+    # Start the print scheduler
+    asyncio.create_task(print_scheduler.run())
+
     yield
 
     # Shutdown
+    print_scheduler.stop()
     printer_manager.disconnect_all()
 
 
@@ -306,6 +628,7 @@ app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
+app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 5 - 0
backend/app/models/archive.py

@@ -26,6 +26,7 @@ class PrintArchive(Base):
     filament_type: Mapped[str | None] = mapped_column(String(50))
     filament_color: Mapped[str | None] = mapped_column(String(50))
     layer_height: Mapped[float | None] = mapped_column(Float)
+    total_layers: Mapped[int | None] = mapped_column(Integer)
     nozzle_diameter: Mapped[float | None] = mapped_column(Float)
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
@@ -50,6 +51,10 @@ class PrintArchive(Base):
     photos: Mapped[list | None] = mapped_column(JSON)  # List of photo filenames
     failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints
 
+    # Energy tracking
+    energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
+    energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(
         DateTime, server_default=func.now()

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

@@ -0,0 +1,50 @@
+from datetime import datetime
+from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PrintQueueItem(Base):
+    """Print queue item for scheduled/queued prints."""
+
+    __tablename__ = "print_queue"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+
+    # Links
+    printer_id: Mapped[int] = mapped_column(
+        ForeignKey("printers.id", ondelete="CASCADE")
+    )
+    archive_id: Mapped[int] = mapped_column(
+        ForeignKey("print_archives.id", ondelete="CASCADE")
+    )
+
+    # Scheduling
+    position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
+    scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # None = ASAP
+
+    # Conditions
+    require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
+
+    # Power management
+    auto_off_after: Mapped[bool] = mapped_column(Boolean, default=False)  # Power off printer after print
+
+    # Status: pending, printing, completed, failed, skipped, cancelled
+    status: Mapped[str] = mapped_column(String(20), default="pending")
+
+    # Tracking
+    started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    # Relationships
+    printer: Mapped["Printer"] = relationship()
+    archive: Mapped["PrintArchive"] = relationship()
+
+
+from backend.app.models.printer import Printer  # noqa: E402
+from backend.app.models.archive import PrintArchive  # noqa: E402

+ 8 - 0
backend/app/schemas/archive.py

@@ -45,6 +45,7 @@ class ArchiveResponse(BaseModel):
     filament_type: str | None
     filament_color: str | None
     layer_height: float | None
+    total_layers: int | None = None
     nozzle_diameter: float | None
     bed_temperature: int | None
     nozzle_temperature: int | None
@@ -65,6 +66,10 @@ class ArchiveResponse(BaseModel):
     photos: list | None
     failure_reason: str | None
 
+    # Energy tracking
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
+
     created_at: datetime
 
     class Config:
@@ -83,6 +88,9 @@ class ArchiveStats(BaseModel):
     # Time accuracy stats
     average_time_accuracy: float | None = None  # Average across all prints with data
     time_accuracy_by_printer: dict | None = None  # Per-printer accuracy
+    # Energy stats
+    total_energy_kwh: float = 0.0
+    total_energy_cost: float = 0.0
 
 
 class ProjectPageImage(BaseModel):

+ 62 - 0
backend/app/schemas/print_queue.py

@@ -0,0 +1,62 @@
+from datetime import datetime, timezone
+from typing import Literal, Annotated
+from pydantic import BaseModel, PlainSerializer
+
+
+# Custom serializer to ensure UTC datetimes have Z suffix
+def serialize_utc_datetime(dt: datetime | None) -> str | None:
+    if dt is None:
+        return None
+    # Add Z suffix to indicate UTC
+    return dt.isoformat() + "Z"
+
+
+UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
+
+
+class PrintQueueItemCreate(BaseModel):
+    printer_id: int
+    archive_id: int
+    scheduled_time: datetime | None = None  # None = ASAP (next when idle)
+    require_previous_success: bool = False
+    auto_off_after: bool = False  # Power off printer after print completes
+
+
+class PrintQueueItemUpdate(BaseModel):
+    printer_id: int | None = None
+    position: int | None = None
+    scheduled_time: datetime | None = None
+    require_previous_success: bool | None = None
+    auto_off_after: bool | None = None
+
+
+class PrintQueueItemResponse(BaseModel):
+    id: int
+    printer_id: int
+    archive_id: int
+    position: int
+    scheduled_time: UTCDatetime
+    require_previous_success: bool
+    auto_off_after: bool
+    status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
+    started_at: UTCDatetime
+    completed_at: UTCDatetime
+    error_message: str | None
+    created_at: UTCDatetime
+
+    # Nested info for UI (populated in route)
+    archive_name: str | None = None
+    archive_thumbnail: str | None = None
+    printer_name: str | None = None
+
+    class Config:
+        from_attributes = True
+
+
+class PrintQueueReorderItem(BaseModel):
+    id: int
+    position: int
+
+
+class PrintQueueReorder(BaseModel):
+    items: list[PrintQueueReorderItem]

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

@@ -6,8 +6,10 @@ class AppSettings(BaseModel):
 
     auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
     save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
+    capture_finish_photo: bool = Field(default=True, description="Capture photo from printer camera when print completes")
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     currency: str = Field(default="USD", description="Currency for cost tracking")
+    energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
 
 
 class AppSettingsUpdate(BaseModel):
@@ -15,5 +17,7 @@ class AppSettingsUpdate(BaseModel):
 
     auto_archive: bool | None = None
     save_thumbnails: bool | None = None
+    capture_finish_photo: bool | None = None
     default_filament_cost: float | None = None
     currency: str | None = None
+    energy_cost_per_kwh: float | None = None

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

@@ -50,10 +50,24 @@ class SmartPlugControl(BaseModel):
     action: Literal["on", "off", "toggle"]
 
 
+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
+    today: float | None = None  # kWh used today
+    yesterday: float | None = None  # kWh used yesterday
+    total: float | None = None  # Total kWh
+    factor: float | None = None  # Power factor (0-1)
+    apparent_power: float | None = None  # VA
+    reactive_power: float | None = None  # VAr
+
+
 class SmartPlugStatus(BaseModel):
     state: str | None = None  # "ON", "OFF", or None if unreachable
     reachable: bool = True
     device_name: str | None = None
+    energy: SmartPlugEnergy | None = None  # Energy data if available
 
 
 class SmartPlugTestConnection(BaseModel):

+ 44 - 0
backend/app/services/archive.py

@@ -12,6 +12,7 @@ from sqlalchemy import select, and_, or_
 from backend.app.core.config import settings
 from backend.app.models.archive import PrintArchive
 from backend.app.models.printer import Printer
+from backend.app.models.filament import Filament
 
 
 class ThreeMFParser:
@@ -27,6 +28,7 @@ class ThreeMFParser:
             with zipfile.ZipFile(self.file_path, "r") as zf:
                 self._parse_slice_info(zf)
                 self._parse_project_settings(zf)
+                self._parse_gcode_header(zf)
                 self._parse_3dmodel(zf)
                 self._extract_thumbnail(zf)
 
@@ -99,6 +101,27 @@ class ThreeMFParser:
         except Exception:
             pass
 
+    def _parse_gcode_header(self, zf: zipfile.ZipFile):
+        """Parse G-code file header for total layer count."""
+        import re
+        try:
+            # Look for plate_1.gcode or similar
+            gcode_files = [f for f in zf.namelist() if f.endswith('.gcode')]
+            if not gcode_files:
+                return
+
+            # Read first 2KB of G-code (header contains the layer count)
+            gcode_path = gcode_files[0]
+            with zf.open(gcode_path) as f:
+                header = f.read(2048).decode('utf-8', errors='ignore')
+
+            # Look for "; total layer number: XX" pattern
+            match = re.search(r';\s*total\s+layer\s+number[:\s]+(\d+)', header, re.IGNORECASE)
+            if match:
+                self.metadata["total_layers"] = int(match.group(1))
+        except Exception:
+            pass
+
     def _extract_filament_info(self, data: dict):
         """Extract filament info, preferring non-support filaments."""
         try:
@@ -618,6 +641,25 @@ class ArchiveService:
         started_at = datetime.now() if status == "printing" else None
         completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
 
+        # Calculate cost based on filament usage and type
+        cost = None
+        filament_grams = metadata.get("filament_used_grams")
+        filament_type = metadata.get("filament_type")
+        if filament_grams and filament_type:
+            # For multi-material prints, use the first filament type for cost calculation
+            primary_type = filament_type.split(",")[0].strip()
+            # Look up filament cost_per_kg from database
+            filament_result = await self.db.execute(
+                select(Filament).where(Filament.type == primary_type).limit(1)
+            )
+            filament = filament_result.scalar_one_or_none()
+            if filament:
+                cost = round((filament_grams / 1000) * filament.cost_per_kg, 2)
+            else:
+                # Default cost_per_kg if filament type not found
+                default_cost_per_kg = 25.0
+                cost = round((filament_grams / 1000) * default_cost_per_kg, 2)
+
         # Create archive record
         archive = PrintArchive(
             printer_id=printer_id,
@@ -632,6 +674,7 @@ class ArchiveService:
             filament_type=metadata.get("filament_type"),
             filament_color=metadata.get("filament_color"),
             layer_height=metadata.get("layer_height"),
+            total_layers=metadata.get("total_layers"),
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
@@ -640,6 +683,7 @@ class ArchiveService:
             status=status,
             started_at=started_at,
             completed_at=completed_at,
+            cost=cost,
             extra_data=metadata,
         )
 

+ 9 - 1
backend/app/services/bambu_ftp.py

@@ -153,13 +153,18 @@ class BambuFTPClient:
     def upload_file(self, local_path: Path, remote_path: str) -> bool:
         """Upload a file to the printer."""
         if not self._ftp:
+            logger.warning(f"upload_file: FTP not connected")
             return False
 
         try:
+            file_size = local_path.stat().st_size if local_path.exists() else 0
+            logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}")
             with open(local_path, "rb") as f:
                 self._ftp.storbinary(f"STOR {remote_path}", f)
+            logger.info(f"FTP upload complete: {remote_path}")
             return True
-        except Exception:
+        except Exception as e:
+            logger.error(f"FTP upload failed for {remote_path}: {e}")
             return False
 
     def upload_bytes(self, data: bytes, remote_path: str) -> bool:
@@ -298,12 +303,15 @@ async def upload_file_async(
     loop = asyncio.get_event_loop()
 
     def _upload():
+        logger.info(f"FTP connecting to {ip_address} for upload...")
         client = BambuFTPClient(ip_address, access_code)
         if client.connect():
+            logger.info(f"FTP connected to {ip_address}")
             try:
                 return client.upload_file(local_path, remote_path)
             finally:
                 client.disconnect()
+        logger.warning(f"FTP connection failed to {ip_address}")
         return False
 
     return await loop.run_in_executor(None, _upload)

+ 77 - 8
backend/app/services/bambu_mqtt.py

@@ -1,6 +1,7 @@
 import json
 import ssl
 import asyncio
+import logging
 from collections import deque
 from datetime import datetime
 from typing import Callable
@@ -8,6 +9,8 @@ from dataclasses import dataclass, field
 
 import paho.mqtt.client as mqtt
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass
 class MQTTLogEntry:
@@ -114,6 +117,12 @@ class BambuMQTTClient:
         """Process incoming MQTT message from printer."""
         if "print" in payload:
             print_data = payload["print"]
+            # Log when we see gcode_state changes
+            if "gcode_state" in print_data:
+                logger.info(
+                    f"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, "
+                    f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
+                )
             self._update_state(print_data)
 
     def _update_state(self, data: dict):
@@ -144,6 +153,11 @@ class BambuMQTTClient:
 
         # Temperature data
         temps = {}
+        # Log all temperature-related fields for debugging (only when we have temp data)
+        temp_fields = {k: v for k, v in data.items() if 'temp' in k.lower() or 'nozzle' in k.lower()}
+        if temp_fields and not hasattr(self, '_temp_fields_logged'):
+            logger.info(f"[{self.serial_number}] Temperature fields in MQTT data: {temp_fields}")
+            self._temp_fields_logged = True
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
@@ -153,10 +167,20 @@ class BambuMQTTClient:
         if "nozzle_target_temper" in data:
             temps["nozzle_target"] = float(data["nozzle_target_temper"])
         # Second nozzle for dual-extruder printers (H2 series)
+        # Try multiple possible field names used by different firmware versions
         if "nozzle_temper_2" in data:
             temps["nozzle_2"] = float(data["nozzle_temper_2"])
+        elif "right_nozzle_temper" in data:
+            temps["nozzle_2"] = float(data["right_nozzle_temper"])
         if "nozzle_target_temper_2" in data:
             temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
+        elif "right_nozzle_target_temper" in data:
+            temps["nozzle_2_target"] = float(data["right_nozzle_target_temper"])
+        # Also check for left nozzle as primary (some H2 models)
+        if "left_nozzle_temper" in data and "nozzle" not in temps:
+            temps["nozzle"] = float(data["left_nozzle_temper"])
+        if "left_nozzle_target_temper" in data and "nozzle_target" not in temps:
+            temps["nozzle_target"] = float(data["left_nozzle_target_temper"])
         if "chamber_temper" in data:
             temps["chamber"] = float(data["chamber_temper"])
         if temps:
@@ -190,6 +214,13 @@ class BambuMQTTClient:
 
         self.state.raw_data = data
 
+        # Log state transitions for debugging
+        if "gcode_state" in data:
+            logger.debug(
+                f"[{self.serial_number}] gcode_state: {self._previous_gcode_state} -> {self.state.state}, "
+                f"file: {self.state.gcode_file}, subtask: {self.state.subtask_name}"
+            )
+
         # Detect print start (state changes TO RUNNING with a file)
         current_file = self.state.gcode_file or self.state.current_print
         is_new_print = (
@@ -205,21 +236,39 @@ class BambuMQTTClient:
             and self._previous_gcode_file is not None
         )
 
+        if is_new_print or is_file_change:
+            # Clear any old HMS errors when a new print starts
+            self.state.hms_errors = []
+
         if (is_new_print or is_file_change) and self.on_print_start:
+            logger.info(
+                f"[{self.serial_number}] PRINT START detected - file: {current_file}, "
+                f"subtask: {self.state.subtask_name}, is_new: {is_new_print}, is_file_change: {is_file_change}"
+            )
             self.on_print_start({
                 "filename": current_file,
                 "subtask_name": self.state.subtask_name,
                 "raw_data": data,
             })
 
-        # Detect print completion
+        # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
         if (
             self._previous_gcode_state == "RUNNING"
-            and self.state.state in ("FINISH", "FAILED")
+            and self.state.state in ("FINISH", "FAILED", "IDLE")
             and self.on_print_complete
         ):
+            if self.state.state == "FINISH":
+                status = "completed"
+            elif self.state.state == "FAILED":
+                status = "failed"
+            else:
+                status = "aborted"
+            logger.info(
+                f"[{self.serial_number}] PRINT COMPLETE detected - state: {self.state.state}, "
+                f"status: {status}, file: {self._previous_gcode_file or current_file}"
+            )
             self.on_print_complete({
-                "status": "completed" if self.state.state == "FINISH" else "failed",
+                "status": status,
                 "filename": self._previous_gcode_file or current_file,
                 "raw_data": data,
             })
@@ -256,20 +305,26 @@ class BambuMQTTClient:
         ssl_context.verify_mode = ssl.CERT_NONE
         self._client.tls_set_context(ssl_context)
 
-        self._client.connect_async(self.ip_address, self.MQTT_PORT)
+        # Use shorter keepalive (15s) for faster disconnect detection
+        # Paho considers connection lost after 1.5x keepalive with no response
+        self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=15)
         self._client.loop_start()
 
     def start_print(self, filename: str, plate_id: int = 1):
-        """Start a print job on the printer."""
+        """Start a print job on the printer.
+
+        The file should already be uploaded to /cache/ on the printer via FTP.
+        """
         if self._client and self.state.connected:
             # Bambu print command format
+            # Based on: https://github.com/darkorb/bambu-ftp-and-print
             command = {
                 "print": {
+                    "sequence_id": 0,
                     "command": "project_file",
                     "param": f"Metadata/plate_{plate_id}.gcode",
                     "subtask_name": filename,
                     "url": f"ftp://{filename}",
-                    "bed_type": "auto",
                     "timelapse": False,
                     "bed_leveling": True,
                     "flow_cali": True,
@@ -278,7 +333,22 @@ class BambuMQTTClient:
                     "use_ams": True,
                 }
             }
+            logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
+            self._client.publish(self.topic_publish, json.dumps(command))
+            return True
+        return False
+
+    def stop_print(self) -> bool:
+        """Stop the current print job."""
+        if self._client and self.state.connected:
+            command = {
+                "print": {
+                    "command": "stop",
+                    "sequence_id": "0"
+                }
+            }
             self._client.publish(self.topic_publish, json.dumps(command))
+            logger.info(f"[{self.serial_number}] Sent stop print command")
             return True
         return False
 
@@ -306,8 +376,7 @@ class BambuMQTTClient:
     def enable_logging(self, enabled: bool = True):
         """Enable or disable MQTT message logging."""
         self._logging_enabled = enabled
-        if not enabled:
-            self._message_log.clear()
+        # Don't clear logs when stopping - user can manually clear with clear_logs()
 
     def get_logs(self) -> list[MQTTLogEntry]:
         """Get all logged MQTT messages."""

+ 192 - 0
backend/app/services/camera.py

@@ -0,0 +1,192 @@
+"""Camera capture service for Bambu Lab printers.
+
+Captures images from the printer's RTSPS camera stream using ffmpeg.
+"""
+
+import asyncio
+import logging
+import subprocess
+from pathlib import Path
+from datetime import datetime
+import uuid
+
+from backend.app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+def get_camera_port(model: str | None) -> int:
+    """Get the RTSPS port based on printer model.
+
+    X1 and H2D series use port 322.
+    P1 and A1 series use port 6000.
+    """
+    if model:
+        model_upper = model.upper()
+        if model_upper.startswith(("X1", "H2")):
+            return 322
+    # Default to 6000 for P1/A1 or unknown models
+    return 6000
+
+
+def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
+    """Build the RTSPS URL for the printer camera."""
+    port = get_camera_port(model)
+    return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
+
+
+async def capture_camera_frame(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    output_path: Path,
+    timeout: int = 30,
+) -> bool:
+    """Capture a single frame from the printer's camera stream.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model (X1, H2D, P1, A1, etc.)
+        output_path: Path where to save the captured image
+        timeout: Timeout in seconds for the capture operation
+
+    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)
+
+    # ffmpeg command to capture a single frame from RTSPS stream
+    # -rtsp_transport tcp: Use TCP for RTSP (more reliable)
+    # -y: Overwrite output file
+    # -frames:v 1: Capture only 1 frame
+    # -q:v 2: High quality JPEG (1-31, lower is better)
+    cmd = [
+        "ffmpeg",
+        "-y",  # Overwrite output
+        "-rtsp_transport", "tcp",
+        "-i", camera_url,
+        "-frames:v", "1",
+        "-q:v", "2",
+        str(output_path),
+    ]
+
+    logger.info(f"Capturing camera frame from {ip_address} (model: {model})")
+
+    try:
+        # Run ffmpeg asynchronously with timeout
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        try:
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(),
+                timeout=timeout
+            )
+        except asyncio.TimeoutError:
+            process.kill()
+            await process.wait()
+            logger.error(f"Camera capture timed out after {timeout}s")
+            return False
+
+        if process.returncode != 0:
+            stderr_text = stderr.decode() if stderr else "Unknown error"
+            logger.error(f"ffmpeg failed with code {process.returncode}: {stderr_text}")
+            return False
+
+        if output_path.exists() and output_path.stat().st_size > 0:
+            logger.info(f"Successfully captured camera frame: {output_path}")
+            return True
+        else:
+            logger.error("Camera capture produced no output file")
+            return False
+
+    except FileNotFoundError:
+        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
+        return False
+    except Exception as e:
+        logger.exception(f"Camera capture failed: {e}")
+        return False
+
+
+async def capture_finish_photo(
+    printer_id: int,
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    archive_dir: Path,
+) -> str | None:
+    """Capture a finish photo and save it to the archive's photos folder.
+
+    Args:
+        printer_id: ID of the printer
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model
+        archive_dir: Directory of the archive (where the 3MF is stored)
+
+    Returns:
+        Filename of the captured photo, or None if capture failed
+    """
+    # Create photos subdirectory
+    photos_dir = archive_dir / "photos"
+    photos_dir.mkdir(parents=True, exist_ok=True)
+
+    # Generate filename with timestamp
+    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+    filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+    output_path = photos_dir / filename
+
+    success = await capture_camera_frame(
+        ip_address=ip_address,
+        access_code=access_code,
+        model=model,
+        output_path=output_path,
+        timeout=30,
+    )
+
+    if success:
+        logger.info(f"Finish photo saved: {filename}")
+        return filename
+    else:
+        logger.warning(f"Failed to capture finish photo for printer {printer_id}")
+        return None
+
+
+async def test_camera_connection(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+) -> dict:
+    """Test if the camera stream is accessible.
+
+    Returns dict with success status and any error message.
+    """
+    import tempfile
+
+    with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
+        test_path = Path(f.name)
+
+    try:
+        success = await capture_camera_frame(
+            ip_address=ip_address,
+            access_code=access_code,
+            model=model,
+            output_path=test_path,
+            timeout=15,
+        )
+
+        if success:
+            return {"success": True, "message": "Camera connection successful"}
+        else:
+            return {"success": False, "error": "Failed to capture frame from camera"}
+    finally:
+        # Clean up test file
+        if test_path.exists():
+            test_path.unlink()

+ 319 - 0
backend/app/services/print_scheduler.py

@@ -0,0 +1,319 @@
+"""Print scheduler service - processes the print queue."""
+
+import asyncio
+import logging
+from datetime import datetime
+from pathlib import Path
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings
+from backend.app.core.database import async_session
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.archive import PrintArchive
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.bambu_ftp import upload_file_async
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.tasmota import tasmota_service
+
+logger = logging.getLogger(__name__)
+
+
+class PrintScheduler:
+    """Background scheduler that processes the print queue."""
+
+    def __init__(self):
+        self._running = False
+        self._check_interval = 30  # seconds
+        self._power_on_wait_time = 180  # seconds to wait for printer after power on (3 min)
+        self._power_on_check_interval = 10  # seconds between connection checks
+
+    async def run(self):
+        """Main loop - check queue every interval."""
+        self._running = True
+        logger.info("Print scheduler started")
+
+        while self._running:
+            try:
+                await self.check_queue()
+            except Exception as e:
+                logger.error(f"Scheduler error: {e}")
+
+            await asyncio.sleep(self._check_interval)
+
+    def stop(self):
+        """Stop the scheduler."""
+        self._running = False
+        logger.info("Print scheduler stopped")
+
+    async def check_queue(self):
+        """Check for prints ready to start."""
+        async with async_session() as db:
+            # Get all pending items, ordered by printer and position
+            result = await db.execute(
+                select(PrintQueueItem)
+                .where(PrintQueueItem.status == "pending")
+                .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
+            )
+            items = list(result.scalars().all())
+
+            if not items:
+                return
+
+            # Group by printer - only process first item per printer
+            processed_printers = set()
+
+            for item in items:
+                if item.printer_id in processed_printers:
+                    continue
+
+                # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
+                if item.scheduled_time and item.scheduled_time > datetime.utcnow():
+                    continue
+
+                # Check if printer is idle
+                printer_idle = self._is_printer_idle(item.printer_id)
+                printer_connected = printer_manager.is_connected(item.printer_id)
+
+                # If printer not connected, try to power on via smart plug
+                if not printer_connected:
+                    plug = await self._get_smart_plug(db, item.printer_id)
+                    if plug and plug.auto_on and plug.enabled:
+                        logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
+                        powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
+                        if powered_on:
+                            printer_connected = True
+                            printer_idle = self._is_printer_idle(item.printer_id)
+                        else:
+                            logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
+                            processed_printers.add(item.printer_id)
+                            continue
+                    else:
+                        # No plug or auto_on disabled
+                        processed_printers.add(item.printer_id)
+                        continue
+
+                # Check if printer is idle (busy with another print)
+                if not printer_idle:
+                    processed_printers.add(item.printer_id)
+                    continue
+
+                # Check condition (previous print success)
+                if item.require_previous_success:
+                    if not await self._check_previous_success(db, item):
+                        item.status = "skipped"
+                        item.error_message = "Previous print failed or was aborted"
+                        item.completed_at = datetime.now()
+                        await db.commit()
+                        logger.info(f"Skipped queue item {item.id} - previous print failed")
+                        continue
+
+                # Start the print
+                await self._start_print(db, item)
+                processed_printers.add(item.printer_id)
+
+    def _is_printer_idle(self, printer_id: int) -> bool:
+        """Check if a printer is connected and idle."""
+        if not printer_manager.is_connected(printer_id):
+            return False
+
+        state = printer_manager.get_status(printer_id)
+        if not state:
+            return False
+
+        # Printer is idle if state is IDLE or FINISH
+        return state.state in ("IDLE", "FINISH", "unknown")
+
+    async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
+        """Get the smart plug associated with a printer."""
+        result = await db.execute(
+            select(SmartPlug).where(SmartPlug.printer_id == printer_id)
+        )
+        return result.scalar_one_or_none()
+
+    async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
+        """Turn on smart plug and wait for printer to connect.
+
+        Returns True if printer connected successfully within timeout.
+        """
+        # Check current plug state
+        status = await tasmota_service.get_status(plug)
+        if not status.get("reachable"):
+            logger.warning(f"Smart plug '{plug.name}' is not reachable")
+            return False
+
+        # Turn on if not already on
+        if status.get("state") != "ON":
+            success = await tasmota_service.turn_on(plug)
+            if not success:
+                logger.warning(f"Failed to turn on smart plug '{plug.name}'")
+                return False
+            logger.info(f"Powered on smart plug '{plug.name}' for printer {printer_id}")
+
+        # Get printer from database for connection
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
+        if not printer:
+            logger.error(f"Printer {printer_id} not found in database")
+            return False
+
+        # Wait for printer to boot (give it some time before trying to connect)
+        logger.info(f"Waiting 30s for printer {printer_id} to boot...")
+        await asyncio.sleep(30)
+
+        # Try to connect to the printer periodically
+        elapsed = 30  # Already waited 30s
+        while elapsed < self._power_on_wait_time:
+            # Try to connect
+            logger.info(f"Attempting to connect to printer {printer_id}...")
+            try:
+                connected = await printer_manager.connect_printer(printer)
+                if connected:
+                    logger.info(f"Printer {printer_id} connected after {elapsed}s")
+                    # Give it a moment to stabilize and get status
+                    await asyncio.sleep(5)
+                    return True
+            except Exception as e:
+                logger.debug(f"Connection attempt failed: {e}")
+
+            await asyncio.sleep(self._power_on_check_interval)
+            elapsed += self._power_on_check_interval
+            logger.debug(f"Waiting for printer {printer_id} to connect... ({elapsed}s)")
+
+        logger.warning(f"Printer {printer_id} did not connect within {self._power_on_wait_time}s after power on")
+        return False
+
+    async def _check_previous_success(self, db: AsyncSession, item: PrintQueueItem) -> bool:
+        """Check if the previous print on this printer succeeded."""
+        # Find the most recent completed queue item for this printer
+        result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.printer_id == item.printer_id)
+            .where(PrintQueueItem.id != item.id)
+            .where(PrintQueueItem.status.in_(["completed", "failed", "skipped", "aborted"]))
+            .order_by(PrintQueueItem.completed_at.desc())
+            .limit(1)
+        )
+        prev_item = result.scalar_one_or_none()
+
+        # If no previous item, assume success (first in queue)
+        if not prev_item:
+            return True
+
+        return prev_item.status == "completed"
+
+    async def _power_off_if_needed(self, db: AsyncSession, item: PrintQueueItem):
+        """Power off printer if auto_off_after is enabled (waits for cooldown)."""
+        if not item.auto_off_after:
+            return
+
+        plug = await self._get_smart_plug(db, item.printer_id)
+        if plug and plug.enabled:
+            logger.info(f"Auto-off: Waiting for printer {item.printer_id} to cool down before power off...")
+            # Wait for cooldown (up to 10 minutes)
+            await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
+            logger.info(f"Auto-off: Powering off printer {item.printer_id}")
+            await tasmota_service.turn_off(plug)
+
+    async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
+        """Upload file and start print for a queue item."""
+        logger.info(f"Starting queue item {item.id}")
+
+        # Get archive
+        result = await db.execute(
+            select(PrintArchive).where(PrintArchive.id == item.archive_id)
+        )
+        archive = result.scalar_one_or_none()
+        if not archive:
+            item.status = "failed"
+            item.error_message = "Archive not found"
+            item.completed_at = datetime.utcnow()
+            await db.commit()
+            logger.error(f"Queue item {item.id}: Archive {item.archive_id} not found")
+            await self._power_off_if_needed(db, item)
+            return
+
+        # Get printer
+        result = await db.execute(
+            select(Printer).where(Printer.id == item.printer_id)
+        )
+        printer = result.scalar_one_or_none()
+        if not printer:
+            item.status = "failed"
+            item.error_message = "Printer not found"
+            item.completed_at = datetime.utcnow()
+            await db.commit()
+            logger.error(f"Queue item {item.id}: Printer {item.printer_id} not found")
+            await self._power_off_if_needed(db, item)
+            return
+
+        # Check printer is connected
+        if not printer_manager.is_connected(item.printer_id):
+            item.status = "failed"
+            item.error_message = "Printer not connected"
+            item.completed_at = datetime.utcnow()
+            await db.commit()
+            logger.error(f"Queue item {item.id}: Printer {item.printer_id} not connected")
+            await self._power_off_if_needed(db, item)
+            return
+
+        # Get file path
+        file_path = settings.base_dir / archive.file_path
+        if not file_path.exists():
+            item.status = "failed"
+            item.error_message = "Archive file not found on disk"
+            item.completed_at = datetime.utcnow()
+            await db.commit()
+            logger.error(f"Queue item {item.id}: File not found: {file_path}")
+            await self._power_off_if_needed(db, item)
+            return
+
+        # Upload file to printer via FTP
+        remote_filename = archive.filename
+        remote_path = f"/cache/{remote_filename}"
+
+        try:
+            uploaded = await upload_file_async(
+                printer.ip_address,
+                printer.access_code,
+                file_path,
+                remote_path,
+            )
+        except Exception as e:
+            uploaded = False
+            logger.error(f"Queue item {item.id}: FTP error: {e}")
+
+        if not uploaded:
+            item.status = "failed"
+            item.error_message = "Failed to upload file to printer"
+            item.completed_at = datetime.utcnow()
+            await db.commit()
+            logger.error(f"Queue item {item.id}: FTP upload failed")
+            await self._power_off_if_needed(db, item)
+            return
+
+        # Register as expected print so we don't create a duplicate archive
+        from backend.app.main import register_expected_print
+        register_expected_print(item.printer_id, remote_filename, archive.id)
+
+        # Start the print
+        started = printer_manager.start_print(item.printer_id, remote_filename)
+
+        if started:
+            item.status = "printing"
+            item.started_at = datetime.utcnow()
+            await db.commit()
+            logger.info(f"Queue item {item.id}: Print started - {archive.filename}")
+        else:
+            item.status = "failed"
+            item.error_message = "Failed to send print command"
+            item.completed_at = datetime.utcnow()
+            await db.commit()
+            logger.error(f"Queue item {item.id}: Failed to start print")
+            await self._power_off_if_needed(db, item)
+
+
+# Global scheduler instance
+scheduler = PrintScheduler()

+ 50 - 0
backend/app/services/printer_manager.py

@@ -118,6 +118,56 @@ class PrinterManager:
             return self._clients[printer_id].start_print(filename)
         return False
 
+    def stop_print(self, printer_id: int) -> bool:
+        """Stop the current print on a connected printer."""
+        if printer_id in self._clients:
+            return self._clients[printer_id].stop_print()
+        return False
+
+    async def wait_for_cooldown(
+        self,
+        printer_id: int,
+        target_temp: float = 50.0,
+        timeout: int = 600,
+        check_interval: int = 10,
+    ) -> bool:
+        """Wait for the nozzle to cool down to a safe temperature.
+
+        Args:
+            printer_id: The printer to monitor
+            target_temp: Target temperature to wait for (default 50°C)
+            timeout: Maximum seconds to wait (default 600s = 10 min)
+            check_interval: Seconds between temperature checks (default 10s)
+
+        Returns:
+            True if cooled down, False if timeout or not connected
+        """
+        import logging
+        logger = logging.getLogger(__name__)
+
+        elapsed = 0
+        while elapsed < timeout:
+            state = self.get_status(printer_id)
+            if not state or not state.connected:
+                logger.warning(f"Printer {printer_id} disconnected during cooldown wait")
+                return False
+
+            # Check nozzle temperature (and nozzle_2 for dual extruders)
+            nozzle_temp = state.temperatures.get("nozzle", 0)
+            nozzle_2_temp = state.temperatures.get("nozzle_2", 0)
+            max_temp = max(nozzle_temp, nozzle_2_temp)
+
+            if max_temp <= target_temp:
+                logger.info(f"Printer {printer_id} cooled down to {max_temp}°C")
+                return True
+
+            logger.debug(f"Printer {printer_id} nozzle at {max_temp}°C, waiting for {target_temp}°C...")
+            await asyncio.sleep(check_interval)
+            elapsed += check_interval
+
+        logger.warning(f"Printer {printer_id} cooldown timeout after {timeout}s")
+        return False
+
     def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:
         """Enable or disable MQTT logging for a printer."""
         if printer_id in self._clients:

+ 19 - 6
backend/app/services/smart_plug_manager.py

@@ -70,7 +70,11 @@ class SmartPlugManager:
     async def on_print_complete(
         self, printer_id: int, status: str, db: AsyncSession
     ):
-        """Called when a print completes - schedule turn off if configured."""
+        """Called when a print completes - schedule turn off if configured.
+
+        Only triggers auto-off on successful completion (status='completed').
+        Failed prints keep the printer powered on for user investigation.
+        """
         plug = await self._get_plug_for_printer(printer_id, db)
 
         if not plug:
@@ -84,8 +88,17 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             return
 
+        # Only auto-off on successful completion, not on failures
+        # This allows the user to investigate errors before power-off
+        if status != "completed":
+            logger.info(
+                f"Print on printer {printer_id} ended with status '{status}', "
+                f"skipping auto-off for plug '{plug.name}' to allow investigation"
+            )
+            return
+
         logger.info(
-            f"Print completed on printer {printer_id} (status: {status}), "
+            f"Print completed successfully on printer {printer_id}, "
             f"scheduling turn-off for plug '{plug.name}'"
         )
 
@@ -192,14 +205,14 @@ class SmartPlugManager:
                     max_nozzle_temp = nozzle_temp
                     if nozzle_2_temp is not None:
                         max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)
-                        logger.debug(
-                            f"Checking temp for plug {plug_id}: nozzle1={nozzle_temp}°C, "
+                        logger.info(
+                            f"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, "
                             f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, "
                             f"threshold={temp_threshold}°C"
                         )
                     else:
-                        logger.debug(
-                            f"Checking temp for plug {plug_id}: nozzle={nozzle_temp}°C, "
+                        logger.info(
+                            f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, "
                             f"threshold={temp_threshold}°C"
                         )
 

+ 38 - 0
backend/app/services/tasmota.py

@@ -150,6 +150,44 @@ class TasmotaService:
 
         return success
 
+    async def get_energy(self, plug: "SmartPlug") -> dict | None:
+        """Get energy monitoring data from the plug.
+
+        Returns dict with energy data or None if not available:
+            - power: Current power in watts
+            - voltage: Voltage in V
+            - current: Current in A
+            - today: Energy used today in kWh
+            - total: Total energy in kWh
+            - factor: Power factor (0-1)
+        """
+        result = await self._send_command(
+            plug.ip_address, "Status 8", plug.username, plug.password
+        )
+
+        if result is None:
+            return None
+
+        # Response format: {"StatusSNS":{"ENERGY":{...}}}
+        status_sns = result.get("StatusSNS", {})
+        energy = status_sns.get("ENERGY")
+
+        if not energy:
+            # Device doesn't have energy monitoring
+            return None
+
+        return {
+            "power": energy.get("Power"),  # Current watts
+            "voltage": energy.get("Voltage"),  # Volts
+            "current": energy.get("Current"),  # Amps
+            "today": energy.get("Today"),  # kWh today
+            "yesterday": energy.get("Yesterday"),  # kWh yesterday
+            "total": energy.get("Total"),  # Total kWh
+            "factor": energy.get("Factor"),  # Power factor
+            "apparent_power": energy.get("ApparentPower"),  # VA
+            "reactive_power": energy.get("ReactivePower"),  # VAr
+        }
+
     async def test_connection(
         self,
         ip: str,

+ 2 - 0
frontend/src/App.tsx

@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { Layout } from './components/Layout';
 import { PrintersPage } from './pages/PrintersPage';
 import { ArchivesPage } from './pages/ArchivesPage';
+import { QueuePage } from './pages/QueuePage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { CloudProfilesPage } from './pages/CloudProfilesPage';
@@ -35,6 +36,7 @@ function App() {
                 <Route path="/" element={<Layout />}>
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
+                  <Route path="queue" element={<QueuePage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="cloud" element={<CloudProfilesPage />} />
                   <Route path="settings" element={<SettingsPage />} />

+ 87 - 0
frontend/src/api/client.ts

@@ -99,6 +99,7 @@ export interface Archive {
   filament_type: string | null;
   filament_color: string | null;
   layer_height: number | null;
+  total_layers: number | null;
   nozzle_diameter: number | null;
   bed_temperature: number | null;
   nozzle_temperature: number | null;
@@ -114,6 +115,8 @@ export interface Archive {
   cost: number | null;
   photos: string[] | null;
   failure_reason: string | null;
+  energy_kwh: number | null;
+  energy_cost: number | null;
   created_at: string;
 }
 
@@ -128,6 +131,8 @@ export interface ArchiveStats {
   prints_by_printer: Record<string, number>;
   average_time_accuracy: number | null;
   time_accuracy_by_printer: Record<string, number> | null;
+  total_energy_kwh: number;
+  total_energy_cost: number;
 }
 
 export interface BulkUploadResult {
@@ -141,8 +146,10 @@ export interface BulkUploadResult {
 export interface AppSettings {
   auto_archive: boolean;
   save_thumbnails: boolean;
+  capture_finish_photo: boolean;
   default_filament_cost: number;
   currency: string;
+  energy_cost_per_kwh: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -230,10 +237,23 @@ export interface SmartPlugUpdate {
   password?: string | null;
 }
 
+export interface SmartPlugEnergy {
+  power: number | null;  // Current watts
+  voltage: number | null;  // Volts
+  current: number | null;  // Amps
+  today: number | null;  // kWh used today
+  yesterday: number | null;  // kWh used yesterday
+  total: number | null;  // Total kWh
+  factor: number | null;  // Power factor (0-1)
+  apparent_power: number | null;  // VA
+  reactive_power: number | null;  // VAr
+}
+
 export interface SmartPlugStatus {
   state: string | null;
   reachable: boolean;
   device_name: string | null;
+  energy: SmartPlugEnergy | null;
 }
 
 export interface SmartPlugTestResult {
@@ -242,6 +262,41 @@ export interface SmartPlugTestResult {
   device_name: string | null;
 }
 
+// Print Queue types
+export interface PrintQueueItem {
+  id: number;
+  printer_id: number;
+  archive_id: number;
+  position: number;
+  scheduled_time: string | null;
+  require_previous_success: boolean;
+  auto_off_after: boolean;
+  status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
+  started_at: string | null;
+  completed_at: string | null;
+  error_message: string | null;
+  created_at: string;
+  archive_name?: string | null;
+  archive_thumbnail?: string | null;
+  printer_name?: string | null;
+}
+
+export interface PrintQueueItemCreate {
+  printer_id: number;
+  archive_id: number;
+  scheduled_time?: string | null;
+  require_previous_success?: boolean;
+  auto_off_after?: boolean;
+}
+
+export interface PrintQueueItemUpdate {
+  printer_id?: number;
+  position?: number;
+  scheduled_time?: string | null;
+  require_previous_success?: boolean;
+  auto_off_after?: boolean;
+}
+
 // MQTT Logging types
 export interface MQTTLogEntry {
   timestamp: string;
@@ -490,6 +545,8 @@ export const api = {
     }),
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
+  checkFfmpeg: () =>
+    request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
 
   // Cloud
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
@@ -519,6 +576,7 @@ export const api = {
   // Smart Plugs
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
+  getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',
@@ -544,4 +602,33 @@ export const api = {
       body: JSON.stringify({ ip_address, username, password }),
     }),
 
+  // Print Queue
+  getQueue: (printerId?: number, status?: string) => {
+    const params = new URLSearchParams();
+    if (printerId) params.set('printer_id', String(printerId));
+    if (status) params.set('status', status);
+    return request<PrintQueueItem[]>(`/queue/?${params}`);
+  },
+  getQueueItem: (id: number) => request<PrintQueueItem>(`/queue/${id}`),
+  addToQueue: (data: PrintQueueItemCreate) =>
+    request<PrintQueueItem>('/queue/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateQueueItem: (id: number, data: PrintQueueItemUpdate) =>
+    request<PrintQueueItem>(`/queue/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  removeFromQueue: (id: number) =>
+    request<{ message: string }>(`/queue/${id}`, { method: 'DELETE' }),
+  reorderQueue: (items: { id: number; position: number }[]) =>
+    request<{ message: string }>('/queue/reorder', {
+      method: 'POST',
+      body: JSON.stringify({ items }),
+    }),
+  cancelQueueItem: (id: number) =>
+    request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
+  stopQueueItem: (id: number) =>
+    request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
 };

+ 239 - 0
frontend/src/components/AddToQueueModal.tsx

@@ -0,0 +1,239 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Calendar, Clock, X, AlertCircle, Power } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrintQueueItemCreate } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface AddToQueueModalProps {
+  archiveId: number;
+  archiveName: string;
+  onClose: () => void;
+}
+
+export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [printerId, setPrinterId] = useState<number | null>(null);
+  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>('asap');
+  const [scheduledTime, setScheduledTime] = useState('');
+  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
+  const [autoOffAfter, setAutoOffAfter] = useState(false);
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
+  // Set default printer if only one available
+  useEffect(() => {
+    if (printers?.length === 1 && !printerId) {
+      setPrinterId(printers[0].id);
+    }
+  }, [printers, printerId]);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const addMutation = useMutation({
+    mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Added to print queue');
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to add to queue', 'error');
+    },
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!printerId) {
+      showToast('Please select a printer', 'error');
+      return;
+    }
+
+    const data: PrintQueueItemCreate = {
+      printer_id: printerId,
+      archive_id: archiveId,
+      require_previous_success: requirePreviousSuccess,
+      auto_off_after: autoOffAfter,
+    };
+
+    if (scheduleType === 'scheduled' && scheduledTime) {
+      data.scheduled_time = new Date(scheduledTime).toISOString();
+    }
+
+    addMutation.mutate(data);
+  };
+
+  // Get minimum datetime (now + 1 minute)
+  const getMinDateTime = () => {
+    const now = new Date();
+    now.setMinutes(now.getMinutes() + 1);
+    return now.toISOString().slice(0, 16);
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
+        <CardContent className="p-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+            <div className="flex items-center gap-2">
+              <Calendar className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">Schedule Print</h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Form */}
+          <form onSubmit={handleSubmit} className="p-4 space-y-4">
+            {/* Archive name */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Print Job</label>
+              <p className="text-white font-medium truncate">{archiveName}</p>
+            </div>
+
+            {/* Printer selection */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+              {printers?.length === 0 ? (
+                <div className="flex items-center gap-2 text-red-400 text-sm">
+                  <AlertCircle className="w-4 h-4" />
+                  No printers configured
+                </div>
+              ) : (
+                <select
+                  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"
+                  value={printerId || ''}
+                  onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
+                  required
+                >
+                  <option value="">Select printer...</option>
+                  {printers?.map((p) => (
+                    <option key={p.id} value={p.id}>{p.name}</option>
+                  ))}
+                </select>
+              )}
+            </div>
+
+            {/* Schedule type */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-2">When to print</label>
+              <div className="flex gap-2">
+                <button
+                  type="button"
+                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                    scheduleType === 'asap'
+                      ? 'bg-bambu-green border-bambu-green text-white'
+                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  onClick={() => setScheduleType('asap')}
+                >
+                  <Clock className="w-4 h-4" />
+                  ASAP (when idle)
+                </button>
+                <button
+                  type="button"
+                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                    scheduleType === 'scheduled'
+                      ? 'bg-bambu-green border-bambu-green text-white'
+                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  onClick={() => setScheduleType('scheduled')}
+                >
+                  <Calendar className="w-4 h-4" />
+                  Scheduled
+                </button>
+              </div>
+            </div>
+
+            {/* Scheduled time input */}
+            {scheduleType === 'scheduled' && (
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
+                <input
+                  type="datetime-local"
+                  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"
+                  value={scheduledTime}
+                  onChange={(e) => setScheduledTime(e.target.value)}
+                  min={getMinDateTime()}
+                  required
+                />
+              </div>
+            )}
+
+            {/* Require previous success */}
+            <div className="flex items-center gap-2">
+              <input
+                type="checkbox"
+                id="requirePrevious"
+                checked={requirePreviousSuccess}
+                onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
+                Only start if previous print succeeded
+              </label>
+            </div>
+
+            {/* Auto power off */}
+            <div className="flex items-center gap-2">
+              <input
+                type="checkbox"
+                id="autoOffAfter"
+                checked={autoOffAfter}
+                onChange={(e) => setAutoOffAfter(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
+                <Power className="w-3.5 h-3.5" />
+                Power off printer when done
+              </label>
+            </div>
+
+            {/* Help text */}
+            <p className="text-xs text-bambu-gray">
+              {scheduleType === 'asap'
+                ? 'Print will start as soon as the printer is idle.'
+                : 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
+            </p>
+
+            {/* Actions */}
+            <div className="flex gap-3 pt-2">
+              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+                Cancel
+              </Button>
+              <Button
+                type="submit"
+                className="flex-1"
+                disabled={addMutation.isPending || !printerId || printers?.length === 0}
+              >
+                {addMutation.isPending ? 'Adding...' : 'Add to Queue'}
+              </Button>
+            </div>
+          </form>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 44 - 28
frontend/src/components/FilamentTrends.tsx

@@ -254,35 +254,51 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         <div className="bg-bambu-dark rounded-lg p-4">
           <h4 className="text-sm font-medium text-bambu-gray mb-4">By Filament Type</h4>
           {filamentTypeData.length > 0 ? (
-            <ResponsiveContainer width="100%" height={200}>
-              <PieChart>
-                <Pie
-                  data={filamentTypeData}
-                  cx="50%"
-                  cy="50%"
-                  innerRadius={50}
-                  outerRadius={80}
-                  paddingAngle={2}
-                  dataKey="value"
-                  label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
-                  labelLine={false}
-                >
-                  {filamentTypeData.map((_, index) => (
-                    <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
-                  ))}
-                </Pie>
-                <Tooltip
-                  contentStyle={{
-                    backgroundColor: '#2d2d2d',
-                    border: '1px solid #3d3d3d',
-                    borderRadius: '8px',
-                  }}
-                  formatter={(value: number) => [`${value}g`, 'Usage']}
-                />
-              </PieChart>
-            </ResponsiveContainer>
+            <div className="flex items-center gap-4">
+              <ResponsiveContainer width={160} height={160}>
+                <PieChart>
+                  <Pie
+                    data={filamentTypeData}
+                    cx="50%"
+                    cy="50%"
+                    innerRadius={40}
+                    outerRadius={70}
+                    paddingAngle={2}
+                    dataKey="value"
+                  >
+                    {filamentTypeData.map((_, index) => (
+                      <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
+                    ))}
+                  </Pie>
+                  <Tooltip
+                    contentStyle={{
+                      backgroundColor: '#2d2d2d',
+                      border: '1px solid #3d3d3d',
+                      borderRadius: '8px',
+                    }}
+                    formatter={(value: number) => [`${value}g`, 'Usage']}
+                  />
+                </PieChart>
+              </ResponsiveContainer>
+              <div className="flex-1 space-y-2 overflow-hidden">
+                {filamentTypeData.map((entry, index) => {
+                  const total = filamentTypeData.reduce((sum, e) => sum + e.value, 0);
+                  const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0;
+                  return (
+                    <div key={entry.name} className="flex items-center gap-2 text-sm">
+                      <div
+                        className="w-3 h-3 rounded-sm flex-shrink-0"
+                        style={{ backgroundColor: COLORS[index % COLORS.length] }}
+                      />
+                      <span className="text-white truncate flex-1">{entry.name}</span>
+                      <span className="text-bambu-gray flex-shrink-0">{percent}%</span>
+                    </div>
+                  );
+                })}
+              </div>
+            </div>
           ) : (
-            <div className="h-[200px] flex items-center justify-center text-bambu-gray">
+            <div className="h-[160px] flex items-center justify-center text-bambu-gray">
               No filament data
             </div>
           )}

+ 125 - 0
frontend/src/components/HMSErrorModal.tsx

@@ -0,0 +1,125 @@
+import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
+import type { HMSError } from '../api/client';
+
+interface HMSErrorModalProps {
+  printerName: string;
+  errors: HMSError[];
+  onClose: () => void;
+}
+
+// HMS error code descriptions (common ones)
+const HMS_DESCRIPTIONS: Record<string, string> = {
+  '0x20054': 'The heatbed temperature is abnormal. The sensor may be disconnected or damaged.',
+  '0x50005': 'Motor driver overheated. Let the printer cool down.',
+  '0x50006': 'Motor driver communication error.',
+  '0x70001': 'AMS communication error.',
+  '0x70002': 'AMS filament runout.',
+  '0x70003': 'AMS filament not detected.',
+  '0xC0003': 'First layer inspection failed.',
+  '0xC0004': 'Nozzle clog detected.',
+  '0xC8000': 'Foreign object detected on print bed.',
+  '0x50000': 'Motor X axis lost steps.',
+  '0x50001': 'Motor Y axis lost steps.',
+  '0x50002': 'Motor Z axis lost steps.',
+};
+
+function getSeverityInfo(severity: number): { label: string; color: string; bgColor: string; Icon: typeof AlertTriangle } {
+  switch (severity) {
+    case 1:
+      return { label: 'Fatal', color: 'text-red-500', bgColor: 'bg-red-500/20', Icon: AlertTriangle };
+    case 2:
+      return { label: 'Serious', color: 'text-red-400', bgColor: 'bg-red-500/15', Icon: AlertTriangle };
+    case 3:
+      return { label: 'Warning', color: 'text-orange-400', bgColor: 'bg-orange-500/20', Icon: AlertCircle };
+    case 4:
+    default:
+      return { label: 'Info', color: 'text-blue-400', bgColor: 'bg-blue-500/20', Icon: Info };
+  }
+}
+
+function getHMSWikiUrl(code: string): string {
+  // Convert hex code to format used by Bambu Lab wiki
+  // Example: 0x20054 -> HMS_0200_0005_0004
+  const codeNum = parseInt(code.replace('0x', ''), 16);
+  const part1 = ((codeNum >> 24) & 0xFF).toString(16).padStart(2, '0').toUpperCase();
+  const part2 = ((codeNum >> 16) & 0xFF).toString(16).padStart(2, '0').toUpperCase();
+  const part3 = ((codeNum >> 8) & 0xFF).toString(16).padStart(2, '0').toUpperCase();
+  const part4 = (codeNum & 0xFF).toString(16).padStart(2, '0').toUpperCase();
+  return `https://wiki.bambulab.com/en/x1/troubleshooting/hmscode/HMS_${part1}${part2}_${part3}${part4}`;
+}
+
+export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <AlertTriangle className="w-5 h-5 text-orange-400" />
+            <h2 className="text-lg font-semibold text-white">HMS Errors - {printerName}</h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+          >
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto p-4">
+          {errors.length === 0 ? (
+            <div className="text-center py-8 text-bambu-gray">
+              <AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
+              <p>No HMS errors</p>
+            </div>
+          ) : (
+            <div className="space-y-3">
+              {errors.map((error, index) => {
+                const { label, color, bgColor, Icon } = getSeverityInfo(error.severity);
+                const description = HMS_DESCRIPTIONS[error.code] || 'Unknown error. Click the link below for details.';
+                const wikiUrl = getHMSWikiUrl(error.code);
+
+                return (
+                  <div
+                    key={`${error.code}-${index}`}
+                    className={`p-4 rounded-lg ${bgColor} border border-white/10`}
+                  >
+                    <div className="flex items-start gap-3">
+                      <Icon className={`w-5 h-5 ${color} flex-shrink-0 mt-0.5`} />
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center gap-2 mb-1">
+                          <span className={`font-mono text-sm ${color}`}>{error.code}</span>
+                          <span className={`text-xs px-2 py-0.5 rounded-full ${bgColor} ${color}`}>
+                            {label}
+                          </span>
+                        </div>
+                        <p className="text-sm text-bambu-gray mb-2">{description}</p>
+                        <a
+                          href={wikiUrl}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
+                        >
+                          <ExternalLink className="w-3 h-3" />
+                          View on Bambu Lab Wiki
+                        </a>
+                      </div>
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="p-4 border-t border-bambu-dark-tertiary">
+          <p className="text-xs text-bambu-gray">
+            HMS (Health Management System) monitors printer health. Clear errors on the printer to dismiss them here.
+          </p>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 4 - 3
frontend/src/components/KeyboardShortcutsModal.tsx

@@ -9,9 +9,10 @@ const shortcuts = [
   { category: 'Navigation', items: [
     { keys: ['1'], description: 'Go to Printers' },
     { keys: ['2'], description: 'Go to Archives' },
-    { keys: ['3'], description: 'Go to Statistics' },
-    { keys: ['4'], description: 'Go to Cloud Profiles' },
-    { keys: ['5'], description: 'Go to Settings' },
+    { keys: ['3'], description: 'Go to Queue' },
+    { keys: ['4'], description: 'Go to Statistics' },
+    { keys: ['5'], description: 'Go to Cloud Profiles' },
+    { keys: ['6'], description: 'Go to Settings' },
   ]},
   { category: 'Archives', items: [
     { keys: ['/'], description: 'Focus search' },

+ 8 - 3
frontend/src/components/Layout.tsx

@@ -1,12 +1,13 @@
 import { useState, useEffect, useCallback } from 'react';
 import { NavLink, Outlet, useNavigate } from 'react-router-dom';
-import { Printer, Archive, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github } from 'lucide-react';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 
 const navItems = [
   { to: '/', icon: Printer, label: 'Printers' },
   { to: '/archives', icon: Archive, label: 'Archives' },
+  { to: '/queue', icon: Calendar, label: 'Queue' },
   { to: '/stats', icon: BarChart3, label: 'Statistics' },
   { to: '/cloud', icon: Cloud, label: 'Cloud Profiles' },
   { to: '/settings', icon: Settings, label: 'Settings' },
@@ -46,13 +47,17 @@ export function Layout() {
           break;
         case '3':
           e.preventDefault();
-          navigate('/stats');
+          navigate('/queue');
           break;
         case '4':
           e.preventDefault();
-          navigate('/cloud');
+          navigate('/stats');
           break;
         case '5':
+          e.preventDefault();
+          navigate('/cloud');
+          break;
+        case '6':
           e.preventDefault();
           navigate('/settings');
           break;

+ 70 - 0
frontend/src/components/PrinterQueueWidget.tsx

@@ -0,0 +1,70 @@
+import { useQuery } from '@tanstack/react-query';
+import { Clock, Calendar, ChevronRight } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import { api } from '../api/client';
+
+interface PrinterQueueWidgetProps {
+  printerId: number;
+}
+
+function formatRelativeTime(dateString: string | null): string {
+  if (!dateString) return 'ASAP';
+  const date = new Date(dateString);
+  const now = new Date();
+  const diff = date.getTime() - now.getTime();
+
+  if (diff < 0) return 'Now';
+  if (diff < 60000) return 'In <1 min';
+  if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
+  if (diff < 86400000) return `In ${Math.round(diff / 3600000)}h`;
+  return date.toLocaleDateString();
+}
+
+export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
+  const { data: queue } = useQuery({
+    queryKey: ['queue', printerId, 'pending'],
+    queryFn: () => api.getQueue(printerId, 'pending'),
+    refetchInterval: 30000,
+  });
+
+  const nextItem = queue?.[0];
+  const totalPending = queue?.length || 0;
+
+  if (totalPending === 0) {
+    return null;
+  }
+
+  return (
+    <div className="mt-3 p-2 bg-bambu-dark rounded-lg">
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2 min-w-0">
+          <Calendar className="w-4 h-4 text-yellow-400 flex-shrink-0" />
+          <div className="min-w-0">
+            <p className="text-xs text-bambu-gray">Next in queue</p>
+            <p className="text-sm text-white truncate">
+              {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
+            </p>
+          </div>
+        </div>
+        <div className="flex items-center gap-2 flex-shrink-0">
+          <span className="text-xs text-bambu-gray flex items-center gap-1">
+            <Clock className="w-3 h-3" />
+            {formatRelativeTime(nextItem?.scheduled_time || null)}
+          </span>
+          {totalPending > 1 && (
+            <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded">
+              +{totalPending - 1}
+            </span>
+          )}
+          <Link
+            to="/queue"
+            className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors text-bambu-gray hover:text-white"
+            title="View queue"
+          >
+            <ChevronRight className="w-4 h-4" />
+          </Link>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 34 - 2
frontend/src/components/SmartPlugCard.tsx

@@ -15,6 +15,8 @@ interface SmartPlugCardProps {
 export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   const queryClient = useQueryClient();
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
+  const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
   // Fetch current status
@@ -108,7 +110,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
               size="sm"
               variant={isOn ? 'primary' : 'secondary'}
               disabled={!isReachable || isPending}
-              onClick={() => controlMutation.mutate('on')}
+              onClick={() => setShowPowerOnConfirm(true)}
               className="flex-1"
             >
               {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
@@ -118,7 +120,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
               size="sm"
               variant={!isOn ? 'primary' : 'secondary'}
               disabled={!isReachable || isPending}
-              onClick={() => controlMutation.mutate('off')}
+              onClick={() => setShowPowerOffConfirm(true)}
               className="flex-1"
             >
               {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
@@ -291,6 +293,36 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           onCancel={() => setShowDeleteConfirm(false)}
         />
       )}
+
+      {/* Power On Confirmation */}
+      {showPowerOnConfirm && (
+        <ConfirmModal
+          title="Turn On Smart Plug"
+          message={`Are you sure you want to turn on "${plug.name}"?`}
+          confirmText="Turn On"
+          variant="default"
+          onConfirm={() => {
+            controlMutation.mutate('on');
+            setShowPowerOnConfirm(false);
+          }}
+          onCancel={() => setShowPowerOnConfirm(false)}
+        />
+      )}
+
+      {/* Power Off Confirmation */}
+      {showPowerOffConfirm && (
+        <ConfirmModal
+          title="Turn Off Smart Plug"
+          message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
+          confirmText="Turn Off"
+          variant="danger"
+          onConfirm={() => {
+            controlMutation.mutate('off');
+            setShowPowerOffConfirm(false);
+          }}
+          onCancel={() => setShowPowerOffConfirm(false)}
+        />
+      )}
     </>
   );
 }

+ 202 - 0
frontend/src/components/TimelapseViewer.tsx

@@ -0,0 +1,202 @@
+import { useState, useRef, useEffect } from 'react';
+import { X, Download, Film, Play, Pause, SkipBack, SkipForward } from 'lucide-react';
+import { Button } from './Button';
+
+interface TimelapseViewerProps {
+  src: string;
+  title: string;
+  downloadFilename: string;
+  onClose: () => void;
+}
+
+const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
+
+export function TimelapseViewer({ src, title, downloadFilename, onClose }: TimelapseViewerProps) {
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const [isPlaying, setIsPlaying] = useState(true);
+  const [playbackRate, setPlaybackRate] = useState(0.5); // Default to 0.5x for timelapse
+  const [currentTime, setCurrentTime] = useState(0);
+  const [duration, setDuration] = useState(0);
+
+  useEffect(() => {
+    const video = videoRef.current;
+    if (video) {
+      video.playbackRate = playbackRate;
+    }
+  }, [playbackRate]);
+
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    const handleTimeUpdate = () => setCurrentTime(video.currentTime);
+    const handleDurationChange = () => setDuration(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);
+    };
+  }, []);
+
+  const togglePlay = () => {
+    const video = videoRef.current;
+    if (!video) return;
+    if (isPlaying) {
+      video.pause();
+    } else {
+      video.play();
+    }
+  };
+
+  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = parseFloat(e.target.value);
+  };
+
+  const skipBackward = () => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = Math.max(0, video.currentTime - 5);
+  };
+
+  const skipForward = () => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = Math.min(duration, video.currentTime + 5);
+  };
+
+  const formatTime = (time: number) => {
+    const minutes = Math.floor(time / 60);
+    const seconds = Math.floor(time % 60);
+    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
+  };
+
+  const handleDownload = () => {
+    const link = document.createElement('a');
+    link.href = src;
+    link.download = downloadFilename;
+    link.click();
+  };
+
+  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-4xl w-full mx-4 overflow-hidden">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <h3 className="text-lg font-semibold text-white flex items-center gap-2">
+            <Film className="w-5 h-5 text-bambu-green" />
+            {title}
+          </h3>
+          <div className="flex items-center gap-2">
+            <Button variant="secondary" size="sm" onClick={handleDownload}>
+              <Download className="w-4 h-4" />
+              Download
+            </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>
+
+        {/* Video */}
+        <div className="p-4">
+          <video
+            ref={videoRef}
+            src={src}
+            autoPlay
+            className="w-full rounded-lg"
+            onClick={togglePlay}
+          />
+
+          {/* Custom Controls */}
+          <div className="mt-4 space-y-3">
+            {/* Progress bar */}
+            <div className="flex items-center gap-3">
+              <span className="text-xs text-bambu-gray w-12 text-right">
+                {formatTime(currentTime)}
+              </span>
+              <input
+                type="range"
+                min={0}
+                max={duration || 100}
+                value={currentTime}
+                onChange={handleSeek}
+                className="flex-1 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"
+              />
+              <span className="text-xs text-bambu-gray w-12">
+                {formatTime(duration)}
+              </span>
+            </div>
+
+            {/* Playback controls */}
+            <div className="flex items-center justify-between">
+              {/* Left: Play controls */}
+              <div className="flex items-center gap-2">
+                <button
+                  onClick={skipBackward}
+                  className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+                  title="Skip back 5s"
+                >
+                  <SkipBack className="w-5 h-5 text-bambu-gray" />
+                </button>
+                <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>
+                <button
+                  onClick={skipForward}
+                  className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+                  title="Skip forward 5s"
+                >
+                  <SkipForward className="w-5 h-5 text-bambu-gray" />
+                </button>
+              </div>
+
+              {/* Right: Speed control */}
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-bambu-gray">Speed:</span>
+                <div className="flex gap-1">
+                  {SPEED_OPTIONS.map((speed) => (
+                    <button
+                      key={speed}
+                      onClick={() => setPlaybackRate(speed)}
+                      className={`px-2 py-1 text-xs rounded transition-colors ${
+                        playbackRate === speed
+                          ? 'bg-bambu-green text-white'
+                          : 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary/80'
+                      }`}
+                    >
+                      {speed}x
+                    </button>
+                  ))}
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 33 - 45
frontend/src/pages/ArchivesPage.tsx

@@ -50,6 +50,8 @@ import { CalendarView } from '../components/CalendarView';
 import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
+import { TimelapseViewer } from '../components/TimelapseViewer';
+import { AddToQueueModal } from '../components/AddToQueueModal';
 import { useToast } from '../contexts/ToastContext';
 
 function formatFileSize(bytes: number): string {
@@ -98,6 +100,7 @@ function ArchiveCard({
   const [showQRCode, setShowQRCode] = useState(false);
   const [showPhotos, setShowPhotos] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
+  const [showSchedule, setShowSchedule] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
 
   const timelapseScanMutation = useMutation({
@@ -147,6 +150,11 @@ function ArchiveCard({
       icon: <Printer className="w-4 h-4" />,
       onClick: () => setShowReprint(true),
     },
+    {
+      label: 'Schedule',
+      icon: <Calendar className="w-4 h-4" />,
+      onClick: () => setShowSchedule(true),
+    },
     {
       label: 'Open in Bambu Studio',
       icon: <ExternalLink className="w-4 h-4" />,
@@ -245,14 +253,15 @@ function ArchiveCard({
 
   return (
     <Card
-      className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''}`}
+      className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
       onContextMenu={handleContextMenu}
+      onClick={selectionMode ? () => onSelect(archive.id) : undefined}
     >
       {/* Selection checkbox */}
       {selectionMode && (
         <button
           className="absolute top-2 left-2 z-10 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
-          onClick={() => onSelect(archive.id)}
+          onClick={(e) => { e.stopPropagation(); onSelect(archive.id); }}
         >
           {isSelected ? (
             <CheckSquare className="w-5 h-5 text-bambu-green" />
@@ -374,10 +383,12 @@ function ArchiveCard({
               {archive.filament_used_grams.toFixed(1)}g
             </div>
           )}
-          {archive.layer_height && (
+          {(archive.layer_height || archive.total_layers) && (
             <div className="flex items-center gap-1.5 text-bambu-gray">
               <Layers className="w-3 h-3" />
-              {archive.layer_height}mm
+              {archive.total_layers && <span>{archive.total_layers} layers</span>}
+              {archive.total_layers && archive.layer_height && <span className="text-bambu-gray/50">·</span>}
+              {archive.layer_height && <span>{archive.layer_height}mm</span>}
             </div>
           )}
           {archive.filament_type && (
@@ -565,45 +576,12 @@ function ArchiveCard({
 
       {/* Timelapse Viewer Modal */}
       {showTimelapse && archive.timelapse_path && (
-        <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-4xl w-full mx-4 overflow-hidden">
-            <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-              <h3 className="text-lg font-semibold text-white flex items-center gap-2">
-                <Film className="w-5 h-5 text-bambu-green" />
-                {archive.print_name || archive.filename} - Timelapse
-              </h3>
-              <div className="flex items-center gap-2">
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => {
-                    const link = document.createElement('a');
-                    link.href = api.getArchiveTimelapse(archive.id);
-                    link.download = `${archive.print_name || archive.filename}_timelapse.mp4`;
-                    link.click();
-                  }}
-                >
-                  <Download className="w-4 h-4" />
-                  Download
-                </Button>
-                <button
-                  onClick={() => setShowTimelapse(false)}
-                  className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
-                >
-                  <X className="w-5 h-5 text-bambu-gray" />
-                </button>
-              </div>
-            </div>
-            <div className="p-4">
-              <video
-                src={api.getArchiveTimelapse(archive.id)}
-                controls
-                autoPlay
-                className="w-full rounded-lg"
-              />
-            </div>
-          </div>
-        </div>
+        <TimelapseViewer
+          src={api.getArchiveTimelapse(archive.id)}
+          title={`${archive.print_name || archive.filename} - Timelapse`}
+          downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
+          onClose={() => setShowTimelapse(false)}
+        />
       )}
 
       {/* QR Code Modal */}
@@ -642,6 +620,14 @@ function ArchiveCard({
           onClose={() => setShowProjectPage(false)}
         />
       )}
+
+      {showSchedule && (
+        <AddToQueueModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowSchedule(false)}
+        />
+      )}
     </Card>
   );
 }
@@ -675,6 +661,7 @@ export function ArchivesPage() {
   const [uploadFiles, setUploadFiles] = useState<File[]>([]);
   const [isDraggingOver, setIsDraggingOver] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [isSelectionMode, setIsSelectionMode] = useState(false);
   const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
   const [showBatchTag, setShowBatchTag] = useState(false);
   const [viewMode, setViewMode] = useState<ViewMode>('grid');
@@ -791,7 +778,7 @@ export function ArchivesPage() {
       }
     });
 
-  const selectionMode = selectedIds.size > 0;
+  const selectionMode = isSelectionMode || selectedIds.size > 0;
 
   const toggleSelect = (id: number) => {
     setSelectedIds((prev) => {
@@ -813,6 +800,7 @@ export function ArchivesPage() {
 
   const clearSelection = () => {
     setSelectedIds(new Set());
+    setIsSelectionMode(false);
   };
 
   const toggleColor = (color: string) => {
@@ -998,7 +986,7 @@ export function ArchivesPage() {
         </div>
         <div className="flex items-center gap-3">
           {!selectionMode && (
-            <Button variant="secondary" onClick={() => filteredArchives?.length && toggleSelect(filteredArchives[0].id)}>
+            <Button variant="secondary" onClick={() => setIsSelectionMode(true)}>
               <CheckSquare className="w-4 h-4" />
               Select
             </Button>

+ 227 - 50
frontend/src/pages/PrintersPage.tsx

@@ -13,6 +13,9 @@ import {
   HardDrive,
   AlertTriangle,
   Terminal,
+  Power,
+  PowerOff,
+  Zap,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { Printer, PrinterCreate } from '../api/client';
@@ -21,6 +24,8 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
+import { HMSErrorModal } from '../components/HMSErrorModal';
+import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 
 function formatTime(seconds: number): string {
   const hours = Math.floor(seconds / 3600);
@@ -83,6 +88,9 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showMQTTDebug, setShowMQTTDebug] = useState(false);
+  const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
+  const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
+  const [showHMSModal, setShowHMSModal] = useState(false);
 
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printer.id],
@@ -90,6 +98,20 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
     refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
   });
 
+  // Fetch smart plug for this printer
+  const { data: smartPlug } = useQuery({
+    queryKey: ['smartPlugByPrinter', printer.id],
+    queryFn: () => api.getSmartPlugByPrinter(printer.id),
+  });
+
+  // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
+  const { data: plugStatus } = useQuery({
+    queryKey: ['smartPlugStatus', smartPlug?.id],
+    queryFn: () => smartPlug ? api.getSmartPlugStatus(smartPlug.id) : null,
+    enabled: !!smartPlug,
+    refetchInterval: 10000, // 10 seconds for real-time power display
+  });
+
   // Determine if this card should be hidden
   const shouldHide = hideIfDisconnected && status && !status.connected;
 
@@ -107,6 +129,23 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
     },
   });
 
+  // Smart plug control mutations
+  const powerControlMutation = useMutation({
+    mutationFn: (action: 'on' | 'off') =>
+      smartPlug ? api.controlSmartPlug(smartPlug.id, action) : Promise.reject('No plug'),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smartPlugStatus', smartPlug?.id] });
+    },
+  });
+
+  const toggleAutoOffMutation = useMutation({
+    mutationFn: (enabled: boolean) =>
+      smartPlug ? api.updateSmartPlug(smartPlug.id, { auto_off: enabled }) : Promise.reject('No plug'),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', printer.id] });
+    },
+  });
+
   if (shouldHide) {
     return null;
   }
@@ -137,25 +176,22 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
             </span>
             {/* HMS Status Indicator */}
             {status?.connected && (
-              <span
-                className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
+              <button
+                onClick={() => setShowHMSModal(true)}
+                className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${
                   status.hms_errors && status.hms_errors.length > 0
                     ? status.hms_errors.some(e => e.severity <= 2)
                       ? 'bg-red-500/20 text-red-400'
                       : 'bg-orange-500/20 text-orange-400'
                     : 'bg-bambu-green/20 text-bambu-green'
                 }`}
-                title={
-                  status.hms_errors && status.hms_errors.length > 0
-                    ? `${status.hms_errors.length} HMS error(s)`
-                    : 'No HMS errors'
-                }
+                title="Click to view HMS errors"
               >
                 <AlertTriangle className="w-3 h-3" />
                 {status.hms_errors && status.hms_errors.length > 0
                   ? status.hms_errors.length
                   : 'OK'}
-              </span>
+              </button>
             )}
             <div className="relative">
               <Button
@@ -221,51 +257,67 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
         {/* Status */}
         {status?.connected && (
           <>
-            {/* Printer State */}
-            <div className="mb-4">
-              <p className="text-sm text-bambu-gray mb-1">Status</p>
-              <p className="text-white font-medium capitalize">
-                {status.state?.toLowerCase() || 'Idle'}
-              </p>
-            </div>
-
-            {/* Current Print */}
-            {status.current_print && status.state === 'RUNNING' && (
-              <div className="mb-4 p-3 bg-bambu-dark rounded-lg">
-                <div className="flex gap-3">
-                  {/* Cover Image */}
-                  <CoverImage url={status.cover_url} printName={status.subtask_name || status.current_print || undefined} />
-                  {/* Print Info */}
-                  <div className="flex-1 min-w-0">
-                    <p className="text-sm text-bambu-gray mb-1">Printing</p>
-                    <p className="text-white text-sm mb-2 truncate">
-                      {status.subtask_name || status.current_print}
-                    </p>
-                    <div className="flex items-center justify-between text-sm">
-                      <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
-                        <div
-                          className="bg-bambu-green h-2 rounded-full transition-all"
-                          style={{ width: `${status.progress || 0}%` }}
-                        />
+            {/* Current Print or Idle Placeholder */}
+            <div className="mb-4 p-3 bg-bambu-dark rounded-lg">
+              <div className="flex gap-3">
+                {/* Cover Image */}
+                <CoverImage
+                  url={status.state === 'RUNNING' ? status.cover_url : null}
+                  printName={status.state === 'RUNNING' ? (status.subtask_name || status.current_print || undefined) : undefined}
+                />
+                {/* Print Info */}
+                <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-white text-sm mb-2 truncate">
+                        {status.subtask_name || status.current_print}
+                      </p>
+                      <div className="flex items-center justify-between text-sm">
+                        <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+                          <div
+                            className="bg-bambu-green h-2 rounded-full transition-all"
+                            style={{ width: `${status.progress || 0}%` }}
+                          />
+                        </div>
+                        <span className="text-white">{Math.round(status.progress || 0)}%</span>
                       </div>
-                      <span className="text-white">{Math.round(status.progress || 0)}%</span>
-                    </div>
-                    <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
-                      {status.remaining_time != null && status.remaining_time > 0 && (
-                        <span className="flex items-center gap-1">
-                          <Clock className="w-3 h-3" />
-                          {formatTime(status.remaining_time * 60)}
-                        </span>
-                      )}
-                      {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
-                        <span>
-                          Layer {status.layer_num}/{status.total_layers}
-                        </span>
-                      )}
-                    </div>
-                  </div>
+                      <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
+                        {status.remaining_time != null && status.remaining_time > 0 && (
+                          <span className="flex items-center gap-1">
+                            <Clock className="w-3 h-3" />
+                            {formatTime(status.remaining_time * 60)}
+                          </span>
+                        )}
+                        {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                          <span>
+                            Layer {status.layer_num}/{status.total_layers}
+                          </span>
+                        )}
+                      </div>
+                    </>
+                  ) : (
+                    <>
+                      <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>
+                      <div className="flex items-center justify-between text-sm">
+                        <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+                          <div className="bg-bambu-dark-tertiary h-2 rounded-full" />
+                        </div>
+                        <span className="text-bambu-gray">—</span>
+                      </div>
+                      <p className="text-xs text-bambu-gray mt-2">Ready to print</p>
+                    </>
+                  )}
                 </div>
               </div>
+            </div>
+
+            {/* Queue Widget - shows next scheduled print */}
+            {status.state !== 'RUNNING' && (
+              <PrinterQueueWidget printerId={printer.id} />
             )}
 
             {/* Temperatures */}
@@ -299,6 +351,88 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
           </>
         )}
 
+        {/* Smart Plug Controls */}
+        {smartPlug && (
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
+            <div className="flex items-center gap-3">
+              {/* Plug name and status */}
+              <div className="flex items-center gap-2 min-w-0">
+                <Zap className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                <span className="text-sm text-white truncate">{smartPlug.name}</span>
+                {plugStatus && (
+                  <span
+                    className={`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${
+                      plugStatus.state === 'ON'
+                        ? 'bg-bambu-green/20 text-bambu-green'
+                        : plugStatus.state === 'OFF'
+                        ? 'bg-red-500/20 text-red-400'
+                        : 'bg-bambu-gray/20 text-bambu-gray'
+                    }`}
+                  >
+                    {plugStatus.state || '?'}
+                  </span>
+                )}
+                {/* Power consumption display */}
+                {plugStatus?.energy?.power != null && plugStatus.state === 'ON' && (
+                  <span className="text-xs text-yellow-400 font-medium flex-shrink-0">
+                    {plugStatus.energy.power}W
+                  </span>
+                )}
+              </div>
+
+              {/* Spacer */}
+              <div className="flex-1" />
+
+              {/* Power buttons */}
+              <div className="flex items-center gap-1">
+                <button
+                  onClick={() => setShowPowerOnConfirm(true)}
+                  disabled={powerControlMutation.isPending || plugStatus?.state === 'ON'}
+                  className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
+                    plugStatus?.state === 'ON'
+                      ? 'bg-bambu-green text-white'
+                      : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  <Power className="w-3 h-3" />
+                  On
+                </button>
+                <button
+                  onClick={() => setShowPowerOffConfirm(true)}
+                  disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF'}
+                  className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
+                    plugStatus?.state === 'OFF'
+                      ? 'bg-red-500/30 text-red-400'
+                      : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  <PowerOff className="w-3 h-3" />
+                  Off
+                </button>
+              </div>
+
+              {/* Auto-off toggle */}
+              <div className="flex items-center gap-2 flex-shrink-0">
+                <span className="text-xs text-bambu-gray hidden sm:inline">Auto-off</span>
+                <button
+                  onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
+                  disabled={toggleAutoOffMutation.isPending}
+                  title="Auto power-off after print"
+                  className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
+                    smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                  }`}
+                >
+                  <span
+                    className={`absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full transition-transform ${
+                      smartPlug.auto_off ? 'translate-x-4' : 'translate-x-0'
+                    }`}
+                  />
+                </button>
+              </div>
+            </div>
+          </div>
+        )}
+
         {/* Connection Info & Actions */}
         <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
           <div className="text-xs text-bambu-gray">
@@ -334,6 +468,49 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
           onClose={() => setShowMQTTDebug(false)}
         />
       )}
+
+      {/* Power On Confirmation */}
+      {showPowerOnConfirm && smartPlug && (
+        <ConfirmModal
+          title="Power On Printer"
+          message={`Are you sure you want to turn ON the power for "${printer.name}"?`}
+          confirmText="Power On"
+          variant="default"
+          onConfirm={() => {
+            powerControlMutation.mutate('on');
+            setShowPowerOnConfirm(false);
+          }}
+          onCancel={() => setShowPowerOnConfirm(false)}
+        />
+      )}
+
+      {/* Power Off Confirmation */}
+      {showPowerOffConfirm && smartPlug && (
+        <ConfirmModal
+          title="Power Off Printer"
+          message={
+            status?.state === 'RUNNING'
+              ? `WARNING: "${printer.name}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.`
+              : `Are you sure you want to turn OFF the power for "${printer.name}"?`
+          }
+          confirmText="Power Off"
+          variant="danger"
+          onConfirm={() => {
+            powerControlMutation.mutate('off');
+            setShowPowerOffConfirm(false);
+          }}
+          onCancel={() => setShowPowerOffConfirm(false)}
+        />
+      )}
+
+      {/* HMS Error Modal */}
+      {showHMSModal && (
+        <HMSErrorModal
+          printerName={printer.name}
+          errors={status?.hms_errors || []}
+          onClose={() => setShowHMSModal(false)}
+        />
+      )}
     </Card>
   );
 }

+ 392 - 0
frontend/src/pages/QueuePage.tsx

@@ -0,0 +1,392 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Link } from 'react-router-dom';
+import {
+  Clock,
+  Trash2,
+  Play,
+  X,
+  CheckCircle,
+  XCircle,
+  AlertCircle,
+  Calendar,
+  Printer,
+  GripVertical,
+  SkipForward,
+  ExternalLink,
+  Power,
+  StopCircle,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { PrintQueueItem } from '../api/client';
+import { Card } from '../components/Card';
+import { Button } from '../components/Button';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+
+function formatRelativeTime(dateString: string | null): string {
+  if (!dateString) return 'ASAP';
+  // Parse ISO string - it's in UTC, convert to local for display
+  const date = new Date(dateString);
+  const now = new Date();
+  const diff = date.getTime() - now.getTime();
+
+  if (diff < -60000) return 'Overdue';
+  if (diff < 0) return 'Now';
+  if (diff < 60000) return 'In less than a minute';
+  if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
+  if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`;
+  return date.toLocaleString();
+}
+
+function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
+  const config = {
+    pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10', label: 'Pending' },
+    printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10', label: 'Printing' },
+    completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10', label: 'Completed' },
+    failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10', label: 'Failed' },
+    skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10', label: 'Skipped' },
+    cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10', label: 'Cancelled' },
+  };
+
+  const { icon: Icon, color, label } = config[status];
+
+  return (
+    <span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${color}`}>
+      <Icon className="w-3.5 h-3.5" />
+      {label}
+    </span>
+  );
+}
+
+function QueueItemRow({
+  item,
+  onCancel,
+  onRemove,
+  onStop,
+}: {
+  item: PrintQueueItem;
+  onCancel: (id: number) => void;
+  onRemove: (id: number) => void;
+  onStop: (id: number) => void;
+}) {
+  const [showCancelConfirm, setShowCancelConfirm] = useState(false);
+  const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
+  const [showStopConfirm, setShowStopConfirm] = useState(false);
+
+  return (
+    <>
+      <div className="flex items-center gap-4 p-4 bg-bambu-dark-secondary rounded-lg">
+        {item.status === 'pending' && (
+          <GripVertical className="w-5 h-5 text-bambu-gray cursor-grab" />
+        )}
+
+        {/* Thumbnail */}
+        <div className="w-16 h-16 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
+          {item.archive_thumbnail ? (
+            <img
+              src={api.getArchiveThumbnail(item.archive_id)}
+              alt=""
+              className="w-full h-full object-cover"
+            />
+          ) : (
+            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+              <Calendar className="w-6 h-6" />
+            </div>
+          )}
+        </div>
+
+        {/* Info */}
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-2">
+            <p className="text-white font-medium truncate">
+              {item.archive_name || `Archive #${item.archive_id}`}
+            </p>
+            <Link
+              to={`/archives?highlight=${item.archive_id}`}
+              className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
+              title="View archive"
+            >
+              <ExternalLink className="w-3.5 h-3.5" />
+            </Link>
+          </div>
+          <div className="flex items-center gap-3 mt-1 text-sm text-bambu-gray">
+            <span className="flex items-center gap-1">
+              <Printer className="w-3.5 h-3.5" />
+              {item.printer_name || `Printer #${item.printer_id}`}
+            </span>
+            <span className="flex items-center gap-1">
+              <Clock className="w-3.5 h-3.5" />
+              {formatRelativeTime(item.scheduled_time)}
+            </span>
+          </div>
+          {item.require_previous_success && (
+            <p className="text-xs text-orange-400 mt-1">
+              Requires previous print to succeed
+            </p>
+          )}
+          {item.auto_off_after && (
+            <p className="text-xs text-blue-400 mt-1 flex items-center gap-1">
+              <Power className="w-3 h-3" />
+              Will power off when done
+            </p>
+          )}
+          {item.error_message && (
+            <p className="text-xs text-red-400 mt-1 flex items-center gap-1">
+              <AlertCircle className="w-3 h-3" />
+              {item.error_message}
+            </p>
+          )}
+        </div>
+
+        {/* Status */}
+        <StatusBadge status={item.status} />
+
+        {/* Actions */}
+        <div className="flex items-center gap-2">
+          {item.status === 'printing' && (
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => setShowStopConfirm(true)}
+              title="Stop Print"
+              className="text-red-400 hover:text-red-300"
+            >
+              <StopCircle className="w-4 h-4" />
+            </Button>
+          )}
+          {item.status === 'pending' && (
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => setShowCancelConfirm(true)}
+              title="Cancel"
+            >
+              <X className="w-4 h-4" />
+            </Button>
+          )}
+          {['completed', 'failed', 'skipped', 'cancelled'].includes(item.status) && (
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => setShowRemoveConfirm(true)}
+              title="Remove"
+            >
+              <Trash2 className="w-4 h-4" />
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {/* Cancel Confirmation Modal */}
+      {showCancelConfirm && (
+        <ConfirmModal
+          title="Cancel Scheduled Print"
+          message={`Are you sure you want to cancel "${item.archive_name || 'this print'}"? It will be removed from the queue.`}
+          confirmText="Cancel Print"
+          variant="danger"
+          onConfirm={() => {
+            onCancel(item.id);
+            setShowCancelConfirm(false);
+          }}
+          onCancel={() => setShowCancelConfirm(false)}
+        />
+      )}
+
+      {/* Remove Confirmation Modal */}
+      {showRemoveConfirm && (
+        <ConfirmModal
+          title="Remove from History"
+          message={`Are you sure you want to remove "${item.archive_name || 'this item'}" from the queue history?`}
+          confirmText="Remove"
+          variant="danger"
+          onConfirm={() => {
+            onRemove(item.id);
+            setShowRemoveConfirm(false);
+          }}
+          onCancel={() => setShowRemoveConfirm(false)}
+        />
+      )}
+
+      {/* Stop Confirmation Modal */}
+      {showStopConfirm && (
+        <ConfirmModal
+          title="Stop Print"
+          message={`Are you sure you want to stop the current print "${item.archive_name || 'this print'}"? This will cancel the print job on the printer.`}
+          confirmText="Stop Print"
+          variant="danger"
+          onConfirm={() => {
+            onStop(item.id);
+            setShowStopConfirm(false);
+          }}
+          onCancel={() => setShowStopConfirm(false)}
+        />
+      )}
+    </>
+  );
+}
+
+export function QueuePage() {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
+  const [filterStatus, setFilterStatus] = useState<string>('');
+
+  const { data: queue, isLoading } = useQuery({
+    queryKey: ['queue', filterPrinter, filterStatus],
+    queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
+    refetchInterval: 10000,
+  });
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
+  const cancelMutation = useMutation({
+    mutationFn: (id: number) => api.cancelQueueItem(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Queue item cancelled');
+    },
+    onError: () => showToast('Failed to cancel item', 'error'),
+  });
+
+  const removeMutation = useMutation({
+    mutationFn: (id: number) => api.removeFromQueue(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Queue item removed');
+    },
+    onError: () => showToast('Failed to remove item', 'error'),
+  });
+
+  const stopMutation = useMutation({
+    mutationFn: (id: number) => api.stopQueueItem(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Print stopped');
+    },
+    onError: () => showToast('Failed to stop print', 'error'),
+  });
+
+  const pendingItems = queue?.filter(i => i.status === 'pending') || [];
+  const activeItems = queue?.filter(i => i.status === 'printing') || [];
+  const historyItems = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
+
+  return (
+    <div className="p-8">
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h1 className="text-2xl font-bold text-white">Print Queue</h1>
+          <p className="text-bambu-gray mt-1">Schedule and manage print jobs</p>
+        </div>
+      </div>
+
+      {/* Filters */}
+      <div className="flex items-center gap-4 mb-6">
+        <select
+          className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          value={filterPrinter || ''}
+          onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
+        >
+          <option value="">All Printers</option>
+          {printers?.map((p) => (
+            <option key={p.id} value={p.id}>{p.name}</option>
+          ))}
+        </select>
+
+        <select
+          className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          value={filterStatus}
+          onChange={(e) => setFilterStatus(e.target.value)}
+        >
+          <option value="">All Status</option>
+          <option value="pending">Pending</option>
+          <option value="printing">Printing</option>
+          <option value="completed">Completed</option>
+          <option value="failed">Failed</option>
+          <option value="skipped">Skipped</option>
+          <option value="cancelled">Cancelled</option>
+        </select>
+      </div>
+
+      {isLoading ? (
+        <div className="text-center py-12 text-bambu-gray">Loading...</div>
+      ) : queue?.length === 0 ? (
+        <Card className="p-12 text-center">
+          <Calendar className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
+          <h3 className="text-lg font-medium text-white mb-2">No prints scheduled</h3>
+          <p className="text-bambu-gray">
+            Schedule a print from the Archives page using the "Schedule" option in the context menu.
+          </p>
+        </Card>
+      ) : (
+        <div className="space-y-6">
+          {/* Active Prints */}
+          {activeItems.length > 0 && (
+            <div>
+              <h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
+                <Play className="w-5 h-5 text-blue-400" />
+                Currently Printing
+              </h2>
+              <div className="space-y-2">
+                {activeItems.map((item) => (
+                  <QueueItemRow
+                    key={item.id}
+                    item={item}
+                    onCancel={(id) => cancelMutation.mutate(id)}
+                    onRemove={(id) => removeMutation.mutate(id)}
+                    onStop={(id) => stopMutation.mutate(id)}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Pending Queue */}
+          {pendingItems.length > 0 && (
+            <div>
+              <h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
+                <Clock className="w-5 h-5 text-yellow-400" />
+                Queued ({pendingItems.length})
+              </h2>
+              <div className="space-y-2">
+                {pendingItems.map((item) => (
+                  <QueueItemRow
+                    key={item.id}
+                    item={item}
+                    onCancel={(id) => cancelMutation.mutate(id)}
+                    onRemove={(id) => removeMutation.mutate(id)}
+                    onStop={(id) => stopMutation.mutate(id)}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* History */}
+          {historyItems.length > 0 && (
+            <div>
+              <h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
+                <CheckCircle className="w-5 h-5 text-bambu-gray" />
+                History ({historyItems.length})
+              </h2>
+              <div className="space-y-2">
+                {historyItems.slice(0, 10).map((item) => (
+                  <QueueItemRow
+                    key={item.id}
+                    item={item}
+                    onCancel={(id) => cancelMutation.mutate(id)}
+                    onRemove={(id) => removeMutation.mutate(id)}
+                    onStop={(id) => stopMutation.mutate(id)}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

+ 57 - 2
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Loader2, Check, Plus, Plug } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug, AlertTriangle } from 'lucide-react';
 import { api } from '../api/client';
 import type { AppSettings, SmartPlug } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
@@ -26,6 +26,11 @@ export function SettingsPage() {
     queryFn: api.getSmartPlugs,
   });
 
+  const { data: ffmpegStatus } = useQuery({
+    queryKey: ['ffmpeg-status'],
+    queryFn: api.checkFfmpeg,
+  });
+
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
@@ -39,8 +44,10 @@ export function SettingsPage() {
       const changed =
         settings.auto_archive !== localSettings.auto_archive ||
         settings.save_thumbnails !== localSettings.save_thumbnails ||
+        settings.capture_finish_photo !== localSettings.capture_finish_photo ||
         settings.default_filament_cost !== localSettings.default_filament_cost ||
-        settings.currency !== localSettings.currency;
+        settings.currency !== localSettings.currency ||
+        settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh;
       setHasChanges(changed);
     }
   }, [settings, localSettings]);
@@ -146,6 +153,36 @@ export function SettingsPage() {
                   <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 className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Capture finish photo</p>
+                  <p className="text-sm text-bambu-gray">
+                    Take a photo from printer camera when print completes
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.capture_finish_photo}
+                    onChange={(e) => updateSetting('capture_finish_photo', 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>
+              {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
+                <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
+                  <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
+                  <div className="text-sm">
+                    <p className="text-yellow-500 font-medium">ffmpeg not installed</p>
+                    <p className="text-bambu-gray mt-1">
+                      Camera capture requires ffmpeg. Install it via{' '}
+                      <code className="bg-bambu-dark-tertiary px-1 rounded">brew install ffmpeg</code> (macOS) or{' '}
+                      <code className="bg-bambu-dark-tertiary px-1 rounded">apt install ffmpeg</code> (Linux).
+                    </p>
+                  </div>
+                </div>
+              )}
             </CardContent>
           </Card>
 
@@ -186,6 +223,24 @@ export function SettingsPage() {
                   <option value="AUD">AUD ($)</option>
                 </select>
               </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Electricity cost per kWh
+                </label>
+                <input
+                  type="number"
+                  step="0.01"
+                  min="0"
+                  value={localSettings.energy_cost_per_kwh}
+                  onChange={(e) =>
+                    updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
+                  }
+                  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"
+                />
+                <p className="text-xs text-bambu-gray mt-1">
+                  Used for tracking energy costs per print via smart plugs
+                </p>
+              </div>
             </CardContent>
           </Card>
 

+ 23 - 2
frontend/src/pages/StatsPage.tsx

@@ -7,6 +7,7 @@ import {
   DollarSign,
   Printer,
   Target,
+  Zap,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
@@ -25,11 +26,13 @@ function QuickStatsWidget({
     total_print_time_hours: number;
     total_filament_grams: number;
     total_cost: number;
+    total_energy_kwh: number;
+    total_energy_cost: number;
   } | undefined;
   currency: string;
 }) {
   return (
-    <div className="grid grid-cols-2 gap-4">
+    <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
       <div className="flex items-start gap-3">
         <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
           <Package className="w-5 h-5" />
@@ -62,10 +65,28 @@ function QuickStatsWidget({
           <DollarSign className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Total Cost</p>
+          <p className="text-xs text-bambu-gray">Filament Cost</p>
           <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
         </div>
       </div>
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-yellow-400">
+          <Zap className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Energy Used</p>
+          <p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(2) || '0.00'} kWh</p>
+        </div>
+      </div>
+      <div className="flex items-start gap-3">
+        <div className="p-2 rounded-lg bg-bambu-dark text-yellow-500">
+          <DollarSign className="w-5 h-5" />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray">Energy Cost</p>
+          <p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
+        </div>
+      </div>
     </div>
   );
 }

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


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


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


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-BPRATuOd.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DUX4pLTn.css">
+    <script type="module" crossorigin src="/assets/index-C7e8XOql.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CJ8fGWbx.css">
   </head>
   <body>
     <div id="root"></div>

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