spoolman_inventory.py 127 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811
  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, nullcontext
  13. from datetime import datetime
  14. from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
  15. from fastapi.responses import JSONResponse
  16. from pydantic import BaseModel, Field, field_validator, model_validator
  17. from sqlalchemy import delete, select, text, tuple_
  18. from sqlalchemy.exc import IntegrityError
  19. from sqlalchemy.ext.asyncio import AsyncSession
  20. from sqlalchemy.orm import selectinload
  21. from backend.app.api.routes._spoolman_helpers import (
  22. BAMBU_LAST_DRIED_AT_KEY,
  23. BAMBU_LAST_DRIED_HOURS_KEY,
  24. BAMBU_LAST_DRIED_TEMP_KEY,
  25. NormalizedFilament,
  26. NormalizedVendorRef,
  27. _map_spoolman_spool,
  28. _safe_int,
  29. _safe_optional_float,
  30. assert_safe_spoolman_url,
  31. cost_per_kg_to_spoolman_price,
  32. spoolman_cost_per_kg,
  33. spoolman_net_weight,
  34. spoolman_price_weight,
  35. spoolman_tare,
  36. )
  37. from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
  38. from backend.app.core.database import get_db
  39. from backend.app.core.permissions import Permission
  40. from backend.app.core.printer_scope import PrinterScope
  41. from backend.app.core.websocket import ws_manager
  42. from backend.app.models.ams_label import AmsLabel
  43. from backend.app.models.printer import Printer
  44. from backend.app.models.settings import Settings
  45. from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
  46. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  47. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  48. from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
  49. from backend.app.models.user import User
  50. from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
  51. from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
  52. from backend.app.schemas.supplier import SpoolSupplierLinkInput
  53. from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
  54. from backend.app.services.ams_assignment_lock import get_spool_move_lock
  55. from backend.app.services.location_service import (
  56. enrich_spool_dicts_with_location_id,
  57. maybe_sync_spoolman_locations,
  58. resolve_spoolman_location_string,
  59. )
  60. from backend.app.services.printer_manager import PrinterManager, printer_manager
  61. from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
  62. from backend.app.services.slot_nozzle import resolve_slot_nozzle
  63. from backend.app.services.spool_filament_preset import forget_spoolman_spool_own_preset, resolve_spoolman_preset
  64. from backend.app.services.spoolman import (
  65. SpoolmanClient,
  66. SpoolmanClientError,
  67. SpoolmanNotFoundError,
  68. SpoolmanUnavailableError,
  69. get_spoolman_client,
  70. init_spoolman_client,
  71. )
  72. from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot, is_slot_fallback_tag
  73. from backend.app.services.tag_conflict import tag_already_linked
  74. from backend.app.utils.color_utils import spoolman_color_hex
  75. from backend.app.utils.filament_finish import finish_generic_id
  76. from backend.app.utils.filament_ids import (
  77. GENERIC_FILAMENT_IDS,
  78. filament_id_to_setting_id,
  79. normalize_slicer_filament,
  80. )
  81. from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
  82. logger = logging.getLogger(__name__)
  83. router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"])
  84. # Cache the last successful health-check timestamp to avoid a round-trip on
  85. # every request. A failed check clears the cache immediately.
  86. _health_check_cache: dict[str, float] = {}
  87. _HEALTH_CHECK_TTL = 30.0 # seconds
  88. def _tag_cleared(val: str | None) -> bool:
  89. """Return True when a PATCH field explicitly removes a tag (null)."""
  90. return val is None
  91. async def _clear_stale_tag_links(
  92. client: SpoolmanClient,
  93. *,
  94. tag: str,
  95. keep_spool_id: int,
  96. log_context: str,
  97. ) -> int:
  98. """Clear extra.tag on OTHER spools still claiming the given tag (#1457).
  99. A given AMS slot tag — whether a real RFID (tray_uuid/tag_uid) or the
  100. deterministic fallback derived from (printer_serial, ams_id, tray_id) for
  101. non-RFID slots — uniquely identifies one physical slot. When a spool is
  102. (re)bound to that slot via Assign or Link, any other Spoolman spool whose
  103. extra.tag still holds the same value is stale and would resurface in the
  104. hover card / fill-level lookup.
  105. Best-effort: per-spool patch failures are logged and skipped, never raised.
  106. Returns the number of spools cleared.
  107. """
  108. if not tag:
  109. return 0
  110. tag_upper = tag.upper()
  111. try:
  112. spools = await client.get_spools()
  113. except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
  114. logger.warning("Could not enumerate spools for stale-tag cleanup: %s", exc)
  115. return 0
  116. cleared = 0
  117. for spool in spools:
  118. spool_id = spool.get("id")
  119. if not spool_id or spool_id == keep_spool_id:
  120. continue
  121. extra = spool.get("extra") or {}
  122. raw_tag = extra.get("tag", "")
  123. if not raw_tag:
  124. continue
  125. clean_tag = raw_tag.strip('"').upper()
  126. if clean_tag != tag_upper:
  127. continue
  128. try:
  129. await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
  130. cleared += 1
  131. logger.info(
  132. "Cleared stale tag '%s' from Spoolman spool %s (%s; reassigned to spool %s)",
  133. tag_upper[:16],
  134. spool_id,
  135. log_context,
  136. keep_spool_id,
  137. )
  138. except (SpoolmanClientError, SpoolmanUnavailableError, SpoolmanNotFoundError) as exc:
  139. logger.warning(
  140. "Failed to clear stale tag on Spoolman spool %s: %s",
  141. spool_id,
  142. exc,
  143. )
  144. return cleared
  145. async def _clear_stale_slot_fallback_tag_links(
  146. client: SpoolmanClient,
  147. *,
  148. printer_serial: str,
  149. ams_id: int,
  150. tray_id: int,
  151. keep_spool_id: int,
  152. ) -> int:
  153. """Convenience wrapper: compute the slot's fallback tag and clear it from
  154. other spools. Used by the assign route, which identifies the slot by
  155. (printer, ams, tray) rather than by an explicit tag value.
  156. """
  157. fallback_tag = get_fallback_spool_tag_for_slot(printer_serial, ams_id, tray_id)
  158. if not fallback_tag:
  159. return 0
  160. return await _clear_stale_tag_links(
  161. client,
  162. tag=fallback_tag,
  163. keep_spool_id=keep_spool_id,
  164. log_context=f"printer={printer_serial} ams={ams_id} tray={tray_id}",
  165. )
  166. async def _clear_vacated_slot_fallback_tag(
  167. client: SpoolmanClient,
  168. db: AsyncSession,
  169. *,
  170. spool_id: int,
  171. spool: dict,
  172. vacated: list[tuple[int, int, int]],
  173. ) -> str | None:
  174. """Clear the spool's extra.tag when it is the fallback tag of a slot it leaves (#1574).
  175. A spool linked to a slot without RFID carries that slot's fallback tag, and
  176. the tag lookup keeps finding it for the old slot after it moved. A real RFID
  177. or any other tag is the spool's own and stays. Returns the tag it cleared,
  178. or None. Raises when Spoolman refuses, before anything local has changed,
  179. so the spool stays where it was.
  180. """
  181. tag = str((spool.get("extra") or {}).get("tag") or "").strip('"').upper()
  182. if not tag or not vacated:
  183. return None
  184. serials = dict(
  185. (await db.execute(select(Printer.id, Printer.serial_number).where(Printer.id.in_({p for p, _, _ in vacated}))))
  186. .tuples()
  187. .all()
  188. )
  189. if not any(
  190. tag == get_fallback_spool_tag_for_slot(serials.get(printer_id) or "", ams_id, tray_id)
  191. for printer_id, ams_id, tray_id in vacated
  192. ):
  193. return None
  194. try:
  195. await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
  196. except (SpoolmanClientError, SpoolmanUnavailableError, SpoolmanNotFoundError) as exc:
  197. logger.warning("Could not clear the old slot's fallback tag from Spoolman spool %s: %s", spool_id, exc)
  198. raise HTTPException(
  199. status_code=503 if isinstance(exc, SpoolmanUnavailableError) else 502,
  200. detail="Spoolman did not clear the spool's tag for its old slot, so the spool was not moved",
  201. ) from exc
  202. logger.info("Cleared the old slot's fallback tag from Spoolman spool %s before moving it", spool_id)
  203. return tag
  204. async def _get_client(db: AsyncSession) -> SpoolmanClient:
  205. """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
  206. result = await db.execute(select(Settings))
  207. settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()}
  208. enabled = settings.get("spoolman_enabled", "false").lower() == "true"
  209. url = settings.get("spoolman_url", "").strip()
  210. if not enabled:
  211. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  212. if not url:
  213. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  214. # SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
  215. # fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
  216. # intentionally permitted (Spoolman commonly runs on the same host or home LAN).
  217. # Raises ValueError with a descriptive message on any violation.
  218. try:
  219. assert_safe_spoolman_url(url)
  220. except ValueError as exc:
  221. raise HTTPException(status_code=400, detail=str(exc)) from exc
  222. # Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation).
  223. client = await get_spoolman_client()
  224. if not client or client.base_url != url.rstrip("/"):
  225. try:
  226. client = await init_spoolman_client(url)
  227. except ValueError as exc:
  228. raise HTTPException(status_code=400, detail=str(exc)) from exc
  229. # Only call health_check() when the cached result has expired.
  230. # Evict stale entries when URL changes (only one Spoolman URL is active at a time).
  231. if url not in _health_check_cache and _health_check_cache:
  232. _health_check_cache.clear()
  233. now = time.monotonic()
  234. last_ok = _health_check_cache.get(url, 0.0)
  235. if now - last_ok > _HEALTH_CHECK_TTL:
  236. if not await client.health_check():
  237. _health_check_cache.pop(url, None)
  238. raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
  239. _health_check_cache[url] = now
  240. return client
  241. @asynccontextmanager
  242. async def _translate_spoolman_errors():
  243. """Translate Spoolman typed exceptions to HTTP errors for all inventory endpoints."""
  244. try:
  245. yield
  246. except SpoolmanNotFoundError as exc:
  247. raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
  248. except SpoolmanClientError as exc:
  249. raise HTTPException(
  250. status_code=502,
  251. detail={
  252. "message": "Spoolman rejected the request",
  253. "upstream_status": exc.status_code,
  254. "upstream_body": getattr(exc, "response_text", ""),
  255. },
  256. ) from exc
  257. except SpoolmanUnavailableError as exc:
  258. raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
  259. def _raise_if_partial_failure(spools: list[dict], results: list, operation: str) -> None:
  260. """Raise HTTP 502 if any gather result is an exception, logging each failure."""
  261. failures = [(s["id"], r) for s, r in zip(spools, results, strict=True) if isinstance(r, BaseException)]
  262. if failures:
  263. logger.error(
  264. "Partial %s failure: %d/%d spools failed: %s",
  265. operation,
  266. len(failures),
  267. len(spools),
  268. [(sid, type(exc).__name__) for sid, exc in failures],
  269. )
  270. raise HTTPException(
  271. status_code=502,
  272. detail=f"{operation} partially applied: {len(spools) - len(failures)}/{len(spools)} spools updated",
  273. )
  274. async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> tuple[dict, list[str]]:
  275. """Patch the spool price; return (updated_spool, warnings).
  276. Spoolman's spool price is what the whole spool cost, so the per-kg rate is
  277. converted at the net weight of the spool Spoolman just created (#3194).
  278. Returns the original spool and a non-empty warnings list when the price
  279. update fails, so the caller can return HTTP 207 instead of silently
  280. discarding the price.
  281. """
  282. if cost_per_kg is None:
  283. return spool, []
  284. try:
  285. async with _translate_spoolman_errors():
  286. updated = await client.update_spool_full(
  287. spool["id"], price=cost_per_kg_to_spoolman_price(cost_per_kg, spoolman_price_weight(spool))
  288. )
  289. return updated, []
  290. except HTTPException as exc:
  291. if exc.status_code >= 500:
  292. raise # Propagate network/server errors — don't swallow Spoolman outages
  293. logger.warning(
  294. "Price update failed for spool %d; spool created without price (cost_per_kg=%s, status=%d)",
  295. spool["id"],
  296. cost_per_kg,
  297. exc.status_code,
  298. )
  299. return spool, [f"price_not_set: Spoolman rejected the price update (HTTP {exc.status_code})"]
  300. # ---------------------------------------------------------------------------
  301. # Request / response schemas
  302. # ---------------------------------------------------------------------------
  303. _HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$")
  304. def _validate_rgba(v: str | None) -> str | None:
  305. if v is None:
  306. return v
  307. clean = v.removeprefix("#")
  308. if not _HEX_RE.match(clean):
  309. raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)")
  310. return clean.upper()
  311. def _validate_storage_location(v: str | None) -> str | None:
  312. if v is not None and any(c in v for c in ("\r", "\n", "\x00")):
  313. raise ValueError("storage_location must not contain control characters")
  314. return v
  315. class SpoolmanInventoryCreate(BaseModel):
  316. # When spoolman_filament_id is provided the caller has already chosen a filament from the
  317. # Spoolman catalog, so material (and other metadata) are optional — the backend skips
  318. # find_or_create_filament() and uses the supplied ID directly.
  319. spoolman_filament_id: int | None = Field(None, gt=0)
  320. material: str | None = Field(None, min_length=1, max_length=64)
  321. subtype: str | None = Field(None, max_length=64)
  322. brand: str | None = Field(None, max_length=128)
  323. color_name: str | None = Field(None, max_length=64)
  324. rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
  325. label_weight: int = Field(1000, ge=1, le=100_000)
  326. # Persisted to the Spoolman spool's own `spool_weight` (tare), which takes
  327. # priority over the filament-level value both in _map_spoolman_spool and in
  328. # the weigh endpoint. Only written when the request actually sets it: the
  329. # 250 default is the display fallback, and writing it on every create would
  330. # stamp an explicit tare on spools that should keep inheriting one (#2908).
  331. core_weight: int = Field(250, ge=0, le=10_000)
  332. weight_used: float = Field(0.0, ge=0.0, le=100_000.0)
  333. note: str | None = Field(None, max_length=1000)
  334. cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
  335. storage_location: str | None = Field(None, max_length=255)
  336. location_id: int | None = Field(None, gt=0)
  337. # BambuStudio slicer preset for this spool. Spoolman has no native field
  338. # for this, so we persist it under the bambu_slicer_filament[_name] keys
  339. # in the spool's extra dict and read it back in _map_spoolman_spool.
  340. slicer_filament: str | None = Field(None, max_length=128)
  341. slicer_filament_name: str | None = Field(None, max_length=255)
  342. @field_validator("rgba")
  343. @classmethod
  344. def validate_rgba(cls, v: str | None) -> str | None:
  345. return _validate_rgba(v)
  346. @field_validator("storage_location")
  347. @classmethod
  348. def validate_storage_location(cls, v: str | None) -> str | None:
  349. return _validate_storage_location(v)
  350. @model_validator(mode="after")
  351. def validate_weight_consistency(self) -> SpoolmanInventoryCreate:
  352. # material is required only when the caller has not pre-selected a Spoolman filament
  353. if self.spoolman_filament_id is None and not self.material:
  354. raise ValueError("material is required when spoolman_filament_id is not provided")
  355. if self.weight_used > self.label_weight:
  356. raise ValueError("weight_used must not exceed label_weight")
  357. return self
  358. class SpoolmanInventoryUpdate(BaseModel):
  359. material: str | None = Field(None, min_length=1, max_length=64)
  360. subtype: str | None = Field(None, max_length=64)
  361. brand: str | None = Field(None, max_length=128)
  362. color_name: str | None = Field(None, max_length=64)
  363. rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
  364. label_weight: int | None = Field(None, ge=1, le=100_000)
  365. # Persisted to the spool's own `spool_weight` (see the Create schema).
  366. # Omitted / null leaves the current value alone, as with every other field
  367. # here. There is no per-spool "go back to inheriting" through this route;
  368. # the filament-level route already owns that decision (#2908).
  369. core_weight: int | None = Field(None, ge=0, le=10_000)
  370. weight_used: float | None = Field(None, ge=0.0, le=100_000.0)
  371. note: str | None = Field(None, max_length=1000)
  372. cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
  373. tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
  374. tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
  375. storage_location: str | None = Field(None, max_length=255)
  376. location_id: int | None = Field(None, gt=0)
  377. # BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
  378. # schema). Pass an empty string to clear; null/omitted leaves unchanged.
  379. slicer_filament: str | None = Field(None, max_length=128)
  380. slicer_filament_name: str | None = Field(None, max_length=255)
  381. # Set by hand for a drying done outside an AMS; null clears it. Persisted
  382. # to the spool's extra dict like the slicer preset (#2863).
  383. last_dried_at: datetime | None = None
  384. @field_validator("rgba")
  385. @classmethod
  386. def validate_rgba(cls, v: str | None) -> str | None:
  387. return _validate_rgba(v)
  388. @field_validator("storage_location")
  389. @classmethod
  390. def validate_storage_location(cls, v: str | None) -> str | None:
  391. return _validate_storage_location(v)
  392. @field_validator("last_dried_at")
  393. @classmethod
  394. def validate_last_dried_at(cls, v: datetime | None) -> datetime | None:
  395. return naive_utc(v)
  396. @model_validator(mode="after")
  397. def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
  398. # null = remove tag; non-null values rejected (use /tag endpoint to write tags)
  399. if self.tag_uid is not None:
  400. raise ValueError("tag_uid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
  401. if self.tray_uuid is not None:
  402. raise ValueError("tray_uuid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
  403. return self
  404. @model_validator(mode="after")
  405. def validate_weight_consistency(self) -> SpoolmanInventoryUpdate:
  406. if self.weight_used is not None and self.label_weight is not None:
  407. if self.weight_used > self.label_weight:
  408. raise ValueError("weight_used must not exceed label_weight")
  409. return self
  410. class SpoolmanInventoryBulkCreate(BaseModel):
  411. spool: SpoolmanInventoryCreate
  412. quantity: int = Field(1, ge=1, le=50)
  413. class SpoolWeightUpdate(BaseModel):
  414. weight_grams: float = Field(..., ge=0.0, le=100_000.0)
  415. class SpoolTagLinkRequest(BaseModel):
  416. # Minimum 8 hex chars = 4-byte NFC UID (Bambu Lab hardware tags use 4-byte UIDs).
  417. tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
  418. tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
  419. @field_validator("tag_uid")
  420. @classmethod
  421. def tag_uid_not_all_zeros(cls, v: str | None) -> str | None:
  422. if v is not None and all(c in "0" for c in v):
  423. raise ValueError("tag_uid must not be all-zero bytes")
  424. return v
  425. @model_validator(mode="after")
  426. def at_least_one(self) -> SpoolTagLinkRequest:
  427. if not self.tag_uid and not self.tray_uuid:
  428. raise ValueError("tag_uid or tray_uuid is required")
  429. return self
  430. class SpoolSlotAssignmentRequest(BaseModel):
  431. spoolman_spool_id: int = Field(..., gt=0)
  432. printer_id: int = Field(..., gt=0)
  433. ams_id: SlotAmsId
  434. tray_id: SlotTrayId
  435. # The spool also leaves any other slot it is assigned to, in the same
  436. # transaction (#1574): a scanned spool is the one in the user's hand.
  437. move: bool = False
  438. # ---------------------------------------------------------------------------
  439. # Endpoints
  440. # ---------------------------------------------------------------------------
  441. @router.get("/spools")
  442. async def list_spools(
  443. include_archived: bool = Query(False),
  444. db: AsyncSession = Depends(get_db, scope="function"),
  445. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  446. ) -> list[dict]:
  447. """Return all Spoolman spools in the InventorySpool format."""
  448. client = await _get_client(db)
  449. # Sync after we have the route-resolved client so tests that patch the
  450. # route module's get_spoolman_client/init_spoolman_client also catch the
  451. # sync's client lookup — otherwise the location_service path imports from
  452. # backend.app.services.spoolman directly and bypasses the patch.
  453. if await maybe_sync_spoolman_locations(db, client=client):
  454. await db.commit()
  455. async with _translate_spoolman_errors():
  456. spools = await client.get_all_spools(allow_archived=include_archived)
  457. mapped: list[dict] = []
  458. spool_ids: list[int] = []
  459. for s in spools:
  460. try:
  461. m = _map_spoolman_spool(s)
  462. mapped.append(m)
  463. spool_ids.append(m["id"])
  464. except ValueError as exc:
  465. logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc)
  466. if spool_ids:
  467. kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id.in_(spool_ids)))
  468. kp_by_spool: dict[int, list[dict]] = {}
  469. for kp in kp_result.scalars().all():
  470. kp_by_spool.setdefault(kp.spoolman_spool_id, []).append(_k_profile_to_dict(kp))
  471. for m in mapped:
  472. m["k_profiles"] = kp_by_spool.get(m["id"], [])
  473. # Supplier assignments (#2988) live Bambuddy-side even for Spoolman
  474. # spools, so the list carries them in both modes identically.
  475. link_result = await db.execute(
  476. select(SpoolmanSpoolSupplier)
  477. .options(selectinload(SpoolmanSpoolSupplier.supplier))
  478. .where(SpoolmanSpoolSupplier.spoolman_spool_id.in_(spool_ids))
  479. )
  480. links_by_spool: dict[int, list[dict]] = {}
  481. for link in link_result.scalars().all():
  482. links_by_spool.setdefault(link.spoolman_spool_id, []).append(_supplier_link_to_dict(link))
  483. for m in mapped:
  484. m["suppliers"] = links_by_spool.get(m["id"], [])
  485. await enrich_spool_dicts_with_location_id(db, mapped)
  486. return mapped
  487. @router.get("/spools/{spool_id}")
  488. async def get_spool(
  489. spool_id: int = Path(..., gt=0),
  490. db: AsyncSession = Depends(get_db, scope="function"),
  491. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  492. ) -> dict:
  493. """Return a single Spoolman spool in the InventorySpool format."""
  494. client = await _get_client(db)
  495. async with _translate_spoolman_errors():
  496. spool = await client.get_spool(spool_id)
  497. try:
  498. mapped = _map_spoolman_spool(spool)
  499. except ValueError as exc:
  500. logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
  501. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
  502. kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
  503. mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
  504. link_result = await db.execute(
  505. select(SpoolmanSpoolSupplier)
  506. .options(selectinload(SpoolmanSpoolSupplier.supplier))
  507. .where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id)
  508. )
  509. mapped["suppliers"] = [_supplier_link_to_dict(link) for link in link_result.scalars().all()]
  510. await enrich_spool_dicts_with_location_id(db, [mapped])
  511. return mapped
  512. async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanClient) -> int:
  513. """Return the Spoolman filament ID for this spool creation request.
  514. If spoolman_filament_id is set the caller pre-selected a catalog entry,
  515. so find_or_create_filament() is skipped and the ID is used directly.
  516. """
  517. if data.spoolman_filament_id is not None:
  518. return data.spoolman_filament_id
  519. # Validator guarantees material is non-None when spoolman_filament_id is None
  520. assert data.material is not None # noqa: S101
  521. # `or "808080"` on the result rather than on the input: spoolman_color_hex
  522. # returns None only for a missing value, so this is the same neutral grey the
  523. # old inline default produced, without handing an Optional to a str parameter.
  524. color_hex = spoolman_color_hex(data.rgba) or "808080"
  525. async with _translate_spoolman_errors():
  526. return await client.find_or_create_filament(
  527. material=data.material,
  528. subtype=data.subtype or "",
  529. brand=data.brand,
  530. color_hex=color_hex,
  531. label_weight=data.label_weight,
  532. color_name=data.color_name,
  533. )
  534. async def _resolve_create_default(
  535. db: AsyncSession, data: SpoolmanInventoryCreate, spool: dict
  536. ) -> profile_defaults.ResolvedProfiles | None:
  537. """The profile default for a spool this request created (#1326).
  538. From the request's product fields; a request that only names a catalog
  539. filament (``spoolman_filament_id``) carries none, so then from the spool
  540. Spoolman made -- its colour name only when it is a real one, the mapper
  541. fills a missing one with the subtype.
  542. """
  543. if data.material:
  544. return await profile_defaults.resolve_profile_default(
  545. db, brand=data.brand, material=data.material, subtype=data.subtype, color_name=data.color_name
  546. )
  547. mapped = _map_spoolman_spool(spool)
  548. return await profile_defaults.resolve_profile_default(
  549. db,
  550. brand=mapped["brand"],
  551. material=mapped["material"],
  552. subtype=mapped["subtype"],
  553. color_name=data.color_name or (None if mapped["color_name_is_synthesized"] else mapped["color_name"]),
  554. )
  555. async def _finish_created_spool(
  556. db: AsyncSession,
  557. client: SpoolmanClient,
  558. spool: dict,
  559. data: SpoolmanInventoryCreate,
  560. default: profile_defaults.ResolvedProfiles | None,
  561. ) -> dict:
  562. """What a create does after Spoolman made the spool: the fields Spoolman
  563. has no place for, and the product's profile default (#1326).
  564. The slicer preset and the colour name go under the spool's extra dict
  565. (mirror update_spool) -- Spoolman has no ``color_name`` on a filament
  566. (#1357). A preset the request did not bring comes from the default, and
  567. its preset overrides and K profiles land in the Bambuddy tables keyed by
  568. the new spool's id. The spool exists either way, so a failed write is
  569. logged and the create still succeeds.
  570. """
  571. slicer_filament = data.slicer_filament
  572. slicer_filament_name = data.slicer_filament_name
  573. if not slicer_filament and default is not None and default.slicer_filament:
  574. slicer_filament = default.slicer_filament
  575. slicer_filament_name = default.slicer_filament_name
  576. new_extra: dict = {}
  577. if slicer_filament is not None:
  578. new_extra["bambu_slicer_filament"] = json.dumps(slicer_filament)
  579. if slicer_filament_name is not None:
  580. new_extra["bambu_slicer_filament_name"] = json.dumps(slicer_filament_name)
  581. if data.color_name is not None:
  582. new_extra["bambu_color_name"] = json.dumps(data.color_name)
  583. if new_extra:
  584. try:
  585. async with _translate_spoolman_errors():
  586. # Registered before the write, or Spoolman refuses the keys.
  587. for name in new_extra:
  588. await client.ensure_extra_field(name)
  589. spool = await client.merge_spool_extra(spool["id"], new_extra)
  590. except HTTPException:
  591. logger.warning("Failed to persist slicer_filament/color_name for spool %s", spool.get("id"))
  592. if default is not None:
  593. try:
  594. if await profile_defaults.fill_spoolman_rows(db, int(spool["id"]), default):
  595. await db.commit()
  596. except Exception as exc: # noqa: BLE001 — the spool is created; its profiles can be set by hand
  597. await db.rollback()
  598. logger.warning("Profile default for Spoolman spool %s not applied: %s", spool.get("id"), exc)
  599. return spool
  600. @router.post("/spools")
  601. async def create_spool(
  602. data: SpoolmanInventoryCreate,
  603. db: AsyncSession = Depends(get_db, scope="function"),
  604. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  605. ) -> dict:
  606. """Create a new spool in Spoolman, auto-creating vendor and filament as needed."""
  607. client = await _get_client(db)
  608. filament_id = await _resolve_filament_id(data, client)
  609. storage_location = data.storage_location
  610. if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
  611. try:
  612. storage_location, _ = await resolve_spoolman_location_string(
  613. db,
  614. location_id=data.location_id,
  615. storage_location=data.storage_location,
  616. fields_set=set(data.model_fields_set),
  617. )
  618. except ValueError as exc:
  619. raise HTTPException(status_code=400, detail=str(exc)) from exc
  620. remaining = max(0.0, data.label_weight - data.weight_used)
  621. try:
  622. async with _translate_spoolman_errors():
  623. spool = await client.create_spool(
  624. filament_id=filament_id,
  625. remaining_weight=remaining,
  626. comment=data.note or None,
  627. location=storage_location or None,
  628. spool_weight=(data.core_weight if "core_weight" in data.model_fields_set else None),
  629. # Only a label weight the caller gave: left off, Spoolman uses
  630. # the filament's weight, which is the right size for a caller
  631. # that didn't say (#3194).
  632. initial_weight=(float(data.label_weight) if "label_weight" in data.model_fields_set else None),
  633. )
  634. except HTTPException as exc:
  635. if exc.status_code == 404 and data.spoolman_filament_id is not None:
  636. raise HTTPException(
  637. status_code=404,
  638. detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
  639. ) from exc
  640. raise
  641. spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
  642. spool = await _finish_created_spool(db, client, spool, data, await _resolve_create_default(db, data, spool))
  643. result = _map_spoolman_spool(spool)
  644. await ws_manager.broadcast({"type": "inventory_changed"})
  645. if price_warnings:
  646. return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
  647. return result
  648. @router.post("/spools/bulk")
  649. async def bulk_create_spools(
  650. payload: SpoolmanInventoryBulkCreate,
  651. db: AsyncSession = Depends(get_db, scope="function"),
  652. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  653. ) -> Response:
  654. """Create multiple identical spools in Spoolman."""
  655. client = await _get_client(db)
  656. data = payload.spool
  657. try:
  658. filament_id = await _resolve_filament_id(data, client)
  659. except HTTPException as exc:
  660. if exc.status_code == 404 and data.spoolman_filament_id is not None:
  661. raise HTTPException(
  662. status_code=404,
  663. detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
  664. ) from exc
  665. raise
  666. storage_location = data.storage_location
  667. if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
  668. try:
  669. storage_location, _ = await resolve_spoolman_location_string(
  670. db,
  671. location_id=data.location_id,
  672. storage_location=data.storage_location,
  673. fields_set=set(data.model_fields_set),
  674. )
  675. except ValueError as exc:
  676. raise HTTPException(status_code=400, detail=str(exc)) from exc
  677. remaining = max(0.0, data.label_weight - data.weight_used)
  678. created: list[dict] = []
  679. failures: list[str] = []
  680. # Every copy is the same product: one lookup for the batch, on the first.
  681. default: profile_defaults.ResolvedProfiles | None = None
  682. resolved_default = False
  683. for _ in range(payload.quantity):
  684. try:
  685. spool = await client.create_spool(
  686. filament_id=filament_id,
  687. remaining_weight=remaining,
  688. comment=data.note or None,
  689. location=storage_location or None,
  690. spool_weight=(data.core_weight if "core_weight" in data.model_fields_set else None),
  691. # Only a label weight the caller gave: left off, Spoolman uses
  692. # the filament's weight, which is the right size for a caller
  693. # that didn't say (#3194).
  694. initial_weight=(float(data.label_weight) if "label_weight" in data.model_fields_set else None),
  695. )
  696. except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
  697. logger.warning("Bulk spool creation: one spool failed: %s", exc)
  698. failures.append("spool creation failed")
  699. continue
  700. try:
  701. spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
  702. except HTTPException as exc:
  703. logger.warning(
  704. "Bulk spool %d: price update failed (HTTP %d); spool not added to created list",
  705. spool.get("id", 0),
  706. exc.status_code,
  707. )
  708. failures.append("spool created but price update failed")
  709. continue
  710. if price_warnings:
  711. logger.warning("Bulk spool %s created without price: %s", spool.get("id"), price_warnings)
  712. # Each copy gets the preset and colour name the single create gives,
  713. # and the product's profile default (#1326).
  714. if not resolved_default:
  715. default = await _resolve_create_default(db, data, spool)
  716. resolved_default = True
  717. spool = await _finish_created_spool(db, client, spool, data, default)
  718. created.append(_map_spoolman_spool(spool))
  719. if not created:
  720. raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
  721. await ws_manager.broadcast({"type": "inventory_changed"})
  722. if len(created) < payload.quantity:
  723. # Some spool creations failed — return 207 Multi-Status so the caller
  724. # can distinguish a full success from a partial one and show a useful message.
  725. return JSONResponse(
  726. status_code=207,
  727. content={
  728. "created": created,
  729. "requested_count": payload.quantity,
  730. "failed_count": payload.quantity - len(created),
  731. "failures": failures,
  732. },
  733. )
  734. return JSONResponse(status_code=200, content=created)
  735. @router.patch("/spools/{spool_id}")
  736. async def update_spool(
  737. *,
  738. spool_id: int = Path(..., gt=0),
  739. data: SpoolmanInventoryUpdate,
  740. db: AsyncSession = Depends(get_db, scope="function"),
  741. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  742. ) -> dict:
  743. """Update an existing Spoolman spool, re-linking the filament if metadata changed."""
  744. client = await _get_client(db)
  745. async with _translate_spoolman_errors():
  746. current = await client.get_spool(spool_id)
  747. cur_filament: dict = current.get("filament") or {}
  748. cur_vendor: dict = cur_filament.get("vendor") or {}
  749. cur_mat: str = (cur_filament.get("material") or "").strip()
  750. cur_name: str = (cur_filament.get("name") or "").strip()
  751. if cur_mat and cur_name.upper().startswith(cur_mat.upper()):
  752. cur_subtype: str = cur_name[len(cur_mat) :].strip()
  753. else:
  754. cur_subtype = cur_name
  755. # Resolve final values: use request value if provided, else keep current
  756. material = data.material if data.material is not None else cur_mat
  757. subtype = data.subtype if data.subtype is not None else cur_subtype
  758. brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
  759. # color_name uses model_fields_set so explicit null (clear) is distinguishable
  760. # from "field omitted" (don't touch). find_or_create_filament's convention:
  761. # None = don't touch, "" = explicit clear, "value" = set.
  762. if "color_name" in data.model_fields_set:
  763. color_name = data.color_name if data.color_name is not None else ""
  764. else:
  765. color_name = cur_filament.get("color_name") or None
  766. cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
  767. # Handed over as stored. The opaque alpha this used to append was folded
  768. # straight back off by `spoolman_color_hex` below, so the two paths landed on
  769. # the same string and the append only obscured which shape was in hand (#2912).
  770. rgba = data.rgba if data.rgba is not None else cur_color
  771. # The label weight is the spool's own net weight (initial_weight), not the
  772. # filament's: one filament can have spools of different sizes (#3194).
  773. # The form shows it in whole grams, so it only counts as changed when it
  774. # differs from that; the exact stored value is otherwise left alone. A
  775. # spool with no size at either level takes the one it is given: Spoolman
  776. # refuses a remaining_weight until the spool has one.
  777. cur_net_weight = spoolman_net_weight(current)
  778. cur_has_size = cur_net_weight is not None and cur_net_weight > 0
  779. cur_label_weight = _safe_int(cur_net_weight, 1000) or 1000
  780. label_weight = data.label_weight if data.label_weight is not None else cur_label_weight
  781. label_weight_changed = data.label_weight is not None and (not cur_has_size or data.label_weight != cur_label_weight)
  782. # Default weight_used from the synthetic mapping (label - remaining) so an
  783. # edit that doesn't touch the weight field preserves Spoolman's real
  784. # remaining_weight after a "Reset usage to 0" — the previous code read
  785. # Spoolman's used_weight directly, which is 0 post-reset, so
  786. # `remaining = label - 0 = 1000` would overwrite the real remaining
  787. # the next time the user edited any other field (#1390).
  788. cur_remaining_raw = current.get("remaining_weight")
  789. if cur_remaining_raw is not None:
  790. synthetic_used = max(0.0, float(label_weight) - float(cur_remaining_raw))
  791. else:
  792. synthetic_used = float(current.get("used_weight") or 0)
  793. weight_used = data.weight_used if data.weight_used is not None else synthetic_used
  794. note = data.note if data.note is not None else current.get("comment")
  795. storage_location_changed = "storage_location" in data.model_fields_set or "location_id" in data.model_fields_set
  796. storage_location = data.storage_location if "storage_location" in data.model_fields_set else None
  797. if storage_location_changed:
  798. try:
  799. storage_location, _ = await resolve_spoolman_location_string(
  800. db,
  801. location_id=data.location_id,
  802. storage_location=storage_location,
  803. fields_set=set(data.model_fields_set),
  804. )
  805. except ValueError as exc:
  806. raise HTTPException(status_code=400, detail=str(exc)) from exc
  807. color_hex = spoolman_color_hex(rgba) or rgba
  808. # Resolve which filament this spool should be linked to AFTER the edit.
  809. #
  810. # The old behaviour was always `find_or_create_filament`, which proliferated
  811. # duplicate Spoolman filaments whenever the user changed any field that
  812. # made up the match key (material/subtype/brand/color) — every edit minted
  813. # a fresh row and orphaned the previous one (#1357 follow-up). To match
  814. # internal-mode behaviour ([[feedback_inventory_modes_parity]]: editing a
  815. # spool does not proliferate new entities), prefer PATCHing the current
  816. # filament in place when it's a singleton.
  817. cur_filament_id = cur_filament.get("id")
  818. desired_name = f"{material} {subtype}".strip() if subtype else material
  819. # Compare the stored shapes, not raw strings and not bare RGB prefixes. Raw
  820. # strings make an opaque spool's six characters differ from an incoming eight
  821. # and PATCH the filament on every no-op edit; bare prefixes make an
  822. # alpha-only edit invisible so the change never lands (#2912).
  823. cur_color_norm = spoolman_color_hex(cur_filament.get("color_hex")) or ""
  824. cur_vendor_name = (cur_vendor.get("name") or "").strip()
  825. # The label weight is not part of this: it is written to the spool's
  826. # initial_weight below. Comparing it against the filament's weight PATCHed
  827. # the filament for every spool of another size, or on a shared filament
  828. # relinked the spool to a new duplicate one (#3194).
  829. metadata_unchanged = (
  830. cur_filament_id
  831. and (cur_filament.get("name") or "").strip() == desired_name
  832. and (cur_filament.get("material") or "").upper() == material.upper()
  833. and cur_color_norm == (color_hex or "").upper()
  834. and cur_vendor_name.lower() == ((brand or "").strip().lower())
  835. )
  836. if metadata_unchanged:
  837. # No filament-side change at all — re-use the existing link, skip
  838. # find_or_create entirely so a no-op edit (e.g. just changing
  839. # weight_used or note) never even touches the filament catalogue.
  840. filament_id = cur_filament_id
  841. else:
  842. async with _translate_spoolman_errors():
  843. shared = await client.is_filament_shared(cur_filament_id, spool_id) if cur_filament_id else False
  844. if cur_filament_id and not shared:
  845. # Singleton filament — PATCH it in place so the user's edit lands
  846. # on the row their spool already points at instead of orphaning it.
  847. patch_body: dict = {
  848. "name": desired_name,
  849. "material": material,
  850. "color_hex": color_hex,
  851. }
  852. if brand:
  853. vendor_id = await client.find_or_create_vendor(brand)
  854. patch_body["vendor_id"] = vendor_id
  855. async with _translate_spoolman_errors():
  856. await client.patch_filament(cur_filament_id, patch_body)
  857. filament_id = cur_filament_id
  858. else:
  859. # Filament is shared with other spools — PATCHing it in place would
  860. # silently rewrite their metadata too. Fall back to find-or-create
  861. # so only this spool's link moves.
  862. async with _translate_spoolman_errors():
  863. filament_id = await client.find_or_create_filament(
  864. material=material,
  865. subtype=subtype or "",
  866. brand=brand,
  867. color_hex=color_hex,
  868. label_weight=label_weight,
  869. color_name=color_name,
  870. )
  871. if not filament_id:
  872. raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
  873. remaining = max(0.0, label_weight - weight_used)
  874. # The spool's size lives on the spool (initial_weight), never the filament
  875. # (#3194). A relink also sends the current size: Spoolman stamps the new
  876. # filament's weight on a spool without an initial_weight of its own.
  877. new_initial_weight: float | None = None
  878. if label_weight_changed:
  879. new_initial_weight = float(label_weight)
  880. elif filament_id != cur_filament_id and cur_has_size:
  881. new_initial_weight = cur_net_weight
  882. # Spoolman's price is per spool, the form's per kg (#3194). An unchanged
  883. # rate on an unchanged size is not written, so a no-op edit cannot move the
  884. # price by rounding. On a resize the rate is kept, as in internal mode.
  885. cur_cost_per_kg = spoolman_cost_per_kg(current)
  886. cost_per_kg = data.cost_per_kg if data.cost_per_kg is not None else cur_cost_per_kg
  887. new_price: float | None = None
  888. if cost_per_kg is not None and (label_weight_changed or cost_per_kg != cur_cost_per_kg):
  889. price_weight = float(label_weight) if label_weight_changed else spoolman_price_weight(current)
  890. new_price = cost_per_kg_to_spoolman_price(cost_per_kg, price_weight)
  891. # Tag removal: clear only the "tag" key so other custom Spoolman extra fields
  892. # set outside Bambuddy are preserved.
  893. tag_nulled = (
  894. ("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set)
  895. and _tag_cleared(data.tag_uid)
  896. and _tag_cleared(data.tray_uuid)
  897. )
  898. # Serialise tag-clear + PATCH under the per-spool extra lock to prevent a
  899. # concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting
  900. # the tag key between our read and our write.
  901. #
  902. # Spoolman PATCHes extra dicts by MERGING — popping "tag" from a re-fetched
  903. # dict and sending the rest doesn't clear the key (Spoolman keeps the old
  904. # value because the key wasn't in the payload). Explicitly set the tag to
  905. # a JSON-encoded empty string; read-side filters strip the quotes.
  906. async with client.extra_lock(spool_id):
  907. if tag_nulled:
  908. # Re-fetch inside the lock so we work with fresh extra data.
  909. async with _translate_spoolman_errors():
  910. fresh = await client.get_spool(spool_id)
  911. cur_extra = dict(fresh.get("extra") or {})
  912. cur_extra["tag"] = json.dumps("")
  913. extra: dict | None = cur_extra
  914. else:
  915. extra = None
  916. async with _translate_spoolman_errors():
  917. updated = await client.update_spool_full(
  918. spool_id=spool_id,
  919. filament_id=filament_id,
  920. # Without a size Spoolman rejects the whole PATCH (HTTP 400),
  921. # so a spool that still has none gets no remaining_weight.
  922. remaining_weight=(remaining if new_initial_weight is not None or cur_has_size else None),
  923. comment=note or "",
  924. price=new_price,
  925. initial_weight=new_initial_weight,
  926. extra=extra,
  927. location=storage_location or None,
  928. clear_location=storage_location_changed and not storage_location,
  929. # No model_fields_set guard here, unlike create: this schema
  930. # already defaults core_weight to None, and None is what
  931. # update_spool_full reads as "leave the tare alone". A guard
  932. # would be a second spelling of the same condition.
  933. spool_weight=data.core_weight,
  934. )
  935. # Spoolman 0.27+: the native tags go as well, or the spool would still be
  936. # found by every one of them after "Clear RFID Tag". Only once the update
  937. # went through, so a failed one leaves the spool as it was; a failure here
  938. # is reported, and clearing again removes what is left.
  939. if tag_nulled:
  940. async with _translate_spoolman_errors():
  941. await client.unlink_all_native_tags(fresh)
  942. updated = {**updated, "tags": []}
  943. # Persist BambuStudio slicer preset AND color_name under spool.extra.
  944. # Spoolman has no native fields for these — color_name was confirmed
  945. # absent from the FilamentUpdateParameters schema in 0.23.1 (#1357), so
  946. # writing `filament.color_name` was a silent no-op that left every
  947. # edit looking "not saved". They all round-trip via extra and get
  948. # unpacked in _map_spoolman_spool. Only writes when the request
  949. # explicitly set the field — passing null/omitting leaves the existing
  950. # extra entry untouched (write empty string to clear).
  951. sf_set = "slicer_filament" in data.model_fields_set
  952. sfn_set = "slicer_filament_name" in data.model_fields_set
  953. cn_set = "color_name" in data.model_fields_set
  954. dried_set = "last_dried_at" in data.model_fields_set
  955. if sf_set or sfn_set or cn_set or dried_set:
  956. # Ensure extra fields are registered (Spoolman rejects PATCHes with
  957. # unknown keys with HTTP 400). Idempotent if startup already ran this.
  958. if sf_set:
  959. await client.ensure_extra_field("bambu_slicer_filament")
  960. if sfn_set:
  961. await client.ensure_extra_field("bambu_slicer_filament_name")
  962. if cn_set:
  963. await client.ensure_extra_field("bambu_color_name")
  964. new_extra: dict = {}
  965. if sf_set:
  966. new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament or "")
  967. if sfn_set:
  968. new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
  969. if cn_set:
  970. new_extra["bambu_color_name"] = json.dumps(data.color_name or "")
  971. if dried_set:
  972. # A hand-set date is not the AMS cycle the temperature and hours
  973. # describe, so they are cleared with it, as in internal mode.
  974. dried_at = data.last_dried_at.isoformat(timespec="seconds") if data.last_dried_at else ""
  975. new_extra[BAMBU_LAST_DRIED_AT_KEY] = json.dumps(dried_at)
  976. new_extra[BAMBU_LAST_DRIED_TEMP_KEY] = json.dumps("")
  977. new_extra[BAMBU_LAST_DRIED_HOURS_KEY] = json.dumps("")
  978. async with _translate_spoolman_errors():
  979. updated = await client.merge_spool_extra(spool_id, new_extra)
  980. if sf_set or sfn_set:
  981. forget_spoolman_spool_own_preset(spool_id)
  982. await ws_manager.broadcast({"type": "inventory_changed"})
  983. return _map_spoolman_spool(updated)
  984. async def _release_spool_slots(db: AsyncSession, spool_id: int, *, keep_tracked: bool) -> list[tuple[int, int, int]]:
  985. """Delete a spool's slot rows; returns the freed (printer, ams, tray) slots.
  986. ``keep_tracked`` keeps the rows of a printer with a print being tracked:
  987. its usage is charged at completion by the slot's spool, which only the row
  988. knows for a spool without a tag. ``release_archived_slot_rows`` frees them
  989. once that print is reported. The caller commits, then announces.
  990. """
  991. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  992. query = select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id)
  993. if keep_tracked:
  994. query = query.where(SpoolmanSlotAssignment.printer_id.not_in(select(ActivePrintSpoolman.printer_id)))
  995. rows = (await db.execute(query)).scalars().all()
  996. freed = [(r.printer_id, r.ams_id, r.tray_id) for r in rows]
  997. for row in rows:
  998. await db.delete(row)
  999. return freed
  1000. async def _announce_freed_slots(freed: list[tuple[int, int, int]]) -> None:
  1001. for printer_id, ams_id, tray_id in freed:
  1002. await ws_manager.broadcast(
  1003. {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
  1004. )
  1005. async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
  1006. """Drop the Bambuddy-side rows a deleted Spoolman spool leaves behind.
  1007. Spoolman owns the spool; Bambuddy owns the K profiles, the filament preset
  1008. overrides and the supplier assignments, each keyed by the remote id with no
  1009. foreign key that could cascade. The K-profile and preset leaks are inert,
  1010. but a leaked ``spoolman_spool_suppliers`` row keeps the supplier's
  1011. reference count non-zero, so deleting that supplier answers 409 forever
  1012. with no way for the user to find the phantom reference (#2988).
  1013. The caller commits.
  1014. """
  1015. for model in (SpoolmanKProfile, SpoolmanFilamentPreset, SpoolmanSpoolSupplier):
  1016. await db.execute(delete(model).where(model.spoolman_spool_id == spool_id))
  1017. @router.delete("/spools/{spool_id}")
  1018. async def delete_spool(
  1019. spool_id: int = Path(..., gt=0),
  1020. db: AsyncSession = Depends(get_db, scope="function"),
  1021. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1022. ) -> dict:
  1023. """Permanently delete a spool from Spoolman."""
  1024. client = await _get_client(db)
  1025. async with _translate_spoolman_errors():
  1026. await client.delete_spool(spool_id)
  1027. await _purge_local_rows_for_spool(db, spool_id)
  1028. # Its slots go too: a row left behind points tracking and the scheduler
  1029. # at an id Spoolman no longer has.
  1030. freed = await _release_spool_slots(db, spool_id, keep_tracked=False)
  1031. await db.commit()
  1032. await ws_manager.broadcast({"type": "inventory_changed"})
  1033. await _announce_freed_slots(freed)
  1034. return {"status": "deleted"}
  1035. @router.post("/spools/{spool_id}/archive")
  1036. async def archive_spool(
  1037. spool_id: int = Path(..., gt=0),
  1038. db: AsyncSession = Depends(get_db, scope="function"),
  1039. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1040. ) -> dict:
  1041. """Archive a spool in Spoolman (soft-delete)."""
  1042. client = await _get_client(db)
  1043. async with _translate_spoolman_errors():
  1044. spool = await client.set_spool_archived(spool_id, archived=True)
  1045. # An archived spool gives its slots up, as in the built-in inventory.
  1046. freed = await _release_spool_slots(db, spool_id, keep_tracked=True)
  1047. await db.commit()
  1048. await ws_manager.broadcast({"type": "inventory_changed"})
  1049. await _announce_freed_slots(freed)
  1050. try:
  1051. mapped = _map_spoolman_spool(spool)
  1052. except ValueError as exc:
  1053. logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
  1054. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
  1055. return mapped
  1056. @router.post("/spools/{spool_id}/restore")
  1057. async def restore_spool(
  1058. spool_id: int = Path(..., gt=0),
  1059. db: AsyncSession = Depends(get_db, scope="function"),
  1060. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1061. ) -> dict:
  1062. """Restore an archived spool in Spoolman."""
  1063. client = await _get_client(db)
  1064. async with _translate_spoolman_errors():
  1065. spool = await client.set_spool_archived(spool_id, archived=False)
  1066. try:
  1067. mapped = _map_spoolman_spool(spool)
  1068. except ValueError as exc:
  1069. logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
  1070. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
  1071. await ws_manager.broadcast({"type": "inventory_changed"})
  1072. return mapped
  1073. @router.post("/spools/{spool_id}/reset-consumed-counter")
  1074. async def reset_spool_consumed_counter(
  1075. spool_id: int = Path(..., gt=0),
  1076. db: AsyncSession = Depends(get_db, scope="function"),
  1077. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1078. ) -> dict:
  1079. """Zero the displayed "Total Consumed" counter for a Spoolman spool.
  1080. Spoolman has no native baseline field, so the baseline lives in
  1081. ``spool.extra`` — the same mechanism internal mode uses for its
  1082. ``weight_used_baseline`` column. ``_map_spoolman_spool`` folds it back in,
  1083. so the Inventory page's ``weight_used - baseline`` reads 0 while every
  1084. native Spoolman field is left exactly as it was.
  1085. It used to PATCH ``used_weight = 0`` upstream, which is not the same thing:
  1086. Spoolman recomputes ``remaining_weight`` from initial minus used, so the
  1087. spool jumped back to full and the measured remaining filament was gone
  1088. (#2906). Parity with the internal-mode endpoint (#1644, #1390, see also
  1089. ``backend/app/api/routes/inventory.py::reset_spool_consumed_counter``).
  1090. """
  1091. client = await _get_client(db)
  1092. async with _translate_spoolman_errors():
  1093. spool = await client.reset_spool_consumed_counter(spool_id)
  1094. try:
  1095. mapped = _map_spoolman_spool(spool)
  1096. except ValueError as exc:
  1097. logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
  1098. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
  1099. await ws_manager.broadcast({"type": "inventory_changed"})
  1100. return mapped
  1101. class SpoolmanBulkUpdateRequest(BaseModel):
  1102. ids: list[int] = Field(..., min_length=1, max_length=500)
  1103. update: SpoolmanInventoryUpdate
  1104. class SpoolmanBulkIdsRequest(BaseModel):
  1105. ids: list[int] = Field(..., min_length=1, max_length=500)
  1106. @router.post("/spools/bulk-update")
  1107. async def bulk_update_spools(
  1108. payload: SpoolmanBulkUpdateRequest,
  1109. db: AsyncSession = Depends(get_db, scope="function"),
  1110. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1111. ) -> dict:
  1112. """Apply the same partial update to every listed Spoolman spool.
  1113. Loops the per-spool ``update_spool`` route so the filament re-linking +
  1114. extra-dict + location-resolution rules stay in sync with the single-spool
  1115. PATCH path. Per-spool errors are collected; one bad ID doesn't abort the
  1116. batch.
  1117. """
  1118. update_fields = payload.update.model_dump(exclude_unset=True)
  1119. if not update_fields:
  1120. raise HTTPException(status_code=400, detail="update must include at least one field")
  1121. updated = 0
  1122. errors: list[dict] = []
  1123. for sid in payload.ids:
  1124. try:
  1125. await update_spool(spool_id=sid, data=payload.update, db=db, _=None)
  1126. updated += 1
  1127. except HTTPException as exc:
  1128. errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
  1129. except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
  1130. logger.exception("Spoolman bulk-update failed for spool %s", sid)
  1131. errors.append({"id": sid, "status": 500, "detail": str(exc)})
  1132. if updated:
  1133. await ws_manager.broadcast({"type": "inventory_changed"})
  1134. return {"updated": updated, "errors": errors}
  1135. @router.post("/spools/bulk-delete")
  1136. async def bulk_delete_spools(
  1137. payload: SpoolmanBulkIdsRequest,
  1138. db: AsyncSession = Depends(get_db, scope="function"),
  1139. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1140. ) -> dict:
  1141. """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
  1142. client = await _get_client(db)
  1143. deleted = 0
  1144. freed: list[tuple[int, int, int]] = []
  1145. errors: list[dict] = []
  1146. for sid in payload.ids:
  1147. try:
  1148. async with _translate_spoolman_errors():
  1149. await client.delete_spool(sid)
  1150. await _purge_local_rows_for_spool(db, sid)
  1151. freed += await _release_spool_slots(db, sid, keep_tracked=False)
  1152. deleted += 1
  1153. except HTTPException as exc:
  1154. errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
  1155. except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
  1156. logger.exception("Spoolman bulk-delete failed for spool %s", sid)
  1157. errors.append({"id": sid, "status": 500, "detail": str(exc)})
  1158. if deleted:
  1159. await db.commit()
  1160. await ws_manager.broadcast({"type": "inventory_changed"})
  1161. await _announce_freed_slots(freed)
  1162. return {"deleted": deleted, "errors": errors}
  1163. @router.post("/spools/bulk-archive")
  1164. async def bulk_archive_spools(
  1165. payload: SpoolmanBulkIdsRequest,
  1166. db: AsyncSession = Depends(get_db, scope="function"),
  1167. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1168. ) -> dict:
  1169. """Archive every listed Spoolman spool. Per-spool failures are collected."""
  1170. client = await _get_client(db)
  1171. archived = 0
  1172. freed: list[tuple[int, int, int]] = []
  1173. errors: list[dict] = []
  1174. for sid in payload.ids:
  1175. try:
  1176. async with _translate_spoolman_errors():
  1177. await client.set_spool_archived(sid, archived=True)
  1178. freed += await _release_spool_slots(db, sid, keep_tracked=True)
  1179. archived += 1
  1180. except HTTPException as exc:
  1181. errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
  1182. except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
  1183. logger.exception("Spoolman bulk-archive failed for spool %s", sid)
  1184. errors.append({"id": sid, "status": 500, "detail": str(exc)})
  1185. if archived:
  1186. await db.commit()
  1187. await ws_manager.broadcast({"type": "inventory_changed"})
  1188. await _announce_freed_slots(freed)
  1189. return {"archived": archived, "errors": errors}
  1190. @router.post("/spools/bulk-restore")
  1191. async def bulk_restore_spools(
  1192. payload: SpoolmanBulkIdsRequest,
  1193. db: AsyncSession = Depends(get_db, scope="function"),
  1194. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1195. ) -> dict:
  1196. """Restore every listed archived Spoolman spool. Per-spool failures are collected."""
  1197. client = await _get_client(db)
  1198. restored = 0
  1199. errors: list[dict] = []
  1200. for sid in payload.ids:
  1201. try:
  1202. async with _translate_spoolman_errors():
  1203. await client.set_spool_archived(sid, archived=False)
  1204. restored += 1
  1205. except HTTPException as exc:
  1206. errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
  1207. except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
  1208. logger.exception("Spoolman bulk-restore failed for spool %s", sid)
  1209. errors.append({"id": sid, "status": 500, "detail": str(exc)})
  1210. if restored:
  1211. await ws_manager.broadcast({"type": "inventory_changed"})
  1212. return {"restored": restored, "errors": errors}
  1213. @router.post("/spools/reset-consumed-counter-bulk")
  1214. async def bulk_reset_spool_consumed_counter(
  1215. payload: dict = Body(...),
  1216. db: AsyncSession = Depends(get_db, scope="function"),
  1217. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1218. ) -> dict:
  1219. """Bulk reset the "Total Consumed" counter across the given Spoolman spool IDs.
  1220. Caller passes an explicit list of IDs — no "reset all" shortcut, since
  1221. a typo on a wildcard would wipe the entire inventory's tracking.
  1222. Returns the count of spools reset and, like the other bulk actions, the
  1223. ones Spoolman refused in ``errors``: an individual failure does not abort
  1224. the batch, and a batch where every reset failed is not reported as a
  1225. success (FO-C8).
  1226. """
  1227. spool_ids = payload.get("spool_ids")
  1228. if not isinstance(spool_ids, list) or not spool_ids:
  1229. raise HTTPException(status_code=400, detail="spool_ids must be a non-empty list")
  1230. if not all(isinstance(sid, int) for sid in spool_ids):
  1231. raise HTTPException(status_code=400, detail="spool_ids must contain integers")
  1232. client = await _get_client(db)
  1233. reset_count = 0
  1234. errors: list[dict] = []
  1235. for spool_id in spool_ids:
  1236. try:
  1237. async with _translate_spoolman_errors():
  1238. await client.reset_spool_consumed_counter(spool_id)
  1239. reset_count += 1
  1240. except HTTPException as exc:
  1241. logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
  1242. errors.append({"id": spool_id, "status": exc.status_code, "detail": exc.detail})
  1243. if reset_count:
  1244. await ws_manager.broadcast({"type": "inventory_changed"})
  1245. return {"reset": reset_count, "errors": errors}
  1246. @router.patch("/spools/{spool_id}/weight")
  1247. async def sync_spool_weight(
  1248. *,
  1249. spool_id: int = Path(..., gt=0),
  1250. data: SpoolWeightUpdate,
  1251. db: AsyncSession = Depends(get_db, scope="function"),
  1252. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1253. ) -> dict:
  1254. """Update a spool's remaining weight from a measured gross weight.
  1255. Computes remaining = gross_weight - tare, with the tare resolved the way
  1256. Spoolman does it (spool, filament, vendor, then 250 g; see spoolman_tare).
  1257. """
  1258. client = await _get_client(db)
  1259. async with _translate_spoolman_errors():
  1260. current = await client.get_spool(spool_id)
  1261. core_weight, _source = spoolman_tare(current)
  1262. remaining = max(0.0, data.weight_grams - core_weight)
  1263. async with _translate_spoolman_errors():
  1264. updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining)
  1265. label_weight = _safe_int(spoolman_net_weight(updated), 1000)
  1266. weight_used = max(0.0, label_weight - remaining)
  1267. await ws_manager.broadcast({"type": "inventory_changed"})
  1268. return {"status": "ok", "weight_used": weight_used}
  1269. def _extra_tag(spool: dict) -> str:
  1270. """The tag stored in a Spoolman spool's ``extra``, normalised for comparison.
  1271. Anything that is not a string reads as no tag. ``extra`` is free-form and
  1272. edited outside Bambuddy, and ``.get("tag", "")`` does not default a key
  1273. that is present and null -- that returns None, which has no ``.strip``.
  1274. """
  1275. extra = spool.get("extra")
  1276. raw = extra.get("tag") if isinstance(extra, dict) else None
  1277. return raw.strip('"').upper() if isinstance(raw, str) else ""
  1278. @router.patch("/spools/{spool_id}/tag")
  1279. async def link_tag_to_spoolman_spool(
  1280. *,
  1281. spool_id: int = Path(..., gt=0),
  1282. data: SpoolTagLinkRequest,
  1283. db: AsyncSession = Depends(get_db, scope="function"),
  1284. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1285. ) -> dict:
  1286. """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
  1287. tray_uuid takes precedence over tag_uid when both are supplied.
  1288. Uses extra_lock to serialise against concurrent extra-field writes.
  1289. A tag another active spool already carries is refused with the shared
  1290. ``tag_already_linked`` 409, identical to the built-in route's (#3110).
  1291. """
  1292. client = await _get_client(db)
  1293. tag = (data.tray_uuid or data.tag_uid).upper()
  1294. tag_json = json.dumps(tag)
  1295. async with client.extra_lock(spool_id):
  1296. # Duplicate check: scan all spools for the same tag on a different spool.
  1297. # Sorted, because Spoolman has no unique constraint on extra.tag either,
  1298. # and a caller offered whichever row the scan happened to reach first
  1299. # could not tell two holders apart. The built-in route names the lowest
  1300. # id for the same reason (#3110).
  1301. #
  1302. # Sorting means every row is read, where the old loop stopped at its
  1303. # first match, so one malformed row after the holder must not be able
  1304. # to take the whole request down: _extra_tag refuses a non-string, and
  1305. # a row without an integer id cannot be named and so is not treated as
  1306. # a holder. Bambuddy only ever writes a JSON string here; a third party
  1307. # editing extra.tag in Spoolman is what puts anything else in reach.
  1308. async with _translate_spoolman_errors():
  1309. all_spools = await client.get_all_spools()
  1310. holders = sorted(
  1311. (s for s in all_spools if _extra_tag(s) == tag and isinstance(s.get("id"), int) and s["id"] != spool_id),
  1312. key=lambda s: s["id"],
  1313. )
  1314. if holders:
  1315. raise tag_already_linked("tray_uuid" if data.tray_uuid else "tag_uid", holders[0]["id"])
  1316. # Re-fetch inside the lock so cur_extra reflects any concurrent update.
  1317. async with _translate_spoolman_errors():
  1318. current = await client.get_spool(spool_id)
  1319. # Spoolman 0.27+: both identifiers become native tags of the spool, added to
  1320. # what it already carries rather than replacing it. Spoolman refuses a UID
  1321. # another spool holds with a 409 naming that spool, the same answer as above.
  1322. # Whatever this request added is taken back if anything after it fails, a
  1323. # refused second identifier or the extra.tag write, so the spool is left as
  1324. # it was found.
  1325. added: list[str] = []
  1326. try:
  1327. if await client.has_tag_api():
  1328. had = {t.get("uid") for t in current.get("tags") or []}
  1329. for field, uid in (("tray_uuid", data.tray_uuid), ("tag_uid", data.tag_uid)):
  1330. if not uid:
  1331. continue
  1332. async with _translate_spoolman_errors():
  1333. holder = await client.claim_native_tag(
  1334. spool_id, uid.upper(), "bambu" if data.tray_uuid else None
  1335. )
  1336. if holder is not None and holder != spool_id:
  1337. if holder < 0:
  1338. # A filament or a location holds it: no spool to name or move it from.
  1339. raise HTTPException(
  1340. status_code=409,
  1341. detail={
  1342. "code": "tag_linked_elsewhere",
  1343. "message": f"{field} is linked to a filament or location in Spoolman",
  1344. "field": field,
  1345. },
  1346. )
  1347. raise tag_already_linked(field, holder)
  1348. if uid.upper() not in had:
  1349. added.append(uid.upper())
  1350. cur_extra = dict(current.get("extra") or {})
  1351. cur_extra["tag"] = tag_json
  1352. async with _translate_spoolman_errors():
  1353. updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
  1354. except HTTPException:
  1355. for uid_added in added:
  1356. try:
  1357. await client.unlink_native_tag(spool_id, uid_added)
  1358. except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
  1359. logger.warning("Could not take back native tag %s from spool %s: %s", uid_added, spool_id, exc)
  1360. raise
  1361. logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
  1362. await ws_manager.broadcast({"type": "inventory_changed"})
  1363. return _map_spoolman_spool(updated)
  1364. _HEX_TAG_RE = re.compile(r"^[0-9A-F]{8,64}$")
  1365. _AMS_CHIP_UID_PADDING = "00000100"
  1366. @router.post("/tags/migrate")
  1367. async def migrate_tags_to_native(
  1368. dry_run: bool = Query(True, description="Only count what would move, change nothing"),
  1369. db: AsyncSession = Depends(get_db, scope="function"),
  1370. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1371. ) -> dict:
  1372. """Move tags from Spoolman's extra.tag into its native tags (Spoolman 0.27+).
  1373. extra.tag itself is left as it is, so older readers keep working and a
  1374. downgrade loses nothing. A slot's fallback ID is not a physical tag and
  1375. stays behind. Archived spools are skipped, and a tag an archived spool still
  1376. holds moves to the active one. A tag Spoolman holds on another active spool
  1377. is reported as a conflict for the user to settle, never moved by guessing.
  1378. """
  1379. client = await _get_client(db)
  1380. if not await client.has_tag_api():
  1381. raise HTTPException(
  1382. status_code=409,
  1383. detail={"code": "spoolman_without_tags", "message": "Spoolman 0.27 or later is required"},
  1384. )
  1385. serials = (await db.execute(select(Printer.serial_number))).scalars().all()
  1386. async with _translate_spoolman_errors():
  1387. spools = await client.get_all_spools(allow_archived=True)
  1388. # Who holds which native tag, from the listing: the dry run sees the same
  1389. # conflicts the real one would, and two spools sharing one extra.tag are
  1390. # caught on the second, with no request spent on either. Archived spools are
  1391. # listed only for this: nothing looks an archived spool up, so they get no
  1392. # tags, and one that still holds a tag gives it up to the active spool.
  1393. archived = {spool["id"] for spool in spools if spool.get("archived")}
  1394. native_holder = {t.get("uid"): spool["id"] for spool in spools for t in spool.get("tags") or []}
  1395. report: dict = {"dry_run": dry_run, "moved": [], "already": 0, "slot_ids": 0, "conflicts": []}
  1396. for spool in spools:
  1397. if spool["id"] in archived:
  1398. continue
  1399. tag = _extra_tag(spool)
  1400. if not _HEX_TAG_RE.match(tag) or set(tag) == {"0"}:
  1401. continue
  1402. if is_slot_fallback_tag(tag, serials):
  1403. report["slot_ids"] += 1
  1404. continue
  1405. # The AMS pads a Bambu chip's 4-byte UID to 8 bytes ("D3E68F32" arrives as
  1406. # "D3E68F3200000100"). The native tag is the chip's own UID, as the AMS
  1407. # sync stores it, so it matches what a reader sees.
  1408. bambu = len(tag) == 32
  1409. if len(tag) == 16 and tag.endswith(_AMS_CHIP_UID_PADDING):
  1410. tag, bambu = tag[:8], True
  1411. holder = native_holder.get(tag)
  1412. if holder == spool["id"]:
  1413. report["already"] += 1
  1414. continue
  1415. if holder in archived:
  1416. holder = None
  1417. if holder is None and not dry_run:
  1418. async with _translate_spoolman_errors():
  1419. holder = await client.claim_native_tag(spool["id"], tag, "bambu" if bambu else None)
  1420. if holder is not None:
  1421. report["conflicts"].append({"spool_id": spool["id"], "tag": tag, "holder": holder})
  1422. continue
  1423. native_holder[tag] = spool["id"]
  1424. report["moved"].append(spool["id"])
  1425. logger.info(
  1426. "Native tag migration (dry_run=%s): %d moved, %d already, %d slot ids, %d conflicts",
  1427. dry_run,
  1428. len(report["moved"]),
  1429. report["already"],
  1430. report["slot_ids"],
  1431. len(report["conflicts"]),
  1432. )
  1433. if not dry_run and report["moved"]:
  1434. await ws_manager.broadcast({"type": "inventory_changed"})
  1435. return report
  1436. @router.get("/slot-assignments/all", response_model=list[SpoolmanSlotAssignmentEnriched])
  1437. async def get_all_spoolman_slot_assignments(
  1438. printer_id: int | None = Query(None, gt=0),
  1439. db: AsyncSession = Depends(get_db, scope="function"),
  1440. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1441. printer_scope: PrinterScope = RequestPrinterScope,
  1442. ) -> list[SpoolmanSlotAssignmentEnriched]:
  1443. """Return all Spoolman slot assignments enriched with printer_name and ams_label.
  1444. ``printer_name`` is null only when the printer relation is missing
  1445. (cascade-deleted edge case). ``ams_label`` is null when no AmsLabel row
  1446. matches the slot's MQTT serial (or the synthetic ``f"p{pid}a{ams_id}"``
  1447. fallback key).
  1448. """
  1449. query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
  1450. if printer_id is not None:
  1451. query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
  1452. if (clause := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
  1453. query = query.where(clause)
  1454. result = await db.execute(query)
  1455. slots = list(result.scalars().all())
  1456. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  1457. # Same pattern as inventory.py:765-806 for the local /assignments endpoint.
  1458. printer_ids = {s.printer_id for s in slots}
  1459. serial_map: dict[tuple[int, int], str] = {}
  1460. all_statuses = printer_manager.get_all_statuses()
  1461. for pid in printer_ids:
  1462. state = all_statuses.get(pid)
  1463. if not (state and state.raw_data):
  1464. continue
  1465. # Some printer firmware variants wrap the AMS list in an outer dict
  1466. # (`{"ams": [...]}`). Mirror the defense used in sync_spoolman_ams_weights
  1467. # (line 842-844) so a wrapped payload still resolves to a list.
  1468. ams_raw = state.raw_data.get("ams", [])
  1469. if isinstance(ams_raw, dict):
  1470. ams_raw = ams_raw.get("ams", [])
  1471. if not isinstance(ams_raw, list):
  1472. continue
  1473. for ams_unit in ams_raw:
  1474. if not isinstance(ams_unit, dict):
  1475. continue
  1476. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  1477. if not sn:
  1478. continue
  1479. try:
  1480. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  1481. except (ValueError, TypeError):
  1482. continue
  1483. # Add the synthetic position key (f"p{pid}a{ams_id}"): a label saved before
  1484. # the unit reported its serial (the AMS Lite before #3348) is still under
  1485. # one, and is shown until it is saved again, as on the printer card.
  1486. all_serials: set[str] = set(serial_map.values())
  1487. for s in slots:
  1488. all_serials.add(f"p{s.printer_id}a{s.ams_id}")
  1489. label_by_serial: dict[str, str] = {}
  1490. if all_serials:
  1491. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  1492. for lbl in lbl_result.scalars().all():
  1493. label_by_serial[lbl.ams_serial_number] = lbl.label
  1494. def _ams_label_for(pid: int, ams_id: int) -> str | None:
  1495. sn = serial_map.get((pid, ams_id))
  1496. if sn and sn in label_by_serial:
  1497. return label_by_serial[sn]
  1498. return label_by_serial.get(f"p{pid}a{ams_id}")
  1499. enriched: list[SpoolmanSlotAssignmentEnriched] = []
  1500. for s in slots:
  1501. if s.printer is None:
  1502. # FK is ondelete=CASCADE so this should be unreachable in normal
  1503. # operation; surface it loudly if a stale row ever appears.
  1504. logger.warning(
  1505. "Orphaned Spoolman slot assignment: printer_id=%d (ams=%d, tray=%d, spoolman_spool_id=%d) has no Printer row",
  1506. s.printer_id,
  1507. s.ams_id,
  1508. s.tray_id,
  1509. s.spoolman_spool_id,
  1510. )
  1511. enriched.append(
  1512. SpoolmanSlotAssignmentEnriched(
  1513. printer_id=s.printer_id,
  1514. printer_name=s.printer.name if s.printer else None,
  1515. ams_id=s.ams_id,
  1516. tray_id=s.tray_id,
  1517. spoolman_spool_id=s.spoolman_spool_id,
  1518. ams_label=_ams_label_for(s.printer_id, s.ams_id),
  1519. )
  1520. )
  1521. return enriched
  1522. @router.post("/sync-ams-weights")
  1523. async def sync_spoolman_ams_weights(
  1524. db: AsyncSession = Depends(get_db, scope="function"),
  1525. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1526. printer_scope: PrinterScope = RequestPrinterScope,
  1527. ):
  1528. """Sync remaining weight back to Spoolman for all slot-assigned spools.
  1529. Reads live AMS remain% from connected printers, computes
  1530. remaining = label_weight * remain% / 100, and PATCHes Spoolman.
  1531. """
  1532. client = await _get_client(db)
  1533. # Fetch all non-archived Spoolman spools once for label_weight lookup
  1534. async with _translate_spoolman_errors():
  1535. raw_spools = await client.get_all_spools(allow_archived=False)
  1536. spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
  1537. result = await db.execute(select(SpoolmanSlotAssignment))
  1538. # Only slots on printers the caller may see (#1727)
  1539. assignments = [a for a in result.scalars().all() if printer_scope.allows(a.printer_id)]
  1540. from backend.app.services.usage_tracker import _active_sessions
  1541. synced = 0
  1542. skipped = 0
  1543. # A printing printer is left alone: its print's usage is charged when it
  1544. # completes, on top of whatever remaining weight the spool has by then.
  1545. printing = 0
  1546. def _find_tray(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  1547. if not ams_data:
  1548. return None
  1549. for ams_unit in ams_data:
  1550. if _safe_int(ams_unit.get("id"), -1) != ams_id:
  1551. continue
  1552. for tray in ams_unit.get("tray", []):
  1553. if _safe_int(tray.get("id"), -1) == tray_id:
  1554. return tray
  1555. return None
  1556. for assignment in assignments:
  1557. spool_dict = spool_lookup.get(assignment.spoolman_spool_id)
  1558. if not spool_dict:
  1559. logger.debug("Spoolman AMS sync: spool %d not found in Spoolman, skipping", assignment.spoolman_spool_id)
  1560. skipped += 1
  1561. continue
  1562. label_weight = _safe_int(spoolman_net_weight(spool_dict), 1000)
  1563. if label_weight <= 0:
  1564. logger.debug("Spoolman AMS sync: spool %d has no label_weight, skipping", assignment.spoolman_spool_id)
  1565. skipped += 1
  1566. continue
  1567. state = printer_manager.get_status(assignment.printer_id)
  1568. # A disconnected printer keeps its last report, which is not a reading.
  1569. if not state or not state.raw_data or getattr(state, "connected", True) is False:
  1570. logger.info(
  1571. "Spoolman AMS sync: printer %d not connected, skipping spool %d",
  1572. assignment.printer_id,
  1573. assignment.spoolman_spool_id,
  1574. )
  1575. skipped += 1
  1576. continue
  1577. # Until completion has charged the print its session stays open, also
  1578. # after the printer has reported the print finished.
  1579. if (
  1580. str(getattr(state, "state", "") or "").upper() in PrinterManager.ACTIVE_PRINT_STATES
  1581. or assignment.printer_id in _active_sessions
  1582. ):
  1583. logger.info(
  1584. "Spoolman AMS sync: printer %d is printing, skipping spool %d",
  1585. assignment.printer_id,
  1586. assignment.spoolman_spool_id,
  1587. )
  1588. skipped += 1
  1589. printing += 1
  1590. continue
  1591. ams_raw = state.raw_data.get("ams", [])
  1592. if isinstance(ams_raw, dict):
  1593. ams_raw = ams_raw.get("ams", [])
  1594. tray = _find_tray(ams_raw, assignment.ams_id, assignment.tray_id)
  1595. if not tray:
  1596. logger.info(
  1597. "Spoolman AMS sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  1598. assignment.spoolman_spool_id,
  1599. assignment.printer_id,
  1600. assignment.ams_id,
  1601. assignment.tray_id,
  1602. )
  1603. skipped += 1
  1604. continue
  1605. remain_raw = tray.get("remain")
  1606. if remain_raw is None:
  1607. logger.debug(
  1608. "Spoolman AMS sync: no remain value for spool %d (tray %d/%d), skipping",
  1609. assignment.spoolman_spool_id,
  1610. assignment.ams_id,
  1611. assignment.tray_id,
  1612. )
  1613. skipped += 1
  1614. continue
  1615. try:
  1616. remain_val = int(remain_raw)
  1617. except (TypeError, ValueError):
  1618. logger.debug(
  1619. "Spoolman AMS sync: non-numeric remain=%r for spool %d, skipping",
  1620. remain_raw,
  1621. assignment.spoolman_spool_id,
  1622. )
  1623. skipped += 1
  1624. continue
  1625. if remain_val < 0 or remain_val > 100:
  1626. logger.debug("Spoolman AMS sync: invalid remain=%s for spool %d", remain_raw, assignment.spoolman_spool_id)
  1627. skipped += 1
  1628. continue
  1629. remaining = round(label_weight * remain_val / 100.0, 1)
  1630. try:
  1631. async with _translate_spoolman_errors():
  1632. await client.update_spool_full(assignment.spoolman_spool_id, remaining_weight=remaining)
  1633. logger.info(
  1634. "Spoolman AMS sync: spool %d remaining set to %s g (remain=%d%%)",
  1635. assignment.spoolman_spool_id,
  1636. remaining,
  1637. remain_val,
  1638. )
  1639. synced += 1
  1640. except HTTPException as exc:
  1641. if exc.status_code == 404:
  1642. logger.warning(
  1643. "Spoolman AMS sync: spool %d not found in Spoolman (404), skipping",
  1644. assignment.spoolman_spool_id,
  1645. )
  1646. else:
  1647. logger.warning(
  1648. "Spoolman AMS sync: failed to update spool %d (HTTP %d)",
  1649. assignment.spoolman_spool_id,
  1650. exc.status_code,
  1651. )
  1652. skipped += 1
  1653. return {"synced": synced, "skipped": skipped, "printing": printing}
  1654. @router.post("/slot-assignments")
  1655. async def assign_spoolman_slot(
  1656. body: SpoolSlotAssignmentRequest,
  1657. db: AsyncSession = Depends(get_db, scope="function"),
  1658. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1659. printer_scope: PrinterScope = RequestPrinterScope,
  1660. ) -> dict:
  1661. """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
  1662. Raises 404 if the printer does not exist or the spool is not found in Spoolman.
  1663. Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
  1664. """
  1665. client = await _get_client(db)
  1666. printer_scope.ensure(body.printer_id)
  1667. result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
  1668. printer = result.scalar_one_or_none()
  1669. if not printer:
  1670. raise HTTPException(status_code=404, detail="Printer not found")
  1671. # Verify the Spoolman spool exists before committing to local DB.
  1672. # This prevents ghost rows pointing at non-existent spool IDs.
  1673. async with _translate_spoolman_errors():
  1674. spool = await client.get_spool(body.spoolman_spool_id)
  1675. # As the built-in inventory refuses it: an archived spool on a slot would
  1676. # be charged the next print's usage (SSA-8).
  1677. if spool.get("archived"):
  1678. raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
  1679. # A move (#1574) takes the spool out of every other slot it holds, in the
  1680. # same commit as the upsert below, so a failed move leaves it where it was
  1681. # instead of in no slot. Its fallback tag for a slot it leaves is cleared
  1682. # first. Moves of one spool take turns (get_spool_move_lock): on PostgreSQL
  1683. # a move's DELETE doesn't see the slot another move inserts and commits
  1684. # after it began, and both left the spool assigned. A slot on a printer
  1685. # outside the caller's scope stays assigned, as unassigning leaves it
  1686. # (#1727).
  1687. target = (body.printer_id, body.ams_id, body.tray_id)
  1688. slot_columns = (SpoolmanSlotAssignment.printer_id, SpoolmanSlotAssignment.ams_id, SpoolmanSlotAssignment.tray_id)
  1689. others = [SpoolmanSlotAssignment.spoolman_spool_id == body.spoolman_spool_id, tuple_(*slot_columns) != target]
  1690. if (in_scope := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
  1691. others.append(in_scope)
  1692. vacated: list[tuple[int, int, int]] = []
  1693. cleared_tag: str | None = None
  1694. async with get_spool_move_lock("spoolman", body.spoolman_spool_id) if body.move else nullcontext():
  1695. if body.move:
  1696. vacated = list((await db.execute(select(*slot_columns).where(*others))).tuples())
  1697. cleared_tag = await _clear_vacated_slot_fallback_tag(
  1698. client, db, spool_id=body.spoolman_spool_id, spool=spool, vacated=vacated
  1699. )
  1700. # Spool confirmed in Spoolman — upsert into local slot-assignment table
  1701. # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
  1702. try:
  1703. if body.move:
  1704. # Every other slot of the spool, not only those read above: a
  1705. # link or the AMS sync may have given it one more meanwhile.
  1706. result = await db.execute(delete(SpoolmanSlotAssignment).where(*others).returning(*slot_columns))
  1707. vacated = list(result.tuples())
  1708. await db.execute(
  1709. text(
  1710. "INSERT INTO spoolman_slot_assignments"
  1711. " (printer_id, ams_id, tray_id, spoolman_spool_id)"
  1712. " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
  1713. " ON CONFLICT(printer_id, ams_id, tray_id)"
  1714. " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
  1715. ),
  1716. {
  1717. "printer_id": body.printer_id,
  1718. "ams_id": body.ams_id,
  1719. "tray_id": body.tray_id,
  1720. "spool_id": body.spoolman_spool_id,
  1721. },
  1722. )
  1723. await spool_swaps.record_assignment(
  1724. db, body.printer_id, body.ams_id, body.tray_id, body.spoolman_spool_id, spool_swaps.SPOOLMAN
  1725. )
  1726. await db.commit()
  1727. slot_unlink_grace.forget_slot(body.printer_id, body.ams_id, body.tray_id)
  1728. except Exception as exc:
  1729. await db.rollback()
  1730. logger.error("Failed to persist slot assignment: %s", exc)
  1731. if cleared_tag:
  1732. # The spool stays in its old slot, so it gets that slot's tag back.
  1733. try:
  1734. await client.merge_spool_extra(body.spoolman_spool_id, {"tag": json.dumps(cleared_tag)})
  1735. except (SpoolmanClientError, SpoolmanUnavailableError, SpoolmanNotFoundError) as restore_exc:
  1736. logger.warning(
  1737. "Could not put back the fallback tag of Spoolman spool %s: %s",
  1738. body.spoolman_spool_id,
  1739. restore_exc,
  1740. )
  1741. raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
  1742. if vacated:
  1743. logger.info(
  1744. "Moved Spoolman spool %d to printer %d AMS%d-T%d from %s",
  1745. body.spoolman_spool_id,
  1746. body.printer_id,
  1747. body.ams_id,
  1748. body.tray_id,
  1749. ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
  1750. )
  1751. # Other tabs and the SpoolBuddy kiosk follow slot changes over the
  1752. # websocket, as the built-in assign announces them: the slots the spool
  1753. # left, and the one it went to.
  1754. await _announce_freed_slots([*vacated, target])
  1755. # #1457: clear stale fallback-tag links on OTHER spools still bound to this
  1756. # slot. Without this, a non-RFID slot's deterministic fallback tag stays
  1757. # attached to the previous spool in Spoolman's extra.tag and re-surfaces in
  1758. # the hover card whenever the local slot assignment is removed.
  1759. if printer.serial_number:
  1760. await _clear_stale_slot_fallback_tag_links(
  1761. client,
  1762. printer_serial=printer.serial_number,
  1763. ams_id=body.ams_id,
  1764. tray_id=body.tray_id,
  1765. keep_spool_id=body.spoolman_spool_id,
  1766. )
  1767. mapped = _map_spoolman_spool(spool)
  1768. # Fetch K-profiles before the MQTT try block so we can use async DB access.
  1769. kp_rows_result = await db.execute(
  1770. select(SpoolmanKProfile).where(
  1771. SpoolmanKProfile.spoolman_spool_id == body.spoolman_spool_id,
  1772. SpoolmanKProfile.printer_id == body.printer_id,
  1773. )
  1774. )
  1775. kp_rows = kp_rows_result.scalars().all()
  1776. # Auto-configure AMS slot via MQTT (best-effort; slot assignment is already persisted)
  1777. try:
  1778. mqtt_client = printer_manager.get_client(body.printer_id)
  1779. if mqtt_client:
  1780. # Spoolman's material is free text, so it arrives as whatever the
  1781. # user typed there -- "PLA+", "PolyTerra PLA". The sub-brand keeps
  1782. # that wording; the slot's type has to be one the printer and the
  1783. # slicer know (issue #2902).
  1784. material = mapped.get("material") or ""
  1785. tray_type = printer_filament_type(material)
  1786. brand = mapped.get("brand") or ""
  1787. subtype = mapped.get("subtype") or ""
  1788. if brand:
  1789. tray_sub_brands = f"{brand} {material} {subtype}".strip()
  1790. elif subtype:
  1791. tray_sub_brands = f"{material} {subtype}".strip()
  1792. else:
  1793. tray_sub_brands = material
  1794. tray_color = (mapped.get("rgba") or "808080FF").upper()
  1795. if len(tray_color) == 6:
  1796. tray_color = tray_color + "FF"
  1797. # Printer state, read here rather than further down because the
  1798. # per-model preset override below needs the slot's nozzle
  1799. # diameter and the K-profile cascade further down needs the same
  1800. # value -- one read, so they cannot disagree. (The previous
  1801. # `mqtt_client.printer_state` access via hasattr always returned
  1802. # None -- the attribute is `state`, not `printer_state` -- so the
  1803. # K-profile cascade silently skipped state.kprofiles, defaulted
  1804. # nozzle_diameter to 0.4, and left slot_extruder unset.)
  1805. state = printer_manager.get_status(body.printer_id)
  1806. slot_nozzle = resolve_slot_nozzle(
  1807. state, body.ams_id, body.tray_id, printer_manager.get_model(body.printer_id)
  1808. )
  1809. nozzle_diameter = slot_nozzle.diameter
  1810. # Per-printer-model preset override, same cascade as internal
  1811. # mode: a cloud/Orca preset is bound to a model, so one stored
  1812. # preset per spool is wrong across two models. Returns Spoolman's
  1813. # own value when no override is set.
  1814. slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
  1815. db,
  1816. spoolman_spool_id=body.spoolman_spool_id,
  1817. printer_model=printer_manager.get_model(body.printer_id),
  1818. nozzle_diameter=nozzle_diameter,
  1819. fallback_filament=mapped.get("slicer_filament"),
  1820. fallback_name=mapped.get("slicer_filament_name"),
  1821. )
  1822. # #1713: resolve the spool's stored slicer_filament reference
  1823. # (cloud preset, local preset, GF-prefix builtin, or numeric
  1824. # LocalPreset id) to the printer-side tray_info_idx + setting_id.
  1825. # Previously the Spoolman path dropped slicer_filament on the
  1826. # floor and only the generic-material fallback fired; the user-
  1827. # configured profile never reached the printer. Shared with the
  1828. # internal-mode route via the same helper so the two flows can't
  1829. # drift again.
  1830. tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
  1831. db=db,
  1832. current_user=current_user,
  1833. slicer_filament=slot_slicer_filament,
  1834. slicer_filament_name=slot_slicer_filament_name,
  1835. material=material,
  1836. )
  1837. if sub_brand_override:
  1838. tray_sub_brands = sub_brand_override
  1839. # A preset carries its own type; the reduction above only infers
  1840. # one from Spoolman's free-text material. The preset wins when the
  1841. # spool has one (issue #2902, @doncaruana).
  1842. if type_override:
  1843. tray_type = printer_filament_type(type_override)
  1844. material_upper = material.upper().strip()
  1845. # Fall back to generic-material id when slicer_filament is empty
  1846. # or the resolver discarded an unresolvable value. Matches the
  1847. # internal-mode tail in inventory.py:_apply_spool_to_slot_inner,
  1848. # including the order: the spool's own wording first and the
  1849. # reduced type only after it, so "PETG HF" keeps its own generic
  1850. # preset (GFG96) rather than trading it for "PETG"'s GFG99.
  1851. if not tray_info_idx:
  1852. tray_info_idx = (
  1853. # The finish first, as in internal mode: a Silk spool
  1854. # gets Generic PLA Silk.
  1855. finish_generic_id(tray_type, f"{material} {subtype}")
  1856. or GENERIC_FILAMENT_IDS.get(material_upper)
  1857. or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
  1858. or GENERIC_FILAMENT_IDS.get(tray_type.upper())
  1859. or ""
  1860. )
  1861. # Ensure setting_id is always derivable from tray_info_idx. The
  1862. # local-preset path can leave it empty when the LP's setting JSON
  1863. # has no filament_id and falls through to the generic material id;
  1864. # without this fallback the slicer gets a half-configured slot
  1865. # (filament id without setting id) and the slot detail modal
  1866. # renders empty fields. Same pattern as the internal-mode tail.
  1867. if tray_info_idx and not setting_id:
  1868. setting_id = filament_id_to_setting_id(tray_info_idx)
  1869. temp_defaults = nozzle_temp_range(material, tray_type)
  1870. temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
  1871. temp_max = temp_defaults[1]
  1872. # Pull printer state from printer_manager. The previous
  1873. # `mqtt_client.printer_state` access via hasattr always returned
  1874. # None (the attribute is `state`, not `printer_state`), so the
  1875. # K-profile cascade silently skipped state.kprofiles, defaulted
  1876. # nozzle_diameter to 0.4, and left slot_extruder unset.
  1877. slot_extruder = slot_nozzle.extruder
  1878. # Prefer exact extruder match, fall back to extruder-agnostic kp
  1879. # for the same nozzle. Hard-skipping on mismatch silently dropped
  1880. # valid stored profiles when the AMS-extruder mapping had shifted.
  1881. exact_kp = None
  1882. fallback_kp = None
  1883. for kp in kp_rows:
  1884. if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
  1885. continue
  1886. if not slot_nozzle.flow_matches(kp.nozzle_type):
  1887. continue
  1888. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  1889. exact_kp = kp
  1890. break
  1891. if fallback_kp is None:
  1892. fallback_kp = kp
  1893. matching_kp = exact_kp or fallback_kp
  1894. # Resolve the printer-side calibration entry by cali_idx so we
  1895. # know the authoritative filament_id (the printer indexes its
  1896. # calibration table by filament_id, not setting_id).
  1897. printer_kp = None
  1898. if matching_kp and state and state.kprofiles:
  1899. for pkp in state.kprofiles:
  1900. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  1901. printer_kp = pkp
  1902. break
  1903. if printer_kp is None:
  1904. logger.warning(
  1905. "Spoolman assign: cali_idx=%d not present in printer's "
  1906. "calibration table — stored kp may be stale.",
  1907. matching_kp.cali_idx,
  1908. )
  1909. # Realign the slot's filament context (tray_info_idx + setting_id)
  1910. # to the kp's calibration context. Without this, ams_filament_setting
  1911. # declares the slot under generic PLA while extrusion_cali_sel points
  1912. # the cali_idx at a different preset — the printer can't link them
  1913. # and falls back to the default profile. P-prefix local presets are
  1914. # valid for tray_info_idx; PFUS-prefix cloud-user presets are not
  1915. # (the slicer rejects them).
  1916. effective_tray_info_idx = tray_info_idx
  1917. effective_setting_id = setting_id
  1918. if printer_kp and printer_kp.filament_id:
  1919. if not printer_kp.filament_id.startswith("PFUS"):
  1920. effective_tray_info_idx = printer_kp.filament_id
  1921. if printer_kp.setting_id:
  1922. effective_setting_id = printer_kp.setting_id
  1923. elif matching_kp and matching_kp.setting_id:
  1924. derived = normalize_slicer_filament(matching_kp.setting_id)[0]
  1925. if derived and not derived.startswith("PFUS"):
  1926. effective_tray_info_idx = derived
  1927. effective_setting_id = matching_kp.setting_id
  1928. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  1929. logger.info(
  1930. "Spoolman assign: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
  1931. tray_info_idx,
  1932. effective_tray_info_idx,
  1933. setting_id,
  1934. effective_setting_id,
  1935. matching_kp.id if matching_kp else None,
  1936. "printer" if printer_kp else "stored",
  1937. )
  1938. mqtt_client.ams_set_filament_setting(
  1939. ams_id=body.ams_id,
  1940. tray_id=body.tray_id,
  1941. tray_info_idx=effective_tray_info_idx,
  1942. tray_type=tray_type,
  1943. tray_sub_brands=tray_sub_brands,
  1944. tray_color=tray_color,
  1945. nozzle_temp_min=temp_min,
  1946. nozzle_temp_max=temp_max,
  1947. setting_id=effective_setting_id,
  1948. )
  1949. if matching_kp and matching_kp.cali_idx is not None:
  1950. # Use printer-reported filament_id when available, otherwise
  1951. # fall back to the realigned tray_info_idx so both commands
  1952. # reference the same filament context.
  1953. cali_filament_id = (
  1954. printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
  1955. ) or effective_tray_info_idx
  1956. mqtt_client.extrusion_cali_sel(
  1957. ams_id=body.ams_id,
  1958. tray_id=body.tray_id,
  1959. cali_idx=matching_kp.cali_idx,
  1960. filament_id=cali_filament_id,
  1961. nozzle_diameter=nozzle_diameter,
  1962. )
  1963. logger.info(
  1964. "Spoolman assign: applied K-profile cali_idx=%d "
  1965. "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
  1966. matching_kp.cali_idx,
  1967. matching_kp.id,
  1968. cali_filament_id,
  1969. body.spoolman_spool_id,
  1970. body.printer_id,
  1971. body.ams_id,
  1972. body.tray_id,
  1973. )
  1974. else:
  1975. # No stored K-profile for this spool — always reset the slot to
  1976. # Default K (cali_idx=-1). The live cali_idx belongs to whatever
  1977. # filament was there before, so preserving it would apply the
  1978. # wrong filament's calibration to the new spool.
  1979. mqtt_client.extrusion_cali_sel(
  1980. ams_id=body.ams_id,
  1981. tray_id=body.tray_id,
  1982. cali_idx=-1,
  1983. filament_id=effective_tray_info_idx,
  1984. nozzle_diameter=nozzle_diameter,
  1985. )
  1986. logger.info(
  1987. "No stored K-profile for Spoolman spool %d — reset slot to Default K (cali_idx=-1)",
  1988. body.spoolman_spool_id,
  1989. )
  1990. # Read-back verification, as the built-in assign registers it (#2582):
  1991. # the AMS pushes that follow confirm the tray took what was sent.
  1992. # Not for a slot the firmware calls empty, which echoes nothing
  1993. # back until a spool goes in (the built-in check, inventory.py).
  1994. from backend.app.api.routes.inventory import _find_tray_in_ams_data # noqa: PLC0415
  1995. from backend.app.services.ams_slot_presence import spool_present # noqa: PLC0415
  1996. live_tray = None
  1997. if state and state.raw_data:
  1998. ams_raw = state.raw_data.get("ams", [])
  1999. if isinstance(ams_raw, dict):
  2000. ams_raw = ams_raw.get("ams", [])
  2001. live_tray = _find_tray_in_ams_data(ams_raw, body.ams_id, body.tray_id)
  2002. tray_state = (live_tray or {}).get("state")
  2003. if not (spool_present(live_tray) is not True and tray_state in (9, 10)):
  2004. mqtt_client.register_assignment_verification(
  2005. ams_id=body.ams_id,
  2006. tray_id=body.tray_id,
  2007. tray_info_idx=effective_tray_info_idx,
  2008. tray_color=tray_color,
  2009. cali_idx=matching_kp.cali_idx if matching_kp and matching_kp.cali_idx is not None else -1,
  2010. )
  2011. # Fresh telemetry for the check to compare against.
  2012. mqtt_client.request_status_update()
  2013. logger.info(
  2014. "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",
  2015. body.ams_id,
  2016. body.tray_id,
  2017. body.spoolman_spool_id,
  2018. body.printer_id,
  2019. )
  2020. except Exception:
  2021. logger.exception(
  2022. "Failed to auto-configure AMS slot for Spoolman spool %d (printer=%d, ams=%d, tray=%d)",
  2023. body.spoolman_spool_id,
  2024. body.printer_id,
  2025. body.ams_id,
  2026. body.tray_id,
  2027. )
  2028. return mapped
  2029. @router.delete("/slot-assignments/{spoolman_spool_id}")
  2030. async def unassign_spoolman_slot(
  2031. spoolman_spool_id: int = Path(..., gt=0),
  2032. db: AsyncSession = Depends(get_db, scope="function"),
  2033. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2034. printer_scope: PrinterScope = RequestPrinterScope,
  2035. ) -> dict:
  2036. """Remove the local slot assignment for a Spoolman spool.
  2037. Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
  2038. """
  2039. client = await _get_client(db)
  2040. try:
  2041. # A slot on a printer the caller can't see stays assigned (#1727)
  2042. unassign = delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
  2043. if (clause := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
  2044. unassign = unassign.where(clause)
  2045. await db.execute(unassign)
  2046. await db.commit()
  2047. except Exception as exc:
  2048. await db.rollback()
  2049. logger.error("Failed to delete slot assignment: %s", exc)
  2050. raise HTTPException(status_code=500, detail="Failed to remove slot assignment") from exc
  2051. # Fetch the spool from Spoolman to return in InventorySpool format.
  2052. # If the spool no longer exists in Spoolman, the local unassignment still succeeded.
  2053. try:
  2054. async with _translate_spoolman_errors():
  2055. spool = await client.get_spool(spoolman_spool_id)
  2056. return _map_spoolman_spool(spool)
  2057. except HTTPException as exc:
  2058. if exc.status_code != 404:
  2059. raise
  2060. # Spool no longer exists in Spoolman; unassignment still succeeded.
  2061. return {"id": spoolman_spool_id}
  2062. @router.get("/slot-assignments")
  2063. async def get_spoolman_slot_assignment(
  2064. printer_id: int = Query(..., gt=0),
  2065. ams_id: int = Query(..., ge=0, le=7),
  2066. tray_id: int = Query(..., ge=0, le=3),
  2067. db: AsyncSession = Depends(get_db, scope="function"),
  2068. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2069. printer_scope: PrinterScope = RequestPrinterScope,
  2070. ) -> dict | None:
  2071. """Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
  2072. client = await _get_client(db)
  2073. printer_scope.ensure(printer_id)
  2074. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2075. printer = result.scalar_one_or_none()
  2076. if not printer:
  2077. raise HTTPException(status_code=404, detail="Printer not found")
  2078. slot_result = await db.execute(
  2079. select(SpoolmanSlotAssignment).where(
  2080. SpoolmanSlotAssignment.printer_id == printer_id,
  2081. SpoolmanSlotAssignment.ams_id == ams_id,
  2082. SpoolmanSlotAssignment.tray_id == tray_id,
  2083. )
  2084. )
  2085. slot = slot_result.scalar_one_or_none()
  2086. if not slot:
  2087. return None
  2088. try:
  2089. async with _translate_spoolman_errors():
  2090. spool = await client.get_spool(slot.spoolman_spool_id)
  2091. return _map_spoolman_spool(spool)
  2092. except HTTPException as exc:
  2093. if exc.status_code != 404:
  2094. raise
  2095. # Spool deleted in Spoolman — clean up stale assignment.
  2096. # Include spoolman_spool_id in WHERE to avoid a TOCTOU race where a
  2097. # concurrent re-assign changed the slot to a different spool between
  2098. # the GET and this DELETE.
  2099. try:
  2100. await db.execute(
  2101. delete(SpoolmanSlotAssignment).where(
  2102. SpoolmanSlotAssignment.id == slot.id,
  2103. SpoolmanSlotAssignment.spoolman_spool_id == slot.spoolman_spool_id,
  2104. )
  2105. )
  2106. await db.commit()
  2107. except Exception as cleanup_exc:
  2108. await db.rollback()
  2109. logger.warning(
  2110. "Failed to remove stale slot assignment for spool %s: %s",
  2111. slot.spoolman_spool_id,
  2112. cleanup_exc,
  2113. )
  2114. return None
  2115. def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
  2116. """Manually map SpoolmanKProfile → SpoolKProfileResponse-compatible dict."""
  2117. return {
  2118. "id": p.id,
  2119. "spool_id": p.spoolman_spool_id,
  2120. "printer_id": p.printer_id,
  2121. "extruder": p.extruder,
  2122. "nozzle_diameter": p.nozzle_diameter,
  2123. "nozzle_type": p.nozzle_type,
  2124. "k_value": p.k_value,
  2125. "name": p.name,
  2126. "cali_idx": p.cali_idx,
  2127. "setting_id": p.setting_id,
  2128. "created_at": p.created_at,
  2129. }
  2130. def _filament_preset_to_dict(p: SpoolmanFilamentPreset) -> dict:
  2131. """Manually map SpoolmanFilamentPreset → SpoolFilamentPresetResponse-compatible dict."""
  2132. return {
  2133. "id": p.id,
  2134. "spool_id": p.spoolman_spool_id,
  2135. "printer_model": p.printer_model,
  2136. "nozzle_diameter": p.nozzle_diameter,
  2137. "slicer_filament": p.slicer_filament,
  2138. "slicer_filament_name": p.slicer_filament_name,
  2139. "created_at": p.created_at,
  2140. }
  2141. @router.get("/spools/{spool_id}/filament-presets")
  2142. async def get_spoolman_filament_presets(
  2143. spool_id: int = Path(..., gt=0),
  2144. db: AsyncSession = Depends(get_db, scope="function"),
  2145. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2146. ) -> list[dict]:
  2147. """Return all per-printer-model preset overrides for a Spoolman spool."""
  2148. await _get_client(db)
  2149. result = await db.execute(
  2150. select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id)
  2151. )
  2152. return [_filament_preset_to_dict(p) for p in result.scalars().all()]
  2153. @router.put("/spools/{spool_id}/filament-presets")
  2154. async def save_spoolman_filament_presets(
  2155. spool_id: int = Path(..., gt=0),
  2156. presets: list[SpoolFilamentPresetBase] = Body(...),
  2157. db: AsyncSession = Depends(get_db, scope="function"),
  2158. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2159. ) -> list[dict]:
  2160. """Replace all per-printer-model preset overrides for a Spoolman spool."""
  2161. client = await _get_client(db)
  2162. async with _translate_spoolman_errors():
  2163. await client.get_spool(spool_id)
  2164. # Same as the internal route: reject a duplicated (model, diameter) before
  2165. # touching the stored rows, so a bad payload cannot clear what it fails to
  2166. # replace.
  2167. seen: set[tuple[str, str]] = set()
  2168. for preset in presets:
  2169. key = (preset.printer_model, preset.nozzle_diameter)
  2170. if key in seen:
  2171. raise HTTPException(
  2172. 422,
  2173. f"Duplicate override for model {preset.printer_model!r} nozzle {preset.nozzle_diameter or 'any'!r}",
  2174. )
  2175. seen.add(key)
  2176. saved: list[SpoolmanFilamentPreset] = []
  2177. try:
  2178. await db.execute(delete(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id))
  2179. await db.flush()
  2180. for preset in presets:
  2181. obj = SpoolmanFilamentPreset(
  2182. spoolman_spool_id=spool_id,
  2183. printer_model=preset.printer_model,
  2184. nozzle_diameter=preset.nozzle_diameter,
  2185. slicer_filament=preset.slicer_filament,
  2186. slicer_filament_name=preset.slicer_filament_name,
  2187. )
  2188. db.add(obj)
  2189. saved.append(obj)
  2190. await db.commit()
  2191. except IntegrityError as exc:
  2192. await db.rollback()
  2193. raise HTTPException(422, "Duplicate or invalid preset override (check model and nozzle uniqueness)") from exc
  2194. except HTTPException:
  2195. raise
  2196. except Exception as exc:
  2197. await db.rollback()
  2198. logger.error("Filament preset save for spool %d failed: %s", spool_id, exc)
  2199. raise HTTPException(500, "Failed to save filament presets") from exc
  2200. for obj in saved:
  2201. await db.refresh(obj)
  2202. return [_filament_preset_to_dict(p) for p in saved]
  2203. def _normalize_filament(raw: dict) -> NormalizedFilament | None:
  2204. """Normalise a raw Spoolman filament dict for the frontend catalog picker.
  2205. Returns None for entries with missing/zero IDs — those are malformed and
  2206. must be filtered out before returning to the client.
  2207. weight=0 is collapsed to None — 0g is not a valid filament weight.
  2208. """
  2209. filament_id = _safe_int(raw.get("id"), 0)
  2210. if filament_id <= 0:
  2211. logger.warning("Skipping Spoolman filament with missing or invalid id: %r", raw.get("name"))
  2212. return None
  2213. vendor = raw.get("vendor") or {}
  2214. vendor_ref: NormalizedVendorRef | None = None
  2215. if vendor:
  2216. vendor_id = _safe_int(vendor.get("id"), 0)
  2217. if vendor_id <= 0:
  2218. logger.warning("Spoolman filament %d has vendor without valid id — vendor omitted", filament_id)
  2219. else:
  2220. vendor_ref = {"id": vendor_id, "name": str(vendor.get("name") or "").strip() or "Unknown"}
  2221. return NormalizedFilament(
  2222. id=filament_id,
  2223. name=str(raw.get("name") or ""),
  2224. material=raw.get("material") or None,
  2225. color_hex=raw.get("color_hex") or None,
  2226. color_name=raw.get("color_name") or None,
  2227. weight=_safe_int(raw.get("weight"), 0) or None, # 0g is not a valid weight
  2228. spool_weight=_safe_optional_float(raw.get("spool_weight")),
  2229. vendor=vendor_ref,
  2230. )
  2231. @router.get("/filaments")
  2232. async def list_spoolman_filaments(
  2233. db: AsyncSession = Depends(get_db, scope="function"),
  2234. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2235. ) -> list[NormalizedFilament]:
  2236. """Return all filaments from Spoolman, normalised for the frontend catalog picker."""
  2237. client = await _get_client(db)
  2238. async with _translate_spoolman_errors():
  2239. raw_filaments = await client.get_filaments()
  2240. if not isinstance(raw_filaments, list):
  2241. logger.warning("Spoolman get_filaments() returned non-list type: %s", type(raw_filaments).__name__)
  2242. return []
  2243. return [f for raw in raw_filaments if (f := _normalize_filament(raw)) is not None]
  2244. @router.patch("/filaments/{filament_id}")
  2245. async def patch_spoolman_filament(
  2246. *,
  2247. filament_id: int = Path(..., gt=0),
  2248. body: SpoolmanFilamentPatch = Body(...),
  2249. db: AsyncSession = Depends(get_db, scope="function"),
  2250. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2251. ) -> NormalizedFilament:
  2252. """Update a Spoolman filament's name and/or spool_weight.
  2253. When spool_weight changes, Option A (keep_existing_spools=True) stamps the old
  2254. weight (the filament's, or its vendor's empty_spool_weight when the filament had
  2255. none) onto spools currently inheriting it (spool.spool_weight is None) so their
  2256. tare calculations are unaffected by the filament change.
  2257. Option B (keep_existing_spools=False, the default): when spool_weight is a
  2258. concrete value, stamps it onto every affected spool explicitly; when spool_weight
  2259. is null, clears per-spool overrides so spools fall back to the filament value.
  2260. """
  2261. client = await _get_client(db)
  2262. async with _translate_spoolman_errors():
  2263. current = await client.get_filament(filament_id)
  2264. patch_data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k != "keep_existing_spools"}
  2265. if not patch_data:
  2266. normalized = _normalize_filament(current)
  2267. if normalized is None:
  2268. raise HTTPException(status_code=404, detail="Filament not found")
  2269. return normalized
  2270. async with _translate_spoolman_errors():
  2271. updated = await client.patch_filament(filament_id, patch_data)
  2272. if "spool_weight" in body.model_fields_set:
  2273. async with _translate_spoolman_errors():
  2274. all_spools = await client.get_all_spools()
  2275. affected_spools = [s for s in all_spools if (s.get("filament") or {}).get("id") == filament_id]
  2276. if affected_spools:
  2277. if body.keep_existing_spools:
  2278. # What the inheriting spools weigh against today: the filament's
  2279. # own value, or when it has none, its vendor's (#3195). With
  2280. # neither they sit on the 250 g fallback, which is Bambuddy's
  2281. # alone, so there is nothing real to keep.
  2282. old_weight = _safe_optional_float(current.get("spool_weight"))
  2283. if old_weight is None:
  2284. old_weight = _safe_optional_float((current.get("vendor") or {}).get("empty_spool_weight"))
  2285. if old_weight is not None:
  2286. spools_to_fix = [s for s in affected_spools if s.get("spool_weight") is None]
  2287. if spools_to_fix:
  2288. async with _translate_spoolman_errors():
  2289. results = await asyncio.gather(
  2290. *(
  2291. client.update_spool_full(spool_id=s["id"], spool_weight=old_weight)
  2292. for s in spools_to_fix
  2293. ),
  2294. return_exceptions=True,
  2295. )
  2296. _raise_if_partial_failure(spools_to_fix, results, "spool_weight stamp (option A)")
  2297. else:
  2298. new_weight = body.spool_weight
  2299. if new_weight is not None:
  2300. # Stamp the new weight onto every spool of this filament type so
  2301. # each spool carries the value explicitly rather than inheriting.
  2302. async with _translate_spoolman_errors():
  2303. results = await asyncio.gather(
  2304. *(
  2305. client.update_spool_full(spool_id=s["id"], spool_weight=new_weight)
  2306. for s in affected_spools
  2307. ),
  2308. return_exceptions=True,
  2309. )
  2310. _raise_if_partial_failure(affected_spools, results, "spool_weight stamp (option B)")
  2311. else:
  2312. # Filament weight is being cleared — remove any per-spool override
  2313. # so spools fall back to the vendor's empty_spool_weight, or 250 g.
  2314. spools_to_clear = [s for s in affected_spools if s.get("spool_weight") is not None]
  2315. if spools_to_clear:
  2316. async with _translate_spoolman_errors():
  2317. results = await asyncio.gather(
  2318. *(
  2319. client.update_spool_full(spool_id=s["id"], clear_spool_weight=True)
  2320. for s in spools_to_clear
  2321. ),
  2322. return_exceptions=True,
  2323. )
  2324. _raise_if_partial_failure(spools_to_clear, results, "spool_weight clear (option B null)")
  2325. normalized = _normalize_filament(updated)
  2326. if normalized is None:
  2327. raise HTTPException(status_code=502, detail="Spoolman returned malformed filament data")
  2328. return normalized
  2329. @router.post("/profile-defaults/{default_id}/apply")
  2330. async def apply_profile_default_to_spoolman_spools(
  2331. default_id: int = Path(..., gt=0),
  2332. db: AsyncSession = Depends(get_db, scope="function"),
  2333. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2334. ) -> dict:
  2335. """Spoolman twin of ``POST /inventory/profile-defaults/{id}/apply`` (#1326).
  2336. Fills in what active Spoolman spools of the default's product do not have
  2337. yet: the spool's own preset (in its extra dict), and its preset overrides
  2338. and K profiles (Bambuddy tables keyed by the spool id). Never overwrites.
  2339. """
  2340. client = await _get_client(db)
  2341. row = await profile_defaults.load_default(db, default_id)
  2342. if row is None:
  2343. raise HTTPException(404, "Profile default not found")
  2344. async with _translate_spoolman_errors():
  2345. spools = await client.get_all_spools()
  2346. updated = 0
  2347. cache: dict[str, profile_defaults.ResolvedProfiles | None] = {}
  2348. slicer_field_ready = False
  2349. for raw in spools:
  2350. try:
  2351. mapped = _map_spoolman_spool(raw)
  2352. except ValueError:
  2353. continue
  2354. # The mapper fills a missing colour name with the subtype; that is no colour.
  2355. colour = None if mapped["color_name_is_synthesized"] else mapped["color_name"]
  2356. if mapped["archived_at"] or not profile_defaults.default_matches(
  2357. row, brand=mapped["brand"], material=mapped["material"], subtype=mapped["subtype"], color_name=colour
  2358. ):
  2359. continue
  2360. colour_key = (colour or "").strip().lower()
  2361. if colour_key not in cache:
  2362. cache[colour_key] = await profile_defaults.resolve_profile_default(
  2363. db, brand=row.brand, material=row.material, subtype=row.subtype, color_name=colour
  2364. )
  2365. resolved = cache[colour_key]
  2366. if resolved is None:
  2367. continue
  2368. changed = await profile_defaults.fill_spoolman_rows(db, mapped["id"], resolved)
  2369. if resolved.slicer_filament and not mapped["slicer_filament"]:
  2370. new_extra = {"bambu_slicer_filament": json.dumps(resolved.slicer_filament)}
  2371. if resolved.slicer_filament_name:
  2372. new_extra["bambu_slicer_filament_name"] = json.dumps(resolved.slicer_filament_name)
  2373. try:
  2374. async with _translate_spoolman_errors():
  2375. if not slicer_field_ready:
  2376. await client.ensure_extra_field("bambu_slicer_filament")
  2377. await client.ensure_extra_field("bambu_slicer_filament_name")
  2378. slicer_field_ready = True
  2379. await client.merge_spool_extra(mapped["id"], new_extra)
  2380. forget_spoolman_spool_own_preset(mapped["id"])
  2381. changed = True
  2382. except HTTPException:
  2383. logger.warning("Profile default: preset not written to Spoolman spool %s", mapped["id"])
  2384. if changed:
  2385. updated += 1
  2386. await db.commit()
  2387. if updated:
  2388. await ws_manager.broadcast({"type": "inventory_changed"})
  2389. return {"updated": updated}
  2390. @router.get("/spools/{spool_id}/k-profiles")
  2391. async def get_spoolman_k_profiles(
  2392. spool_id: int = Path(..., gt=0),
  2393. db: AsyncSession = Depends(get_db, scope="function"),
  2394. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2395. ) -> list[dict]:
  2396. """Return all local K-value calibration profiles for a Spoolman spool."""
  2397. await _get_client(db)
  2398. result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
  2399. profiles = result.scalars().all()
  2400. return [_k_profile_to_dict(p) for p in profiles]
  2401. @router.put("/spools/{spool_id}/k-profiles")
  2402. async def save_spoolman_k_profiles(
  2403. spool_id: int = Path(..., gt=0),
  2404. profiles: list[SpoolKProfileBase] = Body(...),
  2405. db: AsyncSession = Depends(get_db, scope="function"),
  2406. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2407. printer_scope: PrinterScope = RequestPrinterScope,
  2408. ) -> list[dict]:
  2409. """Replace all K-value calibration profiles for a Spoolman spool.
  2410. As the internal route: a caller limited to some printers (#1727) replaces
  2411. only theirs, and the other printers' profiles stay.
  2412. """
  2413. client = await _get_client(db)
  2414. async with _translate_spoolman_errors():
  2415. await client.get_spool(spool_id)
  2416. saved: list[SpoolmanKProfile] = []
  2417. try:
  2418. stmt = delete(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id)
  2419. if (in_scope := printer_scope.where_strict(SpoolmanKProfile.printer_id)) is not None:
  2420. stmt = stmt.where(in_scope)
  2421. await db.execute(stmt)
  2422. for profile in profiles:
  2423. if not printer_scope.allows(profile.printer_id):
  2424. continue
  2425. obj = SpoolmanKProfile(
  2426. spoolman_spool_id=spool_id,
  2427. printer_id=profile.printer_id,
  2428. extruder=profile.extruder,
  2429. nozzle_diameter=profile.nozzle_diameter,
  2430. nozzle_type=profile.nozzle_type,
  2431. k_value=profile.k_value,
  2432. name=profile.name,
  2433. cali_idx=profile.cali_idx,
  2434. setting_id=profile.setting_id,
  2435. )
  2436. db.add(obj)
  2437. saved.append(obj)
  2438. await db.commit()
  2439. except IntegrityError as exc:
  2440. await db.rollback()
  2441. raise HTTPException(422, "Duplicate or invalid K-profile (check printer_id and nozzle uniqueness)") from exc
  2442. except Exception as exc:
  2443. await db.rollback()
  2444. logger.error("K-profile save for spool %d failed: %s", spool_id, exc)
  2445. raise HTTPException(500, "Failed to save K-profiles") from exc
  2446. for obj in saved:
  2447. await db.refresh(obj)
  2448. return [_k_profile_to_dict(p) for p in saved]
  2449. def _supplier_link_to_dict(link: SpoolmanSpoolSupplier) -> dict:
  2450. """Same shape as ``SpoolSupplierResponse`` so the frontend renders both
  2451. inventories with one component."""
  2452. return {
  2453. "id": link.id,
  2454. "supplier_id": link.supplier_id,
  2455. "supplier_name": link.supplier_name,
  2456. "supplier_article_number": link.supplier_article_number,
  2457. "quoted_price_per_kg": link.quoted_price_per_kg,
  2458. "is_purchase_source": link.is_purchase_source,
  2459. }
  2460. @router.get("/spools/{spool_id}/suppliers")
  2461. async def get_spoolman_spool_suppliers(
  2462. spool_id: int = Path(..., gt=0),
  2463. db: AsyncSession = Depends(get_db, scope="function"),
  2464. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2465. ) -> list[dict]:
  2466. """Supplier assignments for a Spoolman spool (#2988, Bambuddy-side rows)."""
  2467. await _get_client(db)
  2468. result = await db.execute(
  2469. select(SpoolmanSpoolSupplier)
  2470. .options(selectinload(SpoolmanSpoolSupplier.supplier))
  2471. .where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id)
  2472. )
  2473. return [_supplier_link_to_dict(link) for link in result.scalars().all()]
  2474. @router.put("/spools/{spool_id}/suppliers")
  2475. async def save_spoolman_spool_suppliers(
  2476. spool_id: int = Path(..., gt=0),
  2477. links: list[SpoolSupplierLinkInput] = Body(...),
  2478. db: AsyncSession = Depends(get_db, scope="function"),
  2479. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2480. ) -> list[dict]:
  2481. """Replace a Spoolman spool's supplier assignments (#2988).
  2482. Mirror of the built-in inventory's replace-all endpoint — Spoolman owns
  2483. the spool, Bambuddy owns the assignment (``SpoolmanKProfile`` precedent),
  2484. so the rows are local and the spool is only verified to exist remotely.
  2485. """
  2486. client = await _get_client(db)
  2487. async with _translate_spoolman_errors():
  2488. await client.get_spool(spool_id)
  2489. supplier_ids = [link.supplier_id for link in links]
  2490. if len(set(supplier_ids)) != len(supplier_ids):
  2491. raise HTTPException(400, "Duplicate supplier in assignment list")
  2492. if sum(1 for link in links if link.is_purchase_source) > 1:
  2493. raise HTTPException(400, "Only one assignment can be the purchase source")
  2494. if supplier_ids:
  2495. found = await db.execute(select(Supplier.id).where(Supplier.id.in_(supplier_ids)))
  2496. missing = set(supplier_ids) - {row[0] for row in found.all()}
  2497. if missing:
  2498. raise HTTPException(404, f"Supplier(s) not found: {sorted(missing)}")
  2499. await db.execute(delete(SpoolmanSpoolSupplier).where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id))
  2500. saved: list[SpoolmanSpoolSupplier] = []
  2501. for link in links:
  2502. row = SpoolmanSpoolSupplier(spoolman_spool_id=spool_id, **link.model_dump())
  2503. db.add(row)
  2504. saved.append(row)
  2505. await db.commit()
  2506. refreshed = await db.execute(
  2507. select(SpoolmanSpoolSupplier)
  2508. .options(selectinload(SpoolmanSpoolSupplier.supplier))
  2509. .where(SpoolmanSpoolSupplier.id.in_([row.id for row in saved]))
  2510. .order_by(SpoolmanSpoolSupplier.id)
  2511. )
  2512. await ws_manager.broadcast({"type": "inventory_changed"})
  2513. return [_supplier_link_to_dict(link) for link in refreshed.scalars().all()]