mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""Spoolman integration API routes."""
|
|
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from pydantic import BaseModel
|
|
|
|
from backend.app.core.database import get_db
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.models.settings import Settings
|
|
from backend.app.services.spoolman import (
|
|
SpoolmanClient,
|
|
get_spoolman_client,
|
|
init_spoolman_client,
|
|
close_spoolman_client,
|
|
)
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/spoolman", tags=["spoolman"])
|
|
|
|
|
|
class SpoolmanStatus(BaseModel):
|
|
"""Spoolman connection status."""
|
|
|
|
enabled: bool
|
|
connected: bool
|
|
url: str | None
|
|
|
|
|
|
class SyncResult(BaseModel):
|
|
"""Result of a Spoolman sync operation."""
|
|
|
|
success: bool
|
|
synced_count: int
|
|
errors: list[str]
|
|
|
|
|
|
async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
|
|
"""Get Spoolman settings from database.
|
|
|
|
Returns:
|
|
Tuple of (enabled, url, sync_mode)
|
|
"""
|
|
enabled = False
|
|
url = ""
|
|
sync_mode = "auto"
|
|
|
|
result = await db.execute(select(Settings))
|
|
for setting in result.scalars().all():
|
|
if setting.key == "spoolman_enabled":
|
|
enabled = setting.value.lower() == "true"
|
|
elif setting.key == "spoolman_url":
|
|
url = setting.value
|
|
elif setting.key == "spoolman_sync_mode":
|
|
sync_mode = setting.value
|
|
|
|
return enabled, url, sync_mode
|
|
|
|
|
|
@router.get("/status", response_model=SpoolmanStatus)
|
|
async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
|
|
"""Get Spoolman integration status."""
|
|
enabled, url, _ = await get_spoolman_settings(db)
|
|
|
|
client = await get_spoolman_client()
|
|
connected = False
|
|
if client:
|
|
connected = await client.health_check()
|
|
|
|
return SpoolmanStatus(
|
|
enabled=enabled,
|
|
connected=connected,
|
|
url=url if url else None,
|
|
)
|
|
|
|
|
|
@router.post("/connect")
|
|
async def connect_spoolman(db: AsyncSession = Depends(get_db)):
|
|
"""Connect to Spoolman server using configured URL."""
|
|
enabled, url, _ = await get_spoolman_settings(db)
|
|
|
|
if not enabled:
|
|
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
|
|
|
if not url:
|
|
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
|
|
|
try:
|
|
client = await init_spoolman_client(url)
|
|
connected = await client.health_check()
|
|
|
|
if not connected:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Could not connect to Spoolman at {url}",
|
|
)
|
|
|
|
return {"success": True, "message": f"Connected to Spoolman at {url}"}
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to Spoolman: {e}")
|
|
raise HTTPException(status_code=503, detail=str(e))
|
|
|
|
|
|
@router.post("/disconnect")
|
|
async def disconnect_spoolman():
|
|
"""Disconnect from Spoolman server."""
|
|
await close_spoolman_client()
|
|
return {"success": True, "message": "Disconnected from Spoolman"}
|
|
|
|
|
|
@router.post("/sync/{printer_id}", response_model=SyncResult)
|
|
async def sync_printer_ams(
|
|
printer_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Sync AMS data from a specific printer to Spoolman."""
|
|
# Check if Spoolman is enabled and connected
|
|
enabled, url, _ = await get_spoolman_settings(db)
|
|
if not enabled:
|
|
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
|
|
|
client = await get_spoolman_client()
|
|
if not client:
|
|
# Try to connect
|
|
if url:
|
|
client = await init_spoolman_client(url)
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
|
|
|
if not await client.health_check():
|
|
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
|
|
|
# Get printer info
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(status_code=404, detail="Printer not found")
|
|
|
|
# Get current printer state with AMS data
|
|
state = printer_manager.get_status(printer_id)
|
|
if not state:
|
|
raise HTTPException(status_code=404, detail="Printer not connected")
|
|
|
|
if not state.raw_data:
|
|
raise HTTPException(status_code=400, detail="No AMS data available")
|
|
|
|
ams_data = state.raw_data.get("ams")
|
|
if not ams_data:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
|
|
)
|
|
|
|
# Sync each AMS tray to Spoolman
|
|
synced = 0
|
|
errors = []
|
|
|
|
# Handle different AMS data structures
|
|
# Traditional AMS: list of {"id": N, "tray": [...]} dicts
|
|
# H2D/newer printers: dict with different structure
|
|
ams_units = []
|
|
if isinstance(ams_data, list):
|
|
ams_units = ams_data
|
|
elif isinstance(ams_data, dict):
|
|
# H2D format: check for "ams" key containing list, or "tray" key directly
|
|
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
|
ams_units = ams_data["ams"]
|
|
elif "tray" in ams_data:
|
|
# Single AMS unit format - wrap in list
|
|
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
|
else:
|
|
logger.info(f"AMS dict keys for debugging: {list(ams_data.keys())}")
|
|
|
|
if not ams_units:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
|
|
)
|
|
|
|
for ams_unit in ams_units:
|
|
if not isinstance(ams_unit, dict):
|
|
continue
|
|
|
|
ams_id = int(ams_unit.get("id", 0))
|
|
trays = ams_unit.get("tray", [])
|
|
|
|
for tray_data in trays:
|
|
if not isinstance(tray_data, dict):
|
|
continue
|
|
|
|
tray = client.parse_ams_tray(ams_id, tray_data)
|
|
if not tray:
|
|
continue # Empty tray
|
|
|
|
# Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
|
|
if not client.is_bambu_lab_spool(tray.tray_uuid):
|
|
continue
|
|
|
|
try:
|
|
sync_result = await client.sync_ams_tray(tray, printer.name)
|
|
if sync_result:
|
|
synced += 1
|
|
logger.info(
|
|
f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}"
|
|
)
|
|
else:
|
|
# Bambu Lab spool that wasn't synced (not found in Spoolman)
|
|
errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
|
|
except Exception as e:
|
|
error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
|
|
logger.error(error_msg)
|
|
errors.append(error_msg)
|
|
|
|
return SyncResult(
|
|
success=len(errors) == 0,
|
|
synced_count=synced,
|
|
errors=errors,
|
|
)
|
|
|
|
|
|
@router.post("/sync-all", response_model=SyncResult)
|
|
async def sync_all_printers(db: AsyncSession = Depends(get_db)):
|
|
"""Sync AMS data from all connected printers to Spoolman."""
|
|
# Check if Spoolman is enabled
|
|
enabled, url, _ = await get_spoolman_settings(db)
|
|
if not enabled:
|
|
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
|
|
|
client = await get_spoolman_client()
|
|
if not client:
|
|
if url:
|
|
client = await init_spoolman_client(url)
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
|
|
|
if not await client.health_check():
|
|
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
|
|
|
# Get all active printers
|
|
result = await db.execute(select(Printer).where(Printer.is_active == True))
|
|
printers = result.scalars().all()
|
|
|
|
total_synced = 0
|
|
all_errors = []
|
|
|
|
for printer in printers:
|
|
state = printer_manager.get_status(printer.id)
|
|
if not state or not state.raw_data:
|
|
continue
|
|
|
|
ams_data = state.raw_data.get("ams")
|
|
if not ams_data:
|
|
continue
|
|
|
|
# Handle different AMS data structures
|
|
# Traditional AMS: list of {"id": N, "tray": [...]} dicts
|
|
# H2D/newer printers: dict with different structure
|
|
ams_units = []
|
|
if isinstance(ams_data, list):
|
|
ams_units = ams_data
|
|
elif isinstance(ams_data, dict):
|
|
# H2D format: check for "ams" key containing list, or "tray" key directly
|
|
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
|
ams_units = ams_data["ams"]
|
|
elif "tray" in ams_data:
|
|
# Single AMS unit format - wrap in list
|
|
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
|
else:
|
|
logger.debug(f"Printer {printer.name} AMS dict keys: {list(ams_data.keys())}")
|
|
|
|
if not ams_units:
|
|
logger.debug(f"Printer {printer.name} has no AMS units to sync (type: {type(ams_data).__name__})")
|
|
continue
|
|
|
|
for ams_unit in ams_units:
|
|
if not isinstance(ams_unit, dict):
|
|
logger.debug(f"Skipping non-dict AMS unit: {type(ams_unit)}")
|
|
continue
|
|
|
|
ams_id = int(ams_unit.get("id", 0))
|
|
trays = ams_unit.get("tray", [])
|
|
|
|
for tray_data in trays:
|
|
if not isinstance(tray_data, dict):
|
|
continue
|
|
|
|
tray = client.parse_ams_tray(ams_id, tray_data)
|
|
if not tray:
|
|
continue
|
|
|
|
# Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
|
|
if not client.is_bambu_lab_spool(tray.tray_uuid):
|
|
continue
|
|
|
|
try:
|
|
sync_result = await client.sync_ams_tray(tray, printer.name)
|
|
if sync_result:
|
|
total_synced += 1
|
|
except Exception as e:
|
|
all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
|
|
|
|
return SyncResult(
|
|
success=len(all_errors) == 0,
|
|
synced_count=total_synced,
|
|
errors=all_errors,
|
|
)
|
|
|
|
|
|
@router.get("/spools")
|
|
async def get_spools(db: AsyncSession = Depends(get_db)):
|
|
"""Get all spools from Spoolman."""
|
|
enabled, url, _ = await get_spoolman_settings(db)
|
|
if not enabled:
|
|
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
|
|
|
client = await get_spoolman_client()
|
|
if not client:
|
|
if url:
|
|
client = await init_spoolman_client(url)
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
|
|
|
if not await client.health_check():
|
|
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
|
|
|
spools = await client.get_spools()
|
|
return {"spools": spools}
|
|
|
|
|
|
@router.get("/filaments")
|
|
async def get_filaments(db: AsyncSession = Depends(get_db)):
|
|
"""Get all filaments from Spoolman."""
|
|
enabled, url, _ = await get_spoolman_settings(db)
|
|
if not enabled:
|
|
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
|
|
|
client = await get_spoolman_client()
|
|
if not client:
|
|
if url:
|
|
client = await init_spoolman_client(url)
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
|
|
|
if not await client.health_check():
|
|
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
|
|
|
filaments = await client.get_filaments()
|
|
return {"filaments": filaments}
|