| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970 |
- 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,
- SpoolCreate,
- SpoolKProfileBase,
- SpoolKProfileResponse,
- SpoolResponse,
- SpoolUpdate,
- )
- from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
- 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.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")
- for field, value in spool_data.model_dump(exclude_unset=True).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
- fingerprint_color = None
- fingerprint_type = None
- 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", "")
- 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", "")
- # 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 = spool.slicer_filament or ""
- setting_id = ""
- # 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_id is not None and kp.extruder_id != 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"}
- # ── 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
|