printers.py 77 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109
  1. import asyncio
  2. import logging
  3. import re
  4. import zipfile
  5. from fastapi import APIRouter, Depends, HTTPException, Query
  6. from fastapi.responses import Response
  7. from sqlalchemy import select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  10. from backend.app.core.config import settings
  11. from backend.app.core.database import get_db
  12. from backend.app.core.permissions import Permission
  13. from backend.app.models.printer import Printer
  14. from backend.app.models.slot_preset import SlotPresetMapping
  15. from backend.app.schemas.printer import (
  16. AMSTray,
  17. AMSUnit,
  18. HMSErrorResponse,
  19. NozzleInfoResponse,
  20. NozzleRackSlot,
  21. PrinterCreate,
  22. PrinterResponse,
  23. PrinterStatus,
  24. PrinterUpdate,
  25. PrintOptionsResponse,
  26. )
  27. from backend.app.services.bambu_ftp import (
  28. delete_file_async,
  29. download_file_bytes_async,
  30. download_file_try_paths_async,
  31. get_storage_info_async,
  32. list_files_async,
  33. )
  34. from backend.app.services.printer_manager import get_derived_status_name, printer_manager, supports_chamber_temp
  35. logger = logging.getLogger(__name__)
  36. router = APIRouter(prefix="/printers", tags=["printers"])
  37. @router.get("/", response_model=list[PrinterResponse])
  38. async def list_printers(
  39. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  40. db: AsyncSession = Depends(get_db),
  41. ):
  42. """List all configured printers."""
  43. result = await db.execute(select(Printer).order_by(Printer.name))
  44. return list(result.scalars().all())
  45. @router.post("/", response_model=PrinterResponse)
  46. async def create_printer(
  47. printer_data: PrinterCreate,
  48. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  49. db: AsyncSession = Depends(get_db),
  50. ):
  51. """Add a new printer."""
  52. # Check if serial number already exists
  53. result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number))
  54. if result.scalar_one_or_none():
  55. raise HTTPException(400, "Printer with this serial number already exists")
  56. printer = Printer(**printer_data.model_dump())
  57. db.add(printer)
  58. await db.commit()
  59. await db.refresh(printer)
  60. # Connect to the printer
  61. if printer.is_active:
  62. await printer_manager.connect_printer(printer)
  63. return printer
  64. @router.get("/usb-cameras")
  65. async def list_usb_cameras(
  66. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  67. ):
  68. """List available USB cameras connected to the system.
  69. Returns a list of detected V4L2 video devices with their info.
  70. Only works on Linux systems with V4L2 support.
  71. Returns:
  72. List of dicts with {device: str, name: str, capabilities: list, formats?: list}
  73. """
  74. from backend.app.services.external_camera import list_usb_cameras
  75. cameras = list_usb_cameras()
  76. return {"cameras": cameras}
  77. @router.get("/{printer_id}", response_model=PrinterResponse)
  78. async def get_printer(
  79. printer_id: int,
  80. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  81. db: AsyncSession = Depends(get_db),
  82. ):
  83. """Get a specific printer."""
  84. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  85. printer = result.scalar_one_or_none()
  86. if not printer:
  87. raise HTTPException(404, "Printer not found")
  88. return printer
  89. @router.patch("/{printer_id}", response_model=PrinterResponse)
  90. async def update_printer(
  91. printer_id: int,
  92. printer_data: PrinterUpdate,
  93. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  94. db: AsyncSession = Depends(get_db),
  95. ):
  96. """Update a printer."""
  97. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  98. printer = result.scalar_one_or_none()
  99. if not printer:
  100. raise HTTPException(404, "Printer not found")
  101. update_data = printer_data.model_dump(exclude_unset=True)
  102. # Handle nested ROI object - flatten to individual columns
  103. if "plate_detection_roi" in update_data:
  104. roi = update_data.pop("plate_detection_roi")
  105. if roi:
  106. update_data["plate_detection_roi_x"] = roi.get("x")
  107. update_data["plate_detection_roi_y"] = roi.get("y")
  108. update_data["plate_detection_roi_w"] = roi.get("w")
  109. update_data["plate_detection_roi_h"] = roi.get("h")
  110. else:
  111. # Clear ROI if set to null
  112. update_data["plate_detection_roi_x"] = None
  113. update_data["plate_detection_roi_y"] = None
  114. update_data["plate_detection_roi_w"] = None
  115. update_data["plate_detection_roi_h"] = None
  116. for field, value in update_data.items():
  117. setattr(printer, field, value)
  118. await db.commit()
  119. await db.refresh(printer)
  120. # Reconnect if connection settings changed
  121. if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
  122. printer_manager.disconnect_printer(printer_id)
  123. if printer.is_active:
  124. await printer_manager.connect_printer(printer)
  125. return printer
  126. @router.delete("/{printer_id}")
  127. async def delete_printer(
  128. printer_id: int,
  129. delete_archives: bool = True,
  130. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
  131. db: AsyncSession = Depends(get_db),
  132. ):
  133. """Delete a printer.
  134. Args:
  135. printer_id: ID of the printer to delete
  136. delete_archives: If True (default), delete all print archives for this printer.
  137. If False, keep archives but remove their printer association.
  138. """
  139. from sqlalchemy import delete as sql_delete
  140. from backend.app.models.archive import PrintArchive
  141. from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
  142. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  143. printer = result.scalar_one_or_none()
  144. if not printer:
  145. raise HTTPException(404, "Printer not found")
  146. printer_manager.disconnect_printer(printer_id)
  147. if delete_archives:
  148. # Delete all archives for this printer
  149. await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
  150. else:
  151. # Orphan the archives instead of deleting them
  152. from sqlalchemy import update
  153. await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
  154. # Delete maintenance history and items for this printer
  155. # (SQLite doesn't enforce FK cascades, so do it explicitly)
  156. maintenance_ids = (
  157. (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id)))
  158. .scalars()
  159. .all()
  160. )
  161. if maintenance_ids:
  162. await db.execute(
  163. sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids))
  164. )
  165. await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
  166. await db.delete(printer)
  167. await db.commit()
  168. return {"status": "deleted", "archives_deleted": delete_archives}
  169. @router.get("/{printer_id}/status", response_model=PrinterStatus)
  170. async def get_printer_status(
  171. printer_id: int,
  172. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  173. db: AsyncSession = Depends(get_db),
  174. ):
  175. """Get real-time status of a printer."""
  176. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  177. printer = result.scalar_one_or_none()
  178. if not printer:
  179. raise HTTPException(404, "Printer not found")
  180. state = printer_manager.get_status(printer_id)
  181. if not state:
  182. return PrinterStatus(
  183. id=printer_id,
  184. name=printer.name,
  185. connected=False,
  186. )
  187. # Determine cover URL if there's an active print (including paused)
  188. cover_url = None
  189. if state.state in ("RUNNING", "PAUSE", "PAUSED") and state.gcode_file:
  190. cover_url = f"/api/v1/printers/{printer_id}/cover"
  191. # Convert HMS errors to response format
  192. hms_errors = [
  193. HMSErrorResponse(code=e.code, attr=e.attr, module=e.module, severity=e.severity)
  194. for e in (state.hms_errors or [])
  195. ]
  196. # Parse AMS data from raw_data
  197. ams_units = []
  198. vt_tray = None
  199. ams_exists = False
  200. raw_data = state.raw_data or {}
  201. # Build K-profile lookup map: cali_idx -> k_value
  202. # This allows looking up the calibrated K value for each AMS slot
  203. kprofile_map: dict[int, float] = {}
  204. for kp in state.kprofiles or []:
  205. if kp.slot_id is not None and kp.k_value:
  206. try:
  207. kprofile_map[kp.slot_id] = float(kp.k_value)
  208. except (ValueError, TypeError):
  209. pass # Skip K-profile entries with unparseable values
  210. if "ams" in raw_data and isinstance(raw_data["ams"], list):
  211. ams_exists = True
  212. for ams_data in raw_data["ams"]:
  213. # Skip if ams_data is not a dict (defensive check)
  214. if not isinstance(ams_data, dict):
  215. continue
  216. trays = []
  217. for tray_data in ams_data.get("tray", []):
  218. # Filter out empty/invalid tag values
  219. tag_uid = tray_data.get("tag_uid", "")
  220. if tag_uid in ("", "0000000000000000"):
  221. tag_uid = None
  222. tray_uuid = tray_data.get("tray_uuid", "")
  223. if tray_uuid in ("", "00000000000000000000000000000000"):
  224. tray_uuid = None
  225. # Get K value: first try tray's k field, then lookup from K-profiles
  226. k_value = tray_data.get("k")
  227. cali_idx = tray_data.get("cali_idx")
  228. if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
  229. k_value = kprofile_map[cali_idx]
  230. trays.append(
  231. AMSTray(
  232. id=tray_data.get("id", 0),
  233. tray_color=tray_data.get("tray_color"),
  234. tray_type=tray_data.get("tray_type"),
  235. tray_sub_brands=tray_data.get("tray_sub_brands"),
  236. tray_id_name=tray_data.get("tray_id_name"),
  237. tray_info_idx=tray_data.get("tray_info_idx"),
  238. remain=tray_data.get("remain", 0),
  239. k=k_value,
  240. cali_idx=cali_idx,
  241. tag_uid=tag_uid,
  242. tray_uuid=tray_uuid,
  243. nozzle_temp_min=tray_data.get("nozzle_temp_min"),
  244. nozzle_temp_max=tray_data.get("nozzle_temp_max"),
  245. )
  246. )
  247. # Prefer humidity_raw (percentage) over humidity (index 1-5)
  248. # humidity_raw is the actual percentage value from the sensor
  249. humidity_raw = ams_data.get("humidity_raw")
  250. humidity_idx = ams_data.get("humidity")
  251. humidity_value = None
  252. if humidity_raw is not None:
  253. try:
  254. humidity_value = int(humidity_raw)
  255. except (ValueError, TypeError):
  256. pass # Skip unparseable humidity; will try index fallback
  257. if humidity_value is None and humidity_idx is not None:
  258. try:
  259. humidity_value = int(humidity_idx)
  260. except (ValueError, TypeError):
  261. pass # Skip unparseable humidity index; humidity remains None
  262. # AMS-HT has 1 tray, regular AMS has 4 trays
  263. is_ams_ht = len(trays) == 1
  264. ams_units.append(
  265. AMSUnit(
  266. id=ams_data.get("id", 0),
  267. humidity=humidity_value,
  268. temp=ams_data.get("temp"),
  269. is_ams_ht=is_ams_ht,
  270. tray=trays,
  271. )
  272. )
  273. # Virtual tray (external spool holder) - comes from vt_tray in raw_data
  274. if "vt_tray" in raw_data:
  275. vt_data = raw_data["vt_tray"]
  276. # Filter out empty/invalid tag values for vt_tray
  277. vt_tag_uid = vt_data.get("tag_uid", "")
  278. if vt_tag_uid in ("", "0000000000000000"):
  279. vt_tag_uid = None
  280. vt_tray_uuid = vt_data.get("tray_uuid", "")
  281. if vt_tray_uuid in ("", "00000000000000000000000000000000"):
  282. vt_tray_uuid = None
  283. # Get K value: first try tray's k field, then lookup from K-profiles
  284. vt_k_value = vt_data.get("k")
  285. vt_cali_idx = vt_data.get("cali_idx")
  286. if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
  287. vt_k_value = kprofile_map[vt_cali_idx]
  288. vt_tray = AMSTray(
  289. id=254, # Virtual tray ID
  290. tray_color=vt_data.get("tray_color"),
  291. tray_type=vt_data.get("tray_type"),
  292. tray_sub_brands=vt_data.get("tray_sub_brands"),
  293. tray_id_name=vt_data.get("tray_id_name"),
  294. tray_info_idx=vt_data.get("tray_info_idx"),
  295. remain=vt_data.get("remain", 0),
  296. k=vt_k_value,
  297. cali_idx=vt_cali_idx,
  298. tag_uid=vt_tag_uid,
  299. tray_uuid=vt_tray_uuid,
  300. nozzle_temp_min=vt_data.get("nozzle_temp_min"),
  301. nozzle_temp_max=vt_data.get("nozzle_temp_max"),
  302. )
  303. # Convert nozzle info to response format
  304. nozzles = [
  305. NozzleInfoResponse(
  306. nozzle_type=n.nozzle_type,
  307. nozzle_diameter=n.nozzle_diameter,
  308. )
  309. for n in (state.nozzles or [])
  310. ]
  311. # H2C nozzle rack (tool-changer dock positions)
  312. nozzle_rack = [
  313. NozzleRackSlot(
  314. id=n.get("id", 0),
  315. nozzle_type=n.get("type", ""),
  316. nozzle_diameter=n.get("diameter", ""),
  317. wear=n.get("wear"),
  318. stat=n.get("stat"),
  319. max_temp=n.get("max_temp", 0),
  320. serial_number=n.get("serial_number", ""),
  321. filament_color=n.get("filament_color", ""),
  322. filament_id=n.get("filament_id", ""),
  323. )
  324. for n in (state.nozzle_rack or [])
  325. ]
  326. # Convert print options to response format
  327. print_options = PrintOptionsResponse(
  328. spaghetti_detector=state.print_options.spaghetti_detector,
  329. print_halt=state.print_options.print_halt,
  330. halt_print_sensitivity=state.print_options.halt_print_sensitivity,
  331. first_layer_inspector=state.print_options.first_layer_inspector,
  332. printing_monitor=state.print_options.printing_monitor,
  333. buildplate_marker_detector=state.print_options.buildplate_marker_detector,
  334. allow_skip_parts=state.print_options.allow_skip_parts,
  335. nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
  336. nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
  337. pileup_detector=state.print_options.pileup_detector,
  338. pileup_sensitivity=state.print_options.pileup_sensitivity,
  339. airprint_detector=state.print_options.airprint_detector,
  340. airprint_sensitivity=state.print_options.airprint_sensitivity,
  341. auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
  342. filament_tangle_detect=state.print_options.filament_tangle_detect,
  343. )
  344. # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
  345. ams_mapping = raw_data.get("ams_mapping", [])
  346. # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
  347. ams_extruder_map = raw_data.get("ams_extruder_map", {})
  348. logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
  349. # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
  350. # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
  351. # No conversion needed - just use the raw value directly
  352. tray_now = state.tray_now
  353. logger.debug("Using tray_now directly as global ID: %s", tray_now)
  354. # Filter out chamber temp for models that don't have a real sensor
  355. # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
  356. temperatures = state.temperatures
  357. if not supports_chamber_temp(printer.model):
  358. temperatures = {
  359. k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
  360. }
  361. return PrinterStatus(
  362. id=printer_id,
  363. name=printer.name,
  364. connected=state.connected,
  365. state=state.state,
  366. current_print=state.current_print,
  367. subtask_name=state.subtask_name,
  368. gcode_file=state.gcode_file,
  369. progress=state.progress,
  370. remaining_time=state.remaining_time,
  371. layer_num=state.layer_num,
  372. total_layers=state.total_layers,
  373. temperatures=temperatures,
  374. cover_url=cover_url,
  375. hms_errors=hms_errors,
  376. ams=ams_units,
  377. ams_exists=ams_exists,
  378. vt_tray=vt_tray,
  379. sdcard=state.sdcard,
  380. store_to_sdcard=state.store_to_sdcard,
  381. timelapse=state.timelapse,
  382. ipcam=state.ipcam,
  383. wifi_signal=state.wifi_signal,
  384. nozzles=nozzles,
  385. nozzle_rack=nozzle_rack,
  386. print_options=print_options,
  387. stg_cur=state.stg_cur,
  388. stg_cur_name=get_derived_status_name(state, printer.model),
  389. stg=state.stg,
  390. airduct_mode=state.airduct_mode,
  391. speed_level=state.speed_level,
  392. chamber_light=state.chamber_light,
  393. active_extruder=state.active_extruder,
  394. ams_mapping=ams_mapping,
  395. ams_extruder_map=ams_extruder_map,
  396. tray_now=tray_now,
  397. ams_status_main=state.ams_status_main,
  398. ams_status_sub=state.ams_status_sub,
  399. mc_print_sub_stage=state.mc_print_sub_stage,
  400. last_ams_update=state.last_ams_update,
  401. printable_objects_count=len(state.printable_objects),
  402. cooling_fan_speed=state.cooling_fan_speed,
  403. big_fan1_speed=state.big_fan1_speed,
  404. big_fan2_speed=state.big_fan2_speed,
  405. heatbreak_fan_speed=state.heatbreak_fan_speed,
  406. firmware_version=state.firmware_version,
  407. )
  408. @router.get("/{printer_id}/current-print-user")
  409. async def get_current_print_user(
  410. printer_id: int,
  411. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  412. db: AsyncSession = Depends(get_db),
  413. ):
  414. """Get the user who started the current print (for reprint tracking).
  415. Returns user info if available, empty object otherwise.
  416. This tracks users for reprints (which bypass the queue).
  417. For queue-based prints, use the queue item's created_by field instead.
  418. """
  419. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  420. printer = result.scalar_one_or_none()
  421. if not printer:
  422. raise HTTPException(404, "Printer not found")
  423. user_info = printer_manager.get_current_print_user(printer_id)
  424. return user_info or {}
  425. @router.post("/{printer_id}/refresh-status")
  426. async def refresh_printer_status(
  427. printer_id: int,
  428. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  429. db: AsyncSession = Depends(get_db),
  430. ):
  431. """Request a full status refresh from the printer (sends pushall command)."""
  432. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  433. printer = result.scalar_one_or_none()
  434. if not printer:
  435. raise HTTPException(404, "Printer not found")
  436. success = printer_manager.request_status_update(printer_id)
  437. if not success:
  438. raise HTTPException(400, "Printer not connected")
  439. return {"status": "refresh_requested"}
  440. @router.post("/{printer_id}/connect")
  441. async def connect_printer(
  442. printer_id: int,
  443. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  444. db: AsyncSession = Depends(get_db),
  445. ):
  446. """Manually connect to a printer."""
  447. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  448. printer = result.scalar_one_or_none()
  449. if not printer:
  450. raise HTTPException(404, "Printer not found")
  451. success = await printer_manager.connect_printer(printer)
  452. return {"connected": success}
  453. @router.post("/{printer_id}/disconnect")
  454. async def disconnect_printer(
  455. printer_id: int,
  456. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  457. db: AsyncSession = Depends(get_db),
  458. ):
  459. """Manually disconnect from a printer."""
  460. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  461. printer = result.scalar_one_or_none()
  462. if not printer:
  463. raise HTTPException(404, "Printer not found")
  464. printer_manager.disconnect_printer(printer_id)
  465. return {"connected": False}
  466. @router.post("/test")
  467. async def test_printer_connection(
  468. ip_address: str,
  469. serial_number: str,
  470. access_code: str,
  471. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  472. ):
  473. """Test connection to a printer without saving."""
  474. result = await printer_manager.test_connection(
  475. ip_address=ip_address,
  476. serial_number=serial_number,
  477. access_code=access_code,
  478. )
  479. return result
  480. # Cache for cover images (printer_id -> {(gcode_file, view) -> image_bytes})
  481. _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
  482. @router.get("/{printer_id}/cover")
  483. async def get_printer_cover(
  484. printer_id: int,
  485. view: str | None = None,
  486. db: AsyncSession = Depends(get_db),
  487. ):
  488. # Note: No auth required - this is an image asset loaded via <img src> which can't send auth headers
  489. """Get the cover image for the current print job.
  490. Args:
  491. view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
  492. Default returns angled 3D perspective view.
  493. """
  494. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  495. printer = result.scalar_one_or_none()
  496. if not printer:
  497. raise HTTPException(404, "Printer not found")
  498. state = printer_manager.get_status(printer_id)
  499. if not state:
  500. raise HTTPException(404, "Printer not connected")
  501. # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
  502. subtask_name = state.subtask_name
  503. if not subtask_name:
  504. raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
  505. # Extract plate number from gcode_file (e.g., "/data/Metadata/plate_12.gcode" -> 12)
  506. plate_num = 1
  507. gcode_file = state.gcode_file
  508. if gcode_file:
  509. match = re.search(r"plate_(\d+)\.gcode", gcode_file)
  510. if match:
  511. plate_num = int(match.group(1))
  512. logger.info("Detected plate number %s from gcode_file: %s", plate_num, gcode_file)
  513. # Normalize view parameter
  514. view_key = view or "default"
  515. # Check cache - include plate_num in cache key for multi-plate projects
  516. if printer_id in _cover_cache:
  517. cache_key = (subtask_name, plate_num, view_key)
  518. if cache_key in _cover_cache[printer_id]:
  519. return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
  520. # Build possible 3MF filenames from subtask_name
  521. # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
  522. # or just "name.3mf" (uploaded directly)
  523. possible_filenames = []
  524. if subtask_name.endswith(".3mf"):
  525. possible_filenames.append(subtask_name)
  526. else:
  527. # Try both naming patterns
  528. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  529. possible_filenames.append(f"{subtask_name}.3mf")
  530. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  531. if " " in subtask_name:
  532. normalized = subtask_name.replace(" ", "_")
  533. if normalized.endswith(".3mf"):
  534. possible_filenames.append(normalized)
  535. else:
  536. possible_filenames.append(f"{normalized}.gcode.3mf")
  537. possible_filenames.append(f"{normalized}.3mf")
  538. # Build list of all remote paths to try
  539. remote_paths = []
  540. for filename in possible_filenames:
  541. remote_paths.extend(
  542. [
  543. f"/{filename}", # Root directory (most common)
  544. f"/cache/{filename}",
  545. f"/model/{filename}",
  546. f"/data/{filename}",
  547. ]
  548. )
  549. # Use first filename for temp path (will be reused)
  550. temp_filename = possible_filenames[0]
  551. temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
  552. temp_path.parent.mkdir(parents=True, exist_ok=True)
  553. logger.info(
  554. f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
  555. )
  556. # Retry logic for transient FTP failures
  557. max_retries = 2
  558. last_error = None
  559. downloaded = False
  560. for attempt in range(max_retries + 1):
  561. try:
  562. downloaded = await download_file_try_paths_async(
  563. printer.ip_address,
  564. printer.access_code,
  565. remote_paths,
  566. temp_path,
  567. printer_model=printer.model,
  568. )
  569. if downloaded:
  570. break
  571. except Exception as e:
  572. last_error = e
  573. if attempt < max_retries:
  574. logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
  575. await asyncio.sleep(0.5 * (attempt + 1)) # Brief backoff
  576. else:
  577. logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
  578. if last_error and not downloaded:
  579. raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
  580. if not downloaded:
  581. raise HTTPException(
  582. 404,
  583. f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
  584. )
  585. # Verify file actually exists and has content
  586. if not temp_path.exists():
  587. raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
  588. file_size = temp_path.stat().st_size
  589. logger.info("Downloaded file size: %s bytes", file_size)
  590. if file_size == 0:
  591. temp_path.unlink()
  592. raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'")
  593. try:
  594. # Extract thumbnail from 3MF (which is a ZIP file)
  595. try:
  596. zf = zipfile.ZipFile(temp_path, "r")
  597. except zipfile.BadZipFile:
  598. raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
  599. except OSError as e:
  600. logger.error("Failed to open 3MF file: %s", e, exc_info=True)
  601. raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
  602. try:
  603. # Try common thumbnail paths in 3MF files
  604. # Use plate_num to get the correct plate's thumbnail for multi-plate projects
  605. # Use top-down view if requested (better for skip objects modal)
  606. if view == "top":
  607. thumbnail_paths = [
  608. f"Metadata/top_{plate_num}.png",
  609. # Fall back to plate 1 if specific plate not found
  610. "Metadata/top_1.png",
  611. f"Metadata/plate_{plate_num}.png",
  612. "Metadata/plate_1.png",
  613. "Metadata/thumbnail.png",
  614. ]
  615. else:
  616. thumbnail_paths = [
  617. f"Metadata/plate_{plate_num}.png",
  618. # Fall back to plate 1 if specific plate not found
  619. "Metadata/plate_1.png",
  620. "Metadata/thumbnail.png",
  621. f"Metadata/plate_{plate_num}_small.png",
  622. "Metadata/plate_1_small.png",
  623. "Thumbnails/thumbnail.png",
  624. "thumbnail.png",
  625. ]
  626. for thumb_path in thumbnail_paths:
  627. try:
  628. image_data = zf.read(thumb_path)
  629. # Cache the result - include plate_num in cache key
  630. if printer_id not in _cover_cache:
  631. _cover_cache[printer_id] = {}
  632. _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
  633. return Response(content=image_data, media_type="image/png")
  634. except KeyError:
  635. continue
  636. # If no specific thumbnail found, try any PNG in Metadata
  637. for name in zf.namelist():
  638. if name.startswith("Metadata/") and name.endswith(".png"):
  639. image_data = zf.read(name)
  640. if printer_id not in _cover_cache:
  641. _cover_cache[printer_id] = {}
  642. _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
  643. return Response(content=image_data, media_type="image/png")
  644. raise HTTPException(404, "No thumbnail found in 3MF file")
  645. finally:
  646. zf.close()
  647. finally:
  648. if temp_path.exists():
  649. temp_path.unlink()
  650. # ============================================
  651. # File Manager Endpoints
  652. # ============================================
  653. @router.get("/{printer_id}/files")
  654. async def list_printer_files(
  655. printer_id: int,
  656. path: str = "/",
  657. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  658. db: AsyncSession = Depends(get_db),
  659. ):
  660. """List files on the printer at the specified path."""
  661. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  662. printer = result.scalar_one_or_none()
  663. if not printer:
  664. raise HTTPException(404, "Printer not found")
  665. files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  666. # Add full path to each file
  667. for f in files:
  668. f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
  669. return {
  670. "path": path,
  671. "files": files,
  672. }
  673. @router.get("/{printer_id}/files/download")
  674. async def download_printer_file(
  675. printer_id: int,
  676. path: str,
  677. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  678. db: AsyncSession = Depends(get_db),
  679. ):
  680. """Download a file from the printer."""
  681. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  682. printer = result.scalar_one_or_none()
  683. if not printer:
  684. raise HTTPException(404, "Printer not found")
  685. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  686. if data is None:
  687. raise HTTPException(404, f"File not found: {path}")
  688. # Determine content type based on extension
  689. filename = path.split("/")[-1]
  690. ext = filename.lower().split(".")[-1] if "." in filename else ""
  691. content_types = {
  692. "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  693. "gcode": "text/plain",
  694. "mp4": "video/mp4",
  695. "avi": "video/x-msvideo",
  696. "png": "image/png",
  697. "jpg": "image/jpeg",
  698. "jpeg": "image/jpeg",
  699. "json": "application/json",
  700. "txt": "text/plain",
  701. }
  702. content_type = content_types.get(ext, "application/octet-stream")
  703. return Response(
  704. content=data,
  705. media_type=content_type,
  706. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  707. )
  708. @router.get("/{printer_id}/files/gcode")
  709. async def get_printer_file_gcode(
  710. printer_id: int,
  711. path: str,
  712. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  713. db: AsyncSession = Depends(get_db),
  714. ):
  715. """Get gcode for a file stored on a printer (for preview)."""
  716. import io
  717. # Validate printer
  718. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  719. printer = result.scalar_one_or_none()
  720. if not printer:
  721. raise HTTPException(404, "Printer not found")
  722. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  723. if data is None:
  724. raise HTTPException(404, f"File not found: {path}")
  725. filename = path.split("/")[-1]
  726. lower = filename.lower()
  727. if lower.endswith(".gcode"):
  728. return Response(content=data, media_type="text/plain")
  729. if lower.endswith(".3mf"):
  730. try:
  731. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  732. gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
  733. if not gcode_files:
  734. raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
  735. gcode_content = zf.read(gcode_files[0])
  736. return Response(content=gcode_content, media_type="text/plain")
  737. except zipfile.BadZipFile:
  738. raise HTTPException(status_code=400, detail="Invalid 3MF file")
  739. raise HTTPException(status_code=400, detail="Unsupported file type")
  740. @router.get("/{printer_id}/files/plates")
  741. async def get_printer_file_plates(
  742. printer_id: int,
  743. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  744. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  745. db: AsyncSession = Depends(get_db),
  746. ):
  747. """Get available plates from a multi-plate 3MF file stored on a printer."""
  748. import io
  749. import json
  750. import defusedxml.ElementTree as ET
  751. # Validate printer
  752. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  753. printer = result.scalar_one_or_none()
  754. if not printer:
  755. raise HTTPException(404, "Printer not found")
  756. filename = path.split("/")[-1]
  757. if not filename.lower().endswith(".3mf"):
  758. return {
  759. "printer_id": printer_id,
  760. "path": path,
  761. "filename": filename,
  762. "plates": [],
  763. "is_multi_plate": False,
  764. }
  765. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  766. if data is None:
  767. raise HTTPException(404, f"File not found: {path}")
  768. plates = []
  769. try:
  770. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  771. namelist = zf.namelist()
  772. # Find all plate gcode files to determine available plates
  773. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  774. # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
  775. plate_indices: list[int] = []
  776. if gcode_files:
  777. for gf in gcode_files:
  778. try:
  779. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  780. plate_indices.append(int(plate_str))
  781. except ValueError:
  782. pass # Skip gcode files with non-numeric plate indices
  783. else:
  784. plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
  785. plate_png_files = [
  786. n
  787. for n in namelist
  788. if n.startswith("Metadata/plate_")
  789. and n.endswith(".png")
  790. and "_small" not in n
  791. and "no_light" not in n
  792. ]
  793. plate_name_candidates = plate_json_files + plate_png_files
  794. plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
  795. seen_indices: set[int] = set()
  796. for name in plate_name_candidates:
  797. match = plate_re.match(name)
  798. if match:
  799. try:
  800. index = int(match.group(1))
  801. except ValueError:
  802. continue
  803. if index in seen_indices:
  804. continue
  805. seen_indices.add(index)
  806. plate_indices.append(index)
  807. if not plate_indices:
  808. return {
  809. "printer_id": printer_id,
  810. "path": path,
  811. "filename": filename,
  812. "plates": [],
  813. "is_multi_plate": False,
  814. }
  815. plate_indices.sort()
  816. # Parse model_settings.config for plate names
  817. plate_names = {}
  818. if "Metadata/model_settings.config" in namelist:
  819. try:
  820. model_content = zf.read("Metadata/model_settings.config").decode()
  821. model_root = ET.fromstring(model_content)
  822. for plate_elem in model_root.findall(".//plate"):
  823. plater_id = None
  824. plater_name = None
  825. for meta in plate_elem.findall("metadata"):
  826. key = meta.get("key")
  827. value = meta.get("value")
  828. if key == "plater_id" and value:
  829. try:
  830. plater_id = int(value)
  831. except ValueError:
  832. pass # Skip plate with unparseable ID
  833. elif key == "plater_name" and value:
  834. plater_name = value.strip()
  835. if plater_id is not None and plater_name:
  836. plate_names[plater_id] = plater_name
  837. except Exception:
  838. pass # Plate names are optional; continue without them
  839. # Parse slice_info.config for plate metadata
  840. plate_metadata = {}
  841. if "Metadata/slice_info.config" in namelist:
  842. content = zf.read("Metadata/slice_info.config").decode()
  843. root = ET.fromstring(content)
  844. for plate_elem in root.findall(".//plate"):
  845. plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
  846. plate_index = None
  847. for meta in plate_elem.findall("metadata"):
  848. key = meta.get("key")
  849. value = meta.get("value")
  850. if key == "index" and value:
  851. try:
  852. plate_index = int(value)
  853. except ValueError:
  854. pass # Skip plate with unparseable index
  855. elif key == "prediction" and value:
  856. try:
  857. plate_info["prediction"] = int(value)
  858. except ValueError:
  859. pass # Skip unparseable prediction; leave as None
  860. elif key == "weight" and value:
  861. try:
  862. plate_info["weight"] = float(value)
  863. except ValueError:
  864. pass # Skip unparseable weight; leave as None
  865. # Get filaments used in this plate
  866. for filament_elem in plate_elem.findall("filament"):
  867. filament_id = filament_elem.get("id")
  868. filament_type = filament_elem.get("type", "")
  869. filament_color = filament_elem.get("color", "")
  870. used_g = filament_elem.get("used_g", "0")
  871. used_m = filament_elem.get("used_m", "0")
  872. try:
  873. used_grams = float(used_g)
  874. except (ValueError, TypeError):
  875. used_grams = 0
  876. if used_grams > 0 and filament_id:
  877. plate_info["filaments"].append(
  878. {
  879. "slot_id": int(filament_id),
  880. "type": filament_type,
  881. "color": filament_color,
  882. "used_grams": round(used_grams, 1),
  883. "used_meters": float(used_m) if used_m else 0,
  884. }
  885. )
  886. plate_info["filaments"].sort(key=lambda x: x["slot_id"])
  887. # Collect object names
  888. for obj_elem in plate_elem.findall("object"):
  889. obj_name = obj_elem.get("name")
  890. if obj_name and obj_name not in plate_info["objects"]:
  891. plate_info["objects"].append(obj_name)
  892. # Set plate name
  893. if plate_index is not None:
  894. custom_name = plate_names.get(plate_index)
  895. if custom_name:
  896. plate_info["name"] = custom_name
  897. elif plate_info["objects"]:
  898. plate_info["name"] = plate_info["objects"][0]
  899. plate_metadata[plate_index] = plate_info
  900. # Parse plate_*.json for object lists when slice_info is missing
  901. plate_json_objects: dict[int, list[str]] = {}
  902. for name in namelist:
  903. match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
  904. if not match:
  905. continue
  906. try:
  907. plate_index = int(match.group(1))
  908. except ValueError:
  909. continue
  910. try:
  911. payload = json.loads(zf.read(name).decode())
  912. bbox_objects = payload.get("bbox_objects", [])
  913. names: list[str] = []
  914. for obj in bbox_objects:
  915. obj_name = obj.get("name") if isinstance(obj, dict) else None
  916. if obj_name and obj_name not in names:
  917. names.append(obj_name)
  918. if names:
  919. plate_json_objects[plate_index] = names
  920. except Exception:
  921. continue
  922. # Build plate list
  923. for idx in plate_indices:
  924. meta = plate_metadata.get(idx, {})
  925. has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
  926. objects = meta.get("objects", [])
  927. if not objects:
  928. objects = plate_json_objects.get(idx, [])
  929. plate_name = meta.get("name")
  930. if not plate_name:
  931. plate_name = plate_names.get(idx)
  932. if not plate_name and objects:
  933. plate_name = objects[0]
  934. plates.append(
  935. {
  936. "index": idx,
  937. "name": plate_name,
  938. "objects": objects,
  939. "object_count": len(objects),
  940. "has_thumbnail": has_thumbnail,
  941. "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
  942. "print_time_seconds": meta.get("prediction"),
  943. "filament_used_grams": meta.get("weight"),
  944. "filaments": meta.get("filaments", []),
  945. }
  946. )
  947. except Exception as e:
  948. logger.warning("Failed to parse plates from printer file %s: %s", path, e)
  949. return {
  950. "printer_id": printer_id,
  951. "path": path,
  952. "filename": filename,
  953. "plates": plates,
  954. "is_multi_plate": len(plates) > 1,
  955. }
  956. @router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
  957. async def get_printer_file_plate_thumbnail(
  958. printer_id: int,
  959. plate_index: int,
  960. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  961. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  962. db: AsyncSession = Depends(get_db),
  963. ):
  964. """Get a plate thumbnail image from a printer-stored 3MF file."""
  965. import io
  966. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  967. printer = result.scalar_one_or_none()
  968. if not printer:
  969. raise HTTPException(404, "Printer not found")
  970. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  971. if data is None:
  972. raise HTTPException(404, f"File not found: {path}")
  973. try:
  974. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  975. thumb_path = f"Metadata/plate_{plate_index}.png"
  976. if thumb_path in zf.namelist():
  977. image_data = zf.read(thumb_path)
  978. return Response(content=image_data, media_type="image/png")
  979. except Exception:
  980. pass # Corrupt or unreadable 3MF; fall through to 404
  981. raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
  982. @router.post("/{printer_id}/files/download-zip")
  983. async def download_printer_files_as_zip(
  984. printer_id: int,
  985. request: dict,
  986. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  987. db: AsyncSession = Depends(get_db),
  988. ):
  989. """Download multiple files from the printer as a ZIP archive."""
  990. import io
  991. paths = request.get("paths", [])
  992. if not paths:
  993. raise HTTPException(400, "No files specified")
  994. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  995. printer = result.scalar_one_or_none()
  996. if not printer:
  997. raise HTTPException(404, "Printer not found")
  998. # Create ZIP in memory
  999. zip_buffer = io.BytesIO()
  1000. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1001. for path in paths:
  1002. try:
  1003. data = await download_file_bytes_async(
  1004. printer.ip_address, printer.access_code, path, printer_model=printer.model
  1005. )
  1006. if data:
  1007. filename = path.split("/")[-1]
  1008. zf.writestr(filename, data)
  1009. except Exception as e:
  1010. logging.warning("Failed to add %s to ZIP: %s", path, e)
  1011. continue
  1012. zip_buffer.seek(0)
  1013. zip_data = zip_buffer.read()
  1014. if len(zip_data) == 0:
  1015. raise HTTPException(404, "No files could be downloaded")
  1016. return Response(
  1017. content=zip_data,
  1018. media_type="application/zip",
  1019. headers={"Content-Disposition": 'attachment; filename="printer-files.zip"'},
  1020. )
  1021. @router.delete("/{printer_id}/files")
  1022. async def delete_printer_file(
  1023. printer_id: int,
  1024. path: str,
  1025. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1026. db: AsyncSession = Depends(get_db),
  1027. ):
  1028. """Delete a file from the printer."""
  1029. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1030. printer = result.scalar_one_or_none()
  1031. if not printer:
  1032. raise HTTPException(404, "Printer not found")
  1033. success = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1034. if not success:
  1035. raise HTTPException(500, f"Failed to delete file: {path}")
  1036. return {"status": "deleted", "path": path}
  1037. @router.get("/{printer_id}/storage")
  1038. async def get_printer_storage(
  1039. printer_id: int,
  1040. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1041. db: AsyncSession = Depends(get_db),
  1042. ):
  1043. """Get storage information from the printer."""
  1044. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1045. printer = result.scalar_one_or_none()
  1046. if not printer:
  1047. raise HTTPException(404, "Printer not found")
  1048. storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
  1049. return storage_info or {"used_bytes": None, "free_bytes": None}
  1050. # ============================================
  1051. # MQTT Debug Logging Endpoints
  1052. # ============================================
  1053. @router.post("/{printer_id}/logging/enable")
  1054. async def enable_mqtt_logging(
  1055. printer_id: int,
  1056. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1057. db: AsyncSession = Depends(get_db),
  1058. ):
  1059. """Enable MQTT message logging for a printer."""
  1060. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1061. printer = result.scalar_one_or_none()
  1062. if not printer:
  1063. raise HTTPException(404, "Printer not found")
  1064. success = printer_manager.enable_logging(printer_id, True)
  1065. if not success:
  1066. raise HTTPException(400, "Printer not connected")
  1067. return {"logging_enabled": True}
  1068. @router.post("/{printer_id}/logging/disable")
  1069. async def disable_mqtt_logging(
  1070. printer_id: int,
  1071. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1072. db: AsyncSession = Depends(get_db),
  1073. ):
  1074. """Disable MQTT message logging for a printer."""
  1075. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1076. printer = result.scalar_one_or_none()
  1077. if not printer:
  1078. raise HTTPException(404, "Printer not found")
  1079. success = printer_manager.enable_logging(printer_id, False)
  1080. if not success:
  1081. raise HTTPException(400, "Printer not connected")
  1082. return {"logging_enabled": False}
  1083. @router.get("/{printer_id}/logging")
  1084. async def get_mqtt_logs(
  1085. printer_id: int,
  1086. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1087. db: AsyncSession = Depends(get_db),
  1088. ):
  1089. """Get MQTT message logs for a printer."""
  1090. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1091. printer = result.scalar_one_or_none()
  1092. if not printer:
  1093. raise HTTPException(404, "Printer not found")
  1094. logs = printer_manager.get_logs(printer_id)
  1095. return {
  1096. "logging_enabled": printer_manager.is_logging_enabled(printer_id),
  1097. "logs": [
  1098. {
  1099. "timestamp": log.timestamp,
  1100. "topic": log.topic,
  1101. "direction": log.direction,
  1102. "payload": log.payload,
  1103. }
  1104. for log in logs
  1105. ],
  1106. }
  1107. @router.delete("/{printer_id}/logging")
  1108. async def clear_mqtt_logs(
  1109. printer_id: int,
  1110. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1111. db: AsyncSession = Depends(get_db),
  1112. ):
  1113. """Clear MQTT message logs for a printer."""
  1114. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1115. printer = result.scalar_one_or_none()
  1116. if not printer:
  1117. raise HTTPException(404, "Printer not found")
  1118. printer_manager.clear_logs(printer_id)
  1119. return {"status": "cleared"}
  1120. # ============================================
  1121. # Print Options (AI Detection) Endpoints
  1122. # ============================================
  1123. @router.post("/{printer_id}/print-options")
  1124. async def set_print_option(
  1125. printer_id: int,
  1126. module_name: str,
  1127. enabled: bool,
  1128. print_halt: bool = True,
  1129. sensitivity: str = "medium",
  1130. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1131. db: AsyncSession = Depends(get_db),
  1132. ):
  1133. """Set an AI detection / print option on the printer.
  1134. Valid module_name values:
  1135. - spaghetti_detector: Spaghetti detection
  1136. - first_layer_inspector: First layer inspection
  1137. - printing_monitor: AI print quality monitoring
  1138. - buildplate_marker_detector: Build plate marker detection
  1139. - allow_skip_parts: Allow skipping failed parts
  1140. """
  1141. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1142. printer = result.scalar_one_or_none()
  1143. if not printer:
  1144. raise HTTPException(404, "Printer not found")
  1145. client = printer_manager.get_client(printer_id)
  1146. if not client or not client.state.connected:
  1147. raise HTTPException(400, "Printer not connected")
  1148. # Validate module_name
  1149. valid_modules = [
  1150. "spaghetti_detector",
  1151. "first_layer_inspector",
  1152. "printing_monitor",
  1153. "buildplate_marker_detector",
  1154. "allow_skip_parts",
  1155. "pileup_detector",
  1156. "clump_detector",
  1157. "airprint_detector",
  1158. "auto_recovery_step_loss",
  1159. ]
  1160. if module_name not in valid_modules:
  1161. raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
  1162. # Validate sensitivity
  1163. valid_sensitivities = ["low", "medium", "high", "never_halt"]
  1164. if sensitivity not in valid_sensitivities:
  1165. raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
  1166. success = client.set_xcam_option(
  1167. module_name=module_name,
  1168. enabled=enabled,
  1169. print_halt=print_halt,
  1170. sensitivity=sensitivity,
  1171. )
  1172. if not success:
  1173. raise HTTPException(500, "Failed to send command to printer")
  1174. return {
  1175. "success": True,
  1176. "module_name": module_name,
  1177. "enabled": enabled,
  1178. "print_halt": print_halt,
  1179. "sensitivity": sensitivity,
  1180. }
  1181. # ============================================
  1182. # Calibration
  1183. # ============================================
  1184. @router.post("/{printer_id}/calibration")
  1185. async def start_calibration(
  1186. printer_id: int,
  1187. bed_leveling: bool = False,
  1188. vibration: bool = False,
  1189. motor_noise: bool = False,
  1190. nozzle_offset: bool = False,
  1191. high_temp_heatbed: bool = False,
  1192. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1193. db: AsyncSession = Depends(get_db),
  1194. ):
  1195. """Start printer calibration with selected options.
  1196. At least one option must be selected.
  1197. Options:
  1198. - bed_leveling: Run bed leveling calibration
  1199. - vibration: Run vibration compensation calibration
  1200. - motor_noise: Run motor noise cancellation calibration
  1201. - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
  1202. - high_temp_heatbed: Run high-temperature heatbed calibration
  1203. """
  1204. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1205. printer = result.scalar_one_or_none()
  1206. if not printer:
  1207. raise HTTPException(404, "Printer not found")
  1208. client = printer_manager.get_client(printer_id)
  1209. if not client or not client.state.connected:
  1210. raise HTTPException(400, "Printer not connected")
  1211. # Check that at least one option is selected
  1212. if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
  1213. raise HTTPException(400, "At least one calibration option must be selected")
  1214. success = client.start_calibration(
  1215. bed_leveling=bed_leveling,
  1216. vibration=vibration,
  1217. motor_noise=motor_noise,
  1218. nozzle_offset=nozzle_offset,
  1219. high_temp_heatbed=high_temp_heatbed,
  1220. )
  1221. if not success:
  1222. raise HTTPException(500, "Failed to send calibration command to printer")
  1223. return {
  1224. "success": True,
  1225. "bed_leveling": bed_leveling,
  1226. "vibration": vibration,
  1227. "motor_noise": motor_noise,
  1228. "nozzle_offset": nozzle_offset,
  1229. "high_temp_heatbed": high_temp_heatbed,
  1230. }
  1231. # ============================================================================
  1232. # Slot Preset Mapping Endpoints
  1233. # ============================================================================
  1234. @router.get("/{printer_id}/slot-presets")
  1235. async def get_slot_presets(
  1236. printer_id: int,
  1237. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1238. db: AsyncSession = Depends(get_db),
  1239. ):
  1240. """Get all saved slot-to-preset mappings for a printer."""
  1241. result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))
  1242. mappings = result.scalars().all()
  1243. return {
  1244. mapping.ams_id * 4 + mapping.tray_id: {
  1245. "ams_id": mapping.ams_id,
  1246. "tray_id": mapping.tray_id,
  1247. "preset_id": mapping.preset_id,
  1248. "preset_name": mapping.preset_name,
  1249. }
  1250. for mapping in mappings
  1251. }
  1252. @router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1253. async def get_slot_preset(
  1254. printer_id: int,
  1255. ams_id: int,
  1256. tray_id: int,
  1257. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1258. db: AsyncSession = Depends(get_db),
  1259. ):
  1260. """Get the saved preset for a specific slot."""
  1261. result = await db.execute(
  1262. select(SlotPresetMapping).where(
  1263. SlotPresetMapping.printer_id == printer_id,
  1264. SlotPresetMapping.ams_id == ams_id,
  1265. SlotPresetMapping.tray_id == tray_id,
  1266. )
  1267. )
  1268. mapping = result.scalar_one_or_none()
  1269. if not mapping:
  1270. return None
  1271. return {
  1272. "ams_id": mapping.ams_id,
  1273. "tray_id": mapping.tray_id,
  1274. "preset_id": mapping.preset_id,
  1275. "preset_name": mapping.preset_name,
  1276. }
  1277. @router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1278. async def save_slot_preset(
  1279. printer_id: int,
  1280. ams_id: int,
  1281. tray_id: int,
  1282. preset_id: str,
  1283. preset_name: str,
  1284. preset_source: str = "cloud",
  1285. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  1286. db: AsyncSession = Depends(get_db),
  1287. ):
  1288. """Save a preset mapping for a specific slot."""
  1289. # Check printer exists
  1290. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1291. if not result.scalar_one_or_none():
  1292. raise HTTPException(404, "Printer not found")
  1293. # Check for existing mapping
  1294. result = await db.execute(
  1295. select(SlotPresetMapping).where(
  1296. SlotPresetMapping.printer_id == printer_id,
  1297. SlotPresetMapping.ams_id == ams_id,
  1298. SlotPresetMapping.tray_id == tray_id,
  1299. )
  1300. )
  1301. mapping = result.scalar_one_or_none()
  1302. if mapping:
  1303. # Update existing
  1304. mapping.preset_id = preset_id
  1305. mapping.preset_name = preset_name
  1306. mapping.preset_source = preset_source
  1307. else:
  1308. # Create new
  1309. mapping = SlotPresetMapping(
  1310. printer_id=printer_id,
  1311. ams_id=ams_id,
  1312. tray_id=tray_id,
  1313. preset_id=preset_id,
  1314. preset_name=preset_name,
  1315. preset_source=preset_source,
  1316. )
  1317. db.add(mapping)
  1318. await db.commit()
  1319. await db.refresh(mapping)
  1320. return {
  1321. "ams_id": mapping.ams_id,
  1322. "tray_id": mapping.tray_id,
  1323. "preset_id": mapping.preset_id,
  1324. "preset_name": mapping.preset_name,
  1325. "preset_source": mapping.preset_source,
  1326. }
  1327. @router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1328. async def delete_slot_preset(
  1329. printer_id: int,
  1330. ams_id: int,
  1331. tray_id: int,
  1332. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  1333. db: AsyncSession = Depends(get_db),
  1334. ):
  1335. """Delete a saved preset mapping for a slot."""
  1336. result = await db.execute(
  1337. select(SlotPresetMapping).where(
  1338. SlotPresetMapping.printer_id == printer_id,
  1339. SlotPresetMapping.ams_id == ams_id,
  1340. SlotPresetMapping.tray_id == tray_id,
  1341. )
  1342. )
  1343. mapping = result.scalar_one_or_none()
  1344. if mapping:
  1345. await db.delete(mapping)
  1346. await db.commit()
  1347. return {"success": True}
  1348. @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
  1349. async def configure_ams_slot(
  1350. printer_id: int,
  1351. ams_id: int,
  1352. tray_id: int,
  1353. tray_info_idx: str = Query(...),
  1354. tray_type: str = Query(...),
  1355. tray_sub_brands: str = Query(...),
  1356. tray_color: str = Query(...),
  1357. nozzle_temp_min: int = Query(...),
  1358. nozzle_temp_max: int = Query(...),
  1359. cali_idx: int = Query(-1),
  1360. nozzle_diameter: str = Query("0.4"),
  1361. setting_id: str = Query(""),
  1362. kprofile_filament_id: str = Query(""),
  1363. kprofile_setting_id: str = Query(""),
  1364. k_value: float = Query(0.0),
  1365. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1366. ):
  1367. """Configure an AMS slot with a specific filament setting and K profile.
  1368. This sends two commands to the printer:
  1369. 1. ams_filament_setting - sets filament type, color, temperature
  1370. 2. extrusion_cali_sel - sets the K profile (pressure advance value)
  1371. Args:
  1372. printer_id: Database ID of the printer
  1373. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
  1374. tray_id: Tray ID within the AMS (0-3)
  1375. tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
  1376. tray_type: Filament type (e.g., "PLA", "PETG")
  1377. tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
  1378. tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
  1379. nozzle_temp_min: Minimum nozzle temperature
  1380. nozzle_temp_max: Maximum nozzle temperature
  1381. cali_idx: K profile calibration index (-1 for default 0.020)
  1382. nozzle_diameter: Nozzle diameter string (e.g., "0.4")
  1383. setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
  1384. kprofile_filament_id: K profile's filament_id for proper K profile linking
  1385. k_value: Direct K value to set (0.0 to skip direct K value setting)
  1386. """
  1387. logger = logging.getLogger(__name__)
  1388. logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
  1389. logger.info(
  1390. f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
  1391. )
  1392. logger.info(
  1393. f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
  1394. )
  1395. # Get MQTT client for this printer
  1396. client = printer_manager.get_client(printer_id)
  1397. if not client:
  1398. raise HTTPException(status_code=400, detail="Printer not connected")
  1399. # Send the filament setting command (type, color, temp)
  1400. success = client.ams_set_filament_setting(
  1401. ams_id=ams_id,
  1402. tray_id=tray_id,
  1403. tray_info_idx=tray_info_idx,
  1404. tray_type=tray_type,
  1405. tray_sub_brands=tray_sub_brands,
  1406. tray_color=tray_color,
  1407. nozzle_temp_min=nozzle_temp_min,
  1408. nozzle_temp_max=nozzle_temp_max,
  1409. setting_id=setting_id,
  1410. )
  1411. if not success:
  1412. raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
  1413. # Send the calibration/K-profile commands
  1414. # Use the K profile's filament_id if provided, otherwise use tray_info_idx
  1415. filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
  1416. # Method 1: Select existing calibration profile by cali_idx
  1417. # IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id)
  1418. # Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer
  1419. client.extrusion_cali_sel(
  1420. ams_id=ams_id,
  1421. tray_id=tray_id,
  1422. cali_idx=cali_idx,
  1423. filament_id=filament_id_for_kprofile,
  1424. nozzle_diameter=nozzle_diameter,
  1425. setting_id=kprofile_setting_id if kprofile_setting_id else None,
  1426. )
  1427. # Method 2: Also directly set the K value if provided (for better compatibility)
  1428. if k_value > 0:
  1429. # Calculate global tray ID for extrusion_cali_set
  1430. if ams_id <= 3:
  1431. global_tray_id = ams_id * 4 + tray_id
  1432. elif ams_id >= 128 and ams_id <= 135:
  1433. global_tray_id = (ams_id - 128) * 4 + tray_id
  1434. else:
  1435. global_tray_id = tray_id
  1436. client.extrusion_cali_set(
  1437. tray_id=global_tray_id,
  1438. k_value=k_value,
  1439. n_coef=0.0,
  1440. nozzle_diameter=nozzle_diameter,
  1441. bed_temp=60,
  1442. nozzle_temp=nozzle_temp_max,
  1443. max_volumetric_speed=20.0,
  1444. )
  1445. # Request fresh status push from printer so frontend gets updated data via WebSocket
  1446. logger.info("[configure_ams_slot] Requesting status update from printer")
  1447. update_result = client.request_status_update()
  1448. logger.info("[configure_ams_slot] Status update request result: %s", update_result)
  1449. return {
  1450. "success": True,
  1451. "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
  1452. }
  1453. @router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
  1454. async def reset_ams_slot(
  1455. printer_id: int,
  1456. ams_id: int,
  1457. tray_id: int,
  1458. db: AsyncSession = Depends(get_db),
  1459. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1460. ):
  1461. """Reset an AMS slot to empty/unconfigured state.
  1462. This clears the filament configuration from the slot.
  1463. """
  1464. # Get MQTT client for this printer
  1465. client = printer_manager.get_client(printer_id)
  1466. if not client:
  1467. raise HTTPException(status_code=400, detail="Printer not connected")
  1468. # Reset the slot
  1469. success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
  1470. if not success:
  1471. raise HTTPException(status_code=500, detail="Failed to send reset command")
  1472. # Also delete any saved slot preset mapping
  1473. result = await db.execute(
  1474. select(SlotPresetMapping).where(
  1475. SlotPresetMapping.printer_id == printer_id,
  1476. SlotPresetMapping.ams_id == ams_id,
  1477. SlotPresetMapping.tray_id == tray_id,
  1478. )
  1479. )
  1480. mapping = result.scalar_one_or_none()
  1481. if mapping:
  1482. await db.delete(mapping)
  1483. await db.commit()
  1484. # Request fresh status push from printer so frontend gets updated data via WebSocket
  1485. client.request_status_update()
  1486. return {
  1487. "success": True,
  1488. "message": f"Reset AMS {ams_id} tray {tray_id}",
  1489. }
  1490. @router.post("/{printer_id}/debug/simulate-print-complete")
  1491. async def debug_simulate_print_complete(
  1492. printer_id: int,
  1493. db: AsyncSession = Depends(get_db),
  1494. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1495. ):
  1496. """DEBUG: Simulate print completion to test freeze behavior.
  1497. This triggers the same code path as a real print completion,
  1498. without needing to wait for an actual print to finish.
  1499. """
  1500. from backend.app.main import _active_prints, on_print_complete
  1501. from backend.app.models.archive import PrintArchive
  1502. # Get the most recent archive for this printer
  1503. result = await db.execute(
  1504. select(PrintArchive)
  1505. .where(PrintArchive.printer_id == printer_id)
  1506. .order_by(PrintArchive.created_at.desc())
  1507. .limit(1)
  1508. )
  1509. archive = result.scalar_one_or_none()
  1510. if not archive:
  1511. raise HTTPException(status_code=404, detail="No archives found for this printer")
  1512. # Register this archive as "active" so on_print_complete can find it
  1513. filename = archive.file_path.split("/")[-1] if archive.file_path else "test.3mf"
  1514. subtask_name = archive.print_name or "Test Print"
  1515. _active_prints[(printer_id, filename)] = archive.id
  1516. _active_prints[(printer_id, subtask_name)] = archive.id
  1517. # Simulate print completion data
  1518. data = {
  1519. "status": "completed",
  1520. "filename": filename,
  1521. "subtask_name": subtask_name,
  1522. "timelapse_was_active": False,
  1523. }
  1524. logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id)
  1525. # Call the actual on_print_complete handler
  1526. await on_print_complete(printer_id, data)
  1527. return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
  1528. # =============================================================================
  1529. # Print Control Endpoints
  1530. # =============================================================================
  1531. @router.post("/{printer_id}/print/stop")
  1532. async def stop_print(
  1533. printer_id: int,
  1534. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1535. db: AsyncSession = Depends(get_db),
  1536. ):
  1537. """Stop/cancel the current print job."""
  1538. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1539. printer = result.scalar_one_or_none()
  1540. if not printer:
  1541. raise HTTPException(404, "Printer not found")
  1542. client = printer_manager.get_client(printer_id)
  1543. if not client:
  1544. raise HTTPException(400, "Printer not connected")
  1545. success = client.stop_print()
  1546. if not success:
  1547. raise HTTPException(500, "Failed to stop print")
  1548. return {"success": True, "message": "Print stop command sent"}
  1549. @router.post("/{printer_id}/print/pause")
  1550. async def pause_print(
  1551. printer_id: int,
  1552. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1553. db: AsyncSession = Depends(get_db),
  1554. ):
  1555. """Pause the current print job."""
  1556. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1557. printer = result.scalar_one_or_none()
  1558. if not printer:
  1559. raise HTTPException(404, "Printer not found")
  1560. client = printer_manager.get_client(printer_id)
  1561. if not client:
  1562. raise HTTPException(400, "Printer not connected")
  1563. success = client.pause_print()
  1564. if not success:
  1565. raise HTTPException(500, "Failed to pause print")
  1566. return {"success": True, "message": "Print pause command sent"}
  1567. @router.post("/{printer_id}/print/resume")
  1568. async def resume_print(
  1569. printer_id: int,
  1570. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1571. db: AsyncSession = Depends(get_db),
  1572. ):
  1573. """Resume a paused print job."""
  1574. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1575. printer = result.scalar_one_or_none()
  1576. if not printer:
  1577. raise HTTPException(404, "Printer not found")
  1578. client = printer_manager.get_client(printer_id)
  1579. if not client:
  1580. raise HTTPException(400, "Printer not connected")
  1581. success = client.resume_print()
  1582. if not success:
  1583. raise HTTPException(500, "Failed to resume print")
  1584. return {"success": True, "message": "Print resume command sent"}
  1585. @router.post("/{printer_id}/chamber-light")
  1586. async def set_chamber_light(
  1587. printer_id: int,
  1588. on: bool = Query(..., description="True to turn on, False to turn off"),
  1589. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1590. db: AsyncSession = Depends(get_db),
  1591. ):
  1592. """Turn the chamber light on or off."""
  1593. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1594. printer = result.scalar_one_or_none()
  1595. if not printer:
  1596. raise HTTPException(404, "Printer not found")
  1597. client = printer_manager.get_client(printer_id)
  1598. if not client:
  1599. raise HTTPException(400, "Printer not connected")
  1600. success = client.set_chamber_light(on)
  1601. if not success:
  1602. raise HTTPException(500, "Failed to control chamber light")
  1603. return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
  1604. @router.get("/{printer_id}/print/objects")
  1605. async def get_printable_objects(
  1606. printer_id: int,
  1607. reload: bool = False,
  1608. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1609. db: AsyncSession = Depends(get_db),
  1610. ):
  1611. """Get the list of printable objects for the current print.
  1612. Returns a list of objects with id, name, position (if available), and skip status.
  1613. Objects that have already been skipped are marked in the skipped_objects list.
  1614. Args:
  1615. reload: If True, reload objects from the archive file (useful after restart)
  1616. """
  1617. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1618. printer = result.scalar_one_or_none()
  1619. if not printer:
  1620. raise HTTPException(404, "Printer not found")
  1621. client = printer_manager.get_client(printer_id)
  1622. if not client:
  1623. raise HTTPException(400, "Printer not connected")
  1624. # Reload objects from 3MF if requested or no objects loaded
  1625. if reload or not client.state.printable_objects:
  1626. subtask_name = client.state.subtask_name
  1627. if subtask_name:
  1628. from backend.app.services.archive import extract_printable_objects_from_3mf
  1629. from backend.app.services.bambu_ftp import download_file_try_paths_async
  1630. # Build possible 3MF filenames (try both .gcode.3mf and .3mf)
  1631. possible_filenames = []
  1632. if subtask_name.endswith(".3mf"):
  1633. possible_filenames.append(subtask_name)
  1634. else:
  1635. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  1636. possible_filenames.append(f"{subtask_name}.3mf")
  1637. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  1638. if " " in subtask_name:
  1639. normalized = subtask_name.replace(" ", "_")
  1640. if normalized.endswith(".3mf"):
  1641. possible_filenames.append(normalized)
  1642. else:
  1643. possible_filenames.append(f"{normalized}.gcode.3mf")
  1644. possible_filenames.append(f"{normalized}.3mf")
  1645. # Download 3MF from printer
  1646. temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}"
  1647. temp_path.parent.mkdir(parents=True, exist_ok=True)
  1648. # Build list of all remote paths to try
  1649. remote_paths = []
  1650. for filename in possible_filenames:
  1651. remote_paths.extend([f"/{filename}", f"/cache/{filename}", f"/model/{filename}"])
  1652. try:
  1653. downloaded = await download_file_try_paths_async(
  1654. printer.ip_address,
  1655. printer.access_code,
  1656. remote_paths,
  1657. temp_path,
  1658. printer_model=printer.model,
  1659. )
  1660. if downloaded and temp_path.exists():
  1661. with open(temp_path, "rb") as f:
  1662. data = f.read()
  1663. objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)
  1664. if objects:
  1665. client.state.printable_objects = objects
  1666. client.state.printable_objects_bbox_all = bbox_all
  1667. logger.info("Reloaded %s objects for printer %s", len(objects), printer_id)
  1668. except Exception as e:
  1669. logger.debug("Failed to reload objects from printer: %s", e)
  1670. finally:
  1671. if temp_path.exists():
  1672. temp_path.unlink()
  1673. # Return objects with their skip status and position data
  1674. objects = []
  1675. for obj_id, obj_data in client.state.printable_objects.items():
  1676. # Handle both old format (string name) and new format (dict with name, x, y)
  1677. if isinstance(obj_data, dict):
  1678. obj_entry = {
  1679. "id": obj_id,
  1680. "name": obj_data.get("name", f"Object {obj_id}"),
  1681. "x": obj_data.get("x"),
  1682. "y": obj_data.get("y"),
  1683. "skipped": obj_id in client.state.skipped_objects,
  1684. }
  1685. else:
  1686. # Legacy format: obj_data is just the name string
  1687. obj_entry = {
  1688. "id": obj_id,
  1689. "name": obj_data,
  1690. "x": None,
  1691. "y": None,
  1692. "skipped": obj_id in client.state.skipped_objects,
  1693. }
  1694. objects.append(obj_entry)
  1695. return {
  1696. "objects": objects,
  1697. "total": len(objects),
  1698. "skipped_count": len(client.state.skipped_objects),
  1699. "is_printing": client.state.state in ("RUNNING", "PAUSE"),
  1700. "bbox_all": getattr(client.state, "printable_objects_bbox_all", None),
  1701. }
  1702. @router.post("/{printer_id}/print/skip-objects")
  1703. async def skip_objects(
  1704. printer_id: int,
  1705. object_ids: list[int],
  1706. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1707. db: AsyncSession = Depends(get_db),
  1708. ):
  1709. """Skip specific objects during the current print.
  1710. Args:
  1711. object_ids: List of object identify_id values to skip
  1712. """
  1713. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1714. printer = result.scalar_one_or_none()
  1715. if not printer:
  1716. raise HTTPException(404, "Printer not found")
  1717. client = printer_manager.get_client(printer_id)
  1718. if not client:
  1719. raise HTTPException(400, "Printer not connected")
  1720. if not object_ids:
  1721. raise HTTPException(400, "No object IDs provided")
  1722. # Validate object IDs exist in printable_objects
  1723. invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]
  1724. if invalid_ids:
  1725. raise HTTPException(400, f"Invalid object IDs: {invalid_ids}")
  1726. success = client.skip_objects(object_ids)
  1727. if not success:
  1728. raise HTTPException(500, "Failed to skip objects")
  1729. # Get names of skipped objects for response (handle both old and new format)
  1730. skipped_names = []
  1731. for oid in object_ids:
  1732. obj_data = client.state.printable_objects.get(oid, str(oid))
  1733. if isinstance(obj_data, dict):
  1734. skipped_names.append(obj_data.get("name", str(oid)))
  1735. else:
  1736. skipped_names.append(obj_data)
  1737. return {
  1738. "success": True,
  1739. "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}",
  1740. "skipped_objects": object_ids,
  1741. }
  1742. # =============================================================================
  1743. # AMS Control Endpoints
  1744. # =============================================================================
  1745. @router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh")
  1746. async def refresh_ams_slot(
  1747. printer_id: int,
  1748. ams_id: int,
  1749. slot_id: int,
  1750. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
  1751. db: AsyncSession = Depends(get_db),
  1752. ):
  1753. """Re-read RFID for an AMS slot (triggers filament info refresh)."""
  1754. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1755. printer = result.scalar_one_or_none()
  1756. if not printer:
  1757. raise HTTPException(404, "Printer not found")
  1758. client = printer_manager.get_client(printer_id)
  1759. if not client:
  1760. raise HTTPException(400, "Printer not connected")
  1761. success, message = client.ams_refresh_tray(ams_id, slot_id)
  1762. if not success:
  1763. raise HTTPException(400, message)
  1764. return {"success": True, "message": message}
  1765. @router.get("/{printer_id}/runtime-debug")
  1766. async def get_runtime_debug(
  1767. printer_id: int,
  1768. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1769. db: AsyncSession = Depends(get_db),
  1770. ):
  1771. """Debug endpoint: Get runtime tracking status for a printer."""
  1772. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1773. printer = result.scalar_one_or_none()
  1774. if not printer:
  1775. raise HTTPException(404, "Printer not found")
  1776. state = printer_manager.get_status(printer_id)
  1777. return {
  1778. "printer_name": printer.name,
  1779. "runtime_seconds": printer.runtime_seconds,
  1780. "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
  1781. "print_hours_offset": printer.print_hours_offset,
  1782. "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
  1783. + (printer.print_hours_offset or 0),
  1784. "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
  1785. "mqtt_state": {
  1786. "connected": state.connected if state else False,
  1787. "state": state.state if state else None,
  1788. "progress": state.progress if state else None,
  1789. "gcode_file": state.gcode_file if state else None,
  1790. }
  1791. if state
  1792. else None,
  1793. "is_active": printer.is_active,
  1794. }