Browse Source

Added configurable logging; Major improvements and bugfixes

Martin Ziegler 5 months ago
parent
commit
28a5e786e9

+ 12 - 0
.env.example

@@ -0,0 +1,12 @@
+# BambuTrack Environment Configuration
+# Copy this file to .env and adjust values as needed
+
+# Debug mode (true = DEBUG logging, false = production with INFO logging)
+DEBUG=true
+
+# Log level (only used when DEBUG=false)
+# Options: DEBUG, INFO, WARNING, ERROR
+LOG_LEVEL=INFO
+
+# Enable file logging (logs written to logs/bambutrack.log)
+LOG_TO_FILE=true

+ 1 - 0
.gitignore

@@ -41,3 +41,4 @@ archive/
 
 # Logs
 *.log
+logs/

+ 58 - 0
README.md

@@ -328,6 +328,36 @@ To connect Bambusy to your printer, you need to enable LAN Mode:
 
 The printer should connect automatically and show real-time status.
 
+### Environment Variables
+
+Bambusy can be configured using environment variables or a `.env` file in the project root. Copy `.env.example` to `.env` and adjust as needed:
+
+```bash
+cp .env.example .env
+```
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `DEBUG` | `false` | Enable debug mode (verbose logging, SQL queries) |
+| `LOG_LEVEL` | `INFO` | Log level when DEBUG=false (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
+| `LOG_TO_FILE` | `true` | Write logs to `logs/bambutrack.log` |
+
+**Production (default):**
+- INFO level logging
+- SQLAlchemy and HTTP library noise suppressed
+- Logs written to `logs/bambutrack.log` (5MB rotating, 3 backups)
+
+**Development (`DEBUG=true`):**
+- DEBUG level logging (verbose)
+- All SQL queries logged
+- Useful for troubleshooting printer connections
+
+Example `.env` for development:
+```bash
+DEBUG=true
+LOG_TO_FILE=true
+```
+
 ## Usage
 
 ### Keyboard Shortcuts
@@ -569,6 +599,34 @@ Common causes:
 - **File upload failed** - Check FTP connectivity to the printer
 - **HMS errors** - Check the printer for any health system errors that prevent printing
 
+### Timelapse not attaching automatically
+
+**The Problem:**
+When printers run in **LAN-only mode** (disconnected from Bambu Cloud), they cannot sync time via NTP. This causes the printer's internal clock to drift significantly (sometimes days or weeks off). Bambusy matches timelapses by comparing the print completion time with the timelapse file's modification time - when the printer's clock is wrong, this matching fails.
+
+**Symptoms:**
+- "Scan for Timelapse" shows "No matching timelapse found"
+- Timelapse files exist on the printer but don't auto-attach
+- Printer shows incorrect date/time in its settings
+
+**Workaround - Manual Selection:**
+When automatic matching fails, Bambusy now offers manual timelapse selection:
+
+1. Right-click the archive and select **"Scan for Timelapse"**
+2. If no match is found, a dialog appears showing all available timelapse files on the printer
+3. Files are sorted by date (newest first) with size information
+4. Select the correct timelapse and click to attach it
+
+**Permanent Fix:**
+To fix the printer's clock:
+1. Temporarily connect the printer to the internet (via router or mobile hotspot)
+2. Wait for the printer to sync time via NTP
+3. Return to LAN-only mode - the clock should remain accurate until the next power cycle
+
+**Note:** Some users report the clock resets after power cycling. In this case, you'll need to either:
+- Periodically connect to the internet to sync time
+- Use the manual timelapse selection feature
+
 ## Known Issues / Roadmap
 
 ### Beta Limitations

+ 153 - 17
backend/app/api/routes/archives.py

@@ -3,7 +3,7 @@ import zipfile
 import io
 import logging
 
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query
 
 logger = logging.getLogger(__name__)
 from fastapi.responses import FileResponse, Response
@@ -618,11 +618,15 @@ async def scan_timelapse(
             break
 
     # Strategy 2: Match by timestamp proximity
+    # Bambu timelapse filename uses the print START time (when recording began)
     if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
         import re
         from datetime import datetime, timedelta
 
-        archive_time = archive.started_at or archive.completed_at or archive.created_at
+        # Prefer started_at since video filename is the print start time
+        # Fall back to completed_at or created_at if started_at is not available
+        archive_start = archive.started_at
+        archive_end = archive.completed_at or archive.created_at
         best_match = None
         best_diff = timedelta(hours=24)  # Max 24 hour difference
 
@@ -633,25 +637,76 @@ async def scan_timelapse(
             if match:
                 try:
                     file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
-                    # Timelapse is usually created at print end, so compare to completed_at or created_at
-                    compare_time = archive.completed_at or archive.created_at
-                    if compare_time:
-                        # Bambu printers use China Standard Time (UTC+8) for filenames
-                        # Try matching with CST offset adjustment
-                        diff_direct = abs(file_time - compare_time)
-                        # Also try with 8-hour offset (CST to UTC-ish local times)
-                        diff_cst_adjusted = abs(file_time - timedelta(hours=8) - compare_time)
-                        diff = min(diff_direct, diff_cst_adjusted)
-                        if diff < best_diff:
-                            best_diff = diff
-                            best_match = f
+
+                    # Try multiple timezone offsets since printer timezone can vary
+                    # Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset)
+                    for hour_offset in [0, 8, -8, 7, -7, 1, -1]:
+                        adjusted_file_time = file_time - timedelta(hours=hour_offset)
+
+                        # Check against start time (video filename = print start)
+                        if archive_start:
+                            diff = abs(adjusted_file_time - archive_start)
+                            if diff < best_diff:
+                                best_diff = diff
+                                best_match = f
+                                logger.debug(
+                                    f"Timelapse match candidate: {fname} with offset {hour_offset}h, "
+                                    f"diff from start: {diff}"
+                                )
+
+                        # Also check against end time with a buffer
+                        # (video timestamp should be BEFORE completion time)
+                        if archive_end:
+                            # The video timestamp should be within the print duration before completion
+                            if adjusted_file_time < archive_end:
+                                diff = archive_end - adjusted_file_time
+                                # Reasonable print duration: up to 48 hours
+                                if diff < timedelta(hours=48) and diff < best_diff:
+                                    best_diff = diff
+                                    best_match = f
+                                    logger.debug(
+                                        f"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, "
+                                        f"diff: {diff}"
+                                    )
+
                 except ValueError:
                     continue
 
-        if best_match and best_diff < timedelta(hours=2):  # Within 2 hours
+        # Accept match within 4 hours (more lenient for timezone issues)
+        if best_match and best_diff < timedelta(hours=4):
+            matching_file = best_match
+            logger.info(f"Matched timelapse by timestamp: {best_match.get('name')} (diff: {best_diff})")
+
+    # Strategy 3: Use file modification time from FTP listing
+    # This handles cases where printer's filename timestamp is wrong but file mtime is correct
+    if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
+        from datetime import datetime, timedelta
+
+        archive_start = archive.started_at
+        archive_end = archive.completed_at or archive.created_at
+        best_match = None
+        best_diff = timedelta(hours=24)
+
+        for f in mp4_files:
+            mtime = f.get("mtime")
+            if mtime:
+                # Timelapse file should be modified during or shortly after the print
+                # The mtime should be close to completion time (video finishes when print ends)
+                if archive_end:
+                    diff = abs(mtime - archive_end)
+                    if diff < best_diff:
+                        best_diff = diff
+                        best_match = f
+                        logger.debug(
+                            f"Timelapse mtime match candidate: {f.get('name')}, "
+                            f"mtime: {mtime}, diff from end: {diff}"
+                        )
+
+        if best_match and best_diff < timedelta(hours=2):
             matching_file = best_match
+            logger.info(f"Matched timelapse by file mtime: {best_match.get('name')} (diff: {best_diff})")
 
-    # Strategy 3: If only one timelapse exists and archive was recently completed, use it
+    # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
     if not matching_file and len(mp4_files) == 1:
         from datetime import datetime, timedelta
@@ -663,8 +718,28 @@ async def scan_timelapse(
                 matching_file = mp4_files[0]
                 logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
 
+    # Note: We intentionally don't use a "most recent file" fallback because
+    # we can't verify if timelapse was actually enabled for this print.
+    # Instead, return the list of available files for manual selection.
+
     if not matching_file:
-        return {"status": "not_found", "message": "No matching timelapse found on printer"}
+        # Return available files for manual selection
+        available_files = [
+            {
+                "name": f.get("name"),
+                "path": f.get("path"),
+                "size": f.get("size"),
+                "mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
+            }
+            for f in mp4_files
+        ]
+        # Sort by mtime descending (most recent first)
+        available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
+        return {
+            "status": "not_found",
+            "message": "No matching timelapse found - please select manually",
+            "available_files": available_files,
+        }
 
     # Download the timelapse - use the full path from the file listing
     remote_path = matching_file.get('path') or f"/timelapse/{matching_file['name']}"
@@ -690,6 +765,67 @@ async def scan_timelapse(
     }
 
 
+@router.post("/{archive_id}/timelapse/select")
+async def select_timelapse(
+    archive_id: int,
+    filename: str = Query(..., description="Timelapse filename to attach"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Manually select a timelapse from the printer to attach."""
+    from backend.app.models.printer import Printer
+    from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.printer_id:
+        raise HTTPException(400, "Archive has no associated printer")
+
+    result = await db.execute(
+        select(Printer).where(Printer.id == archive.printer_id)
+    )
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Find the file on the printer
+    files = []
+    remote_path = None
+    for timelapse_dir in ["/timelapse", "/timelapse/video"]:
+        try:
+            files = await list_files_async(printer.ip_address, printer.access_code, timelapse_dir)
+            for f in files:
+                if f.get("name") == filename:
+                    remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
+                    break
+            if remote_path:
+                break
+        except Exception:
+            continue
+
+    if not remote_path:
+        raise HTTPException(404, f"Timelapse '{filename}' not found on printer")
+
+    # Download and attach
+    timelapse_data = await download_file_bytes_async(
+        printer.ip_address, printer.access_code, remote_path
+    )
+    if not timelapse_data:
+        raise HTTPException(500, "Failed to download timelapse")
+
+    success = await service.attach_timelapse(archive_id, timelapse_data, filename)
+    if not success:
+        raise HTTPException(500, "Failed to attach timelapse")
+
+    return {
+        "status": "attached",
+        "message": f"Timelapse '{filename}' attached successfully",
+        "filename": filename,
+    }
+
+
 @router.post("/{archive_id}/timelapse/upload")
 async def upload_timelapse(
     archive_id: int,

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

@@ -173,9 +173,11 @@ async def control_smart_plug(
     if not success:
         raise HTTPException(503, "Failed to communicate with device")
 
-    # Update last state
+    # Update last state and reset auto_off_executed when turning on
     if expected_state:
         plug.last_state = expected_state
+        if expected_state == "ON":
+            plug.auto_off_executed = False  # Reset flag when manually turning on
     plug.last_checked = datetime.utcnow()
     await db.commit()
 

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

@@ -4,14 +4,19 @@ from pydantic_settings import BaseSettings
 
 class Settings(BaseSettings):
     app_name: str = "BambuTrack"
-    debug: bool = True
+    debug: bool = False  # Default to production mode
 
     # Paths
     base_dir: Path = Path(__file__).resolve().parent.parent.parent.parent
     archive_dir: Path = base_dir / "archive"
     static_dir: Path = base_dir / "static"
+    log_dir: Path = base_dir / "logs"
     database_url: str = f"sqlite+aiosqlite:///{base_dir}/bambutrack.db"
 
+    # Logging
+    log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true
+    log_to_file: bool = True  # Set to false to disable file logging
+
     # API
     api_prefix: str = "/api/v1"
 
@@ -25,3 +30,5 @@ settings = Settings()
 # Ensure directories exist
 settings.archive_dir.mkdir(exist_ok=True)
 settings.static_dir.mkdir(exist_ok=True)
+if settings.log_to_file:
+    settings.log_dir.mkdir(exist_ok=True)

+ 9 - 0
backend/app/core/database.py

@@ -64,3 +64,12 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+    # Migration: Add auto_off_executed column to smart_plugs
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        # Column already exists
+        pass

+ 149 - 51
backend/app/main.py

@@ -1,5 +1,6 @@
 import asyncio
 import logging
+import os
 from datetime import datetime
 from contextlib import asynccontextmanager
 from pathlib import Path
@@ -7,38 +8,51 @@ from logging.handlers import RotatingFileHandler
 
 from fastapi import FastAPI
 
-# Configure logging for all modules - console + file
+# Import settings first for logging configuration
+from backend.app.core.config import settings as app_settings
+
+# Configure logging based on settings
+# DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
+log_level_str = "DEBUG" if app_settings.debug else app_settings.log_level.upper()
+log_level = getattr(logging, log_level_str, logging.INFO)
 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 - always enabled
 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}")
+# File handler - only in production or if explicitly enabled
+if app_settings.log_to_file:
+    log_file = app_settings.log_dir / "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}")
+
+# Reduce noise from third-party libraries in production
+if not app_settings.debug:
+    logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+    logging.getLogger("httpcore").setLevel(logging.WARNING)
+    logging.getLogger("httpx").setLevel(logging.WARNING)
+
+logging.info(f"BambuTrack starting - debug={app_settings.debug}, log_level={log_level_str}")
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 
-from backend.app.core.config import settings as app_settings
 from backend.app.core.database import init_db, async_session
+from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
 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
@@ -86,7 +100,17 @@ _last_status_broadcast: dict[int, str] = {}
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
     # Only broadcast if something meaningful changed (reduce WebSocket spam)
-    status_key = f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}"
+    # Include rounded temperatures to detect meaningful temp changes (within 1 degree)
+    temps = state.temperatures or {}
+    nozzle_temp = round(temps.get("nozzle", 0))
+    bed_temp = round(temps.get("bed", 0))
+    nozzle_2_temp = round(temps.get("nozzle_2", 0)) if "nozzle_2" in temps else ""
+    chamber_temp = round(temps.get("chamber", 0)) if "chamber" in temps else ""
+
+    status_key = (
+        f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
+        f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}"
+    )
     if _last_status_broadcast.get(printer_id) == status_key:
         return  # No change, skip broadcast
 
@@ -108,7 +132,6 @@ async def on_print_start(printer_id: int, data: dict):
     async with async_session() as db:
         from backend.app.models.printer import Printer
         from backend.app.services.bambu_ftp import list_files_async
-        from sqlalchemy import select
 
         result = await db.execute(
             select(Printer).where(Printer.id == printer_id)
@@ -179,11 +202,17 @@ async def on_print_start(printer_id: int, data: dict):
                         select(SmartPlug).where(SmartPlug.printer_id == printer_id)
                     )
                     plug = plug_result.scalar_one_or_none()
+                    logger.info(f"[ENERGY] Print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}")
                     if plug:
                         energy = await tasmota_service.get_energy(plug)
+                        logger.info(f"[ENERGY] Energy response from plug: {energy}")
                         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")
+                            logger.info(f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
+                        else:
+                            logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}")
+                    else:
+                        logger.info(f"[ENERGY] No smart plug found for printer {printer_id}")
                 except Exception as e:
                     logger.warning(f"Failed to record starting energy: {e}")
 
@@ -351,11 +380,17 @@ async def on_print_start(printer_id: int, data: dict):
                         select(SmartPlug).where(SmartPlug.printer_id == printer_id)
                     )
                     plug = plug_result.scalar_one_or_none()
+                    logger.info(f"[ENERGY] Auto-archive print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}")
                     if plug:
                         energy = await tasmota_service.get_energy(plug)
+                        logger.info(f"[ENERGY] Auto-archive energy response: {energy}")
                         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")
+                            logger.info(f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
+                        else:
+                            logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}")
+                    else:
+                        logger.info(f"[ENERGY] No smart plug found for printer {printer_id}")
                 except Exception as e:
                     logger.warning(f"Failed to record starting energy: {e}")
 
@@ -387,29 +422,59 @@ async def on_print_complete(printer_id: int, data: dict):
     await ws_manager.send_print_complete(printer_id, data)
 
     filename = data.get("filename", "")
-    if not filename:
+    subtask_name = data.get("subtask_name", "")
+
+    if not filename and not subtask_name:
+        logger.warning(f"Print complete without filename or subtask_name")
         return
 
-    logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}")
+    logger.info(f"Print complete - filename: {filename}, subtask: {subtask_name}, status: {data.get('status')}")
 
-    # Build list of possible keys to try
+    # Build list of possible keys to try (matching how they were registered in on_print_start)
     possible_keys = []
 
-    if filename.endswith(".3mf"):
-        possible_keys.append((printer_id, filename))
-    elif filename.endswith(".gcode"):
-        base_name = filename.rsplit(".", 1)[0]
-        possible_keys.append((printer_id, f"{base_name}.3mf"))
-        possible_keys.append((printer_id, filename))
-    else:
-        possible_keys.append((printer_id, f"{filename}.3mf"))
-        possible_keys.append((printer_id, filename))
+    # Try subtask_name variations first (most reliable for matching)
+    if subtask_name:
+        possible_keys.append((printer_id, f"{subtask_name}.3mf"))
+        possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
+        possible_keys.append((printer_id, subtask_name))
+
+    # Try filename variations
+    if filename:
+        # Extract just the filename if it's a path
+        fname = filename.split("/")[-1] if "/" in filename else filename
+
+        if fname.endswith(".3mf"):
+            possible_keys.append((printer_id, fname))
+        elif fname.endswith(".gcode"):
+            base_name = fname.rsplit(".", 1)[0]
+            possible_keys.append((printer_id, f"{base_name}.gcode.3mf"))
+            possible_keys.append((printer_id, f"{base_name}.3mf"))
+            possible_keys.append((printer_id, fname))
+        else:
+            possible_keys.append((printer_id, f"{fname}.gcode.3mf"))
+            possible_keys.append((printer_id, f"{fname}.3mf"))
+            possible_keys.append((printer_id, fname))
+
+        # Also try full path versions
+        if filename.endswith(".3mf"):
+            possible_keys.append((printer_id, filename))
+        elif filename.endswith(".gcode"):
+            base_name = filename.rsplit(".", 1)[0]
+            possible_keys.append((printer_id, f"{base_name}.3mf"))
+            possible_keys.append((printer_id, filename))
+        else:
+            possible_keys.append((printer_id, f"{filename}.3mf"))
+            possible_keys.append((printer_id, filename))
 
     # Find the archive for this print
+    logger.info(f"Looking for archive in _active_prints, keys to try: {possible_keys[:5]}...")
+    logger.info(f"Current _active_prints: {list(_active_prints.keys())}")
     archive_id = None
     for key in possible_keys:
         archive_id = _active_prints.pop(key, None)
         if archive_id:
+            logger.info(f"Found archive {archive_id} with key {key}")
             # Also clean up any other keys pointing to this archive
             keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]
             for k in keys_to_remove:
@@ -417,24 +482,44 @@ async def on_print_complete(printer_id: int, data: dict):
             break
 
     if not archive_id:
-        # Try to find by filename if not tracked (for prints started before app)
+        # Try to find by filename or subtask_name if not tracked (for prints started before app)
         async with async_session() as db:
             from backend.app.models.archive import PrintArchive
-            from sqlalchemy import select
 
-            result = await db.execute(
-                select(PrintArchive)
-                .where(PrintArchive.printer_id == printer_id)
-                .where(PrintArchive.filename == filename)
-                .where(PrintArchive.status == "printing")
-                .order_by(PrintArchive.created_at.desc())
-                .limit(1)
-            )
-            archive = result.scalar_one_or_none()
-            if archive:
-                archive_id = archive.id
+            # Try matching by subtask_name (stored as print_name) first
+            if subtask_name:
+                result = await db.execute(
+                    select(PrintArchive)
+                    .where(PrintArchive.printer_id == printer_id)
+                    .where(PrintArchive.status == "printing")
+                    .where(or_(
+                        PrintArchive.print_name.ilike(f"%{subtask_name}%"),
+                        PrintArchive.filename.ilike(f"%{subtask_name}%"),
+                    ))
+                    .order_by(PrintArchive.created_at.desc())
+                    .limit(1)
+                )
+                archive = result.scalar_one_or_none()
+                if archive:
+                    archive_id = archive.id
+                    logger.info(f"Found archive {archive_id} by subtask_name match: {subtask_name}")
+
+            # Also try by filename
+            if not archive_id and filename:
+                result = await db.execute(
+                    select(PrintArchive)
+                    .where(PrintArchive.printer_id == printer_id)
+                    .where(PrintArchive.filename == filename)
+                    .where(PrintArchive.status == "printing")
+                    .order_by(PrintArchive.created_at.desc())
+                    .limit(1)
+                )
+                archive = result.scalar_one_or_none()
+                if archive:
+                    archive_id = archive.id
 
     if not archive_id:
+        logger.warning(f"Could not find archive for print complete: filename={filename}, subtask={subtask_name}")
         return
 
     # Update archive status
@@ -455,9 +540,10 @@ async def on_print_complete(printer_id: int, data: dict):
     # Calculate energy used for this print
     try:
         starting_kwh = _print_energy_start.pop(archive_id, None)
+        logger.info(f"[ENERGY] Print complete for archive {archive_id}, starting_kwh={starting_kwh}, tracked_archives={list(_print_energy_start.keys())}")
         if starting_kwh is not None:
             async with async_session() as db:
-                # Get smart plug for this printer
+                # Get smart plug for this printer (SmartPlug is imported at module level)
                 plug_result = await db.execute(
                     select(SmartPlug).where(SmartPlug.printer_id == printer_id)
                 )
@@ -465,9 +551,11 @@ async def on_print_complete(printer_id: int, data: dict):
 
                 if plug:
                     energy = await tasmota_service.get_energy(plug)
+                    logger.info(f"[ENERGY] Print complete - ending energy response: {energy}")
                     if energy and energy.get("total") is not None:
                         ending_kwh = energy["total"]
                         energy_used = round(ending_kwh - starting_kwh, 4)
+                        logger.info(f"[ENERGY] Calculated: ending={ending_kwh}, starting={starting_kwh}, used={energy_used}")
 
                         # Get energy cost per kWh from settings (default to 0.15)
                         from backend.app.api.routes.settings import get_setting
@@ -485,21 +573,28 @@ async def on_print_complete(printer_id: int, data: dict):
                             archive.energy_kwh = energy_used
                             archive.energy_cost = energy_cost
                             await db.commit()
-                            logger.info(f"Recorded energy for archive {archive_id}: {energy_used} kWh (${energy_cost})")
+                            logger.info(f"[ENERGY] Saved to archive {archive_id}: {energy_used} kWh, cost={energy_cost}")
+                        else:
+                            logger.warning(f"[ENERGY] Archive {archive_id} not found when saving energy")
+                    else:
+                        logger.warning(f"[ENERGY] No 'total' in ending energy response")
+                else:
+                    logger.info(f"[ENERGY] No smart plug found for printer {printer_id} at print complete")
     except Exception as e:
         import logging
         logging.getLogger(__name__).warning(f"Failed to calculate energy: {e}")
 
     # Capture finish photo from printer camera
+    logger.info(f"[PHOTO] Starting finish photo capture for archive {archive_id}")
     try:
         async with async_session() as db:
             # Check if finish photo capture is enabled
             from backend.app.api.routes.settings import get_setting
             capture_enabled = await get_setting(db, "capture_finish_photo")
+            logger.info(f"[PHOTO] capture_finish_photo setting: {capture_enabled}")
             if capture_enabled is None or capture_enabled.lower() == "true":
                 # Get printer details
                 from backend.app.models.printer import Printer
-                from sqlalchemy import select
                 result = await db.execute(
                     select(Printer).where(Printer.id == printer_id)
                 )
@@ -538,10 +633,12 @@ async def on_print_complete(printer_id: int, data: dict):
         logging.getLogger(__name__).warning(f"Finish photo capture failed: {e}")
 
     # Smart plug automation: schedule turn off when print completes
+    logger.info(f"[AUTO-OFF] Calling smart_plug_manager.on_print_complete for printer {printer_id}")
     try:
         async with async_session() as db:
             status = data.get("status", "completed")
             await smart_plug_manager.on_print_complete(printer_id, status, db)
+            logger.info(f"[AUTO-OFF] smart_plug_manager.on_print_complete completed")
     except Exception as e:
         import logging
         logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
@@ -550,8 +647,9 @@ async def on_print_complete(printer_id: int, data: dict):
     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
+            # Note: SmartPlug is already imported at module level (line 56)
+            # Do NOT import it here as it would shadow the module-level import
+            # and cause "cannot access local variable" errors earlier in this function
 
             result = await db.execute(
                 select(PrintQueueItem)

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

@@ -36,6 +36,7 @@ class SmartPlug(Base):
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False)  # True when auto-off was triggered
 
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

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

@@ -39,6 +39,7 @@ class SmartPlugResponse(SmartPlugBase):
     id: int
     last_state: str | None = None
     last_checked: datetime | None = None
+    auto_off_executed: bool = False  # True when auto-off was triggered after print
     created_at: datetime
     updated_at: datetime
 

+ 22 - 12
backend/app/services/archive.py

@@ -32,12 +32,12 @@ class ThreeMFParser:
                 self._parse_3dmodel(zf)
                 self._extract_thumbnail(zf)
 
-                # Prefer slice_info for colors (shows ALL filaments actually used in print)
-                # project_settings may filter out "support" filaments incorrectly
+                # ALWAYS prefer slice_info values - they contain ONLY filaments actually used in print
+                # project_settings contains ALL configured filaments (AMS slots), not just used ones
+                if self.metadata.get("_slice_filament_type"):
+                    self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
                 if self.metadata.get("_slice_filament_color"):
                     self.metadata["filament_color"] = self.metadata["_slice_filament_color"]
-                if not self.metadata.get("filament_type") and self.metadata.get("_slice_filament_type"):
-                    self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
 
                 # Clean up internal keys
                 self.metadata.pop("_slice_filament_type", None)
@@ -65,20 +65,30 @@ class ThreeMFParser:
                         elif key == "weight" and value:
                             self.metadata["filament_used_grams"] = float(value)
 
-                # Get filament info from ALL filaments actually used in the print
+                # Get filament info from filaments ACTUALLY USED in the print
                 # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
+                # Only include filaments where used_g > 0
                 filaments = root.findall(".//filament")
                 if filaments:
-                    # Collect all unique filament types and colors
+                    # Collect unique filament types and colors for filaments that are actually used
                     types = []
                     colors = []
                     for f in filaments:
-                        ftype = f.get("type")
-                        fcolor = f.get("color")
-                        if ftype and ftype not in types:
-                            types.append(ftype)
-                        if fcolor and fcolor not in colors:
-                            colors.append(fcolor)
+                        # Check if this filament is actually used in the print
+                        used_g = f.get("used_g", "0")
+                        try:
+                            used_amount = float(used_g)
+                        except (ValueError, TypeError):
+                            used_amount = 0
+
+                        # Only include if used_g > 0 (filament is actually consumed)
+                        if used_amount > 0:
+                            ftype = f.get("type")
+                            fcolor = f.get("color")
+                            if ftype and ftype not in types:
+                                types.append(ftype)
+                            if fcolor and fcolor not in colors:
+                                colors.append(fcolor)
 
                     if types:
                         self.metadata["_slice_filament_type"] = ", ".join(types)

+ 32 - 2
backend/app/services/bambu_ftp.py

@@ -106,11 +106,41 @@ class BambuFTPClient:
                     name = " ".join(parts[8:])
                     is_dir = item.startswith("d")
                     size = int(parts[4]) if not is_dir else 0
-                    files.append({
+
+                    # Parse modification time from FTP listing
+                    # Format: "Nov 30 10:15" or "Nov 30  2024"
+                    mtime = None
+                    try:
+                        from datetime import datetime
+                        month = parts[5]
+                        day = parts[6]
+                        time_or_year = parts[7]
+
+                        # Determine if it's time (HH:MM) or year
+                        if ":" in time_or_year:
+                            # Recent file: "Nov 30 10:15" - assume current year
+                            year = datetime.now().year
+                            time_str = f"{month} {day} {year} {time_or_year}"
+                            mtime = datetime.strptime(time_str, "%b %d %Y %H:%M")
+                            # If parsed date is in the future, use last year
+                            if mtime > datetime.now():
+                                mtime = mtime.replace(year=year - 1)
+                        else:
+                            # Older file: "Nov 30 2024" - no time, just date
+                            time_str = f"{month} {day} {time_or_year}"
+                            mtime = datetime.strptime(time_str, "%b %d %Y")
+                    except (ValueError, IndexError):
+                        pass
+
+                    file_entry = {
                         "name": name,
                         "is_directory": is_dir,
                         "size": size,
-                    })
+                        "path": f"{path.rstrip('/')}/{name}",
+                    }
+                    if mtime:
+                        file_entry["mtime"] = mtime
+                    files.append(file_entry)
         except Exception:
             pass
 

+ 35 - 3
backend/app/services/bambu_mqtt.py

@@ -74,6 +74,8 @@ class BambuMQTTClient:
         self._loop: asyncio.AbstractEventLoop | None = None
         self._previous_gcode_state: str | None = None
         self._previous_gcode_file: str | None = None
+        self._was_running: bool = False  # Track if we've seen RUNNING state for current print
+        self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
@@ -251,9 +253,19 @@ class BambuMQTTClient:
             and self._previous_gcode_file is not None
         )
 
+        # Track RUNNING state for more robust completion detection
+        if self.state.state == "RUNNING" and current_file:
+            if not self._was_running:
+                logger.info(f"[{self.serial_number}] Now tracking RUNNING state for {current_file}")
+            self._was_running = True
+            self._completion_triggered = False
+
         if is_new_print or is_file_change:
             # Clear any old HMS errors when a new print starts
             self.state.hms_errors = []
+            # Reset completion tracking for new print
+            self._was_running = True
+            self._completion_triggered = False
 
         if (is_new_print or is_file_change) and self.on_print_start:
             logger.info(
@@ -267,11 +279,27 @@ class BambuMQTTClient:
             })
 
         # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
+        # Use _was_running flag in addition to _previous_gcode_state for more robust detection
+        # This handles cases where server restarts during a print
+        should_trigger_completion = (
+            self.state.state in ("FINISH", "FAILED")
+            and not self._completion_triggered
+            and self.on_print_complete
+            and (
+                self._previous_gcode_state == "RUNNING"  # Normal transition
+                or (self._was_running and self._previous_gcode_state != self.state.state)  # After server restart
+            )
+        )
+        # For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)
         if (
-            self._previous_gcode_state == "RUNNING"
-            and self.state.state in ("FINISH", "FAILED", "IDLE")
+            self.state.state == "IDLE"
+            and self._previous_gcode_state == "RUNNING"
+            and not self._completion_triggered
             and self.on_print_complete
         ):
+            should_trigger_completion = True
+
+        if should_trigger_completion:
             if self.state.state == "FINISH":
                 status = "completed"
             elif self.state.state == "FAILED":
@@ -280,11 +308,15 @@ class BambuMQTTClient:
                 status = "aborted"
             logger.info(
                 f"[{self.serial_number}] PRINT COMPLETE detected - state: {self.state.state}, "
-                f"status: {status}, file: {self._previous_gcode_file or current_file}"
+                f"status: {status}, file: {self._previous_gcode_file or current_file}, "
+                f"subtask: {self.state.subtask_name}, was_running: {self._was_running}"
             )
+            self._completion_triggered = True
+            self._was_running = False
             self.on_print_complete({
                 "status": status,
                 "filename": self._previous_gcode_file or current_file,
+                "subtask_name": self.state.subtask_name,
                 "raw_data": data,
             })
 

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

@@ -112,6 +112,25 @@ class PrinterManager:
             return self._clients[printer_id].state.connected
         return False
 
+    def mark_printer_offline(self, printer_id: int):
+        """Mark a printer as offline and trigger status callback.
+
+        This is used when we know the printer power was cut (e.g., smart plug turned off)
+        to immediately update the UI without waiting for MQTT timeout.
+        """
+        import logging
+        logger = logging.getLogger(__name__)
+
+        if printer_id in self._clients:
+            client = self._clients[printer_id]
+            if client.state.connected:
+                logger.info(f"Marking printer {printer_id} as offline (smart plug power off)")
+                client.state.connected = False
+                client.state.state = "unknown"
+                # Trigger the status change callback to broadcast via WebSocket
+                if self._on_status_change:
+                    self._schedule_async(self._on_status_change(printer_id, client.state))
+
     def start_print(self, printer_id: int, filename: str) -> bool:
         """Start a print on a connected printer."""
         if printer_id in self._clients:

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

@@ -62,9 +62,10 @@ class SmartPlugManager:
         success = await tasmota_service.turn_on(plug)
 
         if success:
-            # Update last state
+            # Update last state and reset auto_off_executed
             plug.last_state = "ON"
             plug.last_checked = datetime.utcnow()
+            plug.auto_off_executed = False  # Reset flag when turning on
             await db.commit()
 
     async def on_print_complete(
@@ -103,11 +104,11 @@ class SmartPlugManager:
         )
 
         if plug.off_delay_mode == "time":
-            self._schedule_delayed_off(plug, plug.off_delay_minutes * 60)
+            self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
         elif plug.off_delay_mode == "temperature":
             self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
 
-    def _schedule_delayed_off(self, plug: "SmartPlug", delay_seconds: int):
+    def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
         """Schedule turn-off after delay."""
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
@@ -117,7 +118,7 @@ class SmartPlugManager:
         )
 
         task = asyncio.create_task(
-            self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, delay_seconds)
+            self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, printer_id, delay_seconds)
         )
         self._pending_off[plug.id] = task
 
@@ -127,6 +128,7 @@ class SmartPlugManager:
         ip_address: str,
         username: str | None,
         password: str | None,
+        printer_id: int,
         delay_seconds: int,
     ):
         """Wait and turn off."""
@@ -142,9 +144,15 @@ class SmartPlugManager:
                     self.name = f"plug_{plug_id}"
 
             plug_info = PlugInfo()
-            await tasmota_service.turn_off(plug_info)
+            success = await tasmota_service.turn_off(plug_info)
             logger.info(f"Turned off plug {plug_id} after time delay")
 
+            # Mark auto_off_executed in database and update printer status
+            if success:
+                await self._mark_auto_off_executed(plug_id)
+                # Mark the printer as offline immediately
+                printer_manager.mark_printer_offline(printer_id)
+
         except asyncio.CancelledError:
             logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
         finally:
@@ -226,11 +234,18 @@ class SmartPlugManager:
                                 self.name = f"plug_{plug_id}"
 
                         plug_info = PlugInfo()
-                        await tasmota_service.turn_off(plug_info)
+                        success = await tasmota_service.turn_off(plug_info)
                         logger.info(
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
                             f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
                         )
+
+                        # Mark auto_off_executed in database and update printer status
+                        if success:
+                            await self._mark_auto_off_executed(plug_id)
+                            # Mark the printer as offline immediately
+                            printer_manager.mark_printer_offline(printer_id)
+
                         break
 
                 await asyncio.sleep(check_interval)
@@ -246,6 +261,27 @@ class SmartPlugManager:
         finally:
             self._pending_off.pop(plug_id, None)
 
+    async def _mark_auto_off_executed(self, plug_id: int):
+        """Disable auto-off after it was executed (one-shot behavior)."""
+        try:
+            from backend.app.core.database import async_session
+            from backend.app.models.smart_plug import SmartPlug
+
+            async with async_session() as db:
+                result = await db.execute(
+                    select(SmartPlug).where(SmartPlug.id == plug_id)
+                )
+                plug = result.scalar_one_or_none()
+                if plug:
+                    plug.auto_off = False  # Disable auto-off (one-shot behavior)
+                    plug.auto_off_executed = False  # Reset the flag
+                    plug.last_state = "OFF"
+                    plug.last_checked = datetime.utcnow()
+                    await db.commit()
+                    logger.info(f"Auto-off executed and disabled for plug {plug_id}")
+        except Exception as e:
+            logger.warning(f"Failed to update plug {plug_id} after auto-off: {e}")
+
     def _cancel_pending_off(self, plug_id: int):
         """Cancel any pending off task for this plug."""
         if plug_id in self._pending_off:

+ 12 - 1
frontend/src/api/client.ts

@@ -205,6 +205,7 @@ export interface SmartPlug {
   password: string | null;
   last_state: string | null;
   last_checked: string | null;
+  auto_off_executed: boolean;  // True when auto-off was triggered after print
   created_at: string;
   updated_at: string;
 }
@@ -412,9 +413,19 @@ export const api = {
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse`,
   scanArchiveTimelapse: (id: number) =>
-    request<{ status: string; message: string; filename?: string }>(`/archives/${id}/timelapse/scan`, {
+    request<{
+      status: string;
+      message: string;
+      filename?: string;
+      available_files?: Array<{ name: string; path: string; size: number; mtime: string | null }>;
+    }>(`/archives/${id}/timelapse/scan`, {
       method: 'POST',
     }),
+  selectArchiveTimelapse: (id: number, filename: string) =>
+    request<{ status: string; message: string; filename: string }>(
+      `/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,
+      { method: 'POST' }
+    ),
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);

+ 10 - 1
frontend/src/components/BatchTagModal.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Tag, Plus, Loader2 } from 'lucide-react';
 import { api } from '../api/client';
@@ -19,6 +19,15 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
   const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
   const [mode, setMode] = useState<'add' | 'remove'>('add');
 
+  // 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 batchTagMutation = useMutation({
     mutationFn: async () => {
       const tagsArray = Array.from(selectedTags);

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

@@ -1,3 +1,4 @@
+import { useEffect } from 'react';
 import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
 import type { HMSError } from '../api/client';
 
@@ -49,6 +50,15 @@ function getHMSWikiUrl(code: string): string {
 }
 
 export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
   return (
     <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
       <div className="bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">

+ 10 - 0
frontend/src/components/KeyboardShortcutsModal.tsx

@@ -1,3 +1,4 @@
+import { useEffect } from 'react';
 import { X, Keyboard } from 'lucide-react';
 import { Card, CardContent } from './Card';
 
@@ -52,6 +53,15 @@ function KeyBadge({ children }: { children: string }) {
 export function KeyboardShortcutsModal({ onClose, navItems }: KeyboardShortcutsModalProps) {
   const shortcuts = getShortcuts(navItems);
 
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
   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()}>

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

@@ -239,7 +239,7 @@ export function Layout() {
         <div className="p-2 border-t border-bambu-dark-tertiary">
           {sidebarExpanded ? (
             <div className="flex items-center justify-between px-2">
-              <span className="text-sm text-bambu-gray">v0.1.2</span>
+              <span className="text-sm text-bambu-gray">v0.1.3</span>
               <div className="flex items-center gap-1">
                 <a
                   href="https://github.com/maziggy/bambusy"

+ 9 - 0
frontend/src/components/MQTTDebugModal.tsx

@@ -43,6 +43,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
     },
   });
 
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
   // Auto-scroll to bottom when new logs arrive
   useEffect(() => {
     if (autoScroll && logContainerRef.current) {

+ 10 - 0
frontend/src/components/QRCodeModal.tsx

@@ -1,3 +1,4 @@
+import { useEffect } from 'react';
 import { X, Download } from 'lucide-react';
 import { Button } from './Button';
 import { api } from '../api/client';
@@ -11,6 +12,15 @@ interface QRCodeModalProps {
 export function QRCodeModal({ archiveId, archiveName, onClose }: QRCodeModalProps) {
   const qrCodeUrl = api.getArchiveQRCodeUrl(archiveId, 300);
 
+  // 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 handleDownload = () => {
     const link = document.createElement('a');
     link.href = qrCodeUrl;

+ 10 - 1
frontend/src/components/ReprintModal.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { X, Printer, Loader2 } from 'lucide-react';
 import { api } from '../api/client';
@@ -15,6 +15,15 @@ interface ReprintModalProps {
 export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
 
+  // 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 { data: printers, isLoading: loadingPrinters } = useQuery({
     queryKey: ['printers'],
     queryFn: api.getPrinters,

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

@@ -47,6 +47,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
+      if (plug.printer_id) {
+        queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
+      }
     },
   });
 
@@ -181,7 +185,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
               <div className="flex items-center justify-between">
                 <div>
                   <p className="text-sm text-white">Auto Off</p>
-                  <p className="text-xs text-bambu-gray">Turn off when print completes</p>
+                  <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input

+ 10 - 1
frontend/src/components/UploadModal.tsx

@@ -1,4 +1,4 @@
-import { useState, useCallback, useRef } from 'react';
+import { useState, useCallback, useRef, useEffect } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
 import { api } from '../api/client';
@@ -30,6 +30,15 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
   const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
   const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
 
+  // 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 { data: printers } = useQuery({
     queryKey: ['printers'],
     queryFn: api.getPrinters,

+ 75 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -97,6 +97,8 @@ function ArchiveCard({
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
+  const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
+  const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
   const [showQRCode, setShowQRCode] = useState(false);
   const [showPhotos, setShowPhotos] = useState(false);
   const [showProjectPage, setShowProjectPage] = useState(false);
@@ -111,6 +113,10 @@ function ArchiveCard({
         showToast(`Timelapse attached: ${data.filename}`);
       } else if (data.status === 'exists') {
         showToast('Timelapse already attached');
+      } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {
+        // Show selection dialog
+        setAvailableTimelapses(data.available_files);
+        setShowTimelapseSelect(true);
       } else {
         showToast(data.message || 'No matching timelapse found', 'warning');
       }
@@ -120,6 +126,19 @@ function ArchiveCard({
     },
   });
 
+  const timelapseSelectMutation = useMutation({
+    mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Timelapse attached: ${data.filename}`);
+      setShowTimelapseSelect(false);
+      setAvailableTimelapses([]);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to attach timelapse', 'error');
+    },
+  });
+
   const deleteMutation = useMutation({
     mutationFn: () => api.deleteArchive(archive.id),
     onSuccess: () => {
@@ -584,6 +603,62 @@ function ArchiveCard({
         />
       )}
 
+      {/* Timelapse Selection Modal */}
+      {showTimelapseSelect && availableTimelapses.length > 0 && (
+        <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
+          <div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
+            <div className="flex items-center justify-between p-4 border-b border-gray-700">
+              <div>
+                <h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
+                <p className="text-sm text-gray-400 mt-1">
+                  No auto-match found. Select the timelapse for this print:
+                </p>
+              </div>
+              <button
+                onClick={() => {
+                  setShowTimelapseSelect(false);
+                  setAvailableTimelapses([]);
+                }}
+                className="text-gray-400 hover:text-white p-1"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+            <div className="overflow-y-auto flex-1 p-2">
+              {availableTimelapses.map((file) => (
+                <button
+                  key={file.name}
+                  onClick={() => timelapseSelectMutation.mutate(file.name)}
+                  disabled={timelapseSelectMutation.isPending}
+                  className="w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-3 disabled:opacity-50"
+                >
+                  <Film className="w-8 h-8 text-bambu-green flex-shrink-0" />
+                  <div className="flex-1 min-w-0">
+                    <p className="text-white font-medium truncate">{file.name}</p>
+                    <p className="text-sm text-gray-400">
+                      {formatFileSize(file.size)}
+                      {file.mtime && ` • ${formatDate(file.mtime)}`}
+                    </p>
+                  </div>
+                </button>
+              ))}
+            </div>
+            <div className="p-4 border-t border-gray-700">
+              <Button
+                variant="secondary"
+                onClick={() => {
+                  setShowTimelapseSelect(false);
+                  setAvailableTimelapses([]);
+                }}
+                className="w-full"
+              >
+                Cancel
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+
       {/* QR Code Modal */}
       {showQRCode && (
         <QRCodeModal

+ 11 - 5
frontend/src/pages/PrintersPage.tsx

@@ -143,6 +143,8 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
       smartPlug ? api.updateSmartPlug(smartPlug.id, { auto_off: enabled }) : Promise.reject('No plug'),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', printer.id] });
+      // Also invalidate the smart-plugs list to keep Settings page in sync
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
     },
   });
 
@@ -413,18 +415,22 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
 
               {/* Auto-off toggle */}
               <div className="flex items-center gap-2 flex-shrink-0">
-                <span className="text-xs text-bambu-gray hidden sm:inline">Auto-off</span>
+                <span className={`text-xs hidden sm:inline ${smartPlug.auto_off_executed ? 'text-bambu-green' : 'text-bambu-gray'}`}>
+                  {smartPlug.auto_off_executed ? 'Auto-off done' : 'Auto-off'}
+                </span>
                 <button
                   onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
-                  disabled={toggleAutoOffMutation.isPending}
-                  title="Auto power-off after print"
+                  disabled={toggleAutoOffMutation.isPending || smartPlug.auto_off_executed}
+                  title={smartPlug.auto_off_executed ? 'Auto-off was executed - turn printer on to reset' : 'Auto power-off after print'}
                   className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
-                    smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    smartPlug.auto_off_executed
+                      ? 'bg-bambu-green/50 cursor-not-allowed'
+                      : smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
                   }`}
                 >
                   <span
                     className={`absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full transition-transform ${
-                      smartPlug.auto_off ? 'translate-x-4' : 'translate-x-0'
+                      smartPlug.auto_off || smartPlug.auto_off_executed ? 'translate-x-4' : 'translate-x-0'
                     }`}
                   />
                 </button>

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


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


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


+ 2 - 2
static/index.html

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

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