diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..3d4ed67dd --- /dev/null +++ b/.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 diff --git a/.gitignore b/.gitignore index 2038d89aa..8049ee9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ archive/ # Logs *.log +logs/ diff --git a/README.md b/README.md index f91e4a03e..e4626b620 100644 --- a/README.md +++ b/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 diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 6068db647..92c2eab44 100644 --- a/backend/app/api/routes/archives.py +++ b/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: If only one timelapse exists and archive was recently completed, use it + # 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 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, diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 1f82fabc5..0d134d90c 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index aa8fa3ad2..3837532b6 100644 --- a/backend/app/core/config.py +++ b/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) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 23304286f..a51b9806f 100644 --- a/backend/app/core/database.py +++ b/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 diff --git a/backend/app/main.py b/backend/app/main.py index ab7ac0d03..8b7651816 100644 --- a/backend/app/main.py +++ b/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) +# 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}") -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) diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index c1c43fcd7..d5d007b83 100644 --- a/backend/app/models/smart_plug.py +++ b/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()) diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index a45867919..d1e5b6e0c 100644 --- a/backend/app/schemas/smart_plug.py +++ b/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 diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index b7947c576..2ce245a8c 100644 --- a/backend/app/services/archive.py +++ b/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 + # 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) diff --git a/backend/app/services/bambu_ftp.py b/backend/app/services/bambu_ftp.py index 8549ecb1f..0fe81a247 100644 --- a/backend/app/services/bambu_ftp.py +++ b/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 diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 2f0ffb0e7..c8b14aafe 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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, }) diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 37bae4d6e..16fd42ed8 100644 --- a/backend/app/services/printer_manager.py +++ b/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: diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index e767ed78d..bb5cfba95 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/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: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f19ca822d..5c3765452 100644 --- a/frontend/src/api/client.ts +++ b/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); diff --git a/frontend/src/components/BatchTagModal.tsx b/frontend/src/components/BatchTagModal.tsx index 0dc9f6d97..e2f540d6f 100644 --- a/frontend/src/components/BatchTagModal.tsx +++ b/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>(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); diff --git a/frontend/src/components/HMSErrorModal.tsx b/frontend/src/components/HMSErrorModal.tsx index 35d315159..f2ce06ecb 100644 --- a/frontend/src/components/HMSErrorModal.tsx +++ b/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 (
diff --git a/frontend/src/components/KeyboardShortcutsModal.tsx b/frontend/src/components/KeyboardShortcutsModal.tsx index 85f0852cf..9d0b9e897 100644 --- a/frontend/src/components/KeyboardShortcutsModal.tsx +++ b/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 (
e.stopPropagation()}> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d83c2fdbf..0c659c248 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -239,7 +239,7 @@ export function Layout() {
{sidebarExpanded ? (
- v0.1.2 + v0.1.3
{ + 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) { diff --git a/frontend/src/components/QRCodeModal.tsx b/frontend/src/components/QRCodeModal.tsx index 1a013a18b..adc2dd965 100644 --- a/frontend/src/components/QRCodeModal.tsx +++ b/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; diff --git a/frontend/src/components/ReprintModal.tsx b/frontend/src/components/ReprintModal.tsx index 2927b661e..abe56aad4 100644 --- a/frontend/src/components/ReprintModal.tsx +++ b/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(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, diff --git a/frontend/src/components/SmartPlugCard.tsx b/frontend/src/components/SmartPlugCard.tsx index 9a200121f..b671e2335 100644 --- a/frontend/src/components/SmartPlugCard.tsx +++ b/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) {

Auto Off

-

Turn off when print completes

+

Turn off when print completes (one-shot)