printers.py 34 KB

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