mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 00:38:38 +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)
|
||||
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "BambuTrack"
|
||||
debug: bool = True
|
||||
|
||||
# Paths
|
||||
base_dir: Path = Path(__file__).resolve().parent.parent.parent.parent
|
||||
archive_dir: Path = base_dir / "archive"
|
||||
static_dir: Path = base_dir / "static"
|
||||
database_url: str = f"sqlite+aiosqlite:///{base_dir}/bambutrack.db"
|
||||
|
||||
# API
|
||||
api_prefix: str = "/api/v1"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Ensure directories exist
|
||||
settings.archive_dir.mkdir(exist_ok=True)
|
||||
settings.static_dir.mkdir(exist_ok=True)
|
||||
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from backend.app.core.config import settings
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
# Import models to register them with SQLAlchemy
|
||||
from backend.app.models import printer, archive, filament, settings # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Run migrations for new columns (SQLite doesn't auto-add columns)
|
||||
await run_migrations(conn)
|
||||
|
||||
|
||||
async def run_migrations(conn):
|
||||
"""Add new columns to existing tables if they don't exist."""
|
||||
from sqlalchemy import text
|
||||
|
||||
# Migration: Add is_favorite column to print_archives
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"
|
||||
))
|
||||
except Exception:
|
||||
# Column already exists
|
||||
pass
|
||||
@@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections and broadcasts."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: list[WebSocket] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
"""Accept a new WebSocket connection."""
|
||||
await websocket.accept()
|
||||
async with self._lock:
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket):
|
||||
"""Remove a WebSocket connection."""
|
||||
async with self._lock:
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]):
|
||||
"""Broadcast a message to all connected clients."""
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
data = json.dumps(message)
|
||||
async with self._lock:
|
||||
disconnected = []
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_text(data)
|
||||
except Exception:
|
||||
disconnected.append(connection)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for conn in disconnected:
|
||||
if conn in self.active_connections:
|
||||
self.active_connections.remove(conn)
|
||||
|
||||
async def send_printer_status(self, printer_id: int, status: dict):
|
||||
"""Send printer status update to all clients."""
|
||||
await self.broadcast({
|
||||
"type": "printer_status",
|
||||
"printer_id": printer_id,
|
||||
"data": status,
|
||||
})
|
||||
|
||||
async def send_print_start(self, printer_id: int, data: dict):
|
||||
"""Notify clients that a print has started."""
|
||||
await self.broadcast({
|
||||
"type": "print_start",
|
||||
"printer_id": printer_id,
|
||||
"data": data,
|
||||
})
|
||||
|
||||
async def send_print_complete(self, printer_id: int, data: dict):
|
||||
"""Notify clients that a print has completed."""
|
||||
await self.broadcast({
|
||||
"type": "print_complete",
|
||||
"printer_id": printer_id,
|
||||
"data": data,
|
||||
})
|
||||
|
||||
async def send_archive_created(self, archive: dict):
|
||||
"""Notify clients that a new archive was created."""
|
||||
await self.broadcast({
|
||||
"type": "archive_created",
|
||||
"data": archive,
|
||||
})
|
||||
|
||||
async def send_archive_updated(self, archive: dict):
|
||||
"""Notify clients that an archive was updated."""
|
||||
await self.broadcast({
|
||||
"type": "archive_updated",
|
||||
"data": archive,
|
||||
})
|
||||
|
||||
|
||||
# Global connection manager
|
||||
ws_manager = ConnectionManager()
|
||||
@@ -0,0 +1,339 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import init_db, async_session
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.api.routes import printers, archives, websocket, filaments, cloud
|
||||
from backend.app.api.routes import settings as settings_routes
|
||||
from backend.app.services.printer_manager import (
|
||||
printer_manager,
|
||||
printer_state_to_dict,
|
||||
init_printer_connections,
|
||||
)
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.bambu_ftp import download_file_async
|
||||
|
||||
|
||||
# Track active prints: {(printer_id, filename): archive_id}
|
||||
_active_prints: dict[tuple[int, str], int] = {}
|
||||
|
||||
|
||||
async def on_printer_status_change(printer_id: int, state: PrinterState):
|
||||
"""Handle printer status changes - broadcast via WebSocket."""
|
||||
await ws_manager.send_printer_status(
|
||||
printer_id,
|
||||
printer_state_to_dict(state, printer_id),
|
||||
)
|
||||
|
||||
|
||||
async def on_print_start(printer_id: int, data: dict):
|
||||
"""Handle print start - archive the 3MF file immediately."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
await ws_manager.send_print_start(printer_id, data)
|
||||
|
||||
async with async_session() as db:
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_ftp import list_files_async
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(Printer).where(Printer.id == printer_id)
|
||||
)
|
||||
printer = result.scalar_one_or_none()
|
||||
|
||||
if not printer or not printer.auto_archive:
|
||||
return
|
||||
|
||||
# Get the filename and subtask_name
|
||||
filename = data.get("filename", "")
|
||||
subtask_name = data.get("subtask_name", "")
|
||||
|
||||
logger.info(f"Print start detected - filename: {filename}, subtask: {subtask_name}")
|
||||
|
||||
if not filename and not subtask_name:
|
||||
return
|
||||
|
||||
# Build list of possible 3MF filenames to try
|
||||
possible_names = []
|
||||
|
||||
# Try original filename with .3mf extension
|
||||
if filename:
|
||||
if filename.endswith(".3mf"):
|
||||
possible_names.append(filename)
|
||||
elif filename.endswith(".gcode"):
|
||||
base = filename.rsplit(".", 1)[0]
|
||||
possible_names.append(f"{base}.3mf")
|
||||
else:
|
||||
# No extension - try adding .3mf
|
||||
possible_names.append(f"{filename}.3mf")
|
||||
possible_names.append(filename)
|
||||
|
||||
# Try subtask_name with .3mf extension
|
||||
if subtask_name and subtask_name != filename:
|
||||
possible_names.append(f"{subtask_name}.3mf")
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]
|
||||
|
||||
logger.info(f"Trying filenames: {possible_names}")
|
||||
|
||||
# Try to find and download the 3MF file
|
||||
temp_path = None
|
||||
downloaded_filename = None
|
||||
|
||||
for try_filename in possible_names:
|
||||
if not try_filename.endswith(".3mf"):
|
||||
continue
|
||||
|
||||
remote_paths = [
|
||||
f"/cache/{try_filename}",
|
||||
f"/model/{try_filename}",
|
||||
f"/{try_filename}",
|
||||
]
|
||||
|
||||
temp_path = app_settings.archive_dir / "temp" / try_filename
|
||||
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for remote_path in remote_paths:
|
||||
if await download_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
temp_path,
|
||||
):
|
||||
downloaded_filename = try_filename
|
||||
logger.info(f"Downloaded: {remote_path}")
|
||||
break
|
||||
|
||||
if downloaded_filename:
|
||||
break
|
||||
|
||||
# If still not found, try listing /cache to find matching file
|
||||
if not downloaded_filename and (filename or subtask_name):
|
||||
search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
|
||||
try:
|
||||
cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache")
|
||||
for f in cache_files:
|
||||
if f.get("is_directory"):
|
||||
continue
|
||||
fname = f.get("name", "")
|
||||
if fname.endswith(".3mf") and search_term in fname.lower():
|
||||
temp_path = app_settings.archive_dir / "temp" / fname
|
||||
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if await download_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
f"/cache/{fname}",
|
||||
temp_path,
|
||||
):
|
||||
downloaded_filename = fname
|
||||
logger.info(f"Found and downloaded from cache: {fname}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to list cache: {e}")
|
||||
|
||||
if not downloaded_filename or not temp_path:
|
||||
logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Archive the file with status "printing"
|
||||
service = ArchiveService(db)
|
||||
archive = await service.archive_print(
|
||||
printer_id=printer_id,
|
||||
source_file=temp_path,
|
||||
print_data={**data, "status": "printing"},
|
||||
)
|
||||
|
||||
if archive:
|
||||
# Track this active print (use both original filename and downloaded filename)
|
||||
_active_prints[(printer_id, downloaded_filename)] = archive.id
|
||||
if filename and filename != downloaded_filename:
|
||||
_active_prints[(printer_id, filename)] = archive.id
|
||||
if subtask_name:
|
||||
_active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
|
||||
|
||||
logger.info(f"Created archive {archive.id} for {downloaded_filename}")
|
||||
|
||||
await ws_manager.send_archive_created({
|
||||
"id": archive.id,
|
||||
"printer_id": archive.printer_id,
|
||||
"filename": archive.filename,
|
||||
"print_name": archive.print_name,
|
||||
"status": archive.status,
|
||||
})
|
||||
finally:
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
async def on_print_complete(printer_id: int, data: dict):
|
||||
"""Handle print completion - update the archive status."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
await ws_manager.send_print_complete(printer_id, data)
|
||||
|
||||
filename = data.get("filename", "")
|
||||
if not filename:
|
||||
return
|
||||
|
||||
logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}")
|
||||
|
||||
# Build list of possible keys to try
|
||||
possible_keys = []
|
||||
|
||||
if filename.endswith(".3mf"):
|
||||
possible_keys.append((printer_id, filename))
|
||||
elif filename.endswith(".gcode"):
|
||||
base_name = filename.rsplit(".", 1)[0]
|
||||
possible_keys.append((printer_id, f"{base_name}.3mf"))
|
||||
possible_keys.append((printer_id, filename))
|
||||
else:
|
||||
possible_keys.append((printer_id, f"{filename}.3mf"))
|
||||
possible_keys.append((printer_id, filename))
|
||||
|
||||
# Find the archive for this print
|
||||
archive_id = None
|
||||
for key in possible_keys:
|
||||
archive_id = _active_prints.pop(key, None)
|
||||
if archive_id:
|
||||
# Also clean up any other keys pointing to this archive
|
||||
keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]
|
||||
for k in keys_to_remove:
|
||||
_active_prints.pop(k, None)
|
||||
break
|
||||
|
||||
if not archive_id:
|
||||
# Try to find by filename if not tracked (for prints started before app)
|
||||
async with async_session() as db:
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.printer_id == printer_id)
|
||||
.where(PrintArchive.filename == filename)
|
||||
.where(PrintArchive.status == "printing")
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
archive = result.scalar_one_or_none()
|
||||
if archive:
|
||||
archive_id = archive.id
|
||||
|
||||
if not archive_id:
|
||||
return
|
||||
|
||||
# Update archive status
|
||||
async with async_session() as db:
|
||||
service = ArchiveService(db)
|
||||
status = data.get("status", "completed")
|
||||
await service.update_archive_status(
|
||||
archive_id,
|
||||
status=status,
|
||||
completed_at=datetime.now() if status in ("completed", "failed") else None,
|
||||
)
|
||||
|
||||
await ws_manager.send_archive_updated({
|
||||
"id": archive_id,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
await init_db()
|
||||
|
||||
# Set up printer manager callbacks
|
||||
loop = asyncio.get_event_loop()
|
||||
printer_manager.set_event_loop(loop)
|
||||
printer_manager.set_status_change_callback(on_printer_status_change)
|
||||
printer_manager.set_print_start_callback(on_print_start)
|
||||
printer_manager.set_print_complete_callback(on_print_complete)
|
||||
|
||||
# Connect to all active printers
|
||||
async with async_session() as db:
|
||||
await init_printer_connections(db)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
printer_manager.disconnect_all()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=app_settings.app_name,
|
||||
description="Archive and manage Bambu Lab 3MF files",
|
||||
version="0.1.1",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# API routes
|
||||
app.include_router(printers.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(archives.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(filaments.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(cloud.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(websocket.router, prefix=app_settings.api_prefix)
|
||||
|
||||
|
||||
# Serve static files (React build)
|
||||
if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
|
||||
app.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory=app_settings.static_dir / "assets"),
|
||||
name="assets",
|
||||
)
|
||||
if (app_settings.static_dir / "img").exists():
|
||||
app.mount(
|
||||
"/img",
|
||||
StaticFiles(directory=app_settings.static_dir / "img"),
|
||||
name="img",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def serve_frontend():
|
||||
"""Serve the React frontend."""
|
||||
index_file = app_settings.static_dir / "index.html"
|
||||
if index_file.exists():
|
||||
return FileResponse(index_file)
|
||||
return {
|
||||
"message": "BambuTrack API",
|
||||
"docs": "/docs",
|
||||
"frontend": "Build and place React app in /static directory",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
# Catch-all route for React Router (must be last)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
"""Serve React app for client-side routing."""
|
||||
# Don't intercept API routes
|
||||
if full_path.startswith("api/"):
|
||||
return {"error": "Not found"}
|
||||
|
||||
index_file = app_settings.static_dir / "index.html"
|
||||
if index_file.exists():
|
||||
return FileResponse(index_file)
|
||||
|
||||
return {"error": "Frontend not built"}
|
||||
@@ -0,0 +1,61 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, JSON, Boolean, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class PrintArchive(Base):
|
||||
__tablename__ = "print_archives"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
|
||||
|
||||
# File info
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
file_path: Mapped[str] = mapped_column(String(500))
|
||||
file_size: Mapped[int] = mapped_column(Integer)
|
||||
thumbnail_path: Mapped[str | None] = mapped_column(String(500))
|
||||
timelapse_path: Mapped[str | None] = mapped_column(String(500))
|
||||
|
||||
# Print details from 3MF / printer
|
||||
print_name: Mapped[str | None] = mapped_column(String(255))
|
||||
print_time_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||
filament_used_grams: Mapped[float | None] = mapped_column(Float)
|
||||
filament_type: Mapped[str | None] = mapped_column(String(50))
|
||||
filament_color: Mapped[str | None] = mapped_column(String(50))
|
||||
layer_height: Mapped[float | None] = mapped_column(Float)
|
||||
nozzle_diameter: Mapped[float | None] = mapped_column(Float)
|
||||
bed_temperature: Mapped[int | None] = mapped_column(Integer)
|
||||
nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# Print result
|
||||
status: Mapped[str] = mapped_column(String(20), default="completed")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
# Extended metadata (JSON blob for flexibility)
|
||||
extra_data: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
# MakerWorld info
|
||||
makerworld_url: Mapped[str | None] = mapped_column(String(500))
|
||||
designer: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
# User additions
|
||||
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
tags: Mapped[str | None] = mapped_column(Text)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
cost: Mapped[float | None] = mapped_column(Float)
|
||||
photos: Mapped[list | None] = mapped_column(JSON) # List of photo filenames
|
||||
failure_reason: Mapped[str | None] = mapped_column(String(100)) # For failed prints
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
printer: Mapped["Printer | None"] = relationship(back_populates="archives")
|
||||
|
||||
|
||||
from backend.app.models.printer import Printer # noqa: E402, F811
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Float, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class Filament(Base):
|
||||
"""Filament type with cost information."""
|
||||
|
||||
__tablename__ = "filaments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
type: Mapped[str] = mapped_column(String(50)) # PLA, PETG, ABS, etc.
|
||||
brand: Mapped[str | None] = mapped_column(String(100))
|
||||
color: Mapped[str | None] = mapped_column(String(50))
|
||||
color_hex: Mapped[str | None] = mapped_column(String(7)) # #RRGGBB
|
||||
|
||||
# Cost information
|
||||
cost_per_kg: Mapped[float] = mapped_column(Float, default=25.0)
|
||||
spool_weight_g: Mapped[float] = mapped_column(Float, default=1000.0)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||||
|
||||
# Properties
|
||||
density: Mapped[float | None] = mapped_column(Float) # g/cm³
|
||||
print_temp_min: Mapped[int | None] = mapped_column()
|
||||
print_temp_max: Mapped[int | None] = mapped_column()
|
||||
bed_temp_min: Mapped[int | None] = mapped_column()
|
||||
bed_temp_max: Mapped[int | None] = mapped_column()
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class Printer(Base):
|
||||
__tablename__ = "printers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
serial_number: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
ip_address: Mapped[str] = mapped_column(String(45))
|
||||
access_code: Mapped[str] = mapped_column(String(20))
|
||||
model: Mapped[str | None] = mapped_column(String(50))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
archives: Mapped[list["PrintArchive"]] = relationship(
|
||||
back_populates="printer", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
from backend.app.models.archive import PrintArchive # noqa: E402
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class Settings(Base):
|
||||
"""App settings stored as key-value pairs."""
|
||||
|
||||
__tablename__ = "settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ArchiveBase(BaseModel):
|
||||
print_name: str | None = None
|
||||
is_favorite: bool | None = None
|
||||
tags: str | None = None
|
||||
notes: str | None = None
|
||||
cost: float | None = None
|
||||
failure_reason: str | None = None
|
||||
|
||||
|
||||
class ArchiveUpdate(ArchiveBase):
|
||||
printer_id: int | None = None
|
||||
|
||||
|
||||
class ArchiveResponse(BaseModel):
|
||||
id: int
|
||||
printer_id: int | None
|
||||
filename: str
|
||||
file_path: str
|
||||
file_size: int
|
||||
thumbnail_path: str | None
|
||||
timelapse_path: str | None
|
||||
|
||||
print_name: str | None
|
||||
print_time_seconds: int | None
|
||||
filament_used_grams: float | None
|
||||
filament_type: str | None
|
||||
filament_color: str | None
|
||||
layer_height: float | None
|
||||
nozzle_diameter: float | None
|
||||
bed_temperature: int | None
|
||||
nozzle_temperature: int | None
|
||||
|
||||
status: str
|
||||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
|
||||
extra_data: dict | None
|
||||
|
||||
makerworld_url: str | None
|
||||
designer: str | None
|
||||
|
||||
is_favorite: bool
|
||||
tags: str | None
|
||||
notes: str | None
|
||||
cost: float | None
|
||||
photos: list | None
|
||||
failure_reason: str | None
|
||||
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ArchiveStats(BaseModel):
|
||||
total_prints: int
|
||||
successful_prints: int
|
||||
failed_prints: int
|
||||
total_print_time_hours: float
|
||||
total_filament_grams: float
|
||||
total_cost: float
|
||||
prints_by_filament_type: dict
|
||||
prints_by_printer: dict
|
||||
@@ -0,0 +1,59 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CloudLoginRequest(BaseModel):
|
||||
"""Request to initiate cloud login."""
|
||||
email: str = Field(..., description="Bambu Lab account email")
|
||||
password: str = Field(..., description="Account password")
|
||||
region: str = Field(default="global", description="Region: 'global' or 'china'")
|
||||
|
||||
|
||||
class CloudVerifyRequest(BaseModel):
|
||||
"""Request to verify login with 2FA code."""
|
||||
email: str = Field(..., description="Bambu Lab account email")
|
||||
code: str = Field(..., description="6-digit verification code from email")
|
||||
|
||||
|
||||
class CloudLoginResponse(BaseModel):
|
||||
"""Response from login attempt."""
|
||||
success: bool
|
||||
needs_verification: bool = False
|
||||
message: str
|
||||
|
||||
|
||||
class CloudAuthStatus(BaseModel):
|
||||
"""Current authentication status."""
|
||||
is_authenticated: bool
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class CloudTokenRequest(BaseModel):
|
||||
"""Request to set access token directly."""
|
||||
access_token: str = Field(..., description="Bambu Lab access token")
|
||||
|
||||
|
||||
class SlicerSetting(BaseModel):
|
||||
"""A slicer setting/preset."""
|
||||
setting_id: str
|
||||
name: str
|
||||
type: str # filament, printer, process
|
||||
version: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
updated_time: Optional[str] = None
|
||||
|
||||
|
||||
class SlicerSettingsResponse(BaseModel):
|
||||
"""Response containing slicer settings."""
|
||||
filament: list[SlicerSetting] = []
|
||||
printer: list[SlicerSetting] = []
|
||||
process: list[SlicerSetting] = []
|
||||
|
||||
|
||||
class CloudDevice(BaseModel):
|
||||
"""A bound printer device."""
|
||||
dev_id: str
|
||||
name: str
|
||||
dev_model_name: Optional[str] = None
|
||||
dev_product_name: Optional[str] = None
|
||||
online: bool = False
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FilamentBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
type: str = Field(..., min_length=1, max_length=50)
|
||||
brand: str | None = None
|
||||
color: str | None = None
|
||||
color_hex: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
cost_per_kg: float = 25.0
|
||||
spool_weight_g: float = 1000.0
|
||||
currency: str = "USD"
|
||||
density: float | None = None
|
||||
print_temp_min: int | None = None
|
||||
print_temp_max: int | None = None
|
||||
bed_temp_min: int | None = None
|
||||
bed_temp_max: int | None = None
|
||||
|
||||
|
||||
class FilamentCreate(FilamentBase):
|
||||
pass
|
||||
|
||||
|
||||
class FilamentUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
brand: str | None = None
|
||||
color: str | None = None
|
||||
color_hex: str | None = None
|
||||
cost_per_kg: float | None = None
|
||||
spool_weight_g: float | None = None
|
||||
currency: str | None = None
|
||||
density: float | None = None
|
||||
print_temp_min: int | None = None
|
||||
print_temp_max: int | None = None
|
||||
bed_temp_min: int | None = None
|
||||
bed_temp_max: int | None = None
|
||||
|
||||
|
||||
class FilamentResponse(FilamentBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FilamentCostCalculation(BaseModel):
|
||||
filament_id: int
|
||||
filament_name: str
|
||||
weight_grams: float
|
||||
cost: float
|
||||
currency: str
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
serial_number: str = Field(..., min_length=1, max_length=50)
|
||||
ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
|
||||
access_code: str = Field(..., min_length=1, max_length=20)
|
||||
model: str | None = None
|
||||
auto_archive: bool = True
|
||||
|
||||
|
||||
class PrinterCreate(PrinterBase):
|
||||
pass
|
||||
|
||||
|
||||
class PrinterUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
ip_address: str | None = None
|
||||
access_code: str | None = None
|
||||
model: str | None = None
|
||||
is_active: bool | None = None
|
||||
auto_archive: bool | None = None
|
||||
|
||||
|
||||
class PrinterResponse(PrinterBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PrinterStatus(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
connected: bool
|
||||
state: str | None = None
|
||||
current_print: str | None = None
|
||||
subtask_name: str | None = None
|
||||
gcode_file: str | None = None
|
||||
progress: float | None = None
|
||||
remaining_time: int | None = None
|
||||
layer_num: int | None = None
|
||||
total_layers: int | None = None
|
||||
temperatures: dict | None = None
|
||||
cover_url: str | None = None
|
||||
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
"""Application settings schema."""
|
||||
|
||||
auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
|
||||
save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
|
||||
default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
|
||||
currency: str = Field(default="USD", description="Currency for cost tracking")
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
"""Schema for updating settings (all fields optional)."""
|
||||
|
||||
auto_archive: bool | None = None
|
||||
save_thumbnails: bool | None = None
|
||||
default_filament_cost: float | None = None
|
||||
currency: str | None = None
|
||||
@@ -0,0 +1,434 @@
|
||||
import json
|
||||
import zipfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
|
||||
class ThreeMFParser:
|
||||
"""Parser for Bambu Lab 3MF files."""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self.metadata: dict = {}
|
||||
|
||||
def parse(self) -> dict:
|
||||
"""Extract metadata from 3MF file."""
|
||||
try:
|
||||
with zipfile.ZipFile(self.file_path, "r") as zf:
|
||||
self._parse_slice_info(zf)
|
||||
self._parse_project_settings(zf)
|
||||
self._parse_3dmodel(zf)
|
||||
self._extract_thumbnail(zf)
|
||||
|
||||
# Prefer slice_info for colors (shows ALL filaments actually used in print)
|
||||
# project_settings may filter out "support" filaments incorrectly
|
||||
if self.metadata.get("_slice_filament_color"):
|
||||
self.metadata["filament_color"] = self.metadata["_slice_filament_color"]
|
||||
if not self.metadata.get("filament_type") and self.metadata.get("_slice_filament_type"):
|
||||
self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
|
||||
|
||||
# Clean up internal keys
|
||||
self.metadata.pop("_slice_filament_type", None)
|
||||
self.metadata.pop("_slice_filament_color", None)
|
||||
except Exception:
|
||||
pass
|
||||
return self.metadata
|
||||
|
||||
def _parse_slice_info(self, zf: zipfile.ZipFile):
|
||||
"""Parse slice_info.config for print settings."""
|
||||
try:
|
||||
if "Metadata/slice_info.config" in zf.namelist():
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
# Get first plate's metadata
|
||||
plate = root.find(".//plate")
|
||||
if plate is not None:
|
||||
# Get prediction and weight from metadata elements
|
||||
for meta in plate.findall("metadata"):
|
||||
key = meta.get("key")
|
||||
value = meta.get("value")
|
||||
if key == "prediction" and value:
|
||||
self.metadata["print_time_seconds"] = int(value)
|
||||
elif key == "weight" and value:
|
||||
self.metadata["filament_used_grams"] = float(value)
|
||||
|
||||
# Get filament info from ALL filaments actually used in the print
|
||||
# slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
|
||||
filaments = root.findall(".//filament")
|
||||
if filaments:
|
||||
# Collect all unique filament types and colors
|
||||
types = []
|
||||
colors = []
|
||||
for f in filaments:
|
||||
ftype = f.get("type")
|
||||
fcolor = f.get("color")
|
||||
if ftype and ftype not in types:
|
||||
types.append(ftype)
|
||||
if fcolor and fcolor not in colors:
|
||||
colors.append(fcolor)
|
||||
|
||||
if types:
|
||||
self.metadata["_slice_filament_type"] = ", ".join(types)
|
||||
if colors:
|
||||
self.metadata["_slice_filament_color"] = ",".join(colors)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_project_settings(self, zf: zipfile.ZipFile):
|
||||
"""Parse project settings for print configuration."""
|
||||
try:
|
||||
if "Metadata/project_settings.config" in zf.namelist():
|
||||
content = zf.read("Metadata/project_settings.config").decode()
|
||||
try:
|
||||
data = json.loads(content)
|
||||
self._extract_filament_info(data)
|
||||
self._extract_print_settings(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _extract_filament_info(self, data: dict):
|
||||
"""Extract filament info, preferring non-support filaments."""
|
||||
try:
|
||||
filament_types = data.get("filament_type", [])
|
||||
filament_colors = data.get("filament_colour", [])
|
||||
filament_is_support = data.get("filament_is_support", [])
|
||||
|
||||
if not filament_types:
|
||||
return
|
||||
|
||||
# Collect all non-support filaments
|
||||
non_support_types = []
|
||||
non_support_colors = []
|
||||
|
||||
for i, ftype in enumerate(filament_types):
|
||||
is_support = filament_is_support[i] if i < len(filament_is_support) else '0'
|
||||
if is_support == '0':
|
||||
if ftype and ftype not in non_support_types:
|
||||
non_support_types.append(ftype)
|
||||
if i < len(filament_colors) and filament_colors[i]:
|
||||
color = filament_colors[i]
|
||||
if color not in non_support_colors:
|
||||
non_support_colors.append(color)
|
||||
|
||||
# Fallback to first filament if all are support
|
||||
if not non_support_types and filament_types:
|
||||
non_support_types = [filament_types[0]]
|
||||
if not non_support_colors and filament_colors:
|
||||
non_support_colors = [filament_colors[0]]
|
||||
|
||||
# Store filament type(s)
|
||||
if non_support_types:
|
||||
self.metadata["filament_type"] = ", ".join(non_support_types)
|
||||
|
||||
# Store all colors as comma-separated (for multi-color display)
|
||||
if non_support_colors:
|
||||
self.metadata["filament_color"] = ",".join(non_support_colors)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _extract_print_settings(self, data: dict):
|
||||
"""Extract print settings from JSON config."""
|
||||
try:
|
||||
# Layer height - usually an array, get first value
|
||||
if "layer_height" in data:
|
||||
val = data["layer_height"]
|
||||
if isinstance(val, list) and val:
|
||||
self.metadata["layer_height"] = float(val[0])
|
||||
elif isinstance(val, (int, float, str)):
|
||||
self.metadata["layer_height"] = float(val)
|
||||
|
||||
# Nozzle diameter
|
||||
if "nozzle_diameter" in data:
|
||||
val = data["nozzle_diameter"]
|
||||
if isinstance(val, list) and val:
|
||||
self.metadata["nozzle_diameter"] = float(val[0])
|
||||
elif isinstance(val, (int, float, str)):
|
||||
self.metadata["nozzle_diameter"] = float(val)
|
||||
|
||||
# Bed temperature - first layer or regular
|
||||
for key in ["bed_temperature_initial_layer", "bed_temperature"]:
|
||||
if key in data:
|
||||
val = data[key]
|
||||
if isinstance(val, list) and val:
|
||||
self.metadata["bed_temperature"] = int(float(val[0]))
|
||||
elif isinstance(val, (int, float, str)):
|
||||
self.metadata["bed_temperature"] = int(float(val))
|
||||
break
|
||||
|
||||
# Nozzle temperature
|
||||
for key in ["nozzle_temperature_initial_layer", "nozzle_temperature"]:
|
||||
if key in data:
|
||||
val = data[key]
|
||||
if isinstance(val, list) and val:
|
||||
self.metadata["nozzle_temperature"] = int(float(val[0]))
|
||||
elif isinstance(val, (int, float, str)):
|
||||
self.metadata["nozzle_temperature"] = int(float(val))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _extract_settings_from_content(self, content: str):
|
||||
"""Extract print settings from config content."""
|
||||
settings_map = {
|
||||
"layer_height": ("layer_height", float),
|
||||
"nozzle_diameter": ("nozzle_diameter", float),
|
||||
"bed_temperature": ("bed_temperature", int),
|
||||
"nozzle_temperature": ("nozzle_temperature", int),
|
||||
}
|
||||
|
||||
for key, (search_key, converter) in settings_map.items():
|
||||
if key not in self.metadata:
|
||||
try:
|
||||
# Try JSON format
|
||||
if f'"{search_key}"' in content:
|
||||
start = content.find(f'"{search_key}"')
|
||||
value_start = content.find(":", start) + 1
|
||||
value_end = content.find(",", value_start)
|
||||
if value_end == -1:
|
||||
value_end = content.find("}", value_start)
|
||||
value = content[value_start:value_end].strip().strip('"')
|
||||
self.metadata[key] = converter(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_3dmodel(self, zf: zipfile.ZipFile):
|
||||
"""Parse 3D/3dmodel.model for MakerWorld metadata."""
|
||||
import re
|
||||
try:
|
||||
model_path = "3D/3dmodel.model"
|
||||
if model_path not in zf.namelist():
|
||||
return
|
||||
|
||||
content = zf.read(model_path).decode("utf-8", errors="ignore")
|
||||
|
||||
# Parse XML metadata elements
|
||||
# MakerWorld adds metadata like: <metadata name="Designer">username</metadata>
|
||||
metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
|
||||
matches = re.findall(metadata_pattern, content)
|
||||
|
||||
makerworld_fields = {}
|
||||
for name, value in matches:
|
||||
makerworld_fields[name] = value.strip()
|
||||
|
||||
# Check for direct MakerWorld URL in content
|
||||
url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)'
|
||||
url_match = re.search(url_pattern, content)
|
||||
if url_match:
|
||||
self.metadata["makerworld_url"] = url_match.group(0)
|
||||
self.metadata["makerworld_model_id"] = url_match.group(1)
|
||||
|
||||
# Extract model ID from DSM reference in image URLs
|
||||
# Format: https://makerworld.bblmw.com/makerworld/model/DSM00000001275614/...
|
||||
# The numeric part (1275614) is the MakerWorld model ID
|
||||
if "makerworld_url" not in self.metadata:
|
||||
dsm_pattern = r'DSM0+(\d+)'
|
||||
dsm_match = re.search(dsm_pattern, content)
|
||||
if dsm_match:
|
||||
model_id = dsm_match.group(1)
|
||||
self.metadata["makerworld_url"] = f"https://makerworld.com/en/models/{model_id}"
|
||||
self.metadata["makerworld_model_id"] = model_id
|
||||
|
||||
# Store designer info
|
||||
if "Designer" in makerworld_fields:
|
||||
self.metadata["designer"] = makerworld_fields["Designer"]
|
||||
if "Title" in makerworld_fields:
|
||||
self.metadata["print_name"] = makerworld_fields["Title"]
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _extract_thumbnail(self, zf: zipfile.ZipFile):
|
||||
"""Extract thumbnail image from 3MF."""
|
||||
thumbnail_paths = [
|
||||
"Metadata/plate_1.png",
|
||||
"Metadata/thumbnail.png",
|
||||
"Metadata/model_thumbnail.png",
|
||||
]
|
||||
for thumb_path in thumbnail_paths:
|
||||
if thumb_path in zf.namelist():
|
||||
self.metadata["_thumbnail_data"] = zf.read(thumb_path)
|
||||
self.metadata["_thumbnail_ext"] = ".png"
|
||||
break
|
||||
|
||||
|
||||
class ArchiveService:
|
||||
"""Service for archiving print jobs."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def archive_print(
|
||||
self,
|
||||
printer_id: int | None,
|
||||
source_file: Path,
|
||||
print_data: dict | None = None,
|
||||
) -> PrintArchive | None:
|
||||
"""Archive a 3MF file with metadata."""
|
||||
# Verify printer exists if specified
|
||||
if printer_id is not None:
|
||||
result = await self.db.execute(
|
||||
select(Printer).where(Printer.id == printer_id)
|
||||
)
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
return None
|
||||
|
||||
# Create archive directory structure
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
archive_name = f"{timestamp}_{source_file.stem}"
|
||||
# Use "unassigned" folder for archives without a printer
|
||||
printer_folder = str(printer_id) if printer_id is not None else "unassigned"
|
||||
archive_dir = settings.archive_dir / printer_folder / archive_name
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy 3MF file
|
||||
dest_file = archive_dir / source_file.name
|
||||
shutil.copy2(source_file, dest_file)
|
||||
|
||||
# Parse 3MF metadata
|
||||
parser = ThreeMFParser(dest_file)
|
||||
metadata = parser.parse()
|
||||
|
||||
# Save thumbnail if present
|
||||
thumbnail_path = None
|
||||
if "_thumbnail_data" in metadata:
|
||||
thumb_file = archive_dir / f"thumbnail{metadata['_thumbnail_ext']}"
|
||||
thumb_file.write_bytes(metadata["_thumbnail_data"])
|
||||
thumbnail_path = str(thumb_file.relative_to(settings.base_dir))
|
||||
del metadata["_thumbnail_data"]
|
||||
del metadata["_thumbnail_ext"]
|
||||
|
||||
# Merge with print data from MQTT
|
||||
if print_data:
|
||||
metadata["_print_data"] = print_data
|
||||
|
||||
# Determine status and timestamps
|
||||
status = print_data.get("status", "completed") if print_data else "archived"
|
||||
started_at = datetime.now() if status == "printing" else None
|
||||
completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
|
||||
|
||||
# Create archive record
|
||||
archive = PrintArchive(
|
||||
printer_id=printer_id,
|
||||
filename=source_file.name,
|
||||
file_path=str(dest_file.relative_to(settings.base_dir)),
|
||||
file_size=dest_file.stat().st_size,
|
||||
thumbnail_path=thumbnail_path,
|
||||
print_name=metadata.get("print_name") or source_file.stem,
|
||||
print_time_seconds=metadata.get("print_time_seconds"),
|
||||
filament_used_grams=metadata.get("filament_used_grams"),
|
||||
filament_type=metadata.get("filament_type"),
|
||||
filament_color=metadata.get("filament_color"),
|
||||
layer_height=metadata.get("layer_height"),
|
||||
nozzle_diameter=metadata.get("nozzle_diameter"),
|
||||
bed_temperature=metadata.get("bed_temperature"),
|
||||
nozzle_temperature=metadata.get("nozzle_temperature"),
|
||||
makerworld_url=metadata.get("makerworld_url"),
|
||||
designer=metadata.get("designer"),
|
||||
status=status,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
extra_data=metadata,
|
||||
)
|
||||
|
||||
self.db.add(archive)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(archive)
|
||||
|
||||
return archive
|
||||
|
||||
async def get_archive(self, archive_id: int) -> PrintArchive | None:
|
||||
"""Get an archive by ID."""
|
||||
result = await self.db.execute(
|
||||
select(PrintArchive).where(PrintArchive.id == archive_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_archive_status(
|
||||
self,
|
||||
archive_id: int,
|
||||
status: str,
|
||||
completed_at: datetime | None = None,
|
||||
) -> bool:
|
||||
"""Update the status of an archive."""
|
||||
archive = await self.get_archive(archive_id)
|
||||
if not archive:
|
||||
return False
|
||||
|
||||
archive.status = status
|
||||
if completed_at:
|
||||
archive.completed_at = completed_at
|
||||
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def list_archives(
|
||||
self,
|
||||
printer_id: int | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[PrintArchive]:
|
||||
"""List archives with optional filtering."""
|
||||
query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
|
||||
|
||||
if printer_id:
|
||||
query = query.where(PrintArchive.printer_id == printer_id)
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete_archive(self, archive_id: int) -> bool:
|
||||
"""Delete an archive and its files."""
|
||||
archive = await self.get_archive(archive_id)
|
||||
if not archive:
|
||||
return False
|
||||
|
||||
# Delete files
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if file_path.exists():
|
||||
archive_dir = file_path.parent
|
||||
shutil.rmtree(archive_dir, ignore_errors=True)
|
||||
|
||||
# Delete database record
|
||||
await self.db.delete(archive)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def attach_timelapse(
|
||||
self,
|
||||
archive_id: int,
|
||||
timelapse_data: bytes,
|
||||
filename: str = "timelapse.mp4",
|
||||
) -> bool:
|
||||
"""Attach a timelapse video to an archive."""
|
||||
archive = await self.get_archive(archive_id)
|
||||
if not archive:
|
||||
return False
|
||||
|
||||
# Get archive directory
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
archive_dir = file_path.parent
|
||||
|
||||
# Save timelapse
|
||||
timelapse_file = archive_dir / filename
|
||||
timelapse_file.write_bytes(timelapse_data)
|
||||
|
||||
# Update archive record
|
||||
archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
|
||||
await self.db.commit()
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Bambu Lab Cloud API Service
|
||||
|
||||
Handles authentication and profile management with Bambu Lab's cloud services.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BAMBU_API_BASE = "https://api.bambulab.com"
|
||||
BAMBU_API_BASE_CN = "https://api.bambulab.cn"
|
||||
|
||||
|
||||
class BambuCloudError(Exception):
|
||||
"""Base exception for Bambu Cloud errors."""
|
||||
pass
|
||||
|
||||
|
||||
class BambuCloudAuthError(BambuCloudError):
|
||||
"""Authentication related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class BambuCloudService:
|
||||
"""Service for interacting with Bambu Lab Cloud API."""
|
||||
|
||||
def __init__(self, region: str = "global"):
|
||||
self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
|
||||
self.access_token: Optional[str] = None
|
||||
self.refresh_token: Optional[str] = None
|
||||
self.token_expiry: Optional[datetime] = None
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if we have a valid token."""
|
||||
if not self.access_token:
|
||||
return False
|
||||
if self.token_expiry and datetime.now() > self.token_expiry:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_headers(self) -> dict:
|
||||
"""Get headers for authenticated requests."""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "BambuTrack/1.0",
|
||||
}
|
||||
if self.access_token:
|
||||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||
return headers
|
||||
|
||||
async def login_request(self, email: str, password: str) -> dict:
|
||||
"""
|
||||
Initiate login - this will trigger a verification code email.
|
||||
|
||||
Returns dict with login status and whether verification is needed.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self.base_url}/v1/user-service/user/login",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={
|
||||
"account": email,
|
||||
"password": password,
|
||||
}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
# Check if we need verification code
|
||||
# Bambu API returns loginType or may require tfaKey
|
||||
if data.get("loginType") == "verifyCode" or "tfaKey" in data:
|
||||
return {
|
||||
"success": False,
|
||||
"needs_verification": True,
|
||||
"message": "Verification code sent to email"
|
||||
}
|
||||
|
||||
# Direct login success (rare, usually needs 2FA)
|
||||
if "accessToken" in data:
|
||||
self._set_tokens(data)
|
||||
return {
|
||||
"success": True,
|
||||
"needs_verification": False,
|
||||
"message": "Login successful"
|
||||
}
|
||||
|
||||
# Handle specific error codes
|
||||
error_msg = data.get("message") or data.get("error") or "Login failed"
|
||||
return {
|
||||
"success": False,
|
||||
"needs_verification": False,
|
||||
"message": error_msg
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login request failed: {e}")
|
||||
raise BambuCloudAuthError(f"Login request failed: {e}")
|
||||
|
||||
async def verify_code(self, email: str, code: str) -> dict:
|
||||
"""
|
||||
Complete login with verification code.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self.base_url}/v1/user-service/user/login",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={
|
||||
"account": email,
|
||||
"code": code,
|
||||
}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200 and "accessToken" in data:
|
||||
self._set_tokens(data)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Login successful"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": data.get("message", "Verification failed")
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Verification failed: {e}")
|
||||
raise BambuCloudAuthError(f"Verification failed: {e}")
|
||||
|
||||
def _set_tokens(self, data: dict):
|
||||
"""Set tokens from login response."""
|
||||
self.access_token = data.get("accessToken")
|
||||
self.refresh_token = data.get("refreshToken")
|
||||
# Token typically valid for ~3 months, but we'll refresh more often
|
||||
self.token_expiry = datetime.now() + timedelta(days=30)
|
||||
|
||||
def set_token(self, access_token: str):
|
||||
"""Set access token directly (for stored tokens)."""
|
||||
self.access_token = access_token
|
||||
self.token_expiry = datetime.now() + timedelta(days=30)
|
||||
|
||||
def logout(self):
|
||||
"""Clear authentication state."""
|
||||
self.access_token = None
|
||||
self.refresh_token = None
|
||||
self.token_expiry = None
|
||||
|
||||
async def get_user_profile(self) -> dict:
|
||||
"""Get user profile information."""
|
||||
if not self.is_authenticated:
|
||||
raise BambuCloudAuthError("Not authenticated")
|
||||
|
||||
try:
|
||||
response = await self._client.get(
|
||||
f"{self.base_url}/v1/design-user-service/my/preference",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
raise BambuCloudError(f"Failed to get profile: {response.status_code}")
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise BambuCloudError(f"Request failed: {e}")
|
||||
|
||||
async def get_slicer_settings(self, version: str = "01.09.00.00") -> dict:
|
||||
"""
|
||||
Get all slicer settings (filament, printer, process presets).
|
||||
|
||||
Args:
|
||||
version: Slicer version string
|
||||
"""
|
||||
if not self.is_authenticated:
|
||||
raise BambuCloudAuthError("Not authenticated")
|
||||
|
||||
try:
|
||||
response = await self._client.get(
|
||||
f"{self.base_url}/v1/iot-service/api/slicer/setting",
|
||||
headers=self._get_headers(),
|
||||
params={"version": version}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
return data
|
||||
|
||||
raise BambuCloudError(f"Failed to get settings: {response.status_code}")
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise BambuCloudError(f"Request failed: {e}")
|
||||
|
||||
async def get_setting_detail(self, setting_id: str) -> dict:
|
||||
"""Get detailed information for a specific setting/preset."""
|
||||
if not self.is_authenticated:
|
||||
raise BambuCloudAuthError("Not authenticated")
|
||||
|
||||
try:
|
||||
response = await self._client.get(
|
||||
f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise BambuCloudError(f"Request failed: {e}")
|
||||
|
||||
async def get_devices(self) -> dict:
|
||||
"""Get list of bound devices."""
|
||||
if not self.is_authenticated:
|
||||
raise BambuCloudAuthError("Not authenticated")
|
||||
|
||||
try:
|
||||
response = await self._client.get(
|
||||
f"{self.base_url}/v1/iot-service/api/user/bind",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
raise BambuCloudError(f"Failed to get devices: {response.status_code}")
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise BambuCloudError(f"Request failed: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_cloud_service: Optional[BambuCloudService] = None
|
||||
|
||||
|
||||
def get_cloud_service() -> BambuCloudService:
|
||||
"""Get the singleton cloud service instance."""
|
||||
global _cloud_service
|
||||
if _cloud_service is None:
|
||||
_cloud_service = BambuCloudService()
|
||||
return _cloud_service
|
||||
@@ -0,0 +1,388 @@
|
||||
import ssl
|
||||
import socket
|
||||
import asyncio
|
||||
import logging
|
||||
from ftplib import FTP_TLS, FTP
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImplicitFTP_TLS(FTP_TLS):
|
||||
"""FTP_TLS subclass for implicit FTPS (port 990) with session reuse."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._sock = None
|
||||
self.ssl_context = ssl.create_default_context()
|
||||
self.ssl_context.check_hostname = False
|
||||
self.ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def connect(self, host='', port=990, timeout=-999, source_address=None):
|
||||
"""Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
|
||||
if host:
|
||||
self.host = host
|
||||
if port > 0:
|
||||
self.port = port
|
||||
if timeout != -999:
|
||||
self.timeout = timeout
|
||||
if source_address:
|
||||
self.source_address = source_address
|
||||
|
||||
# Create and wrap socket immediately (implicit TLS)
|
||||
self.sock = socket.create_connection(
|
||||
(self.host, self.port),
|
||||
self.timeout,
|
||||
source_address=self.source_address
|
||||
)
|
||||
self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)
|
||||
self.af = self.sock.family
|
||||
self.file = self.sock.makefile('r', encoding=self.encoding)
|
||||
self.welcome = self.getresp()
|
||||
return self.welcome
|
||||
|
||||
def ntransfercmd(self, cmd, rest=None):
|
||||
"""Override to reuse SSL session for data connection (required by vsFTPd)."""
|
||||
conn, size = FTP.ntransfercmd(self, cmd, rest)
|
||||
if self._prot_p:
|
||||
# Reuse the SSL session from the control connection
|
||||
conn = self.ssl_context.wrap_socket(
|
||||
conn,
|
||||
server_hostname=self.host,
|
||||
session=self.sock.session # Reuse session!
|
||||
)
|
||||
return conn, size
|
||||
|
||||
|
||||
|
||||
class BambuFTPClient:
|
||||
"""FTP client for retrieving files from Bambu Lab printers."""
|
||||
|
||||
FTP_PORT = 990
|
||||
|
||||
def __init__(self, ip_address: str, access_code: str):
|
||||
self.ip_address = ip_address
|
||||
self.access_code = access_code
|
||||
self._ftp: ImplicitFTP_TLS | None = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the printer FTP server (implicit FTPS on port 990)."""
|
||||
try:
|
||||
self._ftp = ImplicitFTP_TLS()
|
||||
self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=10)
|
||||
self._ftp.login("bblp", self.access_code)
|
||||
self._ftp.prot_p()
|
||||
self._ftp.set_pasv(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"FTP connection failed to {self.ip_address}: {e}")
|
||||
self._ftp = None
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the FTP server."""
|
||||
if self._ftp:
|
||||
try:
|
||||
self._ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
self._ftp = None
|
||||
|
||||
def list_files(self, path: str = "/") -> list[dict]:
|
||||
"""List files in a directory."""
|
||||
if not self._ftp:
|
||||
return []
|
||||
|
||||
files = []
|
||||
try:
|
||||
self._ftp.cwd(path)
|
||||
items = []
|
||||
self._ftp.retrlines("LIST", items.append)
|
||||
|
||||
for item in items:
|
||||
parts = item.split()
|
||||
if len(parts) >= 9:
|
||||
name = " ".join(parts[8:])
|
||||
is_dir = item.startswith("d")
|
||||
size = int(parts[4]) if not is_dir else 0
|
||||
files.append({
|
||||
"name": name,
|
||||
"is_directory": is_dir,
|
||||
"size": size,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return files
|
||||
|
||||
def download_file(self, remote_path: str) -> bytes | None:
|
||||
"""Download a file from the printer."""
|
||||
if not self._ftp:
|
||||
return None
|
||||
|
||||
try:
|
||||
buffer = BytesIO()
|
||||
self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
|
||||
return buffer.getvalue()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def download_to_file(self, remote_path: str, local_path: Path) -> bool:
|
||||
"""Download a file from the printer to local filesystem."""
|
||||
if not self._ftp:
|
||||
logger.warning("download_to_file called but FTP not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
self._ftp.retrbinary(f"RETR {remote_path}", f.write)
|
||||
logger.info(f"Successfully downloaded {remote_path} to {local_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download {remote_path}: {e}")
|
||||
# Clean up partial file if it exists
|
||||
if local_path.exists():
|
||||
try:
|
||||
local_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def upload_file(self, local_path: Path, remote_path: str) -> bool:
|
||||
"""Upload a file to the printer."""
|
||||
if not self._ftp:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
self._ftp.storbinary(f"STOR {remote_path}", f)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def upload_bytes(self, data: bytes, remote_path: str) -> bool:
|
||||
"""Upload bytes to the printer."""
|
||||
if not self._ftp:
|
||||
return False
|
||||
|
||||
try:
|
||||
buffer = BytesIO(data)
|
||||
self._ftp.storbinary(f"STOR {remote_path}", buffer)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_file(self, remote_path: str) -> bool:
|
||||
"""Delete a file from the printer."""
|
||||
if not self._ftp:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._ftp.delete(remote_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete {remote_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_file_size(self, remote_path: str) -> int | None:
|
||||
"""Get the size of a file."""
|
||||
if not self._ftp:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._ftp.size(remote_path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_storage_info(self) -> dict | None:
|
||||
"""Get storage information from the printer."""
|
||||
if not self._ftp:
|
||||
return None
|
||||
|
||||
result = {}
|
||||
|
||||
# Try AVBL command (available space) - some FTP servers support this
|
||||
try:
|
||||
response = self._ftp.sendcmd("AVBL")
|
||||
# Response format: "213 <bytes available>"
|
||||
if response.startswith("213"):
|
||||
parts = response.split()
|
||||
if len(parts) >= 2:
|
||||
result["free_bytes"] = int(parts[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Calculate used space by listing root directories
|
||||
try:
|
||||
total_used = 0
|
||||
dirs_to_scan = ["/cache", "/timelapse", "/model"]
|
||||
|
||||
for dir_path in dirs_to_scan:
|
||||
try:
|
||||
self._ftp.cwd(dir_path)
|
||||
items = []
|
||||
self._ftp.retrlines("LIST", items.append)
|
||||
|
||||
for item in items:
|
||||
parts = item.split()
|
||||
if len(parts) >= 5 and not item.startswith("d"):
|
||||
try:
|
||||
total_used += int(parts[4])
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result["used_bytes"] = total_used
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
async def download_file_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
remote_path: str,
|
||||
local_path: Path,
|
||||
) -> bool:
|
||||
"""Async wrapper for downloading a file."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _download():
|
||||
client = BambuFTPClient(ip_address, access_code)
|
||||
if client.connect():
|
||||
try:
|
||||
return client.download_to_file(remote_path, local_path)
|
||||
finally:
|
||||
client.disconnect()
|
||||
return False
|
||||
|
||||
return await loop.run_in_executor(None, _download)
|
||||
|
||||
|
||||
async def download_file_try_paths_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
remote_paths: list[str],
|
||||
local_path: Path,
|
||||
) -> bool:
|
||||
"""Try downloading a file from multiple paths using a single connection."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _download():
|
||||
client = BambuFTPClient(ip_address, access_code)
|
||||
if not client.connect():
|
||||
return False
|
||||
|
||||
try:
|
||||
for remote_path in remote_paths:
|
||||
if client.download_to_file(remote_path, local_path):
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
client.disconnect()
|
||||
|
||||
return await loop.run_in_executor(None, _download)
|
||||
|
||||
|
||||
async def upload_file_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
local_path: Path,
|
||||
remote_path: str,
|
||||
) -> bool:
|
||||
"""Async wrapper for uploading a file."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _upload():
|
||||
client = BambuFTPClient(ip_address, access_code)
|
||||
if client.connect():
|
||||
try:
|
||||
return client.upload_file(local_path, remote_path)
|
||||
finally:
|
||||
client.disconnect()
|
||||
return False
|
||||
|
||||
return await loop.run_in_executor(None, _upload)
|
||||
|
||||
|
||||
async def list_files_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
path: str = "/",
|
||||
) -> list[dict]:
|
||||
"""Async wrapper for listing files."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _list():
|
||||
client = BambuFTPClient(ip_address, access_code)
|
||||
if client.connect():
|
||||
try:
|
||||
return client.list_files(path)
|
||||
finally:
|
||||
client.disconnect()
|
||||
return []
|
||||
|
||||
return await loop.run_in_executor(None, _list)
|
||||
|
||||
|
||||
async def delete_file_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
remote_path: str,
|
||||
) -> bool:
|
||||
"""Async wrapper for deleting a file."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _delete():
|
||||
client = BambuFTPClient(ip_address, access_code)
|
||||
if client.connect():
|
||||
try:
|
||||
return client.delete_file(remote_path)
|
||||
finally:
|
||||
client.disconnect()
|
||||
return False
|
||||
|
||||
return await loop.run_in_executor(None, _delete)
|
||||
|
||||
|
||||
async def download_file_bytes_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
remote_path: str,
|
||||
) -> bytes | None:
|
||||
"""Async wrapper for downloading file as bytes."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _download():
|
||||
client = BambuFTPClient(ip_address, access_code)
|
||||
if client.connect():
|
||||
try:
|
||||
return client.download_file(remote_path)
|
||||
finally:
|
||||
client.disconnect()
|
||||
return None
|
||||
|
||||
return await loop.run_in_executor(None, _download)
|
||||
|
||||
|
||||
async def get_storage_info_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
) -> dict | None:
|
||||
"""Async wrapper for getting storage info."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _get_storage():
|
||||
client = BambuFTPClient(ip_address, access_code)
|
||||
if client.connect():
|
||||
try:
|
||||
return client.get_storage_info()
|
||||
finally:
|
||||
client.disconnect()
|
||||
return None
|
||||
|
||||
return await loop.run_in_executor(None, _get_storage)
|
||||
@@ -0,0 +1,234 @@
|
||||
import json
|
||||
import ssl
|
||||
import asyncio
|
||||
from typing import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrinterState:
|
||||
connected: bool = False
|
||||
state: str = "unknown"
|
||||
current_print: str | None = None
|
||||
subtask_name: str | None = None
|
||||
progress: float = 0.0
|
||||
remaining_time: int = 0
|
||||
layer_num: int = 0
|
||||
total_layers: int = 0
|
||||
temperatures: dict = field(default_factory=dict)
|
||||
raw_data: dict = field(default_factory=dict)
|
||||
gcode_file: str | None = None
|
||||
subtask_id: str | None = None
|
||||
|
||||
|
||||
class BambuMQTTClient:
|
||||
"""MQTT client for Bambu Lab printer communication."""
|
||||
|
||||
MQTT_PORT = 8883
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ip_address: str,
|
||||
serial_number: str,
|
||||
access_code: str,
|
||||
on_state_change: Callable[[PrinterState], None] | None = None,
|
||||
on_print_start: Callable[[dict], None] | None = None,
|
||||
on_print_complete: Callable[[dict], None] | None = None,
|
||||
):
|
||||
self.ip_address = ip_address
|
||||
self.serial_number = serial_number
|
||||
self.access_code = access_code
|
||||
self.on_state_change = on_state_change
|
||||
self.on_print_start = on_print_start
|
||||
self.on_print_complete = on_print_complete
|
||||
|
||||
self.state = PrinterState()
|
||||
self._client: mqtt.Client | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._previous_gcode_state: str | None = None
|
||||
self._previous_gcode_file: str | None = None
|
||||
|
||||
@property
|
||||
def topic_subscribe(self) -> str:
|
||||
return f"device/{self.serial_number}/report"
|
||||
|
||||
@property
|
||||
def topic_publish(self) -> str:
|
||||
return f"device/{self.serial_number}/request"
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc, properties=None):
|
||||
if rc == 0:
|
||||
self.state.connected = True
|
||||
client.subscribe(self.topic_subscribe)
|
||||
# Request full status update
|
||||
self._request_push_all()
|
||||
else:
|
||||
self.state.connected = False
|
||||
|
||||
def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
|
||||
self.state.connected = False
|
||||
if self.on_state_change:
|
||||
self.on_state_change(self.state)
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode())
|
||||
self._process_message(payload)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
def _process_message(self, payload: dict):
|
||||
"""Process incoming MQTT message from printer."""
|
||||
if "print" in payload:
|
||||
print_data = payload["print"]
|
||||
self._update_state(print_data)
|
||||
|
||||
def _update_state(self, data: dict):
|
||||
"""Update printer state from message data."""
|
||||
previous_state = self.state.state
|
||||
|
||||
# Update state fields
|
||||
if "gcode_state" in data:
|
||||
self.state.state = data["gcode_state"]
|
||||
if "gcode_file" in data:
|
||||
self.state.gcode_file = data["gcode_file"]
|
||||
self.state.current_print = data["gcode_file"]
|
||||
if "subtask_name" in data:
|
||||
self.state.subtask_name = data["subtask_name"]
|
||||
# Prefer subtask_name as current_print if available
|
||||
if data["subtask_name"]:
|
||||
self.state.current_print = data["subtask_name"]
|
||||
if "subtask_id" in data:
|
||||
self.state.subtask_id = data["subtask_id"]
|
||||
if "mc_percent" in data:
|
||||
self.state.progress = float(data["mc_percent"])
|
||||
if "mc_remaining_time" in data:
|
||||
self.state.remaining_time = int(data["mc_remaining_time"])
|
||||
if "layer_num" in data:
|
||||
self.state.layer_num = int(data["layer_num"])
|
||||
if "total_layer_num" in data:
|
||||
self.state.total_layers = int(data["total_layer_num"])
|
||||
|
||||
# Temperature data
|
||||
temps = {}
|
||||
if "bed_temper" in data:
|
||||
temps["bed"] = float(data["bed_temper"])
|
||||
if "bed_target_temper" in data:
|
||||
temps["bed_target"] = float(data["bed_target_temper"])
|
||||
if "nozzle_temper" in data:
|
||||
temps["nozzle"] = float(data["nozzle_temper"])
|
||||
if "nozzle_target_temper" in data:
|
||||
temps["nozzle_target"] = float(data["nozzle_target_temper"])
|
||||
if "chamber_temper" in data:
|
||||
temps["chamber"] = float(data["chamber_temper"])
|
||||
if temps:
|
||||
self.state.temperatures = temps
|
||||
|
||||
self.state.raw_data = data
|
||||
|
||||
# Detect print start (state changes TO RUNNING with a file)
|
||||
current_file = self.state.gcode_file or self.state.current_print
|
||||
is_new_print = (
|
||||
self.state.state == "RUNNING"
|
||||
and self._previous_gcode_state != "RUNNING"
|
||||
and current_file
|
||||
)
|
||||
# Also detect if file changed while running (new print started)
|
||||
is_file_change = (
|
||||
self.state.state == "RUNNING"
|
||||
and current_file
|
||||
and current_file != self._previous_gcode_file
|
||||
and self._previous_gcode_file is not None
|
||||
)
|
||||
|
||||
if (is_new_print or is_file_change) and self.on_print_start:
|
||||
self.on_print_start({
|
||||
"filename": current_file,
|
||||
"subtask_name": self.state.subtask_name,
|
||||
"raw_data": data,
|
||||
})
|
||||
|
||||
# Detect print completion
|
||||
if (
|
||||
self._previous_gcode_state == "RUNNING"
|
||||
and self.state.state in ("FINISH", "FAILED")
|
||||
and self.on_print_complete
|
||||
):
|
||||
self.on_print_complete({
|
||||
"status": "completed" if self.state.state == "FINISH" else "failed",
|
||||
"filename": self._previous_gcode_file or current_file,
|
||||
"raw_data": data,
|
||||
})
|
||||
|
||||
self._previous_gcode_state = self.state.state
|
||||
if current_file:
|
||||
self._previous_gcode_file = current_file
|
||||
|
||||
if self.on_state_change:
|
||||
self.on_state_change(self.state)
|
||||
|
||||
def _request_push_all(self):
|
||||
"""Request full status update from printer."""
|
||||
if self._client:
|
||||
message = {"pushing": {"command": "pushall"}}
|
||||
self._client.publish(self.topic_publish, json.dumps(message))
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the printer MQTT broker."""
|
||||
self._client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=f"bambutrack_{self.serial_number}",
|
||||
protocol=mqtt.MQTTv311,
|
||||
)
|
||||
|
||||
self._client.username_pw_set("bblp", self.access_code)
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
self._client.on_message = self._on_message
|
||||
|
||||
# TLS setup - Bambu uses self-signed certs
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
self._client.tls_set_context(ssl_context)
|
||||
|
||||
self._client.connect_async(self.ip_address, self.MQTT_PORT)
|
||||
self._client.loop_start()
|
||||
|
||||
def start_print(self, filename: str, plate_id: int = 1):
|
||||
"""Start a print job on the printer."""
|
||||
if self._client and self.state.connected:
|
||||
# Bambu print command format
|
||||
command = {
|
||||
"print": {
|
||||
"command": "project_file",
|
||||
"param": f"Metadata/plate_{plate_id}.gcode",
|
||||
"subtask_name": filename,
|
||||
"url": f"ftp://{filename}",
|
||||
"bed_type": "auto",
|
||||
"timelapse": False,
|
||||
"bed_leveling": True,
|
||||
"flow_cali": True,
|
||||
"vibration_cali": True,
|
||||
"layer_inspect": False,
|
||||
"use_ams": True,
|
||||
}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
return True
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the printer."""
|
||||
if self._client:
|
||||
self._client.loop_stop()
|
||||
self._client.disconnect()
|
||||
self._client = None
|
||||
self.state.connected = False
|
||||
|
||||
def send_command(self, command: dict):
|
||||
"""Send a command to the printer."""
|
||||
if self._client and self.state.connected:
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
@@ -0,0 +1,183 @@
|
||||
import asyncio
|
||||
from typing import Callable
|
||||
from dataclasses import asdict
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState
|
||||
from backend.app.services.bambu_ftp import BambuFTPClient
|
||||
|
||||
|
||||
class PrinterManager:
|
||||
"""Manager for multiple printer connections."""
|
||||
|
||||
def __init__(self):
|
||||
self._clients: dict[int, BambuMQTTClient] = {}
|
||||
self._on_print_start: Callable[[int, dict], None] | None = None
|
||||
self._on_print_complete: Callable[[int, dict], None] | None = None
|
||||
self._on_status_change: Callable[[int, PrinterState], None] | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
"""Set the event loop for async callbacks."""
|
||||
self._loop = loop
|
||||
|
||||
def set_print_start_callback(self, callback: Callable[[int, dict], None]):
|
||||
"""Set callback for print start events."""
|
||||
self._on_print_start = callback
|
||||
|
||||
def set_print_complete_callback(self, callback: Callable[[int, dict], None]):
|
||||
"""Set callback for print completion events."""
|
||||
self._on_print_complete = callback
|
||||
|
||||
def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):
|
||||
"""Set callback for status change events."""
|
||||
self._on_status_change = callback
|
||||
|
||||
def _schedule_async(self, coro):
|
||||
"""Schedule an async coroutine from a sync context."""
|
||||
if self._loop and self._loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
|
||||
async def connect_printer(self, printer: Printer) -> bool:
|
||||
"""Connect to a printer."""
|
||||
if printer.id in self._clients:
|
||||
self.disconnect_printer(printer.id)
|
||||
|
||||
printer_id = printer.id
|
||||
|
||||
def on_state_change(state: PrinterState):
|
||||
if self._on_status_change:
|
||||
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)
|
||||
)
|
||||
|
||||
def on_print_complete(data: dict):
|
||||
if self._on_print_complete:
|
||||
self._schedule_async(
|
||||
self._on_print_complete(printer_id, data)
|
||||
)
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address=printer.ip_address,
|
||||
serial_number=printer.serial_number,
|
||||
access_code=printer.access_code,
|
||||
on_state_change=on_state_change,
|
||||
on_print_start=on_print_start,
|
||||
on_print_complete=on_print_complete,
|
||||
)
|
||||
|
||||
client.connect()
|
||||
self._clients[printer_id] = client
|
||||
|
||||
# Wait a moment for connection
|
||||
await asyncio.sleep(1)
|
||||
return client.state.connected
|
||||
|
||||
def disconnect_printer(self, printer_id: int):
|
||||
"""Disconnect from a printer."""
|
||||
if printer_id in self._clients:
|
||||
self._clients[printer_id].disconnect()
|
||||
del self._clients[printer_id]
|
||||
|
||||
def disconnect_all(self):
|
||||
"""Disconnect from all printers."""
|
||||
for printer_id in list(self._clients.keys()):
|
||||
self.disconnect_printer(printer_id)
|
||||
|
||||
def get_status(self, printer_id: int) -> PrinterState | None:
|
||||
"""Get the current status of a printer."""
|
||||
if printer_id in self._clients:
|
||||
return self._clients[printer_id].state
|
||||
return None
|
||||
|
||||
def get_all_statuses(self) -> dict[int, PrinterState]:
|
||||
"""Get status of all connected printers."""
|
||||
return {
|
||||
printer_id: client.state
|
||||
for printer_id, client in self._clients.items()
|
||||
}
|
||||
|
||||
def is_connected(self, printer_id: int) -> bool:
|
||||
"""Check if a printer is connected."""
|
||||
if printer_id in self._clients:
|
||||
return self._clients[printer_id].state.connected
|
||||
return False
|
||||
|
||||
def start_print(self, printer_id: int, filename: str) -> bool:
|
||||
"""Start a print on a connected printer."""
|
||||
if printer_id in self._clients:
|
||||
return self._clients[printer_id].start_print(filename)
|
||||
return False
|
||||
|
||||
async def test_connection(
|
||||
self,
|
||||
ip_address: str,
|
||||
serial_number: str,
|
||||
access_code: str,
|
||||
) -> dict:
|
||||
"""Test connection to a printer without persisting."""
|
||||
client = BambuMQTTClient(
|
||||
ip_address=ip_address,
|
||||
serial_number=serial_number,
|
||||
access_code=access_code,
|
||||
)
|
||||
|
||||
try:
|
||||
client.connect()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
result = {
|
||||
"success": client.state.connected,
|
||||
"state": client.state.state if client.state.connected else None,
|
||||
"model": client.state.raw_data.get("device_model"),
|
||||
}
|
||||
finally:
|
||||
client.disconnect()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
|
||||
"""Convert PrinterState to a JSON-serializable dict."""
|
||||
result = {
|
||||
"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,
|
||||
}
|
||||
# Add cover URL if there's an active print and printer_id is provided
|
||||
if printer_id and state.state == "RUNNING" and state.gcode_file:
|
||||
result["cover_url"] = f"/api/v1/printers/{printer_id}/cover"
|
||||
else:
|
||||
result["cover_url"] = None
|
||||
return result
|
||||
|
||||
|
||||
# Global printer manager instance
|
||||
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)
|
||||
)
|
||||
printers = result.scalars().all()
|
||||
|
||||
for printer in printers:
|
||||
await printer_manager.connect_printer(printer)
|
||||
Reference in New Issue
Block a user