Browse Source

Merge pull request #2 from maziggy/0.1.2

Final 0.1.2
MartinNYHC 11 tháng trước cách đây
mục cha
commit
8d27e58393
40 tập tin đã thay đổi với 3279 bổ sung và 196 xóa
  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>
   );
 }

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-C7e8XOql.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-CJ8fGWbx.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác