inventory.py 79 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098
  1. import json
  2. import logging
  3. import httpx
  4. from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
  5. from fastapi.responses import Response, StreamingResponse
  6. from pydantic import BaseModel, Field, field_validator
  7. from sqlalchemy import delete, func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy.orm import selectinload
  10. from backend.app.core.auth import (
  11. RequireAnyPermissionIfAuthEnabled,
  12. RequirePermissionIfAuthEnabled,
  13. require_auth_if_enabled,
  14. )
  15. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.websocket import ws_manager
  19. from backend.app.models.ams_label import AmsLabel
  20. from backend.app.models.color_catalog import ColorCatalogEntry
  21. from backend.app.models.spool import Spool
  22. from backend.app.models.spool_assignment import SpoolAssignment
  23. from backend.app.models.spool_catalog import SpoolCatalogEntry
  24. from backend.app.models.spool_k_profile import SpoolKProfile
  25. from backend.app.models.user import User
  26. from backend.app.schemas.spool import (
  27. SpoolAssignmentCreate,
  28. SpoolAssignmentResponse,
  29. SpoolBulkCreate,
  30. SpoolCreate,
  31. SpoolKProfileBase,
  32. SpoolKProfileResponse,
  33. SpoolResponse,
  34. SpoolUpdate,
  35. normalize_effect_type,
  36. normalize_extra_colors,
  37. )
  38. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  39. from backend.app.services.spool_csv import (
  40. MAX_CSV_IMPORT_BYTES,
  41. ImportPreview,
  42. ImportResult,
  43. parse_and_validate,
  44. serialize,
  45. )
  46. from backend.app.utils.filament_ids import (
  47. GENERIC_FILAMENT_IDS,
  48. MATERIAL_TEMPS,
  49. filament_id_to_setting_id,
  50. normalize_slicer_filament,
  51. )
  52. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  53. logger = logging.getLogger(__name__)
  54. _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
  55. router = APIRouter(prefix="/inventory", tags=["inventory"])
  56. # Bounded read size for the CSV import body so a chunked upload with no
  57. # Content-Length can't stream past the cap into memory before we notice.
  58. _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
  59. # FilamentColors.xyz API
  60. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  61. # Generic Bambu filament IDs by material — fallback when no specific
  62. # preset is resolvable. Keep aligned with the inline table in
  63. # apply_spool_to_slot_via_mqtt below; both paths must produce the same
  64. # value for a given material.
  65. _GENERIC_FILAMENT_IDS: dict[str, str] = {
  66. "PLA": "GFL99",
  67. "PETG": "GFG99",
  68. "ABS": "GFB99",
  69. "ASA": "GFB98",
  70. "PC": "GFC99",
  71. "PA": "GFN99",
  72. "NYLON": "GFN99",
  73. "TPU": "GFU99",
  74. "PVA": "GFS99",
  75. "HIPS": "GFS98",
  76. "PLA-CF": "GFL98",
  77. "PETG-CF": "GFG98",
  78. "PA-CF": "GFN98",
  79. "PETG HF": "GFG96",
  80. }
  81. async def apply_spool_to_slot_via_mqtt(
  82. *,
  83. db: AsyncSession,
  84. current_user: User | None,
  85. spool: Spool,
  86. printer_id: int,
  87. ams_id: int,
  88. tray_id: int,
  89. current_tray_info_idx: str = "",
  90. current_tray_type: str = "",
  91. ) -> bool:
  92. """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
  93. Shared by `assign_spool` (initial assign for a loaded slot) and
  94. `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
  95. empty → loaded). Returns True when MQTT commands were published, False if
  96. no client was available or setup failed mid-way.
  97. `current_tray_info_idx` / `current_tray_type` describe the live tray state
  98. used as fallback hints when the spool's slicer_filament can't be resolved.
  99. Caller should not pass these for the empty-slot re-fire path (they'll be
  100. the freshly-loaded values, which is the intended fallback).
  101. """
  102. from backend.app.services.printer_manager import printer_manager
  103. client = printer_manager.get_client(printer_id)
  104. if client is None:
  105. return False
  106. state = printer_manager.get_status(printer_id)
  107. tray_type = spool.material
  108. tray_sub_brands = (
  109. f"{spool.brand} {spool.material} {spool.subtype}".strip()
  110. if spool.brand
  111. else f"{spool.material} {spool.subtype}"
  112. if spool.subtype
  113. else spool.material
  114. )
  115. tray_color = spool.rgba or "FFFFFFFF"
  116. _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
  117. tray_info_idx = ""
  118. setting_id = ""
  119. sf = spool.slicer_filament or ""
  120. if sf:
  121. base_sf = sf.split("_")[0] if "_" in sf else sf
  122. # Cloud-side preset IDs in three known shapes:
  123. # GFS… — Bambu official cloud preset
  124. # PFUS… — cloud user-created preset
  125. # PFCN… — cloud shared / partner preset (e.g. Polymaker's
  126. # "(Custom)" Bambu Lab H2D variant, #1648)
  127. # All three need a cloud-detail lookup to extract the underlying
  128. # filament_id; without it the raw cloud id ends up in tray_info_idx
  129. # and the printer's calibration table can't resolve it.
  130. if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
  131. setting_id = base_sf
  132. try:
  133. from backend.app.api.routes.cloud import build_authenticated_cloud
  134. cloud = await build_authenticated_cloud(db, current_user)
  135. if cloud is not None and cloud.is_authenticated:
  136. try:
  137. detail = await cloud.get_setting_detail(base_sf)
  138. if detail.get("filament_id"):
  139. tray_info_idx = detail["filament_id"]
  140. cloud_name = detail.get("name", "")
  141. if cloud_name:
  142. tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  143. elif detail.get("base_id"):
  144. bid = detail["base_id"].split("_")[0]
  145. if bid.startswith("GFS") and len(bid) >= 5:
  146. tray_info_idx = f"GF{bid[3:]}"
  147. else:
  148. tray_info_idx = bid
  149. finally:
  150. await cloud.close()
  151. elif cloud is not None:
  152. await cloud.close()
  153. except Exception as e:
  154. logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
  155. if not tray_info_idx:
  156. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  157. elif base_sf.startswith("GF"):
  158. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  159. else:
  160. try:
  161. local_id = int(sf)
  162. from backend.app.models.local_preset import LocalPreset as LP
  163. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  164. lp = lp_result.scalar_one_or_none()
  165. if lp:
  166. # Local preset's setting JSON carries the printer-recognized
  167. # filament_id (e.g. "P4d64437") — use that directly so the
  168. # slicer can resolve the specific preset. Falls through to
  169. # generic material id only when the JSON doesn't carry one.
  170. lp_filament_id = ""
  171. if lp.setting:
  172. try:
  173. setting_data = json.loads(lp.setting)
  174. raw_fid = setting_data.get("filament_id")
  175. if isinstance(raw_fid, str) and raw_fid:
  176. lp_filament_id = raw_fid
  177. except (json.JSONDecodeError, AttributeError):
  178. pass
  179. if lp_filament_id:
  180. tray_info_idx = lp_filament_id
  181. setting_id = filament_id_to_setting_id(lp_filament_id)
  182. else:
  183. mat = (spool.material or lp.filament_type or "").upper().strip()
  184. tray_info_idx = (
  185. _GENERIC_FILAMENT_IDS.get(mat)
  186. or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
  187. or ""
  188. )
  189. if lp.name:
  190. tray_sub_brands = lp.name.split("@")[0].strip()
  191. except (ValueError, TypeError):
  192. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  193. if tray_info_idx and spool.slicer_filament_name:
  194. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  195. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  196. if expected_name and expected_name != spool.slicer_filament_name:
  197. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  198. if fname == spool.slicer_filament_name:
  199. tray_info_idx = fid
  200. setting_id = filament_id_to_setting_id(fid)
  201. break
  202. # Defend against tray_info_idx values the slicer cannot resolve. Three
  203. # shapes leak through and must be discarded so the generic-material
  204. # fallback below can rescue the slot:
  205. # 1. Literal material names ("PLA", "PETG-CF") that pass through
  206. # normalize_slicer_filament unchanged when the spool's slicer_filament
  207. # is free-text rather than a real preset ID.
  208. # 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
  209. # by the slicer as tray_info_idx (the printer's calibration table
  210. # indexes by filament_id, and a PFUS isn't one). This normally gets
  211. # realigned to a P-prefix local id via printer_kp lookup, but the
  212. # replay path in main.py.on_ams_change passes current_user=None,
  213. # which skips cloud auth and leaves the raw PFUS in tray_info_idx —
  214. # overwriting the correctly-configured slot from the original assign.
  215. # 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
  216. # "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
  217. # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
  218. # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
  219. _known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
  220. if tray_info_idx and (
  221. tray_info_idx.upper() in _known_materials
  222. or tray_info_idx.startswith("PFUS")
  223. or tray_info_idx.startswith("PFCN")
  224. ):
  225. tray_info_idx = ""
  226. setting_id = ""
  227. if not tray_info_idx:
  228. if (
  229. current_tray_info_idx
  230. and current_tray_info_idx not in _generic_id_values
  231. and not current_tray_info_idx.startswith("PFUS")
  232. and not current_tray_info_idx.startswith("PFCN")
  233. and current_tray_info_idx.upper() not in _known_materials
  234. and current_tray_type
  235. and current_tray_type.upper() == tray_type.upper()
  236. ):
  237. tray_info_idx = current_tray_info_idx
  238. elif tray_type:
  239. material = tray_type.upper().strip()
  240. generic = (
  241. _GENERIC_FILAMENT_IDS.get(material)
  242. or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  243. or ""
  244. )
  245. if generic:
  246. tray_info_idx = generic
  247. # Ensure setting_id is always derivable from tray_info_idx. The local-preset
  248. # path above sets tray_info_idx to a generic ID (e.g. "GFL99") but leaves
  249. # setting_id empty — without this fallback the slicer gets a half-configured
  250. # slot (filament id without setting id) and shows empty fields in the slot
  251. # detail modal.
  252. if tray_info_idx and not setting_id:
  253. setting_id = filament_id_to_setting_id(tray_info_idx)
  254. temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
  255. if spool.nozzle_temp_min is not None:
  256. temp_min = spool.nozzle_temp_min
  257. if spool.nozzle_temp_max is not None:
  258. temp_max = spool.nozzle_temp_max
  259. nozzle_diameter = "0.4"
  260. if state and state.nozzles:
  261. nd = state.nozzles[0].nozzle_diameter
  262. if nd:
  263. nozzle_diameter = nd
  264. slot_extruder = None
  265. if state and state.ams_extruder_map:
  266. if ams_id == 255:
  267. slot_extruder = 1 - tray_id # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  268. else:
  269. slot_extruder = state.ams_extruder_map.get(str(ams_id))
  270. # Prefer exact extruder match, fall back to extruder-agnostic kp for the
  271. # same nozzle. Hard-skipping on mismatch silently drops valid stored
  272. # profiles when the AMS-extruder mapping has shifted.
  273. exact_kp = None
  274. fallback_kp = None
  275. for kp in spool.k_profiles:
  276. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
  277. continue
  278. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  279. exact_kp = kp
  280. break
  281. if fallback_kp is None:
  282. fallback_kp = kp
  283. matching_kp = exact_kp or fallback_kp
  284. # Resolve the printer-side calibration entry by looking up the cali_idx
  285. # in state.kprofiles. The printer keys its calibration table by
  286. # (filament_id, cali_idx) — for the cali_idx to stick, the slot's
  287. # filament_id must match the kp's. PFUS-prefix cloud user presets are
  288. # rejected by the slicer in tray_info_idx; the printer-reported
  289. # filament_id is typically a P-prefix local preset which is valid.
  290. printer_kp = None
  291. if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
  292. for pkp in state.kprofiles:
  293. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  294. printer_kp = pkp
  295. break
  296. effective_tray_info_idx = tray_info_idx
  297. effective_setting_id = setting_id
  298. if printer_kp and printer_kp.filament_id:
  299. effective_tray_info_idx = printer_kp.filament_id
  300. target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
  301. matching_kp.setting_id if matching_kp else None
  302. )
  303. if target_setting_id:
  304. effective_setting_id = target_setting_id
  305. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  306. logger.info(
  307. "Spool assign: realigning tray_info_idx %r → %r, setting_id %r → %r (source=%s)",
  308. tray_info_idx,
  309. effective_tray_info_idx,
  310. setting_id,
  311. effective_setting_id,
  312. "printer" if printer_kp else "stored",
  313. )
  314. client.ams_set_filament_setting(
  315. ams_id=ams_id,
  316. tray_id=tray_id,
  317. tray_info_idx=effective_tray_info_idx,
  318. tray_type=tray_type,
  319. tray_sub_brands=tray_sub_brands,
  320. tray_color=tray_color,
  321. nozzle_temp_min=temp_min,
  322. nozzle_temp_max=temp_max,
  323. setting_id=effective_setting_id,
  324. )
  325. if matching_kp and matching_kp.cali_idx is not None:
  326. # filament_id for cali_sel must match the preset under which the kp
  327. # was registered. Priority: live printer kp > stored kp.setting_id >
  328. # spool.slicer_filament > realigned tray_info_idx.
  329. if printer_kp and printer_kp.filament_id:
  330. cali_filament_id = printer_kp.filament_id
  331. elif matching_kp.setting_id:
  332. cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
  333. else:
  334. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  335. client.extrusion_cali_sel(
  336. ams_id=ams_id,
  337. tray_id=tray_id,
  338. cali_idx=matching_kp.cali_idx,
  339. filament_id=cali_filament_id,
  340. nozzle_diameter=nozzle_diameter,
  341. )
  342. else:
  343. # No stored K-profile for this spool — always reset the slot to Default
  344. # K (cali_idx=-1). The live cali_idx on the slot belongs to whatever
  345. # filament was there before, so preserving it would apply the wrong
  346. # filament's calibration to the new spool. Default K is the firmware's
  347. # documented "no specific profile" value (see BambuClient.extrusion_cali_sel
  348. # docstring).
  349. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  350. client.extrusion_cali_sel(
  351. ams_id=ams_id,
  352. tray_id=tray_id,
  353. cali_idx=-1,
  354. filament_id=cali_filament_id,
  355. nozzle_diameter=nozzle_diameter,
  356. )
  357. logger.info(
  358. "No stored K-profile for spool %d — reset slot to Default K (cali_idx=-1)",
  359. spool.id,
  360. )
  361. # Persist slot preset mapping for UI display (preset_name on hover card).
  362. try:
  363. from backend.app.models.slot_preset import SlotPresetMapping
  364. preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
  365. preset_source = "cloud"
  366. if sf:
  367. base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
  368. try:
  369. int(base_sf_mapping)
  370. preset_id_to_save = f"local_{base_sf_mapping}"
  371. preset_source = "local"
  372. except (ValueError, TypeError):
  373. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
  374. else:
  375. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
  376. if preset_id_to_save:
  377. existing_mapping = await db.execute(
  378. select(SlotPresetMapping).where(
  379. SlotPresetMapping.printer_id == printer_id,
  380. SlotPresetMapping.ams_id == ams_id,
  381. SlotPresetMapping.tray_id == tray_id,
  382. )
  383. )
  384. mapping = existing_mapping.scalar_one_or_none()
  385. if mapping:
  386. mapping.preset_id = preset_id_to_save
  387. mapping.preset_name = preset_name
  388. mapping.preset_source = preset_source
  389. else:
  390. mapping = SlotPresetMapping(
  391. printer_id=printer_id,
  392. ams_id=ams_id,
  393. tray_id=tray_id,
  394. preset_id=preset_id_to_save,
  395. preset_name=preset_name,
  396. preset_source=preset_source,
  397. )
  398. db.add(mapping)
  399. await db.commit()
  400. except Exception as e:
  401. logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
  402. logger.info(
  403. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  404. ams_id,
  405. tray_id,
  406. spool.id,
  407. printer_id,
  408. )
  409. return True
  410. # ── Spool Catalog Schemas ──────────────────────────────────────────────────
  411. class CatalogEntryResponse(BaseModel):
  412. id: int
  413. name: str
  414. weight: int
  415. is_default: bool
  416. class Config:
  417. from_attributes = True
  418. class CatalogEntryCreate(BaseModel):
  419. name: str
  420. weight: int
  421. class CatalogEntryUpdate(BaseModel):
  422. name: str
  423. weight: int
  424. class BulkDeleteIdsRequest(BaseModel):
  425. ids: list[int]
  426. # ── Color Catalog Schemas ──────────────────────────────────────────────────
  427. class ColorEntryResponse(BaseModel):
  428. id: int
  429. manufacturer: str
  430. color_name: str
  431. hex_color: str
  432. material: str | None
  433. is_default: bool
  434. extra_colors: str | None = None
  435. effect_type: str | None = None
  436. class Config:
  437. from_attributes = True
  438. _HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
  439. class ColorEntryCreate(BaseModel):
  440. manufacturer: str
  441. color_name: str
  442. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  443. material: str | None = None
  444. extra_colors: str | None = None
  445. effect_type: str | None = None
  446. @field_validator("extra_colors")
  447. @classmethod
  448. def _validate_extra_colors(cls, v: str | None) -> str | None:
  449. return normalize_extra_colors(v)
  450. @field_validator("effect_type")
  451. @classmethod
  452. def _validate_effect_type(cls, v: str | None) -> str | None:
  453. return normalize_effect_type(v)
  454. class ColorEntryUpdate(BaseModel):
  455. manufacturer: str
  456. color_name: str
  457. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  458. material: str | None = None
  459. extra_colors: str | None = None
  460. effect_type: str | None = None
  461. @field_validator("extra_colors")
  462. @classmethod
  463. def _validate_extra_colors(cls, v: str | None) -> str | None:
  464. return normalize_extra_colors(v)
  465. @field_validator("effect_type")
  466. @classmethod
  467. def _validate_effect_type(cls, v: str | None) -> str | None:
  468. return normalize_effect_type(v)
  469. class ColorLookupResult(BaseModel):
  470. found: bool
  471. hex_color: str | None = None
  472. material: str | None = None
  473. # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
  474. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  475. async def get_spool_catalog(
  476. db: AsyncSession = Depends(get_db),
  477. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  478. ):
  479. """Get all spool catalog entries."""
  480. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  481. return list(result.scalars().all())
  482. @router.post("/catalog", response_model=CatalogEntryResponse)
  483. async def add_catalog_entry(
  484. entry: CatalogEntryCreate,
  485. db: AsyncSession = Depends(get_db),
  486. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  487. ):
  488. """Add a new spool catalog entry."""
  489. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  490. db.add(row)
  491. await db.commit()
  492. await db.refresh(row)
  493. return row
  494. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  495. async def update_catalog_entry(
  496. entry_id: int,
  497. entry: CatalogEntryUpdate,
  498. db: AsyncSession = Depends(get_db),
  499. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  500. ):
  501. """Update a spool catalog entry."""
  502. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  503. row = result.scalar_one_or_none()
  504. if not row:
  505. raise HTTPException(404, "Entry not found")
  506. row.name = entry.name
  507. row.weight = entry.weight
  508. await db.commit()
  509. await db.refresh(row)
  510. return row
  511. @router.delete("/catalog/{entry_id}")
  512. async def delete_catalog_entry(
  513. entry_id: int,
  514. db: AsyncSession = Depends(get_db),
  515. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  516. ):
  517. """Delete a spool catalog entry."""
  518. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  519. row = result.scalar_one_or_none()
  520. if not row:
  521. raise HTTPException(404, "Entry not found")
  522. await db.delete(row)
  523. await db.commit()
  524. return {"status": "deleted"}
  525. @router.post("/catalog/bulk-delete")
  526. async def bulk_delete_catalog_entries(
  527. data: BulkDeleteIdsRequest,
  528. db: AsyncSession = Depends(get_db),
  529. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  530. ):
  531. """Delete multiple spool catalog entries by ID."""
  532. if not data.ids:
  533. return {"deleted": 0}
  534. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
  535. rows = result.scalars().all()
  536. for row in rows:
  537. await db.delete(row)
  538. await db.commit()
  539. return {"deleted": len(rows)}
  540. @router.post("/catalog/reset")
  541. async def reset_spool_catalog(
  542. db: AsyncSession = Depends(get_db),
  543. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  544. ):
  545. """Reset spool catalog to defaults."""
  546. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  547. # Delete all
  548. result = await db.execute(select(SpoolCatalogEntry))
  549. for row in result.scalars().all():
  550. await db.delete(row)
  551. # Re-seed defaults
  552. for name, weight in DEFAULT_SPOOL_CATALOG:
  553. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  554. await db.commit()
  555. return {"status": "reset"}
  556. # ── Color Catalog CRUD ─────────────────────────────────────────────────────
  557. @router.get("/colors", response_model=list[ColorEntryResponse])
  558. async def get_color_catalog(
  559. db: AsyncSession = Depends(get_db),
  560. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  561. ):
  562. """Get all color catalog entries."""
  563. result = await db.execute(
  564. select(ColorCatalogEntry).order_by(
  565. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  566. )
  567. )
  568. return list(result.scalars().all())
  569. @router.get("/colors/map")
  570. async def get_color_name_map(
  571. db: AsyncSession = Depends(get_db),
  572. _: User | None = Depends(require_auth_if_enabled),
  573. ):
  574. """Compact {hex: name} map for frontend color-name resolution.
  575. Not gated on INVENTORY_READ — every page that renders a spool color needs
  576. this, including read-only views available to users without inventory access.
  577. Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
  578. share the same hex (different materials or manufacturers), Bambu Lab wins,
  579. then default entries, then the first encountered.
  580. """
  581. result = await db.execute(
  582. select(
  583. ColorCatalogEntry.hex_color,
  584. ColorCatalogEntry.color_name,
  585. ColorCatalogEntry.manufacturer,
  586. ColorCatalogEntry.is_default,
  587. )
  588. )
  589. mapping: dict[str, tuple[str, int]] = {} # hex → (name, priority); higher priority wins
  590. for hex_color, color_name, manufacturer, is_default in result.all():
  591. if not hex_color or not color_name:
  592. continue
  593. key = hex_color.lstrip("#").lower()[:6]
  594. if len(key) != 6:
  595. continue
  596. priority = 0
  597. if manufacturer and manufacturer.strip().lower() == "bambu lab":
  598. priority += 2
  599. if is_default:
  600. priority += 1
  601. existing = mapping.get(key)
  602. if existing is None or priority > existing[1]:
  603. mapping[key] = (color_name, priority)
  604. return {"colors": {k: v[0] for k, v in mapping.items()}}
  605. @router.post("/colors", response_model=ColorEntryResponse)
  606. async def add_color_entry(
  607. entry: ColorEntryCreate,
  608. db: AsyncSession = Depends(get_db),
  609. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  610. ):
  611. """Add a new color catalog entry."""
  612. row = ColorCatalogEntry(
  613. manufacturer=entry.manufacturer,
  614. color_name=entry.color_name,
  615. hex_color=entry.hex_color,
  616. material=entry.material,
  617. is_default=False,
  618. extra_colors=entry.extra_colors,
  619. effect_type=entry.effect_type,
  620. )
  621. db.add(row)
  622. await db.commit()
  623. await db.refresh(row)
  624. return row
  625. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  626. async def update_color_entry(
  627. entry_id: int,
  628. entry: ColorEntryUpdate,
  629. db: AsyncSession = Depends(get_db),
  630. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  631. ):
  632. """Update a color catalog entry."""
  633. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  634. row = result.scalar_one_or_none()
  635. if not row:
  636. raise HTTPException(404, "Entry not found")
  637. row.manufacturer = entry.manufacturer
  638. row.color_name = entry.color_name
  639. row.hex_color = entry.hex_color
  640. row.material = entry.material
  641. row.extra_colors = entry.extra_colors
  642. row.effect_type = entry.effect_type
  643. await db.commit()
  644. await db.refresh(row)
  645. return row
  646. @router.delete("/colors/{entry_id}")
  647. async def delete_color_entry(
  648. entry_id: int,
  649. db: AsyncSession = Depends(get_db),
  650. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  651. ):
  652. """Delete a color catalog entry."""
  653. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  654. row = result.scalar_one_or_none()
  655. if not row:
  656. raise HTTPException(404, "Entry not found")
  657. await db.delete(row)
  658. await db.commit()
  659. return {"status": "deleted"}
  660. @router.post("/colors/bulk-delete")
  661. async def bulk_delete_color_entries(
  662. data: BulkDeleteIdsRequest,
  663. db: AsyncSession = Depends(get_db),
  664. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  665. ):
  666. """Delete multiple color catalog entries by ID."""
  667. if not data.ids:
  668. return {"deleted": 0}
  669. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
  670. rows = result.scalars().all()
  671. for row in rows:
  672. await db.delete(row)
  673. await db.commit()
  674. return {"deleted": len(rows)}
  675. @router.post("/colors/reset")
  676. async def reset_color_catalog(
  677. db: AsyncSession = Depends(get_db),
  678. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  679. ):
  680. """Reset color catalog to defaults."""
  681. result = await db.execute(select(ColorCatalogEntry))
  682. for row in result.scalars().all():
  683. await db.delete(row)
  684. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  685. db.add(
  686. ColorCatalogEntry(
  687. manufacturer=manufacturer,
  688. color_name=color_name,
  689. hex_color=hex_color,
  690. material=material,
  691. is_default=True,
  692. )
  693. )
  694. await db.commit()
  695. return {"status": "reset"}
  696. @router.get("/colors/lookup", response_model=ColorLookupResult)
  697. async def lookup_color(
  698. manufacturer: str,
  699. color_name: str,
  700. material: str | None = None,
  701. db: AsyncSession = Depends(get_db),
  702. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  703. ):
  704. """Look up a color by manufacturer and color name."""
  705. query = select(ColorCatalogEntry).where(
  706. ColorCatalogEntry.manufacturer == manufacturer,
  707. ColorCatalogEntry.color_name == color_name,
  708. )
  709. if material:
  710. query = query.where(ColorCatalogEntry.material == material)
  711. query = query.limit(1)
  712. result = await db.execute(query)
  713. row = result.scalar_one_or_none()
  714. if row:
  715. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  716. return ColorLookupResult(found=False)
  717. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  718. async def search_colors(
  719. manufacturer: str | None = None,
  720. material: str | None = None,
  721. db: AsyncSession = Depends(get_db),
  722. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  723. ):
  724. """Search colors by manufacturer and/or material."""
  725. query = select(ColorCatalogEntry)
  726. if manufacturer:
  727. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  728. if material:
  729. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  730. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  731. result = await db.execute(query)
  732. return list(result.scalars().all())
  733. @router.post("/colors/sync")
  734. async def sync_from_filamentcolors(
  735. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  736. ):
  737. """Sync colors from FilamentColors.xyz API with progress streaming."""
  738. async def generate():
  739. from backend.app.core.database import async_session
  740. added = 0
  741. skipped = 0
  742. total_fetched = 0
  743. total_available = 0
  744. try:
  745. # Identify honestly as Bambuddy rather than leaking httpx's
  746. # default "python-httpx/x.y" UA — consistent with every other
  747. # outbound client (bambu_cloud, makerworld, firmware_check).
  748. async with httpx.AsyncClient(
  749. timeout=120.0,
  750. headers={"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"},
  751. ) as client:
  752. page = 1
  753. while True:
  754. response = await client.get(
  755. f"{FILAMENT_COLORS_API}/swatch/",
  756. params={"page": page},
  757. )
  758. response.raise_for_status()
  759. data = response.json()
  760. total_available = data.get("count", total_available)
  761. results = data.get("results", [])
  762. if not results:
  763. break
  764. async with async_session() as db:
  765. for swatch in results:
  766. total_fetched += 1
  767. manufacturer_data = swatch.get("manufacturer")
  768. manufacturer_name = (
  769. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  770. )
  771. filament_type_data = swatch.get("filament_type")
  772. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  773. color_name_val = swatch.get("color_name", "")
  774. hex_color_val = swatch.get("hex_color", "")
  775. if not manufacturer_name or not color_name_val or not hex_color_val:
  776. skipped += 1
  777. continue
  778. if not hex_color_val.startswith("#"):
  779. hex_color_val = f"#{hex_color_val}"
  780. # Check if entry already exists
  781. existing = await db.execute(
  782. select(ColorCatalogEntry)
  783. .where(
  784. ColorCatalogEntry.manufacturer == manufacturer_name,
  785. ColorCatalogEntry.color_name == color_name_val,
  786. ColorCatalogEntry.material == mat,
  787. )
  788. .limit(1)
  789. )
  790. if existing.scalar_one_or_none():
  791. skipped += 1
  792. else:
  793. db.add(
  794. ColorCatalogEntry(
  795. manufacturer=manufacturer_name,
  796. color_name=color_name_val,
  797. hex_color=hex_color_val.upper(),
  798. material=mat,
  799. is_default=False,
  800. )
  801. )
  802. added += 1
  803. await db.commit()
  804. progress = {
  805. "type": "progress",
  806. "added": added,
  807. "skipped": skipped,
  808. "total_fetched": total_fetched,
  809. "total_available": total_available,
  810. }
  811. yield f"data: {json.dumps(progress)}\n\n"
  812. if not data.get("next") or total_fetched >= total_available:
  813. break
  814. page += 1
  815. result = {
  816. "type": "complete",
  817. "added": added,
  818. "skipped": skipped,
  819. "total_fetched": total_fetched,
  820. "total_available": total_available,
  821. }
  822. yield f"data: {json.dumps(result)}\n\n"
  823. except httpx.HTTPError as e:
  824. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  825. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  826. except Exception as e:
  827. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  828. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  829. return StreamingResponse(generate(), media_type="text/event-stream")
  830. # ── Spool CRUD ───────────────────────────────────────────────────────────────
  831. @router.get("/spools", response_model=list[SpoolResponse])
  832. async def list_spools(
  833. include_archived: bool = False,
  834. db: AsyncSession = Depends(get_db),
  835. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  836. ):
  837. """List all spools, excluding archived by default."""
  838. query = select(Spool).options(selectinload(Spool.k_profiles))
  839. if not include_archived:
  840. query = query.where(Spool.archived_at.is_(None))
  841. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  842. result = await db.execute(query)
  843. return list(result.scalars().all())
  844. # ── CSV import / export (#1576) ──────────────────────────────────────────────
  845. # Declared before the dynamic `/spools/{spool_id}` route below so the literal
  846. # `export` / `import` segments match here instead of being parsed as an int id.
  847. @router.get("/spools/export")
  848. async def export_spools_csv(
  849. db: AsyncSession = Depends(get_db),
  850. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  851. ):
  852. """Export the active inventory as CSV (same schema the importer accepts)."""
  853. from datetime import datetime, timezone
  854. query = select(Spool).where(Spool.archived_at.is_(None)).order_by(Spool.material, Spool.brand, Spool.color_name)
  855. result = await db.execute(query)
  856. spools = list(result.scalars().all())
  857. content = serialize(spools)
  858. # Date-stamp the filename so repeat exports don't overwrite each other in
  859. # the browser's default download folder.
  860. filename = f"bambuddy_inventory_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
  861. return Response(
  862. content=content,
  863. media_type="text/csv",
  864. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  865. )
  866. @router.post("/spools/import", response_model=ImportPreview | ImportResult)
  867. async def import_spools_csv(
  868. file: UploadFile = File(...),
  869. dry_run: bool = Query(False),
  870. db: AsyncSession = Depends(get_db),
  871. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  872. ):
  873. """Import spools from a CSV file.
  874. With ``dry_run=true`` returns an ImportPreview (per-row valid/error/skipped,
  875. colours resolved) and writes nothing — the UI shows this before the user
  876. confirms. With ``dry_run=false`` it validates the same way and then persists
  877. only the valid rows in a single transaction (invalid rows are skipped, the
  878. user fixes the CSV and re-uploads), returning an ImportResult summary.
  879. """
  880. def _too_large() -> HTTPException:
  881. return HTTPException(
  882. status_code=413,
  883. detail={
  884. "code": "csv_import_too_large",
  885. "message": f"CSV file exceeds the {MAX_CSV_IMPORT_BYTES // (1024 * 1024)} MB limit.",
  886. },
  887. )
  888. # Reject by declared size first (fast path when Content-Length is set), then
  889. # read in bounded chunks and bail the moment the accumulated body crosses the
  890. # cap — file.size is None for chunked uploads, so the loop is what actually
  891. # keeps an oversized stream from filling memory.
  892. if file.size is not None and file.size > MAX_CSV_IMPORT_BYTES:
  893. raise _too_large()
  894. raw = bytearray()
  895. while chunk := await file.read(_CSV_UPLOAD_CHUNK_BYTES):
  896. raw.extend(chunk)
  897. if len(raw) > MAX_CSV_IMPORT_BYTES:
  898. raise _too_large()
  899. preview = await parse_and_validate(bytes(raw), db)
  900. if dry_run:
  901. return preview
  902. created = 0
  903. for row in preview.rows:
  904. if row.status == "valid" and row.spool is not None:
  905. db.add(Spool(**row.spool))
  906. created += 1
  907. if created:
  908. await db.commit()
  909. await ws_manager.broadcast({"type": "inventory_changed"})
  910. return ImportResult(
  911. created=created,
  912. skipped=preview.skipped_count,
  913. errors=preview.error_count,
  914. error_rows=[r for r in preview.rows if r.status == "error"],
  915. )
  916. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  917. async def get_spool(
  918. spool_id: int,
  919. db: AsyncSession = Depends(get_db),
  920. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  921. ):
  922. """Get a single spool with k_profiles."""
  923. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  924. spool = result.scalar_one_or_none()
  925. if not spool:
  926. raise HTTPException(404, "Spool not found")
  927. return spool
  928. @router.post("/spools", response_model=SpoolResponse)
  929. async def create_spool(
  930. spool_data: SpoolCreate,
  931. db: AsyncSession = Depends(get_db),
  932. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  933. ):
  934. """Create a new spool."""
  935. spool = Spool(**spool_data.model_dump())
  936. db.add(spool)
  937. await db.commit()
  938. await db.refresh(spool)
  939. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  940. await ws_manager.broadcast({"type": "inventory_changed"})
  941. return result.scalar_one()
  942. @router.post("/spools/bulk", response_model=list[SpoolResponse])
  943. async def bulk_create_spools(
  944. data: SpoolBulkCreate,
  945. db: AsyncSession = Depends(get_db),
  946. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  947. ):
  948. """Create multiple identical spools."""
  949. spools = []
  950. for _ in range(data.quantity):
  951. spool = Spool(**data.spool.model_dump())
  952. db.add(spool)
  953. spools.append(spool)
  954. await db.commit()
  955. ids = [s.id for s in spools]
  956. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
  957. await ws_manager.broadcast({"type": "inventory_changed"})
  958. return list(result.scalars().all())
  959. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  960. async def update_spool(
  961. spool_id: int,
  962. spool_data: SpoolUpdate,
  963. db: AsyncSession = Depends(get_db),
  964. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  965. ):
  966. """Update a spool."""
  967. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  968. spool = result.scalar_one_or_none()
  969. if not spool:
  970. raise HTTPException(404, "Spool not found")
  971. update_data = spool_data.model_dump(exclude_unset=True)
  972. # Auto-lock weight when user explicitly sets weight_used
  973. if "weight_used" in update_data and "weight_locked" not in update_data:
  974. update_data["weight_locked"] = True
  975. for field, value in update_data.items():
  976. setattr(spool, field, value)
  977. await db.commit()
  978. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  979. await ws_manager.broadcast({"type": "inventory_changed"})
  980. return result.scalar_one()
  981. @router.delete("/spools/{spool_id}")
  982. async def delete_spool(
  983. spool_id: int,
  984. db: AsyncSession = Depends(get_db),
  985. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  986. ):
  987. """Hard delete a spool."""
  988. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  989. spool = result.scalar_one_or_none()
  990. if not spool:
  991. raise HTTPException(404, "Spool not found")
  992. await db.delete(spool)
  993. await db.commit()
  994. await ws_manager.broadcast({"type": "inventory_changed"})
  995. return {"status": "deleted"}
  996. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  997. async def archive_spool(
  998. spool_id: int,
  999. db: AsyncSession = Depends(get_db),
  1000. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1001. ):
  1002. """Soft-delete a spool by setting archived_at."""
  1003. from datetime import datetime, timezone
  1004. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1005. spool = result.scalar_one_or_none()
  1006. if not spool:
  1007. raise HTTPException(404, "Spool not found")
  1008. spool.archived_at = datetime.now(timezone.utc)
  1009. await db.commit()
  1010. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1011. await ws_manager.broadcast({"type": "inventory_changed"})
  1012. return result.scalar_one()
  1013. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  1014. async def restore_spool(
  1015. spool_id: int,
  1016. db: AsyncSession = Depends(get_db),
  1017. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1018. ):
  1019. """Restore an archived spool."""
  1020. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1021. spool = result.scalar_one_or_none()
  1022. if not spool:
  1023. raise HTTPException(404, "Spool not found")
  1024. spool.archived_at = None
  1025. await db.commit()
  1026. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1027. await ws_manager.broadcast({"type": "inventory_changed"})
  1028. return result.scalar_one()
  1029. @router.post("/spools/{spool_id}/reset-consumed-counter", response_model=SpoolResponse)
  1030. async def reset_spool_consumed_counter(
  1031. spool_id: int,
  1032. db: AsyncSession = Depends(get_db),
  1033. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1034. ):
  1035. """Zero the displayed "Total Consumed" counter without touching remaining.
  1036. Stamps `weight_used_baseline = weight_used` so the Inventory page's
  1037. `weight_used - baseline` display reads 0, while `label_weight -
  1038. weight_used` (remaining) is unchanged. weight_locked is also left
  1039. alone — the spool keeps receiving AMS auto-sync updates. Matches
  1040. Spoolman's split between used_weight and remaining_weight (#1390).
  1041. The earlier name `/reset-usage` was misleading: callers reasonably
  1042. expected `weight_used` itself to drop to 0 and were surprised when
  1043. the response showed it unchanged. The current name describes what
  1044. the endpoint actually does — reset the "Total Consumed" counter
  1045. widget, not the lifetime weight_used field.
  1046. """
  1047. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1048. spool = result.scalar_one_or_none()
  1049. if not spool:
  1050. raise HTTPException(404, "Spool not found")
  1051. spool.weight_used_baseline = spool.weight_used or 0
  1052. await db.commit()
  1053. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1054. await ws_manager.broadcast({"type": "inventory_changed"})
  1055. return result.scalar_one()
  1056. @router.post("/spools/reset-consumed-counter-bulk")
  1057. async def bulk_reset_spool_consumed_counter(
  1058. payload: dict,
  1059. db: AsyncSession = Depends(get_db),
  1060. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1061. ):
  1062. """Bulk-stamp baseline = weight_used across the given spool IDs.
  1063. Caller passes an explicit list of IDs — no "reset all" shortcut, since
  1064. a typo on a wildcard would wipe the entire inventory's tracking.
  1065. Same semantics as the per-spool endpoint: remaining is preserved,
  1066. weight_locked is left alone.
  1067. """
  1068. spool_ids = payload.get("spool_ids")
  1069. if not isinstance(spool_ids, list) or not spool_ids:
  1070. raise HTTPException(400, "spool_ids must be a non-empty list")
  1071. if not all(isinstance(sid, int) for sid in spool_ids):
  1072. raise HTTPException(400, "spool_ids must contain integers")
  1073. result = await db.execute(select(Spool).where(Spool.id.in_(spool_ids)))
  1074. spools = list(result.scalars().all())
  1075. for spool in spools:
  1076. spool.weight_used_baseline = spool.weight_used or 0
  1077. await db.commit()
  1078. await ws_manager.broadcast({"type": "inventory_changed"})
  1079. return {"reset": len(spools)}
  1080. # ── K-Profiles ───────────────────────────────────────────────────────────────
  1081. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  1082. async def list_k_profiles(
  1083. spool_id: int,
  1084. db: AsyncSession = Depends(get_db),
  1085. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1086. ):
  1087. """List K-profiles for a spool."""
  1088. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1089. return list(result.scalars().all())
  1090. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  1091. async def replace_k_profiles(
  1092. spool_id: int,
  1093. profiles: list[SpoolKProfileBase],
  1094. db: AsyncSession = Depends(get_db),
  1095. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1096. ):
  1097. """Replace all K-profiles for a spool (batch save)."""
  1098. # Verify spool exists
  1099. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1100. if not result.scalar_one_or_none():
  1101. raise HTTPException(404, "Spool not found")
  1102. # Delete existing
  1103. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1104. for old in existing.scalars().all():
  1105. await db.delete(old)
  1106. # Create new
  1107. new_profiles = []
  1108. for p in profiles:
  1109. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  1110. db.add(kp)
  1111. new_profiles.append(kp)
  1112. await db.commit()
  1113. for kp in new_profiles:
  1114. await db.refresh(kp)
  1115. return new_profiles
  1116. # ── Spool Assignments ────────────────────────────────────────────────────────
  1117. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  1118. async def list_assignments(
  1119. printer_id: int | None = None,
  1120. db: AsyncSession = Depends(get_db),
  1121. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
  1122. ):
  1123. """List spool assignments, optionally filtered by printer."""
  1124. from backend.app.services.printer_manager import printer_manager
  1125. query = select(SpoolAssignment).options(
  1126. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  1127. selectinload(SpoolAssignment.printer),
  1128. )
  1129. if printer_id is not None:
  1130. query = query.where(SpoolAssignment.printer_id == printer_id)
  1131. result = await db.execute(query)
  1132. assignments = list(result.scalars().all())
  1133. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  1134. # Fetch all statuses in one call rather than one get_status() call per printer.
  1135. serial_map: dict[tuple[int, int], str] = {}
  1136. seen_printer_ids: set[int] = {a.printer_id for a in assignments}
  1137. all_statuses = printer_manager.get_all_statuses()
  1138. for pid in seen_printer_ids:
  1139. state = all_statuses.get(pid)
  1140. if state and state.raw_data:
  1141. for ams_unit in state.raw_data.get("ams", []):
  1142. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  1143. if sn:
  1144. try:
  1145. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  1146. except (ValueError, TypeError):
  1147. continue
  1148. # Fetch all relevant AMS labels keyed by serial number
  1149. all_serials = set(serial_map.values())
  1150. # Also include synthetic fallback keys for assignments without a known serial
  1151. synthetic_keys: dict[str, tuple[int, int]] = {}
  1152. for a in assignments:
  1153. if (a.printer_id, a.ams_id) not in serial_map:
  1154. synthetic = f"p{a.printer_id}a{a.ams_id}"
  1155. synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
  1156. all_serials.add(synthetic)
  1157. label_by_serial: dict[str, str] = {}
  1158. if all_serials:
  1159. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  1160. for lbl in lbl_result.scalars().all():
  1161. label_by_serial[lbl.ams_serial_number] = lbl.label
  1162. # Build response objects, attaching ams_label where available
  1163. responses: list[SpoolAssignmentResponse] = []
  1164. for a in assignments:
  1165. resp = SpoolAssignmentResponse.model_validate(a)
  1166. sn = serial_map.get((a.printer_id, a.ams_id))
  1167. if sn and sn in label_by_serial:
  1168. resp.ams_label = label_by_serial[sn]
  1169. elif not sn:
  1170. synthetic = f"p{a.printer_id}a{a.ams_id}"
  1171. resp.ams_label = label_by_serial.get(synthetic)
  1172. responses.append(resp)
  1173. return responses
  1174. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  1175. async def assign_spool(
  1176. data: SpoolAssignmentCreate,
  1177. db: AsyncSession = Depends(get_db),
  1178. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1179. ):
  1180. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  1181. from backend.app.services.printer_manager import printer_manager
  1182. # 1. Validate spool exists and is not archived
  1183. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
  1184. spool = result.scalar_one_or_none()
  1185. if not spool:
  1186. raise HTTPException(404, "Spool not found")
  1187. if spool.archived_at:
  1188. raise HTTPException(400, "Cannot assign an archived spool")
  1189. # 2. Get current AMS tray state for fingerprint + existing filament ID.
  1190. # tray_state: Bambu firmware reports 11=loaded, 9=empty, 10=spool present
  1191. # but filament not in feeder. Captured here so the empty-slot heuristic
  1192. # below can prefer it over tray_type — a manual "Reset slot" clears
  1193. # tray_type to "" while leaving state at 11 (filament still physically
  1194. # present), which would otherwise mislead the heuristic into the
  1195. # pending-config branch and skip MQTT forever (#1228 follow-up).
  1196. fingerprint_color = None
  1197. fingerprint_type = None
  1198. current_tray_info_idx = ""
  1199. tray_state: int | None = None
  1200. state = printer_manager.get_status(data.printer_id)
  1201. if state and state.raw_data:
  1202. if data.ams_id == 255:
  1203. # External slot: look up tray from vt_tray by global ID
  1204. vt_tray = state.raw_data.get("vt_tray") or []
  1205. ext_id = data.tray_id + 254 # 0→254, 1→255
  1206. for vt in vt_tray:
  1207. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  1208. fingerprint_color = vt.get("tray_color", "")
  1209. fingerprint_type = vt.get("tray_type", "")
  1210. current_tray_info_idx = vt.get("tray_info_idx", "")
  1211. raw_state = vt.get("state")
  1212. if isinstance(raw_state, int):
  1213. tray_state = raw_state
  1214. break
  1215. else:
  1216. ams_data = state.raw_data.get("ams", {})
  1217. ams_list = (
  1218. ams_data.get("ams", [])
  1219. if isinstance(ams_data, dict)
  1220. else ams_data
  1221. if isinstance(ams_data, list)
  1222. else []
  1223. )
  1224. tray = _find_tray_in_ams_data(
  1225. ams_list,
  1226. data.ams_id,
  1227. data.tray_id,
  1228. )
  1229. if tray:
  1230. fingerprint_color = tray.get("tray_color", "")
  1231. fingerprint_type = tray.get("tray_type", "")
  1232. current_tray_info_idx = tray.get("tray_info_idx", "")
  1233. raw_state = tray.get("state")
  1234. if isinstance(raw_state, int):
  1235. tray_state = raw_state
  1236. # 3. Upsert assignment (replace if same printer+ams+tray)
  1237. existing = await db.execute(
  1238. select(SpoolAssignment).where(
  1239. SpoolAssignment.printer_id == data.printer_id,
  1240. SpoolAssignment.ams_id == data.ams_id,
  1241. SpoolAssignment.tray_id == data.tray_id,
  1242. )
  1243. )
  1244. old = existing.scalar_one_or_none()
  1245. if old:
  1246. await db.delete(old)
  1247. await db.flush()
  1248. assignment = SpoolAssignment(
  1249. spool_id=data.spool_id,
  1250. printer_id=data.printer_id,
  1251. ams_id=data.ams_id,
  1252. tray_id=data.tray_id,
  1253. fingerprint_color=fingerprint_color,
  1254. fingerprint_type=fingerprint_type,
  1255. )
  1256. db.add(assignment)
  1257. await db.commit()
  1258. await db.refresh(assignment)
  1259. # 4. Auto-configure AMS slot via MQTT.
  1260. #
  1261. # Only suppress the publish when the firmware's *explicit* empty signal
  1262. # (state ∈ {9, 10}) is set — "no spool" / "spool present but no feed".
  1263. # Every other state, including state=3 (the default idle on A1 Mini BMCU /
  1264. # P1S Standard AMS for both loaded and unconfigured slots) and missing
  1265. # state (older firmwares), is treated as the user's assertion that a
  1266. # spool is in the slot and we attempt the MQTT push.
  1267. #
  1268. # The pre-existing "skip when slot looks empty" guard read state=3 +
  1269. # tray_type="" as "empty" and skipped MQTT. On these firmwares that
  1270. # combination is the post-"Reset Slot" state with the spool still
  1271. # physically inserted — there is NO AMS signal that distinguishes it
  1272. # from a truly-empty slot, so the guard created a deadlock: MQTT never
  1273. # fired, the AMS never reported any change (because nothing changed
  1274. # physically), and on_ams_change replay therefore never re-fired the
  1275. # config either. Reporter (#1322 follow-up by @RosdasHH) verified
  1276. # empirically that removing the guard makes the slot configure
  1277. # correctly because Bambu firmware DOES accept the push for a
  1278. # physically-loaded slot, even when tray_type is "" and state is 3.
  1279. #
  1280. # Trade-off for the truly-empty slot case: firmware drops the push
  1281. # silently (per Bambu's documented behavior), the SpoolAssignment row
  1282. # still has empty fingerprint_type because nothing in the assign path
  1283. # updates that column, and on_ams_change at main.py:1031-1054 still
  1284. # fires the deferred config when a spool eventually appears. So the
  1285. # SpoolBuddy weigh-then-assign-before-insert workflow continues to
  1286. # work — just without the optimization of skipping a no-op MQTT call.
  1287. #
  1288. # state ∈ {9, 10} stays as an explicit short-circuit so we don't churn
  1289. # a doomed MQTT push when the firmware has positively confirmed "no
  1290. # spool" — and to keep the on_ams_change replay path as the single
  1291. # source of truth for those slots.
  1292. slot_is_definitely_empty = tray_state == 9 or tray_state == 10
  1293. configured = False
  1294. if not slot_is_definitely_empty:
  1295. try:
  1296. configured = await apply_spool_to_slot_via_mqtt(
  1297. db=db,
  1298. current_user=current_user,
  1299. spool=spool,
  1300. printer_id=data.printer_id,
  1301. ams_id=data.ams_id,
  1302. tray_id=data.tray_id,
  1303. current_tray_info_idx=current_tray_info_idx,
  1304. current_tray_type=fingerprint_type or "",
  1305. )
  1306. except Exception as e:
  1307. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  1308. # pending_config is the "config not landed yet" UI marker. True when the
  1309. # firmware said empty, OR when MQTT couldn't actually publish (printer
  1310. # offline, no client, transient failure). on_ams_change replay re-fires
  1311. # the config in either case once the AMS reports a non-empty fingerprint.
  1312. pending_config = slot_is_definitely_empty or not configured
  1313. # Return assignment with spool data
  1314. result = await db.execute(
  1315. select(SpoolAssignment)
  1316. .options(
  1317. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  1318. selectinload(SpoolAssignment.printer),
  1319. )
  1320. .where(SpoolAssignment.id == assignment.id)
  1321. )
  1322. resp = result.scalar_one()
  1323. response = SpoolAssignmentResponse.model_validate(resp)
  1324. response.configured = configured
  1325. response.pending_config = pending_config
  1326. if pending_config:
  1327. logger.info(
  1328. "Pre-configured assignment: spool %d → printer %d AMS%d-T%d (slot empty, will configure on insert)",
  1329. spool.id,
  1330. data.printer_id,
  1331. data.ams_id,
  1332. data.tray_id,
  1333. )
  1334. await ws_manager.broadcast(
  1335. {
  1336. "type": "spool_assignment_changed",
  1337. "printer_id": data.printer_id,
  1338. "ams_id": data.ams_id,
  1339. "tray_id": data.tray_id,
  1340. }
  1341. )
  1342. return response
  1343. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  1344. async def unassign_spool(
  1345. printer_id: int,
  1346. ams_id: int,
  1347. tray_id: int,
  1348. db: AsyncSession = Depends(get_db),
  1349. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1350. ):
  1351. """Unassign a spool from an AMS slot."""
  1352. result = await db.execute(
  1353. select(SpoolAssignment).where(
  1354. SpoolAssignment.printer_id == printer_id,
  1355. SpoolAssignment.ams_id == ams_id,
  1356. SpoolAssignment.tray_id == tray_id,
  1357. )
  1358. )
  1359. assignment = result.scalar_one_or_none()
  1360. if not assignment:
  1361. raise HTTPException(404, "Assignment not found")
  1362. await db.delete(assignment)
  1363. await db.commit()
  1364. await ws_manager.broadcast(
  1365. {
  1366. "type": "spool_assignment_changed",
  1367. "printer_id": printer_id,
  1368. "ams_id": ams_id,
  1369. "tray_id": tray_id,
  1370. }
  1371. )
  1372. return {"status": "deleted"}
  1373. # ── Tag Linking ───────────────────────────────────────────────────────────────
  1374. class LinkTagRequest(BaseModel):
  1375. tag_uid: str | None = None
  1376. tray_uuid: str | None = None
  1377. tag_type: str | None = None
  1378. data_origin: str | None = "nfc_link"
  1379. def _validate_tag_input(
  1380. raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
  1381. ) -> None:
  1382. if raw_value is None:
  1383. return
  1384. raw = str(raw_value).strip()
  1385. if not raw:
  1386. return
  1387. if normalized_value is None:
  1388. raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
  1389. if len(normalized_value) % 2 != 0:
  1390. raise HTTPException(422, f"{field_name} must have an even number of hex characters")
  1391. if exact_len is not None and len(normalized_value) != exact_len:
  1392. raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
  1393. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  1394. async def link_tag_to_spool(
  1395. spool_id: int,
  1396. data: LinkTagRequest,
  1397. db: AsyncSession = Depends(get_db),
  1398. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1399. ):
  1400. """Link an RFID tag_uid/tray_uuid to an existing spool."""
  1401. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1402. spool = result.scalar_one_or_none()
  1403. if not spool:
  1404. raise HTTPException(404, "Spool not found")
  1405. if spool.archived_at:
  1406. raise HTTPException(400, "Cannot link tag to archived spool")
  1407. normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
  1408. normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
  1409. _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
  1410. _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
  1411. # Check for conflicts: tag already linked to another active spool
  1412. if normalized_tag_uid:
  1413. conflict = await db.execute(
  1414. select(Spool).where(
  1415. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1416. Spool.id != spool_id,
  1417. Spool.archived_at.is_(None),
  1418. )
  1419. )
  1420. if conflict.scalar_one_or_none():
  1421. raise HTTPException(409, "Tag UID already linked to another active spool")
  1422. # Auto-clear from archived spools (tag recycling)
  1423. archived_with_tag = await db.execute(
  1424. select(Spool).where(
  1425. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1426. Spool.id != spool_id,
  1427. Spool.archived_at.is_not(None),
  1428. )
  1429. )
  1430. for old_spool in archived_with_tag.scalars().all():
  1431. old_spool.tag_uid = None
  1432. if normalized_tray_uuid:
  1433. conflict = await db.execute(
  1434. select(Spool).where(
  1435. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1436. Spool.id != spool_id,
  1437. Spool.archived_at.is_(None),
  1438. )
  1439. )
  1440. if conflict.scalar_one_or_none():
  1441. raise HTTPException(409, "Tray UUID already linked to another active spool")
  1442. archived_with_uuid = await db.execute(
  1443. select(Spool).where(
  1444. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1445. Spool.id != spool_id,
  1446. Spool.archived_at.is_not(None),
  1447. )
  1448. )
  1449. for old_spool in archived_with_uuid.scalars().all():
  1450. old_spool.tray_uuid = None
  1451. if data.tag_uid is not None:
  1452. spool.tag_uid = normalized_tag_uid
  1453. if data.tray_uuid is not None:
  1454. spool.tray_uuid = normalized_tray_uuid
  1455. if data.tag_type is not None:
  1456. spool.tag_type = data.tag_type
  1457. if data.data_origin is not None:
  1458. spool.data_origin = data.data_origin
  1459. await db.commit()
  1460. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1461. return result.scalar_one()
  1462. # ── Usage History ─────────────────────────────────────────────────────────────
  1463. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  1464. async def get_spool_usage_history(
  1465. spool_id: int,
  1466. limit: int = 50,
  1467. db: AsyncSession = Depends(get_db),
  1468. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1469. ):
  1470. """Get usage history for a specific spool."""
  1471. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1472. # Verify spool exists
  1473. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1474. if not spool_result.scalar_one_or_none():
  1475. raise HTTPException(404, "Spool not found")
  1476. result = await db.execute(
  1477. select(SpoolUsageHistory)
  1478. .where(SpoolUsageHistory.spool_id == spool_id)
  1479. .order_by(SpoolUsageHistory.created_at.desc())
  1480. .limit(limit)
  1481. )
  1482. return list(result.scalars().all())
  1483. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  1484. async def get_all_usage_history(
  1485. limit: int = 100,
  1486. printer_id: int | None = None,
  1487. db: AsyncSession = Depends(get_db),
  1488. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1489. ):
  1490. """Get global usage history, optionally filtered by printer."""
  1491. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1492. query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
  1493. if printer_id is not None:
  1494. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  1495. result = await db.execute(query)
  1496. return list(result.scalars().all())
  1497. @router.delete("/spools/{spool_id}/usage")
  1498. async def clear_spool_usage_history(
  1499. spool_id: int,
  1500. db: AsyncSession = Depends(get_db),
  1501. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1502. ):
  1503. """Clear usage history for a spool."""
  1504. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1505. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  1506. for row in result.scalars().all():
  1507. await db.delete(row)
  1508. await db.commit()
  1509. return {"status": "cleared"}
  1510. # ── AMS Weight Sync ──────────────────────────────────────────────────────────
  1511. @router.post("/sync-ams-weights")
  1512. async def sync_weights_from_ams(
  1513. db: AsyncSession = Depends(get_db),
  1514. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1515. ):
  1516. """Force-sync spool weight_used from live AMS remain% data.
  1517. Overwrites the database weight_used for every assigned spool using the
  1518. current AMS remain% from connected printers. This is a manual recovery
  1519. tool — it bypasses the normal "only increase" guard.
  1520. """
  1521. from backend.app.services.printer_manager import printer_manager
  1522. result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  1523. assignments = list(result.scalars().all())
  1524. logger.info("AMS weight sync: found %d assignments", len(assignments))
  1525. synced = 0
  1526. skipped = 0
  1527. for assignment in assignments:
  1528. spool = assignment.spool
  1529. if not spool:
  1530. logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
  1531. skipped += 1
  1532. continue
  1533. if spool.weight_locked:
  1534. logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
  1535. skipped += 1
  1536. continue
  1537. state = printer_manager.get_status(assignment.printer_id)
  1538. if not state or not state.raw_data:
  1539. logger.info(
  1540. "AMS weight sync: printer %d not connected, skipping spool %d",
  1541. assignment.printer_id,
  1542. spool.id,
  1543. )
  1544. skipped += 1
  1545. continue
  1546. ams_raw = state.raw_data.get("ams", [])
  1547. if isinstance(ams_raw, dict):
  1548. ams_raw = ams_raw.get("ams", [])
  1549. tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
  1550. if not tray:
  1551. logger.info(
  1552. "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  1553. spool.id,
  1554. assignment.printer_id,
  1555. assignment.ams_id,
  1556. assignment.tray_id,
  1557. )
  1558. skipped += 1
  1559. continue
  1560. remain_raw = tray.get("remain")
  1561. if remain_raw is None:
  1562. logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
  1563. skipped += 1
  1564. continue
  1565. try:
  1566. remain_val = int(remain_raw)
  1567. except (TypeError, ValueError):
  1568. skipped += 1
  1569. continue
  1570. if remain_val < 0 or remain_val > 100:
  1571. logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
  1572. skipped += 1
  1573. continue
  1574. lw = spool.label_weight or 1000
  1575. new_used = round(lw * (100 - remain_val) / 100.0, 1)
  1576. old_used = spool.weight_used or 0
  1577. if round(old_used, 1) != new_used:
  1578. logger.info(
  1579. "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
  1580. spool.id,
  1581. old_used,
  1582. new_used,
  1583. remain_val,
  1584. )
  1585. spool.weight_used = new_used
  1586. synced += 1
  1587. else:
  1588. skipped += 1
  1589. await db.commit()
  1590. return {"synced": synced, "skipped": skipped}
  1591. # ── Helpers ──────────────────────────────────────────────────────────────────
  1592. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  1593. """Find a specific tray in the AMS data structure."""
  1594. if not ams_data:
  1595. return None
  1596. for ams_unit in ams_data:
  1597. if int(ams_unit.get("id", -1)) != ams_id:
  1598. continue
  1599. for tray in ams_unit.get("tray", []):
  1600. if int(tray.get("id", -1)) == tray_id:
  1601. return tray
  1602. return None
  1603. # ── Filament SKU Settings (reorder forecasting) ───────────────────────────────
  1604. class FilamentSkuSettingsResponse(BaseModel):
  1605. id: int
  1606. material: str
  1607. subtype: str | None
  1608. brand: str | None
  1609. lead_time_days: int
  1610. safety_margin_value: int
  1611. safety_margin_unit: str
  1612. alerts_snoozed: bool = False
  1613. class Config:
  1614. from_attributes = True
  1615. class FilamentSkuSettingsUpsert(BaseModel):
  1616. material: str
  1617. subtype: str | None = None
  1618. brand: str | None = None
  1619. lead_time_days: int = 0
  1620. safety_margin_value: int = 14
  1621. safety_margin_unit: str = "days"
  1622. alerts_snoozed: bool = False
  1623. @router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
  1624. async def list_sku_settings(
  1625. db: AsyncSession = Depends(get_db),
  1626. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  1627. ):
  1628. """List all filament SKU reorder settings."""
  1629. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  1630. result = await db.execute(
  1631. select(FilamentSkuSettings).order_by(FilamentSkuSettings.material, FilamentSkuSettings.brand)
  1632. )
  1633. return list(result.scalars().all())
  1634. @router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
  1635. async def upsert_sku_settings(
  1636. data: FilamentSkuSettingsUpsert,
  1637. db: AsyncSession = Depends(get_db),
  1638. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1639. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1640. ),
  1641. ):
  1642. """Create or update reorder settings for a filament SKU (material/subtype/brand)."""
  1643. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  1644. result = await db.execute(
  1645. select(FilamentSkuSettings).where(
  1646. FilamentSkuSettings.material == data.material,
  1647. FilamentSkuSettings.subtype == data.subtype,
  1648. FilamentSkuSettings.brand == data.brand,
  1649. )
  1650. )
  1651. row = result.scalar_one_or_none()
  1652. if row:
  1653. row.lead_time_days = data.lead_time_days
  1654. row.safety_margin_value = data.safety_margin_value
  1655. row.safety_margin_unit = data.safety_margin_unit
  1656. row.alerts_snoozed = data.alerts_snoozed
  1657. else:
  1658. row = FilamentSkuSettings(
  1659. material=data.material,
  1660. subtype=data.subtype,
  1661. brand=data.brand,
  1662. lead_time_days=data.lead_time_days,
  1663. safety_margin_value=data.safety_margin_value,
  1664. safety_margin_unit=data.safety_margin_unit,
  1665. alerts_snoozed=data.alerts_snoozed,
  1666. )
  1667. db.add(row)
  1668. await db.commit()
  1669. await db.refresh(row)
  1670. return row
  1671. # ── Shopping List ─────────────────────────────────────────────────────────────
  1672. class ShoppingListItemResponse(BaseModel):
  1673. id: int
  1674. material: str
  1675. subtype: str | None
  1676. brand: str | None
  1677. quantity_spools: int
  1678. note: str | None
  1679. status: str
  1680. purchased_at: str | None
  1681. added_at: str
  1682. class Config:
  1683. from_attributes = True
  1684. class ShoppingListItemCreate(BaseModel):
  1685. material: str
  1686. subtype: str | None = None
  1687. brand: str | None = None
  1688. quantity_spools: int = 1
  1689. note: str | None = None
  1690. class ShoppingListItemStatusUpdate(BaseModel):
  1691. status: str # pending | purchased | received
  1692. @router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
  1693. async def get_shopping_list(
  1694. db: AsyncSession = Depends(get_db),
  1695. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  1696. ):
  1697. """Get the filament shopping list."""
  1698. from backend.app.models.shopping_list import ShoppingListItem
  1699. result = await db.execute(select(ShoppingListItem).order_by(ShoppingListItem.added_at.desc()))
  1700. items = result.scalars().all()
  1701. return [
  1702. ShoppingListItemResponse(
  1703. id=i.id,
  1704. material=i.material,
  1705. subtype=i.subtype,
  1706. brand=i.brand,
  1707. quantity_spools=i.quantity_spools,
  1708. note=i.note,
  1709. status=i.status or "pending",
  1710. purchased_at=i.purchased_at.isoformat() if i.purchased_at else None,
  1711. added_at=i.added_at.isoformat() if i.added_at else "",
  1712. )
  1713. for i in items
  1714. ]
  1715. @router.post("/shopping-list", response_model=ShoppingListItemResponse)
  1716. async def add_to_shopping_list(
  1717. data: ShoppingListItemCreate,
  1718. db: AsyncSession = Depends(get_db),
  1719. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1720. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1721. ),
  1722. ):
  1723. """Add a filament SKU to the shopping list."""
  1724. from backend.app.models.shopping_list import ShoppingListItem
  1725. item = ShoppingListItem(
  1726. material=data.material,
  1727. subtype=data.subtype,
  1728. brand=data.brand,
  1729. quantity_spools=data.quantity_spools,
  1730. note=data.note,
  1731. )
  1732. db.add(item)
  1733. await db.commit()
  1734. await db.refresh(item)
  1735. return ShoppingListItemResponse(
  1736. id=item.id,
  1737. material=item.material,
  1738. subtype=item.subtype,
  1739. brand=item.brand,
  1740. quantity_spools=item.quantity_spools,
  1741. note=item.note,
  1742. status=item.status or "pending",
  1743. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  1744. added_at=item.added_at.isoformat() if item.added_at else "",
  1745. )
  1746. @router.patch("/shopping-list/{item_id}/status", response_model=ShoppingListItemResponse)
  1747. async def update_shopping_list_status(
  1748. item_id: int,
  1749. data: ShoppingListItemStatusUpdate,
  1750. db: AsyncSession = Depends(get_db),
  1751. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1752. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1753. ),
  1754. ):
  1755. """Update the purchase status of a shopping list item."""
  1756. from datetime import datetime, timezone
  1757. from backend.app.models.shopping_list import ShoppingListItem
  1758. if data.status not in ("pending", "purchased", "received"):
  1759. raise HTTPException(400, "Invalid status")
  1760. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  1761. item = result.scalar_one_or_none()
  1762. if not item:
  1763. raise HTTPException(404, "Item not found")
  1764. item.status = data.status
  1765. if data.status in ("purchased", "received") and item.purchased_at is None:
  1766. item.purchased_at = datetime.now(timezone.utc)
  1767. elif data.status == "pending":
  1768. item.purchased_at = None
  1769. await db.commit()
  1770. await db.refresh(item)
  1771. return ShoppingListItemResponse(
  1772. id=item.id,
  1773. material=item.material,
  1774. subtype=item.subtype,
  1775. brand=item.brand,
  1776. quantity_spools=item.quantity_spools,
  1777. note=item.note,
  1778. status=item.status or "pending",
  1779. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  1780. added_at=item.added_at.isoformat() if item.added_at else "",
  1781. )
  1782. @router.delete("/shopping-list/{item_id}")
  1783. async def remove_from_shopping_list(
  1784. item_id: int,
  1785. db: AsyncSession = Depends(get_db),
  1786. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1787. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1788. ),
  1789. ):
  1790. """Remove a single item from the shopping list."""
  1791. from backend.app.models.shopping_list import ShoppingListItem
  1792. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  1793. item = result.scalar_one_or_none()
  1794. if not item:
  1795. raise HTTPException(404, "Item not found")
  1796. await db.delete(item)
  1797. await db.commit()
  1798. return {"status": "deleted"}
  1799. @router.delete("/shopping-list")
  1800. async def clear_shopping_list(
  1801. db: AsyncSession = Depends(get_db),
  1802. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1803. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1804. ),
  1805. ):
  1806. """Clear all items from the shopping list."""
  1807. from backend.app.models.shopping_list import ShoppingListItem
  1808. result = await db.execute(delete(ShoppingListItem).returning(ShoppingListItem.id))
  1809. deleted = len(result.fetchall())
  1810. await db.commit()
  1811. return {"deleted": deleted}