spoolman_inventory.py 64 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541
  1. """Spoolman inventory proxy endpoints.
  2. Translates between Spoolman's data model and Bambuddy's internal
  3. InventorySpool format so the frontend can use a single unified inventory UI
  4. regardless of whether data comes from the local database or Spoolman.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import json
  9. import logging
  10. import re
  11. import time
  12. from contextlib import asynccontextmanager
  13. from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
  14. from fastapi.responses import JSONResponse
  15. from pydantic import BaseModel, Field, field_validator, model_validator
  16. from sqlalchemy import delete, select, text
  17. from sqlalchemy.exc import IntegrityError
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from sqlalchemy.orm import selectinload
  20. from backend.app.api.routes._spoolman_helpers import (
  21. NormalizedFilament,
  22. NormalizedVendorRef,
  23. _map_spoolman_spool,
  24. _safe_float,
  25. _safe_int,
  26. _safe_optional_float,
  27. assert_safe_spoolman_url,
  28. )
  29. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  30. from backend.app.core.database import get_db
  31. from backend.app.core.permissions import Permission
  32. from backend.app.models.ams_label import AmsLabel
  33. from backend.app.models.printer import Printer
  34. from backend.app.models.settings import Settings
  35. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  36. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  37. from backend.app.models.user import User
  38. from backend.app.schemas.spool import SpoolKProfileBase
  39. from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
  40. from backend.app.services.printer_manager import printer_manager
  41. from backend.app.services.spoolman import (
  42. SpoolmanClient,
  43. SpoolmanClientError,
  44. SpoolmanNotFoundError,
  45. SpoolmanUnavailableError,
  46. get_spoolman_client,
  47. init_spoolman_client,
  48. )
  49. from backend.app.utils.filament_ids import (
  50. GENERIC_FILAMENT_IDS,
  51. MATERIAL_TEMPS,
  52. normalize_slicer_filament,
  53. )
  54. logger = logging.getLogger(__name__)
  55. router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"])
  56. # Cache the last successful health-check timestamp to avoid a round-trip on
  57. # every request. A failed check clears the cache immediately.
  58. _health_check_cache: dict[str, float] = {}
  59. _HEALTH_CHECK_TTL = 30.0 # seconds
  60. def _tag_cleared(val: str | None) -> bool:
  61. """Return True when a PATCH field explicitly removes a tag (null)."""
  62. return val is None
  63. async def _get_client(db: AsyncSession) -> SpoolmanClient:
  64. """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
  65. result = await db.execute(select(Settings))
  66. settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()}
  67. enabled = settings.get("spoolman_enabled", "false").lower() == "true"
  68. url = settings.get("spoolman_url", "").strip()
  69. if not enabled:
  70. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  71. if not url:
  72. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  73. # SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
  74. # fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
  75. # intentionally permitted (Spoolman commonly runs on the same host or home LAN).
  76. # Raises ValueError with a descriptive message on any violation.
  77. try:
  78. assert_safe_spoolman_url(url)
  79. except ValueError as exc:
  80. raise HTTPException(status_code=400, detail=str(exc)) from exc
  81. # Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation).
  82. client = await get_spoolman_client()
  83. if not client or client.base_url != url.rstrip("/"):
  84. try:
  85. client = await init_spoolman_client(url)
  86. except ValueError as exc:
  87. raise HTTPException(status_code=400, detail=str(exc)) from exc
  88. # Only call health_check() when the cached result has expired.
  89. # Evict stale entries when URL changes (only one Spoolman URL is active at a time).
  90. if url not in _health_check_cache and _health_check_cache:
  91. _health_check_cache.clear()
  92. now = time.monotonic()
  93. last_ok = _health_check_cache.get(url, 0.0)
  94. if now - last_ok > _HEALTH_CHECK_TTL:
  95. if not await client.health_check():
  96. _health_check_cache.pop(url, None)
  97. raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
  98. _health_check_cache[url] = now
  99. return client
  100. @asynccontextmanager
  101. async def _translate_spoolman_errors():
  102. """Translate Spoolman typed exceptions to HTTP errors for all inventory endpoints."""
  103. try:
  104. yield
  105. except SpoolmanNotFoundError as exc:
  106. raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
  107. except SpoolmanClientError as exc:
  108. raise HTTPException(
  109. status_code=502,
  110. detail={
  111. "message": "Spoolman rejected the request",
  112. "upstream_status": exc.status_code,
  113. "upstream_body": getattr(exc, "response_text", ""),
  114. },
  115. ) from exc
  116. except SpoolmanUnavailableError as exc:
  117. raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
  118. def _raise_if_partial_failure(spools: list[dict], results: list, operation: str) -> None:
  119. """Raise HTTP 502 if any gather result is an exception, logging each failure."""
  120. failures = [(s["id"], r) for s, r in zip(spools, results, strict=True) if isinstance(r, BaseException)]
  121. if failures:
  122. logger.error(
  123. "Partial %s failure: %d/%d spools failed: %s",
  124. operation,
  125. len(failures),
  126. len(spools),
  127. [(sid, type(exc).__name__) for sid, exc in failures],
  128. )
  129. raise HTTPException(
  130. status_code=502,
  131. detail=f"{operation} partially applied: {len(spools) - len(failures)}/{len(spools)} spools updated",
  132. )
  133. async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> tuple[dict, list[str]]:
  134. """Patch the spool price; return (updated_spool, warnings).
  135. Returns the original spool and a non-empty warnings list when the price
  136. update fails, so the caller can return HTTP 207 instead of silently
  137. discarding the price.
  138. """
  139. if cost_per_kg is None:
  140. return spool, []
  141. try:
  142. async with _translate_spoolman_errors():
  143. updated = await client.update_spool_full(spool["id"], price=cost_per_kg)
  144. return updated, []
  145. except HTTPException as exc:
  146. if exc.status_code >= 500:
  147. raise # Propagate network/server errors — don't swallow Spoolman outages
  148. logger.warning(
  149. "Price update failed for spool %d; spool created without price (cost_per_kg=%s, status=%d)",
  150. spool["id"],
  151. cost_per_kg,
  152. exc.status_code,
  153. )
  154. return spool, [f"price_not_set: Spoolman rejected the price update (HTTP {exc.status_code})"]
  155. # ---------------------------------------------------------------------------
  156. # Request / response schemas
  157. # ---------------------------------------------------------------------------
  158. _HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$")
  159. def _validate_rgba(v: str | None) -> str | None:
  160. if v is None:
  161. return v
  162. clean = v.removeprefix("#")
  163. if not _HEX_RE.match(clean):
  164. raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)")
  165. return clean.upper()
  166. def _validate_storage_location(v: str | None) -> str | None:
  167. if v is not None and any(c in v for c in ("\r", "\n", "\x00")):
  168. raise ValueError("storage_location must not contain control characters")
  169. return v
  170. class SpoolmanInventoryCreate(BaseModel):
  171. # When spoolman_filament_id is provided the caller has already chosen a filament from the
  172. # Spoolman catalog, so material (and other metadata) are optional — the backend skips
  173. # find_or_create_filament() and uses the supplied ID directly.
  174. spoolman_filament_id: int | None = Field(None, gt=0)
  175. material: str | None = Field(None, min_length=1, max_length=64)
  176. subtype: str | None = Field(None, max_length=64)
  177. brand: str | None = Field(None, max_length=128)
  178. color_name: str | None = Field(None, max_length=64)
  179. rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
  180. label_weight: int = Field(1000, ge=1, le=100_000)
  181. core_weight: int = Field(
  182. 250, ge=0, le=10_000
  183. ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
  184. weight_used: float = Field(0.0, ge=0.0, le=100_000.0)
  185. note: str | None = Field(None, max_length=1000)
  186. cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
  187. storage_location: str | None = Field(None, max_length=255)
  188. # BambuStudio slicer preset for this spool. Spoolman has no native field
  189. # for this, so we persist it under the bambu_slicer_filament[_name] keys
  190. # in the spool's extra dict and read it back in _map_spoolman_spool.
  191. slicer_filament: str | None = Field(None, max_length=128)
  192. slicer_filament_name: str | None = Field(None, max_length=255)
  193. @field_validator("rgba")
  194. @classmethod
  195. def validate_rgba(cls, v: str | None) -> str | None:
  196. return _validate_rgba(v)
  197. @field_validator("storage_location")
  198. @classmethod
  199. def validate_storage_location(cls, v: str | None) -> str | None:
  200. return _validate_storage_location(v)
  201. @model_validator(mode="after")
  202. def validate_weight_consistency(self) -> SpoolmanInventoryCreate:
  203. # material is required only when the caller has not pre-selected a Spoolman filament
  204. if self.spoolman_filament_id is None and not self.material:
  205. raise ValueError("material is required when spoolman_filament_id is not provided")
  206. if self.weight_used > self.label_weight:
  207. raise ValueError("weight_used must not exceed label_weight")
  208. return self
  209. class SpoolmanInventoryUpdate(BaseModel):
  210. material: str | None = Field(None, min_length=1, max_length=64)
  211. subtype: str | None = Field(None, max_length=64)
  212. brand: str | None = Field(None, max_length=128)
  213. color_name: str | None = Field(None, max_length=64)
  214. rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
  215. label_weight: int | None = Field(None, ge=1, le=100_000)
  216. core_weight: int | None = Field(
  217. None, ge=0, le=10_000
  218. ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
  219. weight_used: float | None = Field(None, ge=0.0, le=100_000.0)
  220. note: str | None = Field(None, max_length=1000)
  221. cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
  222. tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
  223. tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
  224. storage_location: str | None = Field(None, max_length=255)
  225. # BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
  226. # schema). Pass an empty string to clear; null/omitted leaves unchanged.
  227. slicer_filament: str | None = Field(None, max_length=128)
  228. slicer_filament_name: str | None = Field(None, max_length=255)
  229. @field_validator("rgba")
  230. @classmethod
  231. def validate_rgba(cls, v: str | None) -> str | None:
  232. return _validate_rgba(v)
  233. @field_validator("storage_location")
  234. @classmethod
  235. def validate_storage_location(cls, v: str | None) -> str | None:
  236. return _validate_storage_location(v)
  237. @model_validator(mode="after")
  238. def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
  239. # null = remove tag; non-null values rejected (use /tag endpoint to write tags)
  240. if self.tag_uid is not None:
  241. raise ValueError("tag_uid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
  242. if self.tray_uuid is not None:
  243. raise ValueError("tray_uuid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
  244. return self
  245. @model_validator(mode="after")
  246. def validate_weight_consistency(self) -> SpoolmanInventoryUpdate:
  247. if self.weight_used is not None and self.label_weight is not None:
  248. if self.weight_used > self.label_weight:
  249. raise ValueError("weight_used must not exceed label_weight")
  250. return self
  251. class SpoolmanInventoryBulkCreate(BaseModel):
  252. spool: SpoolmanInventoryCreate
  253. quantity: int = Field(1, ge=1, le=50)
  254. class SpoolWeightUpdate(BaseModel):
  255. weight_grams: float = Field(..., ge=0.0, le=100_000.0)
  256. class SpoolTagLinkRequest(BaseModel):
  257. # Minimum 8 hex chars = 4-byte NFC UID (Bambu Lab hardware tags use 4-byte UIDs).
  258. tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
  259. tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
  260. @field_validator("tag_uid")
  261. @classmethod
  262. def tag_uid_not_all_zeros(cls, v: str | None) -> str | None:
  263. if v is not None and all(c in "0" for c in v):
  264. raise ValueError("tag_uid must not be all-zero bytes")
  265. return v
  266. @model_validator(mode="after")
  267. def at_least_one(self) -> SpoolTagLinkRequest:
  268. if not self.tag_uid and not self.tray_uuid:
  269. raise ValueError("tag_uid or tray_uuid is required")
  270. return self
  271. class SpoolSlotAssignmentRequest(BaseModel):
  272. spoolman_spool_id: int = Field(..., gt=0)
  273. printer_id: int = Field(..., gt=0)
  274. # ams_id 0–7 for physical AMS units; 255 = external/virtual spool extruder slot
  275. ams_id: int = Field(..., ge=0, le=255)
  276. tray_id: int = Field(..., ge=0, le=3)
  277. # ---------------------------------------------------------------------------
  278. # Endpoints
  279. # ---------------------------------------------------------------------------
  280. @router.get("/spools")
  281. async def list_spools(
  282. include_archived: bool = Query(False),
  283. db: AsyncSession = Depends(get_db),
  284. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  285. ) -> list[dict]:
  286. """Return all Spoolman spools in the InventorySpool format."""
  287. client = await _get_client(db)
  288. async with _translate_spoolman_errors():
  289. spools = await client.get_all_spools(allow_archived=include_archived)
  290. mapped: list[dict] = []
  291. spool_ids: list[int] = []
  292. for s in spools:
  293. try:
  294. m = _map_spoolman_spool(s)
  295. mapped.append(m)
  296. spool_ids.append(m["id"])
  297. except ValueError as exc:
  298. logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc)
  299. if spool_ids:
  300. kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id.in_(spool_ids)))
  301. kp_by_spool: dict[int, list[dict]] = {}
  302. for kp in kp_result.scalars().all():
  303. kp_by_spool.setdefault(kp.spoolman_spool_id, []).append(_k_profile_to_dict(kp))
  304. for m in mapped:
  305. m["k_profiles"] = kp_by_spool.get(m["id"], [])
  306. return mapped
  307. @router.get("/spools/{spool_id}")
  308. async def get_spool(
  309. spool_id: int = Path(..., gt=0),
  310. db: AsyncSession = Depends(get_db),
  311. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  312. ) -> dict:
  313. """Return a single Spoolman spool in the InventorySpool format."""
  314. client = await _get_client(db)
  315. async with _translate_spoolman_errors():
  316. spool = await client.get_spool(spool_id)
  317. try:
  318. mapped = _map_spoolman_spool(spool)
  319. except ValueError as exc:
  320. logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
  321. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
  322. kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
  323. mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
  324. return mapped
  325. async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanClient) -> int:
  326. """Return the Spoolman filament ID for this spool creation request.
  327. If spoolman_filament_id is set the caller pre-selected a catalog entry,
  328. so find_or_create_filament() is skipped and the ID is used directly.
  329. """
  330. if data.spoolman_filament_id is not None:
  331. return data.spoolman_filament_id
  332. # Validator guarantees material is non-None when spoolman_filament_id is None
  333. assert data.material is not None # noqa: S101
  334. color_hex = (data.rgba or "808080FF")[:6]
  335. async with _translate_spoolman_errors():
  336. return await client.find_or_create_filament(
  337. material=data.material,
  338. subtype=data.subtype or "",
  339. brand=data.brand,
  340. color_hex=color_hex,
  341. label_weight=data.label_weight,
  342. color_name=data.color_name,
  343. )
  344. @router.post("/spools")
  345. async def create_spool(
  346. data: SpoolmanInventoryCreate,
  347. db: AsyncSession = Depends(get_db),
  348. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  349. ) -> dict:
  350. """Create a new spool in Spoolman, auto-creating vendor and filament as needed."""
  351. client = await _get_client(db)
  352. filament_id = await _resolve_filament_id(data, client)
  353. remaining = max(0.0, data.label_weight - data.weight_used)
  354. try:
  355. async with _translate_spoolman_errors():
  356. spool = await client.create_spool(
  357. filament_id=filament_id,
  358. remaining_weight=remaining,
  359. comment=data.note or None,
  360. location=data.storage_location or None,
  361. )
  362. except HTTPException as exc:
  363. if exc.status_code == 404 and data.spoolman_filament_id is not None:
  364. raise HTTPException(
  365. status_code=404,
  366. detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
  367. ) from exc
  368. raise
  369. spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
  370. # Persist slicer_filament under the spool's extra dict (mirror update_spool).
  371. if data.slicer_filament is not None or data.slicer_filament_name is not None:
  372. # Ensure extra fields are registered before write.
  373. if data.slicer_filament is not None:
  374. await client.ensure_extra_field("bambu_slicer_filament")
  375. if data.slicer_filament_name is not None:
  376. await client.ensure_extra_field("bambu_slicer_filament_name")
  377. new_extra: dict = {}
  378. if data.slicer_filament is not None:
  379. new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament)
  380. if data.slicer_filament_name is not None:
  381. new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name)
  382. if new_extra:
  383. try:
  384. async with _translate_spoolman_errors():
  385. spool = await client.merge_spool_extra(spool["id"], new_extra)
  386. except HTTPException:
  387. # Best-effort — the spool already exists, log and continue.
  388. logger.warning(
  389. "Failed to persist slicer_filament for spool %s",
  390. spool.get("id"),
  391. )
  392. result = _map_spoolman_spool(spool)
  393. if price_warnings:
  394. return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
  395. return result
  396. @router.post("/spools/bulk")
  397. async def bulk_create_spools(
  398. payload: SpoolmanInventoryBulkCreate,
  399. db: AsyncSession = Depends(get_db),
  400. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  401. ) -> Response:
  402. """Create multiple identical spools in Spoolman."""
  403. client = await _get_client(db)
  404. data = payload.spool
  405. try:
  406. filament_id = await _resolve_filament_id(data, client)
  407. except HTTPException as exc:
  408. if exc.status_code == 404 and data.spoolman_filament_id is not None:
  409. raise HTTPException(
  410. status_code=404,
  411. detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
  412. ) from exc
  413. raise
  414. remaining = max(0.0, data.label_weight - data.weight_used)
  415. created: list[dict] = []
  416. failures: list[str] = []
  417. for _ in range(payload.quantity):
  418. try:
  419. spool = await client.create_spool(
  420. filament_id=filament_id,
  421. remaining_weight=remaining,
  422. comment=data.note or None,
  423. location=data.storage_location or None,
  424. )
  425. except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
  426. logger.warning("Bulk spool creation: one spool failed: %s", exc)
  427. failures.append("spool creation failed")
  428. continue
  429. try:
  430. spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
  431. except HTTPException as exc:
  432. logger.warning(
  433. "Bulk spool %d: price update failed (HTTP %d); spool not added to created list",
  434. spool.get("id", 0),
  435. exc.status_code,
  436. )
  437. failures.append("spool created but price update failed")
  438. continue
  439. if price_warnings:
  440. logger.warning("Bulk spool %s created without price: %s", spool.get("id"), price_warnings)
  441. created.append(_map_spoolman_spool(spool))
  442. if not created:
  443. raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
  444. if len(created) < payload.quantity:
  445. # Some spool creations failed — return 207 Multi-Status so the caller
  446. # can distinguish a full success from a partial one and show a useful message.
  447. return JSONResponse(
  448. status_code=207,
  449. content={
  450. "created": created,
  451. "requested_count": payload.quantity,
  452. "failed_count": payload.quantity - len(created),
  453. "failures": failures,
  454. },
  455. )
  456. return JSONResponse(status_code=200, content=created)
  457. @router.patch("/spools/{spool_id}")
  458. async def update_spool(
  459. *,
  460. spool_id: int = Path(..., gt=0),
  461. data: SpoolmanInventoryUpdate,
  462. db: AsyncSession = Depends(get_db),
  463. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  464. ) -> dict:
  465. """Update an existing Spoolman spool, re-linking the filament if metadata changed."""
  466. client = await _get_client(db)
  467. async with _translate_spoolman_errors():
  468. current = await client.get_spool(spool_id)
  469. cur_filament: dict = current.get("filament") or {}
  470. cur_vendor: dict = cur_filament.get("vendor") or {}
  471. cur_mat: str = (cur_filament.get("material") or "").strip()
  472. cur_name: str = (cur_filament.get("name") or "").strip()
  473. if cur_mat and cur_name.upper().startswith(cur_mat.upper()):
  474. cur_subtype: str = cur_name[len(cur_mat) :].strip()
  475. else:
  476. cur_subtype = cur_name
  477. # Resolve final values: use request value if provided, else keep current
  478. material = data.material if data.material is not None else cur_mat
  479. subtype = data.subtype if data.subtype is not None else cur_subtype
  480. brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
  481. color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None)
  482. cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
  483. rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
  484. label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
  485. weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0)
  486. note = data.note if data.note is not None else current.get("comment")
  487. storage_location_changed = "storage_location" in data.model_fields_set
  488. storage_location = data.storage_location if storage_location_changed else None
  489. color_hex = rgba[:6]
  490. async with _translate_spoolman_errors():
  491. filament_id = await client.find_or_create_filament(
  492. material=material,
  493. subtype=subtype or "",
  494. brand=brand,
  495. color_hex=color_hex,
  496. label_weight=label_weight,
  497. color_name=color_name,
  498. )
  499. if not filament_id:
  500. raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
  501. remaining = max(0.0, label_weight - weight_used)
  502. # Tag removal: clear only the "tag" key so other custom Spoolman extra fields
  503. # set outside Bambuddy are preserved.
  504. tag_nulled = (
  505. ("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set)
  506. and _tag_cleared(data.tag_uid)
  507. and _tag_cleared(data.tray_uuid)
  508. )
  509. # Serialise tag-clear + PATCH under the per-spool extra lock to prevent a
  510. # concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting
  511. # the tag key between our read and our write.
  512. #
  513. # Spoolman PATCHes extra dicts by MERGING — popping "tag" from a re-fetched
  514. # dict and sending the rest doesn't clear the key (Spoolman keeps the old
  515. # value because the key wasn't in the payload). Explicitly set the tag to
  516. # a JSON-encoded empty string; read-side filters strip the quotes.
  517. async with client.extra_lock(spool_id):
  518. if tag_nulled:
  519. # Re-fetch inside the lock so we work with fresh extra data.
  520. async with _translate_spoolman_errors():
  521. fresh = await client.get_spool(spool_id)
  522. cur_extra = dict(fresh.get("extra") or {})
  523. cur_extra["tag"] = json.dumps("")
  524. extra: dict | None = cur_extra
  525. else:
  526. extra = None
  527. async with _translate_spoolman_errors():
  528. updated = await client.update_spool_full(
  529. spool_id=spool_id,
  530. filament_id=filament_id,
  531. remaining_weight=remaining,
  532. comment=note or "",
  533. price=data.cost_per_kg,
  534. extra=extra,
  535. location=storage_location or None,
  536. clear_location=storage_location_changed and not storage_location,
  537. )
  538. # Persist BambuStudio slicer preset under the spool's extra dict.
  539. # Spoolman doesn't have a native field for this, so we round-trip via
  540. # extra and unpack in _map_spoolman_spool. Only writes when the request
  541. # explicitly set the field — passing null/omitting leaves the existing
  542. # extra entry untouched (write empty string to clear).
  543. sf_set = "slicer_filament" in data.model_fields_set
  544. sfn_set = "slicer_filament_name" in data.model_fields_set
  545. if sf_set or sfn_set:
  546. # Ensure extra fields are registered (Spoolman rejects PATCHes with
  547. # unknown keys with HTTP 400). Idempotent if startup already ran this.
  548. if sf_set:
  549. await client.ensure_extra_field("bambu_slicer_filament")
  550. if sfn_set:
  551. await client.ensure_extra_field("bambu_slicer_filament_name")
  552. new_extra: dict = {}
  553. if sf_set:
  554. new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament or "")
  555. if sfn_set:
  556. new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
  557. async with _translate_spoolman_errors():
  558. updated = await client.merge_spool_extra(spool_id, new_extra)
  559. return _map_spoolman_spool(updated)
  560. @router.delete("/spools/{spool_id}")
  561. async def delete_spool(
  562. spool_id: int = Path(..., gt=0),
  563. db: AsyncSession = Depends(get_db),
  564. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  565. ) -> dict:
  566. """Permanently delete a spool from Spoolman."""
  567. client = await _get_client(db)
  568. async with _translate_spoolman_errors():
  569. await client.delete_spool(spool_id)
  570. return {"status": "deleted"}
  571. @router.post("/spools/{spool_id}/archive")
  572. async def archive_spool(
  573. spool_id: int = Path(..., gt=0),
  574. db: AsyncSession = Depends(get_db),
  575. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  576. ) -> dict:
  577. """Archive a spool in Spoolman (soft-delete)."""
  578. client = await _get_client(db)
  579. async with _translate_spoolman_errors():
  580. spool = await client.set_spool_archived(spool_id, archived=True)
  581. try:
  582. return _map_spoolman_spool(spool)
  583. except ValueError as exc:
  584. logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
  585. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
  586. @router.post("/spools/{spool_id}/restore")
  587. async def restore_spool(
  588. spool_id: int = Path(..., gt=0),
  589. db: AsyncSession = Depends(get_db),
  590. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  591. ) -> dict:
  592. """Restore an archived spool in Spoolman."""
  593. client = await _get_client(db)
  594. async with _translate_spoolman_errors():
  595. spool = await client.set_spool_archived(spool_id, archived=False)
  596. try:
  597. return _map_spoolman_spool(spool)
  598. except ValueError as exc:
  599. logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
  600. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
  601. @router.patch("/spools/{spool_id}/weight")
  602. async def sync_spool_weight(
  603. *,
  604. spool_id: int = Path(..., gt=0),
  605. data: SpoolWeightUpdate,
  606. db: AsyncSession = Depends(get_db),
  607. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  608. ) -> dict:
  609. """Update a spool's remaining weight from a measured gross weight.
  610. Computes remaining = gross_weight - tare, where tare = spool.spool_weight
  611. if set, else filament.spool_weight; falls back to 250 g when both unset.
  612. """
  613. client = await _get_client(db)
  614. async with _translate_spoolman_errors():
  615. current = await client.get_spool(spool_id)
  616. cur_filament = current.get("filament") or {}
  617. spool_tare = current.get("spool_weight")
  618. raw_tare = spool_tare if spool_tare is not None else cur_filament.get("spool_weight")
  619. core_weight = _safe_float(raw_tare, 250.0)
  620. remaining = max(0.0, data.weight_grams - core_weight)
  621. async with _translate_spoolman_errors():
  622. updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining)
  623. upd_filament = updated.get("filament") or {}
  624. label_weight = _safe_int(upd_filament.get("weight"), 1000)
  625. weight_used = max(0.0, label_weight - remaining)
  626. return {"status": "ok", "weight_used": weight_used}
  627. @router.patch("/spools/{spool_id}/tag")
  628. async def link_tag_to_spoolman_spool(
  629. *,
  630. spool_id: int = Path(..., gt=0),
  631. data: SpoolTagLinkRequest,
  632. db: AsyncSession = Depends(get_db),
  633. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  634. ) -> dict:
  635. """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
  636. tray_uuid takes precedence over tag_uid when both are supplied.
  637. Returns 409 if another spool already carries the same tag.
  638. Uses extra_lock to serialise against concurrent extra-field writes.
  639. """
  640. client = await _get_client(db)
  641. tag = (data.tray_uuid or data.tag_uid).upper()
  642. tag_json = json.dumps(tag)
  643. async with client.extra_lock(spool_id):
  644. # Duplicate check: scan all spools for the same tag on a different spool.
  645. async with _translate_spoolman_errors():
  646. all_spools = await client.get_all_spools()
  647. for s in all_spools:
  648. s_tag = (s.get("extra") or {}).get("tag", "")
  649. if s_tag.strip('"').upper() == tag and s.get("id") != spool_id:
  650. raise HTTPException(
  651. status_code=409,
  652. detail=f"Tag is already assigned to spool {s['id']}",
  653. )
  654. # Re-fetch inside the lock so cur_extra reflects any concurrent update.
  655. async with _translate_spoolman_errors():
  656. current = await client.get_spool(spool_id)
  657. cur_extra = dict(current.get("extra") or {})
  658. cur_extra["tag"] = tag_json
  659. async with _translate_spoolman_errors():
  660. updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
  661. logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
  662. return _map_spoolman_spool(updated)
  663. @router.get("/slot-assignments/all", response_model=list[SpoolmanSlotAssignmentEnriched])
  664. async def get_all_spoolman_slot_assignments(
  665. printer_id: int | None = Query(None, gt=0),
  666. db: AsyncSession = Depends(get_db),
  667. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  668. ) -> list[SpoolmanSlotAssignmentEnriched]:
  669. """Return all Spoolman slot assignments enriched with printer_name and ams_label.
  670. ``printer_name`` is null only when the printer relation is missing
  671. (cascade-deleted edge case). ``ams_label`` is null when no AmsLabel row
  672. matches the slot's MQTT serial (or the synthetic ``f"p{pid}a{ams_id}"``
  673. fallback key).
  674. """
  675. query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
  676. if printer_id is not None:
  677. query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
  678. result = await db.execute(query)
  679. slots = list(result.scalars().all())
  680. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  681. # Same pattern as inventory.py:765-806 for the local /assignments endpoint.
  682. printer_ids = {s.printer_id for s in slots}
  683. serial_map: dict[tuple[int, int], str] = {}
  684. all_statuses = printer_manager.get_all_statuses()
  685. for pid in printer_ids:
  686. state = all_statuses.get(pid)
  687. if not (state and state.raw_data):
  688. continue
  689. # Some printer firmware variants wrap the AMS list in an outer dict
  690. # (`{"ams": [...]}`). Mirror the defense used in sync_spoolman_ams_weights
  691. # (line 842-844) so a wrapped payload still resolves to a list.
  692. ams_raw = state.raw_data.get("ams", [])
  693. if isinstance(ams_raw, dict):
  694. ams_raw = ams_raw.get("ams", [])
  695. if not isinstance(ams_raw, list):
  696. continue
  697. for ams_unit in ams_raw:
  698. if not isinstance(ams_unit, dict):
  699. continue
  700. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  701. if not sn:
  702. continue
  703. try:
  704. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  705. except (ValueError, TypeError):
  706. continue
  707. # Add synthetic fallback key (f"p{pid}a{ams_id}") for slots without a serial.
  708. all_serials: set[str] = set(serial_map.values())
  709. for s in slots:
  710. if (s.printer_id, s.ams_id) not in serial_map:
  711. all_serials.add(f"p{s.printer_id}a{s.ams_id}")
  712. label_by_serial: dict[str, str] = {}
  713. if all_serials:
  714. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  715. for lbl in lbl_result.scalars().all():
  716. label_by_serial[lbl.ams_serial_number] = lbl.label
  717. def _ams_label_for(pid: int, ams_id: int) -> str | None:
  718. sn = serial_map.get((pid, ams_id))
  719. if sn and sn in label_by_serial:
  720. return label_by_serial[sn]
  721. if not sn:
  722. return label_by_serial.get(f"p{pid}a{ams_id}")
  723. return None
  724. enriched: list[SpoolmanSlotAssignmentEnriched] = []
  725. for s in slots:
  726. if s.printer is None:
  727. # FK is ondelete=CASCADE so this should be unreachable in normal
  728. # operation; surface it loudly if a stale row ever appears.
  729. logger.warning(
  730. "Orphaned Spoolman slot assignment: printer_id=%d (ams=%d, tray=%d, spoolman_spool_id=%d) has no Printer row",
  731. s.printer_id,
  732. s.ams_id,
  733. s.tray_id,
  734. s.spoolman_spool_id,
  735. )
  736. enriched.append(
  737. SpoolmanSlotAssignmentEnriched(
  738. printer_id=s.printer_id,
  739. printer_name=s.printer.name if s.printer else None,
  740. ams_id=s.ams_id,
  741. tray_id=s.tray_id,
  742. spoolman_spool_id=s.spoolman_spool_id,
  743. ams_label=_ams_label_for(s.printer_id, s.ams_id),
  744. )
  745. )
  746. return enriched
  747. @router.post("/sync-ams-weights")
  748. async def sync_spoolman_ams_weights(
  749. db: AsyncSession = Depends(get_db),
  750. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  751. ):
  752. """Sync remaining weight back to Spoolman for all slot-assigned spools.
  753. Reads live AMS remain% from connected printers, computes
  754. remaining = label_weight * remain% / 100, and PATCHes Spoolman.
  755. """
  756. client = await _get_client(db)
  757. # Fetch all non-archived Spoolman spools once for label_weight lookup
  758. async with _translate_spoolman_errors():
  759. raw_spools = await client.get_all_spools(allow_archived=False)
  760. spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
  761. result = await db.execute(select(SpoolmanSlotAssignment))
  762. assignments = list(result.scalars().all())
  763. synced = 0
  764. skipped = 0
  765. def _find_tray(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  766. if not ams_data:
  767. return None
  768. for ams_unit in ams_data:
  769. if _safe_int(ams_unit.get("id"), -1) != ams_id:
  770. continue
  771. for tray in ams_unit.get("tray", []):
  772. if _safe_int(tray.get("id"), -1) == tray_id:
  773. return tray
  774. return None
  775. for assignment in assignments:
  776. spool_dict = spool_lookup.get(assignment.spoolman_spool_id)
  777. if not spool_dict:
  778. logger.debug("Spoolman AMS sync: spool %d not found in Spoolman, skipping", assignment.spoolman_spool_id)
  779. skipped += 1
  780. continue
  781. label_weight = _safe_int((spool_dict.get("filament") or {}).get("weight"), 1000)
  782. if label_weight <= 0:
  783. logger.debug("Spoolman AMS sync: spool %d has no label_weight, skipping", assignment.spoolman_spool_id)
  784. skipped += 1
  785. continue
  786. state = printer_manager.get_status(assignment.printer_id)
  787. if not state or not state.raw_data:
  788. logger.info(
  789. "Spoolman AMS sync: printer %d not connected, skipping spool %d",
  790. assignment.printer_id,
  791. assignment.spoolman_spool_id,
  792. )
  793. skipped += 1
  794. continue
  795. ams_raw = state.raw_data.get("ams", [])
  796. if isinstance(ams_raw, dict):
  797. ams_raw = ams_raw.get("ams", [])
  798. tray = _find_tray(ams_raw, assignment.ams_id, assignment.tray_id)
  799. if not tray:
  800. logger.info(
  801. "Spoolman AMS sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  802. assignment.spoolman_spool_id,
  803. assignment.printer_id,
  804. assignment.ams_id,
  805. assignment.tray_id,
  806. )
  807. skipped += 1
  808. continue
  809. remain_raw = tray.get("remain")
  810. if remain_raw is None:
  811. logger.debug(
  812. "Spoolman AMS sync: no remain value for spool %d (tray %d/%d), skipping",
  813. assignment.spoolman_spool_id,
  814. assignment.ams_id,
  815. assignment.tray_id,
  816. )
  817. skipped += 1
  818. continue
  819. try:
  820. remain_val = int(remain_raw)
  821. except (TypeError, ValueError):
  822. logger.debug(
  823. "Spoolman AMS sync: non-numeric remain=%r for spool %d, skipping",
  824. remain_raw,
  825. assignment.spoolman_spool_id,
  826. )
  827. skipped += 1
  828. continue
  829. if remain_val < 0 or remain_val > 100:
  830. logger.debug("Spoolman AMS sync: invalid remain=%s for spool %d", remain_raw, assignment.spoolman_spool_id)
  831. skipped += 1
  832. continue
  833. remaining = round(label_weight * remain_val / 100.0, 1)
  834. try:
  835. async with _translate_spoolman_errors():
  836. await client.update_spool_full(assignment.spoolman_spool_id, remaining_weight=remaining)
  837. logger.info(
  838. "Spoolman AMS sync: spool %d remaining set to %s g (remain=%d%%)",
  839. assignment.spoolman_spool_id,
  840. remaining,
  841. remain_val,
  842. )
  843. synced += 1
  844. except HTTPException as exc:
  845. if exc.status_code == 404:
  846. logger.warning(
  847. "Spoolman AMS sync: spool %d not found in Spoolman (404), skipping",
  848. assignment.spoolman_spool_id,
  849. )
  850. else:
  851. logger.warning(
  852. "Spoolman AMS sync: failed to update spool %d (HTTP %d)",
  853. assignment.spoolman_spool_id,
  854. exc.status_code,
  855. )
  856. skipped += 1
  857. return {"synced": synced, "skipped": skipped}
  858. @router.post("/slot-assignments")
  859. async def assign_spoolman_slot(
  860. body: SpoolSlotAssignmentRequest,
  861. db: AsyncSession = Depends(get_db),
  862. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  863. ) -> dict:
  864. """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
  865. Raises 404 if the printer does not exist or the spool is not found in Spoolman.
  866. Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
  867. """
  868. client = await _get_client(db)
  869. result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
  870. printer = result.scalar_one_or_none()
  871. if not printer:
  872. raise HTTPException(status_code=404, detail="Printer not found")
  873. # Verify the Spoolman spool exists before committing to local DB.
  874. # This prevents ghost rows pointing at non-existent spool IDs.
  875. async with _translate_spoolman_errors():
  876. spool = await client.get_spool(body.spoolman_spool_id)
  877. # Spool confirmed in Spoolman — upsert into local slot-assignment table
  878. # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
  879. try:
  880. await db.execute(
  881. text(
  882. "INSERT INTO spoolman_slot_assignments"
  883. " (printer_id, ams_id, tray_id, spoolman_spool_id)"
  884. " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
  885. " ON CONFLICT(printer_id, ams_id, tray_id)"
  886. " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
  887. ),
  888. {
  889. "printer_id": body.printer_id,
  890. "ams_id": body.ams_id,
  891. "tray_id": body.tray_id,
  892. "spool_id": body.spoolman_spool_id,
  893. },
  894. )
  895. await db.commit()
  896. except Exception as exc:
  897. await db.rollback()
  898. logger.error("Failed to persist slot assignment: %s", exc)
  899. raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
  900. mapped = _map_spoolman_spool(spool)
  901. # Fetch K-profiles before the MQTT try block so we can use async DB access.
  902. kp_rows_result = await db.execute(
  903. select(SpoolmanKProfile).where(
  904. SpoolmanKProfile.spoolman_spool_id == body.spoolman_spool_id,
  905. SpoolmanKProfile.printer_id == body.printer_id,
  906. )
  907. )
  908. kp_rows = kp_rows_result.scalars().all()
  909. # Auto-configure AMS slot via MQTT (best-effort; slot assignment is already persisted)
  910. try:
  911. mqtt_client = printer_manager.get_client(body.printer_id)
  912. if mqtt_client:
  913. tray_type = mapped.get("material") or ""
  914. brand = mapped.get("brand") or ""
  915. subtype = mapped.get("subtype") or ""
  916. if brand:
  917. tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
  918. elif subtype:
  919. tray_sub_brands = f"{tray_type} {subtype}".strip()
  920. else:
  921. tray_sub_brands = tray_type
  922. tray_color = (mapped.get("rgba") or "808080FF").upper()
  923. if len(tray_color) == 6:
  924. tray_color = tray_color + "FF"
  925. material_upper = tray_type.upper().strip()
  926. tray_info_idx = (
  927. GENERIC_FILAMENT_IDS.get(material_upper)
  928. or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
  929. or ""
  930. )
  931. setting_id = ""
  932. temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
  933. temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
  934. temp_max = temp_defaults[1]
  935. # Pull printer state from printer_manager. The previous
  936. # `mqtt_client.printer_state` access via hasattr always returned
  937. # None (the attribute is `state`, not `printer_state`), so the
  938. # K-profile cascade silently skipped state.kprofiles, defaulted
  939. # nozzle_diameter to 0.4, and left slot_extruder unset.
  940. state = printer_manager.get_status(body.printer_id)
  941. nozzle_diameter = "0.4"
  942. if state and state.nozzles:
  943. nd = state.nozzles[0].nozzle_diameter
  944. if nd:
  945. nozzle_diameter = nd
  946. slot_extruder = None
  947. if state and state.ams_extruder_map:
  948. if body.ams_id == 255:
  949. # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  950. # tray_id 0→1, 1→0
  951. slot_extruder = 1 - body.tray_id
  952. else:
  953. slot_extruder = state.ams_extruder_map.get(str(body.ams_id))
  954. # Prefer exact extruder match, fall back to extruder-agnostic kp
  955. # for the same nozzle. Hard-skipping on mismatch silently dropped
  956. # valid stored profiles when the AMS-extruder mapping had shifted.
  957. exact_kp = None
  958. fallback_kp = None
  959. for kp in kp_rows:
  960. if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
  961. continue
  962. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  963. exact_kp = kp
  964. break
  965. if fallback_kp is None:
  966. fallback_kp = kp
  967. matching_kp = exact_kp or fallback_kp
  968. # Resolve the printer-side calibration entry by cali_idx so we
  969. # know the authoritative filament_id (the printer indexes its
  970. # calibration table by filament_id, not setting_id).
  971. printer_kp = None
  972. if matching_kp and state and state.kprofiles:
  973. for pkp in state.kprofiles:
  974. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  975. printer_kp = pkp
  976. break
  977. if printer_kp is None:
  978. logger.warning(
  979. "Spoolman assign: cali_idx=%d not present in printer's "
  980. "calibration table — stored kp may be stale.",
  981. matching_kp.cali_idx,
  982. )
  983. # Realign the slot's filament context (tray_info_idx + setting_id)
  984. # to the kp's calibration context. Without this, ams_filament_setting
  985. # declares the slot under generic PLA while extrusion_cali_sel points
  986. # the cali_idx at a different preset — the printer can't link them
  987. # and falls back to the default profile. P-prefix local presets are
  988. # valid for tray_info_idx; PFUS-prefix cloud-user presets are not
  989. # (the slicer rejects them).
  990. effective_tray_info_idx = tray_info_idx
  991. effective_setting_id = setting_id
  992. if printer_kp and printer_kp.filament_id:
  993. if not printer_kp.filament_id.startswith("PFUS"):
  994. effective_tray_info_idx = printer_kp.filament_id
  995. if printer_kp.setting_id:
  996. effective_setting_id = printer_kp.setting_id
  997. elif matching_kp and matching_kp.setting_id:
  998. derived = normalize_slicer_filament(matching_kp.setting_id)[0]
  999. if derived and not derived.startswith("PFUS"):
  1000. effective_tray_info_idx = derived
  1001. effective_setting_id = matching_kp.setting_id
  1002. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  1003. logger.info(
  1004. "Spoolman assign: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
  1005. tray_info_idx,
  1006. effective_tray_info_idx,
  1007. setting_id,
  1008. effective_setting_id,
  1009. matching_kp.id if matching_kp else None,
  1010. "printer" if printer_kp else "stored",
  1011. )
  1012. mqtt_client.ams_set_filament_setting(
  1013. ams_id=body.ams_id,
  1014. tray_id=body.tray_id,
  1015. tray_info_idx=effective_tray_info_idx,
  1016. tray_type=tray_type,
  1017. tray_sub_brands=tray_sub_brands,
  1018. tray_color=tray_color,
  1019. nozzle_temp_min=temp_min,
  1020. nozzle_temp_max=temp_max,
  1021. setting_id=effective_setting_id,
  1022. )
  1023. if matching_kp and matching_kp.cali_idx is not None:
  1024. # Use printer-reported filament_id when available, otherwise
  1025. # fall back to the realigned tray_info_idx so both commands
  1026. # reference the same filament context.
  1027. cali_filament_id = (
  1028. printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
  1029. ) or effective_tray_info_idx
  1030. mqtt_client.extrusion_cali_sel(
  1031. ams_id=body.ams_id,
  1032. tray_id=body.tray_id,
  1033. cali_idx=matching_kp.cali_idx,
  1034. filament_id=cali_filament_id,
  1035. nozzle_diameter=nozzle_diameter,
  1036. )
  1037. logger.info(
  1038. "Spoolman assign: applied K-profile cali_idx=%d "
  1039. "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
  1040. matching_kp.cali_idx,
  1041. matching_kp.id,
  1042. cali_filament_id,
  1043. body.spoolman_spool_id,
  1044. body.printer_id,
  1045. body.ams_id,
  1046. body.tray_id,
  1047. )
  1048. else:
  1049. # No stored K-profile: preserve the slot's current live cali_idx
  1050. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  1051. live_tray = None
  1052. if state and state.raw_data:
  1053. ams_raw = state.raw_data.get("ams", [])
  1054. if isinstance(ams_raw, dict):
  1055. ams_raw = ams_raw.get("ams", [])
  1056. live_tray = _find_tray_in_ams_data(ams_raw, body.ams_id, body.tray_id)
  1057. live_cali_idx = (live_tray or {}).get("cali_idx")
  1058. if live_cali_idx is not None and live_cali_idx >= 0:
  1059. mqtt_client.extrusion_cali_sel(
  1060. ams_id=body.ams_id,
  1061. tray_id=body.tray_id,
  1062. cali_idx=live_cali_idx,
  1063. filament_id=effective_tray_info_idx,
  1064. nozzle_diameter=nozzle_diameter,
  1065. )
  1066. logger.info(
  1067. "No stored K-profile for Spoolman spool %d — preserved live cali_idx=%d",
  1068. body.spoolman_spool_id,
  1069. live_cali_idx,
  1070. )
  1071. logger.info(
  1072. "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",
  1073. body.ams_id,
  1074. body.tray_id,
  1075. body.spoolman_spool_id,
  1076. body.printer_id,
  1077. )
  1078. except Exception:
  1079. logger.exception(
  1080. "Failed to auto-configure AMS slot for Spoolman spool %d (printer=%d, ams=%d, tray=%d)",
  1081. body.spoolman_spool_id,
  1082. body.printer_id,
  1083. body.ams_id,
  1084. body.tray_id,
  1085. )
  1086. return mapped
  1087. @router.delete("/slot-assignments/{spoolman_spool_id}")
  1088. async def unassign_spoolman_slot(
  1089. spoolman_spool_id: int = Path(..., gt=0),
  1090. db: AsyncSession = Depends(get_db),
  1091. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1092. ) -> dict:
  1093. """Remove the local slot assignment for a Spoolman spool.
  1094. Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
  1095. """
  1096. client = await _get_client(db)
  1097. try:
  1098. await db.execute(
  1099. delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
  1100. )
  1101. await db.commit()
  1102. except Exception as exc:
  1103. await db.rollback()
  1104. logger.error("Failed to delete slot assignment: %s", exc)
  1105. raise HTTPException(status_code=500, detail="Failed to remove slot assignment") from exc
  1106. # Fetch the spool from Spoolman to return in InventorySpool format.
  1107. # If the spool no longer exists in Spoolman, the local unassignment still succeeded.
  1108. try:
  1109. async with _translate_spoolman_errors():
  1110. spool = await client.get_spool(spoolman_spool_id)
  1111. return _map_spoolman_spool(spool)
  1112. except HTTPException as exc:
  1113. if exc.status_code != 404:
  1114. raise
  1115. # Spool no longer exists in Spoolman; unassignment still succeeded.
  1116. return {"id": spoolman_spool_id}
  1117. @router.get("/slot-assignments")
  1118. async def get_spoolman_slot_assignment(
  1119. printer_id: int = Query(..., gt=0),
  1120. ams_id: int = Query(..., ge=0, le=7),
  1121. tray_id: int = Query(..., ge=0, le=3),
  1122. db: AsyncSession = Depends(get_db),
  1123. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1124. ) -> dict | None:
  1125. """Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
  1126. client = await _get_client(db)
  1127. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1128. printer = result.scalar_one_or_none()
  1129. if not printer:
  1130. raise HTTPException(status_code=404, detail="Printer not found")
  1131. slot_result = await db.execute(
  1132. select(SpoolmanSlotAssignment).where(
  1133. SpoolmanSlotAssignment.printer_id == printer_id,
  1134. SpoolmanSlotAssignment.ams_id == ams_id,
  1135. SpoolmanSlotAssignment.tray_id == tray_id,
  1136. )
  1137. )
  1138. slot = slot_result.scalar_one_or_none()
  1139. if not slot:
  1140. return None
  1141. try:
  1142. async with _translate_spoolman_errors():
  1143. spool = await client.get_spool(slot.spoolman_spool_id)
  1144. return _map_spoolman_spool(spool)
  1145. except HTTPException as exc:
  1146. if exc.status_code != 404:
  1147. raise
  1148. # Spool deleted in Spoolman — clean up stale assignment.
  1149. # Include spoolman_spool_id in WHERE to avoid a TOCTOU race where a
  1150. # concurrent re-assign changed the slot to a different spool between
  1151. # the GET and this DELETE.
  1152. try:
  1153. await db.execute(
  1154. delete(SpoolmanSlotAssignment).where(
  1155. SpoolmanSlotAssignment.id == slot.id,
  1156. SpoolmanSlotAssignment.spoolman_spool_id == slot.spoolman_spool_id,
  1157. )
  1158. )
  1159. await db.commit()
  1160. except Exception as cleanup_exc:
  1161. await db.rollback()
  1162. logger.warning(
  1163. "Failed to remove stale slot assignment for spool %s: %s",
  1164. slot.spoolman_spool_id,
  1165. cleanup_exc,
  1166. )
  1167. return None
  1168. def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
  1169. """Manually map SpoolmanKProfile → SpoolKProfileResponse-compatible dict."""
  1170. return {
  1171. "id": p.id,
  1172. "spool_id": p.spoolman_spool_id,
  1173. "printer_id": p.printer_id,
  1174. "extruder": p.extruder,
  1175. "nozzle_diameter": p.nozzle_diameter,
  1176. "nozzle_type": p.nozzle_type,
  1177. "k_value": p.k_value,
  1178. "name": p.name,
  1179. "cali_idx": p.cali_idx,
  1180. "setting_id": p.setting_id,
  1181. "created_at": p.created_at,
  1182. }
  1183. def _normalize_filament(raw: dict) -> NormalizedFilament | None:
  1184. """Normalise a raw Spoolman filament dict for the frontend catalog picker.
  1185. Returns None for entries with missing/zero IDs — those are malformed and
  1186. must be filtered out before returning to the client.
  1187. weight=0 is collapsed to None — 0g is not a valid filament weight.
  1188. """
  1189. filament_id = _safe_int(raw.get("id"), 0)
  1190. if filament_id <= 0:
  1191. logger.warning("Skipping Spoolman filament with missing or invalid id: %r", raw.get("name"))
  1192. return None
  1193. vendor = raw.get("vendor") or {}
  1194. vendor_ref: NormalizedVendorRef | None = None
  1195. if vendor:
  1196. vendor_id = _safe_int(vendor.get("id"), 0)
  1197. if vendor_id <= 0:
  1198. logger.warning("Spoolman filament %d has vendor without valid id — vendor omitted", filament_id)
  1199. else:
  1200. vendor_ref = {"id": vendor_id, "name": str(vendor.get("name") or "").strip() or "Unknown"}
  1201. return NormalizedFilament(
  1202. id=filament_id,
  1203. name=str(raw.get("name") or ""),
  1204. material=raw.get("material") or None,
  1205. color_hex=raw.get("color_hex") or None,
  1206. color_name=raw.get("color_name") or None,
  1207. weight=_safe_int(raw.get("weight"), 0) or None, # 0g is not a valid weight
  1208. spool_weight=_safe_optional_float(raw.get("spool_weight")),
  1209. vendor=vendor_ref,
  1210. )
  1211. @router.get("/filaments")
  1212. async def list_spoolman_filaments(
  1213. db: AsyncSession = Depends(get_db),
  1214. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1215. ) -> list[NormalizedFilament]:
  1216. """Return all filaments from Spoolman, normalised for the frontend catalog picker."""
  1217. client = await _get_client(db)
  1218. async with _translate_spoolman_errors():
  1219. raw_filaments = await client.get_filaments()
  1220. if not isinstance(raw_filaments, list):
  1221. logger.warning("Spoolman get_filaments() returned non-list type: %s", type(raw_filaments).__name__)
  1222. return []
  1223. return [f for raw in raw_filaments if (f := _normalize_filament(raw)) is not None]
  1224. @router.patch("/filaments/{filament_id}")
  1225. async def patch_spoolman_filament(
  1226. *,
  1227. filament_id: int = Path(..., gt=0),
  1228. body: SpoolmanFilamentPatch = Body(...),
  1229. db: AsyncSession = Depends(get_db),
  1230. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1231. ) -> NormalizedFilament:
  1232. """Update a Spoolman filament's name and/or spool_weight.
  1233. When spool_weight changes, Option A (keep_existing_spools=True) stamps the old
  1234. weight onto spools currently inheriting it (spool.spool_weight is None) so their
  1235. tare calculations are unaffected by the filament change.
  1236. Option B (keep_existing_spools=False, the default): when spool_weight is a
  1237. concrete value, stamps it onto every affected spool explicitly; when spool_weight
  1238. is null, clears per-spool overrides so spools fall back to the filament value.
  1239. """
  1240. client = await _get_client(db)
  1241. async with _translate_spoolman_errors():
  1242. current = await client.get_filament(filament_id)
  1243. patch_data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k != "keep_existing_spools"}
  1244. if not patch_data:
  1245. normalized = _normalize_filament(current)
  1246. if normalized is None:
  1247. raise HTTPException(status_code=404, detail="Filament not found")
  1248. return normalized
  1249. async with _translate_spoolman_errors():
  1250. updated = await client.patch_filament(filament_id, patch_data)
  1251. if "spool_weight" in body.model_fields_set:
  1252. async with _translate_spoolman_errors():
  1253. all_spools = await client.get_all_spools()
  1254. affected_spools = [s for s in all_spools if (s.get("filament") or {}).get("id") == filament_id]
  1255. if affected_spools:
  1256. if body.keep_existing_spools:
  1257. old_weight = _safe_optional_float(current.get("spool_weight"))
  1258. if old_weight is not None:
  1259. spools_to_fix = [s for s in affected_spools if s.get("spool_weight") is None]
  1260. if spools_to_fix:
  1261. async with _translate_spoolman_errors():
  1262. results = await asyncio.gather(
  1263. *(
  1264. client.update_spool_full(spool_id=s["id"], spool_weight=old_weight)
  1265. for s in spools_to_fix
  1266. ),
  1267. return_exceptions=True,
  1268. )
  1269. _raise_if_partial_failure(spools_to_fix, results, "spool_weight stamp (option A)")
  1270. else:
  1271. new_weight = body.spool_weight
  1272. if new_weight is not None:
  1273. # Stamp the new weight onto every spool of this filament type so
  1274. # each spool carries the value explicitly rather than inheriting.
  1275. async with _translate_spoolman_errors():
  1276. results = await asyncio.gather(
  1277. *(
  1278. client.update_spool_full(spool_id=s["id"], spool_weight=new_weight)
  1279. for s in affected_spools
  1280. ),
  1281. return_exceptions=True,
  1282. )
  1283. _raise_if_partial_failure(affected_spools, results, "spool_weight stamp (option B)")
  1284. else:
  1285. # Filament weight is being cleared — remove any per-spool override
  1286. # so spools fall back to whatever the filament now provides.
  1287. spools_to_clear = [s for s in affected_spools if s.get("spool_weight") is not None]
  1288. if spools_to_clear:
  1289. async with _translate_spoolman_errors():
  1290. results = await asyncio.gather(
  1291. *(
  1292. client.update_spool_full(spool_id=s["id"], clear_spool_weight=True)
  1293. for s in spools_to_clear
  1294. ),
  1295. return_exceptions=True,
  1296. )
  1297. _raise_if_partial_failure(spools_to_clear, results, "spool_weight clear (option B null)")
  1298. normalized = _normalize_filament(updated)
  1299. if normalized is None:
  1300. raise HTTPException(status_code=502, detail="Spoolman returned malformed filament data")
  1301. return normalized
  1302. @router.get("/spools/{spool_id}/k-profiles")
  1303. async def get_spoolman_k_profiles(
  1304. spool_id: int = Path(..., gt=0),
  1305. db: AsyncSession = Depends(get_db),
  1306. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1307. ) -> list[dict]:
  1308. """Return all local K-value calibration profiles for a Spoolman spool."""
  1309. await _get_client(db)
  1310. result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
  1311. profiles = result.scalars().all()
  1312. return [_k_profile_to_dict(p) for p in profiles]
  1313. @router.put("/spools/{spool_id}/k-profiles")
  1314. async def save_spoolman_k_profiles(
  1315. spool_id: int = Path(..., gt=0),
  1316. profiles: list[SpoolKProfileBase] = Body(...),
  1317. db: AsyncSession = Depends(get_db),
  1318. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1319. ) -> list[dict]:
  1320. """Replace all K-value calibration profiles for a Spoolman spool."""
  1321. client = await _get_client(db)
  1322. async with _translate_spoolman_errors():
  1323. await client.get_spool(spool_id)
  1324. saved: list[SpoolmanKProfile] = []
  1325. try:
  1326. await db.execute(delete(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
  1327. for profile in profiles:
  1328. obj = SpoolmanKProfile(
  1329. spoolman_spool_id=spool_id,
  1330. printer_id=profile.printer_id,
  1331. extruder=profile.extruder,
  1332. nozzle_diameter=profile.nozzle_diameter,
  1333. nozzle_type=profile.nozzle_type,
  1334. k_value=profile.k_value,
  1335. name=profile.name,
  1336. cali_idx=profile.cali_idx,
  1337. setting_id=profile.setting_id,
  1338. )
  1339. db.add(obj)
  1340. saved.append(obj)
  1341. await db.commit()
  1342. except IntegrityError as exc:
  1343. await db.rollback()
  1344. raise HTTPException(422, "Duplicate or invalid K-profile (check printer_id and nozzle uniqueness)") from exc
  1345. except Exception as exc:
  1346. await db.rollback()
  1347. logger.error("K-profile save for spool %d failed: %s", spool_id, exc)
  1348. raise HTTPException(500, "Failed to save K-profiles") from exc
  1349. for obj in saved:
  1350. await db.refresh(obj)
  1351. return [_k_profile_to_dict(p) for p in saved]