mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 12:15:36 +02:00
413 lines
13 KiB
Python
413 lines
13 KiB
Python
import io
|
|
import logging
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
logger = logging.getLogger(__name__)
|
|
from fastapi.responses import Response
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.config import settings
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.schemas.printer import (
|
|
PrinterCreate,
|
|
PrinterUpdate,
|
|
PrinterResponse,
|
|
PrinterStatus,
|
|
)
|
|
from backend.app.services.printer_manager import printer_manager
|
|
from backend.app.services.bambu_ftp import (
|
|
download_file_try_paths_async,
|
|
list_files_async,
|
|
delete_file_async,
|
|
download_file_bytes_async,
|
|
get_storage_info_async,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/printers", tags=["printers"])
|
|
|
|
|
|
@router.get("/", response_model=list[PrinterResponse])
|
|
async def list_printers(db: AsyncSession = Depends(get_db)):
|
|
"""List all configured printers."""
|
|
result = await db.execute(select(Printer).order_by(Printer.name))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
@router.post("/", response_model=PrinterResponse)
|
|
async def create_printer(
|
|
printer_data: PrinterCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Add a new printer."""
|
|
# Check if serial number already exists
|
|
result = await db.execute(
|
|
select(Printer).where(Printer.serial_number == printer_data.serial_number)
|
|
)
|
|
if result.scalar_one_or_none():
|
|
raise HTTPException(400, "Printer with this serial number already exists")
|
|
|
|
printer = Printer(**printer_data.model_dump())
|
|
db.add(printer)
|
|
await db.commit()
|
|
await db.refresh(printer)
|
|
|
|
# Connect to the printer
|
|
if printer.is_active:
|
|
await printer_manager.connect_printer(printer)
|
|
|
|
return printer
|
|
|
|
|
|
@router.get("/{printer_id}", response_model=PrinterResponse)
|
|
async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
|
|
"""Get a specific printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
return printer
|
|
|
|
|
|
@router.patch("/{printer_id}", response_model=PrinterResponse)
|
|
async def update_printer(
|
|
printer_id: int,
|
|
printer_data: PrinterUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update a printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
update_data = printer_data.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(printer, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(printer)
|
|
|
|
# Reconnect if connection settings changed
|
|
if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
|
|
printer_manager.disconnect_printer(printer_id)
|
|
if printer.is_active:
|
|
await printer_manager.connect_printer(printer)
|
|
|
|
return printer
|
|
|
|
|
|
@router.delete("/{printer_id}")
|
|
async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
|
|
"""Delete a printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
printer_manager.disconnect_printer(printer_id)
|
|
await db.delete(printer)
|
|
await db.commit()
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.get("/{printer_id}/status", response_model=PrinterStatus)
|
|
async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
|
|
"""Get real-time status of a printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
state = printer_manager.get_status(printer_id)
|
|
if not state:
|
|
return PrinterStatus(
|
|
id=printer_id,
|
|
name=printer.name,
|
|
connected=False,
|
|
)
|
|
|
|
# Determine cover URL if there's an active print
|
|
cover_url = None
|
|
if state.state == "RUNNING" and state.gcode_file:
|
|
cover_url = f"/api/v1/printers/{printer_id}/cover"
|
|
|
|
return PrinterStatus(
|
|
id=printer_id,
|
|
name=printer.name,
|
|
connected=state.connected,
|
|
state=state.state,
|
|
current_print=state.current_print,
|
|
subtask_name=state.subtask_name,
|
|
gcode_file=state.gcode_file,
|
|
progress=state.progress,
|
|
remaining_time=state.remaining_time,
|
|
layer_num=state.layer_num,
|
|
total_layers=state.total_layers,
|
|
temperatures=state.temperatures,
|
|
cover_url=cover_url,
|
|
)
|
|
|
|
|
|
@router.post("/{printer_id}/connect")
|
|
async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
|
|
"""Manually connect to a printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
success = await printer_manager.connect_printer(printer)
|
|
return {"connected": success}
|
|
|
|
|
|
@router.post("/{printer_id}/disconnect")
|
|
async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
|
|
"""Manually disconnect from a printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
printer_manager.disconnect_printer(printer_id)
|
|
return {"connected": False}
|
|
|
|
|
|
@router.post("/test")
|
|
async def test_printer_connection(
|
|
ip_address: str,
|
|
serial_number: str,
|
|
access_code: str,
|
|
):
|
|
"""Test connection to a printer without saving."""
|
|
result = await printer_manager.test_connection(
|
|
ip_address=ip_address,
|
|
serial_number=serial_number,
|
|
access_code=access_code,
|
|
)
|
|
return result
|
|
|
|
|
|
# Cache for cover images (printer_id -> (gcode_file, image_bytes))
|
|
_cover_cache: dict[int, tuple[str, bytes]] = {}
|
|
|
|
|
|
@router.get("/{printer_id}/cover")
|
|
async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)):
|
|
"""Get the cover image for the current print job."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
state = printer_manager.get_status(printer_id)
|
|
if not state:
|
|
raise HTTPException(404, "Printer not connected")
|
|
|
|
# Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
|
|
subtask_name = state.subtask_name
|
|
if not subtask_name:
|
|
raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
|
|
|
|
# Check cache
|
|
if printer_id in _cover_cache:
|
|
cached_file, cached_image = _cover_cache[printer_id]
|
|
if cached_file == subtask_name:
|
|
return Response(content=cached_image, media_type="image/png")
|
|
|
|
# Build 3MF filename from subtask_name
|
|
# Bambu printers store files as "name.gcode.3mf"
|
|
filename = subtask_name
|
|
if not filename.endswith(".3mf"):
|
|
filename = filename + ".gcode.3mf"
|
|
|
|
# Try to download the 3MF file from printer
|
|
temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{filename}"
|
|
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
remote_paths = [
|
|
f"/{filename}", # Root directory (most common)
|
|
f"/cache/{filename}",
|
|
f"/model/{filename}",
|
|
f"/data/{filename}",
|
|
]
|
|
|
|
logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}")
|
|
|
|
try:
|
|
downloaded = await download_file_try_paths_async(
|
|
printer.ip_address,
|
|
printer.access_code,
|
|
remote_paths,
|
|
temp_path,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"FTP download exception: {e}")
|
|
raise HTTPException(500, f"FTP download failed: {e}")
|
|
|
|
if not downloaded:
|
|
raise HTTPException(404, f"Could not download 3MF file '{filename}' from printer {printer.ip_address}. Tried: {remote_paths}")
|
|
|
|
# Verify file actually exists and has content
|
|
if not temp_path.exists():
|
|
raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
|
|
|
|
file_size = temp_path.stat().st_size
|
|
logger.info(f"Downloaded file size: {file_size} bytes")
|
|
|
|
if file_size == 0:
|
|
temp_path.unlink()
|
|
raise HTTPException(500, f"Downloaded file is empty: {filename}")
|
|
|
|
try:
|
|
# Extract thumbnail from 3MF (which is a ZIP file)
|
|
try:
|
|
zf = zipfile.ZipFile(temp_path, 'r')
|
|
except zipfile.BadZipFile as e:
|
|
raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}")
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Failed to open 3MF file: {e}")
|
|
|
|
try:
|
|
# Try common thumbnail paths in 3MF files
|
|
thumbnail_paths = [
|
|
"Metadata/plate_1.png",
|
|
"Metadata/thumbnail.png",
|
|
"Metadata/plate_1_small.png",
|
|
"Thumbnails/thumbnail.png",
|
|
"thumbnail.png",
|
|
]
|
|
|
|
for thumb_path in thumbnail_paths:
|
|
try:
|
|
image_data = zf.read(thumb_path)
|
|
# Cache the result
|
|
_cover_cache[printer_id] = (subtask_name, image_data)
|
|
return Response(content=image_data, media_type="image/png")
|
|
except KeyError:
|
|
continue
|
|
|
|
# If no specific thumbnail found, try any PNG in Metadata
|
|
for name in zf.namelist():
|
|
if name.startswith("Metadata/") and name.endswith(".png"):
|
|
image_data = zf.read(name)
|
|
_cover_cache[printer_id] = (subtask_name, image_data)
|
|
return Response(content=image_data, media_type="image/png")
|
|
|
|
raise HTTPException(404, "No thumbnail found in 3MF file")
|
|
finally:
|
|
zf.close()
|
|
|
|
finally:
|
|
if temp_path.exists():
|
|
temp_path.unlink()
|
|
|
|
|
|
# ============================================
|
|
# File Manager Endpoints
|
|
# ============================================
|
|
|
|
@router.get("/{printer_id}/files")
|
|
async def list_printer_files(
|
|
printer_id: int,
|
|
path: str = "/",
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List files on the printer at the specified path."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
files = await list_files_async(printer.ip_address, printer.access_code, path)
|
|
|
|
# Add full path to each file
|
|
for f in files:
|
|
f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
|
|
|
|
return {
|
|
"path": path,
|
|
"files": files,
|
|
}
|
|
|
|
|
|
@router.get("/{printer_id}/files/download")
|
|
async def download_printer_file(
|
|
printer_id: int,
|
|
path: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Download a file from the printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
|
|
if data is None:
|
|
raise HTTPException(404, f"File not found: {path}")
|
|
|
|
# Determine content type based on extension
|
|
filename = path.split("/")[-1]
|
|
ext = filename.lower().split(".")[-1] if "." in filename else ""
|
|
|
|
content_types = {
|
|
"3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
|
|
"gcode": "text/plain",
|
|
"mp4": "video/mp4",
|
|
"avi": "video/x-msvideo",
|
|
"png": "image/png",
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"json": "application/json",
|
|
"txt": "text/plain",
|
|
}
|
|
content_type = content_types.get(ext, "application/octet-stream")
|
|
|
|
return Response(
|
|
content=data,
|
|
media_type=content_type,
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.delete("/{printer_id}/files")
|
|
async def delete_printer_file(
|
|
printer_id: int,
|
|
path: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a file from the printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
success = await delete_file_async(printer.ip_address, printer.access_code, path)
|
|
if not success:
|
|
raise HTTPException(500, f"Failed to delete file: {path}")
|
|
|
|
return {"status": "deleted", "path": path}
|
|
|
|
|
|
@router.get("/{printer_id}/storage")
|
|
async def get_printer_storage(
|
|
printer_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get storage information from the printer."""
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(404, "Printer not found")
|
|
|
|
storage_info = await get_storage_info_async(printer.ip_address, printer.access_code)
|
|
|
|
return storage_info or {"used_bytes": None, "free_bytes": None}
|