inventory.py 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567
  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 func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy.orm import selectinload
  10. from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
  11. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  12. from backend.app.core.database import get_db
  13. from backend.app.core.permissions import Permission
  14. from backend.app.core.websocket import ws_manager
  15. from backend.app.models.ams_label import AmsLabel
  16. from backend.app.models.color_catalog import ColorCatalogEntry
  17. from backend.app.models.spool import Spool
  18. from backend.app.models.spool_assignment import SpoolAssignment
  19. from backend.app.models.spool_catalog import SpoolCatalogEntry
  20. from backend.app.models.spool_k_profile import SpoolKProfile
  21. from backend.app.models.user import User
  22. from backend.app.schemas.spool import (
  23. SpoolAssignmentCreate,
  24. SpoolAssignmentResponse,
  25. SpoolBulkCreate,
  26. SpoolCreate,
  27. SpoolKProfileBase,
  28. SpoolKProfileResponse,
  29. SpoolResponse,
  30. SpoolUpdate,
  31. normalize_effect_type,
  32. normalize_extra_colors,
  33. )
  34. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  35. from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
  36. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  37. logger = logging.getLogger(__name__)
  38. router = APIRouter(prefix="/inventory", tags=["inventory"])
  39. # Material temperature defaults (nozzle min/max)
  40. MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
  41. "PLA": (190, 230),
  42. "PETG": (220, 260),
  43. "ABS": (240, 270),
  44. "ASA": (240, 270),
  45. "TPU": (200, 240),
  46. "PA": (260, 290),
  47. "PC": (250, 280),
  48. "PVA": (190, 210),
  49. "PLA-CF": (210, 240),
  50. "PETG-CF": (240, 270),
  51. "PA-CF": (270, 300),
  52. }
  53. # FilamentColors.xyz API
  54. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  55. # Generic Bambu filament IDs by material — fallback when no specific
  56. # preset is resolvable. Keep aligned with the inline table in
  57. # apply_spool_to_slot_via_mqtt below; both paths must produce the same
  58. # value for a given material.
  59. _GENERIC_FILAMENT_IDS: dict[str, str] = {
  60. "PLA": "GFL99",
  61. "PETG": "GFG99",
  62. "ABS": "GFB99",
  63. "ASA": "GFB98",
  64. "PC": "GFC99",
  65. "PA": "GFN99",
  66. "NYLON": "GFN99",
  67. "TPU": "GFU99",
  68. "PVA": "GFS99",
  69. "HIPS": "GFS98",
  70. "PLA-CF": "GFL98",
  71. "PETG-CF": "GFG98",
  72. "PA-CF": "GFN98",
  73. "PETG HF": "GFG96",
  74. }
  75. async def apply_spool_to_slot_via_mqtt(
  76. *,
  77. db: AsyncSession,
  78. current_user: User | None,
  79. spool: Spool,
  80. printer_id: int,
  81. ams_id: int,
  82. tray_id: int,
  83. current_tray_info_idx: str = "",
  84. current_tray_type: str = "",
  85. ) -> bool:
  86. """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
  87. Shared by `assign_spool` (initial assign for a loaded slot) and
  88. `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
  89. empty → loaded). Returns True when MQTT commands were published, False if
  90. no client was available or setup failed mid-way.
  91. `current_tray_info_idx` / `current_tray_type` describe the live tray state
  92. used as fallback hints when the spool's slicer_filament can't be resolved.
  93. Caller should not pass these for the empty-slot re-fire path (they'll be
  94. the freshly-loaded values, which is the intended fallback).
  95. """
  96. from backend.app.services.printer_manager import printer_manager
  97. client = printer_manager.get_client(printer_id)
  98. if client is None:
  99. return False
  100. state = printer_manager.get_status(printer_id)
  101. tray_type = spool.material
  102. tray_sub_brands = (
  103. f"{spool.brand} {spool.material} {spool.subtype}".strip()
  104. if spool.brand
  105. else f"{spool.material} {spool.subtype}"
  106. if spool.subtype
  107. else spool.material
  108. )
  109. tray_color = spool.rgba or "FFFFFFFF"
  110. _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
  111. tray_info_idx = ""
  112. setting_id = ""
  113. sf = spool.slicer_filament or ""
  114. if sf:
  115. base_sf = sf.split("_")[0] if "_" in sf else sf
  116. if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
  117. setting_id = base_sf
  118. try:
  119. from backend.app.api.routes.cloud import build_authenticated_cloud
  120. cloud = await build_authenticated_cloud(db, current_user)
  121. if cloud is not None and cloud.is_authenticated:
  122. try:
  123. detail = await cloud.get_setting_detail(base_sf)
  124. if detail.get("filament_id"):
  125. tray_info_idx = detail["filament_id"]
  126. cloud_name = detail.get("name", "")
  127. if cloud_name:
  128. tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  129. elif detail.get("base_id"):
  130. bid = detail["base_id"].split("_")[0]
  131. if bid.startswith("GFS") and len(bid) >= 5:
  132. tray_info_idx = f"GF{bid[3:]}"
  133. else:
  134. tray_info_idx = bid
  135. finally:
  136. await cloud.close()
  137. elif cloud is not None:
  138. await cloud.close()
  139. except Exception as e:
  140. logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
  141. if not tray_info_idx:
  142. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  143. elif base_sf.startswith("GF"):
  144. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  145. else:
  146. try:
  147. local_id = int(sf)
  148. from backend.app.models.local_preset import LocalPreset as LP
  149. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  150. lp = lp_result.scalar_one_or_none()
  151. if lp:
  152. mat = (spool.material or lp.filament_type or "").upper().strip()
  153. tray_info_idx = (
  154. _GENERIC_FILAMENT_IDS.get(mat)
  155. or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
  156. or ""
  157. )
  158. if lp.name:
  159. tray_sub_brands = lp.name.split("@")[0].strip()
  160. except (ValueError, TypeError):
  161. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  162. if tray_info_idx and spool.slicer_filament_name:
  163. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  164. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  165. if expected_name and expected_name != spool.slicer_filament_name:
  166. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  167. if fname == spool.slicer_filament_name:
  168. tray_info_idx = fid
  169. setting_id = filament_id_to_setting_id(fid)
  170. break
  171. if not tray_info_idx:
  172. if (
  173. current_tray_info_idx
  174. and current_tray_info_idx not in _generic_id_values
  175. and current_tray_type
  176. and current_tray_type.upper() == tray_type.upper()
  177. ):
  178. tray_info_idx = current_tray_info_idx
  179. elif tray_type:
  180. material = tray_type.upper().strip()
  181. generic = (
  182. _GENERIC_FILAMENT_IDS.get(material)
  183. or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  184. or ""
  185. )
  186. if generic:
  187. tray_info_idx = generic
  188. temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
  189. if spool.nozzle_temp_min is not None:
  190. temp_min = spool.nozzle_temp_min
  191. if spool.nozzle_temp_max is not None:
  192. temp_max = spool.nozzle_temp_max
  193. nozzle_diameter = "0.4"
  194. if state and state.nozzles:
  195. nd = state.nozzles[0].nozzle_diameter
  196. if nd:
  197. nozzle_diameter = nd
  198. slot_extruder = None
  199. if state and state.ams_extruder_map:
  200. if ams_id == 255:
  201. slot_extruder = 1 - tray_id # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  202. else:
  203. slot_extruder = state.ams_extruder_map.get(str(ams_id))
  204. # Prefer exact extruder match, fall back to extruder-agnostic kp for the
  205. # same nozzle. Hard-skipping on mismatch silently drops valid stored
  206. # profiles when the AMS-extruder mapping has shifted.
  207. exact_kp = None
  208. fallback_kp = None
  209. for kp in spool.k_profiles:
  210. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
  211. continue
  212. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  213. exact_kp = kp
  214. break
  215. if fallback_kp is None:
  216. fallback_kp = kp
  217. matching_kp = exact_kp or fallback_kp
  218. # Resolve the printer-side calibration entry by looking up the cali_idx
  219. # in state.kprofiles. The printer keys its calibration table by
  220. # (filament_id, cali_idx) — for the cali_idx to stick, the slot's
  221. # filament_id must match the kp's. PFUS-prefix cloud user presets are
  222. # rejected by the slicer in tray_info_idx; the printer-reported
  223. # filament_id is typically a P-prefix local preset which is valid.
  224. printer_kp = None
  225. if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
  226. for pkp in state.kprofiles:
  227. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  228. printer_kp = pkp
  229. break
  230. effective_tray_info_idx = tray_info_idx
  231. effective_setting_id = setting_id
  232. if printer_kp and printer_kp.filament_id:
  233. effective_tray_info_idx = printer_kp.filament_id
  234. target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
  235. matching_kp.setting_id if matching_kp else None
  236. )
  237. if target_setting_id:
  238. effective_setting_id = target_setting_id
  239. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  240. logger.info(
  241. "Spool assign: realigning tray_info_idx %r → %r, setting_id %r → %r (source=%s)",
  242. tray_info_idx,
  243. effective_tray_info_idx,
  244. setting_id,
  245. effective_setting_id,
  246. "printer" if printer_kp else "stored",
  247. )
  248. client.ams_set_filament_setting(
  249. ams_id=ams_id,
  250. tray_id=tray_id,
  251. tray_info_idx=effective_tray_info_idx,
  252. tray_type=tray_type,
  253. tray_sub_brands=tray_sub_brands,
  254. tray_color=tray_color,
  255. nozzle_temp_min=temp_min,
  256. nozzle_temp_max=temp_max,
  257. setting_id=effective_setting_id,
  258. )
  259. if matching_kp and matching_kp.cali_idx is not None:
  260. # filament_id for cali_sel must match the preset under which the kp
  261. # was registered. Priority: live printer kp > stored kp.setting_id >
  262. # spool.slicer_filament > realigned tray_info_idx.
  263. if printer_kp and printer_kp.filament_id:
  264. cali_filament_id = printer_kp.filament_id
  265. elif matching_kp.setting_id:
  266. cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
  267. else:
  268. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  269. client.extrusion_cali_sel(
  270. ams_id=ams_id,
  271. tray_id=tray_id,
  272. cali_idx=matching_kp.cali_idx,
  273. filament_id=cali_filament_id,
  274. nozzle_diameter=nozzle_diameter,
  275. )
  276. # Persist slot preset mapping for UI display (preset_name on hover card).
  277. try:
  278. from backend.app.models.slot_preset import SlotPresetMapping
  279. preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
  280. preset_source = "cloud"
  281. if sf:
  282. base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
  283. try:
  284. int(base_sf_mapping)
  285. preset_id_to_save = f"local_{base_sf_mapping}"
  286. preset_source = "local"
  287. except (ValueError, TypeError):
  288. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
  289. else:
  290. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
  291. if preset_id_to_save:
  292. existing_mapping = await db.execute(
  293. select(SlotPresetMapping).where(
  294. SlotPresetMapping.printer_id == printer_id,
  295. SlotPresetMapping.ams_id == ams_id,
  296. SlotPresetMapping.tray_id == tray_id,
  297. )
  298. )
  299. mapping = existing_mapping.scalar_one_or_none()
  300. if mapping:
  301. mapping.preset_id = preset_id_to_save
  302. mapping.preset_name = preset_name
  303. mapping.preset_source = preset_source
  304. else:
  305. mapping = SlotPresetMapping(
  306. printer_id=printer_id,
  307. ams_id=ams_id,
  308. tray_id=tray_id,
  309. preset_id=preset_id_to_save,
  310. preset_name=preset_name,
  311. preset_source=preset_source,
  312. )
  313. db.add(mapping)
  314. await db.commit()
  315. except Exception as e:
  316. logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
  317. logger.info(
  318. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  319. ams_id,
  320. tray_id,
  321. spool.id,
  322. printer_id,
  323. )
  324. return True
  325. # ── Spool Catalog Schemas ──────────────────────────────────────────────────
  326. class CatalogEntryResponse(BaseModel):
  327. id: int
  328. name: str
  329. weight: int
  330. is_default: bool
  331. class Config:
  332. from_attributes = True
  333. class CatalogEntryCreate(BaseModel):
  334. name: str
  335. weight: int
  336. class CatalogEntryUpdate(BaseModel):
  337. name: str
  338. weight: int
  339. class BulkDeleteIdsRequest(BaseModel):
  340. ids: list[int]
  341. # ── Color Catalog Schemas ──────────────────────────────────────────────────
  342. class ColorEntryResponse(BaseModel):
  343. id: int
  344. manufacturer: str
  345. color_name: str
  346. hex_color: str
  347. material: str | None
  348. is_default: bool
  349. extra_colors: str | None = None
  350. effect_type: str | None = None
  351. class Config:
  352. from_attributes = True
  353. _HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
  354. class ColorEntryCreate(BaseModel):
  355. manufacturer: str
  356. color_name: str
  357. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  358. material: str | None = None
  359. extra_colors: str | None = None
  360. effect_type: str | None = None
  361. @field_validator("extra_colors")
  362. @classmethod
  363. def _validate_extra_colors(cls, v: str | None) -> str | None:
  364. return normalize_extra_colors(v)
  365. @field_validator("effect_type")
  366. @classmethod
  367. def _validate_effect_type(cls, v: str | None) -> str | None:
  368. return normalize_effect_type(v)
  369. class ColorEntryUpdate(BaseModel):
  370. manufacturer: str
  371. color_name: str
  372. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  373. material: str | None = None
  374. extra_colors: str | None = None
  375. effect_type: str | None = None
  376. @field_validator("extra_colors")
  377. @classmethod
  378. def _validate_extra_colors(cls, v: str | None) -> str | None:
  379. return normalize_extra_colors(v)
  380. @field_validator("effect_type")
  381. @classmethod
  382. def _validate_effect_type(cls, v: str | None) -> str | None:
  383. return normalize_effect_type(v)
  384. class ColorLookupResult(BaseModel):
  385. found: bool
  386. hex_color: str | None = None
  387. material: str | None = None
  388. # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
  389. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  390. async def get_spool_catalog(
  391. db: AsyncSession = Depends(get_db),
  392. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  393. ):
  394. """Get all spool catalog entries."""
  395. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  396. return list(result.scalars().all())
  397. @router.post("/catalog", response_model=CatalogEntryResponse)
  398. async def add_catalog_entry(
  399. entry: CatalogEntryCreate,
  400. db: AsyncSession = Depends(get_db),
  401. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  402. ):
  403. """Add a new spool catalog entry."""
  404. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  405. db.add(row)
  406. await db.commit()
  407. await db.refresh(row)
  408. return row
  409. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  410. async def update_catalog_entry(
  411. entry_id: int,
  412. entry: CatalogEntryUpdate,
  413. db: AsyncSession = Depends(get_db),
  414. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  415. ):
  416. """Update a spool catalog entry."""
  417. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  418. row = result.scalar_one_or_none()
  419. if not row:
  420. raise HTTPException(404, "Entry not found")
  421. row.name = entry.name
  422. row.weight = entry.weight
  423. await db.commit()
  424. await db.refresh(row)
  425. return row
  426. @router.delete("/catalog/{entry_id}")
  427. async def delete_catalog_entry(
  428. entry_id: int,
  429. db: AsyncSession = Depends(get_db),
  430. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  431. ):
  432. """Delete a spool catalog entry."""
  433. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  434. row = result.scalar_one_or_none()
  435. if not row:
  436. raise HTTPException(404, "Entry not found")
  437. await db.delete(row)
  438. await db.commit()
  439. return {"status": "deleted"}
  440. @router.post("/catalog/bulk-delete")
  441. async def bulk_delete_catalog_entries(
  442. data: BulkDeleteIdsRequest,
  443. db: AsyncSession = Depends(get_db),
  444. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  445. ):
  446. """Delete multiple spool catalog entries by ID."""
  447. if not data.ids:
  448. return {"deleted": 0}
  449. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
  450. rows = result.scalars().all()
  451. for row in rows:
  452. await db.delete(row)
  453. await db.commit()
  454. return {"deleted": len(rows)}
  455. @router.post("/catalog/reset")
  456. async def reset_spool_catalog(
  457. db: AsyncSession = Depends(get_db),
  458. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  459. ):
  460. """Reset spool catalog to defaults."""
  461. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  462. # Delete all
  463. result = await db.execute(select(SpoolCatalogEntry))
  464. for row in result.scalars().all():
  465. await db.delete(row)
  466. # Re-seed defaults
  467. for name, weight in DEFAULT_SPOOL_CATALOG:
  468. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  469. await db.commit()
  470. return {"status": "reset"}
  471. # ── Color Catalog CRUD ─────────────────────────────────────────────────────
  472. @router.get("/colors", response_model=list[ColorEntryResponse])
  473. async def get_color_catalog(
  474. db: AsyncSession = Depends(get_db),
  475. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  476. ):
  477. """Get all color catalog entries."""
  478. result = await db.execute(
  479. select(ColorCatalogEntry).order_by(
  480. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  481. )
  482. )
  483. return list(result.scalars().all())
  484. @router.get("/colors/map")
  485. async def get_color_name_map(
  486. db: AsyncSession = Depends(get_db),
  487. _: User | None = Depends(require_auth_if_enabled),
  488. ):
  489. """Compact {hex: name} map for frontend color-name resolution.
  490. Not gated on INVENTORY_READ — every page that renders a spool color needs
  491. this, including read-only views available to users without inventory access.
  492. Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
  493. share the same hex (different materials or manufacturers), Bambu Lab wins,
  494. then default entries, then the first encountered.
  495. """
  496. result = await db.execute(
  497. select(
  498. ColorCatalogEntry.hex_color,
  499. ColorCatalogEntry.color_name,
  500. ColorCatalogEntry.manufacturer,
  501. ColorCatalogEntry.is_default,
  502. )
  503. )
  504. mapping: dict[str, tuple[str, int]] = {} # hex → (name, priority); higher priority wins
  505. for hex_color, color_name, manufacturer, is_default in result.all():
  506. if not hex_color or not color_name:
  507. continue
  508. key = hex_color.lstrip("#").lower()[:6]
  509. if len(key) != 6:
  510. continue
  511. priority = 0
  512. if manufacturer and manufacturer.strip().lower() == "bambu lab":
  513. priority += 2
  514. if is_default:
  515. priority += 1
  516. existing = mapping.get(key)
  517. if existing is None or priority > existing[1]:
  518. mapping[key] = (color_name, priority)
  519. return {"colors": {k: v[0] for k, v in mapping.items()}}
  520. @router.post("/colors", response_model=ColorEntryResponse)
  521. async def add_color_entry(
  522. entry: ColorEntryCreate,
  523. db: AsyncSession = Depends(get_db),
  524. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  525. ):
  526. """Add a new color catalog entry."""
  527. row = ColorCatalogEntry(
  528. manufacturer=entry.manufacturer,
  529. color_name=entry.color_name,
  530. hex_color=entry.hex_color,
  531. material=entry.material,
  532. is_default=False,
  533. extra_colors=entry.extra_colors,
  534. effect_type=entry.effect_type,
  535. )
  536. db.add(row)
  537. await db.commit()
  538. await db.refresh(row)
  539. return row
  540. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  541. async def update_color_entry(
  542. entry_id: int,
  543. entry: ColorEntryUpdate,
  544. db: AsyncSession = Depends(get_db),
  545. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  546. ):
  547. """Update a color catalog entry."""
  548. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  549. row = result.scalar_one_or_none()
  550. if not row:
  551. raise HTTPException(404, "Entry not found")
  552. row.manufacturer = entry.manufacturer
  553. row.color_name = entry.color_name
  554. row.hex_color = entry.hex_color
  555. row.material = entry.material
  556. row.extra_colors = entry.extra_colors
  557. row.effect_type = entry.effect_type
  558. await db.commit()
  559. await db.refresh(row)
  560. return row
  561. @router.delete("/colors/{entry_id}")
  562. async def delete_color_entry(
  563. entry_id: int,
  564. db: AsyncSession = Depends(get_db),
  565. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  566. ):
  567. """Delete a color catalog entry."""
  568. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  569. row = result.scalar_one_or_none()
  570. if not row:
  571. raise HTTPException(404, "Entry not found")
  572. await db.delete(row)
  573. await db.commit()
  574. return {"status": "deleted"}
  575. @router.post("/colors/bulk-delete")
  576. async def bulk_delete_color_entries(
  577. data: BulkDeleteIdsRequest,
  578. db: AsyncSession = Depends(get_db),
  579. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  580. ):
  581. """Delete multiple color catalog entries by ID."""
  582. if not data.ids:
  583. return {"deleted": 0}
  584. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
  585. rows = result.scalars().all()
  586. for row in rows:
  587. await db.delete(row)
  588. await db.commit()
  589. return {"deleted": len(rows)}
  590. @router.post("/colors/reset")
  591. async def reset_color_catalog(
  592. db: AsyncSession = Depends(get_db),
  593. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  594. ):
  595. """Reset color catalog to defaults."""
  596. result = await db.execute(select(ColorCatalogEntry))
  597. for row in result.scalars().all():
  598. await db.delete(row)
  599. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  600. db.add(
  601. ColorCatalogEntry(
  602. manufacturer=manufacturer,
  603. color_name=color_name,
  604. hex_color=hex_color,
  605. material=material,
  606. is_default=True,
  607. )
  608. )
  609. await db.commit()
  610. return {"status": "reset"}
  611. @router.get("/colors/lookup", response_model=ColorLookupResult)
  612. async def lookup_color(
  613. manufacturer: str,
  614. color_name: str,
  615. material: str | None = None,
  616. db: AsyncSession = Depends(get_db),
  617. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  618. ):
  619. """Look up a color by manufacturer and color name."""
  620. query = select(ColorCatalogEntry).where(
  621. ColorCatalogEntry.manufacturer == manufacturer,
  622. ColorCatalogEntry.color_name == color_name,
  623. )
  624. if material:
  625. query = query.where(ColorCatalogEntry.material == material)
  626. query = query.limit(1)
  627. result = await db.execute(query)
  628. row = result.scalar_one_or_none()
  629. if row:
  630. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  631. return ColorLookupResult(found=False)
  632. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  633. async def search_colors(
  634. manufacturer: str | None = None,
  635. material: str | None = None,
  636. db: AsyncSession = Depends(get_db),
  637. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  638. ):
  639. """Search colors by manufacturer and/or material."""
  640. query = select(ColorCatalogEntry)
  641. if manufacturer:
  642. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  643. if material:
  644. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  645. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  646. result = await db.execute(query)
  647. return list(result.scalars().all())
  648. @router.post("/colors/sync")
  649. async def sync_from_filamentcolors(
  650. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  651. ):
  652. """Sync colors from FilamentColors.xyz API with progress streaming."""
  653. async def generate():
  654. from backend.app.core.database import async_session
  655. added = 0
  656. skipped = 0
  657. total_fetched = 0
  658. total_available = 0
  659. try:
  660. async with httpx.AsyncClient(timeout=120.0) as client:
  661. page = 1
  662. while True:
  663. response = await client.get(
  664. f"{FILAMENT_COLORS_API}/swatch/",
  665. params={"page": page},
  666. )
  667. response.raise_for_status()
  668. data = response.json()
  669. total_available = data.get("count", total_available)
  670. results = data.get("results", [])
  671. if not results:
  672. break
  673. async with async_session() as db:
  674. for swatch in results:
  675. total_fetched += 1
  676. manufacturer_data = swatch.get("manufacturer")
  677. manufacturer_name = (
  678. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  679. )
  680. filament_type_data = swatch.get("filament_type")
  681. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  682. color_name_val = swatch.get("color_name", "")
  683. hex_color_val = swatch.get("hex_color", "")
  684. if not manufacturer_name or not color_name_val or not hex_color_val:
  685. skipped += 1
  686. continue
  687. if not hex_color_val.startswith("#"):
  688. hex_color_val = f"#{hex_color_val}"
  689. # Check if entry already exists
  690. existing = await db.execute(
  691. select(ColorCatalogEntry)
  692. .where(
  693. ColorCatalogEntry.manufacturer == manufacturer_name,
  694. ColorCatalogEntry.color_name == color_name_val,
  695. ColorCatalogEntry.material == mat,
  696. )
  697. .limit(1)
  698. )
  699. if existing.scalar_one_or_none():
  700. skipped += 1
  701. else:
  702. db.add(
  703. ColorCatalogEntry(
  704. manufacturer=manufacturer_name,
  705. color_name=color_name_val,
  706. hex_color=hex_color_val.upper(),
  707. material=mat,
  708. is_default=False,
  709. )
  710. )
  711. added += 1
  712. await db.commit()
  713. progress = {
  714. "type": "progress",
  715. "added": added,
  716. "skipped": skipped,
  717. "total_fetched": total_fetched,
  718. "total_available": total_available,
  719. }
  720. yield f"data: {json.dumps(progress)}\n\n"
  721. if not data.get("next") or total_fetched >= total_available:
  722. break
  723. page += 1
  724. result = {
  725. "type": "complete",
  726. "added": added,
  727. "skipped": skipped,
  728. "total_fetched": total_fetched,
  729. "total_available": total_available,
  730. }
  731. yield f"data: {json.dumps(result)}\n\n"
  732. except httpx.HTTPError as e:
  733. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  734. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  735. except Exception as e:
  736. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  737. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  738. return StreamingResponse(generate(), media_type="text/event-stream")
  739. # ── Spool CRUD ───────────────────────────────────────────────────────────────
  740. @router.get("/spools", response_model=list[SpoolResponse])
  741. async def list_spools(
  742. include_archived: bool = False,
  743. db: AsyncSession = Depends(get_db),
  744. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  745. ):
  746. """List all spools, excluding archived by default."""
  747. query = select(Spool).options(selectinload(Spool.k_profiles))
  748. if not include_archived:
  749. query = query.where(Spool.archived_at.is_(None))
  750. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  751. result = await db.execute(query)
  752. return list(result.scalars().all())
  753. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  754. async def get_spool(
  755. spool_id: int,
  756. db: AsyncSession = Depends(get_db),
  757. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  758. ):
  759. """Get a single spool with k_profiles."""
  760. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  761. spool = result.scalar_one_or_none()
  762. if not spool:
  763. raise HTTPException(404, "Spool not found")
  764. return spool
  765. @router.post("/spools", response_model=SpoolResponse)
  766. async def create_spool(
  767. spool_data: SpoolCreate,
  768. db: AsyncSession = Depends(get_db),
  769. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  770. ):
  771. """Create a new spool."""
  772. spool = Spool(**spool_data.model_dump())
  773. db.add(spool)
  774. await db.commit()
  775. await db.refresh(spool)
  776. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  777. await ws_manager.broadcast({"type": "inventory_changed"})
  778. return result.scalar_one()
  779. @router.post("/spools/bulk", response_model=list[SpoolResponse])
  780. async def bulk_create_spools(
  781. data: SpoolBulkCreate,
  782. db: AsyncSession = Depends(get_db),
  783. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  784. ):
  785. """Create multiple identical spools."""
  786. spools = []
  787. for _ in range(data.quantity):
  788. spool = Spool(**data.spool.model_dump())
  789. db.add(spool)
  790. spools.append(spool)
  791. await db.commit()
  792. ids = [s.id for s in spools]
  793. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
  794. await ws_manager.broadcast({"type": "inventory_changed"})
  795. return list(result.scalars().all())
  796. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  797. async def update_spool(
  798. spool_id: int,
  799. spool_data: SpoolUpdate,
  800. db: AsyncSession = Depends(get_db),
  801. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  802. ):
  803. """Update a spool."""
  804. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  805. spool = result.scalar_one_or_none()
  806. if not spool:
  807. raise HTTPException(404, "Spool not found")
  808. update_data = spool_data.model_dump(exclude_unset=True)
  809. # Auto-lock weight when user explicitly sets weight_used
  810. if "weight_used" in update_data and "weight_locked" not in update_data:
  811. update_data["weight_locked"] = True
  812. for field, value in update_data.items():
  813. setattr(spool, field, value)
  814. await db.commit()
  815. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  816. await ws_manager.broadcast({"type": "inventory_changed"})
  817. return result.scalar_one()
  818. @router.delete("/spools/{spool_id}")
  819. async def delete_spool(
  820. spool_id: int,
  821. db: AsyncSession = Depends(get_db),
  822. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  823. ):
  824. """Hard delete a spool."""
  825. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  826. spool = result.scalar_one_or_none()
  827. if not spool:
  828. raise HTTPException(404, "Spool not found")
  829. await db.delete(spool)
  830. await db.commit()
  831. await ws_manager.broadcast({"type": "inventory_changed"})
  832. return {"status": "deleted"}
  833. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  834. async def archive_spool(
  835. spool_id: int,
  836. db: AsyncSession = Depends(get_db),
  837. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  838. ):
  839. """Soft-delete a spool by setting archived_at."""
  840. from datetime import datetime, timezone
  841. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  842. spool = result.scalar_one_or_none()
  843. if not spool:
  844. raise HTTPException(404, "Spool not found")
  845. spool.archived_at = datetime.now(timezone.utc)
  846. await db.commit()
  847. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  848. await ws_manager.broadcast({"type": "inventory_changed"})
  849. return result.scalar_one()
  850. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  851. async def restore_spool(
  852. spool_id: int,
  853. db: AsyncSession = Depends(get_db),
  854. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  855. ):
  856. """Restore an archived spool."""
  857. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  858. spool = result.scalar_one_or_none()
  859. if not spool:
  860. raise HTTPException(404, "Spool not found")
  861. spool.archived_at = None
  862. await db.commit()
  863. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  864. await ws_manager.broadcast({"type": "inventory_changed"})
  865. return result.scalar_one()
  866. # ── K-Profiles ───────────────────────────────────────────────────────────────
  867. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  868. async def list_k_profiles(
  869. spool_id: int,
  870. db: AsyncSession = Depends(get_db),
  871. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  872. ):
  873. """List K-profiles for a spool."""
  874. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  875. return list(result.scalars().all())
  876. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  877. async def replace_k_profiles(
  878. spool_id: int,
  879. profiles: list[SpoolKProfileBase],
  880. db: AsyncSession = Depends(get_db),
  881. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  882. ):
  883. """Replace all K-profiles for a spool (batch save)."""
  884. # Verify spool exists
  885. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  886. if not result.scalar_one_or_none():
  887. raise HTTPException(404, "Spool not found")
  888. # Delete existing
  889. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  890. for old in existing.scalars().all():
  891. await db.delete(old)
  892. # Create new
  893. new_profiles = []
  894. for p in profiles:
  895. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  896. db.add(kp)
  897. new_profiles.append(kp)
  898. await db.commit()
  899. for kp in new_profiles:
  900. await db.refresh(kp)
  901. return new_profiles
  902. # ── Spool Assignments ────────────────────────────────────────────────────────
  903. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  904. async def list_assignments(
  905. printer_id: int | None = None,
  906. db: AsyncSession = Depends(get_db),
  907. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
  908. ):
  909. """List spool assignments, optionally filtered by printer."""
  910. from backend.app.services.printer_manager import printer_manager
  911. query = select(SpoolAssignment).options(
  912. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  913. selectinload(SpoolAssignment.printer),
  914. )
  915. if printer_id is not None:
  916. query = query.where(SpoolAssignment.printer_id == printer_id)
  917. result = await db.execute(query)
  918. assignments = list(result.scalars().all())
  919. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  920. # Fetch all statuses in one call rather than one get_status() call per printer.
  921. serial_map: dict[tuple[int, int], str] = {}
  922. seen_printer_ids: set[int] = {a.printer_id for a in assignments}
  923. all_statuses = printer_manager.get_all_statuses()
  924. for pid in seen_printer_ids:
  925. state = all_statuses.get(pid)
  926. if state and state.raw_data:
  927. for ams_unit in state.raw_data.get("ams", []):
  928. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  929. if sn:
  930. try:
  931. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  932. except (ValueError, TypeError):
  933. continue
  934. # Fetch all relevant AMS labels keyed by serial number
  935. all_serials = set(serial_map.values())
  936. # Also include synthetic fallback keys for assignments without a known serial
  937. synthetic_keys: dict[str, tuple[int, int]] = {}
  938. for a in assignments:
  939. if (a.printer_id, a.ams_id) not in serial_map:
  940. synthetic = f"p{a.printer_id}a{a.ams_id}"
  941. synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
  942. all_serials.add(synthetic)
  943. label_by_serial: dict[str, str] = {}
  944. if all_serials:
  945. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  946. for lbl in lbl_result.scalars().all():
  947. label_by_serial[lbl.ams_serial_number] = lbl.label
  948. # Build response objects, attaching ams_label where available
  949. responses: list[SpoolAssignmentResponse] = []
  950. for a in assignments:
  951. resp = SpoolAssignmentResponse.model_validate(a)
  952. sn = serial_map.get((a.printer_id, a.ams_id))
  953. if sn and sn in label_by_serial:
  954. resp.ams_label = label_by_serial[sn]
  955. elif not sn:
  956. synthetic = f"p{a.printer_id}a{a.ams_id}"
  957. resp.ams_label = label_by_serial.get(synthetic)
  958. responses.append(resp)
  959. return responses
  960. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  961. async def assign_spool(
  962. data: SpoolAssignmentCreate,
  963. db: AsyncSession = Depends(get_db),
  964. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  965. ):
  966. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  967. from backend.app.services.printer_manager import printer_manager
  968. # 1. Validate spool exists and is not archived
  969. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
  970. spool = result.scalar_one_or_none()
  971. if not spool:
  972. raise HTTPException(404, "Spool not found")
  973. if spool.archived_at:
  974. raise HTTPException(400, "Cannot assign an archived spool")
  975. # 2. Get current AMS tray state for fingerprint + existing filament ID
  976. fingerprint_color = None
  977. fingerprint_type = None
  978. current_tray_info_idx = ""
  979. state = printer_manager.get_status(data.printer_id)
  980. if state and state.raw_data:
  981. if data.ams_id == 255:
  982. # External slot: look up tray from vt_tray by global ID
  983. vt_tray = state.raw_data.get("vt_tray") or []
  984. ext_id = data.tray_id + 254 # 0→254, 1→255
  985. for vt in vt_tray:
  986. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  987. fingerprint_color = vt.get("tray_color", "")
  988. fingerprint_type = vt.get("tray_type", "")
  989. current_tray_info_idx = vt.get("tray_info_idx", "")
  990. break
  991. else:
  992. ams_data = state.raw_data.get("ams", {})
  993. ams_list = (
  994. ams_data.get("ams", [])
  995. if isinstance(ams_data, dict)
  996. else ams_data
  997. if isinstance(ams_data, list)
  998. else []
  999. )
  1000. tray = _find_tray_in_ams_data(
  1001. ams_list,
  1002. data.ams_id,
  1003. data.tray_id,
  1004. )
  1005. if tray:
  1006. fingerprint_color = tray.get("tray_color", "")
  1007. fingerprint_type = tray.get("tray_type", "")
  1008. current_tray_info_idx = tray.get("tray_info_idx", "")
  1009. # 3. Upsert assignment (replace if same printer+ams+tray)
  1010. existing = await db.execute(
  1011. select(SpoolAssignment).where(
  1012. SpoolAssignment.printer_id == data.printer_id,
  1013. SpoolAssignment.ams_id == data.ams_id,
  1014. SpoolAssignment.tray_id == data.tray_id,
  1015. )
  1016. )
  1017. old = existing.scalar_one_or_none()
  1018. if old:
  1019. await db.delete(old)
  1020. await db.flush()
  1021. assignment = SpoolAssignment(
  1022. spool_id=data.spool_id,
  1023. printer_id=data.printer_id,
  1024. ams_id=data.ams_id,
  1025. tray_id=data.tray_id,
  1026. fingerprint_color=fingerprint_color,
  1027. fingerprint_type=fingerprint_type,
  1028. )
  1029. db.add(assignment)
  1030. await db.commit()
  1031. await db.refresh(assignment)
  1032. # 4. Auto-configure AMS slot via MQTT.
  1033. #
  1034. # Skip the publish entirely when the target slot is empty: Bambu firmware
  1035. # silently drops ams_filament_setting / extrusion_cali_sel for unloaded
  1036. # slots (there is no filament context for the cali_idx to attach to). The
  1037. # SpoolAssignment row is preserved with an empty fingerprint_type, which
  1038. # acts as the "pending config" marker — when the spool is physically
  1039. # inserted later, on_ams_change re-fires the full configuration. This is
  1040. # the SpoolBuddy primary workflow: weigh-then-assign before insertion.
  1041. slot_is_empty = not (fingerprint_type and fingerprint_type.strip())
  1042. configured = False
  1043. pending_config = slot_is_empty
  1044. if not slot_is_empty:
  1045. try:
  1046. configured = await apply_spool_to_slot_via_mqtt(
  1047. db=db,
  1048. current_user=current_user,
  1049. spool=spool,
  1050. printer_id=data.printer_id,
  1051. ams_id=data.ams_id,
  1052. tray_id=data.tray_id,
  1053. current_tray_info_idx=current_tray_info_idx,
  1054. current_tray_type=fingerprint_type or "",
  1055. )
  1056. except Exception as e:
  1057. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  1058. # Return assignment with spool data
  1059. result = await db.execute(
  1060. select(SpoolAssignment)
  1061. .options(
  1062. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  1063. selectinload(SpoolAssignment.printer),
  1064. )
  1065. .where(SpoolAssignment.id == assignment.id)
  1066. )
  1067. resp = result.scalar_one()
  1068. response = SpoolAssignmentResponse.model_validate(resp)
  1069. response.configured = configured
  1070. response.pending_config = pending_config
  1071. if pending_config:
  1072. logger.info(
  1073. "Pre-configured assignment: spool %d → printer %d AMS%d-T%d (slot empty, will configure on insert)",
  1074. spool.id,
  1075. data.printer_id,
  1076. data.ams_id,
  1077. data.tray_id,
  1078. )
  1079. await ws_manager.broadcast(
  1080. {
  1081. "type": "spool_assignment_changed",
  1082. "printer_id": data.printer_id,
  1083. "ams_id": data.ams_id,
  1084. "tray_id": data.tray_id,
  1085. }
  1086. )
  1087. return response
  1088. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  1089. async def unassign_spool(
  1090. printer_id: int,
  1091. ams_id: int,
  1092. tray_id: int,
  1093. db: AsyncSession = Depends(get_db),
  1094. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1095. ):
  1096. """Unassign a spool from an AMS slot."""
  1097. result = await db.execute(
  1098. select(SpoolAssignment).where(
  1099. SpoolAssignment.printer_id == printer_id,
  1100. SpoolAssignment.ams_id == ams_id,
  1101. SpoolAssignment.tray_id == tray_id,
  1102. )
  1103. )
  1104. assignment = result.scalar_one_or_none()
  1105. if not assignment:
  1106. raise HTTPException(404, "Assignment not found")
  1107. await db.delete(assignment)
  1108. await db.commit()
  1109. await ws_manager.broadcast(
  1110. {
  1111. "type": "spool_assignment_changed",
  1112. "printer_id": printer_id,
  1113. "ams_id": ams_id,
  1114. "tray_id": tray_id,
  1115. }
  1116. )
  1117. return {"status": "deleted"}
  1118. # ── Tag Linking ───────────────────────────────────────────────────────────────
  1119. class LinkTagRequest(BaseModel):
  1120. tag_uid: str | None = None
  1121. tray_uuid: str | None = None
  1122. tag_type: str | None = None
  1123. data_origin: str | None = "nfc_link"
  1124. def _validate_tag_input(
  1125. raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
  1126. ) -> None:
  1127. if raw_value is None:
  1128. return
  1129. raw = str(raw_value).strip()
  1130. if not raw:
  1131. return
  1132. if normalized_value is None:
  1133. raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
  1134. if len(normalized_value) % 2 != 0:
  1135. raise HTTPException(422, f"{field_name} must have an even number of hex characters")
  1136. if exact_len is not None and len(normalized_value) != exact_len:
  1137. raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
  1138. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  1139. async def link_tag_to_spool(
  1140. spool_id: int,
  1141. data: LinkTagRequest,
  1142. db: AsyncSession = Depends(get_db),
  1143. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1144. ):
  1145. """Link an RFID tag_uid/tray_uuid to an existing spool."""
  1146. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1147. spool = result.scalar_one_or_none()
  1148. if not spool:
  1149. raise HTTPException(404, "Spool not found")
  1150. if spool.archived_at:
  1151. raise HTTPException(400, "Cannot link tag to archived spool")
  1152. normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
  1153. normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
  1154. _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
  1155. _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
  1156. # Check for conflicts: tag already linked to another active spool
  1157. if normalized_tag_uid:
  1158. conflict = await db.execute(
  1159. select(Spool).where(
  1160. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1161. Spool.id != spool_id,
  1162. Spool.archived_at.is_(None),
  1163. )
  1164. )
  1165. if conflict.scalar_one_or_none():
  1166. raise HTTPException(409, "Tag UID already linked to another active spool")
  1167. # Auto-clear from archived spools (tag recycling)
  1168. archived_with_tag = await db.execute(
  1169. select(Spool).where(
  1170. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1171. Spool.id != spool_id,
  1172. Spool.archived_at.is_not(None),
  1173. )
  1174. )
  1175. for old_spool in archived_with_tag.scalars().all():
  1176. old_spool.tag_uid = None
  1177. if normalized_tray_uuid:
  1178. conflict = await db.execute(
  1179. select(Spool).where(
  1180. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1181. Spool.id != spool_id,
  1182. Spool.archived_at.is_(None),
  1183. )
  1184. )
  1185. if conflict.scalar_one_or_none():
  1186. raise HTTPException(409, "Tray UUID already linked to another active spool")
  1187. archived_with_uuid = await db.execute(
  1188. select(Spool).where(
  1189. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1190. Spool.id != spool_id,
  1191. Spool.archived_at.is_not(None),
  1192. )
  1193. )
  1194. for old_spool in archived_with_uuid.scalars().all():
  1195. old_spool.tray_uuid = None
  1196. if data.tag_uid is not None:
  1197. spool.tag_uid = normalized_tag_uid
  1198. if data.tray_uuid is not None:
  1199. spool.tray_uuid = normalized_tray_uuid
  1200. if data.tag_type is not None:
  1201. spool.tag_type = data.tag_type
  1202. if data.data_origin is not None:
  1203. spool.data_origin = data.data_origin
  1204. await db.commit()
  1205. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1206. return result.scalar_one()
  1207. # ── Usage History ─────────────────────────────────────────────────────────────
  1208. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  1209. async def get_spool_usage_history(
  1210. spool_id: int,
  1211. limit: int = 50,
  1212. db: AsyncSession = Depends(get_db),
  1213. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1214. ):
  1215. """Get usage history for a specific spool."""
  1216. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1217. # Verify spool exists
  1218. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1219. if not spool_result.scalar_one_or_none():
  1220. raise HTTPException(404, "Spool not found")
  1221. result = await db.execute(
  1222. select(SpoolUsageHistory)
  1223. .where(SpoolUsageHistory.spool_id == spool_id)
  1224. .order_by(SpoolUsageHistory.created_at.desc())
  1225. .limit(limit)
  1226. )
  1227. return list(result.scalars().all())
  1228. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  1229. async def get_all_usage_history(
  1230. limit: int = 100,
  1231. printer_id: int | None = None,
  1232. db: AsyncSession = Depends(get_db),
  1233. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1234. ):
  1235. """Get global usage history, optionally filtered by printer."""
  1236. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1237. query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
  1238. if printer_id is not None:
  1239. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  1240. result = await db.execute(query)
  1241. return list(result.scalars().all())
  1242. @router.delete("/spools/{spool_id}/usage")
  1243. async def clear_spool_usage_history(
  1244. spool_id: int,
  1245. db: AsyncSession = Depends(get_db),
  1246. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1247. ):
  1248. """Clear usage history for a spool."""
  1249. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1250. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  1251. for row in result.scalars().all():
  1252. await db.delete(row)
  1253. await db.commit()
  1254. return {"status": "cleared"}
  1255. # ── AMS Weight Sync ──────────────────────────────────────────────────────────
  1256. @router.post("/sync-ams-weights")
  1257. async def sync_weights_from_ams(
  1258. db: AsyncSession = Depends(get_db),
  1259. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1260. ):
  1261. """Force-sync spool weight_used from live AMS remain% data.
  1262. Overwrites the database weight_used for every assigned spool using the
  1263. current AMS remain% from connected printers. This is a manual recovery
  1264. tool — it bypasses the normal "only increase" guard.
  1265. """
  1266. from backend.app.services.printer_manager import printer_manager
  1267. result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  1268. assignments = list(result.scalars().all())
  1269. logger.info("AMS weight sync: found %d assignments", len(assignments))
  1270. synced = 0
  1271. skipped = 0
  1272. for assignment in assignments:
  1273. spool = assignment.spool
  1274. if not spool:
  1275. logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
  1276. skipped += 1
  1277. continue
  1278. if spool.weight_locked:
  1279. logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
  1280. skipped += 1
  1281. continue
  1282. state = printer_manager.get_status(assignment.printer_id)
  1283. if not state or not state.raw_data:
  1284. logger.info(
  1285. "AMS weight sync: printer %d not connected, skipping spool %d",
  1286. assignment.printer_id,
  1287. spool.id,
  1288. )
  1289. skipped += 1
  1290. continue
  1291. ams_raw = state.raw_data.get("ams", [])
  1292. if isinstance(ams_raw, dict):
  1293. ams_raw = ams_raw.get("ams", [])
  1294. tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
  1295. if not tray:
  1296. logger.info(
  1297. "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  1298. spool.id,
  1299. assignment.printer_id,
  1300. assignment.ams_id,
  1301. assignment.tray_id,
  1302. )
  1303. skipped += 1
  1304. continue
  1305. remain_raw = tray.get("remain")
  1306. if remain_raw is None:
  1307. logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
  1308. skipped += 1
  1309. continue
  1310. try:
  1311. remain_val = int(remain_raw)
  1312. except (TypeError, ValueError):
  1313. skipped += 1
  1314. continue
  1315. if remain_val < 0 or remain_val > 100:
  1316. logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
  1317. skipped += 1
  1318. continue
  1319. lw = spool.label_weight or 1000
  1320. new_used = round(lw * (100 - remain_val) / 100.0, 1)
  1321. old_used = spool.weight_used or 0
  1322. if round(old_used, 1) != new_used:
  1323. logger.info(
  1324. "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
  1325. spool.id,
  1326. old_used,
  1327. new_used,
  1328. remain_val,
  1329. )
  1330. spool.weight_used = new_used
  1331. synced += 1
  1332. else:
  1333. skipped += 1
  1334. await db.commit()
  1335. return {"synced": synced, "skipped": skipped}
  1336. # ── Helpers ──────────────────────────────────────────────────────────────────
  1337. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  1338. """Find a specific tray in the AMS data structure."""
  1339. if not ams_data:
  1340. return None
  1341. for ams_unit in ams_data:
  1342. if int(ams_unit.get("id", -1)) != ams_id:
  1343. continue
  1344. for tray in ams_unit.get("tray", []):
  1345. if int(tray.get("id", -1)) == tray_id:
  1346. return tray
  1347. return None