| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760 |
- """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.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.printer_manager import printer_manager
- 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,
- 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)
- # 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)
- # 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)
- 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"], [])
- 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()]
- 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)
- 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=data.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)
- 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
- 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=data.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")
- 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
- storage_location = data.storage_location if storage_location_changed else None
- 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)
- 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)
- 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:
- return _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
- @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:
- return _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
- @router.post("/spools/{spool_id}/reset-usage")
- async def reset_spool_usage(
- spool_id: int = Path(..., gt=0),
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ) -> dict:
- """Zero the spool's used_weight in Spoolman without touching anything else."""
- client = await _get_client(db)
- async with _translate_spoolman_errors():
- spool = await client.reset_spool_usage(spool_id)
- try:
- return _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
- @router.post("/spools/reset-usage-bulk")
- async def bulk_reset_spool_usage(
- payload: dict = Body(...),
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
- ) -> dict:
- """Bulk-reset used_weight to 0 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-usage failed for spool %s: %s", spool_id, exc.detail)
- 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)
- 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)
- 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),
- _: 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"
- material_upper = tray_type.upper().strip()
- tray_info_idx = (
- GENERIC_FILAMENT_IDS.get(material_upper)
- or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
- or ""
- )
- setting_id = ""
- temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
- temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
- temp_max = temp_defaults[1]
- # Pull printer state 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]
|