spoolman.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. """Spoolman integration API routes."""
  2. import logging
  3. from fastapi import APIRouter, Depends, HTTPException
  4. from pydantic import BaseModel
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from sqlalchemy.orm import selectinload
  8. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  9. from backend.app.core.database import get_db
  10. from backend.app.core.permissions import Permission
  11. from backend.app.models.printer import Printer
  12. from backend.app.models.settings import Settings
  13. from backend.app.models.spool_assignment import SpoolAssignment
  14. from backend.app.models.user import User
  15. from backend.app.services.printer_manager import printer_manager
  16. from backend.app.services.spoolman import (
  17. close_spoolman_client,
  18. get_spoolman_client,
  19. init_spoolman_client,
  20. )
  21. logger = logging.getLogger(__name__)
  22. router = APIRouter(prefix="/spoolman", tags=["spoolman"])
  23. class SpoolmanStatus(BaseModel):
  24. """Spoolman connection status."""
  25. enabled: bool
  26. connected: bool
  27. url: str | None
  28. class SkippedSpool(BaseModel):
  29. """Information about a skipped spool during sync."""
  30. location: str # e.g., "AMS A1" or "External Spool"
  31. reason: str # e.g., "Not a Bambu Lab spool", "Empty tray"
  32. filament_type: str | None = None # e.g., "PLA", "PETG"
  33. color: str | None = None # Hex color
  34. class SyncResult(BaseModel):
  35. """Result of a Spoolman sync operation."""
  36. success: bool
  37. synced_count: int
  38. skipped_count: int = 0
  39. skipped: list[SkippedSpool] = []
  40. errors: list[str]
  41. async def get_spoolman_settings(db: AsyncSession) -> dict:
  42. """Get Spoolman settings from database.
  43. Returns:
  44. Dict with keys: enabled, url, sync_mode, disable_weight_sync
  45. """
  46. settings = {
  47. "enabled": False,
  48. "url": "",
  49. "sync_mode": "auto",
  50. "disable_weight_sync": False,
  51. }
  52. result = await db.execute(select(Settings))
  53. for setting in result.scalars().all():
  54. if setting.key == "spoolman_enabled":
  55. settings["enabled"] = setting.value.lower() == "true"
  56. elif setting.key == "spoolman_url":
  57. settings["url"] = setting.value
  58. elif setting.key == "spoolman_sync_mode":
  59. settings["sync_mode"] = setting.value
  60. elif setting.key == "spoolman_disable_weight_sync":
  61. settings["disable_weight_sync"] = setting.value.lower() == "true"
  62. return settings
  63. @router.get("/status", response_model=SpoolmanStatus)
  64. async def get_spoolman_status(
  65. db: AsyncSession = Depends(get_db),
  66. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  67. ):
  68. """Get Spoolman integration status."""
  69. sm = await get_spoolman_settings(db)
  70. enabled, url = sm["enabled"], sm["url"]
  71. client = await get_spoolman_client()
  72. connected = False
  73. if client:
  74. connected = await client.health_check()
  75. return SpoolmanStatus(
  76. enabled=enabled,
  77. connected=connected,
  78. url=url if url else None,
  79. )
  80. @router.post("/connect")
  81. async def connect_spoolman(
  82. db: AsyncSession = Depends(get_db),
  83. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  84. ):
  85. """Connect to Spoolman server using configured URL."""
  86. sm = await get_spoolman_settings(db)
  87. enabled, url = sm["enabled"], sm["url"]
  88. if not enabled:
  89. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  90. if not url:
  91. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  92. try:
  93. client = await init_spoolman_client(url)
  94. connected = await client.health_check()
  95. if not connected:
  96. raise HTTPException(
  97. status_code=503,
  98. detail=f"Could not connect to Spoolman at {url}",
  99. )
  100. # Ensure the 'tag' extra field exists for RFID/UUID storage
  101. await client.ensure_tag_extra_field()
  102. return {"success": True, "message": f"Connected to Spoolman at {url}"}
  103. except Exception as e:
  104. logger.error("Failed to connect to Spoolman: %s", e)
  105. raise HTTPException(status_code=503, detail=str(e))
  106. @router.post("/disconnect")
  107. async def disconnect_spoolman(
  108. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  109. ):
  110. """Disconnect from Spoolman server."""
  111. await close_spoolman_client()
  112. return {"success": True, "message": "Disconnected from Spoolman"}
  113. @router.post("/sync/{printer_id}", response_model=SyncResult)
  114. async def sync_printer_ams(
  115. printer_id: int,
  116. db: AsyncSession = Depends(get_db),
  117. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  118. ):
  119. """Sync AMS data from a specific printer to Spoolman."""
  120. # Check if Spoolman is enabled and connected
  121. sm = await get_spoolman_settings(db)
  122. enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
  123. if not enabled:
  124. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  125. client = await get_spoolman_client()
  126. if not client:
  127. # Try to connect
  128. if url:
  129. client = await init_spoolman_client(url)
  130. else:
  131. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  132. if not await client.health_check():
  133. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  134. # Get printer info
  135. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  136. printer = result.scalar_one_or_none()
  137. if not printer:
  138. raise HTTPException(status_code=404, detail="Printer not found")
  139. # Get current printer state with AMS data
  140. state = printer_manager.get_status(printer_id)
  141. if not state:
  142. raise HTTPException(status_code=404, detail="Printer not connected")
  143. if not state.raw_data:
  144. raise HTTPException(status_code=400, detail="No AMS data available")
  145. ams_data = state.raw_data.get("ams")
  146. if not ams_data:
  147. raise HTTPException(
  148. status_code=400,
  149. detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
  150. )
  151. # Sync each AMS tray to Spoolman
  152. synced = 0
  153. skipped: list[SkippedSpool] = []
  154. errors = []
  155. # Track tray UUIDs currently in the AMS (for clearing removed spools)
  156. current_tray_uuids: set[str] = set()
  157. # Handle different AMS data structures
  158. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  159. # H2D/newer printers: dict with different structure
  160. ams_units = []
  161. if isinstance(ams_data, list):
  162. ams_units = ams_data
  163. elif isinstance(ams_data, dict):
  164. # H2D format: check for "ams" key containing list, or "tray" key directly
  165. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  166. ams_units = ams_data["ams"]
  167. elif "tray" in ams_data:
  168. # Single AMS unit format - wrap in list
  169. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  170. else:
  171. logger.info("AMS dict keys for debugging: %s", list(ams_data.keys()))
  172. if not ams_units:
  173. raise HTTPException(
  174. status_code=400,
  175. detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
  176. )
  177. # OPTIMIZATION: Fetch all spools once before processing trays
  178. # This eliminates redundant API calls (one per tray) when syncing multiple trays
  179. logger.debug("[Printer %s] Fetching spools cache for sync...", printer.name)
  180. try:
  181. cached_spools = await client.get_spools()
  182. logger.debug("[Printer %s] Cached %d spools for batch sync", printer.name, len(cached_spools))
  183. except Exception as e:
  184. logger.error("[Printer %s] Failed to fetch spools cache after retries: %s", printer.name, e)
  185. raise HTTPException(
  186. status_code=503,
  187. detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
  188. )
  189. # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
  190. inv_weights: dict[tuple[int, int], float] = {}
  191. try:
  192. assign_result = await db.execute(
  193. select(SpoolAssignment)
  194. .options(selectinload(SpoolAssignment.spool))
  195. .where(SpoolAssignment.printer_id == printer_id)
  196. )
  197. for assignment in assign_result.scalars().all():
  198. spool = assignment.spool
  199. if spool and spool.label_weight > 0:
  200. remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
  201. inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
  202. except Exception as e:
  203. logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
  204. for ams_unit in ams_units:
  205. if not isinstance(ams_unit, dict):
  206. continue
  207. ams_id = int(ams_unit.get("id", 0))
  208. trays = ams_unit.get("tray", [])
  209. for tray_data in trays:
  210. if not isinstance(tray_data, dict):
  211. continue
  212. tray = client.parse_ams_tray(ams_id, tray_data)
  213. if not tray:
  214. continue # Empty tray - nothing to sync
  215. # Build location string for reporting
  216. location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
  217. # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
  218. if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
  219. skipped.append(
  220. SkippedSpool(
  221. location=location,
  222. reason="Non-Bambu Lab spool (no RFID tag)",
  223. filament_type=tray.tray_type if tray.tray_type else None,
  224. color=tray.tray_color[:6] if tray.tray_color else None,
  225. )
  226. )
  227. continue
  228. # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
  229. spool_tag = (
  230. tray.tray_uuid
  231. if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
  232. else tray.tag_uid
  233. )
  234. if spool_tag:
  235. current_tray_uuids.add(spool_tag.upper())
  236. try:
  237. inv_remaining = inv_weights.get((ams_id, tray.tray_id))
  238. sync_result = await client.sync_ams_tray(
  239. tray,
  240. printer.name,
  241. disable_weight_sync=disable_weight_sync,
  242. cached_spools=cached_spools,
  243. inventory_remaining=inv_remaining,
  244. )
  245. if sync_result:
  246. synced += 1
  247. # Add newly created spool to cache
  248. if sync_result.get("id"):
  249. spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
  250. if not spool_exists:
  251. cached_spools.append(sync_result)
  252. logger.debug("Added newly created spool %s to cache", sync_result["id"])
  253. logger.info(
  254. "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
  255. )
  256. else:
  257. # Bambu Lab spool that wasn't synced (not found in Spoolman)
  258. errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
  259. except Exception as e:
  260. error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
  261. logger.error(error_msg)
  262. errors.append(error_msg)
  263. # Clear location for spools that were removed from this printer's AMS
  264. try:
  265. cleared = await client.clear_location_for_removed_spools(
  266. printer.name, current_tray_uuids, cached_spools=cached_spools
  267. )
  268. if cleared > 0:
  269. logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
  270. except Exception as e:
  271. logger.error("Error clearing locations for removed spools: %s", e)
  272. return SyncResult(
  273. success=len(errors) == 0,
  274. synced_count=synced,
  275. skipped_count=len(skipped),
  276. skipped=skipped,
  277. errors=errors,
  278. )
  279. @router.post("/sync-all", response_model=SyncResult)
  280. async def sync_all_printers(
  281. db: AsyncSession = Depends(get_db),
  282. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  283. ):
  284. """Sync AMS data from all connected printers to Spoolman."""
  285. # Check if Spoolman is enabled
  286. sm = await get_spoolman_settings(db)
  287. enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
  288. if not enabled:
  289. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  290. client = await get_spoolman_client()
  291. if not client:
  292. if url:
  293. client = await init_spoolman_client(url)
  294. else:
  295. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  296. if not await client.health_check():
  297. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  298. # Get all active printers
  299. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  300. printers = result.scalars().all()
  301. total_synced = 0
  302. all_skipped: list[SkippedSpool] = []
  303. all_errors = []
  304. # Track tray UUIDs per printer (for clearing removed spools)
  305. printer_tray_uuids: dict[str, set[str]] = {}
  306. # Track synced spool IDs per printer (for location-based cleanup when no UUIDs available)
  307. printer_synced_ids: dict[str, set[int]] = {}
  308. # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
  309. # This eliminates redundant API calls across all printers
  310. logger.debug("Fetching spools cache for sync-all operation...")
  311. try:
  312. cached_spools = await client.get_spools()
  313. logger.debug("Cached %d spools for batch sync across %d printers", len(cached_spools), len(printers))
  314. except Exception as e:
  315. logger.error("Failed to fetch spools cache after retries: %s", e)
  316. raise HTTPException(
  317. status_code=503,
  318. detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
  319. )
  320. # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
  321. # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
  322. inventory_weights: dict[tuple[int, int, int], float] = {}
  323. try:
  324. assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  325. for assignment in assign_result.scalars().all():
  326. spool = assignment.spool
  327. if spool and spool.label_weight > 0:
  328. remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
  329. inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
  330. except Exception as e:
  331. logger.debug("Could not load inventory assignments for weight fallback: %s", e)
  332. for printer in printers:
  333. state = printer_manager.get_status(printer.id)
  334. if not state or not state.raw_data:
  335. continue
  336. ams_data = state.raw_data.get("ams")
  337. if not ams_data:
  338. continue
  339. # Initialize tracking sets for this printer
  340. printer_tray_uuids[printer.name] = set()
  341. printer_synced_ids[printer.name] = set()
  342. # Handle different AMS data structures
  343. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  344. # H2D/newer printers: dict with different structure
  345. ams_units = []
  346. if isinstance(ams_data, list):
  347. ams_units = ams_data
  348. elif isinstance(ams_data, dict):
  349. # H2D format: check for "ams" key containing list, or "tray" key directly
  350. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  351. ams_units = ams_data["ams"]
  352. elif "tray" in ams_data:
  353. # Single AMS unit format - wrap in list
  354. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  355. else:
  356. logger.debug("Printer %s AMS dict keys: %s", printer.name, list(ams_data.keys()))
  357. if not ams_units:
  358. logger.debug("Printer %s has no AMS units to sync (type: %s)", printer.name, type(ams_data).__name__)
  359. continue
  360. for ams_unit in ams_units:
  361. if not isinstance(ams_unit, dict):
  362. logger.debug("Skipping non-dict AMS unit: %s", type(ams_unit))
  363. continue
  364. ams_id = int(ams_unit.get("id", 0))
  365. trays = ams_unit.get("tray", [])
  366. for tray_data in trays:
  367. if not isinstance(tray_data, dict):
  368. continue
  369. tray = client.parse_ams_tray(ams_id, tray_data)
  370. if not tray:
  371. continue
  372. # Build location string for reporting
  373. location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}"
  374. # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
  375. if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
  376. all_skipped.append(
  377. SkippedSpool(
  378. location=location,
  379. reason="Non-Bambu Lab spool (no RFID tag)",
  380. filament_type=tray.tray_type if tray.tray_type else None,
  381. color=tray.tray_color[:6] if tray.tray_color else None,
  382. )
  383. )
  384. continue
  385. # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
  386. spool_tag = (
  387. tray.tray_uuid
  388. if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
  389. else tray.tag_uid
  390. )
  391. if spool_tag:
  392. printer_tray_uuids[printer.name].add(spool_tag.upper())
  393. try:
  394. # Look up inventory weight as fallback when AMS data is invalid
  395. inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))
  396. sync_result = await client.sync_ams_tray(
  397. tray,
  398. printer.name,
  399. disable_weight_sync=disable_weight_sync,
  400. cached_spools=cached_spools,
  401. inventory_remaining=inv_remaining,
  402. )
  403. if sync_result:
  404. total_synced += 1
  405. # Track synced spool ID for cleanup
  406. if sync_result.get("id"):
  407. printer_synced_ids[printer.name].add(sync_result["id"])
  408. # Add newly created spool to cache
  409. spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
  410. if not spool_exists:
  411. cached_spools.append(sync_result)
  412. logger.debug("Added newly created spool %s to cache", sync_result["id"])
  413. except Exception as e:
  414. all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
  415. # Clear location for spools that were removed from each printer's AMS
  416. for printer_name, current_tray_uuids in printer_tray_uuids.items():
  417. try:
  418. cleared = await client.clear_location_for_removed_spools(
  419. printer_name,
  420. current_tray_uuids,
  421. cached_spools=cached_spools,
  422. synced_spool_ids=printer_synced_ids.get(printer_name, set()),
  423. )
  424. if cleared > 0:
  425. logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
  426. except Exception as e:
  427. logger.error("Error clearing locations for %s: %s", printer_name, e)
  428. return SyncResult(
  429. success=len(all_errors) == 0,
  430. synced_count=total_synced,
  431. skipped_count=len(all_skipped),
  432. skipped=all_skipped,
  433. errors=all_errors,
  434. )
  435. @router.get("/spools")
  436. async def get_spools(
  437. db: AsyncSession = Depends(get_db),
  438. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  439. ):
  440. """Get all spools from Spoolman."""
  441. sm = await get_spoolman_settings(db)
  442. enabled, url = sm["enabled"], sm["url"]
  443. if not enabled:
  444. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  445. client = await get_spoolman_client()
  446. if not client:
  447. if url:
  448. client = await init_spoolman_client(url)
  449. else:
  450. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  451. if not await client.health_check():
  452. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  453. spools = await client.get_spools()
  454. return {"spools": spools}
  455. @router.get("/filaments")
  456. async def get_filaments(
  457. db: AsyncSession = Depends(get_db),
  458. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  459. ):
  460. """Get all filaments from Spoolman."""
  461. sm = await get_spoolman_settings(db)
  462. enabled, url = sm["enabled"], sm["url"]
  463. if not enabled:
  464. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  465. client = await get_spoolman_client()
  466. if not client:
  467. if url:
  468. client = await init_spoolman_client(url)
  469. else:
  470. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  471. if not await client.health_check():
  472. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  473. filaments = await client.get_filaments()
  474. return {"filaments": filaments}
  475. class UnlinkedSpool(BaseModel):
  476. """A Spoolman spool that is not linked to any AMS tray."""
  477. id: int
  478. filament_name: str | None
  479. filament_material: str | None
  480. filament_color_hex: str | None
  481. remaining_weight: float | None
  482. location: str | None
  483. @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
  484. async def get_unlinked_spools(
  485. db: AsyncSession = Depends(get_db),
  486. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  487. ):
  488. """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
  489. sm = await get_spoolman_settings(db)
  490. enabled, url = sm["enabled"], sm["url"]
  491. if not enabled:
  492. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  493. client = await get_spoolman_client()
  494. if not client:
  495. if url:
  496. client = await init_spoolman_client(url)
  497. else:
  498. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  499. if not await client.health_check():
  500. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  501. spools = await client.get_spools()
  502. unlinked = []
  503. for spool in spools:
  504. # Check if spool has a tag in extra field
  505. extra = spool.get("extra", {}) or {}
  506. tag = extra.get("tag", "")
  507. # Remove quotes if present (JSON encoded string) and check if empty
  508. clean_tag = tag.strip('"') if tag else ""
  509. if not clean_tag:
  510. filament = spool.get("filament", {}) or {}
  511. unlinked.append(
  512. UnlinkedSpool(
  513. id=spool["id"],
  514. filament_name=filament.get("name"),
  515. filament_material=filament.get("material"),
  516. filament_color_hex=filament.get("color_hex"),
  517. remaining_weight=spool.get("remaining_weight"),
  518. location=spool.get("location"),
  519. )
  520. )
  521. return unlinked
  522. @router.get("/spools/linked")
  523. async def get_linked_spools(
  524. db: AsyncSession = Depends(get_db),
  525. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  526. ):
  527. """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
  528. sm = await get_spoolman_settings(db)
  529. enabled, url = sm["enabled"], sm["url"]
  530. if not enabled:
  531. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  532. client = await get_spoolman_client()
  533. if not client:
  534. if url:
  535. client = await init_spoolman_client(url)
  536. else:
  537. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  538. if not await client.health_check():
  539. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  540. spools = await client.get_spools()
  541. linked: dict[str, dict] = {}
  542. for spool in spools:
  543. # Check if spool has a tag in extra field
  544. extra = spool.get("extra", {}) or {}
  545. tag = extra.get("tag", "")
  546. if tag:
  547. # Remove quotes if present (JSON encoded string)
  548. clean_tag = tag.strip('"').upper()
  549. if clean_tag:
  550. filament = spool.get("filament") or {}
  551. linked[clean_tag] = {
  552. "id": spool["id"],
  553. "remaining_weight": spool.get("remaining_weight"),
  554. "filament_weight": filament.get("weight"),
  555. }
  556. return {"linked": linked}
  557. class LinkSpoolRequest(BaseModel):
  558. """Request to link a Spoolman spool to an AMS tray."""
  559. tray_uuid: str
  560. @router.post("/spools/{spool_id}/link")
  561. async def link_spool(
  562. spool_id: int,
  563. request: LinkSpoolRequest,
  564. db: AsyncSession = Depends(get_db),
  565. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  566. ):
  567. """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
  568. sm = await get_spoolman_settings(db)
  569. enabled, url = sm["enabled"], sm["url"]
  570. if not enabled:
  571. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  572. client = await get_spoolman_client()
  573. if not client:
  574. if url:
  575. client = await init_spoolman_client(url)
  576. else:
  577. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  578. if not await client.health_check():
  579. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  580. # Validate tray_uuid format (32 hex characters)
  581. tray_uuid = request.tray_uuid.strip()
  582. if len(tray_uuid) != 32:
  583. raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
  584. try:
  585. int(tray_uuid, 16)
  586. except ValueError:
  587. raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
  588. # Update spool with tag
  589. # Note: Spoolman extra field values must be valid JSON, so we encode the string
  590. import json
  591. result = await client.update_spool(
  592. spool_id=spool_id,
  593. extra={"tag": json.dumps(tray_uuid)},
  594. )
  595. if result:
  596. logger.info("Linked Spoolman spool %s to tray_uuid %s", spool_id, tray_uuid)
  597. return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
  598. else:
  599. raise HTTPException(status_code=500, detail="Failed to update spool")