spoolman.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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 backend.app.core.database import get_db
  8. from backend.app.models.printer import Printer
  9. from backend.app.models.settings import Settings
  10. from backend.app.services.printer_manager import printer_manager
  11. from backend.app.services.spoolman import (
  12. close_spoolman_client,
  13. get_spoolman_client,
  14. init_spoolman_client,
  15. )
  16. logger = logging.getLogger(__name__)
  17. router = APIRouter(prefix="/spoolman", tags=["spoolman"])
  18. class SpoolmanStatus(BaseModel):
  19. """Spoolman connection status."""
  20. enabled: bool
  21. connected: bool
  22. url: str | None
  23. class SkippedSpool(BaseModel):
  24. """Information about a skipped spool during sync."""
  25. location: str # e.g., "AMS A1" or "External Spool"
  26. reason: str # e.g., "Not a Bambu Lab spool", "Empty tray"
  27. filament_type: str | None = None # e.g., "PLA", "PETG"
  28. color: str | None = None # Hex color
  29. class SyncResult(BaseModel):
  30. """Result of a Spoolman sync operation."""
  31. success: bool
  32. synced_count: int
  33. skipped_count: int = 0
  34. skipped: list[SkippedSpool] = []
  35. errors: list[str]
  36. async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
  37. """Get Spoolman settings from database.
  38. Returns:
  39. Tuple of (enabled, url, sync_mode)
  40. """
  41. enabled = False
  42. url = ""
  43. sync_mode = "auto"
  44. result = await db.execute(select(Settings))
  45. for setting in result.scalars().all():
  46. if setting.key == "spoolman_enabled":
  47. enabled = setting.value.lower() == "true"
  48. elif setting.key == "spoolman_url":
  49. url = setting.value
  50. elif setting.key == "spoolman_sync_mode":
  51. sync_mode = setting.value
  52. return enabled, url, sync_mode
  53. @router.get("/status", response_model=SpoolmanStatus)
  54. async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
  55. """Get Spoolman integration status."""
  56. enabled, url, _ = await get_spoolman_settings(db)
  57. client = await get_spoolman_client()
  58. connected = False
  59. if client:
  60. connected = await client.health_check()
  61. return SpoolmanStatus(
  62. enabled=enabled,
  63. connected=connected,
  64. url=url if url else None,
  65. )
  66. @router.post("/connect")
  67. async def connect_spoolman(db: AsyncSession = Depends(get_db)):
  68. """Connect to Spoolman server using configured URL."""
  69. enabled, url, _ = await get_spoolman_settings(db)
  70. if not enabled:
  71. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  72. if not url:
  73. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  74. try:
  75. client = await init_spoolman_client(url)
  76. connected = await client.health_check()
  77. if not connected:
  78. raise HTTPException(
  79. status_code=503,
  80. detail=f"Could not connect to Spoolman at {url}",
  81. )
  82. return {"success": True, "message": f"Connected to Spoolman at {url}"}
  83. except Exception as e:
  84. logger.error(f"Failed to connect to Spoolman: {e}")
  85. raise HTTPException(status_code=503, detail=str(e))
  86. @router.post("/disconnect")
  87. async def disconnect_spoolman():
  88. """Disconnect from Spoolman server."""
  89. await close_spoolman_client()
  90. return {"success": True, "message": "Disconnected from Spoolman"}
  91. @router.post("/sync/{printer_id}", response_model=SyncResult)
  92. async def sync_printer_ams(
  93. printer_id: int,
  94. db: AsyncSession = Depends(get_db),
  95. ):
  96. """Sync AMS data from a specific printer to Spoolman."""
  97. # Check if Spoolman is enabled and connected
  98. enabled, url, _ = await get_spoolman_settings(db)
  99. if not enabled:
  100. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  101. client = await get_spoolman_client()
  102. if not client:
  103. # Try to connect
  104. if url:
  105. client = await init_spoolman_client(url)
  106. else:
  107. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  108. if not await client.health_check():
  109. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  110. # Get printer info
  111. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  112. printer = result.scalar_one_or_none()
  113. if not printer:
  114. raise HTTPException(status_code=404, detail="Printer not found")
  115. # Get current printer state with AMS data
  116. state = printer_manager.get_status(printer_id)
  117. if not state:
  118. raise HTTPException(status_code=404, detail="Printer not connected")
  119. if not state.raw_data:
  120. raise HTTPException(status_code=400, detail="No AMS data available")
  121. ams_data = state.raw_data.get("ams")
  122. if not ams_data:
  123. raise HTTPException(
  124. status_code=400,
  125. detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
  126. )
  127. # Sync each AMS tray to Spoolman
  128. synced = 0
  129. skipped: list[SkippedSpool] = []
  130. errors = []
  131. # Handle different AMS data structures
  132. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  133. # H2D/newer printers: dict with different structure
  134. ams_units = []
  135. if isinstance(ams_data, list):
  136. ams_units = ams_data
  137. elif isinstance(ams_data, dict):
  138. # H2D format: check for "ams" key containing list, or "tray" key directly
  139. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  140. ams_units = ams_data["ams"]
  141. elif "tray" in ams_data:
  142. # Single AMS unit format - wrap in list
  143. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  144. else:
  145. logger.info(f"AMS dict keys for debugging: {list(ams_data.keys())}")
  146. if not ams_units:
  147. raise HTTPException(
  148. status_code=400,
  149. detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
  150. )
  151. for ams_unit in ams_units:
  152. if not isinstance(ams_unit, dict):
  153. continue
  154. ams_id = int(ams_unit.get("id", 0))
  155. trays = ams_unit.get("tray", [])
  156. for tray_data in trays:
  157. if not isinstance(tray_data, dict):
  158. continue
  159. tray = client.parse_ams_tray(ams_id, tray_data)
  160. if not tray:
  161. continue # Empty tray - nothing to sync
  162. # Build location string for reporting
  163. location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
  164. # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
  165. if not client.is_bambu_lab_spool(tray.tray_uuid):
  166. skipped.append(
  167. SkippedSpool(
  168. location=location,
  169. reason="Non-Bambu Lab spool (no RFID tag)",
  170. filament_type=tray.tray_type if tray.tray_type else None,
  171. color=tray.tray_color[:6] if tray.tray_color else None,
  172. )
  173. )
  174. continue
  175. try:
  176. sync_result = await client.sync_ams_tray(tray, printer.name)
  177. if sync_result:
  178. synced += 1
  179. logger.info(f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}")
  180. else:
  181. # Bambu Lab spool that wasn't synced (not found in Spoolman)
  182. errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
  183. except Exception as e:
  184. error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
  185. logger.error(error_msg)
  186. errors.append(error_msg)
  187. return SyncResult(
  188. success=len(errors) == 0,
  189. synced_count=synced,
  190. skipped_count=len(skipped),
  191. skipped=skipped,
  192. errors=errors,
  193. )
  194. @router.post("/sync-all", response_model=SyncResult)
  195. async def sync_all_printers(db: AsyncSession = Depends(get_db)):
  196. """Sync AMS data from all connected printers to Spoolman."""
  197. # Check if Spoolman is enabled
  198. enabled, url, _ = await get_spoolman_settings(db)
  199. if not enabled:
  200. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  201. client = await get_spoolman_client()
  202. if not client:
  203. if url:
  204. client = await init_spoolman_client(url)
  205. else:
  206. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  207. if not await client.health_check():
  208. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  209. # Get all active printers
  210. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  211. printers = result.scalars().all()
  212. total_synced = 0
  213. all_skipped: list[SkippedSpool] = []
  214. all_errors = []
  215. for printer in printers:
  216. state = printer_manager.get_status(printer.id)
  217. if not state or not state.raw_data:
  218. continue
  219. ams_data = state.raw_data.get("ams")
  220. if not ams_data:
  221. continue
  222. # Handle different AMS data structures
  223. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  224. # H2D/newer printers: dict with different structure
  225. ams_units = []
  226. if isinstance(ams_data, list):
  227. ams_units = ams_data
  228. elif isinstance(ams_data, dict):
  229. # H2D format: check for "ams" key containing list, or "tray" key directly
  230. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  231. ams_units = ams_data["ams"]
  232. elif "tray" in ams_data:
  233. # Single AMS unit format - wrap in list
  234. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  235. else:
  236. logger.debug(f"Printer {printer.name} AMS dict keys: {list(ams_data.keys())}")
  237. if not ams_units:
  238. logger.debug(f"Printer {printer.name} has no AMS units to sync (type: {type(ams_data).__name__})")
  239. continue
  240. for ams_unit in ams_units:
  241. if not isinstance(ams_unit, dict):
  242. logger.debug(f"Skipping non-dict AMS unit: {type(ams_unit)}")
  243. continue
  244. ams_id = int(ams_unit.get("id", 0))
  245. trays = ams_unit.get("tray", [])
  246. for tray_data in trays:
  247. if not isinstance(tray_data, dict):
  248. continue
  249. tray = client.parse_ams_tray(ams_id, tray_data)
  250. if not tray:
  251. continue
  252. # Build location string for reporting
  253. location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}"
  254. # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
  255. if not client.is_bambu_lab_spool(tray.tray_uuid):
  256. all_skipped.append(
  257. SkippedSpool(
  258. location=location,
  259. reason="Non-Bambu Lab spool (no RFID tag)",
  260. filament_type=tray.tray_type if tray.tray_type else None,
  261. color=tray.tray_color[:6] if tray.tray_color else None,
  262. )
  263. )
  264. continue
  265. try:
  266. sync_result = await client.sync_ams_tray(tray, printer.name)
  267. if sync_result:
  268. total_synced += 1
  269. except Exception as e:
  270. all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
  271. return SyncResult(
  272. success=len(all_errors) == 0,
  273. synced_count=total_synced,
  274. skipped_count=len(all_skipped),
  275. skipped=all_skipped,
  276. errors=all_errors,
  277. )
  278. @router.get("/spools")
  279. async def get_spools(db: AsyncSession = Depends(get_db)):
  280. """Get all spools from Spoolman."""
  281. enabled, url, _ = await get_spoolman_settings(db)
  282. if not enabled:
  283. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  284. client = await get_spoolman_client()
  285. if not client:
  286. if url:
  287. client = await init_spoolman_client(url)
  288. else:
  289. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  290. if not await client.health_check():
  291. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  292. spools = await client.get_spools()
  293. return {"spools": spools}
  294. @router.get("/filaments")
  295. async def get_filaments(db: AsyncSession = Depends(get_db)):
  296. """Get all filaments from Spoolman."""
  297. enabled, url, _ = await get_spoolman_settings(db)
  298. if not enabled:
  299. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  300. client = await get_spoolman_client()
  301. if not client:
  302. if url:
  303. client = await init_spoolman_client(url)
  304. else:
  305. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  306. if not await client.health_check():
  307. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  308. filaments = await client.get_filaments()
  309. return {"filaments": filaments}
  310. class UnlinkedSpool(BaseModel):
  311. """A Spoolman spool that is not linked to any AMS tray."""
  312. id: int
  313. filament_name: str | None
  314. filament_material: str | None
  315. filament_color_hex: str | None
  316. remaining_weight: float | None
  317. location: str | None
  318. @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
  319. async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
  320. """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
  321. enabled, url, _ = await get_spoolman_settings(db)
  322. if not enabled:
  323. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  324. client = await get_spoolman_client()
  325. if not client:
  326. if url:
  327. client = await init_spoolman_client(url)
  328. else:
  329. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  330. if not await client.health_check():
  331. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  332. spools = await client.get_spools()
  333. unlinked = []
  334. for spool in spools:
  335. # Check if spool has a tag in extra field
  336. extra = spool.get("extra", {}) or {}
  337. tag = extra.get("tag", "")
  338. if not tag:
  339. filament = spool.get("filament", {}) or {}
  340. unlinked.append(
  341. UnlinkedSpool(
  342. id=spool["id"],
  343. filament_name=filament.get("name"),
  344. filament_material=filament.get("material"),
  345. filament_color_hex=filament.get("color_hex"),
  346. remaining_weight=spool.get("remaining_weight"),
  347. location=spool.get("location"),
  348. )
  349. )
  350. return unlinked
  351. class LinkSpoolRequest(BaseModel):
  352. """Request to link a Spoolman spool to an AMS tray."""
  353. tray_uuid: str
  354. @router.post("/spools/{spool_id}/link")
  355. async def link_spool(
  356. spool_id: int,
  357. request: LinkSpoolRequest,
  358. db: AsyncSession = Depends(get_db),
  359. ):
  360. """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
  361. enabled, url, _ = await get_spoolman_settings(db)
  362. if not enabled:
  363. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  364. client = await get_spoolman_client()
  365. if not client:
  366. if url:
  367. client = await init_spoolman_client(url)
  368. else:
  369. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  370. if not await client.health_check():
  371. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  372. # Validate tray_uuid format (32 hex characters)
  373. tray_uuid = request.tray_uuid.strip()
  374. if len(tray_uuid) != 32:
  375. raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
  376. try:
  377. int(tray_uuid, 16)
  378. except ValueError:
  379. raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
  380. # Update spool with tag
  381. # Note: Spoolman extra field values must be valid JSON, so we encode the string
  382. import json
  383. result = await client.update_spool(
  384. spool_id=spool_id,
  385. extra={"tag": json.dumps(tray_uuid)},
  386. )
  387. if result:
  388. logger.info(f"Linked Spoolman spool {spool_id} to tray_uuid {tray_uuid}")
  389. return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
  390. else:
  391. raise HTTPException(status_code=500, detail="Failed to update spool")