diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index deec190ee..ad1464642 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -1,22 +1,21 @@ -from pathlib import Path -import zipfile import io import logging +import zipfile +from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query - -logger = logging.getLogger(__name__) +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse, Response +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func from backend.app.core.config import settings from backend.app.core.database import get_db from backend.app.models.archive import PrintArchive from backend.app.models.filament import Filament -from backend.app.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats +from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate from backend.app.services.archive import ArchiveService +logger = logging.getLogger(__name__) router = APIRouter(prefix="/archives", tags=["archives"]) @@ -145,7 +144,7 @@ async def search_archives( # Prepare search query - add wildcard for partial matches search_term = q.strip() - if not search_term.endswith('*'): + if not search_term.endswith("*"): search_term = f"{search_term}*" # Build the FTS query @@ -168,12 +167,12 @@ async def search_archives( select(PrintArchive) .options(selectinload(PrintArchive.project)) .where( - (PrintArchive.print_name.ilike(like_pattern)) | - (PrintArchive.filename.ilike(like_pattern)) | - (PrintArchive.tags.ilike(like_pattern)) | - (PrintArchive.notes.ilike(like_pattern)) | - (PrintArchive.designer.ilike(like_pattern)) | - (PrintArchive.filament_type.ilike(like_pattern)) + (PrintArchive.print_name.ilike(like_pattern)) + | (PrintArchive.filename.ilike(like_pattern)) + | (PrintArchive.tags.ilike(like_pattern)) + | (PrintArchive.notes.ilike(like_pattern)) + | (PrintArchive.designer.ilike(like_pattern)) + | (PrintArchive.filament_type.ilike(like_pattern)) ) .order_by(PrintArchive.created_at.desc()) ) @@ -194,11 +193,7 @@ async def search_archives( return [] # Fetch full archive records for matched IDs - query = ( - select(PrintArchive) - .options(selectinload(PrintArchive.project)) - .where(PrintArchive.id.in_(matched_ids)) - ) + query = select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(matched_ids)) # Apply additional filters if printer_id: @@ -213,7 +208,7 @@ async def search_archives( # Preserve FTS ranking order and apply pagination ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict] - paginated = ordered_archives[offset:offset + limit] + paginated = ordered_archives[offset : offset + limit] return [archive_to_response(a) for a in paginated] @@ -231,11 +226,13 @@ async def rebuild_search_index(db: AsyncSession = Depends(get_db)): await db.execute(text("DELETE FROM archive_fts")) # Repopulate from print_archives - await db.execute(text(""" + await db.execute( + text(""" INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type) SELECT id, print_name, filename, tags, notes, designer, filament_type FROM print_archives - """)) + """) + ) await db.commit() @@ -325,7 +322,9 @@ async def export_archives( Returns a downloadable file with archive data. """ from datetime import datetime + from fastapi.responses import StreamingResponse + from backend.app.services.export import ExportService if format not in ("csv", "xlsx"): @@ -382,6 +381,7 @@ async def export_stats( ): """Export statistics summary to CSV or Excel format.""" from fastapi.responses import StreamingResponse + from backend.app.services.export import ExportService if format not in ("csv", "xlsx"): @@ -412,36 +412,25 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): total_result = await db.execute(select(func.count(PrintArchive.id))) total_prints = total_result.scalar() or 0 - successful_result = await db.execute( - select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed") - ) + successful_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")) successful_prints = successful_result.scalar() or 0 - failed_result = await db.execute( - select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed") - ) + failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")) failed_prints = failed_result.scalar() or 0 # Totals - time_result = await db.execute( - select(func.sum(PrintArchive.print_time_seconds)) - ) + time_result = await db.execute(select(func.sum(PrintArchive.print_time_seconds))) total_time = (time_result.scalar() or 0) / 3600 # Convert to hours - filament_result = await db.execute( - select(func.sum(PrintArchive.filament_used_grams)) - ) + filament_result = await db.execute(select(func.sum(PrintArchive.filament_used_grams))) total_filament = filament_result.scalar() or 0 - cost_result = await db.execute( - select(func.sum(PrintArchive.cost)) - ) + cost_result = await db.execute(select(func.sum(PrintArchive.cost))) total_cost = cost_result.scalar() or 0 # By filament type (split comma-separated values for multi-material prints) filament_type_result = await db.execute( - select(PrintArchive.filament_type) - .where(PrintArchive.filament_type.isnot(None)) + select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None)) ) prints_by_filament: dict[str, int] = {} for (filament_types,) in filament_type_result.all(): @@ -453,8 +442,7 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): # By printer printer_result = await db.execute( - select(PrintArchive.printer_id, func.count(PrintArchive.id)) - .group_by(PrintArchive.printer_id) + select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id) ) prints_by_printer = {str(k): v for k, v in printer_result.all()} @@ -496,6 +484,7 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): # Energy totals - check which mode to use from backend.app.api.routes.settings import get_setting + energy_tracking_mode = await get_setting(db, "energy_tracking_mode") or "total" energy_cost_per_kwh_str = await get_setting(db, "energy_cost_per_kwh") energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15 @@ -518,14 +507,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2) else: # Print mode: sum up per-print energy from archives - energy_kwh_result = await db.execute( - select(func.sum(PrintArchive.energy_kwh)) - ) + energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh))) total_energy_kwh = energy_kwh_result.scalar() or 0 - energy_cost_result = await db.execute( - select(func.sum(PrintArchive.energy_cost)) - ) + energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost))) total_energy_cost = energy_cost_result.scalar() or 0 return ArchiveStats( @@ -595,9 +580,7 @@ async def update_archive( from sqlalchemy.orm import selectinload result = await db.execute( - select(PrintArchive) - .options(selectinload(PrintArchive.project)) - .where(PrintArchive.id == archive_id) + select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id) ) archive = result.scalar_one_or_none() if not archive: @@ -610,9 +593,7 @@ async def update_archive( # Re-fetch with project relationship loaded after commit result = await db.execute( - select(PrintArchive) - .options(selectinload(PrintArchive.project)) - .where(PrintArchive.id == archive_id) + select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id) ) archive = result.scalar_one_or_none() @@ -625,9 +606,7 @@ async def toggle_favorite( db: AsyncSession = Depends(get_db), ): """Toggle favorite status for an archive.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -643,9 +622,7 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)): """Rescan the 3MF file and update metadata.""" from backend.app.services.archive import ThreeMFParser - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -683,9 +660,7 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)): # Calculate cost based on filament usage and type if archive.filament_used_grams and archive.filament_type: primary_type = archive.filament_type.split(",")[0].strip() - filament_result = await db.execute( - select(Filament).where(Filament.type == primary_type).limit(1) - ) + filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1)) filament = filament_result.scalar_one_or_none() if filament: archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2) @@ -789,9 +764,7 @@ async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get @router.post("/backfill-hashes") async def backfill_content_hashes(db: AsyncSession = Depends(get_db)): """Compute and store content hashes for all archives missing them.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.content_hash.is_(None)) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None))) archives = list(result.scalars().all()) updated = 0 @@ -913,7 +886,7 @@ async def scan_timelapse( ): """Scan printer for timelapse matching this archive and attach it.""" from backend.app.models.printer import Printer - from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async + from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async service = ArchiveService(db) archive = await service.get_archive(archive_id) @@ -975,7 +948,7 @@ async def scan_timelapse( for f in mp4_files: fname = f.get("name", "") # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4" - match = re.search(r'(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})', fname) + match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname) if match: try: file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S") @@ -1040,8 +1013,7 @@ async def scan_timelapse( best_diff = diff best_match = f logger.debug( - f"Timelapse mtime match candidate: {f.get('name')}, " - f"mtime: {mtime}, diff from end: {diff}" + f"Timelapse mtime match candidate: {f.get('name')}, mtime: {mtime}, diff from end: {diff}" ) if best_match and best_diff < timedelta(hours=2): @@ -1052,6 +1024,7 @@ async def scan_timelapse( # 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 + archive_completed = archive.completed_at or archive.created_at if archive_completed: time_since_completion = datetime.now() - archive_completed @@ -1084,18 +1057,14 @@ async def scan_timelapse( } # Download the timelapse - use the full path from the file listing - remote_path = matching_file.get('path') or f"/timelapse/{matching_file['name']}" - timelapse_data = await download_file_bytes_async( - printer.ip_address, printer.access_code, remote_path - ) + remote_path = matching_file.get("path") or f"/timelapse/{matching_file['name']}" + 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") # Attach timelapse to archive - success = await service.attach_timelapse( - archive_id, timelapse_data, matching_file["name"] - ) + success = await service.attach_timelapse(archive_id, timelapse_data, matching_file["name"]) if not success: raise HTTPException(500, "Failed to attach timelapse") @@ -1115,7 +1084,7 @@ async def select_timelapse( ): """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 + from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async service = ArchiveService(db) archive = await service.get_archive(archive_id) @@ -1125,9 +1094,7 @@ async def select_timelapse( 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) - ) + 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") @@ -1151,9 +1118,7 @@ async def select_timelapse( 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 - ) + 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") @@ -1196,6 +1161,7 @@ async def upload_timelapse( # Photo Endpoints # ============================================ + @router.post("/{archive_id}/photos") async def upload_photo( archive_id: int, @@ -1203,9 +1169,7 @@ async def upload_photo( db: AsyncSession = Depends(get_db), ): """Upload a photo of the printed result.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -1221,6 +1185,7 @@ async def upload_photo( # Generate unique filename import uuid + ext = Path(file.filename).suffix.lower() photo_filename = f"{uuid.uuid4().hex[:8]}{ext}" photo_path = photos_dir / photo_filename @@ -1247,9 +1212,7 @@ async def get_photo( db: AsyncSession = Depends(get_db), ): """Get a specific photo.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -1280,9 +1243,7 @@ async def delete_photo( db: AsyncSession = Depends(get_db), ): """Delete a photo.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -1309,6 +1270,7 @@ async def delete_photo( # QR Code Endpoint # ============================================ + @router.get("/{archive_id}/qrcode") async def get_qrcode( archive_id: int, @@ -1318,17 +1280,14 @@ async def get_qrcode( ): """Generate a QR code that links to this archive.""" import qrcode - from qrcode.image.styledpil import StyledPilImage - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") # Build URL to archive detail page - base_url = str(request.base_url).rstrip('/') + base_url = str(request.base_url).rstrip("/") archive_url = f"{base_url}/archives?id={archive_id}" # Generate QR code @@ -1355,9 +1314,7 @@ async def get_qrcode( return Response( content=buffer.getvalue(), media_type="image/png", - headers={ - "Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"' - } + headers={"Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'}, ) @@ -1365,7 +1322,6 @@ async def get_qrcode( async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)): """Check what viewing capabilities are available for this 3MF file.""" import json - import re service = ArchiveService(db) archive = await service.get_archive(archive_id) @@ -1381,39 +1337,39 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size try: - with zipfile.ZipFile(file_path, 'r') as zf: + with zipfile.ZipFile(file_path, "r") as zf: names = zf.namelist() # Check for G-code - has_gcode = any(n.startswith('Metadata/') and n.endswith('.gcode') for n in names) + has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names) # Check for 3D model - need to look for actual mesh data for name in names: - if name.endswith('.model'): + if name.endswith(".model"): try: - content = zf.read(name).decode('utf-8') + content = zf.read(name).decode("utf-8") # Check if this model file contains actual mesh vertices - if '= 3: # Get max X and Y from the corner coordinates max_x = 0 max_y = 0 for coord in printable_area: - if 'x' in coord: - parts = coord.split('x') + if "x" in coord: + parts = coord.split("x") if len(parts) == 2: try: x, y = int(parts[0]), int(parts[1]) @@ -1426,7 +1382,7 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g build_volume["y"] = max_y # Parse printable_height - printable_height = config_data.get('printable_height') + printable_height = config_data.get("printable_height") if printable_height: try: build_volume["z"] = int(printable_height) @@ -1458,17 +1414,17 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)): raise HTTPException(404, "File not found") try: - with zipfile.ZipFile(file_path, 'r') as zf: + with zipfile.ZipFile(file_path, "r") as zf: # Bambu 3MF files store G-code in Metadata/plate_X.gcode - gcode_files = [n for n in zf.namelist() if n.startswith('Metadata/') and n.endswith('.gcode')] + gcode_files = [n for n in zf.namelist() if n.startswith("Metadata/") and n.endswith(".gcode")] if not gcode_files: raise HTTPException( 404, - "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio." + "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio.", ) # Get the first plate's G-code (usually plate_1.gcode) - gcode_content = zf.read(gcode_files[0]).decode('utf-8') + gcode_content = zf.read(gcode_files[0]).decode("utf-8") return Response(content=gcode_content, media_type="text/plain") except zipfile.BadZipFile: raise HTTPException(400, "Invalid 3MF file") @@ -1540,11 +1496,13 @@ async def upload_archives_bulk( ) if archive: - results.append({ - "filename": file.filename, - "id": archive.id, - "status": "success", - }) + results.append( + { + "filename": file.filename, + "id": archive.id, + "status": "success", + } + ) else: errors.append({"filename": file.filename, "error": "Failed to process"}) except Exception as e: @@ -1568,10 +1526,10 @@ async def reprint_archive( db: AsyncSession = Depends(get_db), ): """Send an archived 3MF file to a printer and start printing.""" + from backend.app.main import register_expected_print from backend.app.models.printer import Printer from backend.app.services.bambu_ftp import upload_file_async from backend.app.services.printer_manager import printer_manager - from backend.app.main import register_expected_print # Get archive service = ArchiveService(db) @@ -1589,14 +1547,30 @@ async def reprint_archive( if not printer_manager.is_connected(printer_id): raise HTTPException(400, "Printer is not connected") - # Get the 3MF file path + # Get the sliced 3MF file path file_path = settings.base_dir / archive.file_path if not file_path.exists(): raise HTTPException(404, "Archive file not found") # Upload file to printer via FTP - remote_filename = archive.filename - remote_path = f"/cache/{remote_filename}" + from backend.app.services.bambu_ftp import delete_file_async + + # Use a clean filename to avoid issues with double extensions like .gcode.3mf + # The printer might reject filenames with unusual extensions + base_name = archive.filename + if base_name.endswith(".gcode.3mf"): + base_name = base_name[:-10] # Remove .gcode.3mf + elif base_name.endswith(".3mf"): + base_name = base_name[:-4] # Remove .3mf + remote_filename = f"{base_name}.3mf" + remote_path = f"/{remote_filename}" + + # Delete existing file if present (avoids 553 error) + await delete_file_async( + printer.ip_address, + printer.access_code, + remote_path, + ) uploaded = await upload_file_async( printer.ip_address, @@ -1611,8 +1585,23 @@ async def reprint_archive( # Register this as an expected print so we don't create a duplicate archive register_expected_print(printer_id, remote_filename, archive_id) + # Detect plate ID from 3MF file + plate_id = 1 + try: + with zipfile.ZipFile(file_path, "r") as zf: + for name in zf.namelist(): + if name.startswith("Metadata/plate_") and name.endswith(".gcode"): + # Extract plate number from "Metadata/plate_X.gcode" + plate_str = name[15:-6] # Remove "Metadata/plate_" and ".gcode" + plate_id = int(plate_str) + break + except Exception: + pass # Default to plate 1 if detection fails + + logger.info(f"Reprint archive {archive_id}: using plate_id={plate_id}") + # Start the print - started = printer_manager.start_print(printer_id, remote_filename) + started = printer_manager.start_print(printer_id, remote_filename, plate_id) if not started: raise HTTPException(500, "Failed to start print") @@ -1629,11 +1618,12 @@ async def reprint_archive( # Project Page API # ============================================================================= + @router.get("/{archive_id}/project-page") async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)): """Get the project page data from the 3MF file.""" - from backend.app.services.archive import ProjectPageParser from backend.app.schemas.archive import ProjectPageResponse + from backend.app.services.archive import ProjectPageParser service = ArchiveService(db) archive = await service.get_archive(archive_id) @@ -1715,6 +1705,7 @@ async def get_project_image( # Source 3MF API (Original Project Files) # ============================================================================= + @router.post("/{archive_id}/source") async def upload_source_3mf( archive_id: int, @@ -1722,9 +1713,7 @@ async def upload_source_3mf( db: AsyncSession = Depends(get_db), ): """Upload the original source 3MF project file for an archive.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -1770,9 +1759,7 @@ async def download_source_3mf( db: AsyncSession = Depends(get_db), ): """Download the source 3MF project file.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -1801,9 +1788,7 @@ async def download_source_3mf_for_slicer( db: AsyncSession = Depends(get_db), ): """Download source 3MF with filename in URL (for Bambu Studio compatibility).""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") @@ -1839,9 +1824,9 @@ async def upload_source_3mf_by_name( # Derive print name from filename if not provided if not print_name: # Remove .3mf extension and common suffixes - print_name = file.filename.rsplit('.3mf', 1)[0] + print_name = file.filename.rsplit(".3mf", 1)[0] # Remove _source suffix if present - if print_name.endswith('_source'): + if print_name.endswith("_source"): print_name = print_name[:-7] # Find matching archive - try exact match first, then fuzzy @@ -1915,9 +1900,7 @@ async def delete_source_3mf( db: AsyncSession = Depends(get_db), ): """Delete the source 3MF project file from an archive.""" - result = await db.execute( - select(PrintArchive).where(PrintArchive.id == archive_id) - ) + result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) archive = result.scalar_one_or_none() if not archive: raise HTTPException(404, "Archive not found") diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index cbc315743..8b6abef2a 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -1,13 +1,11 @@ import asyncio -from typing import Callable -from dataclasses import asdict +from collections.abc import Callable -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from backend.app.models.printer import Printer -from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState, MQTTLogEntry -from backend.app.services.bambu_ftp import BambuFTPClient +from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState class PrinterManager: @@ -56,9 +54,8 @@ class PrinterManager: f.result() except Exception as e: import logging - logging.getLogger(__name__).error( - f"Exception in scheduled callback: {e}", exc_info=True - ) + + logging.getLogger(__name__).error(f"Exception in scheduled callback: {e}", exc_info=True) future.add_done_callback(handle_exception) @@ -71,27 +68,19 @@ class PrinterManager: def on_state_change(state: PrinterState): if self._on_status_change: - self._schedule_async( - self._on_status_change(printer_id, state) - ) + self._schedule_async(self._on_status_change(printer_id, state)) def on_print_start(data: dict): if self._on_print_start: - self._schedule_async( - self._on_print_start(printer_id, data) - ) + self._schedule_async(self._on_print_start(printer_id, data)) def on_print_complete(data: dict): if self._on_print_complete: - self._schedule_async( - self._on_print_complete(printer_id, data) - ) + self._schedule_async(self._on_print_complete(printer_id, data)) def on_ams_change(ams_data: list): if self._on_ams_change: - self._schedule_async( - self._on_ams_change(printer_id, ams_data) - ) + self._schedule_async(self._on_ams_change(printer_id, ams_data)) client = BambuMQTTClient( ip_address=printer.ip_address, @@ -158,6 +147,7 @@ class PrinterManager: to immediately update the UI without waiting for MQTT timeout. """ import logging + logger = logging.getLogger(__name__) if printer_id in self._clients: @@ -170,10 +160,10 @@ class PrinterManager: 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: + def start_print(self, printer_id: int, filename: str, plate_id: int = 1) -> bool: """Start a print on a connected printer.""" if printer_id in self._clients: - return self._clients[printer_id].start_print(filename) + return self._clients[printer_id].start_print(filename, plate_id) return False def stop_print(self, printer_id: int) -> bool: @@ -201,6 +191,7 @@ class PrinterManager: True if cooled down, False if timeout or not connected """ import logging + logger = logging.getLogger(__name__) elapsed = 0 @@ -306,16 +297,18 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> tray_uuid = tray.get("tray_uuid") if tray_uuid in ("", "00000000000000000000000000000000"): tray_uuid = None - trays.append({ - "id": tray.get("id", 0), - "tray_color": tray.get("tray_color"), - "tray_type": tray.get("tray_type"), - "tray_sub_brands": tray.get("tray_sub_brands"), - "remain": tray.get("remain", 0), - "k": tray.get("k"), - "tag_uid": tag_uid, - "tray_uuid": tray_uuid, - }) + trays.append( + { + "id": tray.get("id", 0), + "tray_color": tray.get("tray_color"), + "tray_type": tray.get("tray_type"), + "tray_sub_brands": tray.get("tray_sub_brands"), + "remain": tray.get("remain", 0), + "k": tray.get("k"), + "tag_uid": tag_uid, + "tray_uuid": tray_uuid, + } + ) # Prefer humidity_raw (actual percentage) over humidity (index 1-5) humidity_raw = ams_data.get("humidity_raw") humidity_idx = ams_data.get("humidity") @@ -336,13 +329,15 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> # AMS-HT has 1 tray, regular AMS has 4 trays is_ams_ht = len(trays) == 1 - ams_units.append({ - "id": ams_data.get("id", 0), - "humidity": humidity_value, - "temp": ams_data.get("temp"), - "is_ams_ht": is_ams_ht, - "tray": trays, - }) + ams_units.append( + { + "id": ams_data.get("id", 0), + "humidity": humidity_value, + "temp": ams_data.get("temp"), + "is_ams_ht": is_ams_ht, + "tray": trays, + } + ) # Parse virtual tray (external spool) if "vt_tray" in raw_data: @@ -403,9 +398,7 @@ printer_manager = PrinterManager() async def init_printer_connections(db: AsyncSession): """Initialize connections to all active printers.""" - result = await db.execute( - select(Printer).where(Printer.is_active == True) - ) + result = await db.execute(select(Printer).where(Printer.is_active.is_(True))) printers = result.scalars().all() for printer in printers: