printers.py 13 KB

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