Browse Source

Added scheduling and queueing; Misc improvements

Martin Ziegler 5 months ago
parent
commit
af8120604b

+ 74 - 6
README.md

@@ -22,9 +22,16 @@ v∆v
 ## Features
 ## Features
 
 
 - **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (H2C, H2D, H2S, X1, X1C, P1P, P1S, A1, A1 Mini)
 - **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
+- **Automatic Print Archiving** - Automatically saves 3MF files with full metadata extraction
 - **3D Model Preview** - Interactive Three.js viewer for archived prints
 - **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:
 - **Smart Plug Integration** - Control Tasmota-based smart plugs with automation:
   - Auto power-on when print starts
   - Auto power-on when print starts
   - Auto power-off when print completes
   - Auto power-off when print completes
@@ -56,6 +63,14 @@ v∆v
   - Expandable JSON payloads for detailed inspection
   - Expandable JSON payloads for detailed inspection
 - **Filament Cost Tracking** - Track costs per print with customizable filament database
 - **Filament Cost Tracking** - Track costs per print with customizable filament database
 - **Photo Attachments** - Attach photos to archived prints for documentation
 - **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
 - **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
 - **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
 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
@@ -334,6 +349,38 @@ Prints are automatically archived when they complete. You can also:
 - Re-print any archived 3MF to a connected printer
 - Re-print any archived 3MF to a connected printer
 - Export archives for backup
 - 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
 ### Project Page
 
 
 3MF files downloaded from MakerWorld contain embedded project pages with model information. To view:
 3MF files downloaded from MakerWorld contain embedded project pages with model information. To view:
@@ -470,8 +517,13 @@ mv bambusy.db bambusy.db.backup
 
 
 ### View server logs
 ### View server logs
 
 
+Bambusy writes logs to `bambutrack.log` in the application directory (rotating, max 5MB × 3 files).
+
 ```bash
 ```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
 uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug
 
 
 # If running as systemd service
 # If running as systemd service
@@ -492,6 +544,21 @@ sudo journalctl -u bambusy -f
 2. **Verify automation is enabled** - Check the Enabled, Auto On, and Auto Off toggles
 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
 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
 ## Known Issues / Roadmap
 
 
 ### Beta Limitations
 ### Beta Limitations
@@ -509,10 +576,11 @@ sudo journalctl -u bambusy -f
 - [x] Embedded project page editor
 - [x] Embedded project page editor
 - [x] QR code labels
 - [x] QR code labels
 - [x] Energy monitoring and statistics
 - [x] Energy monitoring and statistics
-- [ ] Print scheduling and queuing
+- [x] Print scheduling and queuing
+- [x] Automatic finish photo capture
 - [ ] Maintenance tracker
 - [ ] Maintenance tracker
-- [ ] Notifications
-- [ ] Mobile support
+- [ ] Notifications (email, push)
+- [ ] Mobile-optimized UI
 
 
 ## License
 ## License
 
 

+ 5 - 0
backend/app/api/routes/archives.py

@@ -68,6 +68,7 @@ def archive_to_response(
         "filament_type": archive.filament_type,
         "filament_type": archive.filament_type,
         "filament_color": archive.filament_color,
         "filament_color": archive.filament_color,
         "layer_height": archive.layer_height,
         "layer_height": archive.layer_height,
+        "total_layers": archive.total_layers,
         "nozzle_diameter": archive.nozzle_diameter,
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
         "bed_temperature": archive.bed_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
         "nozzle_temperature": archive.nozzle_temperature,
@@ -1092,6 +1093,7 @@ async def reprint_archive(
     from backend.app.models.printer import Printer
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import upload_file_async
     from backend.app.services.bambu_ftp import upload_file_async
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
+    from backend.app.main import register_expected_print
 
 
     # Get archive
     # Get archive
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -1128,6 +1130,9 @@ async def reprint_archive(
     if not uploaded:
     if not uploaded:
         raise HTTPException(500, "Failed to upload file to printer")
         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
     # Start the print
     started = printer_manager.start_print(printer_id, remote_filename)
     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)"}

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

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 
 async def init_db():
 async def init_db():
     # Import models to register them with SQLAlchemy
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue  # noqa: F401
 
 
     async with engine.begin() as conn:
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
         await conn.run_sync(Base.metadata.create_all)

+ 208 - 5
backend/app/main.py

@@ -3,27 +3,51 @@ import logging
 from datetime import datetime
 from datetime import datetime
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from pathlib import Path
 from pathlib import Path
+from logging.handlers import RotatingFileHandler
 
 
 from fastapi import FastAPI
 from fastapi import FastAPI
 
 
-# Configure logging for all modules
-logging.basicConfig(
-    level=logging.INFO,
-    format='%(asctime)s %(levelname)s [%(name)s] %(message)s'
+# 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.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import init_db, async_session
 from backend.app.core.database import init_db, async_session
 from backend.app.core.websocket import ws_manager
 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.api.routes import settings as settings_routes
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     printer_manager,
     printer_manager,
     printer_state_to_dict,
     printer_state_to_dict,
     init_printer_connections,
     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.bambu_mqtt import PrinterState
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async
 from backend.app.services.bambu_ftp import download_file_async
@@ -35,10 +59,28 @@ from backend.app.models.smart_plug import SmartPlug
 # Track active prints: {(printer_id, filename): archive_id}
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
 _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}
 # Track starting energy for prints: {archive_id: starting_kwh}
 _print_energy_start: dict[int, float] = {}
 _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):
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
     """Handle printer status changes - broadcast via WebSocket."""
     await ws_manager.send_printer_status(
     await ws_manager.send_printer_status(
@@ -76,6 +118,112 @@ async def on_print_start(printer_id: int, data: dict):
         if not filename and not subtask_name:
         if not filename and not subtask_name:
             return
             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
         # Build list of possible 3MF filenames to try
         possible_names = []
         possible_names = []
 
 
@@ -389,6 +537,56 @@ async def on_print_complete(printer_id: int, data: dict):
         import logging
         import logging
         logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
         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
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 async def lifespan(app: FastAPI):
@@ -406,9 +604,13 @@ async def lifespan(app: FastAPI):
     async with async_session() as db:
     async with async_session() as db:
         await init_printer_connections(db)
         await init_printer_connections(db)
 
 
+    # Start the print scheduler
+    asyncio.create_task(print_scheduler.run())
+
     yield
     yield
 
 
     # Shutdown
     # Shutdown
+    print_scheduler.stop()
     printer_manager.disconnect_all()
     printer_manager.disconnect_all()
 
 
 
 
@@ -426,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(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.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(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)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 
 
 

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

@@ -26,6 +26,7 @@ class PrintArchive(Base):
     filament_type: Mapped[str | None] = mapped_column(String(50))
     filament_type: Mapped[str | None] = mapped_column(String(50))
     filament_color: Mapped[str | None] = mapped_column(String(50))
     filament_color: Mapped[str | None] = mapped_column(String(50))
     layer_height: Mapped[float | None] = mapped_column(Float)
     layer_height: Mapped[float | None] = mapped_column(Float)
+    total_layers: Mapped[int | None] = mapped_column(Integer)
     nozzle_diameter: Mapped[float | None] = mapped_column(Float)
     nozzle_diameter: Mapped[float | None] = mapped_column(Float)
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)

+ 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

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

@@ -45,6 +45,7 @@ class ArchiveResponse(BaseModel):
     filament_type: str | None
     filament_type: str | None
     filament_color: str | None
     filament_color: str | None
     layer_height: float | None
     layer_height: float | None
+    total_layers: int | None = None
     nozzle_diameter: float | None
     nozzle_diameter: float | None
     bed_temperature: int | None
     bed_temperature: int | None
     nozzle_temperature: int | None
     nozzle_temperature: int | None

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

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

@@ -28,6 +28,7 @@ class ThreeMFParser:
             with zipfile.ZipFile(self.file_path, "r") as zf:
             with zipfile.ZipFile(self.file_path, "r") as zf:
                 self._parse_slice_info(zf)
                 self._parse_slice_info(zf)
                 self._parse_project_settings(zf)
                 self._parse_project_settings(zf)
+                self._parse_gcode_header(zf)
                 self._parse_3dmodel(zf)
                 self._parse_3dmodel(zf)
                 self._extract_thumbnail(zf)
                 self._extract_thumbnail(zf)
 
 
@@ -100,6 +101,27 @@ class ThreeMFParser:
         except Exception:
         except Exception:
             pass
             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):
     def _extract_filament_info(self, data: dict):
         """Extract filament info, preferring non-support filaments."""
         """Extract filament info, preferring non-support filaments."""
         try:
         try:
@@ -652,6 +674,7 @@ class ArchiveService:
             filament_type=metadata.get("filament_type"),
             filament_type=metadata.get("filament_type"),
             filament_color=metadata.get("filament_color"),
             filament_color=metadata.get("filament_color"),
             layer_height=metadata.get("layer_height"),
             layer_height=metadata.get("layer_height"),
+            total_layers=metadata.get("total_layers"),
             nozzle_diameter=metadata.get("nozzle_diameter"),
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
             bed_temperature=metadata.get("bed_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
             nozzle_temperature=metadata.get("nozzle_temperature"),

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

+ 25 - 5
backend/app/services/bambu_mqtt.py

@@ -305,20 +305,26 @@ class BambuMQTTClient:
         ssl_context.verify_mode = ssl.CERT_NONE
         ssl_context.verify_mode = ssl.CERT_NONE
         self._client.tls_set_context(ssl_context)
         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()
         self._client.loop_start()
 
 
     def start_print(self, filename: str, plate_id: int = 1):
     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:
         if self._client and self.state.connected:
             # Bambu print command format
             # Bambu print command format
+            # Based on: https://github.com/darkorb/bambu-ftp-and-print
             command = {
             command = {
                 "print": {
                 "print": {
+                    "sequence_id": 0,
                     "command": "project_file",
                     "command": "project_file",
                     "param": f"Metadata/plate_{plate_id}.gcode",
                     "param": f"Metadata/plate_{plate_id}.gcode",
                     "subtask_name": filename,
                     "subtask_name": filename,
                     "url": f"ftp://{filename}",
                     "url": f"ftp://{filename}",
-                    "bed_type": "auto",
                     "timelapse": False,
                     "timelapse": False,
                     "bed_leveling": True,
                     "bed_leveling": True,
                     "flow_cali": True,
                     "flow_cali": True,
@@ -327,7 +333,22 @@ class BambuMQTTClient:
                     "use_ams": True,
                     "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))
             self._client.publish(self.topic_publish, json.dumps(command))
+            logger.info(f"[{self.serial_number}] Sent stop print command")
             return True
             return True
         return False
         return False
 
 
@@ -355,8 +376,7 @@ class BambuMQTTClient:
     def enable_logging(self, enabled: bool = True):
     def enable_logging(self, enabled: bool = True):
         """Enable or disable MQTT message logging."""
         """Enable or disable MQTT message logging."""
         self._logging_enabled = enabled
         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]:
     def get_logs(self) -> list[MQTTLogEntry]:
         """Get all logged MQTT messages."""
         """Get all logged MQTT messages."""

+ 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 self._clients[printer_id].start_print(filename)
         return False
         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:
     def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:
         """Enable or disable MQTT logging for a printer."""
         """Enable or disable MQTT logging for a printer."""
         if printer_id in self._clients:
         if printer_id in self._clients:

+ 2 - 0
frontend/src/App.tsx

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

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

@@ -99,6 +99,7 @@ export interface Archive {
   filament_type: string | null;
   filament_type: string | null;
   filament_color: string | null;
   filament_color: string | null;
   layer_height: number | null;
   layer_height: number | null;
+  total_layers: number | null;
   nozzle_diameter: number | null;
   nozzle_diameter: number | null;
   bed_temperature: number | null;
   bed_temperature: number | null;
   nozzle_temperature: number | null;
   nozzle_temperature: number | null;
@@ -261,6 +262,41 @@ export interface SmartPlugTestResult {
   device_name: string | null;
   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
 // MQTT Logging types
 export interface MQTTLogEntry {
 export interface MQTTLogEntry {
   timestamp: string;
   timestamp: string;
@@ -566,4 +602,33 @@ export const api = {
       body: JSON.stringify({ ip_address, username, password }),
       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>
+  );
+}

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

@@ -9,9 +9,10 @@ const shortcuts = [
   { category: 'Navigation', items: [
   { category: 'Navigation', items: [
     { keys: ['1'], description: 'Go to Printers' },
     { keys: ['1'], description: 'Go to Printers' },
     { keys: ['2'], description: 'Go to Archives' },
     { 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: [
   { category: 'Archives', items: [
     { keys: ['/'], description: 'Focus search' },
     { keys: ['/'], description: 'Focus search' },

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

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

+ 19 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -51,6 +51,7 @@ import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { TimelapseViewer } from '../components/TimelapseViewer';
+import { AddToQueueModal } from '../components/AddToQueueModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 function formatFileSize(bytes: number): string {
 function formatFileSize(bytes: number): string {
@@ -99,6 +100,7 @@ function ArchiveCard({
   const [showQRCode, setShowQRCode] = useState(false);
   const [showQRCode, setShowQRCode] = useState(false);
   const [showPhotos, setShowPhotos] = useState(false);
   const [showPhotos, setShowPhotos] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
+  const [showSchedule, setShowSchedule] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
 
 
   const timelapseScanMutation = useMutation({
   const timelapseScanMutation = useMutation({
@@ -148,6 +150,11 @@ function ArchiveCard({
       icon: <Printer className="w-4 h-4" />,
       icon: <Printer className="w-4 h-4" />,
       onClick: () => setShowReprint(true),
       onClick: () => setShowReprint(true),
     },
     },
+    {
+      label: 'Schedule',
+      icon: <Calendar className="w-4 h-4" />,
+      onClick: () => setShowSchedule(true),
+    },
     {
     {
       label: 'Open in Bambu Studio',
       label: 'Open in Bambu Studio',
       icon: <ExternalLink className="w-4 h-4" />,
       icon: <ExternalLink className="w-4 h-4" />,
@@ -376,10 +383,12 @@ function ArchiveCard({
               {archive.filament_used_grams.toFixed(1)}g
               {archive.filament_used_grams.toFixed(1)}g
             </div>
             </div>
           )}
           )}
-          {archive.layer_height && (
+          {(archive.layer_height || archive.total_layers) && (
             <div className="flex items-center gap-1.5 text-bambu-gray">
             <div className="flex items-center gap-1.5 text-bambu-gray">
               <Layers className="w-3 h-3" />
               <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>
             </div>
           )}
           )}
           {archive.filament_type && (
           {archive.filament_type && (
@@ -611,6 +620,14 @@ function ArchiveCard({
           onClose={() => setShowProjectPage(false)}
           onClose={() => setShowProjectPage(false)}
         />
         />
       )}
       )}
+
+      {showSchedule && (
+        <AddToQueueModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowSchedule(false)}
+        />
+      )}
     </Card>
     </Card>
   );
   );
 }
 }

+ 6 - 0
frontend/src/pages/PrintersPage.tsx

@@ -25,6 +25,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal } from '../components/HMSErrorModal';
 import { HMSErrorModal } from '../components/HMSErrorModal';
+import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 
 
 function formatTime(seconds: number): string {
 function formatTime(seconds: number): string {
   const hours = Math.floor(seconds / 3600);
   const hours = Math.floor(seconds / 3600);
@@ -314,6 +315,11 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
               </div>
               </div>
             </div>
             </div>
 
 
+            {/* Queue Widget - shows next scheduled print */}
+            {status.state !== 'RUNNING' && (
+              <PrinterQueueWidget printerId={printer.id} />
+            )}
+
             {/* Temperatures */}
             {/* Temperatures */}
             {status.temperatures && (
             {status.temperatures && (
               <div className="grid grid-cols-3 gap-3">
               <div className="grid grid-cols-3 gap-3">

+ 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>
+  );
+}

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


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


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


+ 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="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.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" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-DFJpXKHm.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C2KAUV7t.css">
+    <script type="module" crossorigin src="/assets/index-C7e8XOql.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CJ8fGWbx.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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