| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141 |
- import json
- import logging
- import httpx
- from fastapi import APIRouter, Depends, HTTPException
- from fastapi.responses import StreamingResponse
- from pydantic import BaseModel
- from sqlalchemy import func, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy.orm import selectinload
- from backend.app.core.auth import RequirePermissionIfAuthEnabled
- from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.color_catalog import ColorCatalogEntry
- from backend.app.models.spool import Spool
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spool_catalog import SpoolCatalogEntry
- from backend.app.models.spool_k_profile import SpoolKProfile
- from backend.app.models.user import User
- from backend.app.schemas.spool import (
- SpoolAssignmentCreate,
- SpoolAssignmentResponse,
- SpoolBulkCreate,
- SpoolCreate,
- SpoolKProfileBase,
- SpoolKProfileResponse,
- SpoolResponse,
- SpoolUpdate,
- )
- from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
- from backend.app.utils.filament_ids import normalize_slicer_filament
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/inventory", tags=["inventory"])
- # Material temperature defaults (nozzle min/max)
- MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
- "PLA": (190, 230),
- "PETG": (220, 260),
- "ABS": (240, 270),
- "ASA": (240, 270),
- "TPU": (200, 240),
- "PA": (260, 290),
- "PC": (250, 280),
- "PVA": (190, 210),
- "PLA-CF": (210, 240),
- "PETG-CF": (240, 270),
- "PA-CF": (270, 300),
- }
- # FilamentColors.xyz API
- FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
- # ── Spool Catalog Schemas ──────────────────────────────────────────────────
- class CatalogEntryResponse(BaseModel):
- id: int
- name: str
- weight: int
- is_default: bool
- class Config:
- from_attributes = True
- class CatalogEntryCreate(BaseModel):
- name: str
- weight: int
- class CatalogEntryUpdate(BaseModel):
- name: str
- weight: int
- # ── Color Catalog Schemas ──────────────────────────────────────────────────
- class ColorEntryResponse(BaseModel):
- id: int
- manufacturer: str
- color_name: str
- hex_color: str
- material: str | None
- is_default: bool
- class Config:
- from_attributes = True
- class ColorEntryCreate(BaseModel):
- manufacturer: str
- color_name: str
- hex_color: str
- material: str | None = None
- class ColorEntryUpdate(BaseModel):
- manufacturer: str
- color_name: str
- hex_color: str
- material: str | None = None
- class ColorLookupResult(BaseModel):
- found: bool
- hex_color: str | None = None
- material: str | None = None
- # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
- @router.get("/catalog", response_model=list[CatalogEntryResponse])
- async def get_spool_catalog(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """Get all spool catalog entries."""
- result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
- return list(result.scalars().all())
- @router.post("/catalog", response_model=CatalogEntryResponse)
- async def add_catalog_entry(
- entry: CatalogEntryCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Add a new spool catalog entry."""
- row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
- db.add(row)
- await db.commit()
- await db.refresh(row)
- return row
- @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
- async def update_catalog_entry(
- entry_id: int,
- entry: CatalogEntryUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Update a spool catalog entry."""
- result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
- row = result.scalar_one_or_none()
- if not row:
- raise HTTPException(404, "Entry not found")
- row.name = entry.name
- row.weight = entry.weight
- await db.commit()
- await db.refresh(row)
- return row
- @router.delete("/catalog/{entry_id}")
- async def delete_catalog_entry(
- entry_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Delete a spool catalog entry."""
- result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
- row = result.scalar_one_or_none()
- if not row:
- raise HTTPException(404, "Entry not found")
- await db.delete(row)
- await db.commit()
- return {"status": "deleted"}
- @router.post("/catalog/reset")
- async def reset_spool_catalog(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Reset spool catalog to defaults."""
- await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
- # Delete all
- result = await db.execute(select(SpoolCatalogEntry))
- for row in result.scalars().all():
- await db.delete(row)
- # Re-seed defaults
- for name, weight in DEFAULT_SPOOL_CATALOG:
- db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
- await db.commit()
- return {"status": "reset"}
- # ── Color Catalog CRUD ─────────────────────────────────────────────────────
- @router.get("/colors", response_model=list[ColorEntryResponse])
- async def get_color_catalog(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """Get all color catalog entries."""
- result = await db.execute(
- select(ColorCatalogEntry).order_by(
- ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
- )
- )
- return list(result.scalars().all())
- @router.post("/colors", response_model=ColorEntryResponse)
- async def add_color_entry(
- entry: ColorEntryCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Add a new color catalog entry."""
- row = ColorCatalogEntry(
- manufacturer=entry.manufacturer,
- color_name=entry.color_name,
- hex_color=entry.hex_color,
- material=entry.material,
- is_default=False,
- )
- db.add(row)
- await db.commit()
- await db.refresh(row)
- return row
- @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
- async def update_color_entry(
- entry_id: int,
- entry: ColorEntryUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Update a color catalog entry."""
- result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
- row = result.scalar_one_or_none()
- if not row:
- raise HTTPException(404, "Entry not found")
- row.manufacturer = entry.manufacturer
- row.color_name = entry.color_name
- row.hex_color = entry.hex_color
- row.material = entry.material
- await db.commit()
- await db.refresh(row)
- return row
- @router.delete("/colors/{entry_id}")
- async def delete_color_entry(
- entry_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Delete a color catalog entry."""
- result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
- row = result.scalar_one_or_none()
- if not row:
- raise HTTPException(404, "Entry not found")
- await db.delete(row)
- await db.commit()
- return {"status": "deleted"}
- @router.post("/colors/reset")
- async def reset_color_catalog(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Reset color catalog to defaults."""
- result = await db.execute(select(ColorCatalogEntry))
- for row in result.scalars().all():
- await db.delete(row)
- for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
- db.add(
- ColorCatalogEntry(
- manufacturer=manufacturer,
- color_name=color_name,
- hex_color=hex_color,
- material=material,
- is_default=True,
- )
- )
- await db.commit()
- return {"status": "reset"}
- @router.get("/colors/lookup", response_model=ColorLookupResult)
- async def lookup_color(
- manufacturer: str,
- color_name: str,
- material: str | None = None,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """Look up a color by manufacturer and color name."""
- query = select(ColorCatalogEntry).where(
- ColorCatalogEntry.manufacturer == manufacturer,
- ColorCatalogEntry.color_name == color_name,
- )
- if material:
- query = query.where(ColorCatalogEntry.material == material)
- query = query.limit(1)
- result = await db.execute(query)
- row = result.scalar_one_or_none()
- if row:
- return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
- return ColorLookupResult(found=False)
- @router.get("/colors/search", response_model=list[ColorEntryResponse])
- async def search_colors(
- manufacturer: str | None = None,
- material: str | None = None,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """Search colors by manufacturer and/or material."""
- query = select(ColorCatalogEntry)
- if manufacturer:
- query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
- if material:
- query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
- query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
- result = await db.execute(query)
- return list(result.scalars().all())
- @router.post("/colors/sync")
- async def sync_from_filamentcolors(
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Sync colors from FilamentColors.xyz API with progress streaming."""
- async def generate():
- from backend.app.core.database import async_session
- added = 0
- skipped = 0
- total_fetched = 0
- total_available = 0
- try:
- async with httpx.AsyncClient(timeout=120.0) as client:
- page = 1
- while True:
- response = await client.get(
- f"{FILAMENT_COLORS_API}/swatch/",
- params={"page": page},
- )
- response.raise_for_status()
- data = response.json()
- total_available = data.get("count", total_available)
- results = data.get("results", [])
- if not results:
- break
- async with async_session() as db:
- for swatch in results:
- total_fetched += 1
- manufacturer_data = swatch.get("manufacturer")
- manufacturer_name = (
- manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
- )
- filament_type_data = swatch.get("filament_type")
- mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
- color_name_val = swatch.get("color_name", "")
- hex_color_val = swatch.get("hex_color", "")
- if not manufacturer_name or not color_name_val or not hex_color_val:
- skipped += 1
- continue
- if not hex_color_val.startswith("#"):
- hex_color_val = f"#{hex_color_val}"
- # Check if entry already exists
- existing = await db.execute(
- select(ColorCatalogEntry)
- .where(
- ColorCatalogEntry.manufacturer == manufacturer_name,
- ColorCatalogEntry.color_name == color_name_val,
- ColorCatalogEntry.material == mat,
- )
- .limit(1)
- )
- if existing.scalar_one_or_none():
- skipped += 1
- else:
- db.add(
- ColorCatalogEntry(
- manufacturer=manufacturer_name,
- color_name=color_name_val,
- hex_color=hex_color_val.upper(),
- material=mat,
- is_default=False,
- )
- )
- added += 1
- await db.commit()
- progress = {
- "type": "progress",
- "added": added,
- "skipped": skipped,
- "total_fetched": total_fetched,
- "total_available": total_available,
- }
- yield f"data: {json.dumps(progress)}\n\n"
- if not data.get("next") or total_fetched >= total_available:
- break
- page += 1
- result = {
- "type": "complete",
- "added": added,
- "skipped": skipped,
- "total_fetched": total_fetched,
- "total_available": total_available,
- }
- yield f"data: {json.dumps(result)}\n\n"
- except httpx.HTTPError as e:
- logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
- yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
- except Exception as e:
- logger.error("Error syncing from FilamentColors.xyz: %s", e)
- yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
- return StreamingResponse(generate(), media_type="text/event-stream")
- # ── Spool CRUD ───────────────────────────────────────────────────────────────
- @router.get("/spools", response_model=list[SpoolResponse])
- async def list_spools(
- include_archived: bool = False,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """List all spools, excluding archived by default."""
- query = select(Spool).options(selectinload(Spool.k_profiles))
- if not include_archived:
- query = query.where(Spool.archived_at.is_(None))
- query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
- result = await db.execute(query)
- return list(result.scalars().all())
- @router.get("/spools/{spool_id}", response_model=SpoolResponse)
- async def get_spool(
- spool_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """Get a single spool with k_profiles."""
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
- spool = result.scalar_one_or_none()
- if not spool:
- raise HTTPException(404, "Spool not found")
- return spool
- @router.post("/spools", response_model=SpoolResponse)
- async def create_spool(
- spool_data: SpoolCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Create a new spool."""
- spool = Spool(**spool_data.model_dump())
- db.add(spool)
- await db.commit()
- await db.refresh(spool)
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
- return result.scalar_one()
- @router.post("/spools/bulk", response_model=list[SpoolResponse])
- async def bulk_create_spools(
- data: SpoolBulkCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Create multiple identical spools."""
- spools = []
- for _ in range(data.quantity):
- spool = Spool(**data.spool.model_dump())
- db.add(spool)
- spools.append(spool)
- await db.commit()
- ids = [s.id for s in spools]
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
- return list(result.scalars().all())
- @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
- async def update_spool(
- spool_id: int,
- spool_data: SpoolUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Update a spool."""
- result = await db.execute(select(Spool).where(Spool.id == spool_id))
- spool = result.scalar_one_or_none()
- if not spool:
- raise HTTPException(404, "Spool not found")
- update_data = spool_data.model_dump(exclude_unset=True)
- # Auto-lock weight when user explicitly sets weight_used
- if "weight_used" in update_data and "weight_locked" not in update_data:
- update_data["weight_locked"] = True
- for field, value in update_data.items():
- setattr(spool, field, value)
- await db.commit()
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
- return result.scalar_one()
- @router.delete("/spools/{spool_id}")
- async def delete_spool(
- spool_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Hard delete a spool."""
- result = await db.execute(select(Spool).where(Spool.id == spool_id))
- spool = result.scalar_one_or_none()
- if not spool:
- raise HTTPException(404, "Spool not found")
- await db.delete(spool)
- await db.commit()
- return {"status": "deleted"}
- @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
- async def archive_spool(
- spool_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Soft-delete a spool by setting archived_at."""
- from datetime import datetime, timezone
- result = await db.execute(select(Spool).where(Spool.id == spool_id))
- spool = result.scalar_one_or_none()
- if not spool:
- raise HTTPException(404, "Spool not found")
- spool.archived_at = datetime.now(timezone.utc)
- await db.commit()
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
- return result.scalar_one()
- @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
- async def restore_spool(
- spool_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Restore an archived spool."""
- result = await db.execute(select(Spool).where(Spool.id == spool_id))
- spool = result.scalar_one_or_none()
- if not spool:
- raise HTTPException(404, "Spool not found")
- spool.archived_at = None
- await db.commit()
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
- return result.scalar_one()
- # ── K-Profiles ───────────────────────────────────────────────────────────────
- @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
- async def list_k_profiles(
- spool_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """List K-profiles for a spool."""
- result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
- return list(result.scalars().all())
- @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
- async def replace_k_profiles(
- spool_id: int,
- profiles: list[SpoolKProfileBase],
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Replace all K-profiles for a spool (batch save)."""
- # Verify spool exists
- result = await db.execute(select(Spool).where(Spool.id == spool_id))
- if not result.scalar_one_or_none():
- raise HTTPException(404, "Spool not found")
- # Delete existing
- existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
- for old in existing.scalars().all():
- await db.delete(old)
- # Create new
- new_profiles = []
- for p in profiles:
- kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
- db.add(kp)
- new_profiles.append(kp)
- await db.commit()
- for kp in new_profiles:
- await db.refresh(kp)
- return new_profiles
- # ── Spool Assignments ────────────────────────────────────────────────────────
- @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
- async def list_assignments(
- printer_id: int | None = None,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """List spool assignments, optionally filtered by printer."""
- query = select(SpoolAssignment).options(
- selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
- selectinload(SpoolAssignment.printer),
- )
- if printer_id is not None:
- query = query.where(SpoolAssignment.printer_id == printer_id)
- result = await db.execute(query)
- return list(result.scalars().all())
- @router.post("/assignments", response_model=SpoolAssignmentResponse)
- async def assign_spool(
- data: SpoolAssignmentCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Assign a spool to an AMS slot and auto-configure via MQTT."""
- from backend.app.services.printer_manager import printer_manager
- # 1. Validate spool exists and is not archived
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
- spool = result.scalar_one_or_none()
- if not spool:
- raise HTTPException(404, "Spool not found")
- if spool.archived_at:
- raise HTTPException(400, "Cannot assign an archived spool")
- # 2. Get current AMS tray state for fingerprint + existing filament ID
- fingerprint_color = None
- fingerprint_type = None
- current_tray_info_idx = ""
- state = printer_manager.get_status(data.printer_id)
- if state and state.raw_data:
- if data.ams_id == 255:
- # External slot: look up tray from vt_tray by global ID
- vt_tray = state.raw_data.get("vt_tray") or []
- ext_id = data.tray_id + 254 # 0→254, 1→255
- for vt in vt_tray:
- if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
- fingerprint_color = vt.get("tray_color", "")
- fingerprint_type = vt.get("tray_type", "")
- current_tray_info_idx = vt.get("tray_info_idx", "")
- break
- else:
- ams_data = state.raw_data.get("ams", {})
- ams_list = (
- ams_data.get("ams", [])
- if isinstance(ams_data, dict)
- else ams_data
- if isinstance(ams_data, list)
- else []
- )
- tray = _find_tray_in_ams_data(
- ams_list,
- data.ams_id,
- data.tray_id,
- )
- if tray:
- fingerprint_color = tray.get("tray_color", "")
- fingerprint_type = tray.get("tray_type", "")
- current_tray_info_idx = tray.get("tray_info_idx", "")
- # 3. Upsert assignment (replace if same printer+ams+tray)
- existing = await db.execute(
- select(SpoolAssignment).where(
- SpoolAssignment.printer_id == data.printer_id,
- SpoolAssignment.ams_id == data.ams_id,
- SpoolAssignment.tray_id == data.tray_id,
- )
- )
- old = existing.scalar_one_or_none()
- if old:
- await db.delete(old)
- await db.flush()
- assignment = SpoolAssignment(
- spool_id=data.spool_id,
- printer_id=data.printer_id,
- ams_id=data.ams_id,
- tray_id=data.tray_id,
- fingerprint_color=fingerprint_color,
- fingerprint_type=fingerprint_type,
- )
- db.add(assignment)
- await db.commit()
- await db.refresh(assignment)
- # 4. Auto-configure AMS slot via MQTT
- configured = False
- try:
- client = printer_manager.get_client(data.printer_id)
- if client:
- # Build filament setting from spool data
- tray_type = spool.material
- tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
- tray_color = spool.rgba or "FFFFFFFF"
- tray_info_idx, setting_id = normalize_slicer_filament(spool.slicer_filament)
- # Resolve tray_info_idx for the MQTT command.
- # Priority:
- # 1. Use the spool's own slicer_filament if set (including
- # cloud-synced custom presets like PFUS* / P*).
- # 2. Reuse the slot's existing tray_info_idx if it's a specific
- # (non-generic) preset for the same material.
- # 3. Fall back to a generic Bambu filament ID.
- _GENERIC_IDS = {
- "PLA": "GFL99",
- "PETG": "GFG99",
- "ABS": "GFB99",
- "ASA": "GFB98",
- "PC": "GFC99",
- "PA": "GFN99",
- "NYLON": "GFN99",
- "TPU": "GFU99",
- "PVA": "GFS99",
- "HIPS": "GFS98",
- "PLA-CF": "GFL98",
- "PETG-CF": "GFG98",
- "PA-CF": "GFN98",
- "PETG HF": "GFG96",
- }
- _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
- if tray_info_idx:
- logger.info("Spool assign: using spool's slicer_filament=%r", tray_info_idx)
- elif (
- current_tray_info_idx
- and current_tray_info_idx not in _GENERIC_ID_VALUES
- and fingerprint_type
- and fingerprint_type.upper() == tray_type.upper()
- ):
- logger.info(
- "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
- current_tray_info_idx,
- tray_type,
- )
- tray_info_idx = current_tray_info_idx
- elif tray_type:
- material = tray_type.upper().strip()
- generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
- if generic:
- logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
- tray_info_idx = generic
- # Temperature: use spool overrides if set, else material defaults
- temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
- if spool.nozzle_temp_min is not None:
- temp_min = spool.nozzle_temp_min
- if spool.nozzle_temp_max is not None:
- temp_max = spool.nozzle_temp_max
- # a. Set filament setting
- client.ams_set_filament_setting(
- ams_id=data.ams_id,
- tray_id=data.tray_id,
- tray_info_idx=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=setting_id,
- )
- # b. Look up K-profile for this spool + printer + nozzle + extruder
- nozzle_diameter = "0.4"
- if state and state.nozzles:
- nd = state.nozzles[0].nozzle_diameter
- if nd:
- nozzle_diameter = nd
- # Determine slot's extruder from ams_extruder_map
- slot_extruder = None
- if state and state.ams_extruder_map:
- if data.ams_id == 255:
- # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
- slot_extruder = 1 - data.tray_id # 0→1, 1→0
- else:
- slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
- matching_kp = None
- for kp in spool.k_profiles:
- if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
- if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
- continue
- matching_kp = kp
- break
- if matching_kp and matching_kp.cali_idx is not None:
- client.extrusion_cali_sel(
- ams_id=data.ams_id,
- tray_id=data.tray_id,
- cali_idx=matching_kp.cali_idx,
- filament_id=tray_info_idx,
- nozzle_diameter=nozzle_diameter,
- )
- configured = True
- logger.info(
- "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
- data.ams_id,
- data.tray_id,
- spool.id,
- data.printer_id,
- )
- except Exception as e:
- logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
- # Return assignment with spool data
- result = await db.execute(
- select(SpoolAssignment)
- .options(
- selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
- selectinload(SpoolAssignment.printer),
- )
- .where(SpoolAssignment.id == assignment.id)
- )
- resp = result.scalar_one()
- response = SpoolAssignmentResponse.model_validate(resp)
- response.configured = configured
- return response
- @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
- async def unassign_spool(
- printer_id: int,
- ams_id: int,
- tray_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Unassign a spool from an AMS slot."""
- result = await db.execute(
- select(SpoolAssignment).where(
- SpoolAssignment.printer_id == printer_id,
- SpoolAssignment.ams_id == ams_id,
- SpoolAssignment.tray_id == tray_id,
- )
- )
- assignment = result.scalar_one_or_none()
- if not assignment:
- raise HTTPException(404, "Assignment not found")
- await db.delete(assignment)
- await db.commit()
- return {"status": "deleted"}
- # ── Tag Linking ───────────────────────────────────────────────────────────────
- class LinkTagRequest(BaseModel):
- tag_uid: str | None = None
- tray_uuid: str | None = None
- tag_type: str | None = None
- data_origin: str | None = "nfc_link"
- @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
- async def link_tag_to_spool(
- spool_id: int,
- data: LinkTagRequest,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Link an RFID tag_uid/tray_uuid to an existing spool."""
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
- spool = result.scalar_one_or_none()
- if not spool:
- raise HTTPException(404, "Spool not found")
- if spool.archived_at:
- raise HTTPException(400, "Cannot link tag to archived spool")
- # Check for conflicts: tag already linked to another active spool
- if data.tag_uid:
- conflict = await db.execute(
- select(Spool).where(
- Spool.tag_uid == data.tag_uid,
- Spool.id != spool_id,
- Spool.archived_at.is_(None),
- )
- )
- if conflict.scalar_one_or_none():
- raise HTTPException(409, "Tag UID already linked to another active spool")
- # Auto-clear from archived spools (tag recycling)
- archived_with_tag = await db.execute(
- select(Spool).where(
- Spool.tag_uid == data.tag_uid,
- Spool.id != spool_id,
- Spool.archived_at.is_not(None),
- )
- )
- for old_spool in archived_with_tag.scalars().all():
- old_spool.tag_uid = None
- if data.tray_uuid:
- conflict = await db.execute(
- select(Spool).where(
- Spool.tray_uuid == data.tray_uuid,
- Spool.id != spool_id,
- Spool.archived_at.is_(None),
- )
- )
- if conflict.scalar_one_or_none():
- raise HTTPException(409, "Tray UUID already linked to another active spool")
- archived_with_uuid = await db.execute(
- select(Spool).where(
- Spool.tray_uuid == data.tray_uuid,
- Spool.id != spool_id,
- Spool.archived_at.is_not(None),
- )
- )
- for old_spool in archived_with_uuid.scalars().all():
- old_spool.tray_uuid = None
- if data.tag_uid is not None:
- spool.tag_uid = data.tag_uid
- if data.tray_uuid is not None:
- spool.tray_uuid = data.tray_uuid
- if data.tag_type is not None:
- spool.tag_type = data.tag_type
- if data.data_origin is not None:
- spool.data_origin = data.data_origin
- await db.commit()
- result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
- return result.scalar_one()
- # ── Usage History ─────────────────────────────────────────────────────────────
- @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
- async def get_spool_usage_history(
- spool_id: int,
- limit: int = 50,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """Get usage history for a specific spool."""
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- # Verify spool exists
- spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
- if not spool_result.scalar_one_or_none():
- raise HTTPException(404, "Spool not found")
- result = await db.execute(
- select(SpoolUsageHistory)
- .where(SpoolUsageHistory.spool_id == spool_id)
- .order_by(SpoolUsageHistory.created_at.desc())
- .limit(limit)
- )
- return list(result.scalars().all())
- @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
- async def get_all_usage_history(
- limit: int = 100,
- printer_id: int | None = None,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
- ):
- """Get global usage history, optionally filtered by printer."""
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
- if printer_id is not None:
- query = query.where(SpoolUsageHistory.printer_id == printer_id)
- result = await db.execute(query)
- return list(result.scalars().all())
- @router.delete("/spools/{spool_id}/usage")
- async def clear_spool_usage_history(
- spool_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Clear usage history for a spool."""
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
- for row in result.scalars().all():
- await db.delete(row)
- await db.commit()
- return {"status": "cleared"}
- # ── AMS Weight Sync ──────────────────────────────────────────────────────────
- @router.post("/sync-ams-weights")
- async def sync_weights_from_ams(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ):
- """Force-sync spool weight_used from live AMS remain% data.
- Overwrites the database weight_used for every assigned spool using the
- current AMS remain% from connected printers. This is a manual recovery
- tool — it bypasses the normal "only increase" guard.
- """
- from backend.app.services.printer_manager import printer_manager
- result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
- assignments = list(result.scalars().all())
- logger.info("AMS weight sync: found %d assignments", len(assignments))
- synced = 0
- skipped = 0
- for assignment in assignments:
- spool = assignment.spool
- if not spool:
- logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
- skipped += 1
- continue
- if spool.weight_locked:
- logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
- skipped += 1
- continue
- state = printer_manager.get_status(assignment.printer_id)
- if not state or not state.raw_data:
- logger.info(
- "AMS weight sync: printer %d not connected, skipping spool %d",
- assignment.printer_id,
- spool.id,
- )
- skipped += 1
- continue
- ams_raw = state.raw_data.get("ams", [])
- if isinstance(ams_raw, dict):
- ams_raw = ams_raw.get("ams", [])
- tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
- if not tray:
- logger.info(
- "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
- spool.id,
- assignment.printer_id,
- assignment.ams_id,
- assignment.tray_id,
- )
- skipped += 1
- continue
- remain_raw = tray.get("remain")
- if remain_raw is None:
- logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
- skipped += 1
- continue
- try:
- remain_val = int(remain_raw)
- except (TypeError, ValueError):
- skipped += 1
- continue
- if remain_val < 0 or remain_val > 100:
- logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
- skipped += 1
- continue
- lw = spool.label_weight or 1000
- new_used = round(lw * (100 - remain_val) / 100.0, 1)
- old_used = spool.weight_used or 0
- if round(old_used, 1) != new_used:
- logger.info(
- "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
- spool.id,
- old_used,
- new_used,
- remain_val,
- )
- spool.weight_used = new_used
- synced += 1
- else:
- skipped += 1
- await db.commit()
- return {"synced": synced, "skipped": skipped}
- # ── Helpers ──────────────────────────────────────────────────────────────────
- def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
- """Find a specific tray in the AMS data structure."""
- if not ams_data:
- return None
- for ams_unit in ams_data:
- if int(ams_unit.get("id", -1)) != ams_id:
- continue
- for tray in ams_unit.get("tray", []):
- if int(tray.get("id", -1)) == tray_id:
- return tray
- return None
|