printers.py 21 KB

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