mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Spool and color_catalog rows carry extra_colors (comma-separated hex stops) and effect_type (14 visual variants: surface effects, sheen, structural). The shared FilamentSwatch component renders gradient, conic, effect overlay, and alpha-checkerboard consistently across the inventory grid, table, group banner, card, ColorSection preview, and catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries can carry transparency too. The paste field accepts the exact format 3dfilamentprofiles.com puts on its filament details pages, so users can copy a multi-colour combo directly. The effect dropdown spans the full filament-variant vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen variants (silk/galaxy/rainbow/metal/translucent), and structural variants (gradient/dual-color/tri-color/multicolor). None of these fields touch MQTT/firmware -- pure visual hint. Spool group-key extended to include extra_colors + effect_type so "Group similar" no longer collapses visually distinct spools. Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores VARCHAR length). Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14 FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping regression). 3522 backend + 1582 frontend tests pass; ruff clean. Localised across all 8 UI locales.
1512 lines
58 KiB
Python
1512 lines
58 KiB
Python
import json
|
|
import logging
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
|
|
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.core.websocket import ws_manager
|
|
from backend.app.models.ams_label import AmsLabel
|
|
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,
|
|
normalize_effect_type,
|
|
normalize_extra_colors,
|
|
)
|
|
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
|
|
from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
|
|
from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
|
|
|
|
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
|
|
|
|
|
|
class BulkDeleteIdsRequest(BaseModel):
|
|
ids: list[int]
|
|
|
|
|
|
# ── Color Catalog Schemas ──────────────────────────────────────────────────
|
|
|
|
|
|
class ColorEntryResponse(BaseModel):
|
|
id: int
|
|
manufacturer: str
|
|
color_name: str
|
|
hex_color: str
|
|
material: str | None
|
|
is_default: bool
|
|
extra_colors: str | None = None
|
|
effect_type: str | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
_HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
|
|
|
|
|
|
class ColorEntryCreate(BaseModel):
|
|
manufacturer: str
|
|
color_name: str
|
|
hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
|
|
material: str | None = None
|
|
extra_colors: str | None = None
|
|
effect_type: str | None = None
|
|
|
|
@field_validator("extra_colors")
|
|
@classmethod
|
|
def _validate_extra_colors(cls, v: str | None) -> str | None:
|
|
return normalize_extra_colors(v)
|
|
|
|
@field_validator("effect_type")
|
|
@classmethod
|
|
def _validate_effect_type(cls, v: str | None) -> str | None:
|
|
return normalize_effect_type(v)
|
|
|
|
|
|
class ColorEntryUpdate(BaseModel):
|
|
manufacturer: str
|
|
color_name: str
|
|
hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
|
|
material: str | None = None
|
|
extra_colors: str | None = None
|
|
effect_type: str | None = None
|
|
|
|
@field_validator("extra_colors")
|
|
@classmethod
|
|
def _validate_extra_colors(cls, v: str | None) -> str | None:
|
|
return normalize_extra_colors(v)
|
|
|
|
@field_validator("effect_type")
|
|
@classmethod
|
|
def _validate_effect_type(cls, v: str | None) -> str | None:
|
|
return normalize_effect_type(v)
|
|
|
|
|
|
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/bulk-delete")
|
|
async def bulk_delete_catalog_entries(
|
|
data: BulkDeleteIdsRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
):
|
|
"""Delete multiple spool catalog entries by ID."""
|
|
if not data.ids:
|
|
return {"deleted": 0}
|
|
result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
|
|
rows = result.scalars().all()
|
|
for row in rows:
|
|
await db.delete(row)
|
|
await db.commit()
|
|
return {"deleted": len(rows)}
|
|
|
|
|
|
@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.get("/colors/map")
|
|
async def get_color_name_map(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = Depends(require_auth_if_enabled),
|
|
):
|
|
"""Compact {hex: name} map for frontend color-name resolution.
|
|
|
|
Not gated on INVENTORY_READ — every page that renders a spool color needs
|
|
this, including read-only views available to users without inventory access.
|
|
Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
|
|
share the same hex (different materials or manufacturers), Bambu Lab wins,
|
|
then default entries, then the first encountered.
|
|
"""
|
|
result = await db.execute(
|
|
select(
|
|
ColorCatalogEntry.hex_color,
|
|
ColorCatalogEntry.color_name,
|
|
ColorCatalogEntry.manufacturer,
|
|
ColorCatalogEntry.is_default,
|
|
)
|
|
)
|
|
mapping: dict[str, tuple[str, int]] = {} # hex → (name, priority); higher priority wins
|
|
for hex_color, color_name, manufacturer, is_default in result.all():
|
|
if not hex_color or not color_name:
|
|
continue
|
|
key = hex_color.lstrip("#").lower()[:6]
|
|
if len(key) != 6:
|
|
continue
|
|
priority = 0
|
|
if manufacturer and manufacturer.strip().lower() == "bambu lab":
|
|
priority += 2
|
|
if is_default:
|
|
priority += 1
|
|
existing = mapping.get(key)
|
|
if existing is None or priority > existing[1]:
|
|
mapping[key] = (color_name, priority)
|
|
return {"colors": {k: v[0] for k, v in mapping.items()}}
|
|
|
|
|
|
@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,
|
|
extra_colors=entry.extra_colors,
|
|
effect_type=entry.effect_type,
|
|
)
|
|
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
|
|
row.extra_colors = entry.extra_colors
|
|
row.effect_type = entry.effect_type
|
|
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/bulk-delete")
|
|
async def bulk_delete_color_entries(
|
|
data: BulkDeleteIdsRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
|
):
|
|
"""Delete multiple color catalog entries by ID."""
|
|
if not data.ids:
|
|
return {"deleted": 0}
|
|
result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
|
|
rows = result.scalars().all()
|
|
for row in rows:
|
|
await db.delete(row)
|
|
await db.commit()
|
|
return {"deleted": len(rows)}
|
|
|
|
|
|
@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))
|
|
await ws_manager.broadcast({"type": "inventory_changed"})
|
|
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)))
|
|
await ws_manager.broadcast({"type": "inventory_changed"})
|
|
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))
|
|
await ws_manager.broadcast({"type": "inventory_changed"})
|
|
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()
|
|
await ws_manager.broadcast({"type": "inventory_changed"})
|
|
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))
|
|
await ws_manager.broadcast({"type": "inventory_changed"})
|
|
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))
|
|
await ws_manager.broadcast({"type": "inventory_changed"})
|
|
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_VIEW_ASSIGNMENTS),
|
|
):
|
|
"""List spool assignments, optionally filtered by printer."""
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
|
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)
|
|
assignments = list(result.scalars().all())
|
|
|
|
# Build (printer_id, ams_id) -> ams_serial map from live printer states.
|
|
# Fetch all statuses in one call rather than one get_status() call per printer.
|
|
serial_map: dict[tuple[int, int], str] = {}
|
|
seen_printer_ids: set[int] = {a.printer_id for a in assignments}
|
|
all_statuses = printer_manager.get_all_statuses()
|
|
for pid in seen_printer_ids:
|
|
state = all_statuses.get(pid)
|
|
if state and state.raw_data:
|
|
for ams_unit in state.raw_data.get("ams", []):
|
|
sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
|
|
if sn:
|
|
try:
|
|
serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
# Fetch all relevant AMS labels keyed by serial number
|
|
all_serials = set(serial_map.values())
|
|
# Also include synthetic fallback keys for assignments without a known serial
|
|
synthetic_keys: dict[str, tuple[int, int]] = {}
|
|
for a in assignments:
|
|
if (a.printer_id, a.ams_id) not in serial_map:
|
|
synthetic = f"p{a.printer_id}a{a.ams_id}"
|
|
synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
|
|
all_serials.add(synthetic)
|
|
|
|
label_by_serial: dict[str, str] = {}
|
|
if all_serials:
|
|
lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
|
|
for lbl in lbl_result.scalars().all():
|
|
label_by_serial[lbl.ams_serial_number] = lbl.label
|
|
|
|
# Build response objects, attaching ams_label where available
|
|
responses: list[SpoolAssignmentResponse] = []
|
|
for a in assignments:
|
|
resp = SpoolAssignmentResponse.model_validate(a)
|
|
sn = serial_map.get((a.printer_id, a.ams_id))
|
|
if sn and sn in label_by_serial:
|
|
resp.ams_label = label_by_serial[sn]
|
|
elif not sn:
|
|
synthetic = f"p{a.printer_id}a{a.ams_id}"
|
|
resp.ams_label = label_by_serial.get(synthetic)
|
|
responses.append(resp)
|
|
|
|
return responses
|
|
|
|
|
|
@router.post("/assignments", response_model=SpoolAssignmentResponse)
|
|
async def assign_spool(
|
|
data: SpoolAssignmentCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: 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.brand} {spool.material} {spool.subtype}".strip()
|
|
if spool.brand
|
|
else f"{spool.material} {spool.subtype}"
|
|
if spool.subtype
|
|
else spool.material
|
|
)
|
|
tray_color = spool.rgba or "FFFFFFFF"
|
|
|
|
_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())
|
|
|
|
# Resolve tray_info_idx + setting_id for the MQTT command.
|
|
# Three sources in priority order:
|
|
# 1. Cloud profile (if cloud connected) — resolve filament_id
|
|
# from setting_id via cloud API
|
|
# 2. Local profile — use generic filament ID for material
|
|
# 3. Hard-coded fallback — generic Bambu filament IDs
|
|
tray_info_idx = ""
|
|
setting_id = ""
|
|
sf = spool.slicer_filament or ""
|
|
|
|
if sf:
|
|
# Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
|
|
base_sf = sf.split("_")[0] if "_" in sf else sf
|
|
if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
|
|
# Cloud setting_id — need to resolve real filament_id
|
|
# Use base_sf (version suffix stripped) for cloud API + MQTT
|
|
setting_id = base_sf
|
|
try:
|
|
from backend.app.api.routes.cloud import build_authenticated_cloud
|
|
|
|
cloud = await build_authenticated_cloud(db, current_user)
|
|
if cloud is not None and cloud.is_authenticated:
|
|
try:
|
|
detail = await cloud.get_setting_detail(base_sf)
|
|
if detail.get("filament_id"):
|
|
tray_info_idx = detail["filament_id"]
|
|
logger.info(
|
|
"Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
|
|
tray_info_idx,
|
|
sf,
|
|
)
|
|
# Use cloud preset name for tray_sub_brands if available
|
|
cloud_name = detail.get("name", "")
|
|
if cloud_name:
|
|
tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
|
|
elif detail.get("base_id"):
|
|
# Derive from base_id (e.g. "GFSL05" → "GFL05")
|
|
bid = detail["base_id"].split("_")[0]
|
|
if bid.startswith("GFS") and len(bid) >= 5:
|
|
tray_info_idx = f"GF{bid[3:]}"
|
|
else:
|
|
tray_info_idx = bid
|
|
logger.info(
|
|
"Spool assign: derived filament_id=%r from base_id=%r",
|
|
tray_info_idx,
|
|
detail["base_id"],
|
|
)
|
|
finally:
|
|
await cloud.close()
|
|
elif cloud is not None:
|
|
await cloud.close()
|
|
except Exception as e:
|
|
logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
|
|
|
|
if not tray_info_idx:
|
|
# Cloud lookup failed — use normalize as fallback
|
|
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
|
elif base_sf.startswith("GF"):
|
|
# Official Bambu filament_id (e.g. "GFL05")
|
|
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
|
logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
|
|
|
|
else:
|
|
# Could be a local preset ID or material type — try local DB
|
|
try:
|
|
local_id = int(sf)
|
|
from backend.app.models.local_preset import LocalPreset as LP
|
|
|
|
lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
|
|
lp = lp_result.scalar_one_or_none()
|
|
if lp:
|
|
mat = (spool.material or lp.filament_type or "").upper().strip()
|
|
tray_info_idx = (
|
|
_GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
|
|
)
|
|
# Use local preset name for tray_sub_brands
|
|
if lp.name:
|
|
tray_sub_brands = lp.name.split("@")[0].strip()
|
|
logger.info(
|
|
"Spool assign: local preset %d, material=%r, tray_info_idx=%r",
|
|
local_id,
|
|
mat,
|
|
tray_info_idx,
|
|
)
|
|
except (ValueError, TypeError):
|
|
# Not a numeric ID — treat as material type string
|
|
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
|
|
|
# Cross-check: the cloud API returns the base filament_id for
|
|
# versioned setting_ids (e.g. GFSL99 → GFL99 for all PLA variants).
|
|
# If the spool has a specific preset name (e.g. "Generic PLA Silk"),
|
|
# reverse-lookup the correct filament_id from the built-in table.
|
|
if tray_info_idx and spool.slicer_filament_name:
|
|
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
|
|
|
|
expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
|
|
if expected_name and expected_name != spool.slicer_filament_name:
|
|
for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
|
|
if fname == spool.slicer_filament_name:
|
|
logger.info(
|
|
"Spool assign: corrected filament_id %r→%r (name=%r)",
|
|
tray_info_idx,
|
|
fid,
|
|
spool.slicer_filament_name,
|
|
)
|
|
tray_info_idx = fid
|
|
setting_id = filament_id_to_setting_id(fid)
|
|
break
|
|
|
|
if not tray_info_idx:
|
|
# Fallback: reuse slot's existing tray_info_idx or generic ID
|
|
if (
|
|
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,
|
|
)
|
|
|
|
# Save slot preset mapping so the UI shows the correct preset name.
|
|
# Use slicer_filament_name (authoritative) with fallback to tray_sub_brands.
|
|
try:
|
|
from backend.app.models.slot_preset import SlotPresetMapping
|
|
|
|
preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
|
|
preset_source = "cloud"
|
|
if sf:
|
|
base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
|
|
try:
|
|
local_id = int(base_sf_mapping)
|
|
preset_id_to_save = f"local_{local_id}"
|
|
preset_source = "local"
|
|
except (ValueError, TypeError):
|
|
# Cloud or builtin preset — convert filament_id to setting_id
|
|
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
|
|
else:
|
|
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
|
|
|
|
if preset_id_to_save:
|
|
existing_mapping = await db.execute(
|
|
select(SlotPresetMapping).where(
|
|
SlotPresetMapping.printer_id == data.printer_id,
|
|
SlotPresetMapping.ams_id == data.ams_id,
|
|
SlotPresetMapping.tray_id == data.tray_id,
|
|
)
|
|
)
|
|
mapping = existing_mapping.scalar_one_or_none()
|
|
if mapping:
|
|
mapping.preset_id = preset_id_to_save
|
|
mapping.preset_name = preset_name
|
|
mapping.preset_source = preset_source
|
|
else:
|
|
mapping = SlotPresetMapping(
|
|
printer_id=data.printer_id,
|
|
ams_id=data.ams_id,
|
|
tray_id=data.tray_id,
|
|
preset_id=preset_id_to_save,
|
|
preset_name=preset_name,
|
|
preset_source=preset_source,
|
|
)
|
|
db.add(mapping)
|
|
await db.commit()
|
|
logger.info(
|
|
"Saved slot preset mapping: preset_id=%r, preset_name=%r",
|
|
preset_id_to_save,
|
|
preset_name,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to save slot preset mapping: %s", e)
|
|
|
|
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
|
|
|
|
await ws_manager.broadcast(
|
|
{
|
|
"type": "spool_assignment_changed",
|
|
"printer_id": data.printer_id,
|
|
"ams_id": data.ams_id,
|
|
"tray_id": data.tray_id,
|
|
}
|
|
)
|
|
|
|
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()
|
|
|
|
await ws_manager.broadcast(
|
|
{
|
|
"type": "spool_assignment_changed",
|
|
"printer_id": printer_id,
|
|
"ams_id": ams_id,
|
|
"tray_id": tray_id,
|
|
}
|
|
)
|
|
|
|
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"
|
|
|
|
|
|
def _validate_tag_input(
|
|
raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
|
|
) -> None:
|
|
if raw_value is None:
|
|
return
|
|
raw = str(raw_value).strip()
|
|
if not raw:
|
|
return
|
|
if normalized_value is None:
|
|
raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
|
|
if len(normalized_value) % 2 != 0:
|
|
raise HTTPException(422, f"{field_name} must have an even number of hex characters")
|
|
if exact_len is not None and len(normalized_value) != exact_len:
|
|
raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
|
|
|
|
|
|
@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")
|
|
|
|
normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
|
|
normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
|
|
|
|
_validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
|
|
_validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
|
|
|
|
# Check for conflicts: tag already linked to another active spool
|
|
if normalized_tag_uid:
|
|
conflict = await db.execute(
|
|
select(Spool).where(
|
|
func.upper(Spool.tag_uid) == normalized_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(
|
|
func.upper(Spool.tag_uid) == normalized_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 normalized_tray_uuid:
|
|
conflict = await db.execute(
|
|
select(Spool).where(
|
|
func.upper(Spool.tray_uuid) == normalized_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(
|
|
func.upper(Spool.tray_uuid) == normalized_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 = normalized_tag_uid
|
|
if data.tray_uuid is not None:
|
|
spool.tray_uuid = normalized_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
|