Files
bambuddy/backend/app/api/routes/spoolman_inventory.py
maziggy fb3821630f feat(inventory): batch / mass edit on the Filament tab (#1795)
Bulk operations on the Inventory page in both built-in and Spoolman modes.
  Reporter wanted ten-of-the-same-spool edits without ten round-trips through
  the per-spool editor.

  Frontend
  - New checkbox column on the inventory table (header / row / group). Sticky
    toolbar appears when at least one row is selected with Edit / Print labels /
    Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
    Selection clears on any filter / tab / search change so the count can't
    drift from what is on screen.
  - BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
    field by ticking its checkbox or just typing into it; only ticked + non-
    empty fields are sent. Clearing fields in bulk is intentionally NOT
    supported per the issue discussion.
  - A new SearchableSelect renders all categorical fields (material, sub-type,
    brand, category, slicer preset name, slicer filament, storage location)
    with the same dropdown pattern the per-spool editor uses - text input +
    chevron + filtered button list, click-outside / Escape closes. No native
    select anywhere in the modal. Options merge the canonical constants from
    spool-form/constants.ts with whatever already exists in the user's
    inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
    form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
    Options() and three useQuery calls gated on isOpen.
  - onSuccess handlers surface three outcomes: all-succeeded (green toast),
    partial-success (yellow toast with ok / failed counts), all-failed (red
    toast that keeps the selection and modal open so the user can retry).
    The first cut silently dropped errors / not_found arrays - audited and
    fixed before merge.
  - Invalid rgba hex is flagged inline with a red border + helper text and
    the Apply button is gated on a hasDroppedTickedField guard, so silently
    dropping a ticked field is no longer possible.
  - bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
    clears selection, matching the other three bulk mutations.

  Backend
  - Four new endpoints per inventory mode (eight total):
      POST /api/v1/inventory/spools/bulk-update         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-delete         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-archive        INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-restore        INVENTORY_UPDATE
      POST /api/v1/spoolman/inventory/spools/bulk-*     FILAMENTS_UPDATE
  - Built-in update runs the same prepare_internal_spool_payload(...) +
    weight_used / weight_locked auto-stamp as the per-spool PATCH.
  - Spoolman update loops the per-spool update_spool route function so the
    filament re-linking / extra-dict / extra-lock / shared-filament rules
    stay byte-identical to single-spool edits.
  - Per-spool failures inside the batch are collected. Spoolman bulk-delete /
    archive / restore now catch non-HTTPException too (matches bulk-update) -
    a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
    with a 500 and skips the WS broadcast.
  - Both modes broadcast a single inventory_changed WS event at the end of
    the batch.
2026-06-23 11:34:15 +02:00

1986 lines
84 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Spoolman inventory proxy endpoints.
Translates between Spoolman's data model and Bambuddy's internal
InventorySpool format so the frontend can use a single unified inventory UI
regardless of whether data comes from the local database or Spoolman.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import time
from contextlib import asynccontextmanager
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import delete, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.api.routes._spoolman_helpers import (
NormalizedFilament,
NormalizedVendorRef,
_map_spoolman_spool,
_safe_float,
_safe_int,
_safe_optional_float,
assert_safe_spoolman_url,
)
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.core.websocket import ws_manager
from backend.app.models.ams_label import AmsLabel
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.models.user import User
from backend.app.schemas.spool import SpoolKProfileBase
from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
from backend.app.services.location_service import (
enrich_spool_dicts_with_location_id,
maybe_sync_spoolman_locations,
resolve_spoolman_location_string,
)
from backend.app.services.printer_manager import printer_manager
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
from backend.app.services.spoolman import (
SpoolmanClient,
SpoolmanClientError,
SpoolmanNotFoundError,
SpoolmanUnavailableError,
get_spoolman_client,
init_spoolman_client,
)
from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
from backend.app.utils.filament_ids import (
GENERIC_FILAMENT_IDS,
MATERIAL_TEMPS,
filament_id_to_setting_id,
normalize_slicer_filament,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"])
# Cache the last successful health-check timestamp to avoid a round-trip on
# every request. A failed check clears the cache immediately.
_health_check_cache: dict[str, float] = {}
_HEALTH_CHECK_TTL = 30.0 # seconds
def _tag_cleared(val: str | None) -> bool:
"""Return True when a PATCH field explicitly removes a tag (null)."""
return val is None
async def _clear_stale_tag_links(
client: SpoolmanClient,
*,
tag: str,
keep_spool_id: int,
log_context: str,
) -> int:
"""Clear extra.tag on OTHER spools still claiming the given tag (#1457).
A given AMS slot tag — whether a real RFID (tray_uuid/tag_uid) or the
deterministic fallback derived from (printer_serial, ams_id, tray_id) for
non-RFID slots — uniquely identifies one physical slot. When a spool is
(re)bound to that slot via Assign or Link, any other Spoolman spool whose
extra.tag still holds the same value is stale and would resurface in the
hover card / fill-level lookup.
Best-effort: per-spool patch failures are logged and skipped, never raised.
Returns the number of spools cleared.
"""
if not tag:
return 0
tag_upper = tag.upper()
try:
spools = await client.get_spools()
except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
logger.warning("Could not enumerate spools for stale-tag cleanup: %s", exc)
return 0
cleared = 0
for spool in spools:
spool_id = spool.get("id")
if not spool_id or spool_id == keep_spool_id:
continue
extra = spool.get("extra") or {}
raw_tag = extra.get("tag", "")
if not raw_tag:
continue
clean_tag = raw_tag.strip('"').upper()
if clean_tag != tag_upper:
continue
try:
await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
cleared += 1
logger.info(
"Cleared stale tag '%s' from Spoolman spool %s (%s; reassigned to spool %s)",
tag_upper[:16],
spool_id,
log_context,
keep_spool_id,
)
except (SpoolmanClientError, SpoolmanUnavailableError, SpoolmanNotFoundError) as exc:
logger.warning(
"Failed to clear stale tag on Spoolman spool %s: %s",
spool_id,
exc,
)
return cleared
async def _clear_stale_slot_fallback_tag_links(
client: SpoolmanClient,
*,
printer_serial: str,
ams_id: int,
tray_id: int,
keep_spool_id: int,
) -> int:
"""Convenience wrapper: compute the slot's fallback tag and clear it from
other spools. Used by the assign route, which identifies the slot by
(printer, ams, tray) rather than by an explicit tag value.
"""
fallback_tag = get_fallback_spool_tag_for_slot(printer_serial, ams_id, tray_id)
if not fallback_tag:
return 0
return await _clear_stale_tag_links(
client,
tag=fallback_tag,
keep_spool_id=keep_spool_id,
log_context=f"printer={printer_serial} ams={ams_id} tray={tray_id}",
)
async def _get_client(db: AsyncSession) -> SpoolmanClient:
"""Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
result = await db.execute(select(Settings))
settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()}
enabled = settings.get("spoolman_enabled", "false").lower() == "true"
url = settings.get("spoolman_url", "").strip()
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
if not url:
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
# SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
# fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
# intentionally permitted (Spoolman commonly runs on the same host or home LAN).
# Raises ValueError with a descriptive message on any violation.
try:
assert_safe_spoolman_url(url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation).
client = await get_spoolman_client()
if not client or client.base_url != url.rstrip("/"):
try:
client = await init_spoolman_client(url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Only call health_check() when the cached result has expired.
# Evict stale entries when URL changes (only one Spoolman URL is active at a time).
if url not in _health_check_cache and _health_check_cache:
_health_check_cache.clear()
now = time.monotonic()
last_ok = _health_check_cache.get(url, 0.0)
if now - last_ok > _HEALTH_CHECK_TTL:
if not await client.health_check():
_health_check_cache.pop(url, None)
raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
_health_check_cache[url] = now
return client
@asynccontextmanager
async def _translate_spoolman_errors():
"""Translate Spoolman typed exceptions to HTTP errors for all inventory endpoints."""
try:
yield
except SpoolmanNotFoundError as exc:
raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
except SpoolmanClientError as exc:
raise HTTPException(
status_code=502,
detail={
"message": "Spoolman rejected the request",
"upstream_status": exc.status_code,
"upstream_body": getattr(exc, "response_text", ""),
},
) from exc
except SpoolmanUnavailableError as exc:
raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
def _raise_if_partial_failure(spools: list[dict], results: list, operation: str) -> None:
"""Raise HTTP 502 if any gather result is an exception, logging each failure."""
failures = [(s["id"], r) for s, r in zip(spools, results, strict=True) if isinstance(r, BaseException)]
if failures:
logger.error(
"Partial %s failure: %d/%d spools failed: %s",
operation,
len(failures),
len(spools),
[(sid, type(exc).__name__) for sid, exc in failures],
)
raise HTTPException(
status_code=502,
detail=f"{operation} partially applied: {len(spools) - len(failures)}/{len(spools)} spools updated",
)
async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> tuple[dict, list[str]]:
"""Patch the spool price; return (updated_spool, warnings).
Returns the original spool and a non-empty warnings list when the price
update fails, so the caller can return HTTP 207 instead of silently
discarding the price.
"""
if cost_per_kg is None:
return spool, []
try:
async with _translate_spoolman_errors():
updated = await client.update_spool_full(spool["id"], price=cost_per_kg)
return updated, []
except HTTPException as exc:
if exc.status_code >= 500:
raise # Propagate network/server errors — don't swallow Spoolman outages
logger.warning(
"Price update failed for spool %d; spool created without price (cost_per_kg=%s, status=%d)",
spool["id"],
cost_per_kg,
exc.status_code,
)
return spool, [f"price_not_set: Spoolman rejected the price update (HTTP {exc.status_code})"]
# ---------------------------------------------------------------------------
# Request / response schemas
# ---------------------------------------------------------------------------
_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$")
def _validate_rgba(v: str | None) -> str | None:
if v is None:
return v
clean = v.removeprefix("#")
if not _HEX_RE.match(clean):
raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)")
return clean.upper()
def _validate_storage_location(v: str | None) -> str | None:
if v is not None and any(c in v for c in ("\r", "\n", "\x00")):
raise ValueError("storage_location must not contain control characters")
return v
class SpoolmanInventoryCreate(BaseModel):
# When spoolman_filament_id is provided the caller has already chosen a filament from the
# Spoolman catalog, so material (and other metadata) are optional — the backend skips
# find_or_create_filament() and uses the supplied ID directly.
spoolman_filament_id: int | None = Field(None, gt=0)
material: str | None = Field(None, min_length=1, max_length=64)
subtype: str | None = Field(None, max_length=64)
brand: str | None = Field(None, max_length=128)
color_name: str | None = Field(None, max_length=64)
rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
label_weight: int = Field(1000, ge=1, le=100_000)
core_weight: int = Field(
250, ge=0, le=10_000
) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
weight_used: float = Field(0.0, ge=0.0, le=100_000.0)
note: str | None = Field(None, max_length=1000)
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
storage_location: str | None = Field(None, max_length=255)
location_id: int | None = Field(None, gt=0)
# BambuStudio slicer preset for this spool. Spoolman has no native field
# for this, so we persist it under the bambu_slicer_filament[_name] keys
# in the spool's extra dict and read it back in _map_spoolman_spool.
slicer_filament: str | None = Field(None, max_length=128)
slicer_filament_name: str | None = Field(None, max_length=255)
@field_validator("rgba")
@classmethod
def validate_rgba(cls, v: str | None) -> str | None:
return _validate_rgba(v)
@field_validator("storage_location")
@classmethod
def validate_storage_location(cls, v: str | None) -> str | None:
return _validate_storage_location(v)
@model_validator(mode="after")
def validate_weight_consistency(self) -> SpoolmanInventoryCreate:
# material is required only when the caller has not pre-selected a Spoolman filament
if self.spoolman_filament_id is None and not self.material:
raise ValueError("material is required when spoolman_filament_id is not provided")
if self.weight_used > self.label_weight:
raise ValueError("weight_used must not exceed label_weight")
return self
class SpoolmanInventoryUpdate(BaseModel):
material: str | None = Field(None, min_length=1, max_length=64)
subtype: str | None = Field(None, max_length=64)
brand: str | None = Field(None, max_length=128)
color_name: str | None = Field(None, max_length=64)
rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
label_weight: int | None = Field(None, ge=1, le=100_000)
core_weight: int | None = Field(
None, ge=0, le=10_000
) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
weight_used: float | None = Field(None, ge=0.0, le=100_000.0)
note: str | None = Field(None, max_length=1000)
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
storage_location: str | None = Field(None, max_length=255)
location_id: int | None = Field(None, gt=0)
# BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
# schema). Pass an empty string to clear; null/omitted leaves unchanged.
slicer_filament: str | None = Field(None, max_length=128)
slicer_filament_name: str | None = Field(None, max_length=255)
@field_validator("rgba")
@classmethod
def validate_rgba(cls, v: str | None) -> str | None:
return _validate_rgba(v)
@field_validator("storage_location")
@classmethod
def validate_storage_location(cls, v: str | None) -> str | None:
return _validate_storage_location(v)
@model_validator(mode="after")
def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
# null = remove tag; non-null values rejected (use /tag endpoint to write tags)
if self.tag_uid is not None:
raise ValueError("tag_uid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
if self.tray_uuid is not None:
raise ValueError("tray_uuid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
return self
@model_validator(mode="after")
def validate_weight_consistency(self) -> SpoolmanInventoryUpdate:
if self.weight_used is not None and self.label_weight is not None:
if self.weight_used > self.label_weight:
raise ValueError("weight_used must not exceed label_weight")
return self
class SpoolmanInventoryBulkCreate(BaseModel):
spool: SpoolmanInventoryCreate
quantity: int = Field(1, ge=1, le=50)
class SpoolWeightUpdate(BaseModel):
weight_grams: float = Field(..., ge=0.0, le=100_000.0)
class SpoolTagLinkRequest(BaseModel):
# Minimum 8 hex chars = 4-byte NFC UID (Bambu Lab hardware tags use 4-byte UIDs).
tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
@field_validator("tag_uid")
@classmethod
def tag_uid_not_all_zeros(cls, v: str | None) -> str | None:
if v is not None and all(c in "0" for c in v):
raise ValueError("tag_uid must not be all-zero bytes")
return v
@model_validator(mode="after")
def at_least_one(self) -> SpoolTagLinkRequest:
if not self.tag_uid and not self.tray_uuid:
raise ValueError("tag_uid or tray_uuid is required")
return self
class SpoolSlotAssignmentRequest(BaseModel):
spoolman_spool_id: int = Field(..., gt=0)
printer_id: int = Field(..., gt=0)
# ams_id 0–7 for physical AMS units; 255 = external/virtual spool extruder slot
ams_id: int = Field(..., ge=0, le=255)
tray_id: int = Field(..., ge=0, le=3)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/spools")
async def list_spools(
include_archived: bool = Query(False),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> list[dict]:
"""Return all Spoolman spools in the InventorySpool format."""
client = await _get_client(db)
# Sync after we have the route-resolved client so tests that patch the
# route module's get_spoolman_client/init_spoolman_client also catch the
# sync's client lookup — otherwise the location_service path imports from
# backend.app.services.spoolman directly and bypasses the patch.
if await maybe_sync_spoolman_locations(db, client=client):
await db.commit()
async with _translate_spoolman_errors():
spools = await client.get_all_spools(allow_archived=include_archived)
mapped: list[dict] = []
spool_ids: list[int] = []
for s in spools:
try:
m = _map_spoolman_spool(s)
mapped.append(m)
spool_ids.append(m["id"])
except ValueError as exc:
logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc)
if spool_ids:
kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id.in_(spool_ids)))
kp_by_spool: dict[int, list[dict]] = {}
for kp in kp_result.scalars().all():
kp_by_spool.setdefault(kp.spoolman_spool_id, []).append(_k_profile_to_dict(kp))
for m in mapped:
m["k_profiles"] = kp_by_spool.get(m["id"], [])
await enrich_spool_dicts_with_location_id(db, mapped)
return mapped
@router.get("/spools/{spool_id}")
async def get_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> dict:
"""Return a single Spoolman spool in the InventorySpool format."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.get_spool(spool_id)
try:
mapped = _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
await enrich_spool_dicts_with_location_id(db, [mapped])
return mapped
async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanClient) -> int:
"""Return the Spoolman filament ID for this spool creation request.
If spoolman_filament_id is set the caller pre-selected a catalog entry,
so find_or_create_filament() is skipped and the ID is used directly.
"""
if data.spoolman_filament_id is not None:
return data.spoolman_filament_id
# Validator guarantees material is non-None when spoolman_filament_id is None
assert data.material is not None # noqa: S101
color_hex = (data.rgba or "808080FF")[:6]
async with _translate_spoolman_errors():
return await client.find_or_create_filament(
material=data.material,
subtype=data.subtype or "",
brand=data.brand,
color_hex=color_hex,
label_weight=data.label_weight,
color_name=data.color_name,
)
@router.post("/spools")
async def create_spool(
data: SpoolmanInventoryCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Create a new spool in Spoolman, auto-creating vendor and filament as needed."""
client = await _get_client(db)
filament_id = await _resolve_filament_id(data, client)
storage_location = data.storage_location
if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
try:
storage_location, _ = await resolve_spoolman_location_string(
db,
location_id=data.location_id,
storage_location=data.storage_location,
fields_set=set(data.model_fields_set),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
remaining = max(0.0, data.label_weight - data.weight_used)
try:
async with _translate_spoolman_errors():
spool = await client.create_spool(
filament_id=filament_id,
remaining_weight=remaining,
comment=data.note or None,
location=storage_location or None,
)
except HTTPException as exc:
if exc.status_code == 404 and data.spoolman_filament_id is not None:
raise HTTPException(
status_code=404,
detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
) from exc
raise
spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
# Persist slicer_filament AND color_name under the spool's extra dict
# (mirror update_spool). Spoolman has no `color_name` field on filament
# (#1357) so we own the round-trip ourselves.
if data.slicer_filament is not None or data.slicer_filament_name is not None or data.color_name is not None:
# Ensure extra fields are registered before write.
if data.slicer_filament is not None:
await client.ensure_extra_field("bambu_slicer_filament")
if data.slicer_filament_name is not None:
await client.ensure_extra_field("bambu_slicer_filament_name")
if data.color_name is not None:
await client.ensure_extra_field("bambu_color_name")
new_extra: dict = {}
if data.slicer_filament is not None:
new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament)
if data.slicer_filament_name is not None:
new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name)
if data.color_name is not None:
new_extra["bambu_color_name"] = json.dumps(data.color_name)
if new_extra:
try:
async with _translate_spoolman_errors():
spool = await client.merge_spool_extra(spool["id"], new_extra)
except HTTPException:
# Best-effort — the spool already exists, log and continue.
logger.warning(
"Failed to persist slicer_filament/color_name for spool %s",
spool.get("id"),
)
result = _map_spoolman_spool(spool)
await ws_manager.broadcast({"type": "inventory_changed"})
if price_warnings:
return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
return result
@router.post("/spools/bulk")
async def bulk_create_spools(
payload: SpoolmanInventoryBulkCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> Response:
"""Create multiple identical spools in Spoolman."""
client = await _get_client(db)
data = payload.spool
try:
filament_id = await _resolve_filament_id(data, client)
except HTTPException as exc:
if exc.status_code == 404 and data.spoolman_filament_id is not None:
raise HTTPException(
status_code=404,
detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
) from exc
raise
storage_location = data.storage_location
if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
try:
storage_location, _ = await resolve_spoolman_location_string(
db,
location_id=data.location_id,
storage_location=data.storage_location,
fields_set=set(data.model_fields_set),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
remaining = max(0.0, data.label_weight - data.weight_used)
created: list[dict] = []
failures: list[str] = []
for _ in range(payload.quantity):
try:
spool = await client.create_spool(
filament_id=filament_id,
remaining_weight=remaining,
comment=data.note or None,
location=storage_location or None,
)
except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
logger.warning("Bulk spool creation: one spool failed: %s", exc)
failures.append("spool creation failed")
continue
try:
spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
except HTTPException as exc:
logger.warning(
"Bulk spool %d: price update failed (HTTP %d); spool not added to created list",
spool.get("id", 0),
exc.status_code,
)
failures.append("spool created but price update failed")
continue
if price_warnings:
logger.warning("Bulk spool %s created without price: %s", spool.get("id"), price_warnings)
created.append(_map_spoolman_spool(spool))
if not created:
raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
await ws_manager.broadcast({"type": "inventory_changed"})
if len(created) < payload.quantity:
# Some spool creations failed — return 207 Multi-Status so the caller
# can distinguish a full success from a partial one and show a useful message.
return JSONResponse(
status_code=207,
content={
"created": created,
"requested_count": payload.quantity,
"failed_count": payload.quantity - len(created),
"failures": failures,
},
)
return JSONResponse(status_code=200, content=created)
@router.patch("/spools/{spool_id}")
async def update_spool(
*,
spool_id: int = Path(..., gt=0),
data: SpoolmanInventoryUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Update an existing Spoolman spool, re-linking the filament if metadata changed."""
client = await _get_client(db)
async with _translate_spoolman_errors():
current = await client.get_spool(spool_id)
cur_filament: dict = current.get("filament") or {}
cur_vendor: dict = cur_filament.get("vendor") or {}
cur_mat: str = (cur_filament.get("material") or "").strip()
cur_name: str = (cur_filament.get("name") or "").strip()
if cur_mat and cur_name.upper().startswith(cur_mat.upper()):
cur_subtype: str = cur_name[len(cur_mat) :].strip()
else:
cur_subtype = cur_name
# Resolve final values: use request value if provided, else keep current
material = data.material if data.material is not None else cur_mat
subtype = data.subtype if data.subtype is not None else cur_subtype
brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
# color_name uses model_fields_set so explicit null (clear) is distinguishable
# from "field omitted" (don't touch). find_or_create_filament's convention:
# None = don't touch, "" = explicit clear, "value" = set.
if "color_name" in data.model_fields_set:
color_name = data.color_name if data.color_name is not None else ""
else:
color_name = cur_filament.get("color_name") or None
cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
# Default weight_used from the synthetic mapping (label - remaining) so an
# edit that doesn't touch the weight field preserves Spoolman's real
# remaining_weight after a "Reset usage to 0" — the previous code read
# Spoolman's used_weight directly, which is 0 post-reset, so
# `remaining = label - 0 = 1000` would overwrite the real remaining
# the next time the user edited any other field (#1390).
cur_remaining_raw = current.get("remaining_weight")
if cur_remaining_raw is not None:
synthetic_used = max(0.0, float(label_weight) - float(cur_remaining_raw))
else:
synthetic_used = float(current.get("used_weight") or 0)
weight_used = data.weight_used if data.weight_used is not None else synthetic_used
note = data.note if data.note is not None else current.get("comment")
storage_location_changed = "storage_location" in data.model_fields_set or "location_id" in data.model_fields_set
storage_location = data.storage_location if "storage_location" in data.model_fields_set else None
if storage_location_changed:
try:
storage_location, _ = await resolve_spoolman_location_string(
db,
location_id=data.location_id,
storage_location=storage_location,
fields_set=set(data.model_fields_set),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
color_hex = rgba[:6]
# Resolve which filament this spool should be linked to AFTER the edit.
#
# The old behaviour was always `find_or_create_filament`, which proliferated
# duplicate Spoolman filaments whenever the user changed any field that
# made up the match key (material/subtype/brand/color) — every edit minted
# a fresh row and orphaned the previous one (#1357 follow-up). To match
# internal-mode behaviour ([[feedback_inventory_modes_parity]]: editing a
# spool does not proliferate new entities), prefer PATCHing the current
# filament in place when it's a singleton.
cur_filament_id = cur_filament.get("id")
desired_name = f"{material} {subtype}".strip() if subtype else material
cur_color_norm = (cur_filament.get("color_hex") or "").upper()[:6]
cur_vendor_name = (cur_vendor.get("name") or "").strip()
cur_weight_int = int(cur_filament.get("weight") or 0)
metadata_unchanged = (
cur_filament_id
and (cur_filament.get("name") or "").strip() == desired_name
and (cur_filament.get("material") or "").upper() == material.upper()
and cur_color_norm == color_hex.upper()
and cur_vendor_name.lower() == ((brand or "").strip().lower())
and cur_weight_int == int(label_weight)
)
if metadata_unchanged:
# No filament-side change at all — re-use the existing link, skip
# find_or_create entirely so a no-op edit (e.g. just changing
# weight_used or note) never even touches the filament catalogue.
filament_id = cur_filament_id
else:
async with _translate_spoolman_errors():
shared = await client.is_filament_shared(cur_filament_id, spool_id) if cur_filament_id else False
if cur_filament_id and not shared:
# Singleton filament — PATCH it in place so the user's edit lands
# on the row their spool already points at instead of orphaning it.
patch_body: dict = {
"name": desired_name,
"material": material,
"color_hex": color_hex,
"weight": float(label_weight),
}
if brand:
vendor_id = await client.find_or_create_vendor(brand)
patch_body["vendor_id"] = vendor_id
async with _translate_spoolman_errors():
await client.patch_filament(cur_filament_id, patch_body)
filament_id = cur_filament_id
else:
# Filament is shared with other spools — PATCHing it in place would
# silently rewrite their metadata too. Fall back to find-or-create
# so only this spool's link moves.
async with _translate_spoolman_errors():
filament_id = await client.find_or_create_filament(
material=material,
subtype=subtype or "",
brand=brand,
color_hex=color_hex,
label_weight=label_weight,
color_name=color_name,
)
if not filament_id:
raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
remaining = max(0.0, label_weight - weight_used)
# Tag removal: clear only the "tag" key so other custom Spoolman extra fields
# set outside Bambuddy are preserved.
tag_nulled = (
("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set)
and _tag_cleared(data.tag_uid)
and _tag_cleared(data.tray_uuid)
)
# Serialise tag-clear + PATCH under the per-spool extra lock to prevent a
# concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting
# the tag key between our read and our write.
#
# Spoolman PATCHes extra dicts by MERGING — popping "tag" from a re-fetched
# dict and sending the rest doesn't clear the key (Spoolman keeps the old
# value because the key wasn't in the payload). Explicitly set the tag to
# a JSON-encoded empty string; read-side filters strip the quotes.
async with client.extra_lock(spool_id):
if tag_nulled:
# Re-fetch inside the lock so we work with fresh extra data.
async with _translate_spoolman_errors():
fresh = await client.get_spool(spool_id)
cur_extra = dict(fresh.get("extra") or {})
cur_extra["tag"] = json.dumps("")
extra: dict | None = cur_extra
else:
extra = None
async with _translate_spoolman_errors():
updated = await client.update_spool_full(
spool_id=spool_id,
filament_id=filament_id,
remaining_weight=remaining,
comment=note or "",
price=data.cost_per_kg,
extra=extra,
location=storage_location or None,
clear_location=storage_location_changed and not storage_location,
)
# Persist BambuStudio slicer preset AND color_name under spool.extra.
# Spoolman has no native fields for these — color_name was confirmed
# absent from the FilamentUpdateParameters schema in 0.23.1 (#1357), so
# writing `filament.color_name` was a silent no-op that left every
# edit looking "not saved". They all round-trip via extra and get
# unpacked in _map_spoolman_spool. Only writes when the request
# explicitly set the field — passing null/omitting leaves the existing
# extra entry untouched (write empty string to clear).
sf_set = "slicer_filament" in data.model_fields_set
sfn_set = "slicer_filament_name" in data.model_fields_set
cn_set = "color_name" in data.model_fields_set
if sf_set or sfn_set or cn_set:
# Ensure extra fields are registered (Spoolman rejects PATCHes with
# unknown keys with HTTP 400). Idempotent if startup already ran this.
if sf_set:
await client.ensure_extra_field("bambu_slicer_filament")
if sfn_set:
await client.ensure_extra_field("bambu_slicer_filament_name")
if cn_set:
await client.ensure_extra_field("bambu_color_name")
new_extra: dict = {}
if sf_set:
new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament or "")
if sfn_set:
new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
if cn_set:
new_extra["bambu_color_name"] = json.dumps(data.color_name or "")
async with _translate_spoolman_errors():
updated = await client.merge_spool_extra(spool_id, new_extra)
await ws_manager.broadcast({"type": "inventory_changed"})
return _map_spoolman_spool(updated)
@router.delete("/spools/{spool_id}")
async def delete_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Permanently delete a spool from Spoolman."""
client = await _get_client(db)
async with _translate_spoolman_errors():
await client.delete_spool(spool_id)
await ws_manager.broadcast({"type": "inventory_changed"})
return {"status": "deleted"}
@router.post("/spools/{spool_id}/archive")
async def archive_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Archive a spool in Spoolman (soft-delete)."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.set_spool_archived(spool_id, archived=True)
try:
mapped = _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
await ws_manager.broadcast({"type": "inventory_changed"})
return mapped
@router.post("/spools/{spool_id}/restore")
async def restore_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Restore an archived spool in Spoolman."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.set_spool_archived(spool_id, archived=False)
try:
mapped = _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
await ws_manager.broadcast({"type": "inventory_changed"})
return mapped
@router.post("/spools/{spool_id}/reset-consumed-counter")
async def reset_spool_consumed_counter(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Zero the displayed "Total Consumed" counter for a Spoolman spool.
Spoolman doesn't have a native "baseline" field, so the implementation
reaches for the closest equivalent: PATCH `used_weight=0` upstream.
The read mapping in ``_map_spoolman_spool`` then derives Bambuddy's
`weight_used = label - remaining_weight` and `baseline = weight_used -
real_used_weight`, so the Inventory page's `weight_used - baseline`
display lands at 0 while remaining (= label - weight_used) is preserved
— parity with the internal-mode endpoint (#1390, see also
``backend/app/api/routes/inventory.py::reset_spool_consumed_counter``).
"""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.reset_spool_usage(spool_id)
try:
mapped = _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
await ws_manager.broadcast({"type": "inventory_changed"})
return mapped
class SpoolmanBulkUpdateRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=500)
update: SpoolmanInventoryUpdate
class SpoolmanBulkIdsRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=500)
@router.post("/spools/bulk-update")
async def bulk_update_spools(
payload: SpoolmanBulkUpdateRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Apply the same partial update to every listed Spoolman spool.
Loops the per-spool ``update_spool`` route so the filament re-linking +
extra-dict + location-resolution rules stay in sync with the single-spool
PATCH path. Per-spool errors are collected; one bad ID doesn't abort the
batch.
"""
update_fields = payload.update.model_dump(exclude_unset=True)
if not update_fields:
raise HTTPException(status_code=400, detail="update must include at least one field")
updated = 0
errors: list[dict] = []
for sid in payload.ids:
try:
await update_spool(spool_id=sid, data=payload.update, db=db, _=None)
updated += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-update failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if updated:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"updated": updated, "errors": errors}
@router.post("/spools/bulk-delete")
async def bulk_delete_spools(
payload: SpoolmanBulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
client = await _get_client(db)
deleted = 0
errors: list[dict] = []
for sid in payload.ids:
try:
async with _translate_spoolman_errors():
await client.delete_spool(sid)
deleted += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-delete failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if deleted:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"deleted": deleted, "errors": errors}
@router.post("/spools/bulk-archive")
async def bulk_archive_spools(
payload: SpoolmanBulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Archive every listed Spoolman spool. Per-spool failures are collected."""
client = await _get_client(db)
archived = 0
errors: list[dict] = []
for sid in payload.ids:
try:
async with _translate_spoolman_errors():
await client.set_spool_archived(sid, archived=True)
archived += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-archive failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if archived:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"archived": archived, "errors": errors}
@router.post("/spools/bulk-restore")
async def bulk_restore_spools(
payload: SpoolmanBulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Restore every listed archived Spoolman spool. Per-spool failures are collected."""
client = await _get_client(db)
restored = 0
errors: list[dict] = []
for sid in payload.ids:
try:
async with _translate_spoolman_errors():
await client.set_spool_archived(sid, archived=False)
restored += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-restore failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if restored:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"restored": restored, "errors": errors}
@router.post("/spools/reset-consumed-counter-bulk")
async def bulk_reset_spool_consumed_counter(
payload: dict = Body(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Bulk reset the "Total Consumed" counter across the given Spoolman spool IDs.
Caller passes an explicit list of IDs — no "reset all" shortcut, since
a typo on a wildcard would wipe the entire inventory's tracking.
Returns the count of spools successfully reset; individual failures are
logged but do not abort the batch.
"""
spool_ids = payload.get("spool_ids")
if not isinstance(spool_ids, list) or not spool_ids:
raise HTTPException(status_code=400, detail="spool_ids must be a non-empty list")
if not all(isinstance(sid, int) for sid in spool_ids):
raise HTTPException(status_code=400, detail="spool_ids must contain integers")
client = await _get_client(db)
reset_count = 0
for spool_id in spool_ids:
try:
async with _translate_spoolman_errors():
await client.reset_spool_usage(spool_id)
reset_count += 1
except HTTPException as exc:
logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
if reset_count:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"reset": reset_count}
@router.patch("/spools/{spool_id}/weight")
async def sync_spool_weight(
*,
spool_id: int = Path(..., gt=0),
data: SpoolWeightUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Update a spool's remaining weight from a measured gross weight.
Computes remaining = gross_weight - tare, where tare = spool.spool_weight
if set, else filament.spool_weight; falls back to 250 g when both unset.
"""
client = await _get_client(db)
async with _translate_spoolman_errors():
current = await client.get_spool(spool_id)
cur_filament = current.get("filament") or {}
spool_tare = current.get("spool_weight")
raw_tare = spool_tare if spool_tare is not None else cur_filament.get("spool_weight")
core_weight = _safe_float(raw_tare, 250.0)
remaining = max(0.0, data.weight_grams - core_weight)
async with _translate_spoolman_errors():
updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining)
upd_filament = updated.get("filament") or {}
label_weight = _safe_int(upd_filament.get("weight"), 1000)
weight_used = max(0.0, label_weight - remaining)
await ws_manager.broadcast({"type": "inventory_changed"})
return {"status": "ok", "weight_used": weight_used}
@router.patch("/spools/{spool_id}/tag")
async def link_tag_to_spoolman_spool(
*,
spool_id: int = Path(..., gt=0),
data: SpoolTagLinkRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
tray_uuid takes precedence over tag_uid when both are supplied.
Returns 409 if another spool already carries the same tag.
Uses extra_lock to serialise against concurrent extra-field writes.
"""
client = await _get_client(db)
tag = (data.tray_uuid or data.tag_uid).upper()
tag_json = json.dumps(tag)
async with client.extra_lock(spool_id):
# Duplicate check: scan all spools for the same tag on a different spool.
async with _translate_spoolman_errors():
all_spools = await client.get_all_spools()
for s in all_spools:
s_tag = (s.get("extra") or {}).get("tag", "")
if s_tag.strip('"').upper() == tag and s.get("id") != spool_id:
raise HTTPException(
status_code=409,
detail=f"Tag is already assigned to spool {s['id']}",
)
# Re-fetch inside the lock so cur_extra reflects any concurrent update.
async with _translate_spoolman_errors():
current = await client.get_spool(spool_id)
cur_extra = dict(current.get("extra") or {})
cur_extra["tag"] = tag_json
async with _translate_spoolman_errors():
updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
await ws_manager.broadcast({"type": "inventory_changed"})
return _map_spoolman_spool(updated)
@router.get("/slot-assignments/all", response_model=list[SpoolmanSlotAssignmentEnriched])
async def get_all_spoolman_slot_assignments(
printer_id: int | None = Query(None, gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> list[SpoolmanSlotAssignmentEnriched]:
"""Return all Spoolman slot assignments enriched with printer_name and ams_label.
``printer_name`` is null only when the printer relation is missing
(cascade-deleted edge case). ``ams_label`` is null when no AmsLabel row
matches the slot's MQTT serial (or the synthetic ``f"p{pid}a{ams_id}"``
fallback key).
"""
query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
if printer_id is not None:
query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
result = await db.execute(query)
slots = list(result.scalars().all())
# Build (printer_id, ams_id) -> ams_serial map from live printer states.
# Same pattern as inventory.py:765-806 for the local /assignments endpoint.
printer_ids = {s.printer_id for s in slots}
serial_map: dict[tuple[int, int], str] = {}
all_statuses = printer_manager.get_all_statuses()
for pid in printer_ids:
state = all_statuses.get(pid)
if not (state and state.raw_data):
continue
# Some printer firmware variants wrap the AMS list in an outer dict
# (`{"ams": [...]}`). Mirror the defense used in sync_spoolman_ams_weights
# (line 842-844) so a wrapped payload still resolves to a list.
ams_raw = state.raw_data.get("ams", [])
if isinstance(ams_raw, dict):
ams_raw = ams_raw.get("ams", [])
if not isinstance(ams_raw, list):
continue
for ams_unit in ams_raw:
if not isinstance(ams_unit, dict):
continue
sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
if not sn:
continue
try:
serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
except (ValueError, TypeError):
continue
# Add synthetic fallback key (f"p{pid}a{ams_id}") for slots without a serial.
all_serials: set[str] = set(serial_map.values())
for s in slots:
if (s.printer_id, s.ams_id) not in serial_map:
all_serials.add(f"p{s.printer_id}a{s.ams_id}")
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
def _ams_label_for(pid: int, ams_id: int) -> str | None:
sn = serial_map.get((pid, ams_id))
if sn and sn in label_by_serial:
return label_by_serial[sn]
if not sn:
return label_by_serial.get(f"p{pid}a{ams_id}")
return None
enriched: list[SpoolmanSlotAssignmentEnriched] = []
for s in slots:
if s.printer is None:
# FK is ondelete=CASCADE so this should be unreachable in normal
# operation; surface it loudly if a stale row ever appears.
logger.warning(
"Orphaned Spoolman slot assignment: printer_id=%d (ams=%d, tray=%d, spoolman_spool_id=%d) has no Printer row",
s.printer_id,
s.ams_id,
s.tray_id,
s.spoolman_spool_id,
)
enriched.append(
SpoolmanSlotAssignmentEnriched(
printer_id=s.printer_id,
printer_name=s.printer.name if s.printer else None,
ams_id=s.ams_id,
tray_id=s.tray_id,
spoolman_spool_id=s.spoolman_spool_id,
ams_label=_ams_label_for(s.printer_id, s.ams_id),
)
)
return enriched
@router.post("/sync-ams-weights")
async def sync_spoolman_ams_weights(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Sync remaining weight back to Spoolman for all slot-assigned spools.
Reads live AMS remain% from connected printers, computes
remaining = label_weight * remain% / 100, and PATCHes Spoolman.
"""
client = await _get_client(db)
# Fetch all non-archived Spoolman spools once for label_weight lookup
async with _translate_spoolman_errors():
raw_spools = await client.get_all_spools(allow_archived=False)
spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
result = await db.execute(select(SpoolmanSlotAssignment))
assignments = list(result.scalars().all())
synced = 0
skipped = 0
def _find_tray(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
if not ams_data:
return None
for ams_unit in ams_data:
if _safe_int(ams_unit.get("id"), -1) != ams_id:
continue
for tray in ams_unit.get("tray", []):
if _safe_int(tray.get("id"), -1) == tray_id:
return tray
return None
for assignment in assignments:
spool_dict = spool_lookup.get(assignment.spoolman_spool_id)
if not spool_dict:
logger.debug("Spoolman AMS sync: spool %d not found in Spoolman, skipping", assignment.spoolman_spool_id)
skipped += 1
continue
label_weight = _safe_int((spool_dict.get("filament") or {}).get("weight"), 1000)
if label_weight <= 0:
logger.debug("Spoolman AMS sync: spool %d has no label_weight, skipping", assignment.spoolman_spool_id)
skipped += 1
continue
state = printer_manager.get_status(assignment.printer_id)
if not state or not state.raw_data:
logger.info(
"Spoolman AMS sync: printer %d not connected, skipping spool %d",
assignment.printer_id,
assignment.spoolman_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(ams_raw, assignment.ams_id, assignment.tray_id)
if not tray:
logger.info(
"Spoolman AMS sync: no tray data for spool %d (printer %d AMS%d-T%d)",
assignment.spoolman_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(
"Spoolman AMS sync: no remain value for spool %d (tray %d/%d), skipping",
assignment.spoolman_spool_id,
assignment.ams_id,
assignment.tray_id,
)
skipped += 1
continue
try:
remain_val = int(remain_raw)
except (TypeError, ValueError):
logger.debug(
"Spoolman AMS sync: non-numeric remain=%r for spool %d, skipping",
remain_raw,
assignment.spoolman_spool_id,
)
skipped += 1
continue
if remain_val < 0 or remain_val > 100:
logger.debug("Spoolman AMS sync: invalid remain=%s for spool %d", remain_raw, assignment.spoolman_spool_id)
skipped += 1
continue
remaining = round(label_weight * remain_val / 100.0, 1)
try:
async with _translate_spoolman_errors():
await client.update_spool_full(assignment.spoolman_spool_id, remaining_weight=remaining)
logger.info(
"Spoolman AMS sync: spool %d remaining set to %s g (remain=%d%%)",
assignment.spoolman_spool_id,
remaining,
remain_val,
)
synced += 1
except HTTPException as exc:
if exc.status_code == 404:
logger.warning(
"Spoolman AMS sync: spool %d not found in Spoolman (404), skipping",
assignment.spoolman_spool_id,
)
else:
logger.warning(
"Spoolman AMS sync: failed to update spool %d (HTTP %d)",
assignment.spoolman_spool_id,
exc.status_code,
)
skipped += 1
return {"synced": synced, "skipped": skipped}
@router.post("/slot-assignments")
async def assign_spoolman_slot(
body: SpoolSlotAssignmentRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
Raises 404 if the printer does not exist or the spool is not found in Spoolman.
Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
"""
client = await _get_client(db)
result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Verify the Spoolman spool exists before committing to local DB.
# This prevents ghost rows pointing at non-existent spool IDs.
async with _translate_spoolman_errors():
spool = await client.get_spool(body.spoolman_spool_id)
# Spool confirmed in Spoolman — upsert into local slot-assignment table
# assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
try:
await db.execute(
text(
"INSERT INTO spoolman_slot_assignments"
" (printer_id, ams_id, tray_id, spoolman_spool_id)"
" VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
" ON CONFLICT(printer_id, ams_id, tray_id)"
" DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
),
{
"printer_id": body.printer_id,
"ams_id": body.ams_id,
"tray_id": body.tray_id,
"spool_id": body.spoolman_spool_id,
},
)
await db.commit()
except Exception as exc:
await db.rollback()
logger.error("Failed to persist slot assignment: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
# #1457: clear stale fallback-tag links on OTHER spools still bound to this
# slot. Without this, a non-RFID slot's deterministic fallback tag stays
# attached to the previous spool in Spoolman's extra.tag and re-surfaces in
# the hover card whenever the local slot assignment is removed.
if printer.serial_number:
await _clear_stale_slot_fallback_tag_links(
client,
printer_serial=printer.serial_number,
ams_id=body.ams_id,
tray_id=body.tray_id,
keep_spool_id=body.spoolman_spool_id,
)
mapped = _map_spoolman_spool(spool)
# Fetch K-profiles before the MQTT try block so we can use async DB access.
kp_rows_result = await db.execute(
select(SpoolmanKProfile).where(
SpoolmanKProfile.spoolman_spool_id == body.spoolman_spool_id,
SpoolmanKProfile.printer_id == body.printer_id,
)
)
kp_rows = kp_rows_result.scalars().all()
# Auto-configure AMS slot via MQTT (best-effort; slot assignment is already persisted)
try:
mqtt_client = printer_manager.get_client(body.printer_id)
if mqtt_client:
tray_type = mapped.get("material") or ""
brand = mapped.get("brand") or ""
subtype = mapped.get("subtype") or ""
if brand:
tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
elif subtype:
tray_sub_brands = f"{tray_type} {subtype}".strip()
else:
tray_sub_brands = tray_type
tray_color = (mapped.get("rgba") or "808080FF").upper()
if len(tray_color) == 6:
tray_color = tray_color + "FF"
# #1713: resolve the spool's stored slicer_filament reference
# (cloud preset, local preset, GF-prefix builtin, or numeric
# LocalPreset id) to the printer-side tray_info_idx + setting_id.
# Previously the Spoolman path dropped slicer_filament on the
# floor and only the generic-material fallback fired; the user-
# configured profile never reached the printer. Shared with the
# internal-mode route via the same helper so the two flows can't
# drift again.
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
db=db,
current_user=current_user,
slicer_filament=mapped.get("slicer_filament"),
slicer_filament_name=mapped.get("slicer_filament_name"),
material=tray_type,
)
if sub_brand_override:
tray_sub_brands = sub_brand_override
material_upper = tray_type.upper().strip()
# Fall back to generic-material id when slicer_filament is empty
# or the resolver discarded an unresolvable value. Matches the
# internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
if not tray_info_idx:
tray_info_idx = (
GENERIC_FILAMENT_IDS.get(material_upper)
or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
or ""
)
# Ensure setting_id is always derivable from tray_info_idx. The
# local-preset path can leave it empty when the LP's setting JSON
# has no filament_id and falls through to the generic material id;
# without this fallback the slicer gets a half-configured slot
# (filament id without setting id) and the slot detail modal
# renders empty fields. Same pattern as the internal-mode tail.
if tray_info_idx and not setting_id:
setting_id = filament_id_to_setting_id(tray_info_idx)
temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
temp_max = temp_defaults[1]
# Pull printer state from printer_manager. The previous
# `mqtt_client.printer_state` access via hasattr always returned
# None (the attribute is `state`, not `printer_state`), so the
# K-profile cascade silently skipped state.kprofiles, defaulted
# nozzle_diameter to 0.4, and left slot_extruder unset.
state = printer_manager.get_status(body.printer_id)
nozzle_diameter = "0.4"
if state and state.nozzles:
nd = state.nozzles[0].nozzle_diameter
if nd:
nozzle_diameter = nd
slot_extruder = None
if state and state.ams_extruder_map:
if body.ams_id == 255:
# External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
# tray_id 0→1, 1→0
slot_extruder = 1 - body.tray_id
else:
slot_extruder = state.ams_extruder_map.get(str(body.ams_id))
# Prefer exact extruder match, fall back to extruder-agnostic kp
# for the same nozzle. Hard-skipping on mismatch silently dropped
# valid stored profiles when the AMS-extruder mapping had shifted.
exact_kp = None
fallback_kp = None
for kp in kp_rows:
if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
continue
if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
exact_kp = kp
break
if fallback_kp is None:
fallback_kp = kp
matching_kp = exact_kp or fallback_kp
# Resolve the printer-side calibration entry by cali_idx so we
# know the authoritative filament_id (the printer indexes its
# calibration table by filament_id, not setting_id).
printer_kp = None
if matching_kp and state and state.kprofiles:
for pkp in state.kprofiles:
if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
printer_kp = pkp
break
if printer_kp is None:
logger.warning(
"Spoolman assign: cali_idx=%d not present in printer's "
"calibration table — stored kp may be stale.",
matching_kp.cali_idx,
)
# Realign the slot's filament context (tray_info_idx + setting_id)
# to the kp's calibration context. Without this, ams_filament_setting
# declares the slot under generic PLA while extrusion_cali_sel points
# the cali_idx at a different preset — the printer can't link them
# and falls back to the default profile. P-prefix local presets are
# valid for tray_info_idx; PFUS-prefix cloud-user presets are not
# (the slicer rejects them).
effective_tray_info_idx = tray_info_idx
effective_setting_id = setting_id
if printer_kp and printer_kp.filament_id:
if not printer_kp.filament_id.startswith("PFUS"):
effective_tray_info_idx = printer_kp.filament_id
if printer_kp.setting_id:
effective_setting_id = printer_kp.setting_id
elif matching_kp and matching_kp.setting_id:
derived = normalize_slicer_filament(matching_kp.setting_id)[0]
if derived and not derived.startswith("PFUS"):
effective_tray_info_idx = derived
effective_setting_id = matching_kp.setting_id
if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
logger.info(
"Spoolman assign: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
tray_info_idx,
effective_tray_info_idx,
setting_id,
effective_setting_id,
matching_kp.id if matching_kp else None,
"printer" if printer_kp else "stored",
)
mqtt_client.ams_set_filament_setting(
ams_id=body.ams_id,
tray_id=body.tray_id,
tray_info_idx=effective_tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=temp_min,
nozzle_temp_max=temp_max,
setting_id=effective_setting_id,
)
if matching_kp and matching_kp.cali_idx is not None:
# Use printer-reported filament_id when available, otherwise
# fall back to the realigned tray_info_idx so both commands
# reference the same filament context.
cali_filament_id = (
printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
) or effective_tray_info_idx
mqtt_client.extrusion_cali_sel(
ams_id=body.ams_id,
tray_id=body.tray_id,
cali_idx=matching_kp.cali_idx,
filament_id=cali_filament_id,
nozzle_diameter=nozzle_diameter,
)
logger.info(
"Spoolman assign: applied K-profile cali_idx=%d "
"(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
matching_kp.cali_idx,
matching_kp.id,
cali_filament_id,
body.spoolman_spool_id,
body.printer_id,
body.ams_id,
body.tray_id,
)
else:
# No stored K-profile for this spool — always reset the slot to
# Default K (cali_idx=-1). The live cali_idx belongs to whatever
# filament was there before, so preserving it would apply the
# wrong filament's calibration to the new spool.
mqtt_client.extrusion_cali_sel(
ams_id=body.ams_id,
tray_id=body.tray_id,
cali_idx=-1,
filament_id=effective_tray_info_idx,
nozzle_diameter=nozzle_diameter,
)
logger.info(
"No stored K-profile for Spoolman spool %d — reset slot to Default K (cali_idx=-1)",
body.spoolman_spool_id,
)
logger.info(
"Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",
body.ams_id,
body.tray_id,
body.spoolman_spool_id,
body.printer_id,
)
except Exception:
logger.exception(
"Failed to auto-configure AMS slot for Spoolman spool %d (printer=%d, ams=%d, tray=%d)",
body.spoolman_spool_id,
body.printer_id,
body.ams_id,
body.tray_id,
)
return mapped
@router.delete("/slot-assignments/{spoolman_spool_id}")
async def unassign_spoolman_slot(
spoolman_spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Remove the local slot assignment for a Spoolman spool.
Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
"""
client = await _get_client(db)
try:
await db.execute(
delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
)
await db.commit()
except Exception as exc:
await db.rollback()
logger.error("Failed to delete slot assignment: %s", exc)
raise HTTPException(status_code=500, detail="Failed to remove slot assignment") from exc
# Fetch the spool from Spoolman to return in InventorySpool format.
# If the spool no longer exists in Spoolman, the local unassignment still succeeded.
try:
async with _translate_spoolman_errors():
spool = await client.get_spool(spoolman_spool_id)
return _map_spoolman_spool(spool)
except HTTPException as exc:
if exc.status_code != 404:
raise
# Spool no longer exists in Spoolman; unassignment still succeeded.
return {"id": spoolman_spool_id}
@router.get("/slot-assignments")
async def get_spoolman_slot_assignment(
printer_id: int = Query(..., gt=0),
ams_id: int = Query(..., ge=0, le=7),
tray_id: int = Query(..., ge=0, le=3),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> dict | None:
"""Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
client = await _get_client(db)
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
slot_result = await db.execute(
select(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.printer_id == printer_id,
SpoolmanSlotAssignment.ams_id == ams_id,
SpoolmanSlotAssignment.tray_id == tray_id,
)
)
slot = slot_result.scalar_one_or_none()
if not slot:
return None
try:
async with _translate_spoolman_errors():
spool = await client.get_spool(slot.spoolman_spool_id)
return _map_spoolman_spool(spool)
except HTTPException as exc:
if exc.status_code != 404:
raise
# Spool deleted in Spoolman — clean up stale assignment.
# Include spoolman_spool_id in WHERE to avoid a TOCTOU race where a
# concurrent re-assign changed the slot to a different spool between
# the GET and this DELETE.
try:
await db.execute(
delete(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.id == slot.id,
SpoolmanSlotAssignment.spoolman_spool_id == slot.spoolman_spool_id,
)
)
await db.commit()
except Exception as cleanup_exc:
await db.rollback()
logger.warning(
"Failed to remove stale slot assignment for spool %s: %s",
slot.spoolman_spool_id,
cleanup_exc,
)
return None
def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
"""Manually map SpoolmanKProfile → SpoolKProfileResponse-compatible dict."""
return {
"id": p.id,
"spool_id": p.spoolman_spool_id,
"printer_id": p.printer_id,
"extruder": p.extruder,
"nozzle_diameter": p.nozzle_diameter,
"nozzle_type": p.nozzle_type,
"k_value": p.k_value,
"name": p.name,
"cali_idx": p.cali_idx,
"setting_id": p.setting_id,
"created_at": p.created_at,
}
def _normalize_filament(raw: dict) -> NormalizedFilament | None:
"""Normalise a raw Spoolman filament dict for the frontend catalog picker.
Returns None for entries with missing/zero IDs — those are malformed and
must be filtered out before returning to the client.
weight=0 is collapsed to None — 0g is not a valid filament weight.
"""
filament_id = _safe_int(raw.get("id"), 0)
if filament_id <= 0:
logger.warning("Skipping Spoolman filament with missing or invalid id: %r", raw.get("name"))
return None
vendor = raw.get("vendor") or {}
vendor_ref: NormalizedVendorRef | None = None
if vendor:
vendor_id = _safe_int(vendor.get("id"), 0)
if vendor_id <= 0:
logger.warning("Spoolman filament %d has vendor without valid id — vendor omitted", filament_id)
else:
vendor_ref = {"id": vendor_id, "name": str(vendor.get("name") or "").strip() or "Unknown"}
return NormalizedFilament(
id=filament_id,
name=str(raw.get("name") or ""),
material=raw.get("material") or None,
color_hex=raw.get("color_hex") or None,
color_name=raw.get("color_name") or None,
weight=_safe_int(raw.get("weight"), 0) or None, # 0g is not a valid weight
spool_weight=_safe_optional_float(raw.get("spool_weight")),
vendor=vendor_ref,
)
@router.get("/filaments")
async def list_spoolman_filaments(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> list[NormalizedFilament]:
"""Return all filaments from Spoolman, normalised for the frontend catalog picker."""
client = await _get_client(db)
async with _translate_spoolman_errors():
raw_filaments = await client.get_filaments()
if not isinstance(raw_filaments, list):
logger.warning("Spoolman get_filaments() returned non-list type: %s", type(raw_filaments).__name__)
return []
return [f for raw in raw_filaments if (f := _normalize_filament(raw)) is not None]
@router.patch("/filaments/{filament_id}")
async def patch_spoolman_filament(
*,
filament_id: int = Path(..., gt=0),
body: SpoolmanFilamentPatch = Body(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> NormalizedFilament:
"""Update a Spoolman filament's name and/or spool_weight.
When spool_weight changes, Option A (keep_existing_spools=True) stamps the old
weight onto spools currently inheriting it (spool.spool_weight is None) so their
tare calculations are unaffected by the filament change.
Option B (keep_existing_spools=False, the default): when spool_weight is a
concrete value, stamps it onto every affected spool explicitly; when spool_weight
is null, clears per-spool overrides so spools fall back to the filament value.
"""
client = await _get_client(db)
async with _translate_spoolman_errors():
current = await client.get_filament(filament_id)
patch_data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k != "keep_existing_spools"}
if not patch_data:
normalized = _normalize_filament(current)
if normalized is None:
raise HTTPException(status_code=404, detail="Filament not found")
return normalized
async with _translate_spoolman_errors():
updated = await client.patch_filament(filament_id, patch_data)
if "spool_weight" in body.model_fields_set:
async with _translate_spoolman_errors():
all_spools = await client.get_all_spools()
affected_spools = [s for s in all_spools if (s.get("filament") or {}).get("id") == filament_id]
if affected_spools:
if body.keep_existing_spools:
old_weight = _safe_optional_float(current.get("spool_weight"))
if old_weight is not None:
spools_to_fix = [s for s in affected_spools if s.get("spool_weight") is None]
if spools_to_fix:
async with _translate_spoolman_errors():
results = await asyncio.gather(
*(
client.update_spool_full(spool_id=s["id"], spool_weight=old_weight)
for s in spools_to_fix
),
return_exceptions=True,
)
_raise_if_partial_failure(spools_to_fix, results, "spool_weight stamp (option A)")
else:
new_weight = body.spool_weight
if new_weight is not None:
# Stamp the new weight onto every spool of this filament type so
# each spool carries the value explicitly rather than inheriting.
async with _translate_spoolman_errors():
results = await asyncio.gather(
*(
client.update_spool_full(spool_id=s["id"], spool_weight=new_weight)
for s in affected_spools
),
return_exceptions=True,
)
_raise_if_partial_failure(affected_spools, results, "spool_weight stamp (option B)")
else:
# Filament weight is being cleared — remove any per-spool override
# so spools fall back to whatever the filament now provides.
spools_to_clear = [s for s in affected_spools if s.get("spool_weight") is not None]
if spools_to_clear:
async with _translate_spoolman_errors():
results = await asyncio.gather(
*(
client.update_spool_full(spool_id=s["id"], clear_spool_weight=True)
for s in spools_to_clear
),
return_exceptions=True,
)
_raise_if_partial_failure(spools_to_clear, results, "spool_weight clear (option B null)")
normalized = _normalize_filament(updated)
if normalized is None:
raise HTTPException(status_code=502, detail="Spoolman returned malformed filament data")
return normalized
@router.get("/spools/{spool_id}/k-profiles")
async def get_spoolman_k_profiles(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> list[dict]:
"""Return all local K-value calibration profiles for a Spoolman spool."""
await _get_client(db)
result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
profiles = result.scalars().all()
return [_k_profile_to_dict(p) for p in profiles]
@router.put("/spools/{spool_id}/k-profiles")
async def save_spoolman_k_profiles(
spool_id: int = Path(..., gt=0),
profiles: list[SpoolKProfileBase] = Body(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> list[dict]:
"""Replace all K-value calibration profiles for a Spoolman spool."""
client = await _get_client(db)
async with _translate_spoolman_errors():
await client.get_spool(spool_id)
saved: list[SpoolmanKProfile] = []
try:
await db.execute(delete(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
for profile in profiles:
obj = SpoolmanKProfile(
spoolman_spool_id=spool_id,
printer_id=profile.printer_id,
extruder=profile.extruder,
nozzle_diameter=profile.nozzle_diameter,
nozzle_type=profile.nozzle_type,
k_value=profile.k_value,
name=profile.name,
cali_idx=profile.cali_idx,
setting_id=profile.setting_id,
)
db.add(obj)
saved.append(obj)
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(422, "Duplicate or invalid K-profile (check printer_id and nozzle uniqueness)") from exc
except Exception as exc:
await db.rollback()
logger.error("K-profile save for spool %d failed: %s", spool_id, exc)
raise HTTPException(500, "Failed to save K-profiles") from exc
for obj in saved:
await db.refresh(obj)
return [_k_profile_to_dict(p) for p in saved]