inventory.py 76 KB

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