spoolman.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. """Spoolman integration API routes."""
  2. import logging
  3. from fastapi import APIRouter, Depends, HTTPException
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from sqlalchemy import select
  6. from pydantic import BaseModel
  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.spoolman import (
  11. SpoolmanClient,
  12. get_spoolman_client,
  13. init_spoolman_client,
  14. close_spoolman_client,
  15. )
  16. from backend.app.services.printer_manager import printer_manager
  17. logger = logging.getLogger(__name__)
  18. router = APIRouter(prefix="/spoolman", tags=["spoolman"])
  19. class SpoolmanStatus(BaseModel):
  20. """Spoolman connection status."""
  21. enabled: bool
  22. connected: bool
  23. url: str | None
  24. class SyncResult(BaseModel):
  25. """Result of a Spoolman sync operation."""
  26. success: bool
  27. synced_count: int
  28. errors: list[str]
  29. async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
  30. """Get Spoolman settings from database.
  31. Returns:
  32. Tuple of (enabled, url, sync_mode)
  33. """
  34. enabled = False
  35. url = ""
  36. sync_mode = "auto"
  37. result = await db.execute(select(Settings))
  38. for setting in result.scalars().all():
  39. if setting.key == "spoolman_enabled":
  40. enabled = setting.value.lower() == "true"
  41. elif setting.key == "spoolman_url":
  42. url = setting.value
  43. elif setting.key == "spoolman_sync_mode":
  44. sync_mode = setting.value
  45. return enabled, url, sync_mode
  46. @router.get("/status", response_model=SpoolmanStatus)
  47. async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
  48. """Get Spoolman integration status."""
  49. enabled, url, _ = await get_spoolman_settings(db)
  50. client = await get_spoolman_client()
  51. connected = False
  52. if client:
  53. connected = await client.health_check()
  54. return SpoolmanStatus(
  55. enabled=enabled,
  56. connected=connected,
  57. url=url if url else None,
  58. )
  59. @router.post("/connect")
  60. async def connect_spoolman(db: AsyncSession = Depends(get_db)):
  61. """Connect to Spoolman server using configured URL."""
  62. enabled, url, _ = await get_spoolman_settings(db)
  63. if not enabled:
  64. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  65. if not url:
  66. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  67. try:
  68. client = await init_spoolman_client(url)
  69. connected = await client.health_check()
  70. if not connected:
  71. raise HTTPException(
  72. status_code=503,
  73. detail=f"Could not connect to Spoolman at {url}",
  74. )
  75. return {"success": True, "message": f"Connected to Spoolman at {url}"}
  76. except Exception as e:
  77. logger.error(f"Failed to connect to Spoolman: {e}")
  78. raise HTTPException(status_code=503, detail=str(e))
  79. @router.post("/disconnect")
  80. async def disconnect_spoolman():
  81. """Disconnect from Spoolman server."""
  82. await close_spoolman_client()
  83. return {"success": True, "message": "Disconnected from Spoolman"}
  84. @router.post("/sync/{printer_id}", response_model=SyncResult)
  85. async def sync_printer_ams(
  86. printer_id: int,
  87. db: AsyncSession = Depends(get_db),
  88. ):
  89. """Sync AMS data from a specific printer to Spoolman."""
  90. # Check if Spoolman is enabled and connected
  91. enabled, url, _ = await get_spoolman_settings(db)
  92. if not enabled:
  93. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  94. client = await get_spoolman_client()
  95. if not client:
  96. # Try to connect
  97. if url:
  98. client = await init_spoolman_client(url)
  99. else:
  100. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  101. if not await client.health_check():
  102. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  103. # Get printer info
  104. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  105. printer = result.scalar_one_or_none()
  106. if not printer:
  107. raise HTTPException(status_code=404, detail="Printer not found")
  108. # Get current printer state with AMS data
  109. state = printer_manager.get_status(printer_id)
  110. if not state:
  111. raise HTTPException(status_code=404, detail="Printer not connected")
  112. if not state.raw_data:
  113. raise HTTPException(status_code=400, detail="No AMS data available")
  114. ams_data = state.raw_data.get("ams")
  115. if not ams_data:
  116. raise HTTPException(
  117. status_code=400,
  118. detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
  119. )
  120. # Sync each AMS tray to Spoolman
  121. synced = 0
  122. errors = []
  123. # Handle different AMS data structures
  124. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  125. # H2D/newer printers: dict with different structure
  126. ams_units = []
  127. if isinstance(ams_data, list):
  128. ams_units = ams_data
  129. elif isinstance(ams_data, dict):
  130. # H2D format: check for "ams" key containing list, or "tray" key directly
  131. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  132. ams_units = ams_data["ams"]
  133. elif "tray" in ams_data:
  134. # Single AMS unit format - wrap in list
  135. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  136. else:
  137. logger.info(f"AMS dict keys for debugging: {list(ams_data.keys())}")
  138. if not ams_units:
  139. raise HTTPException(
  140. status_code=400,
  141. detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
  142. )
  143. for ams_unit in ams_units:
  144. if not isinstance(ams_unit, dict):
  145. continue
  146. ams_id = int(ams_unit.get("id", 0))
  147. trays = ams_unit.get("tray", [])
  148. for tray_data in trays:
  149. if not isinstance(tray_data, dict):
  150. continue
  151. tray = client.parse_ams_tray(ams_id, tray_data)
  152. if not tray:
  153. continue # Empty tray
  154. # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
  155. if not client.is_bambu_lab_spool(tray.tray_uuid):
  156. continue
  157. try:
  158. sync_result = await client.sync_ams_tray(tray, printer.name)
  159. if sync_result:
  160. synced += 1
  161. logger.info(
  162. f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}"
  163. )
  164. else:
  165. # Bambu Lab spool that wasn't synced (not found in Spoolman)
  166. errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
  167. except Exception as e:
  168. error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
  169. logger.error(error_msg)
  170. errors.append(error_msg)
  171. return SyncResult(
  172. success=len(errors) == 0,
  173. synced_count=synced,
  174. errors=errors,
  175. )
  176. @router.post("/sync-all", response_model=SyncResult)
  177. async def sync_all_printers(db: AsyncSession = Depends(get_db)):
  178. """Sync AMS data from all connected printers to Spoolman."""
  179. # Check if Spoolman is enabled
  180. enabled, url, _ = await get_spoolman_settings(db)
  181. if not enabled:
  182. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  183. client = await get_spoolman_client()
  184. if not client:
  185. if url:
  186. client = await init_spoolman_client(url)
  187. else:
  188. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  189. if not await client.health_check():
  190. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  191. # Get all active printers
  192. result = await db.execute(select(Printer).where(Printer.is_active == True))
  193. printers = result.scalars().all()
  194. total_synced = 0
  195. all_errors = []
  196. for printer in printers:
  197. state = printer_manager.get_status(printer.id)
  198. if not state or not state.raw_data:
  199. continue
  200. ams_data = state.raw_data.get("ams")
  201. if not ams_data:
  202. continue
  203. # Handle different AMS data structures
  204. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  205. # H2D/newer printers: dict with different structure
  206. ams_units = []
  207. if isinstance(ams_data, list):
  208. ams_units = ams_data
  209. elif isinstance(ams_data, dict):
  210. # H2D format: check for "ams" key containing list, or "tray" key directly
  211. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  212. ams_units = ams_data["ams"]
  213. elif "tray" in ams_data:
  214. # Single AMS unit format - wrap in list
  215. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  216. else:
  217. logger.debug(f"Printer {printer.name} AMS dict keys: {list(ams_data.keys())}")
  218. if not ams_units:
  219. logger.debug(f"Printer {printer.name} has no AMS units to sync (type: {type(ams_data).__name__})")
  220. continue
  221. for ams_unit in ams_units:
  222. if not isinstance(ams_unit, dict):
  223. logger.debug(f"Skipping non-dict AMS unit: {type(ams_unit)}")
  224. continue
  225. ams_id = int(ams_unit.get("id", 0))
  226. trays = ams_unit.get("tray", [])
  227. for tray_data in trays:
  228. if not isinstance(tray_data, dict):
  229. continue
  230. tray = client.parse_ams_tray(ams_id, tray_data)
  231. if not tray:
  232. continue
  233. # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
  234. if not client.is_bambu_lab_spool(tray.tray_uuid):
  235. continue
  236. try:
  237. sync_result = await client.sync_ams_tray(tray, printer.name)
  238. if sync_result:
  239. total_synced += 1
  240. except Exception as e:
  241. all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
  242. return SyncResult(
  243. success=len(all_errors) == 0,
  244. synced_count=total_synced,
  245. errors=all_errors,
  246. )
  247. @router.get("/spools")
  248. async def get_spools(db: AsyncSession = Depends(get_db)):
  249. """Get all spools from Spoolman."""
  250. enabled, url, _ = await get_spoolman_settings(db)
  251. if not enabled:
  252. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  253. client = await get_spoolman_client()
  254. if not client:
  255. if url:
  256. client = await init_spoolman_client(url)
  257. else:
  258. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  259. if not await client.health_check():
  260. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  261. spools = await client.get_spools()
  262. return {"spools": spools}
  263. @router.get("/filaments")
  264. async def get_filaments(db: AsyncSession = Depends(get_db)):
  265. """Get all filaments from Spoolman."""
  266. enabled, url, _ = await get_spoolman_settings(db)
  267. if not enabled:
  268. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  269. client = await get_spoolman_client()
  270. if not client:
  271. if url:
  272. client = await init_spoolman_client(url)
  273. else:
  274. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  275. if not await client.health_check():
  276. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  277. filaments = await client.get_filaments()
  278. return {"filaments": filaments}