mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Added screenshots
This commit is contained in:
@@ -0,0 +1,890 @@
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
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.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats
|
||||
from backend.app.services.archive import ArchiveService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/archives", tags=["archives"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ArchiveResponse])
|
||||
async def list_archives(
|
||||
printer_id: int | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List archived prints."""
|
||||
service = ArchiveService(db)
|
||||
return await service.list_archives(
|
||||
printer_id=printer_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ArchiveStats)
|
||||
async def get_archive_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Get statistics across all archives."""
|
||||
# Total counts
|
||||
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_prints = successful_result.scalar() or 0
|
||||
|
||||
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))
|
||||
)
|
||||
total_time = (time_result.scalar() or 0) / 3600 # Convert to hours
|
||||
|
||||
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))
|
||||
)
|
||||
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))
|
||||
)
|
||||
prints_by_filament: dict[str, int] = {}
|
||||
for (filament_types,) in filament_type_result.all():
|
||||
# Split by comma and count each type
|
||||
for ftype in filament_types.split(","):
|
||||
ftype = ftype.strip()
|
||||
if ftype:
|
||||
prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1
|
||||
|
||||
# By printer
|
||||
printer_result = await db.execute(
|
||||
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()}
|
||||
|
||||
return ArchiveStats(
|
||||
total_prints=total_prints,
|
||||
successful_prints=successful_prints,
|
||||
failed_prints=failed_prints,
|
||||
total_print_time_hours=round(total_time, 1),
|
||||
total_filament_grams=round(total_filament, 1),
|
||||
total_cost=round(total_cost, 2),
|
||||
prints_by_filament_type=prints_by_filament,
|
||||
prints_by_printer=prints_by_printer,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{archive_id}", response_model=ArchiveResponse)
|
||||
async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific archive."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
return archive
|
||||
|
||||
|
||||
@router.patch("/{archive_id}", response_model=ArchiveResponse)
|
||||
async def update_archive(
|
||||
archive_id: int,
|
||||
update_data: ArchiveUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update archive metadata (tags, notes, cost, is_favorite)."""
|
||||
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")
|
||||
|
||||
for field, value in update_data.model_dump(exclude_unset=True).items():
|
||||
setattr(archive, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(archive)
|
||||
return archive
|
||||
|
||||
|
||||
@router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
|
||||
async def toggle_favorite(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Toggle favorite status for an archive."""
|
||||
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")
|
||||
|
||||
archive.is_favorite = not archive.is_favorite
|
||||
await db.commit()
|
||||
await db.refresh(archive)
|
||||
return archive
|
||||
|
||||
|
||||
@router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
|
||||
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)
|
||||
)
|
||||
archive = result.scalar_one_or_none()
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Archive file not found")
|
||||
|
||||
# Parse the 3MF file
|
||||
parser = ThreeMFParser(file_path)
|
||||
metadata = parser.parse()
|
||||
|
||||
# Update fields from metadata
|
||||
if metadata.get("filament_type"):
|
||||
archive.filament_type = metadata["filament_type"]
|
||||
if metadata.get("filament_color"):
|
||||
archive.filament_color = metadata["filament_color"]
|
||||
if metadata.get("print_time_seconds"):
|
||||
archive.print_time_seconds = metadata["print_time_seconds"]
|
||||
if metadata.get("filament_used_grams"):
|
||||
archive.filament_used_grams = metadata["filament_used_grams"]
|
||||
if metadata.get("layer_height"):
|
||||
archive.layer_height = metadata["layer_height"]
|
||||
if metadata.get("nozzle_diameter"):
|
||||
archive.nozzle_diameter = metadata["nozzle_diameter"]
|
||||
if metadata.get("bed_temperature"):
|
||||
archive.bed_temperature = metadata["bed_temperature"]
|
||||
if metadata.get("nozzle_temperature"):
|
||||
archive.nozzle_temperature = metadata["nozzle_temperature"]
|
||||
if metadata.get("makerworld_url"):
|
||||
archive.makerworld_url = metadata["makerworld_url"]
|
||||
if metadata.get("designer"):
|
||||
archive.designer = metadata["designer"]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(archive)
|
||||
return archive
|
||||
|
||||
|
||||
@router.post("/rescan-all")
|
||||
async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
|
||||
"""Rescan all archives and update their metadata."""
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
|
||||
result = await db.execute(select(PrintArchive))
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
updated = 0
|
||||
errors = []
|
||||
|
||||
for archive in archives:
|
||||
try:
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
errors.append({"id": archive.id, "error": "File not found"})
|
||||
continue
|
||||
|
||||
parser = ThreeMFParser(file_path)
|
||||
metadata = parser.parse()
|
||||
|
||||
if metadata.get("filament_type"):
|
||||
archive.filament_type = metadata["filament_type"]
|
||||
if metadata.get("filament_color"):
|
||||
archive.filament_color = metadata["filament_color"]
|
||||
if metadata.get("print_time_seconds"):
|
||||
archive.print_time_seconds = metadata["print_time_seconds"]
|
||||
if metadata.get("filament_used_grams"):
|
||||
archive.filament_used_grams = metadata["filament_used_grams"]
|
||||
if metadata.get("layer_height"):
|
||||
archive.layer_height = metadata["layer_height"]
|
||||
if metadata.get("nozzle_diameter"):
|
||||
archive.nozzle_diameter = metadata["nozzle_diameter"]
|
||||
if metadata.get("makerworld_url"):
|
||||
archive.makerworld_url = metadata["makerworld_url"]
|
||||
if metadata.get("designer"):
|
||||
archive.designer = metadata["designer"]
|
||||
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
errors.append({"id": archive.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
return {"updated": updated, "errors": errors}
|
||||
|
||||
|
||||
@router.delete("/{archive_id}")
|
||||
async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete an archive."""
|
||||
service = ArchiveService(db)
|
||||
if not await service.delete_archive(archive_id):
|
||||
raise HTTPException(404, "Archive not found")
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/{archive_id}/download")
|
||||
async def download_archive(
|
||||
archive_id: int,
|
||||
inline: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download the 3MF file."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
# Use inline disposition to let browser/OS handle file association
|
||||
content_disposition = "inline" if inline else "attachment"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=archive.filename,
|
||||
media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
|
||||
content_disposition_type=content_disposition,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{archive_id}/file/{filename}")
|
||||
async def download_archive_with_filename(
|
||||
archive_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=archive.filename,
|
||||
media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{archive_id}/thumbnail")
|
||||
async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the thumbnail image."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive or not archive.thumbnail_path:
|
||||
raise HTTPException(404, "Thumbnail not found")
|
||||
|
||||
thumb_path = settings.base_dir / archive.thumbnail_path
|
||||
if not thumb_path.exists():
|
||||
raise HTTPException(404, "Thumbnail file not found")
|
||||
|
||||
return FileResponse(path=thumb_path, media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/{archive_id}/timelapse")
|
||||
async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the timelapse video."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive or not archive.timelapse_path:
|
||||
raise HTTPException(404, "Timelapse not found")
|
||||
|
||||
timelapse_path = settings.base_dir / archive.timelapse_path
|
||||
if not timelapse_path.exists():
|
||||
raise HTTPException(404, "Timelapse file not found")
|
||||
|
||||
return FileResponse(
|
||||
path=timelapse_path,
|
||||
media_type="video/mp4",
|
||||
filename=f"{archive.print_name or 'timelapse'}.mp4",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{archive_id}/timelapse/scan")
|
||||
async def scan_timelapse(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
if archive.timelapse_path:
|
||||
return {"status": "exists", "message": "Timelapse already attached"}
|
||||
|
||||
if not archive.printer_id:
|
||||
raise HTTPException(400, "Archive has no associated printer")
|
||||
|
||||
# Get 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")
|
||||
|
||||
# Get base name from archive filename (without .3mf extension)
|
||||
base_name = Path(archive.filename).stem
|
||||
|
||||
# Scan timelapse directory on printer
|
||||
try:
|
||||
files = await list_files_async(printer.ip_address, printer.access_code, "/timelapse/video")
|
||||
except Exception:
|
||||
raise HTTPException(500, "Failed to connect to printer")
|
||||
|
||||
# Look for matching timelapse
|
||||
matching_file = None
|
||||
mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
|
||||
|
||||
# Strategy 1: Match by print name in filename
|
||||
for f in mp4_files:
|
||||
fname = f.get("name", "")
|
||||
if base_name.lower() in fname.lower():
|
||||
matching_file = f
|
||||
break
|
||||
|
||||
# Strategy 2: Match by timestamp proximity
|
||||
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
|
||||
best_match = None
|
||||
best_diff = timedelta(hours=24) # Max 24 hour difference
|
||||
|
||||
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)
|
||||
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:
|
||||
diff = abs(file_time - compare_time)
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best_match = f
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if best_match and best_diff < timedelta(hours=2): # Within 2 hours
|
||||
matching_file = best_match
|
||||
|
||||
if not matching_file:
|
||||
return {"status": "not_found", "message": "No matching timelapse found on printer"}
|
||||
|
||||
# Download the timelapse
|
||||
remote_path = f"/timelapse/video/{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"]
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(500, "Failed to attach timelapse")
|
||||
|
||||
return {
|
||||
"status": "attached",
|
||||
"message": f"Timelapse '{matching_file['name']}' attached successfully",
|
||||
"filename": matching_file["name"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{archive_id}/timelapse/upload")
|
||||
async def upload_timelapse(
|
||||
archive_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Manually upload a timelapse video to an archive."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
|
||||
raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)")
|
||||
|
||||
content = await file.read()
|
||||
success = await service.attach_timelapse(archive_id, content, file.filename)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(500, "Failed to attach timelapse")
|
||||
|
||||
return {"status": "attached", "filename": file.filename}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Photo Endpoints
|
||||
# ============================================
|
||||
|
||||
@router.post("/{archive_id}/photos")
|
||||
async def upload_photo(
|
||||
archive_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a photo of the printed result."""
|
||||
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")
|
||||
|
||||
if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
|
||||
raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
|
||||
|
||||
# Get archive directory
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
archive_dir = file_path.parent
|
||||
photos_dir = archive_dir / "photos"
|
||||
photos_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 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
|
||||
|
||||
# Save file
|
||||
content = await file.read()
|
||||
photo_path.write_bytes(content)
|
||||
|
||||
# Update archive photos list (create new list to trigger SQLAlchemy change detection)
|
||||
photos = list(archive.photos or [])
|
||||
photos.append(photo_filename)
|
||||
archive.photos = photos
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(archive)
|
||||
|
||||
return {"status": "uploaded", "filename": photo_filename, "photos": archive.photos}
|
||||
|
||||
|
||||
@router.get("/{archive_id}/photos/{filename}")
|
||||
async def get_photo(
|
||||
archive_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a specific photo."""
|
||||
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")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
photo_path = file_path.parent / "photos" / filename
|
||||
|
||||
if not photo_path.exists():
|
||||
raise HTTPException(404, "Photo not found")
|
||||
|
||||
# Determine media type
|
||||
ext = Path(filename).suffix.lower()
|
||||
media_types = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
media_type = media_types.get(ext, "image/jpeg")
|
||||
|
||||
return FileResponse(path=photo_path, media_type=media_type)
|
||||
|
||||
|
||||
@router.delete("/{archive_id}/photos/{filename}")
|
||||
async def delete_photo(
|
||||
archive_id: int,
|
||||
filename: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a photo."""
|
||||
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")
|
||||
|
||||
if not archive.photos or filename not in archive.photos:
|
||||
raise HTTPException(404, "Photo not found")
|
||||
|
||||
# Delete file
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
photo_path = file_path.parent / "photos" / filename
|
||||
if photo_path.exists():
|
||||
photo_path.unlink()
|
||||
|
||||
# Update archive photos list
|
||||
photos = [p for p in archive.photos if p != filename]
|
||||
archive.photos = photos if photos else None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted", "photos": archive.photos}
|
||||
|
||||
|
||||
# ============================================
|
||||
# QR Code Endpoint
|
||||
# ============================================
|
||||
|
||||
@router.get("/{archive_id}/qrcode")
|
||||
async def get_qrcode(
|
||||
archive_id: int,
|
||||
request: Request,
|
||||
size: int = 200,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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)
|
||||
)
|
||||
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('/')
|
||||
archive_url = f"{base_url}/archives?id={archive_id}"
|
||||
|
||||
# Generate QR code
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(archive_url)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Resize if needed
|
||||
if size != 200:
|
||||
img = img.resize((size, size))
|
||||
|
||||
# Convert to bytes
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
return Response(
|
||||
content=buffer.getvalue(),
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{archive_id}/capabilities")
|
||||
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)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
has_model = False
|
||||
has_gcode = False
|
||||
build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
# Check for 3D model - need to look for actual mesh data
|
||||
for name in names:
|
||||
if name.endswith('.model'):
|
||||
try:
|
||||
content = zf.read(name).decode('utf-8')
|
||||
# Check if this model file contains actual mesh vertices
|
||||
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:
|
||||
try:
|
||||
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', [])
|
||||
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 len(parts) == 2:
|
||||
try:
|
||||
x, y = int(parts[0]), int(parts[1])
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
except ValueError:
|
||||
pass
|
||||
if max_x > 0 and max_y > 0:
|
||||
build_volume["x"] = max_x
|
||||
build_volume["y"] = max_y
|
||||
|
||||
# Parse printable_height
|
||||
printable_height = config_data.get('printable_height')
|
||||
if printable_height:
|
||||
try:
|
||||
build_volume["z"] = int(printable_height)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid 3MF file")
|
||||
|
||||
return {
|
||||
"has_model": has_model,
|
||||
"has_gcode": has_gcode,
|
||||
"build_volume": build_volume,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{archive_id}/gcode")
|
||||
async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Extract and return G-code from the 3MF file."""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
try:
|
||||
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')]
|
||||
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."
|
||||
)
|
||||
|
||||
# Get the first plate's G-code (usually plate_1.gcode)
|
||||
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")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Error extracting G-code: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_archive(
|
||||
file: UploadFile = File(...),
|
||||
printer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Manually upload a 3MF file to archive."""
|
||||
if not file.filename or not file.filename.endswith(".3mf"):
|
||||
raise HTTPException(400, "File must be a .3mf file")
|
||||
|
||||
# Save uploaded file temporarily
|
||||
temp_path = settings.archive_dir / "temp" / file.filename
|
||||
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
temp_path.write_bytes(content)
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.archive_print(
|
||||
printer_id=printer_id,
|
||||
source_file=temp_path,
|
||||
)
|
||||
|
||||
if not archive:
|
||||
raise HTTPException(400, "Failed to archive file")
|
||||
|
||||
return ArchiveResponse.model_validate(archive)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
@router.post("/upload-bulk")
|
||||
async def upload_archives_bulk(
|
||||
files: list[UploadFile] = File(...),
|
||||
printer_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Bulk upload multiple 3MF files to archive."""
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for file in files:
|
||||
if not file.filename or not file.filename.endswith(".3mf"):
|
||||
errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"})
|
||||
continue
|
||||
|
||||
temp_path = settings.archive_dir / "temp" / file.filename
|
||||
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
temp_path.write_bytes(content)
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.archive_print(
|
||||
printer_id=printer_id,
|
||||
source_file=temp_path,
|
||||
)
|
||||
|
||||
if archive:
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
"id": archive.id,
|
||||
"status": "success",
|
||||
})
|
||||
else:
|
||||
errors.append({"filename": file.filename, "error": "Failed to process"})
|
||||
except Exception as e:
|
||||
errors.append({"filename": file.filename, "error": str(e)})
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
return {
|
||||
"uploaded": len(results),
|
||||
"failed": len(errors),
|
||||
"results": results,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{archive_id}/reprint")
|
||||
async def reprint_archive(
|
||||
archive_id: int,
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send an archived 3MF file to a printer and start printing."""
|
||||
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
|
||||
|
||||
# Get archive
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
# Get 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")
|
||||
|
||||
# Check printer is connected
|
||||
if not printer_manager.is_connected(printer_id):
|
||||
raise HTTPException(400, "Printer is not connected")
|
||||
|
||||
# Get the 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}"
|
||||
|
||||
uploaded = await upload_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
if not uploaded:
|
||||
raise HTTPException(500, "Failed to upload file to printer")
|
||||
|
||||
# Start the print
|
||||
started = printer_manager.start_print(printer_id, remote_filename)
|
||||
|
||||
if not started:
|
||||
raise HTTPException(500, "Failed to start print")
|
||||
|
||||
return {
|
||||
"status": "printing",
|
||||
"printer_id": printer_id,
|
||||
"archive_id": archive_id,
|
||||
"filename": archive.filename,
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Bambu Lab Cloud API Routes
|
||||
|
||||
Handles authentication and profile management with Bambu Cloud.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.bambu_cloud import (
|
||||
get_cloud_service,
|
||||
BambuCloudError,
|
||||
BambuCloudAuthError,
|
||||
)
|
||||
from backend.app.schemas.cloud import (
|
||||
CloudLoginRequest,
|
||||
CloudVerifyRequest,
|
||||
CloudLoginResponse,
|
||||
CloudAuthStatus,
|
||||
CloudTokenRequest,
|
||||
SlicerSettingsResponse,
|
||||
SlicerSetting,
|
||||
CloudDevice,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/cloud", tags=["cloud"])
|
||||
|
||||
|
||||
# Keys for storing cloud credentials in settings
|
||||
CLOUD_TOKEN_KEY = "bambu_cloud_token"
|
||||
CLOUD_EMAIL_KEY = "bambu_cloud_email"
|
||||
|
||||
|
||||
async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
|
||||
"""Get stored cloud token and email from database."""
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
|
||||
)
|
||||
settings = {s.key: s.value for s in result.scalars().all()}
|
||||
return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
|
||||
|
||||
|
||||
async def store_token(db: AsyncSession, token: str, email: str) -> None:
|
||||
"""Store cloud token and email in database."""
|
||||
for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]:
|
||||
result = await db.execute(select(Settings).where(Settings.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.add(Settings(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_token(db: AsyncSession) -> None:
|
||||
"""Clear stored cloud token and email."""
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
|
||||
)
|
||||
for setting in result.scalars().all():
|
||||
await db.delete(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/status", response_model=CloudAuthStatus)
|
||||
async def get_auth_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get current cloud authentication status."""
|
||||
token, email = await get_stored_token(db)
|
||||
cloud = get_cloud_service()
|
||||
|
||||
if token:
|
||||
cloud.set_token(token)
|
||||
|
||||
return CloudAuthStatus(
|
||||
is_authenticated=cloud.is_authenticated,
|
||||
email=email if cloud.is_authenticated else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=CloudLoginResponse)
|
||||
async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Initiate login to Bambu Cloud.
|
||||
|
||||
This will typically trigger a verification code to be sent to the user's email.
|
||||
After receiving the code, call /cloud/verify to complete the login.
|
||||
"""
|
||||
cloud = get_cloud_service()
|
||||
|
||||
# Store email temporarily for verification step
|
||||
await store_token(db, "", request.email)
|
||||
|
||||
try:
|
||||
result = await cloud.login_request(request.email, request.password)
|
||||
|
||||
if result.get("success") and cloud.access_token:
|
||||
# Direct login succeeded (rare)
|
||||
await store_token(db, cloud.access_token, request.email)
|
||||
|
||||
return CloudLoginResponse(
|
||||
success=result.get("success", False),
|
||||
needs_verification=result.get("needs_verification", False),
|
||||
message=result.get("message", "Unknown error"),
|
||||
)
|
||||
except BambuCloudAuthError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
except BambuCloudError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/verify", response_model=CloudLoginResponse)
|
||||
async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Complete login with verification code.
|
||||
|
||||
After calling /cloud/login, the user will receive an email with a 6-digit code.
|
||||
Submit that code here to complete authentication.
|
||||
"""
|
||||
cloud = get_cloud_service()
|
||||
|
||||
try:
|
||||
result = await cloud.verify_code(request.email, request.code)
|
||||
|
||||
if result.get("success") and cloud.access_token:
|
||||
await store_token(db, cloud.access_token, request.email)
|
||||
|
||||
return CloudLoginResponse(
|
||||
success=result.get("success", False),
|
||||
needs_verification=False,
|
||||
message=result.get("message", "Unknown error"),
|
||||
)
|
||||
except BambuCloudAuthError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
except BambuCloudError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/token", response_model=CloudAuthStatus)
|
||||
async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Set access token directly.
|
||||
|
||||
For users who already have a token (e.g., from Bambu Studio).
|
||||
"""
|
||||
cloud = get_cloud_service()
|
||||
cloud.set_token(request.access_token)
|
||||
|
||||
# Verify token works by trying to get profile
|
||||
try:
|
||||
await cloud.get_user_profile()
|
||||
await store_token(db, request.access_token, "token-auth")
|
||||
return CloudAuthStatus(is_authenticated=True, email="token-auth")
|
||||
except BambuCloudError:
|
||||
cloud.logout()
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(db: AsyncSession = Depends(get_db)):
|
||||
"""Log out of Bambu Cloud."""
|
||||
cloud = get_cloud_service()
|
||||
cloud.logout()
|
||||
await clear_token(db)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SlicerSettingsResponse)
|
||||
async def get_slicer_settings(
|
||||
version: str = "01.09.00.00",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get all slicer settings (filament, printer, process presets).
|
||||
|
||||
Requires authentication.
|
||||
"""
|
||||
token, _ = await get_stored_token(db)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
cloud = get_cloud_service()
|
||||
cloud.set_token(token)
|
||||
|
||||
if not cloud.is_authenticated:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
data = await cloud.get_slicer_settings(version)
|
||||
|
||||
result = SlicerSettingsResponse()
|
||||
|
||||
# Map API keys to our types (API uses 'print' for process presets)
|
||||
type_mapping = {
|
||||
"filament": "filament",
|
||||
"printer": "printer",
|
||||
"print": "process", # API calls it 'print', we call it 'process'
|
||||
}
|
||||
|
||||
for api_key, our_type in type_mapping.items():
|
||||
type_data = data.get(api_key, {})
|
||||
# Combine public and private presets, private (user's own) first
|
||||
all_settings = type_data.get("private", []) + type_data.get("public", [])
|
||||
|
||||
parsed = []
|
||||
for s in all_settings:
|
||||
parsed.append(SlicerSetting(
|
||||
setting_id=s.get("setting_id", s.get("id", "")),
|
||||
name=s.get("name", "Unknown"),
|
||||
type=our_type,
|
||||
version=s.get("version"),
|
||||
user_id=s.get("user_id"),
|
||||
updated_time=s.get("updated_time"),
|
||||
))
|
||||
setattr(result, our_type, parsed)
|
||||
|
||||
return result
|
||||
except BambuCloudAuthError:
|
||||
await clear_token(db)
|
||||
raise HTTPException(status_code=401, detail="Authentication expired")
|
||||
except BambuCloudError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/settings/{setting_id}")
|
||||
async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Get detailed information for a specific setting/preset.
|
||||
|
||||
Returns the full preset configuration.
|
||||
"""
|
||||
token, _ = await get_stored_token(db)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
cloud = get_cloud_service()
|
||||
cloud.set_token(token)
|
||||
|
||||
if not cloud.is_authenticated:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
data = await cloud.get_setting_detail(setting_id)
|
||||
return data
|
||||
except BambuCloudAuthError:
|
||||
await clear_token(db)
|
||||
raise HTTPException(status_code=401, detail="Authentication expired")
|
||||
except BambuCloudError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/devices", response_model=list[CloudDevice])
|
||||
async def get_devices(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Get list of bound printer devices.
|
||||
|
||||
Returns printers registered to the user's Bambu account.
|
||||
"""
|
||||
token, _ = await get_stored_token(db)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
cloud = get_cloud_service()
|
||||
cloud.set_token(token)
|
||||
|
||||
if not cloud.is_authenticated:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
data = await cloud.get_devices()
|
||||
devices = data.get("devices", [])
|
||||
|
||||
return [
|
||||
CloudDevice(
|
||||
dev_id=d.get("dev_id", ""),
|
||||
name=d.get("name", "Unknown"),
|
||||
dev_model_name=d.get("dev_model_name"),
|
||||
dev_product_name=d.get("dev_product_name"),
|
||||
online=d.get("online", False),
|
||||
)
|
||||
for d in devices
|
||||
]
|
||||
except BambuCloudAuthError:
|
||||
await clear_token(db)
|
||||
raise HTTPException(status_code=401, detail="Authentication expired")
|
||||
except BambuCloudError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,160 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.schemas.filament import (
|
||||
FilamentCreate,
|
||||
FilamentUpdate,
|
||||
FilamentResponse,
|
||||
FilamentCostCalculation,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/filaments", tags=["filaments"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FilamentResponse])
|
||||
async def list_filaments(db: AsyncSession = Depends(get_db)):
|
||||
"""List all filaments."""
|
||||
result = await db.execute(
|
||||
select(Filament).order_by(Filament.type, Filament.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=FilamentResponse)
|
||||
async def create_filament(
|
||||
filament_data: FilamentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new filament entry."""
|
||||
filament = Filament(**filament_data.model_dump())
|
||||
db.add(filament)
|
||||
await db.commit()
|
||||
await db.refresh(filament)
|
||||
return filament
|
||||
|
||||
|
||||
@router.get("/{filament_id}", response_model=FilamentResponse)
|
||||
async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific filament."""
|
||||
result = await db.execute(
|
||||
select(Filament).where(Filament.id == filament_id)
|
||||
)
|
||||
filament = result.scalar_one_or_none()
|
||||
if not filament:
|
||||
raise HTTPException(404, "Filament not found")
|
||||
return filament
|
||||
|
||||
|
||||
@router.patch("/{filament_id}", response_model=FilamentResponse)
|
||||
async def update_filament(
|
||||
filament_id: int,
|
||||
filament_data: FilamentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a filament."""
|
||||
result = await db.execute(
|
||||
select(Filament).where(Filament.id == filament_id)
|
||||
)
|
||||
filament = result.scalar_one_or_none()
|
||||
if not filament:
|
||||
raise HTTPException(404, "Filament not found")
|
||||
|
||||
for field, value in filament_data.model_dump(exclude_unset=True).items():
|
||||
setattr(filament, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(filament)
|
||||
return filament
|
||||
|
||||
|
||||
@router.delete("/{filament_id}")
|
||||
async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a filament."""
|
||||
result = await db.execute(
|
||||
select(Filament).where(Filament.id == filament_id)
|
||||
)
|
||||
filament = result.scalar_one_or_none()
|
||||
if not filament:
|
||||
raise HTTPException(404, "Filament not found")
|
||||
|
||||
await db.delete(filament)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/calculate-cost", response_model=FilamentCostCalculation)
|
||||
async def calculate_cost(
|
||||
filament_id: int,
|
||||
weight_grams: float,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Calculate the cost for a given weight of filament."""
|
||||
result = await db.execute(
|
||||
select(Filament).where(Filament.id == filament_id)
|
||||
)
|
||||
filament = result.scalar_one_or_none()
|
||||
if not filament:
|
||||
raise HTTPException(404, "Filament not found")
|
||||
|
||||
cost = (weight_grams / 1000) * filament.cost_per_kg
|
||||
|
||||
return FilamentCostCalculation(
|
||||
filament_id=filament.id,
|
||||
filament_name=filament.name,
|
||||
weight_grams=weight_grams,
|
||||
cost=round(cost, 2),
|
||||
currency=filament.currency,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-type/{filament_type}", response_model=list[FilamentResponse])
|
||||
async def get_filaments_by_type(
|
||||
filament_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all filaments of a specific type."""
|
||||
result = await db.execute(
|
||||
select(Filament)
|
||||
.where(Filament.type.ilike(f"%{filament_type}%"))
|
||||
.order_by(Filament.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/seed-defaults")
|
||||
async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
|
||||
"""Seed the database with common filament types."""
|
||||
defaults = [
|
||||
{"name": "Generic PLA", "type": "PLA", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 50, "bed_temp_max": 60, "density": 1.24},
|
||||
{"name": "Generic PETG", "type": "PETG", "cost_per_kg": 25.0, "print_temp_min": 230, "print_temp_max": 250, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27},
|
||||
{"name": "Generic ABS", "type": "ABS", "cost_per_kg": 22.0, "print_temp_min": 230, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.04},
|
||||
{"name": "Generic TPU", "type": "TPU", "cost_per_kg": 35.0, "print_temp_min": 220, "print_temp_max": 250, "bed_temp_min": 40, "bed_temp_max": 60, "density": 1.21},
|
||||
{"name": "Generic ASA", "type": "ASA", "cost_per_kg": 28.0, "print_temp_min": 240, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.07},
|
||||
{"name": "Bambu PLA Basic", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24},
|
||||
{"name": "Bambu PLA Matte", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24},
|
||||
{"name": "Bambu PETG Basic", "type": "PETG", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 250, "print_temp_max": 270, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27},
|
||||
{"name": "Bambu ABS", "type": "ABS", "brand": "Bambu Lab", "cost_per_kg": 30.0, "print_temp_min": 260, "print_temp_max": 280, "bed_temp_min": 90, "bed_temp_max": 100, "density": 1.04},
|
||||
]
|
||||
|
||||
created = 0
|
||||
for filament_data in defaults:
|
||||
# Check if already exists
|
||||
result = await db.execute(
|
||||
select(Filament).where(
|
||||
Filament.name == filament_data["name"],
|
||||
Filament.type == filament_data["type"],
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
filament = Filament(**filament_data)
|
||||
db.add(filament)
|
||||
created += 1
|
||||
|
||||
await db.commit()
|
||||
return {"created": created, "message": f"Created {created} default filaments"}
|
||||
@@ -0,0 +1,412 @@
|
||||
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}
|
||||
@@ -0,0 +1,90 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
|
||||
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
# Default settings
|
||||
DEFAULT_SETTINGS = AppSettings()
|
||||
|
||||
|
||||
async def get_setting(db: AsyncSession, key: str) -> str | None:
|
||||
"""Get a single setting value by key."""
|
||||
result = await db.execute(select(Settings).where(Settings.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def set_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
"""Set a single setting value."""
|
||||
result = await db.execute(select(Settings).where(Settings.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = Settings(key=key, value=value)
|
||||
db.add(setting)
|
||||
|
||||
|
||||
@router.get("/", response_model=AppSettings)
|
||||
async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all application settings."""
|
||||
settings_dict = DEFAULT_SETTINGS.model_dump()
|
||||
|
||||
# Load saved settings from database
|
||||
result = await db.execute(select(Settings))
|
||||
db_settings = result.scalars().all()
|
||||
|
||||
for setting in db_settings:
|
||||
if setting.key in settings_dict:
|
||||
# Parse the value based on the expected type
|
||||
if setting.key in ["auto_archive", "save_thumbnails"]:
|
||||
settings_dict[setting.key] = setting.value.lower() == "true"
|
||||
elif setting.key == "default_filament_cost":
|
||||
settings_dict[setting.key] = float(setting.value)
|
||||
else:
|
||||
settings_dict[setting.key] = setting.value
|
||||
|
||||
return AppSettings(**settings_dict)
|
||||
|
||||
|
||||
@router.put("/", response_model=AppSettings)
|
||||
async def update_settings(
|
||||
settings_update: AppSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update application settings."""
|
||||
update_data = settings_update.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_data.items():
|
||||
# Convert value to string for storage
|
||||
if isinstance(value, bool):
|
||||
str_value = "true" if value else "false"
|
||||
else:
|
||||
str_value = str(value)
|
||||
await set_setting(db, key, str_value)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Return updated settings
|
||||
return await get_settings(db)
|
||||
|
||||
|
||||
@router.post("/reset", response_model=AppSettings)
|
||||
async def reset_settings(db: AsyncSession = Depends(get_db)):
|
||||
"""Reset all settings to defaults."""
|
||||
# Delete all settings
|
||||
result = await db.execute(select(Settings))
|
||||
for setting in result.scalars().all():
|
||||
await db.delete(setting)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return DEFAULT_SETTINGS
|
||||
@@ -0,0 +1,48 @@
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint for real-time updates."""
|
||||
await ws_manager.connect(websocket)
|
||||
|
||||
try:
|
||||
# Send initial status of all printers
|
||||
statuses = printer_manager.get_all_statuses()
|
||||
for printer_id, state in statuses.items():
|
||||
await websocket.send_json({
|
||||
"type": "printer_status",
|
||||
"printer_id": printer_id,
|
||||
"data": printer_state_to_dict(state),
|
||||
})
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
# Handle ping/pong for keepalive
|
||||
if data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
# Handle status request
|
||||
elif data.get("type") == "get_status":
|
||||
printer_id = data.get("printer_id")
|
||||
if printer_id:
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if state:
|
||||
await websocket.send_json({
|
||||
"type": "printer_status",
|
||||
"printer_id": printer_id,
|
||||
"data": printer_state_to_dict(state),
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await ws_manager.disconnect(websocket)
|
||||
except Exception:
|
||||
await ws_manager.disconnect(websocket)
|
||||
Reference in New Issue
Block a user