| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081 |
- """Spoolman integration API routes."""
- import json
- import logging
- from typing import Literal
- from fastapi import APIRouter, Depends, HTTPException
- from pydantic import BaseModel
- from sqlalchemy import delete, select, text
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy.orm import selectinload
- from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
- from backend.app.core.auth import RequirePermissionIfAuthEnabled
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.printer import Printer
- from backend.app.models.settings import Settings
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spoolman_k_profile import SpoolmanKProfile
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.models.user import User
- from backend.app.services.printer_manager import printer_manager
- from backend.app.services.spoolman import (
- SpoolmanClientError,
- SpoolmanNotFoundError,
- SpoolmanUnavailableError,
- close_spoolman_client,
- get_spoolman_client,
- init_spoolman_client,
- )
- from backend.app.utils.filament_ids import (
- GENERIC_FILAMENT_IDS,
- MATERIAL_TEMPS,
- normalize_slicer_filament,
- )
- 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
- reason: Literal["No RFID tag and no slot assignment"]
- filament_type: str | None = None
- color: str | None = None
- 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) -> dict:
- """Get Spoolman settings from database.
- Returns:
- Dict with keys: enabled, url, sync_mode, disable_weight_sync
- """
- settings = {
- "enabled": False,
- "url": "",
- "sync_mode": "auto",
- "disable_weight_sync": False,
- }
- result = await db.execute(select(Settings))
- for setting in result.scalars().all():
- if setting.key == "spoolman_enabled":
- settings["enabled"] = setting.value.lower() == "true"
- elif setting.key == "spoolman_url":
- settings["url"] = setting.value
- elif setting.key == "spoolman_sync_mode":
- settings["sync_mode"] = setting.value
- elif setting.key == "spoolman_disable_weight_sync":
- settings["disable_weight_sync"] = setting.value.lower() == "true"
- return settings
- @router.get("/status", response_model=SpoolmanStatus)
- async def get_spoolman_status(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
- ):
- """Get Spoolman integration status."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- """Connect to Spoolman server using configured URL."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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
- field_ok = await client.ensure_tag_extra_field()
- if not field_ok:
- logger.error("Spoolman tag extra field registration failed — NFC tag links may not persist")
- # Register slicer-preset extra fields (Spoolman rejects unknown extra keys).
- for field_name in ("bambu_slicer_filament", "bambu_slicer_filament_name"):
- if not await client.ensure_extra_field(field_name):
- logger.warning(
- "Spoolman extra field %r registration failed — spool slicer-preset edits will return 502",
- field_name,
- )
- return {"success": True, "message": f"Connected to Spoolman at {url}"}
- except ValueError as exc:
- logger.warning("Spoolman URL rejected: %s", exc)
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- except Exception as e:
- logger.error("Failed to connect to Spoolman: %s", e)
- raise HTTPException(status_code=503, detail=str(e))
- @router.post("/disconnect")
- async def disconnect_spoolman(
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- """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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
- ):
- """Sync AMS data from a specific printer to Spoolman."""
- # Check if Spoolman is enabled and connected
- sm = await get_spoolman_settings(db)
- enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
- 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 = []
- # 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("AMS dict keys for debugging: %s", list(ams_data.keys()))
- if not ams_units:
- raise HTTPException(
- status_code=400,
- detail=(
- "AMS data format not supported. Keys: "
- f"{list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}"
- ),
- )
- # OPTIMIZATION: Fetch all spools once before processing trays
- # This eliminates redundant API calls (one per tray) when syncing multiple trays
- logger.debug("[Printer %s] Fetching spools cache for sync...", printer.name)
- try:
- cached_spools = await client.get_spools()
- logger.debug("[Printer %s] Cached %d spools for batch sync", printer.name, len(cached_spools))
- except Exception as e:
- logger.error("[Printer %s] Failed to fetch spools cache after retries: %s", printer.name, e)
- raise HTTPException(
- status_code=503,
- detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
- )
- # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
- inv_weights: dict[tuple[int, int], float] = {}
- try:
- assign_result = await db.execute(
- select(SpoolAssignment)
- .options(selectinload(SpoolAssignment.spool))
- .where(SpoolAssignment.printer_id == printer_id)
- )
- for assignment in assign_result.scalars().all():
- spool = assignment.spool
- if spool and spool.label_weight > 0:
- remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
- inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
- except Exception as e:
- logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
- # Load existing Spoolman slot assignments for the no-RFID fallback path
- spoolman_slot_map: dict[tuple[int, int], int] = {}
- try:
- slot_result = await db.execute(
- select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
- )
- for slot in slot_result.scalars().all():
- spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
- except Exception as e:
- logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
- slot_changes: list[tuple[int, int, int]] = [] # (ams_id, tray_id, spoolman_spool_id)
- empty_slots: list[tuple[int, int]] = [] # (ams_id, tray_id) now empty
- 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_id_raw = int(tray_data.get("id", 0))
- tray = client.parse_ams_tray(ams_id, tray_data)
- if not tray:
- empty_slots.append((ams_id, tray_id_raw))
- continue
- spool_tag = (
- tray.tray_uuid
- if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
- else tray.tag_uid
- )
- hint = spoolman_slot_map.get((ams_id, tray.tray_id)) if not spool_tag else None
- try:
- inv_remaining = inv_weights.get((ams_id, tray.tray_id))
- sync_result = await client.sync_ams_tray(
- tray,
- printer.name,
- disable_weight_sync=disable_weight_sync,
- cached_spools=cached_spools,
- inventory_remaining=inv_remaining,
- spoolman_spool_id_hint=hint,
- )
- if sync_result:
- synced += 1
- if sync_result.get("id"):
- slot_changes.append((ams_id, tray.tray_id, sync_result["id"]))
- spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
- if not spool_exists:
- cached_spools.append(sync_result)
- logger.debug("Added newly created spool %s to cache", sync_result["id"])
- logger.info(
- "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
- )
- elif spool_tag:
- errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
- elif not hint:
- skipped.append(
- SkippedSpool(
- location=f"AMS {ams_id} T{tray.tray_id}",
- reason="No RFID tag and no slot assignment",
- filament_type=tray.tray_type or None,
- color=tray.tray_color[:6] if tray.tray_color else None,
- )
- )
- 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)
- # Persist slot assignment changes to the local table
- if slot_changes or empty_slots:
- try:
- for ams_id, tray_id, spool_id in slot_changes:
- await db.execute(
- text(
- "INSERT INTO spoolman_slot_assignments"
- " (printer_id, ams_id, tray_id, spoolman_spool_id)"
- " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
- " ON CONFLICT(printer_id, ams_id, tray_id)"
- " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
- ),
- {"printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
- )
- for ams_id, tray_id in empty_slots:
- await db.execute(
- delete(SpoolmanSlotAssignment).where(
- SpoolmanSlotAssignment.printer_id == printer_id,
- SpoolmanSlotAssignment.ams_id == ams_id,
- SpoolmanSlotAssignment.tray_id == tray_id,
- )
- )
- await db.commit()
- except Exception as e:
- await db.rollback()
- logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
- errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
- 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
- ):
- """Sync AMS data from all connected printers to Spoolman."""
- # Check if Spoolman is enabled
- sm = await get_spoolman_settings(db)
- enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
- 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 = []
- # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
- # This eliminates redundant API calls across all printers
- logger.debug("Fetching spools cache for sync-all operation...")
- try:
- cached_spools = await client.get_spools()
- logger.debug("Cached %d spools for batch sync across %d printers", len(cached_spools), len(printers))
- except Exception as e:
- logger.error("Failed to fetch spools cache after retries: %s", e)
- raise HTTPException(
- status_code=503,
- detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
- )
- # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
- # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
- inventory_weights: dict[tuple[int, int, int], float] = {}
- try:
- assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
- for assignment in assign_result.scalars().all():
- spool = assignment.spool
- if spool and spool.label_weight > 0:
- remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
- inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
- except Exception as e:
- logger.debug("Could not load inventory assignments for weight fallback: %s", e)
- # Load all Spoolman slot assignments for the no-RFID fallback
- # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
- all_slot_map: dict[tuple[int, int, int], int] = {}
- try:
- slot_result = await db.execute(select(SpoolmanSlotAssignment))
- for slot in slot_result.scalars().all():
- all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
- except Exception as e:
- logger.warning("Could not load Spoolman slot assignments: %s", e)
- # Collect slot changes across all printers for a single DB write at the end
- all_slot_changes: list[tuple[int, int, int, int]] = [] # (printer_id, ams_id, tray_id, spool_id)
- all_empty_slots: list[tuple[int, int, int]] = [] # (printer_id, ams_id, tray_id)
- 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("Printer %s AMS dict keys: %s", printer.name, list(ams_data.keys()))
- if not ams_units:
- logger.debug("Printer %s has no AMS units to sync (type: %s)", printer.name, type(ams_data).__name__)
- continue
- for ams_unit in ams_units:
- if not isinstance(ams_unit, dict):
- logger.debug("Skipping non-dict AMS unit: %s", 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_id_raw = int(tray_data.get("id", 0))
- tray = client.parse_ams_tray(ams_id, tray_data)
- if not tray:
- all_empty_slots.append((printer.id, ams_id, tray_id_raw))
- continue
- spool_tag = (
- tray.tray_uuid
- if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
- else tray.tag_uid
- )
- hint = all_slot_map.get((printer.id, ams_id, tray.tray_id)) if not spool_tag else None
- try:
- inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))
- sync_result = await client.sync_ams_tray(
- tray,
- printer.name,
- disable_weight_sync=disable_weight_sync,
- cached_spools=cached_spools,
- inventory_remaining=inv_remaining,
- spoolman_spool_id_hint=hint,
- )
- if sync_result:
- total_synced += 1
- if sync_result.get("id"):
- all_slot_changes.append((printer.id, ams_id, tray.tray_id, sync_result["id"]))
- spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
- if not spool_exists:
- cached_spools.append(sync_result)
- logger.debug("Added newly created spool %s to cache", sync_result["id"])
- elif spool_tag:
- all_errors.append(f"Spool not found in Spoolman: {printer.name} AMS {ams_id}:{tray.tray_id}")
- elif not hint:
- all_skipped.append(
- SkippedSpool(
- location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
- reason="No RFID tag and no slot assignment",
- filament_type=tray.tray_type or None,
- color=tray.tray_color[:6] if tray.tray_color else None,
- )
- )
- except Exception as e:
- all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
- # Persist slot assignment changes across all printers
- if all_slot_changes or all_empty_slots:
- try:
- for p_id, ams_id, tray_id, spool_id in all_slot_changes:
- await db.execute(
- text(
- "INSERT INTO spoolman_slot_assignments"
- " (printer_id, ams_id, tray_id, spoolman_spool_id)"
- " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
- " ON CONFLICT(printer_id, ams_id, tray_id)"
- " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
- ),
- {"printer_id": p_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
- )
- for p_id, ams_id, tray_id in all_empty_slots:
- await db.execute(
- delete(SpoolmanSlotAssignment).where(
- SpoolmanSlotAssignment.printer_id == p_id,
- SpoolmanSlotAssignment.ams_id == ams_id,
- SpoolmanSlotAssignment.tray_id == tray_id,
- )
- )
- await db.commit()
- except Exception as e:
- await db.rollback()
- logger.error("Error persisting Spoolman slot assignments: %s", e)
- all_errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
- 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
- ):
- """Get all spools from Spoolman."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
- ):
- """Get all filaments from Spoolman."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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_vendor: 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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
- ):
- """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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_vendor=(filament.get("vendor") or {}).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),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
- ):
- """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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, dict] = {}
- 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:
- filament = spool.get("filament") or {}
- linked[clean_tag] = {
- "id": spool["id"],
- "remaining_weight": spool.get("remaining_weight"),
- "filament_weight": filament.get("weight"),
- }
- return {"linked": linked}
- class LinkSpoolRequest(BaseModel):
- """Request to link a Spoolman spool to an AMS tag (tray_uuid or tag_uid)."""
- spool_tag: str | None = None
- tray_uuid: str | None = None
- tag_uid: str | None = None
- printer_id: int | None = None
- ams_id: int | None = None
- tray_id: int | None = None
- @router.post("/spools/{spool_id}/link")
- async def link_spool(
- spool_id: int,
- request: LinkSpoolRequest,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
- ):
- """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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")
- # Resolve and validate spool tag (supports tray_uuid=32 hex and tag_uid=16 hex)
- spool_tag = (request.spool_tag or request.tray_uuid or request.tag_uid or "").strip()
- if not spool_tag:
- raise HTTPException(status_code=400, detail="Missing spool tag (tray_uuid or tag_uid)")
- if len(spool_tag) not in (16, 32):
- raise HTTPException(status_code=400, detail="Invalid spool tag format (must be 16 or 32 hex characters)")
- try:
- int(spool_tag, 16)
- except ValueError:
- raise HTTPException(status_code=400, detail="Invalid spool tag format (must be hex)")
- if set(spool_tag) == {"0"}:
- raise HTTPException(status_code=400, detail="Invalid spool tag format (all-zero tag is not linkable)")
- spool_tag = spool_tag.upper()
- # Validate printer context when provided, but do NOT write spool.location —
- # that field is user-managed in Spoolman. Slot assignment is stored locally.
- printer_context: tuple[int, int, int] | None = None
- if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
- printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
- if not printer_result.scalar_one_or_none():
- raise HTTPException(status_code=404, detail="Printer not found")
- printer_context = (request.printer_id, request.ams_id, request.tray_id)
- try:
- await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
- except SpoolmanNotFoundError:
- raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
- except SpoolmanClientError:
- raise HTTPException(status_code=502, detail="Spoolman rejected the request")
- except SpoolmanUnavailableError:
- raise HTTPException(status_code=503, detail="Spoolman is not reachable")
- # Upsert slot assignment locally when printer context was supplied
- if printer_context:
- p_id, a_id, t_id = printer_context
- try:
- await db.execute(
- text(
- "INSERT INTO spoolman_slot_assignments"
- " (printer_id, ams_id, tray_id, spoolman_spool_id)"
- " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
- " ON CONFLICT(printer_id, ams_id, tray_id)"
- " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
- ),
- {"printer_id": p_id, "ams_id": a_id, "tray_id": t_id, "spool_id": spool_id},
- )
- await db.commit()
- except Exception as e:
- await db.rollback()
- logger.error(
- "Linked spool %s in Spoolman but failed to persist local slot assignment "
- "(printer=%s ams=%s tray=%s): %s",
- spool_id,
- p_id,
- a_id,
- t_id,
- e,
- )
- raise HTTPException(
- status_code=500,
- detail=(
- "Spool linked in Spoolman but the local slot assignment could not be saved. "
- "Please re-open the link dialog to retry."
- ),
- ) from e
- logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
- # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
- if printer_context:
- p_id, a_id, t_id = printer_context
- try:
- spool_data = await client.get_spool(spool_id)
- mapped = _map_spoolman_spool(spool_data)
- mqtt_client = printer_manager.get_client(p_id)
- if mqtt_client:
- tray_type = mapped.get("material") or ""
- brand = mapped.get("brand") or ""
- subtype = mapped.get("subtype") or ""
- if brand:
- tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
- elif subtype:
- tray_sub_brands = f"{tray_type} {subtype}".strip()
- else:
- tray_sub_brands = tray_type
- tray_color = (mapped.get("rgba") or "808080FF").upper()
- if len(tray_color) == 6:
- tray_color = tray_color + "FF"
- material_upper = tray_type.upper().strip()
- tray_info_idx = (
- GENERIC_FILAMENT_IDS.get(material_upper)
- or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
- or ""
- )
- setting_id = ""
- temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
- temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
- temp_max = temp_defaults[1]
- # Pull printer state via printer_manager (mqtt_client.printer_state
- # was a non-existent attribute — the hasattr check silently
- # returned None, defeating every state-based lookup below).
- state = printer_manager.get_status(p_id)
- nozzle_diameter = "0.4"
- if state and state.nozzles:
- nd = state.nozzles[0].nozzle_diameter
- if nd:
- nozzle_diameter = nd
- kp_result = await db.execute(
- select(SpoolmanKProfile).where(
- SpoolmanKProfile.spoolman_spool_id == spool_id,
- SpoolmanKProfile.printer_id == p_id,
- )
- )
- kp_rows = kp_result.scalars().all()
- slot_extruder = None
- if state and state.ams_extruder_map:
- if a_id == 255:
- slot_extruder = 1 - t_id
- else:
- slot_extruder = state.ams_extruder_map.get(str(a_id))
- # Prefer exact extruder match, fall back to extruder-agnostic kp
- # for the same nozzle. Hard-skip on extruder mismatch silently
- # dropped valid stored profiles when the AMS-extruder map
- # shifted since calibration.
- exact_kp = None
- fallback_kp = None
- for kp in kp_rows:
- if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
- continue
- if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
- exact_kp = kp
- break
- if fallback_kp is None:
- fallback_kp = kp
- matching_kp = exact_kp or fallback_kp
- # Resolve printer-side calibration entry by cali_idx — the
- # printer keys its calibration table by filament_id, not by
- # setting_id. Stored kp.setting_id alone isn't enough.
- printer_kp = None
- if matching_kp and state and state.kprofiles:
- for pkp in state.kprofiles:
- if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
- printer_kp = pkp
- break
- # Realign slot's filament context to the kp's calibration
- # context so ams_filament_setting and extrusion_cali_sel
- # reference the same preset; otherwise the printer drops the
- # cali_idx to default. PFUS-prefix cloud-user presets are
- # rejected by the slicer in tray_info_idx — skip realignment
- # in that case.
- effective_tray_info_idx = tray_info_idx
- effective_setting_id = setting_id
- if printer_kp and printer_kp.filament_id:
- if not printer_kp.filament_id.startswith("PFUS"):
- effective_tray_info_idx = printer_kp.filament_id
- if printer_kp.setting_id:
- effective_setting_id = printer_kp.setting_id
- elif matching_kp and matching_kp.setting_id:
- derived = normalize_slicer_filament(matching_kp.setting_id)[0]
- if derived and not derived.startswith("PFUS"):
- effective_tray_info_idx = derived
- effective_setting_id = matching_kp.setting_id
- if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
- logger.info(
- "Spoolman link: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
- tray_info_idx,
- effective_tray_info_idx,
- setting_id,
- effective_setting_id,
- matching_kp.id if matching_kp else None,
- "printer" if printer_kp else "stored",
- )
- mqtt_client.ams_set_filament_setting(
- ams_id=a_id,
- tray_id=t_id,
- tray_info_idx=effective_tray_info_idx,
- tray_type=tray_type,
- tray_sub_brands=tray_sub_brands,
- tray_color=tray_color,
- nozzle_temp_min=temp_min,
- nozzle_temp_max=temp_max,
- setting_id=effective_setting_id,
- )
- if matching_kp and matching_kp.cali_idx is not None:
- cali_filament_id = (
- printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
- ) or effective_tray_info_idx
- mqtt_client.extrusion_cali_sel(
- ams_id=a_id,
- tray_id=t_id,
- cali_idx=matching_kp.cali_idx,
- filament_id=cali_filament_id,
- nozzle_diameter=nozzle_diameter,
- )
- logger.info(
- "Spoolman link: applied K-profile cali_idx=%d "
- "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
- matching_kp.cali_idx,
- matching_kp.id,
- cali_filament_id,
- spool_id,
- p_id,
- a_id,
- t_id,
- )
- else:
- from backend.app.api.routes.inventory import _find_tray_in_ams_data # noqa: PLC0415
- live_tray = None
- if state and state.raw_data:
- ams_raw = state.raw_data.get("ams", [])
- if isinstance(ams_raw, dict):
- ams_raw = ams_raw.get("ams", [])
- live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
- live_cali_idx = (live_tray or {}).get("cali_idx")
- if live_cali_idx is not None and live_cali_idx >= 0:
- mqtt_client.extrusion_cali_sel(
- ams_id=a_id,
- tray_id=t_id,
- cali_idx=live_cali_idx,
- filament_id=effective_tray_info_idx,
- nozzle_diameter=nozzle_diameter,
- )
- logger.info(
- "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
- a_id,
- t_id,
- spool_id,
- p_id,
- )
- except (SpoolmanNotFoundError, SpoolmanUnavailableError) as e:
- logger.warning(
- "Could not fetch Spoolman spool %d for MQTT configure after tag link: %s",
- spool_id,
- e,
- )
- except Exception:
- logger.exception(
- "Failed to auto-configure AMS slot after linking Spoolman spool %d (printer=%d ams=%d tray=%d)",
- spool_id,
- p_id,
- a_id,
- t_id,
- )
- return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
- @router.post("/spools/{spool_id}/unlink")
- async def unlink_spool(
- spool_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
- ):
- """Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
- sm = await get_spoolman_settings(db)
- enabled, url = sm["enabled"], sm["url"]
- 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")
- # Spoolman PATCHes the extra dict by MERGING with the existing keys —
- # popping "tag" from a copy of the dict and sending the rest doesn't
- # clear it; Spoolman keeps the old value because the key wasn't in the
- # payload. To actually clear a key we must explicitly send it as the
- # JSON-encoded empty string ('""'), which the read-side filters in
- # _map_spoolman_spool and get_linked_spools strip via .strip('"').
- #
- # merge_spool_extra acquires extra_lock(spool_id) internally — wrapping
- # this call in another `async with client.extra_lock(spool_id)` would
- # deadlock (asyncio.Lock is not reentrant).
- try:
- await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
- except SpoolmanNotFoundError:
- raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
- except SpoolmanClientError:
- raise HTTPException(status_code=502, detail="Spoolman rejected the request")
- except SpoolmanUnavailableError:
- raise HTTPException(status_code=503, detail="Spoolman is not reachable")
- # Remove local slot assignment for this spool (all slots — a spool can only be in one at a time)
- try:
- await db.execute(delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id))
- await db.commit()
- except Exception:
- await db.rollback()
- logger.exception("DB error removing slot assignment for spool %s", spool_id)
- raise HTTPException(status_code=500, detail="Failed to remove local slot assignment")
- logger.info("Unlinked Spoolman spool %s", spool_id)
- return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
|