Fixed bug where print button sent slicer source file instesf of sliced gcode file

This commit is contained in:
maziggy
2025-12-14 09:50:12 +01:00
parent 6cd9cb7033
commit 76795b5fe2
2 changed files with 159 additions and 183 deletions
+124 -141
View File
@@ -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 '<vertex' in content or '<mesh' in content:
if "<vertex" in content or "<mesh" in content:
has_model = True
break
except Exception:
pass
# Extract build volume from project settings
if 'Metadata/project_settings.config' in names:
if "Metadata/project_settings.config" in names:
try:
config_content = zf.read('Metadata/project_settings.config').decode('utf-8')
config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
config_data = json.loads(config_content)
# Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
printable_area = config_data.get('printable_area', [])
printable_area = config_data.get("printable_area", [])
if printable_area and len(printable_area) >= 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")
+35 -42
View File
@@ -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: