Added configurable logging; Major improvements and bugfixes

This commit is contained in:
Martin Ziegler
2025-11-30 12:55:00 +01:00
parent 2cc7539738
commit 28a5e786e9
31 changed files with 1115 additions and 501 deletions
+12
View File
@@ -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
View File
@@ -41,3 +41,4 @@ archive/
# Logs
*.log
logs/
+58
View File
@@ -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
View File
@@ -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,
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+148 -50
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
@@ -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
View File
@@ -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"
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>