printers.py 18 KB

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