printers.py 23 KB

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