Files
bambuddy/backend/app/main.py
T
2025-11-28 10:23:59 +01:00

340 lines
12 KiB
Python

import asyncio
from datetime import datetime
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from backend.app.core.config import settings as app_settings
from backend.app.core.database import init_db, async_session
from backend.app.core.websocket import ws_manager
from backend.app.api.routes import printers, archives, websocket, filaments, cloud
from backend.app.api.routes import settings as settings_routes
from backend.app.services.printer_manager import (
printer_manager,
printer_state_to_dict,
init_printer_connections,
)
from backend.app.services.bambu_mqtt import PrinterState
from backend.app.services.archive import ArchiveService
from backend.app.services.bambu_ftp import download_file_async
# Track active prints: {(printer_id, filename): archive_id}
_active_prints: dict[tuple[int, str], int] = {}
async def on_printer_status_change(printer_id: int, state: PrinterState):
"""Handle printer status changes - broadcast via WebSocket."""
await ws_manager.send_printer_status(
printer_id,
printer_state_to_dict(state, printer_id),
)
async def on_print_start(printer_id: int, data: dict):
"""Handle print start - archive the 3MF file immediately."""
import logging
logger = logging.getLogger(__name__)
await ws_manager.send_print_start(printer_id, data)
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)
)
printer = result.scalar_one_or_none()
if not printer or not printer.auto_archive:
return
# Get the filename and subtask_name
filename = data.get("filename", "")
subtask_name = data.get("subtask_name", "")
logger.info(f"Print start detected - filename: {filename}, subtask: {subtask_name}")
if not filename and not subtask_name:
return
# Build list of possible 3MF filenames to try
possible_names = []
# Try original filename with .3mf extension
if filename:
if filename.endswith(".3mf"):
possible_names.append(filename)
elif filename.endswith(".gcode"):
base = filename.rsplit(".", 1)[0]
possible_names.append(f"{base}.3mf")
else:
# No extension - try adding .3mf
possible_names.append(f"{filename}.3mf")
possible_names.append(filename)
# Try subtask_name with .3mf extension
if subtask_name and subtask_name != filename:
possible_names.append(f"{subtask_name}.3mf")
# Remove duplicates while preserving order
seen = set()
possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]
logger.info(f"Trying filenames: {possible_names}")
# Try to find and download the 3MF file
temp_path = None
downloaded_filename = None
for try_filename in possible_names:
if not try_filename.endswith(".3mf"):
continue
remote_paths = [
f"/cache/{try_filename}",
f"/model/{try_filename}",
f"/{try_filename}",
]
temp_path = app_settings.archive_dir / "temp" / try_filename
temp_path.parent.mkdir(parents=True, exist_ok=True)
for remote_path in remote_paths:
if await download_file_async(
printer.ip_address,
printer.access_code,
remote_path,
temp_path,
):
downloaded_filename = try_filename
logger.info(f"Downloaded: {remote_path}")
break
if downloaded_filename:
break
# If still not found, try listing /cache to find matching file
if not downloaded_filename and (filename or subtask_name):
search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
try:
cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
for f in cache_files:
if f.get("is_directory"):
continue
fname = f.get("name", "")
if fname.endswith(".3mf") and search_term in fname.lower():
temp_path = app_settings.archive_dir / "temp" / fname
temp_path.parent.mkdir(parents=True, exist_ok=True)
if await download_file_async(
printer.ip_address,
printer.access_code,
f"/cache/{fname}",
temp_path,
):
downloaded_filename = fname
logger.info(f"Found and downloaded from cache: {fname}")
break
except Exception as e:
logger.warning(f"Failed to list cache: {e}")
if not downloaded_filename or not temp_path:
logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
return
try:
# Archive the file with status "printing"
service = ArchiveService(db)
archive = await service.archive_print(
printer_id=printer_id,
source_file=temp_path,
print_data={**data, "status": "printing"},
)
if archive:
# Track this active print (use both original filename and downloaded filename)
_active_prints[(printer_id, downloaded_filename)] = archive.id
if filename and filename != downloaded_filename:
_active_prints[(printer_id, filename)] = archive.id
if subtask_name:
_active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
logger.info(f"Created archive {archive.id} for {downloaded_filename}")
await ws_manager.send_archive_created({
"id": archive.id,
"printer_id": archive.printer_id,
"filename": archive.filename,
"print_name": archive.print_name,
"status": archive.status,
})
finally:
if temp_path and temp_path.exists():
temp_path.unlink()
async def on_print_complete(printer_id: int, data: dict):
"""Handle print completion - update the archive status."""
import logging
logger = logging.getLogger(__name__)
await ws_manager.send_print_complete(printer_id, data)
filename = data.get("filename", "")
if not filename:
return
logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}")
# Build list of possible keys to try
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))
# Find the archive for this print
archive_id = None
for key in possible_keys:
archive_id = _active_prints.pop(key, None)
if archive_id:
# 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:
_active_prints.pop(k, None)
break
if not archive_id:
# Try to find by filename 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
if not archive_id:
return
# Update archive status
async with async_session() as db:
service = ArchiveService(db)
status = data.get("status", "completed")
await service.update_archive_status(
archive_id,
status=status,
completed_at=datetime.now() if status in ("completed", "failed") else None,
)
await ws_manager.send_archive_updated({
"id": archive_id,
"status": status,
})
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
await init_db()
# Set up printer manager callbacks
loop = asyncio.get_event_loop()
printer_manager.set_event_loop(loop)
printer_manager.set_status_change_callback(on_printer_status_change)
printer_manager.set_print_start_callback(on_print_start)
printer_manager.set_print_complete_callback(on_print_complete)
# Connect to all active printers
async with async_session() as db:
await init_printer_connections(db)
yield
# Shutdown
printer_manager.disconnect_all()
app = FastAPI(
title=app_settings.app_name,
description="Archive and manage Bambu Lab 3MF files",
version="0.1.1",
lifespan=lifespan,
)
# API routes
app.include_router(printers.router, prefix=app_settings.api_prefix)
app.include_router(archives.router, prefix=app_settings.api_prefix)
app.include_router(filaments.router, prefix=app_settings.api_prefix)
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
app.include_router(cloud.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
# Serve static files (React build)
if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
app.mount(
"/assets",
StaticFiles(directory=app_settings.static_dir / "assets"),
name="assets",
)
if (app_settings.static_dir / "img").exists():
app.mount(
"/img",
StaticFiles(directory=app_settings.static_dir / "img"),
name="img",
)
@app.get("/")
async def serve_frontend():
"""Serve the React frontend."""
index_file = app_settings.static_dir / "index.html"
if index_file.exists():
return FileResponse(index_file)
return {
"message": "BambuTrack API",
"docs": "/docs",
"frontend": "Build and place React app in /static directory",
}
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}
# Catch-all route for React Router (must be last)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve React app for client-side routing."""
# Don't intercept API routes
if full_path.startswith("api/"):
return {"error": "Not found"}
index_file = app_settings.static_dir / "index.html"
if index_file.exists():
return FileResponse(index_file)
return {"error": "Frontend not built"}