printers.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
  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.models.slot_preset import SlotPresetMapping
  14. from backend.app.schemas.printer import (
  15. PrinterCreate,
  16. PrinterUpdate,
  17. PrinterResponse,
  18. PrinterStatus,
  19. HMSErrorResponse,
  20. AMSUnit,
  21. AMSTray,
  22. NozzleInfoResponse,
  23. PrintOptionsResponse,
  24. )
  25. from backend.app.services.printer_manager import printer_manager
  26. from backend.app.services.bambu_mqtt import get_stage_name
  27. from backend.app.services.bambu_ftp import (
  28. download_file_try_paths_async,
  29. list_files_async,
  30. delete_file_async,
  31. download_file_bytes_async,
  32. get_storage_info_async,
  33. )
  34. router = APIRouter(prefix="/printers", tags=["printers"])
  35. @router.get("/", response_model=list[PrinterResponse])
  36. async def list_printers(db: AsyncSession = Depends(get_db)):
  37. """List all configured printers."""
  38. result = await db.execute(select(Printer).order_by(Printer.name))
  39. return list(result.scalars().all())
  40. @router.post("/", response_model=PrinterResponse)
  41. async def create_printer(
  42. printer_data: PrinterCreate,
  43. db: AsyncSession = Depends(get_db),
  44. ):
  45. """Add a new printer."""
  46. # Check if serial number already exists
  47. result = await db.execute(
  48. select(Printer).where(Printer.serial_number == printer_data.serial_number)
  49. )
  50. if result.scalar_one_or_none():
  51. raise HTTPException(400, "Printer with this serial number already exists")
  52. printer = Printer(**printer_data.model_dump())
  53. db.add(printer)
  54. await db.commit()
  55. await db.refresh(printer)
  56. # Connect to the printer
  57. if printer.is_active:
  58. await printer_manager.connect_printer(printer)
  59. return printer
  60. @router.get("/{printer_id}", response_model=PrinterResponse)
  61. async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  62. """Get a specific printer."""
  63. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  64. printer = result.scalar_one_or_none()
  65. if not printer:
  66. raise HTTPException(404, "Printer not found")
  67. return printer
  68. @router.patch("/{printer_id}", response_model=PrinterResponse)
  69. async def update_printer(
  70. printer_id: int,
  71. printer_data: PrinterUpdate,
  72. db: AsyncSession = Depends(get_db),
  73. ):
  74. """Update a printer."""
  75. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  76. printer = result.scalar_one_or_none()
  77. if not printer:
  78. raise HTTPException(404, "Printer not found")
  79. update_data = printer_data.model_dump(exclude_unset=True)
  80. for field, value in update_data.items():
  81. setattr(printer, field, value)
  82. await db.commit()
  83. await db.refresh(printer)
  84. # Reconnect if connection settings changed
  85. if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
  86. printer_manager.disconnect_printer(printer_id)
  87. if printer.is_active:
  88. await printer_manager.connect_printer(printer)
  89. return printer
  90. @router.delete("/{printer_id}")
  91. async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  92. """Delete a printer."""
  93. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  94. printer = result.scalar_one_or_none()
  95. if not printer:
  96. raise HTTPException(404, "Printer not found")
  97. printer_manager.disconnect_printer(printer_id)
  98. await db.delete(printer)
  99. await db.commit()
  100. return {"status": "deleted"}
  101. @router.get("/{printer_id}/status", response_model=PrinterStatus)
  102. async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
  103. """Get real-time status of a printer."""
  104. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  105. printer = result.scalar_one_or_none()
  106. if not printer:
  107. raise HTTPException(404, "Printer not found")
  108. state = printer_manager.get_status(printer_id)
  109. if not state:
  110. return PrinterStatus(
  111. id=printer_id,
  112. name=printer.name,
  113. connected=False,
  114. )
  115. # Determine cover URL if there's an active print
  116. cover_url = None
  117. if state.state == "RUNNING" and state.gcode_file:
  118. cover_url = f"/api/v1/printers/{printer_id}/cover"
  119. # Convert HMS errors to response format
  120. hms_errors = [
  121. HMSErrorResponse(code=e.code, attr=e.attr, module=e.module, severity=e.severity)
  122. for e in (state.hms_errors or [])
  123. ]
  124. # Parse AMS data from raw_data
  125. ams_units = []
  126. vt_tray = None
  127. ams_exists = False
  128. raw_data = state.raw_data or {}
  129. if "ams" in raw_data and isinstance(raw_data["ams"], list):
  130. ams_exists = True
  131. for ams_data in raw_data["ams"]:
  132. # Skip if ams_data is not a dict (defensive check)
  133. if not isinstance(ams_data, dict):
  134. continue
  135. trays = []
  136. for tray_data in ams_data.get("tray", []):
  137. # Filter out empty/invalid tag values
  138. tag_uid = tray_data.get("tag_uid", "")
  139. if tag_uid in ("", "0000000000000000"):
  140. tag_uid = None
  141. tray_uuid = tray_data.get("tray_uuid", "")
  142. if tray_uuid in ("", "00000000000000000000000000000000"):
  143. tray_uuid = None
  144. trays.append(AMSTray(
  145. id=tray_data.get("id", 0),
  146. tray_color=tray_data.get("tray_color"),
  147. tray_type=tray_data.get("tray_type"),
  148. tray_sub_brands=tray_data.get("tray_sub_brands"),
  149. tray_id_name=tray_data.get("tray_id_name"),
  150. tray_info_idx=tray_data.get("tray_info_idx"),
  151. remain=tray_data.get("remain", 0),
  152. k=tray_data.get("k"),
  153. tag_uid=tag_uid,
  154. tray_uuid=tray_uuid,
  155. nozzle_temp_min=tray_data.get("nozzle_temp_min"),
  156. nozzle_temp_max=tray_data.get("nozzle_temp_max"),
  157. ))
  158. # Prefer humidity_raw (percentage) over humidity (index 1-5)
  159. # humidity_raw is the actual percentage value from the sensor
  160. humidity_raw = ams_data.get("humidity_raw")
  161. humidity_idx = ams_data.get("humidity")
  162. humidity_value = None
  163. if humidity_raw is not None:
  164. try:
  165. humidity_value = int(humidity_raw)
  166. except (ValueError, TypeError):
  167. pass
  168. if humidity_value is None and humidity_idx is not None:
  169. try:
  170. humidity_value = int(humidity_idx)
  171. except (ValueError, TypeError):
  172. pass
  173. # AMS-HT has 1 tray, regular AMS has 4 trays
  174. is_ams_ht = len(trays) == 1
  175. ams_units.append(AMSUnit(
  176. id=ams_data.get("id", 0),
  177. humidity=humidity_value,
  178. temp=ams_data.get("temp"),
  179. is_ams_ht=is_ams_ht,
  180. tray=trays,
  181. ))
  182. # Virtual tray (external spool holder) - comes from vt_tray in raw_data
  183. if "vt_tray" in raw_data:
  184. vt_data = raw_data["vt_tray"]
  185. # Filter out empty/invalid tag values for vt_tray
  186. vt_tag_uid = vt_data.get("tag_uid", "")
  187. if vt_tag_uid in ("", "0000000000000000"):
  188. vt_tag_uid = None
  189. vt_tray_uuid = vt_data.get("tray_uuid", "")
  190. if vt_tray_uuid in ("", "00000000000000000000000000000000"):
  191. vt_tray_uuid = None
  192. vt_tray = AMSTray(
  193. id=254, # Virtual tray ID
  194. tray_color=vt_data.get("tray_color"),
  195. tray_type=vt_data.get("tray_type"),
  196. tray_sub_brands=vt_data.get("tray_sub_brands"),
  197. remain=vt_data.get("remain", 0),
  198. k=vt_data.get("k"),
  199. tag_uid=vt_tag_uid,
  200. tray_uuid=vt_tray_uuid,
  201. nozzle_temp_min=vt_data.get("nozzle_temp_min"),
  202. nozzle_temp_max=vt_data.get("nozzle_temp_max"),
  203. )
  204. # Convert nozzle info to response format
  205. nozzles = [
  206. NozzleInfoResponse(
  207. nozzle_type=n.nozzle_type,
  208. nozzle_diameter=n.nozzle_diameter,
  209. )
  210. for n in (state.nozzles or [])
  211. ]
  212. # Convert print options to response format
  213. print_options = PrintOptionsResponse(
  214. spaghetti_detector=state.print_options.spaghetti_detector,
  215. print_halt=state.print_options.print_halt,
  216. halt_print_sensitivity=state.print_options.halt_print_sensitivity,
  217. first_layer_inspector=state.print_options.first_layer_inspector,
  218. printing_monitor=state.print_options.printing_monitor,
  219. buildplate_marker_detector=state.print_options.buildplate_marker_detector,
  220. allow_skip_parts=state.print_options.allow_skip_parts,
  221. nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
  222. nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
  223. pileup_detector=state.print_options.pileup_detector,
  224. pileup_sensitivity=state.print_options.pileup_sensitivity,
  225. airprint_detector=state.print_options.airprint_detector,
  226. airprint_sensitivity=state.print_options.airprint_sensitivity,
  227. auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
  228. filament_tangle_detect=state.print_options.filament_tangle_detect,
  229. )
  230. # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
  231. ams_mapping = raw_data.get("ams_mapping", [])
  232. # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
  233. ams_extruder_map = raw_data.get("ams_extruder_map", {})
  234. logger.debug(f"API returning ams_mapping: {ams_mapping}, ams_extruder_map: {ams_extruder_map}")
  235. # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
  236. # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
  237. # No conversion needed - just use the raw value directly
  238. tray_now = state.tray_now
  239. logger.debug(f"Using tray_now directly as global ID: {tray_now}")
  240. return PrinterStatus(
  241. id=printer_id,
  242. name=printer.name,
  243. connected=state.connected,
  244. state=state.state,
  245. current_print=state.current_print,
  246. subtask_name=state.subtask_name,
  247. gcode_file=state.gcode_file,
  248. progress=state.progress,
  249. remaining_time=state.remaining_time,
  250. layer_num=state.layer_num,
  251. total_layers=state.total_layers,
  252. temperatures=state.temperatures,
  253. cover_url=cover_url,
  254. hms_errors=hms_errors,
  255. ams=ams_units,
  256. ams_exists=ams_exists,
  257. vt_tray=vt_tray,
  258. sdcard=state.sdcard,
  259. store_to_sdcard=state.store_to_sdcard,
  260. timelapse=state.timelapse,
  261. ipcam=state.ipcam,
  262. wifi_signal=state.wifi_signal,
  263. nozzles=nozzles,
  264. print_options=print_options,
  265. stg_cur=state.stg_cur,
  266. stg_cur_name=get_stage_name(state.stg_cur) if state.stg_cur >= 0 else None,
  267. stg=state.stg,
  268. airduct_mode=state.airduct_mode,
  269. speed_level=state.speed_level,
  270. chamber_light=state.chamber_light,
  271. active_extruder=state.active_extruder,
  272. ams_mapping=ams_mapping,
  273. ams_extruder_map=ams_extruder_map,
  274. tray_now=tray_now,
  275. ams_status_main=state.ams_status_main,
  276. ams_status_sub=state.ams_status_sub,
  277. mc_print_sub_stage=state.mc_print_sub_stage,
  278. last_ams_update=state.last_ams_update,
  279. )
  280. @router.post("/{printer_id}/connect")
  281. async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  282. """Manually connect to a printer."""
  283. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  284. printer = result.scalar_one_or_none()
  285. if not printer:
  286. raise HTTPException(404, "Printer not found")
  287. success = await printer_manager.connect_printer(printer)
  288. return {"connected": success}
  289. @router.post("/{printer_id}/disconnect")
  290. async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
  291. """Manually disconnect from a printer."""
  292. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  293. printer = result.scalar_one_or_none()
  294. if not printer:
  295. raise HTTPException(404, "Printer not found")
  296. printer_manager.disconnect_printer(printer_id)
  297. return {"connected": False}
  298. @router.post("/test")
  299. async def test_printer_connection(
  300. ip_address: str,
  301. serial_number: str,
  302. access_code: str,
  303. ):
  304. """Test connection to a printer without saving."""
  305. result = await printer_manager.test_connection(
  306. ip_address=ip_address,
  307. serial_number=serial_number,
  308. access_code=access_code,
  309. )
  310. return result
  311. # Cache for cover images (printer_id -> (gcode_file, image_bytes))
  312. _cover_cache: dict[int, tuple[str, bytes]] = {}
  313. @router.get("/{printer_id}/cover")
  314. async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)):
  315. """Get the cover image for the current print job."""
  316. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  317. printer = result.scalar_one_or_none()
  318. if not printer:
  319. raise HTTPException(404, "Printer not found")
  320. state = printer_manager.get_status(printer_id)
  321. if not state:
  322. raise HTTPException(404, "Printer not connected")
  323. # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
  324. subtask_name = state.subtask_name
  325. if not subtask_name:
  326. raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
  327. # Check cache
  328. if printer_id in _cover_cache:
  329. cached_file, cached_image = _cover_cache[printer_id]
  330. if cached_file == subtask_name:
  331. return Response(content=cached_image, media_type="image/png")
  332. # Build 3MF filename from subtask_name
  333. # Bambu printers store files as "name.gcode.3mf"
  334. filename = subtask_name
  335. if not filename.endswith(".3mf"):
  336. filename = filename + ".gcode.3mf"
  337. # Try to download the 3MF file from printer
  338. temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{filename}"
  339. temp_path.parent.mkdir(parents=True, exist_ok=True)
  340. remote_paths = [
  341. f"/{filename}", # Root directory (most common)
  342. f"/cache/{filename}",
  343. f"/model/{filename}",
  344. f"/data/{filename}",
  345. ]
  346. logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}")
  347. try:
  348. downloaded = await download_file_try_paths_async(
  349. printer.ip_address,
  350. printer.access_code,
  351. remote_paths,
  352. temp_path,
  353. )
  354. except Exception as e:
  355. logger.error(f"FTP download exception: {e}")
  356. raise HTTPException(500, f"FTP download failed: {e}")
  357. if not downloaded:
  358. raise HTTPException(404, f"Could not download 3MF file '{filename}' from printer {printer.ip_address}. Tried: {remote_paths}")
  359. # Verify file actually exists and has content
  360. if not temp_path.exists():
  361. raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
  362. file_size = temp_path.stat().st_size
  363. logger.info(f"Downloaded file size: {file_size} bytes")
  364. if file_size == 0:
  365. temp_path.unlink()
  366. raise HTTPException(500, f"Downloaded file is empty: {filename}")
  367. try:
  368. # Extract thumbnail from 3MF (which is a ZIP file)
  369. try:
  370. zf = zipfile.ZipFile(temp_path, 'r')
  371. except zipfile.BadZipFile as e:
  372. raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}")
  373. except Exception as e:
  374. raise HTTPException(500, f"Failed to open 3MF file: {e}")
  375. try:
  376. # Try common thumbnail paths in 3MF files
  377. thumbnail_paths = [
  378. "Metadata/plate_1.png",
  379. "Metadata/thumbnail.png",
  380. "Metadata/plate_1_small.png",
  381. "Thumbnails/thumbnail.png",
  382. "thumbnail.png",
  383. ]
  384. for thumb_path in thumbnail_paths:
  385. try:
  386. image_data = zf.read(thumb_path)
  387. # Cache the result
  388. _cover_cache[printer_id] = (subtask_name, image_data)
  389. return Response(content=image_data, media_type="image/png")
  390. except KeyError:
  391. continue
  392. # If no specific thumbnail found, try any PNG in Metadata
  393. for name in zf.namelist():
  394. if name.startswith("Metadata/") and name.endswith(".png"):
  395. image_data = zf.read(name)
  396. _cover_cache[printer_id] = (subtask_name, image_data)
  397. return Response(content=image_data, media_type="image/png")
  398. raise HTTPException(404, "No thumbnail found in 3MF file")
  399. finally:
  400. zf.close()
  401. finally:
  402. if temp_path.exists():
  403. temp_path.unlink()
  404. # ============================================
  405. # File Manager Endpoints
  406. # ============================================
  407. @router.get("/{printer_id}/files")
  408. async def list_printer_files(
  409. printer_id: int,
  410. path: str = "/",
  411. db: AsyncSession = Depends(get_db),
  412. ):
  413. """List files on the printer at the specified path."""
  414. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  415. printer = result.scalar_one_or_none()
  416. if not printer:
  417. raise HTTPException(404, "Printer not found")
  418. files = await list_files_async(printer.ip_address, printer.access_code, path)
  419. # Add full path to each file
  420. for f in files:
  421. f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
  422. return {
  423. "path": path,
  424. "files": files,
  425. }
  426. @router.get("/{printer_id}/files/download")
  427. async def download_printer_file(
  428. printer_id: int,
  429. path: str,
  430. db: AsyncSession = Depends(get_db),
  431. ):
  432. """Download a file from the printer."""
  433. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  434. printer = result.scalar_one_or_none()
  435. if not printer:
  436. raise HTTPException(404, "Printer not found")
  437. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
  438. if data is None:
  439. raise HTTPException(404, f"File not found: {path}")
  440. # Determine content type based on extension
  441. filename = path.split("/")[-1]
  442. ext = filename.lower().split(".")[-1] if "." in filename else ""
  443. content_types = {
  444. "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  445. "gcode": "text/plain",
  446. "mp4": "video/mp4",
  447. "avi": "video/x-msvideo",
  448. "png": "image/png",
  449. "jpg": "image/jpeg",
  450. "jpeg": "image/jpeg",
  451. "json": "application/json",
  452. "txt": "text/plain",
  453. }
  454. content_type = content_types.get(ext, "application/octet-stream")
  455. return Response(
  456. content=data,
  457. media_type=content_type,
  458. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  459. )
  460. @router.delete("/{printer_id}/files")
  461. async def delete_printer_file(
  462. printer_id: int,
  463. path: str,
  464. db: AsyncSession = Depends(get_db),
  465. ):
  466. """Delete a file from the printer."""
  467. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  468. printer = result.scalar_one_or_none()
  469. if not printer:
  470. raise HTTPException(404, "Printer not found")
  471. success = await delete_file_async(printer.ip_address, printer.access_code, path)
  472. if not success:
  473. raise HTTPException(500, f"Failed to delete file: {path}")
  474. return {"status": "deleted", "path": path}
  475. @router.get("/{printer_id}/storage")
  476. async def get_printer_storage(
  477. printer_id: int,
  478. db: AsyncSession = Depends(get_db),
  479. ):
  480. """Get storage information from the printer."""
  481. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  482. printer = result.scalar_one_or_none()
  483. if not printer:
  484. raise HTTPException(404, "Printer not found")
  485. storage_info = await get_storage_info_async(printer.ip_address, printer.access_code)
  486. return storage_info or {"used_bytes": None, "free_bytes": None}
  487. # ============================================
  488. # MQTT Debug Logging Endpoints
  489. # ============================================
  490. @router.post("/{printer_id}/logging/enable")
  491. async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
  492. """Enable MQTT message logging for a printer."""
  493. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  494. printer = result.scalar_one_or_none()
  495. if not printer:
  496. raise HTTPException(404, "Printer not found")
  497. success = printer_manager.enable_logging(printer_id, True)
  498. if not success:
  499. raise HTTPException(400, "Printer not connected")
  500. return {"logging_enabled": True}
  501. @router.post("/{printer_id}/logging/disable")
  502. async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
  503. """Disable MQTT message logging for a printer."""
  504. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  505. printer = result.scalar_one_or_none()
  506. if not printer:
  507. raise HTTPException(404, "Printer not found")
  508. success = printer_manager.enable_logging(printer_id, False)
  509. if not success:
  510. raise HTTPException(400, "Printer not connected")
  511. return {"logging_enabled": False}
  512. @router.get("/{printer_id}/logging")
  513. async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
  514. """Get MQTT message logs for a printer."""
  515. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  516. printer = result.scalar_one_or_none()
  517. if not printer:
  518. raise HTTPException(404, "Printer not found")
  519. logs = printer_manager.get_logs(printer_id)
  520. return {
  521. "logging_enabled": printer_manager.is_logging_enabled(printer_id),
  522. "logs": [
  523. {
  524. "timestamp": log.timestamp,
  525. "topic": log.topic,
  526. "direction": log.direction,
  527. "payload": log.payload,
  528. }
  529. for log in logs
  530. ],
  531. }
  532. @router.delete("/{printer_id}/logging")
  533. async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
  534. """Clear MQTT message logs for a printer."""
  535. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  536. printer = result.scalar_one_or_none()
  537. if not printer:
  538. raise HTTPException(404, "Printer not found")
  539. printer_manager.clear_logs(printer_id)
  540. return {"status": "cleared"}
  541. # ============================================
  542. # Print Options (AI Detection) Endpoints
  543. # ============================================
  544. @router.post("/{printer_id}/print-options")
  545. async def set_print_option(
  546. printer_id: int,
  547. module_name: str,
  548. enabled: bool,
  549. print_halt: bool = True,
  550. sensitivity: str = "medium",
  551. db: AsyncSession = Depends(get_db),
  552. ):
  553. """Set an AI detection / print option on the printer.
  554. Valid module_name values:
  555. - spaghetti_detector: Spaghetti detection
  556. - first_layer_inspector: First layer inspection
  557. - printing_monitor: AI print quality monitoring
  558. - buildplate_marker_detector: Build plate marker detection
  559. - allow_skip_parts: Allow skipping failed parts
  560. """
  561. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  562. printer = result.scalar_one_or_none()
  563. if not printer:
  564. raise HTTPException(404, "Printer not found")
  565. client = printer_manager.get_client(printer_id)
  566. if not client or not client.state.connected:
  567. raise HTTPException(400, "Printer not connected")
  568. # Validate module_name
  569. valid_modules = [
  570. "spaghetti_detector",
  571. "first_layer_inspector",
  572. "printing_monitor",
  573. "buildplate_marker_detector",
  574. "allow_skip_parts",
  575. "pileup_detector",
  576. "clump_detector",
  577. "airprint_detector",
  578. "auto_recovery_step_loss",
  579. ]
  580. if module_name not in valid_modules:
  581. raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
  582. # Validate sensitivity
  583. valid_sensitivities = ["low", "medium", "high", "never_halt"]
  584. if sensitivity not in valid_sensitivities:
  585. raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
  586. success = client.set_xcam_option(
  587. module_name=module_name,
  588. enabled=enabled,
  589. print_halt=print_halt,
  590. sensitivity=sensitivity,
  591. )
  592. if not success:
  593. raise HTTPException(500, "Failed to send command to printer")
  594. return {
  595. "success": True,
  596. "module_name": module_name,
  597. "enabled": enabled,
  598. "print_halt": print_halt,
  599. "sensitivity": sensitivity,
  600. }
  601. # ============================================
  602. # Calibration
  603. # ============================================
  604. @router.post("/{printer_id}/calibration")
  605. async def start_calibration(
  606. printer_id: int,
  607. bed_leveling: bool = False,
  608. vibration: bool = False,
  609. motor_noise: bool = False,
  610. nozzle_offset: bool = False,
  611. high_temp_heatbed: bool = False,
  612. db: AsyncSession = Depends(get_db),
  613. ):
  614. """Start printer calibration with selected options.
  615. At least one option must be selected.
  616. Options:
  617. - bed_leveling: Run bed leveling calibration
  618. - vibration: Run vibration compensation calibration
  619. - motor_noise: Run motor noise cancellation calibration
  620. - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
  621. - high_temp_heatbed: Run high-temperature heatbed calibration
  622. """
  623. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  624. printer = result.scalar_one_or_none()
  625. if not printer:
  626. raise HTTPException(404, "Printer not found")
  627. client = printer_manager.get_client(printer_id)
  628. if not client or not client.state.connected:
  629. raise HTTPException(400, "Printer not connected")
  630. # Check that at least one option is selected
  631. if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
  632. raise HTTPException(400, "At least one calibration option must be selected")
  633. success = client.start_calibration(
  634. bed_leveling=bed_leveling,
  635. vibration=vibration,
  636. motor_noise=motor_noise,
  637. nozzle_offset=nozzle_offset,
  638. high_temp_heatbed=high_temp_heatbed,
  639. )
  640. if not success:
  641. raise HTTPException(500, "Failed to send calibration command to printer")
  642. return {
  643. "success": True,
  644. "bed_leveling": bed_leveling,
  645. "vibration": vibration,
  646. "motor_noise": motor_noise,
  647. "nozzle_offset": nozzle_offset,
  648. "high_temp_heatbed": high_temp_heatbed,
  649. }
  650. # ============================================================================
  651. # Slot Preset Mapping Endpoints
  652. # ============================================================================
  653. @router.get("/{printer_id}/slot-presets")
  654. async def get_slot_presets(
  655. printer_id: int,
  656. db: AsyncSession = Depends(get_db),
  657. ):
  658. """Get all saved slot-to-preset mappings for a printer."""
  659. result = await db.execute(
  660. select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id)
  661. )
  662. mappings = result.scalars().all()
  663. return {
  664. mapping.ams_id * 4 + mapping.tray_id: {
  665. "ams_id": mapping.ams_id,
  666. "tray_id": mapping.tray_id,
  667. "preset_id": mapping.preset_id,
  668. "preset_name": mapping.preset_name,
  669. }
  670. for mapping in mappings
  671. }
  672. @router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  673. async def get_slot_preset(
  674. printer_id: int,
  675. ams_id: int,
  676. tray_id: int,
  677. db: AsyncSession = Depends(get_db),
  678. ):
  679. """Get the saved preset for a specific slot."""
  680. result = await db.execute(
  681. select(SlotPresetMapping).where(
  682. SlotPresetMapping.printer_id == printer_id,
  683. SlotPresetMapping.ams_id == ams_id,
  684. SlotPresetMapping.tray_id == tray_id,
  685. )
  686. )
  687. mapping = result.scalar_one_or_none()
  688. if not mapping:
  689. return None
  690. return {
  691. "ams_id": mapping.ams_id,
  692. "tray_id": mapping.tray_id,
  693. "preset_id": mapping.preset_id,
  694. "preset_name": mapping.preset_name,
  695. }
  696. @router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  697. async def save_slot_preset(
  698. printer_id: int,
  699. ams_id: int,
  700. tray_id: int,
  701. preset_id: str,
  702. preset_name: str,
  703. db: AsyncSession = Depends(get_db),
  704. ):
  705. """Save a preset mapping for a specific slot."""
  706. # Check printer exists
  707. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  708. if not result.scalar_one_or_none():
  709. raise HTTPException(404, "Printer not found")
  710. # Check for existing mapping
  711. result = await db.execute(
  712. select(SlotPresetMapping).where(
  713. SlotPresetMapping.printer_id == printer_id,
  714. SlotPresetMapping.ams_id == ams_id,
  715. SlotPresetMapping.tray_id == tray_id,
  716. )
  717. )
  718. mapping = result.scalar_one_or_none()
  719. if mapping:
  720. # Update existing
  721. mapping.preset_id = preset_id
  722. mapping.preset_name = preset_name
  723. else:
  724. # Create new
  725. mapping = SlotPresetMapping(
  726. printer_id=printer_id,
  727. ams_id=ams_id,
  728. tray_id=tray_id,
  729. preset_id=preset_id,
  730. preset_name=preset_name,
  731. )
  732. db.add(mapping)
  733. await db.commit()
  734. await db.refresh(mapping)
  735. return {
  736. "ams_id": mapping.ams_id,
  737. "tray_id": mapping.tray_id,
  738. "preset_id": mapping.preset_id,
  739. "preset_name": mapping.preset_name,
  740. }
  741. @router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  742. async def delete_slot_preset(
  743. printer_id: int,
  744. ams_id: int,
  745. tray_id: int,
  746. db: AsyncSession = Depends(get_db),
  747. ):
  748. """Delete a saved preset mapping for a slot."""
  749. result = await db.execute(
  750. select(SlotPresetMapping).where(
  751. SlotPresetMapping.printer_id == printer_id,
  752. SlotPresetMapping.ams_id == ams_id,
  753. SlotPresetMapping.tray_id == tray_id,
  754. )
  755. )
  756. mapping = result.scalar_one_or_none()
  757. if mapping:
  758. await db.delete(mapping)
  759. await db.commit()
  760. return {"success": True}