mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
When a spool is already linked in Spoolman, the FilamentHoverCard now shows "Open in Spoolman" button instead of "Link to Spoolman". This allows users to quickly navigate to the spool's page in Spoolman for editing. Changes: - Add GET /api/v1/spoolman/spools/linked endpoint returning tag->spool_id map - FilamentHoverCard shows "Open in Spoolman" when linkedSpoolId is set - "Link to Spoolman" only shows when spool is not linked - Fix unlinked spools detection to strip JSON quotes from empty tags - Add toast notifications for link success/failure - Invalidate linked-spools query after linking - Add backend tests for linked spools endpoint - Add frontend tests for LinkSpoolModal Closes #210
572 lines
20 KiB
Python
572 lines
20 KiB
Python
"""Spoolman integration API routes."""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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.printer_manager import printer_manager
|
|
from backend.app.services.spoolman import (
|
|
close_spoolman_client,
|
|
get_spoolman_client,
|
|
init_spoolman_client,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/spoolman", tags=["spoolman"])
|
|
|
|
|
|
class SpoolmanStatus(BaseModel):
|
|
"""Spoolman connection status."""
|
|
|
|
enabled: bool
|
|
connected: bool
|
|
url: str | None
|
|
|
|
|
|
class SkippedSpool(BaseModel):
|
|
"""Information about a skipped spool during sync."""
|
|
|
|
location: str # e.g., "AMS A1" or "External Spool"
|
|
reason: str # e.g., "Not a Bambu Lab spool", "Empty tray"
|
|
filament_type: str | None = None # e.g., "PLA", "PETG"
|
|
color: str | None = None # Hex color
|
|
|
|
|
|
class SyncResult(BaseModel):
|
|
"""Result of a Spoolman sync operation."""
|
|
|
|
success: bool
|
|
synced_count: int
|
|
skipped_count: int = 0
|
|
skipped: list[SkippedSpool] = []
|
|
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}",
|
|
)
|
|
|
|
# Ensure the 'tag' extra field exists for RFID/UUID storage
|
|
await client.ensure_tag_extra_field()
|
|
|
|
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
|
|
skipped: list[SkippedSpool] = []
|
|
errors = []
|
|
# Track tray UUIDs currently in the AMS (for clearing removed spools)
|
|
current_tray_uuids: set[str] = set()
|
|
|
|
# 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 - nothing to sync
|
|
|
|
# Build location string for reporting
|
|
location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
|
|
|
|
# Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
|
|
if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
|
|
skipped.append(
|
|
SkippedSpool(
|
|
location=location,
|
|
reason="Non-Bambu Lab spool (no RFID tag)",
|
|
filament_type=tray.tray_type if tray.tray_type else None,
|
|
color=tray.tray_color[:6] if tray.tray_color else None,
|
|
)
|
|
)
|
|
continue
|
|
|
|
# Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
|
|
spool_tag = (
|
|
tray.tray_uuid
|
|
if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
|
|
else tray.tag_uid
|
|
)
|
|
if spool_tag:
|
|
current_tray_uuids.add(spool_tag.upper())
|
|
|
|
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)
|
|
|
|
# Clear location for spools that were removed from this printer's AMS
|
|
try:
|
|
cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids)
|
|
if cleared > 0:
|
|
logger.info(f"Cleared location for {cleared} spools removed from {printer.name}")
|
|
except Exception as e:
|
|
logger.error(f"Error clearing locations for removed spools: {e}")
|
|
|
|
return SyncResult(
|
|
success=len(errors) == 0,
|
|
synced_count=synced,
|
|
skipped_count=len(skipped),
|
|
skipped=skipped,
|
|
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.is_(True)))
|
|
printers = result.scalars().all()
|
|
|
|
total_synced = 0
|
|
all_skipped: list[SkippedSpool] = []
|
|
all_errors = []
|
|
# Track tray UUIDs per printer (for clearing removed spools)
|
|
printer_tray_uuids: dict[str, set[str]] = {}
|
|
|
|
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
|
|
|
|
# Initialize tray UUID set for this printer
|
|
printer_tray_uuids[printer.name] = set()
|
|
|
|
# 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
|
|
|
|
# Build location string for reporting
|
|
location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}"
|
|
|
|
# Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
|
|
if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
|
|
all_skipped.append(
|
|
SkippedSpool(
|
|
location=location,
|
|
reason="Non-Bambu Lab spool (no RFID tag)",
|
|
filament_type=tray.tray_type if tray.tray_type else None,
|
|
color=tray.tray_color[:6] if tray.tray_color else None,
|
|
)
|
|
)
|
|
continue
|
|
|
|
# Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
|
|
spool_tag = (
|
|
tray.tray_uuid
|
|
if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
|
|
else tray.tag_uid
|
|
)
|
|
if spool_tag:
|
|
printer_tray_uuids[printer.name].add(spool_tag.upper())
|
|
|
|
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}")
|
|
|
|
# Clear location for spools that were removed from each printer's AMS
|
|
for printer_name, current_tray_uuids in printer_tray_uuids.items():
|
|
try:
|
|
cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids)
|
|
if cleared > 0:
|
|
logger.info(f"Cleared location for {cleared} spools removed from {printer_name}")
|
|
except Exception as e:
|
|
logger.error(f"Error clearing locations for {printer_name}: {e}")
|
|
|
|
return SyncResult(
|
|
success=len(all_errors) == 0,
|
|
synced_count=total_synced,
|
|
skipped_count=len(all_skipped),
|
|
skipped=all_skipped,
|
|
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}
|
|
|
|
|
|
class UnlinkedSpool(BaseModel):
|
|
"""A Spoolman spool that is not linked to any AMS tray."""
|
|
|
|
id: int
|
|
filament_name: str | None
|
|
filament_material: str | None
|
|
filament_color_hex: str | None
|
|
remaining_weight: float | None
|
|
location: str | None
|
|
|
|
|
|
@router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
|
|
async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
|
|
"""Get all Spoolman spools that don't have a tag (not linked to AMS)."""
|
|
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()
|
|
unlinked = []
|
|
|
|
for spool in spools:
|
|
# Check if spool has a tag in extra field
|
|
extra = spool.get("extra", {}) or {}
|
|
tag = extra.get("tag", "")
|
|
# Remove quotes if present (JSON encoded string) and check if empty
|
|
clean_tag = tag.strip('"') if tag else ""
|
|
if not clean_tag:
|
|
filament = spool.get("filament", {}) or {}
|
|
unlinked.append(
|
|
UnlinkedSpool(
|
|
id=spool["id"],
|
|
filament_name=filament.get("name"),
|
|
filament_material=filament.get("material"),
|
|
filament_color_hex=filament.get("color_hex"),
|
|
remaining_weight=spool.get("remaining_weight"),
|
|
location=spool.get("location"),
|
|
)
|
|
)
|
|
|
|
return unlinked
|
|
|
|
|
|
@router.get("/spools/linked")
|
|
async def get_linked_spools(db: AsyncSession = Depends(get_db)):
|
|
"""Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
|
|
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()
|
|
linked: dict[str, int] = {}
|
|
|
|
for spool in spools:
|
|
# Check if spool has a tag in extra field
|
|
extra = spool.get("extra", {}) or {}
|
|
tag = extra.get("tag", "")
|
|
if tag:
|
|
# Remove quotes if present (JSON encoded string)
|
|
clean_tag = tag.strip('"').upper()
|
|
if clean_tag:
|
|
linked[clean_tag] = spool["id"]
|
|
|
|
return {"linked": linked}
|
|
|
|
|
|
class LinkSpoolRequest(BaseModel):
|
|
"""Request to link a Spoolman spool to an AMS tray."""
|
|
|
|
tray_uuid: str
|
|
|
|
|
|
@router.post("/spools/{spool_id}/link")
|
|
async def link_spool(
|
|
spool_id: int,
|
|
request: LinkSpoolRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
|
|
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")
|
|
|
|
# Validate tray_uuid format (32 hex characters)
|
|
tray_uuid = request.tray_uuid.strip()
|
|
if len(tray_uuid) != 32:
|
|
raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
|
|
try:
|
|
int(tray_uuid, 16)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
|
|
|
|
# Update spool with tag
|
|
# Note: Spoolman extra field values must be valid JSON, so we encode the string
|
|
import json
|
|
|
|
result = await client.update_spool(
|
|
spool_id=spool_id,
|
|
extra={"tag": json.dumps(tray_uuid)},
|
|
)
|
|
|
|
if result:
|
|
logger.info(f"Linked Spoolman spool {spool_id} to tray_uuid {tray_uuid}")
|
|
return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Failed to update spool")
|