printers.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. import io
  2. import logging
  3. import zipfile
  4. from pathlib import Path
  5. from fastapi import APIRouter, Depends, HTTPException
  6. logger = logging.getLogger(__name__)
  7. from fastapi.responses import Response
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy import select
  10. from backend.app.core.database import get_db
  11. from backend.app.core.config import settings
  12. from backend.app.models.printer import Printer
  13. from backend.app.schemas.printer import (
  14. PrinterCreate,
  15. PrinterUpdate,
  16. PrinterResponse,
  17. PrinterStatus,
  18. HMSErrorResponse,
  19. )
  20. from backend.app.services.printer_manager import printer_manager
  21. from backend.app.services.bambu_ftp import (
  22. download_file_try_paths_async,
  23. list_files_async,
  24. delete_file_async,
  25. download_file_bytes_async,
  26. get_storage_info_async,
  27. )
  28. router = APIRouter(prefix="/printers", tags=["printers"])
  29. @router.get("/", response_model=list[PrinterResponse])
  30. async def list_printers(db: AsyncSession = Depends(get_db)):
  31. """List all configured printers."""
  32. result = await db.execute(select(Printer).order_by(Printer.name))
  33. return list(result.scalars().all())
  34. @router.post("/", response_model=PrinterResponse)
  35. async def create_printer(
  36. printer_data: PrinterCreate,
  37. db: AsyncSession = Depends(get_db),
  38. ):
  39. """Add a new printer."""
  40. # Check if serial number already exists
  41. result = await db.execute(
  42. select(Printer).where(Printer.serial_number == printer_data.serial_number)
  43. )
  44. if result.scalar_one_or_none():
  45. raise HTTPException(400, "Printer with this serial number already exists")
  46. printer = Printer(**printer_data.model_dump())
  47. db.add(printer)
  48. await db.commit()
  49. await db.refresh(printer)
  50. # Connect to the printer
  51. if printer.is_active:
  52. await printer_manager.connect_printer(printer)
  53. return printer
  54. @router.get("/{printer_id}", response_model=PrinterResponse)
  55. async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  56. """Get a specific printer."""
  57. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  58. printer = result.scalar_one_or_none()
  59. if not printer:
  60. raise HTTPException(404, "Printer not found")
  61. return printer
  62. @router.patch("/{printer_id}", response_model=PrinterResponse)
  63. async def update_printer(
  64. printer_id: int,
  65. printer_data: PrinterUpdate,
  66. db: AsyncSession = Depends(get_db),
  67. ):
  68. """Update a printer."""
  69. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  70. printer = result.scalar_one_or_none()
  71. if not printer:
  72. raise HTTPException(404, "Printer not found")
  73. update_data = printer_data.model_dump(exclude_unset=True)
  74. for field, value in update_data.items():
  75. setattr(printer, field, value)
  76. await db.commit()
  77. await db.refresh(printer)
  78. # Reconnect if connection settings changed
  79. if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
  80. printer_manager.disconnect_printer(printer_id)
  81. if printer.is_active:
  82. await printer_manager.connect_printer(printer)
  83. return printer
  84. @router.delete("/{printer_id}")
  85. async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  86. """Delete a printer."""
  87. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  88. printer = result.scalar_one_or_none()
  89. if not printer:
  90. raise HTTPException(404, "Printer not found")
  91. printer_manager.disconnect_printer(printer_id)
  92. await db.delete(printer)
  93. await db.commit()
  94. return {"status": "deleted"}
  95. @router.get("/{printer_id}/status", response_model=PrinterStatus)
  96. async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
  97. """Get real-time status of a printer."""
  98. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  99. printer = result.scalar_one_or_none()
  100. if not printer:
  101. raise HTTPException(404, "Printer not found")
  102. state = printer_manager.get_status(printer_id)
  103. if not state:
  104. return PrinterStatus(
  105. id=printer_id,
  106. name=printer.name,
  107. connected=False,
  108. )
  109. # Determine cover URL if there's an active print
  110. cover_url = None
  111. if state.state == "RUNNING" and state.gcode_file:
  112. cover_url = f"/api/v1/printers/{printer_id}/cover"
  113. # Convert HMS errors to response format
  114. hms_errors = [
  115. HMSErrorResponse(code=e.code, module=e.module, severity=e.severity)
  116. for e in (state.hms_errors or [])
  117. ]
  118. return PrinterStatus(
  119. id=printer_id,
  120. name=printer.name,
  121. connected=state.connected,
  122. state=state.state,
  123. current_print=state.current_print,
  124. subtask_name=state.subtask_name,
  125. gcode_file=state.gcode_file,
  126. progress=state.progress,
  127. remaining_time=state.remaining_time,
  128. layer_num=state.layer_num,
  129. total_layers=state.total_layers,
  130. temperatures=state.temperatures,
  131. cover_url=cover_url,
  132. hms_errors=hms_errors,
  133. )
  134. @router.post("/{printer_id}/connect")
  135. async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  136. """Manually connect to a printer."""
  137. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  138. printer = result.scalar_one_or_none()
  139. if not printer:
  140. raise HTTPException(404, "Printer not found")
  141. success = await printer_manager.connect_printer(printer)
  142. return {"connected": success}
  143. @router.post("/{printer_id}/disconnect")
  144. async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  145. """Manually disconnect from a printer."""
  146. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  147. printer = result.scalar_one_or_none()
  148. if not printer:
  149. raise HTTPException(404, "Printer not found")
  150. printer_manager.disconnect_printer(printer_id)
  151. return {"connected": False}
  152. @router.post("/test")
  153. async def test_printer_connection(
  154. ip_address: str,
  155. serial_number: str,
  156. access_code: str,
  157. ):
  158. """Test connection to a printer without saving."""
  159. result = await printer_manager.test_connection(
  160. ip_address=ip_address,
  161. serial_number=serial_number,
  162. access_code=access_code,
  163. )
  164. return result
  165. # Cache for cover images (printer_id -> (gcode_file, image_bytes))
  166. _cover_cache: dict[int, tuple[str, bytes]] = {}
  167. @router.get("/{printer_id}/cover")
  168. async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)):
  169. """Get the cover image for the current print job."""
  170. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  171. printer = result.scalar_one_or_none()
  172. if not printer:
  173. raise HTTPException(404, "Printer not found")
  174. state = printer_manager.get_status(printer_id)
  175. if not state:
  176. raise HTTPException(404, "Printer not connected")
  177. # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
  178. subtask_name = state.subtask_name
  179. if not subtask_name:
  180. raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
  181. # Check cache
  182. if printer_id in _cover_cache:
  183. cached_file, cached_image = _cover_cache[printer_id]
  184. if cached_file == subtask_name:
  185. return Response(content=cached_image, media_type="image/png")
  186. # Build 3MF filename from subtask_name
  187. # Bambu printers store files as "name.gcode.3mf"
  188. filename = subtask_name
  189. if not filename.endswith(".3mf"):
  190. filename = filename + ".gcode.3mf"
  191. # Try to download the 3MF file from printer
  192. temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{filename}"
  193. temp_path.parent.mkdir(parents=True, exist_ok=True)
  194. remote_paths = [
  195. f"/{filename}", # Root directory (most common)
  196. f"/cache/{filename}",
  197. f"/model/{filename}",
  198. f"/data/{filename}",
  199. ]
  200. logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}")
  201. try:
  202. downloaded = await download_file_try_paths_async(
  203. printer.ip_address,
  204. printer.access_code,
  205. remote_paths,
  206. temp_path,
  207. )
  208. except Exception as e:
  209. logger.error(f"FTP download exception: {e}")
  210. raise HTTPException(500, f"FTP download failed: {e}")
  211. if not downloaded:
  212. raise HTTPException(404, f"Could not download 3MF file '{filename}' from printer {printer.ip_address}. Tried: {remote_paths}")
  213. # Verify file actually exists and has content
  214. if not temp_path.exists():
  215. raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
  216. file_size = temp_path.stat().st_size
  217. logger.info(f"Downloaded file size: {file_size} bytes")
  218. if file_size == 0:
  219. temp_path.unlink()
  220. raise HTTPException(500, f"Downloaded file is empty: {filename}")
  221. try:
  222. # Extract thumbnail from 3MF (which is a ZIP file)
  223. try:
  224. zf = zipfile.ZipFile(temp_path, 'r')
  225. except zipfile.BadZipFile as e:
  226. raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}")
  227. except Exception as e:
  228. raise HTTPException(500, f"Failed to open 3MF file: {e}")
  229. try:
  230. # Try common thumbnail paths in 3MF files
  231. thumbnail_paths = [
  232. "Metadata/plate_1.png",
  233. "Metadata/thumbnail.png",
  234. "Metadata/plate_1_small.png",
  235. "Thumbnails/thumbnail.png",
  236. "thumbnail.png",
  237. ]
  238. for thumb_path in thumbnail_paths:
  239. try:
  240. image_data = zf.read(thumb_path)
  241. # Cache the result
  242. _cover_cache[printer_id] = (subtask_name, image_data)
  243. return Response(content=image_data, media_type="image/png")
  244. except KeyError:
  245. continue
  246. # If no specific thumbnail found, try any PNG in Metadata
  247. for name in zf.namelist():
  248. if name.startswith("Metadata/") and name.endswith(".png"):
  249. image_data = zf.read(name)
  250. _cover_cache[printer_id] = (subtask_name, image_data)
  251. return Response(content=image_data, media_type="image/png")
  252. raise HTTPException(404, "No thumbnail found in 3MF file")
  253. finally:
  254. zf.close()
  255. finally:
  256. if temp_path.exists():
  257. temp_path.unlink()
  258. # ============================================
  259. # File Manager Endpoints
  260. # ============================================
  261. @router.get("/{printer_id}/files")
  262. async def list_printer_files(
  263. printer_id: int,
  264. path: str = "/",
  265. db: AsyncSession = Depends(get_db),
  266. ):
  267. """List files on the printer at the specified path."""
  268. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  269. printer = result.scalar_one_or_none()
  270. if not printer:
  271. raise HTTPException(404, "Printer not found")
  272. files = await list_files_async(printer.ip_address, printer.access_code, path)
  273. # Add full path to each file
  274. for f in files:
  275. f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
  276. return {
  277. "path": path,
  278. "files": files,
  279. }
  280. @router.get("/{printer_id}/files/download")
  281. async def download_printer_file(
  282. printer_id: int,
  283. path: str,
  284. db: AsyncSession = Depends(get_db),
  285. ):
  286. """Download a file from the printer."""
  287. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  288. printer = result.scalar_one_or_none()
  289. if not printer:
  290. raise HTTPException(404, "Printer not found")
  291. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
  292. if data is None:
  293. raise HTTPException(404, f"File not found: {path}")
  294. # Determine content type based on extension
  295. filename = path.split("/")[-1]
  296. ext = filename.lower().split(".")[-1] if "." in filename else ""
  297. content_types = {
  298. "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  299. "gcode": "text/plain",
  300. "mp4": "video/mp4",
  301. "avi": "video/x-msvideo",
  302. "png": "image/png",
  303. "jpg": "image/jpeg",
  304. "jpeg": "image/jpeg",
  305. "json": "application/json",
  306. "txt": "text/plain",
  307. }
  308. content_type = content_types.get(ext, "application/octet-stream")
  309. return Response(
  310. content=data,
  311. media_type=content_type,
  312. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  313. )
  314. @router.delete("/{printer_id}/files")
  315. async def delete_printer_file(
  316. printer_id: int,
  317. path: str,
  318. db: AsyncSession = Depends(get_db),
  319. ):
  320. """Delete a file from the printer."""
  321. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  322. printer = result.scalar_one_or_none()
  323. if not printer:
  324. raise HTTPException(404, "Printer not found")
  325. success = await delete_file_async(printer.ip_address, printer.access_code, path)
  326. if not success:
  327. raise HTTPException(500, f"Failed to delete file: {path}")
  328. return {"status": "deleted", "path": path}
  329. @router.get("/{printer_id}/storage")
  330. async def get_printer_storage(
  331. printer_id: int,
  332. db: AsyncSession = Depends(get_db),
  333. ):
  334. """Get storage information from the printer."""
  335. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  336. printer = result.scalar_one_or_none()
  337. if not printer:
  338. raise HTTPException(404, "Printer not found")
  339. storage_info = await get_storage_info_async(printer.ip_address, printer.access_code)
  340. return storage_info or {"used_bytes": None, "free_bytes": None}
  341. # ============================================
  342. # MQTT Debug Logging Endpoints
  343. # ============================================
  344. @router.post("/{printer_id}/logging/enable")
  345. async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
  346. """Enable MQTT message logging for a printer."""
  347. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  348. printer = result.scalar_one_or_none()
  349. if not printer:
  350. raise HTTPException(404, "Printer not found")
  351. success = printer_manager.enable_logging(printer_id, True)
  352. if not success:
  353. raise HTTPException(400, "Printer not connected")
  354. return {"logging_enabled": True}
  355. @router.post("/{printer_id}/logging/disable")
  356. async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
  357. """Disable MQTT message logging for a printer."""
  358. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  359. printer = result.scalar_one_or_none()
  360. if not printer:
  361. raise HTTPException(404, "Printer not found")
  362. success = printer_manager.enable_logging(printer_id, False)
  363. if not success:
  364. raise HTTPException(400, "Printer not connected")
  365. return {"logging_enabled": False}
  366. @router.get("/{printer_id}/logging")
  367. async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
  368. """Get MQTT message logs for a printer."""
  369. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  370. printer = result.scalar_one_or_none()
  371. if not printer:
  372. raise HTTPException(404, "Printer not found")
  373. logs = printer_manager.get_logs(printer_id)
  374. return {
  375. "logging_enabled": printer_manager.is_logging_enabled(printer_id),
  376. "logs": [
  377. {
  378. "timestamp": log.timestamp,
  379. "topic": log.topic,
  380. "direction": log.direction,
  381. "payload": log.payload,
  382. }
  383. for log in logs
  384. ],
  385. }
  386. @router.delete("/{printer_id}/logging")
  387. async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
  388. """Clear MQTT message logs for a printer."""
  389. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  390. printer = result.scalar_one_or_none()
  391. if not printer:
  392. raise HTTPException(404, "Printer not found")
  393. printer_manager.clear_logs(printer_id)
  394. return {"status": "cleared"}