printers.py 107 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874
  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 func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, 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.ams_label import AmsLabel
  14. from backend.app.models.printer import Printer
  15. from backend.app.models.slot_preset import SlotPresetMapping
  16. from backend.app.schemas.printer import (
  17. AmsLabelBody,
  18. AMSTray,
  19. AMSUnit,
  20. HMSErrorResponse,
  21. NozzleInfoResponse,
  22. NozzleRackSlot,
  23. PrinterCreate,
  24. PrinterResponse,
  25. PrinterStatus,
  26. PrinterUpdate,
  27. PrintOptionsResponse,
  28. )
  29. from backend.app.services.bambu_ftp import (
  30. delete_file_async,
  31. download_file_bytes_async,
  32. download_file_try_paths_async,
  33. get_storage_info_async,
  34. list_files_async,
  35. )
  36. from backend.app.services.printer_manager import (
  37. get_derived_status_name,
  38. printer_manager,
  39. supports_chamber_temp,
  40. supports_drying,
  41. )
  42. logger = logging.getLogger(__name__)
  43. router = APIRouter(prefix="/printers", tags=["printers"])
  44. @router.get("/", response_model=list[PrinterResponse])
  45. async def list_printers(
  46. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  47. db: AsyncSession = Depends(get_db),
  48. ):
  49. """List all configured printers."""
  50. result = await db.execute(select(Printer).order_by(Printer.name))
  51. return list(result.scalars().all())
  52. @router.post("/", response_model=PrinterResponse)
  53. async def create_printer(
  54. printer_data: PrinterCreate,
  55. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  56. db: AsyncSession = Depends(get_db),
  57. ):
  58. """Add a new printer."""
  59. # Check if serial number already exists
  60. result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number))
  61. if result.scalar_one_or_none():
  62. raise HTTPException(400, "Printer with this serial number already exists")
  63. printer = Printer(**printer_data.model_dump())
  64. db.add(printer)
  65. await db.commit()
  66. await db.refresh(printer)
  67. # Connect to the printer
  68. if printer.is_active:
  69. await printer_manager.connect_printer(printer)
  70. return printer
  71. @router.get("/usb-cameras")
  72. async def list_usb_cameras(
  73. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  74. ):
  75. """List available USB cameras connected to the system.
  76. Returns a list of detected V4L2 video devices with their info.
  77. Only works on Linux systems with V4L2 support.
  78. Returns:
  79. List of dicts with {device: str, name: str, capabilities: list, formats?: list}
  80. """
  81. from backend.app.services.external_camera import list_usb_cameras
  82. cameras = list_usb_cameras()
  83. return {"cameras": cameras}
  84. @router.get("/available-filaments")
  85. async def get_available_filaments(
  86. model: str = Query(..., description="Target printer model"),
  87. location: str | None = Query(None, description="Optional location filter"),
  88. _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
  89. db: AsyncSession = Depends(get_db),
  90. ):
  91. """Get deduplicated list of filaments loaded across all active printers of a given model.
  92. Used by the frontend to offer filament override options for model-based queue assignment.
  93. """
  94. from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
  95. # Normalize model name
  96. normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model
  97. query = (
  98. select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True) # noqa: E712
  99. )
  100. if location:
  101. query = query.where(Printer.location == location)
  102. result = await db.execute(query)
  103. printers_list = list(result.scalars().all())
  104. if not printers_list:
  105. return []
  106. # Collect filaments from all matching printers
  107. # Dedup key includes extruder_id and tray_sub_brands so "PLA Basic" and "PLA Matte" appear separately
  108. seen: set[tuple[str, str, str, int | None]] = set() # (type_upper, color_normalized, sub_brands_upper, extruder_id)
  109. filaments = []
  110. for printer in printers_list:
  111. status = printer_manager.get_status(printer.id)
  112. if not status:
  113. continue
  114. # Get ams_extruder_map for dual-nozzle printers
  115. ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
  116. # AMS trays
  117. for ams_unit in status.raw_data.get("ams", []):
  118. ams_id = str(ams_unit.get("id", 0))
  119. extruder_id = ams_extruder_map.get(ams_id)
  120. for tray in ams_unit.get("tray", []):
  121. tray_type = tray.get("tray_type")
  122. if not tray_type:
  123. continue
  124. tray_color = tray.get("tray_color", "")
  125. # Normalize color: remove alpha, add hash
  126. hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
  127. color = f"#{hex_color}"
  128. tray_info_idx = tray.get("tray_info_idx", "")
  129. tray_sub_brands = tray.get("tray_sub_brands", "") or ""
  130. key = (tray_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
  131. if key not in seen:
  132. seen.add(key)
  133. filaments.append(
  134. {
  135. "type": tray_type,
  136. "color": color,
  137. "tray_info_idx": tray_info_idx,
  138. "tray_sub_brands": tray_sub_brands,
  139. "extruder_id": extruder_id,
  140. }
  141. )
  142. # External spools (vt_tray)
  143. for vt in status.raw_data.get("vt_tray") or []:
  144. vt_type = vt.get("tray_type")
  145. if not vt_type:
  146. continue
  147. vt_color = vt.get("tray_color", "")
  148. hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
  149. color = f"#{hex_color}"
  150. tray_info_idx = vt.get("tray_info_idx", "")
  151. tray_sub_brands = vt.get("tray_sub_brands", "") or ""
  152. vt_id = int(vt.get("id", 254))
  153. extruder_id = (255 - vt_id) if ams_extruder_map else None
  154. key = (vt_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
  155. if key not in seen:
  156. seen.add(key)
  157. filaments.append(
  158. {
  159. "type": vt_type,
  160. "color": color,
  161. "tray_info_idx": tray_info_idx,
  162. "tray_sub_brands": tray_sub_brands,
  163. "extruder_id": extruder_id,
  164. }
  165. )
  166. return filaments
  167. @router.get("/developer-mode-warnings")
  168. async def get_developer_mode_warnings(
  169. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  170. db: AsyncSession = Depends(get_db),
  171. ):
  172. """Check if any connected printer lacks developer LAN mode."""
  173. result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
  174. printers = result.scalars().all()
  175. statuses = printer_manager.get_all_statuses()
  176. warnings = []
  177. for printer in printers:
  178. state = statuses.get(printer.id)
  179. if state and state.connected and state.developer_mode is False:
  180. warnings.append(
  181. {
  182. "printer_id": printer.id,
  183. "name": printer.name,
  184. }
  185. )
  186. return warnings
  187. @router.get("/{printer_id}", response_model=PrinterResponse)
  188. async def get_printer(
  189. printer_id: int,
  190. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  191. db: AsyncSession = Depends(get_db),
  192. ):
  193. """Get a specific printer."""
  194. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  195. printer = result.scalar_one_or_none()
  196. if not printer:
  197. raise HTTPException(404, "Printer not found")
  198. return printer
  199. @router.patch("/{printer_id}", response_model=PrinterResponse)
  200. async def update_printer(
  201. printer_id: int,
  202. printer_data: PrinterUpdate,
  203. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  204. db: AsyncSession = Depends(get_db),
  205. ):
  206. """Update a printer."""
  207. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  208. printer = result.scalar_one_or_none()
  209. if not printer:
  210. raise HTTPException(404, "Printer not found")
  211. update_data = printer_data.model_dump(exclude_unset=True)
  212. # Handle nested ROI object - flatten to individual columns
  213. if "plate_detection_roi" in update_data:
  214. roi = update_data.pop("plate_detection_roi")
  215. if roi:
  216. update_data["plate_detection_roi_x"] = roi.get("x")
  217. update_data["plate_detection_roi_y"] = roi.get("y")
  218. update_data["plate_detection_roi_w"] = roi.get("w")
  219. update_data["plate_detection_roi_h"] = roi.get("h")
  220. else:
  221. # Clear ROI if set to null
  222. update_data["plate_detection_roi_x"] = None
  223. update_data["plate_detection_roi_y"] = None
  224. update_data["plate_detection_roi_w"] = None
  225. update_data["plate_detection_roi_h"] = None
  226. for field, value in update_data.items():
  227. setattr(printer, field, value)
  228. await db.commit()
  229. await db.refresh(printer)
  230. # Reconnect if connection settings changed
  231. if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
  232. printer_manager.disconnect_printer(printer_id)
  233. if printer.is_active:
  234. await printer_manager.connect_printer(printer)
  235. return printer
  236. @router.delete("/{printer_id}")
  237. async def delete_printer(
  238. printer_id: int,
  239. delete_archives: bool = True,
  240. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
  241. db: AsyncSession = Depends(get_db),
  242. ):
  243. """Delete a printer.
  244. Args:
  245. printer_id: ID of the printer to delete
  246. delete_archives: If True (default), delete all print archives for this printer.
  247. If False, keep archives but remove their printer association.
  248. """
  249. from sqlalchemy import delete as sql_delete
  250. from backend.app.models.archive import PrintArchive
  251. from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
  252. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  253. printer = result.scalar_one_or_none()
  254. if not printer:
  255. raise HTTPException(404, "Printer not found")
  256. printer_manager.disconnect_printer(printer_id)
  257. if delete_archives:
  258. # Delete all archives for this printer
  259. await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
  260. else:
  261. # Orphan the archives instead of deleting them
  262. from sqlalchemy import update
  263. await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
  264. # Delete maintenance history and items for this printer
  265. # (SQLite doesn't enforce FK cascades, so do it explicitly)
  266. maintenance_ids = (
  267. (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id)))
  268. .scalars()
  269. .all()
  270. )
  271. if maintenance_ids:
  272. await db.execute(
  273. sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids))
  274. )
  275. await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
  276. await db.delete(printer)
  277. await db.commit()
  278. return {"status": "deleted", "archives_deleted": delete_archives}
  279. @router.get("/{printer_id}/status", response_model=PrinterStatus)
  280. async def get_printer_status(
  281. printer_id: int,
  282. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  283. db: AsyncSession = Depends(get_db),
  284. ):
  285. """Get real-time status of a printer."""
  286. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  287. printer = result.scalar_one_or_none()
  288. if not printer:
  289. raise HTTPException(404, "Printer not found")
  290. state = printer_manager.get_status(printer_id)
  291. if not state:
  292. return PrinterStatus(
  293. id=printer_id,
  294. name=printer.name,
  295. connected=False,
  296. )
  297. # Determine cover URL if there's an active print (including paused)
  298. cover_url = None
  299. if state.state in ("RUNNING", "PAUSE") and state.gcode_file:
  300. cover_url = f"/api/v1/printers/{printer_id}/cover"
  301. # Convert HMS errors to response format
  302. hms_errors = [
  303. HMSErrorResponse(code=e.code, attr=e.attr, module=e.module, severity=e.severity)
  304. for e in (state.hms_errors or [])
  305. ]
  306. # Parse AMS data from raw_data
  307. ams_units = []
  308. vt_tray = []
  309. ams_exists = False
  310. raw_data = state.raw_data or {}
  311. # Build K-profile lookup map: cali_idx -> k_value
  312. # This allows looking up the calibrated K value for each AMS slot
  313. kprofile_map: dict[int, float] = {}
  314. for kp in state.kprofiles or []:
  315. if kp.slot_id is not None and kp.k_value:
  316. try:
  317. kprofile_map[kp.slot_id] = float(kp.k_value)
  318. except (ValueError, TypeError):
  319. pass # Skip K-profile entries with unparseable values
  320. if "ams" in raw_data and isinstance(raw_data["ams"], list):
  321. ams_exists = True
  322. for ams_data in raw_data["ams"]:
  323. # Skip if ams_data is not a dict (defensive check)
  324. if not isinstance(ams_data, dict):
  325. continue
  326. trays = []
  327. for tray_data in ams_data.get("tray", []):
  328. # Filter out empty/invalid tag values
  329. tag_uid = tray_data.get("tag_uid", "")
  330. if tag_uid in ("", "0000000000000000"):
  331. tag_uid = None
  332. tray_uuid = tray_data.get("tray_uuid", "")
  333. if tray_uuid in ("", "00000000000000000000000000000000"):
  334. tray_uuid = None
  335. # Get K value: first try tray's k field, then lookup from K-profiles
  336. k_value = tray_data.get("k")
  337. cali_idx = tray_data.get("cali_idx")
  338. if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
  339. k_value = kprofile_map[cali_idx]
  340. trays.append(
  341. AMSTray(
  342. id=tray_data.get("id", 0),
  343. tray_color=tray_data.get("tray_color"),
  344. tray_type=tray_data.get("tray_type"),
  345. tray_sub_brands=tray_data.get("tray_sub_brands"),
  346. tray_id_name=tray_data.get("tray_id_name"),
  347. tray_info_idx=tray_data.get("tray_info_idx"),
  348. remain=tray_data.get("remain", 0),
  349. k=k_value,
  350. cali_idx=cali_idx,
  351. tag_uid=tag_uid,
  352. tray_uuid=tray_uuid,
  353. nozzle_temp_min=tray_data.get("nozzle_temp_min"),
  354. nozzle_temp_max=tray_data.get("nozzle_temp_max"),
  355. drying_temp=tray_data.get("drying_temp"),
  356. drying_time=tray_data.get("drying_time"),
  357. state=tray_data.get("state"),
  358. )
  359. )
  360. # Prefer humidity_raw (percentage) over humidity (index 1-5)
  361. # humidity_raw is the actual percentage value from the sensor
  362. humidity_raw = ams_data.get("humidity_raw")
  363. humidity_idx = ams_data.get("humidity")
  364. humidity_value = None
  365. if humidity_raw is not None:
  366. try:
  367. humidity_value = int(humidity_raw)
  368. except (ValueError, TypeError):
  369. pass # Skip unparseable humidity; will try index fallback
  370. if humidity_value is None and humidity_idx is not None:
  371. try:
  372. humidity_value = int(humidity_idx)
  373. except (ValueError, TypeError):
  374. pass # Skip unparseable humidity index; humidity remains None
  375. # AMS-HT has 1 tray, regular AMS has 4 trays
  376. is_ams_ht = len(trays) == 1
  377. ams_units.append(
  378. AMSUnit(
  379. id=ams_data.get("id", 0),
  380. humidity=humidity_value,
  381. temp=ams_data.get("temp"),
  382. is_ams_ht=is_ams_ht,
  383. tray=trays,
  384. # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
  385. serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
  386. # Firmware version: populated by _handle_version_info from info.module ams/* entries
  387. sw_ver=str(ams_data.get("sw_ver") or ""),
  388. # Drying: dry_time > 0 means drying is active (minutes remaining)
  389. dry_time=int(ams_data.get("dry_time") or 0),
  390. module_type=str(ams_data.get("module_type") or ""),
  391. )
  392. )
  393. # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
  394. if "vt_tray" in raw_data:
  395. for vt_data in raw_data["vt_tray"]:
  396. # Filter out empty/invalid tag values for vt_tray
  397. vt_tag_uid = vt_data.get("tag_uid", "")
  398. if vt_tag_uid in ("", "0000000000000000"):
  399. vt_tag_uid = None
  400. vt_tray_uuid = vt_data.get("tray_uuid", "")
  401. if vt_tray_uuid in ("", "00000000000000000000000000000000"):
  402. vt_tray_uuid = None
  403. # Get K value: first try tray's k field, then lookup from K-profiles
  404. vt_k_value = vt_data.get("k")
  405. vt_cali_idx = vt_data.get("cali_idx")
  406. if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
  407. vt_k_value = kprofile_map[vt_cali_idx]
  408. tray_id = int(vt_data.get("id", 254))
  409. vt_tray.append(
  410. AMSTray(
  411. id=tray_id,
  412. tray_color=vt_data.get("tray_color"),
  413. tray_type=vt_data.get("tray_type"),
  414. tray_sub_brands=vt_data.get("tray_sub_brands"),
  415. tray_id_name=vt_data.get("tray_id_name"),
  416. tray_info_idx=vt_data.get("tray_info_idx"),
  417. remain=vt_data.get("remain", 0),
  418. k=vt_k_value,
  419. cali_idx=vt_cali_idx,
  420. tag_uid=vt_tag_uid,
  421. tray_uuid=vt_tray_uuid,
  422. nozzle_temp_min=vt_data.get("nozzle_temp_min"),
  423. nozzle_temp_max=vt_data.get("nozzle_temp_max"),
  424. )
  425. )
  426. # Convert nozzle info to response format
  427. nozzles = [
  428. NozzleInfoResponse(
  429. nozzle_type=n.nozzle_type,
  430. nozzle_diameter=n.nozzle_diameter,
  431. )
  432. for n in (state.nozzles or [])
  433. ]
  434. # H2C nozzle rack (tool-changer dock positions)
  435. nozzle_rack = [
  436. NozzleRackSlot(
  437. id=n.get("id", 0),
  438. nozzle_type=n.get("type", ""),
  439. nozzle_diameter=n.get("diameter", ""),
  440. wear=n.get("wear"),
  441. stat=n.get("stat"),
  442. max_temp=n.get("max_temp", 0),
  443. serial_number=n.get("serial_number", ""),
  444. filament_color=n.get("filament_color", ""),
  445. filament_id=n.get("filament_id", ""),
  446. filament_type=n.get("filament_type", ""),
  447. )
  448. for n in (state.nozzle_rack or [])
  449. ]
  450. # Convert print options to response format
  451. print_options = PrintOptionsResponse(
  452. spaghetti_detector=state.print_options.spaghetti_detector,
  453. print_halt=state.print_options.print_halt,
  454. halt_print_sensitivity=state.print_options.halt_print_sensitivity,
  455. first_layer_inspector=state.print_options.first_layer_inspector,
  456. printing_monitor=state.print_options.printing_monitor,
  457. buildplate_marker_detector=state.print_options.buildplate_marker_detector,
  458. allow_skip_parts=state.print_options.allow_skip_parts,
  459. nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
  460. nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
  461. pileup_detector=state.print_options.pileup_detector,
  462. pileup_sensitivity=state.print_options.pileup_sensitivity,
  463. airprint_detector=state.print_options.airprint_detector,
  464. airprint_sensitivity=state.print_options.airprint_sensitivity,
  465. auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
  466. filament_tangle_detect=state.print_options.filament_tangle_detect,
  467. )
  468. # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
  469. ams_mapping = raw_data.get("ams_mapping", [])
  470. # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
  471. # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
  472. ams_extruder_map = state.ams_extruder_map or {}
  473. logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
  474. # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
  475. # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
  476. # No conversion needed - just use the raw value directly
  477. tray_now = state.tray_now
  478. logger.debug("Using tray_now directly as global ID: %s", tray_now)
  479. # Filter out chamber temp for models that don't have a real sensor
  480. # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
  481. temperatures = state.temperatures
  482. if not supports_chamber_temp(printer.model):
  483. temperatures = {
  484. k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
  485. }
  486. return PrinterStatus(
  487. id=printer_id,
  488. name=printer.name,
  489. connected=state.connected,
  490. state=state.state,
  491. current_print=state.current_print,
  492. subtask_name=state.subtask_name,
  493. gcode_file=state.gcode_file,
  494. progress=state.progress,
  495. remaining_time=state.remaining_time,
  496. layer_num=state.layer_num,
  497. total_layers=state.total_layers,
  498. temperatures=temperatures,
  499. cover_url=cover_url,
  500. hms_errors=hms_errors,
  501. ams=ams_units,
  502. ams_exists=ams_exists,
  503. vt_tray=vt_tray,
  504. sdcard=state.sdcard,
  505. store_to_sdcard=state.store_to_sdcard,
  506. timelapse=state.timelapse,
  507. ipcam=state.ipcam,
  508. wifi_signal=state.wifi_signal,
  509. wired_network=state.wired_network,
  510. door_open=state.door_open,
  511. nozzles=nozzles,
  512. nozzle_rack=nozzle_rack,
  513. print_options=print_options,
  514. stg_cur=state.stg_cur,
  515. stg_cur_name=get_derived_status_name(state, printer.model),
  516. stg=state.stg,
  517. airduct_mode=state.airduct_mode,
  518. speed_level=state.speed_level,
  519. chamber_light=state.chamber_light,
  520. active_extruder=state.active_extruder,
  521. ams_mapping=ams_mapping,
  522. ams_extruder_map=ams_extruder_map,
  523. tray_now=tray_now,
  524. ams_status_main=state.ams_status_main,
  525. ams_status_sub=state.ams_status_sub,
  526. mc_print_sub_stage=state.mc_print_sub_stage,
  527. last_ams_update=state.last_ams_update,
  528. printable_objects_count=len(state.printable_objects),
  529. cooling_fan_speed=state.cooling_fan_speed,
  530. big_fan1_speed=state.big_fan1_speed,
  531. big_fan2_speed=state.big_fan2_speed,
  532. heatbreak_fan_speed=state.heatbreak_fan_speed,
  533. firmware_version=state.firmware_version,
  534. developer_mode=state.developer_mode if state else None,
  535. awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
  536. supports_drying=supports_drying(printer.model, state.firmware_version),
  537. )
  538. @router.get("/{printer_id}/current-print-user")
  539. async def get_current_print_user(
  540. printer_id: int,
  541. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  542. db: AsyncSession = Depends(get_db),
  543. ):
  544. """Get the user who started the current print (for reprint tracking).
  545. Returns user info if available, empty object otherwise.
  546. This tracks users for reprints (which bypass the queue).
  547. For queue-based prints, use the queue item's created_by field instead.
  548. """
  549. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  550. printer = result.scalar_one_or_none()
  551. if not printer:
  552. raise HTTPException(404, "Printer not found")
  553. user_info = printer_manager.get_current_print_user(printer_id)
  554. return user_info or {}
  555. @router.post("/{printer_id}/refresh-status")
  556. async def refresh_printer_status(
  557. printer_id: int,
  558. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  559. db: AsyncSession = Depends(get_db),
  560. ):
  561. """Request a full status refresh from the printer (sends pushall command)."""
  562. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  563. printer = result.scalar_one_or_none()
  564. if not printer:
  565. raise HTTPException(404, "Printer not found")
  566. success = printer_manager.request_status_update(printer_id)
  567. if not success:
  568. raise HTTPException(400, "Printer not connected")
  569. return {"status": "refresh_requested"}
  570. @router.post("/{printer_id}/connect")
  571. async def connect_printer(
  572. printer_id: int,
  573. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  574. db: AsyncSession = Depends(get_db),
  575. ):
  576. """Manually connect to a printer."""
  577. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  578. printer = result.scalar_one_or_none()
  579. if not printer:
  580. raise HTTPException(404, "Printer not found")
  581. success = await printer_manager.connect_printer(printer)
  582. return {"connected": success}
  583. @router.post("/{printer_id}/disconnect")
  584. async def disconnect_printer(
  585. printer_id: int,
  586. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  587. db: AsyncSession = Depends(get_db),
  588. ):
  589. """Manually disconnect from a printer."""
  590. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  591. printer = result.scalar_one_or_none()
  592. if not printer:
  593. raise HTTPException(404, "Printer not found")
  594. printer_manager.disconnect_printer(printer_id)
  595. return {"connected": False}
  596. @router.post("/test")
  597. async def test_printer_connection(
  598. ip_address: str,
  599. serial_number: str,
  600. access_code: str,
  601. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  602. ):
  603. """Test connection to a printer without saving."""
  604. result = await printer_manager.test_connection(
  605. ip_address=ip_address,
  606. serial_number=serial_number,
  607. access_code=access_code,
  608. )
  609. return result
  610. # Cache for cover images (printer_id -> {(subtask_name, plate_num, view) -> image_bytes})
  611. _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
  612. def clear_cover_cache(printer_id: int) -> None:
  613. """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
  614. _cover_cache.pop(printer_id, None)
  615. @router.get("/{printer_id}/cover")
  616. async def get_printer_cover(
  617. printer_id: int,
  618. view: str | None = None,
  619. db: AsyncSession = Depends(get_db),
  620. _: None = RequireCameraStreamTokenIfAuthEnabled,
  621. ):
  622. """Get the cover image for the current print job.
  623. Args:
  624. view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
  625. Default returns angled 3D perspective view.
  626. """
  627. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  628. printer = result.scalar_one_or_none()
  629. if not printer:
  630. raise HTTPException(404, "Printer not found")
  631. state = printer_manager.get_status(printer_id)
  632. if not state:
  633. raise HTTPException(404, "Printer not connected")
  634. # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
  635. subtask_name = state.subtask_name
  636. if not subtask_name:
  637. raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
  638. # Extract plate number from gcode_file (e.g., "/data/Metadata/plate_12.gcode" -> 12)
  639. plate_num = 1
  640. gcode_file = state.gcode_file
  641. if gcode_file:
  642. match = re.search(r"plate_(\d+)\.gcode", gcode_file)
  643. if match:
  644. plate_num = int(match.group(1))
  645. logger.info("Detected plate number %s from gcode_file: %s", plate_num, gcode_file)
  646. # Normalize view parameter
  647. view_key = view or "default"
  648. # Check cache - include plate_num in cache key for multi-plate projects
  649. if printer_id in _cover_cache:
  650. cache_key = (subtask_name, plate_num, view_key)
  651. if cache_key in _cover_cache[printer_id]:
  652. return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
  653. # Build possible 3MF filenames from subtask_name
  654. # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
  655. # or just "name.3mf" (uploaded directly)
  656. possible_filenames = []
  657. if subtask_name.endswith(".3mf"):
  658. possible_filenames.append(subtask_name)
  659. else:
  660. # Try both naming patterns
  661. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  662. possible_filenames.append(f"{subtask_name}.3mf")
  663. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  664. if " " in subtask_name:
  665. normalized = subtask_name.replace(" ", "_")
  666. if normalized.endswith(".3mf"):
  667. possible_filenames.append(normalized)
  668. else:
  669. possible_filenames.append(f"{normalized}.gcode.3mf")
  670. possible_filenames.append(f"{normalized}.3mf")
  671. # Build list of all remote paths to try
  672. remote_paths = []
  673. for filename in possible_filenames:
  674. remote_paths.extend(
  675. [
  676. f"/{filename}", # Root directory (most common)
  677. f"/cache/{filename}",
  678. f"/model/{filename}",
  679. f"/data/{filename}",
  680. ]
  681. )
  682. # Use first filename for temp path (will be reused)
  683. temp_filename = possible_filenames[0]
  684. temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
  685. temp_path.parent.mkdir(parents=True, exist_ok=True)
  686. logger.info(
  687. f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
  688. )
  689. # Retry logic for transient FTP failures
  690. max_retries = 2
  691. last_error = None
  692. downloaded = False
  693. for attempt in range(max_retries + 1):
  694. try:
  695. downloaded = await download_file_try_paths_async(
  696. printer.ip_address,
  697. printer.access_code,
  698. remote_paths,
  699. temp_path,
  700. printer_model=printer.model,
  701. )
  702. if downloaded:
  703. break
  704. except Exception as e:
  705. last_error = e
  706. if attempt < max_retries:
  707. logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
  708. await asyncio.sleep(0.5 * (attempt + 1)) # Brief backoff
  709. else:
  710. logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
  711. if last_error and not downloaded:
  712. raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
  713. if not downloaded:
  714. raise HTTPException(
  715. 404,
  716. f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
  717. )
  718. # Verify file actually exists and has content
  719. if not temp_path.exists():
  720. raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
  721. file_size = temp_path.stat().st_size
  722. logger.info("Downloaded file size: %s bytes", file_size)
  723. if file_size == 0:
  724. temp_path.unlink()
  725. raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'")
  726. try:
  727. # Extract thumbnail from 3MF (which is a ZIP file)
  728. try:
  729. zf = zipfile.ZipFile(temp_path, "r")
  730. except zipfile.BadZipFile:
  731. raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
  732. except OSError as e:
  733. logger.error("Failed to open 3MF file: %s", e, exc_info=True)
  734. raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
  735. try:
  736. # Try common thumbnail paths in 3MF files
  737. # Use plate_num to get the correct plate's thumbnail for multi-plate projects
  738. # Use top-down view if requested (better for skip objects modal)
  739. if view == "top":
  740. thumbnail_paths = [
  741. f"Metadata/top_{plate_num}.png",
  742. # Fall back to plate 1 if specific plate not found
  743. "Metadata/top_1.png",
  744. f"Metadata/plate_{plate_num}.png",
  745. "Metadata/plate_1.png",
  746. "Metadata/thumbnail.png",
  747. ]
  748. else:
  749. thumbnail_paths = [
  750. f"Metadata/plate_{plate_num}.png",
  751. # Fall back to plate 1 if specific plate not found
  752. "Metadata/plate_1.png",
  753. "Metadata/thumbnail.png",
  754. f"Metadata/plate_{plate_num}_small.png",
  755. "Metadata/plate_1_small.png",
  756. "Thumbnails/thumbnail.png",
  757. "thumbnail.png",
  758. ]
  759. for thumb_path in thumbnail_paths:
  760. try:
  761. image_data = zf.read(thumb_path)
  762. # Cache the result - include plate_num in cache key
  763. if printer_id not in _cover_cache:
  764. _cover_cache[printer_id] = {}
  765. _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
  766. return Response(content=image_data, media_type="image/png")
  767. except KeyError:
  768. continue
  769. # If no specific thumbnail found, try any PNG in Metadata
  770. for name in zf.namelist():
  771. if name.startswith("Metadata/") and name.endswith(".png"):
  772. image_data = zf.read(name)
  773. if printer_id not in _cover_cache:
  774. _cover_cache[printer_id] = {}
  775. _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
  776. return Response(content=image_data, media_type="image/png")
  777. raise HTTPException(404, "No thumbnail found in 3MF file")
  778. finally:
  779. zf.close()
  780. finally:
  781. if temp_path.exists():
  782. temp_path.unlink()
  783. # ============================================
  784. # File Manager Endpoints
  785. # ============================================
  786. @router.get("/{printer_id}/files")
  787. async def list_printer_files(
  788. printer_id: int,
  789. path: str = "/",
  790. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  791. db: AsyncSession = Depends(get_db),
  792. ):
  793. """List files on the printer at the specified path."""
  794. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  795. printer = result.scalar_one_or_none()
  796. if not printer:
  797. raise HTTPException(404, "Printer not found")
  798. files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  799. # Add full path to each file
  800. for f in files:
  801. f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
  802. return {
  803. "path": path,
  804. "files": files,
  805. }
  806. @router.get("/{printer_id}/files/download")
  807. async def download_printer_file(
  808. printer_id: int,
  809. path: str,
  810. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  811. db: AsyncSession = Depends(get_db),
  812. ):
  813. """Download a file from the printer."""
  814. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  815. printer = result.scalar_one_or_none()
  816. if not printer:
  817. raise HTTPException(404, "Printer not found")
  818. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  819. if data is None:
  820. raise HTTPException(404, f"File not found: {path}")
  821. # Determine content type based on extension
  822. filename = path.split("/")[-1]
  823. ext = filename.lower().split(".")[-1] if "." in filename else ""
  824. content_types = {
  825. "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  826. "gcode": "text/plain",
  827. "mp4": "video/mp4",
  828. "avi": "video/x-msvideo",
  829. "png": "image/png",
  830. "jpg": "image/jpeg",
  831. "jpeg": "image/jpeg",
  832. "json": "application/json",
  833. "txt": "text/plain",
  834. }
  835. content_type = content_types.get(ext, "application/octet-stream")
  836. return Response(
  837. content=data,
  838. media_type=content_type,
  839. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  840. )
  841. @router.get("/{printer_id}/files/gcode")
  842. async def get_printer_file_gcode(
  843. printer_id: int,
  844. path: str,
  845. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  846. db: AsyncSession = Depends(get_db),
  847. ):
  848. """Get gcode for a file stored on a printer (for preview)."""
  849. import io
  850. # Validate printer
  851. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  852. printer = result.scalar_one_or_none()
  853. if not printer:
  854. raise HTTPException(404, "Printer not found")
  855. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  856. if data is None:
  857. raise HTTPException(404, f"File not found: {path}")
  858. filename = path.split("/")[-1]
  859. lower = filename.lower()
  860. if lower.endswith(".gcode"):
  861. return Response(content=data, media_type="text/plain")
  862. if lower.endswith(".3mf"):
  863. try:
  864. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  865. gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
  866. if not gcode_files:
  867. raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
  868. gcode_content = zf.read(gcode_files[0])
  869. return Response(content=gcode_content, media_type="text/plain")
  870. except zipfile.BadZipFile:
  871. raise HTTPException(status_code=400, detail="Invalid 3MF file")
  872. raise HTTPException(status_code=400, detail="Unsupported file type")
  873. @router.get("/{printer_id}/files/plates")
  874. async def get_printer_file_plates(
  875. printer_id: int,
  876. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  877. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  878. db: AsyncSession = Depends(get_db),
  879. ):
  880. """Get available plates from a multi-plate 3MF file stored on a printer."""
  881. import io
  882. import json
  883. import defusedxml.ElementTree as ET
  884. # Validate printer
  885. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  886. printer = result.scalar_one_or_none()
  887. if not printer:
  888. raise HTTPException(404, "Printer not found")
  889. filename = path.split("/")[-1]
  890. if not filename.lower().endswith(".3mf"):
  891. return {
  892. "printer_id": printer_id,
  893. "path": path,
  894. "filename": filename,
  895. "plates": [],
  896. "is_multi_plate": False,
  897. }
  898. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  899. if data is None:
  900. raise HTTPException(404, f"File not found: {path}")
  901. plates = []
  902. try:
  903. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  904. namelist = zf.namelist()
  905. # Find all plate gcode files to determine available plates
  906. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  907. # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
  908. plate_indices: list[int] = []
  909. if gcode_files:
  910. for gf in gcode_files:
  911. try:
  912. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  913. plate_indices.append(int(plate_str))
  914. except ValueError:
  915. pass # Skip gcode files with non-numeric plate indices
  916. else:
  917. plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
  918. plate_png_files = [
  919. n
  920. for n in namelist
  921. if n.startswith("Metadata/plate_")
  922. and n.endswith(".png")
  923. and "_small" not in n
  924. and "no_light" not in n
  925. ]
  926. plate_name_candidates = plate_json_files + plate_png_files
  927. plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
  928. seen_indices: set[int] = set()
  929. for name in plate_name_candidates:
  930. match = plate_re.match(name)
  931. if match:
  932. try:
  933. index = int(match.group(1))
  934. except ValueError:
  935. continue
  936. if index in seen_indices:
  937. continue
  938. seen_indices.add(index)
  939. plate_indices.append(index)
  940. if not plate_indices:
  941. return {
  942. "printer_id": printer_id,
  943. "path": path,
  944. "filename": filename,
  945. "plates": [],
  946. "is_multi_plate": False,
  947. }
  948. plate_indices.sort()
  949. # Parse model_settings.config for plate names
  950. plate_names = {}
  951. if "Metadata/model_settings.config" in namelist:
  952. try:
  953. model_content = zf.read("Metadata/model_settings.config").decode()
  954. model_root = ET.fromstring(model_content)
  955. for plate_elem in model_root.findall(".//plate"):
  956. plater_id = None
  957. plater_name = None
  958. for meta in plate_elem.findall("metadata"):
  959. key = meta.get("key")
  960. value = meta.get("value")
  961. if key == "plater_id" and value:
  962. try:
  963. plater_id = int(value)
  964. except ValueError:
  965. pass # Skip plate with unparseable ID
  966. elif key == "plater_name" and value:
  967. plater_name = value.strip()
  968. if plater_id is not None and plater_name:
  969. plate_names[plater_id] = plater_name
  970. except Exception:
  971. pass # Plate names are optional; continue without them
  972. # Parse slice_info.config for plate metadata
  973. plate_metadata = {}
  974. if "Metadata/slice_info.config" in namelist:
  975. content = zf.read("Metadata/slice_info.config").decode()
  976. root = ET.fromstring(content)
  977. for plate_elem in root.findall(".//plate"):
  978. plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
  979. plate_index = None
  980. for meta in plate_elem.findall("metadata"):
  981. key = meta.get("key")
  982. value = meta.get("value")
  983. if key == "index" and value:
  984. try:
  985. plate_index = int(value)
  986. except ValueError:
  987. pass # Skip plate with unparseable index
  988. elif key == "prediction" and value:
  989. try:
  990. plate_info["prediction"] = int(value)
  991. except ValueError:
  992. pass # Skip unparseable prediction; leave as None
  993. elif key == "weight" and value:
  994. try:
  995. plate_info["weight"] = float(value)
  996. except ValueError:
  997. pass # Skip unparseable weight; leave as None
  998. # Get filaments used in this plate
  999. for filament_elem in plate_elem.findall("filament"):
  1000. filament_id = filament_elem.get("id")
  1001. filament_type = filament_elem.get("type", "")
  1002. filament_color = filament_elem.get("color", "")
  1003. used_g = filament_elem.get("used_g", "0")
  1004. used_m = filament_elem.get("used_m", "0")
  1005. try:
  1006. used_grams = float(used_g)
  1007. except (ValueError, TypeError):
  1008. used_grams = 0
  1009. if used_grams > 0 and filament_id:
  1010. plate_info["filaments"].append(
  1011. {
  1012. "slot_id": int(filament_id),
  1013. "type": filament_type,
  1014. "color": filament_color,
  1015. "used_grams": round(used_grams, 1),
  1016. "used_meters": float(used_m) if used_m else 0,
  1017. }
  1018. )
  1019. plate_info["filaments"].sort(key=lambda x: x["slot_id"])
  1020. # Collect object names
  1021. for obj_elem in plate_elem.findall("object"):
  1022. obj_name = obj_elem.get("name")
  1023. if obj_name and obj_name not in plate_info["objects"]:
  1024. plate_info["objects"].append(obj_name)
  1025. # Set plate name
  1026. if plate_index is not None:
  1027. custom_name = plate_names.get(plate_index)
  1028. if custom_name:
  1029. plate_info["name"] = custom_name
  1030. elif plate_info["objects"]:
  1031. plate_info["name"] = plate_info["objects"][0]
  1032. plate_metadata[plate_index] = plate_info
  1033. # Parse plate_*.json for object lists when slice_info is missing
  1034. plate_json_objects: dict[int, list[str]] = {}
  1035. for name in namelist:
  1036. match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
  1037. if not match:
  1038. continue
  1039. try:
  1040. plate_index = int(match.group(1))
  1041. except ValueError:
  1042. continue
  1043. try:
  1044. payload = json.loads(zf.read(name).decode())
  1045. bbox_objects = payload.get("bbox_objects", [])
  1046. names: list[str] = []
  1047. for obj in bbox_objects:
  1048. obj_name = obj.get("name") if isinstance(obj, dict) else None
  1049. if obj_name and obj_name not in names:
  1050. names.append(obj_name)
  1051. if names:
  1052. plate_json_objects[plate_index] = names
  1053. except Exception:
  1054. continue
  1055. # Build plate list
  1056. for idx in plate_indices:
  1057. meta = plate_metadata.get(idx, {})
  1058. has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
  1059. objects = meta.get("objects", [])
  1060. if not objects:
  1061. objects = plate_json_objects.get(idx, [])
  1062. plate_name = meta.get("name")
  1063. if not plate_name:
  1064. plate_name = plate_names.get(idx)
  1065. if not plate_name and objects:
  1066. plate_name = objects[0]
  1067. plates.append(
  1068. {
  1069. "index": idx,
  1070. "name": plate_name,
  1071. "objects": objects,
  1072. "object_count": len(objects),
  1073. "has_thumbnail": has_thumbnail,
  1074. "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
  1075. "print_time_seconds": meta.get("prediction"),
  1076. "filament_used_grams": meta.get("weight"),
  1077. "filaments": meta.get("filaments", []),
  1078. }
  1079. )
  1080. except Exception as e:
  1081. logger.warning("Failed to parse plates from printer file %s: %s", path, e)
  1082. return {
  1083. "printer_id": printer_id,
  1084. "path": path,
  1085. "filename": filename,
  1086. "plates": plates,
  1087. "is_multi_plate": len(plates) > 1,
  1088. }
  1089. @router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
  1090. async def get_printer_file_plate_thumbnail(
  1091. printer_id: int,
  1092. plate_index: int,
  1093. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  1094. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1095. db: AsyncSession = Depends(get_db),
  1096. ):
  1097. """Get a plate thumbnail image from a printer-stored 3MF file."""
  1098. import io
  1099. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1100. printer = result.scalar_one_or_none()
  1101. if not printer:
  1102. raise HTTPException(404, "Printer not found")
  1103. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1104. if data is None:
  1105. raise HTTPException(404, f"File not found: {path}")
  1106. try:
  1107. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  1108. thumb_path = f"Metadata/plate_{plate_index}.png"
  1109. if thumb_path in zf.namelist():
  1110. image_data = zf.read(thumb_path)
  1111. return Response(content=image_data, media_type="image/png")
  1112. except Exception:
  1113. pass # Corrupt or unreadable 3MF; fall through to 404
  1114. raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
  1115. @router.post("/{printer_id}/files/download-zip")
  1116. async def download_printer_files_as_zip(
  1117. printer_id: int,
  1118. request: dict,
  1119. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1120. db: AsyncSession = Depends(get_db),
  1121. ):
  1122. """Download multiple files from the printer as a ZIP archive."""
  1123. import io
  1124. paths = request.get("paths", [])
  1125. if not paths:
  1126. raise HTTPException(400, "No files specified")
  1127. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1128. printer = result.scalar_one_or_none()
  1129. if not printer:
  1130. raise HTTPException(404, "Printer not found")
  1131. # Create ZIP in memory
  1132. zip_buffer = io.BytesIO()
  1133. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1134. for path in paths:
  1135. try:
  1136. data = await download_file_bytes_async(
  1137. printer.ip_address, printer.access_code, path, printer_model=printer.model
  1138. )
  1139. if data:
  1140. filename = path.split("/")[-1]
  1141. zf.writestr(filename, data)
  1142. except Exception as e:
  1143. logging.warning("Failed to add %s to ZIP: %s", path, e)
  1144. continue
  1145. zip_buffer.seek(0)
  1146. zip_data = zip_buffer.read()
  1147. if len(zip_data) == 0:
  1148. raise HTTPException(404, "No files could be downloaded")
  1149. return Response(
  1150. content=zip_data,
  1151. media_type="application/zip",
  1152. headers={"Content-Disposition": 'attachment; filename="printer-files.zip"'},
  1153. )
  1154. @router.delete("/{printer_id}/files")
  1155. async def delete_printer_file(
  1156. printer_id: int,
  1157. path: str,
  1158. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1159. db: AsyncSession = Depends(get_db),
  1160. ):
  1161. """Delete a file from the printer."""
  1162. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1163. printer = result.scalar_one_or_none()
  1164. if not printer:
  1165. raise HTTPException(404, "Printer not found")
  1166. success = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1167. if not success:
  1168. raise HTTPException(500, f"Failed to delete file: {path}")
  1169. return {"status": "deleted", "path": path}
  1170. @router.get("/{printer_id}/storage")
  1171. async def get_printer_storage(
  1172. printer_id: int,
  1173. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1174. db: AsyncSession = Depends(get_db),
  1175. ):
  1176. """Get storage information from the printer."""
  1177. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1178. printer = result.scalar_one_or_none()
  1179. if not printer:
  1180. raise HTTPException(404, "Printer not found")
  1181. storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
  1182. return storage_info or {"used_bytes": None, "free_bytes": None}
  1183. # ============================================
  1184. # MQTT Debug Logging Endpoints
  1185. # ============================================
  1186. @router.post("/{printer_id}/logging/enable")
  1187. async def enable_mqtt_logging(
  1188. printer_id: int,
  1189. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1190. db: AsyncSession = Depends(get_db),
  1191. ):
  1192. """Enable MQTT message logging for a printer."""
  1193. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1194. printer = result.scalar_one_or_none()
  1195. if not printer:
  1196. raise HTTPException(404, "Printer not found")
  1197. success = printer_manager.enable_logging(printer_id, True)
  1198. if not success:
  1199. raise HTTPException(400, "Printer not connected")
  1200. return {"logging_enabled": True}
  1201. @router.post("/{printer_id}/logging/disable")
  1202. async def disable_mqtt_logging(
  1203. printer_id: int,
  1204. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1205. db: AsyncSession = Depends(get_db),
  1206. ):
  1207. """Disable MQTT message logging for a printer."""
  1208. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1209. printer = result.scalar_one_or_none()
  1210. if not printer:
  1211. raise HTTPException(404, "Printer not found")
  1212. success = printer_manager.enable_logging(printer_id, False)
  1213. if not success:
  1214. raise HTTPException(400, "Printer not connected")
  1215. return {"logging_enabled": False}
  1216. @router.get("/{printer_id}/logging")
  1217. async def get_mqtt_logs(
  1218. printer_id: int,
  1219. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1220. db: AsyncSession = Depends(get_db),
  1221. ):
  1222. """Get MQTT message logs for a printer."""
  1223. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1224. printer = result.scalar_one_or_none()
  1225. if not printer:
  1226. raise HTTPException(404, "Printer not found")
  1227. logs = printer_manager.get_logs(printer_id)
  1228. return {
  1229. "logging_enabled": printer_manager.is_logging_enabled(printer_id),
  1230. "logs": [
  1231. {
  1232. "timestamp": log.timestamp,
  1233. "topic": log.topic,
  1234. "direction": log.direction,
  1235. "payload": log.payload,
  1236. }
  1237. for log in logs
  1238. ],
  1239. }
  1240. @router.delete("/{printer_id}/logging")
  1241. async def clear_mqtt_logs(
  1242. printer_id: int,
  1243. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1244. db: AsyncSession = Depends(get_db),
  1245. ):
  1246. """Clear MQTT message logs for a printer."""
  1247. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1248. printer = result.scalar_one_or_none()
  1249. if not printer:
  1250. raise HTTPException(404, "Printer not found")
  1251. printer_manager.clear_logs(printer_id)
  1252. return {"status": "cleared"}
  1253. # ============================================
  1254. # AMS Drying Endpoints
  1255. # ============================================
  1256. @router.post("/{printer_id}/drying/start")
  1257. async def start_drying(
  1258. printer_id: int,
  1259. ams_id: int,
  1260. temp: int = 45,
  1261. duration: int = 4,
  1262. filament: str = "",
  1263. rotate_tray: bool = False,
  1264. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1265. db: AsyncSession = Depends(get_db),
  1266. ):
  1267. """Send AMS drying start command. temp=45-85, duration=hours."""
  1268. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1269. printer = result.scalar_one_or_none()
  1270. if not printer:
  1271. raise HTTPException(404, "Printer not found")
  1272. # Server-side guard: reject if this model/firmware doesn't support drying
  1273. live_state = printer_manager.get_status(printer_id)
  1274. firmware = live_state.firmware_version if live_state else None
  1275. if not supports_drying(printer.model, firmware):
  1276. raise HTTPException(400, "Drying not supported for this printer model or firmware version")
  1277. if temp < 45 or temp > 85:
  1278. raise HTTPException(400, "Temperature must be 45-85°C")
  1279. if duration < 1 or duration > 24:
  1280. raise HTTPException(400, "Duration must be 1-24 hours")
  1281. # Inspect the live AMS unit: surface blocking dry_sf_reasons (otherwise the
  1282. # firmware silently ignores the command — #971) and backfill an empty
  1283. # filament field from the first loaded tray so the printer doesn't reject
  1284. # the payload.
  1285. target_ams: dict | None = None
  1286. for unit in (live_state.raw_data.get("ams") if live_state else None) or []:
  1287. try:
  1288. if int(unit.get("id", -1)) == ams_id:
  1289. target_ams = unit
  1290. break
  1291. except (TypeError, ValueError):
  1292. continue
  1293. if target_ams is not None:
  1294. reason_messages = {
  1295. 0: "Printer is busy",
  1296. 1: "Insufficient power — too many AMS drying or external PSU required",
  1297. 2: "AMS is busy",
  1298. 3: "Filament is at the AMS outlet — retract it first",
  1299. 4: "AMS is already starting a drying cycle",
  1300. 5: "Not supported in 2D mode",
  1301. 6: "AMS is already drying",
  1302. 7: "AMS firmware is upgrading",
  1303. 8: "Plug in the external AMS power adapter to start drying",
  1304. }
  1305. for code in target_ams.get("dry_sf_reason") or []:
  1306. try:
  1307. code_int = int(code)
  1308. except (TypeError, ValueError):
  1309. continue
  1310. if code_int in reason_messages:
  1311. raise HTTPException(409, reason_messages[code_int])
  1312. if not filament:
  1313. for tray in target_ams.get("tray") or []:
  1314. tray_type = tray.get("tray_type")
  1315. if tray_type:
  1316. filament = str(tray_type)
  1317. break
  1318. if not filament:
  1319. filament = "PLA"
  1320. success = printer_manager.send_drying_command(
  1321. printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray
  1322. )
  1323. if not success:
  1324. raise HTTPException(400, "Printer not connected")
  1325. return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
  1326. @router.post("/{printer_id}/drying/stop")
  1327. async def stop_drying(
  1328. printer_id: int,
  1329. ams_id: int,
  1330. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1331. db: AsyncSession = Depends(get_db),
  1332. ):
  1333. """Send AMS drying stop command."""
  1334. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1335. printer = result.scalar_one_or_none()
  1336. if not printer:
  1337. raise HTTPException(404, "Printer not found")
  1338. success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
  1339. if not success:
  1340. raise HTTPException(400, "Printer not connected")
  1341. return {"status": "drying_stopped", "ams_id": ams_id}
  1342. # ============================================
  1343. # Print Options (AI Detection) Endpoints
  1344. # ============================================
  1345. @router.post("/{printer_id}/print-options")
  1346. async def set_print_option(
  1347. printer_id: int,
  1348. module_name: str,
  1349. enabled: bool,
  1350. print_halt: bool = True,
  1351. sensitivity: str = "medium",
  1352. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1353. db: AsyncSession = Depends(get_db),
  1354. ):
  1355. """Set an AI detection / print option on the printer.
  1356. Valid module_name values:
  1357. - spaghetti_detector: Spaghetti detection
  1358. - first_layer_inspector: First layer inspection
  1359. - printing_monitor: AI print quality monitoring
  1360. - buildplate_marker_detector: Build plate marker detection
  1361. - allow_skip_parts: Allow skipping failed parts
  1362. """
  1363. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1364. printer = result.scalar_one_or_none()
  1365. if not printer:
  1366. raise HTTPException(404, "Printer not found")
  1367. client = printer_manager.get_client(printer_id)
  1368. if not client or not client.state.connected:
  1369. raise HTTPException(400, "Printer not connected")
  1370. # Validate module_name
  1371. valid_modules = [
  1372. "spaghetti_detector",
  1373. "first_layer_inspector",
  1374. "printing_monitor",
  1375. "buildplate_marker_detector",
  1376. "allow_skip_parts",
  1377. "pileup_detector",
  1378. "clump_detector",
  1379. "airprint_detector",
  1380. "auto_recovery_step_loss",
  1381. ]
  1382. if module_name not in valid_modules:
  1383. raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
  1384. # Validate sensitivity
  1385. valid_sensitivities = ["low", "medium", "high", "never_halt"]
  1386. if sensitivity not in valid_sensitivities:
  1387. raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
  1388. success = client.set_xcam_option(
  1389. module_name=module_name,
  1390. enabled=enabled,
  1391. print_halt=print_halt,
  1392. sensitivity=sensitivity,
  1393. )
  1394. if not success:
  1395. raise HTTPException(500, "Failed to send command to printer")
  1396. return {
  1397. "success": True,
  1398. "module_name": module_name,
  1399. "enabled": enabled,
  1400. "print_halt": print_halt,
  1401. "sensitivity": sensitivity,
  1402. }
  1403. # ============================================
  1404. # Calibration
  1405. # ============================================
  1406. @router.post("/{printer_id}/calibration")
  1407. async def start_calibration(
  1408. printer_id: int,
  1409. bed_leveling: bool = False,
  1410. vibration: bool = False,
  1411. motor_noise: bool = False,
  1412. nozzle_offset: bool = False,
  1413. high_temp_heatbed: bool = False,
  1414. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1415. db: AsyncSession = Depends(get_db),
  1416. ):
  1417. """Start printer calibration with selected options.
  1418. At least one option must be selected.
  1419. Options:
  1420. - bed_leveling: Run bed leveling calibration
  1421. - vibration: Run vibration compensation calibration
  1422. - motor_noise: Run motor noise cancellation calibration
  1423. - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
  1424. - high_temp_heatbed: Run high-temperature heatbed calibration
  1425. """
  1426. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1427. printer = result.scalar_one_or_none()
  1428. if not printer:
  1429. raise HTTPException(404, "Printer not found")
  1430. client = printer_manager.get_client(printer_id)
  1431. if not client or not client.state.connected:
  1432. raise HTTPException(400, "Printer not connected")
  1433. # Check that at least one option is selected
  1434. if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
  1435. raise HTTPException(400, "At least one calibration option must be selected")
  1436. success = client.start_calibration(
  1437. bed_leveling=bed_leveling,
  1438. vibration=vibration,
  1439. motor_noise=motor_noise,
  1440. nozzle_offset=nozzle_offset,
  1441. high_temp_heatbed=high_temp_heatbed,
  1442. )
  1443. if not success:
  1444. raise HTTPException(500, "Failed to send calibration command to printer")
  1445. return {
  1446. "success": True,
  1447. "bed_leveling": bed_leveling,
  1448. "vibration": vibration,
  1449. "motor_noise": motor_noise,
  1450. "nozzle_offset": nozzle_offset,
  1451. "high_temp_heatbed": high_temp_heatbed,
  1452. }
  1453. # ============================================================================
  1454. # Slot Preset Mapping Endpoints
  1455. # ============================================================================
  1456. @router.get("/{printer_id}/slot-presets")
  1457. async def get_slot_presets(
  1458. printer_id: int,
  1459. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1460. db: AsyncSession = Depends(get_db),
  1461. ):
  1462. """Get all saved slot-to-preset mappings for a printer."""
  1463. result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))
  1464. mappings = result.scalars().all()
  1465. return {
  1466. mapping.ams_id * 4 + mapping.tray_id: {
  1467. "ams_id": mapping.ams_id,
  1468. "tray_id": mapping.tray_id,
  1469. "preset_id": mapping.preset_id,
  1470. "preset_name": mapping.preset_name,
  1471. }
  1472. for mapping in mappings
  1473. }
  1474. @router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1475. async def get_slot_preset(
  1476. printer_id: int,
  1477. ams_id: int,
  1478. tray_id: int,
  1479. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1480. db: AsyncSession = Depends(get_db),
  1481. ):
  1482. """Get the saved preset for a specific slot."""
  1483. result = await db.execute(
  1484. select(SlotPresetMapping).where(
  1485. SlotPresetMapping.printer_id == printer_id,
  1486. SlotPresetMapping.ams_id == ams_id,
  1487. SlotPresetMapping.tray_id == tray_id,
  1488. )
  1489. )
  1490. mapping = result.scalar_one_or_none()
  1491. if not mapping:
  1492. return None
  1493. return {
  1494. "ams_id": mapping.ams_id,
  1495. "tray_id": mapping.tray_id,
  1496. "preset_id": mapping.preset_id,
  1497. "preset_name": mapping.preset_name,
  1498. }
  1499. @router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1500. async def save_slot_preset(
  1501. printer_id: int,
  1502. ams_id: int,
  1503. tray_id: int,
  1504. preset_id: str,
  1505. preset_name: str,
  1506. preset_source: str = "cloud",
  1507. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  1508. db: AsyncSession = Depends(get_db),
  1509. ):
  1510. """Save a preset mapping for a specific slot."""
  1511. # Check printer exists
  1512. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1513. if not result.scalar_one_or_none():
  1514. raise HTTPException(404, "Printer not found")
  1515. # Check for existing mapping
  1516. result = await db.execute(
  1517. select(SlotPresetMapping).where(
  1518. SlotPresetMapping.printer_id == printer_id,
  1519. SlotPresetMapping.ams_id == ams_id,
  1520. SlotPresetMapping.tray_id == tray_id,
  1521. )
  1522. )
  1523. mapping = result.scalar_one_or_none()
  1524. if mapping:
  1525. # Update existing
  1526. mapping.preset_id = preset_id
  1527. mapping.preset_name = preset_name
  1528. mapping.preset_source = preset_source
  1529. else:
  1530. # Create new
  1531. mapping = SlotPresetMapping(
  1532. printer_id=printer_id,
  1533. ams_id=ams_id,
  1534. tray_id=tray_id,
  1535. preset_id=preset_id,
  1536. preset_name=preset_name,
  1537. preset_source=preset_source,
  1538. )
  1539. db.add(mapping)
  1540. await db.commit()
  1541. await db.refresh(mapping)
  1542. return {
  1543. "ams_id": mapping.ams_id,
  1544. "tray_id": mapping.tray_id,
  1545. "preset_id": mapping.preset_id,
  1546. "preset_name": mapping.preset_name,
  1547. "preset_source": mapping.preset_source,
  1548. }
  1549. @router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1550. async def delete_slot_preset(
  1551. printer_id: int,
  1552. ams_id: int,
  1553. tray_id: int,
  1554. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  1555. db: AsyncSession = Depends(get_db),
  1556. ):
  1557. """Delete a saved preset mapping for a slot."""
  1558. result = await db.execute(
  1559. select(SlotPresetMapping).where(
  1560. SlotPresetMapping.printer_id == printer_id,
  1561. SlotPresetMapping.ams_id == ams_id,
  1562. SlotPresetMapping.tray_id == tray_id,
  1563. )
  1564. )
  1565. mapping = result.scalar_one_or_none()
  1566. if mapping:
  1567. await db.delete(mapping)
  1568. await db.commit()
  1569. return {"success": True}
  1570. @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
  1571. async def configure_ams_slot(
  1572. printer_id: int,
  1573. ams_id: int,
  1574. tray_id: int,
  1575. tray_info_idx: str = Query(...),
  1576. tray_type: str = Query(...),
  1577. tray_sub_brands: str = Query(...),
  1578. tray_color: str = Query(...),
  1579. nozzle_temp_min: int = Query(...),
  1580. nozzle_temp_max: int = Query(...),
  1581. cali_idx: int = Query(-1),
  1582. nozzle_diameter: str = Query("0.4"),
  1583. setting_id: str = Query(""),
  1584. kprofile_filament_id: str = Query(""),
  1585. kprofile_setting_id: str = Query(""),
  1586. k_value: float = Query(0.0),
  1587. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1588. ):
  1589. """Configure an AMS slot with a specific filament setting and K profile.
  1590. This sends two commands to the printer:
  1591. 1. ams_filament_setting - sets filament type, color, temperature
  1592. 2. extrusion_cali_sel - sets the K profile (pressure advance value)
  1593. Args:
  1594. printer_id: Database ID of the printer
  1595. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
  1596. tray_id: Tray ID within the AMS (0-3)
  1597. tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
  1598. tray_type: Filament type (e.g., "PLA", "PETG")
  1599. tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
  1600. tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
  1601. nozzle_temp_min: Minimum nozzle temperature
  1602. nozzle_temp_max: Maximum nozzle temperature
  1603. cali_idx: K profile calibration index (-1 for default 0.020)
  1604. nozzle_diameter: Nozzle diameter string (e.g., "0.4")
  1605. setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
  1606. kprofile_filament_id: K profile's filament_id for proper K profile linking
  1607. k_value: Direct K value to set (0.0 to skip direct K value setting)
  1608. """
  1609. logger = logging.getLogger(__name__)
  1610. logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
  1611. logger.info(
  1612. f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
  1613. )
  1614. logger.info(
  1615. f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
  1616. )
  1617. # Get MQTT client for this printer
  1618. client = printer_manager.get_client(printer_id)
  1619. if not client:
  1620. raise HTTPException(status_code=400, detail="Printer not connected")
  1621. # Resolve tray_info_idx for the MQTT command.
  1622. # Priority:
  1623. # 1. Use the provided tray_info_idx if set (including cloud-synced
  1624. # custom presets like PFUS* / P*).
  1625. # 2. Reuse the slot's existing tray_info_idx if it's a specific
  1626. # (non-generic) preset for the same material.
  1627. # 3. Fall back to a generic Bambu filament ID.
  1628. _GENERIC_FILAMENT_IDS = {
  1629. "PLA": "GFL99",
  1630. "PETG": "GFG99",
  1631. "ABS": "GFB99",
  1632. "ASA": "GFB98",
  1633. "PC": "GFC99",
  1634. "PA": "GFN99",
  1635. "NYLON": "GFN99",
  1636. "TPU": "GFU99",
  1637. "PVA": "GFS99",
  1638. "HIPS": "GFS98",
  1639. "PLA-CF": "GFL98",
  1640. "PETG-CF": "GFG98",
  1641. "PA-CF": "GFN98",
  1642. "PETG HF": "GFG96",
  1643. }
  1644. _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values())
  1645. effective_tray_info_idx = tray_info_idx
  1646. if not tray_info_idx:
  1647. # No preset provided — try slot reuse or generic fallback
  1648. current_tray_info_idx = ""
  1649. current_tray_type = ""
  1650. state = printer_manager.get_status(printer_id)
  1651. if state and state.raw_data:
  1652. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  1653. if ams_id == 255:
  1654. vt_tray = state.raw_data.get("vt_tray") or []
  1655. ext_id = tray_id + 254
  1656. for vt in vt_tray:
  1657. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  1658. current_tray_info_idx = vt.get("tray_info_idx", "")
  1659. current_tray_type = vt.get("tray_type", "")
  1660. break
  1661. else:
  1662. ams_data = state.raw_data.get("ams", {})
  1663. ams_list = (
  1664. ams_data.get("ams", [])
  1665. if isinstance(ams_data, dict)
  1666. else ams_data
  1667. if isinstance(ams_data, list)
  1668. else []
  1669. )
  1670. cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
  1671. if cur_tray:
  1672. current_tray_info_idx = cur_tray.get("tray_info_idx", "")
  1673. current_tray_type = cur_tray.get("tray_type", "")
  1674. if (
  1675. current_tray_info_idx
  1676. and current_tray_info_idx not in _GENERIC_ID_VALUES
  1677. and current_tray_type
  1678. and current_tray_type.upper() == tray_type.upper()
  1679. ):
  1680. logger.info(
  1681. "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
  1682. current_tray_info_idx,
  1683. tray_type,
  1684. )
  1685. effective_tray_info_idx = current_tray_info_idx
  1686. elif tray_type:
  1687. material = tray_type.upper().strip()
  1688. generic = (
  1689. _GENERIC_FILAMENT_IDS.get(material)
  1690. or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  1691. or ""
  1692. )
  1693. if generic:
  1694. logger.info("[configure_ams_slot] Falling back to generic %r for material %r", generic, tray_type)
  1695. effective_tray_info_idx = generic
  1696. # Send filament setting + K-profile commands
  1697. filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
  1698. # Always send ams_set_filament_setting — the user explicitly clicked
  1699. # "Configure Slot", so honor that. Previous versions skipped this for
  1700. # RFID-tagged slots to preserve the slicer eye icon, but printers cache
  1701. # stale tag_uid/tray_uuid after a BL spool is removed, causing the check
  1702. # to false-positive on non-RFID slots and silently drop the command.
  1703. success = client.ams_set_filament_setting(
  1704. ams_id=ams_id,
  1705. tray_id=tray_id,
  1706. tray_info_idx=effective_tray_info_idx,
  1707. tray_type=tray_type,
  1708. tray_sub_brands=tray_sub_brands,
  1709. tray_color=tray_color,
  1710. nozzle_temp_min=nozzle_temp_min,
  1711. nozzle_temp_max=nozzle_temp_max,
  1712. setting_id=setting_id,
  1713. )
  1714. if not success:
  1715. raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
  1716. # Method 1: Select existing calibration profile by cali_idx
  1717. # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
  1718. # and including it causes the firmware to mislink the profile on X1C/P1S.
  1719. client.extrusion_cali_sel(
  1720. ams_id=ams_id,
  1721. tray_id=tray_id,
  1722. cali_idx=cali_idx,
  1723. filament_id=filament_id_for_kprofile,
  1724. nozzle_diameter=nozzle_diameter,
  1725. )
  1726. # Method 2: Only send extrusion_cali_set when NO existing profile was selected
  1727. # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the
  1728. # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY
  1729. # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
  1730. # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
  1731. # hardcoded extruder_id=0 and nozzle_id=HS00.
  1732. if k_value > 0 and cali_idx < 0:
  1733. # Calculate global tray ID for extrusion_cali_set
  1734. if ams_id <= 3:
  1735. global_tray_id = ams_id * 4 + tray_id
  1736. elif ams_id >= 128 and ams_id <= 135:
  1737. global_tray_id = (ams_id - 128) * 4 + tray_id
  1738. else:
  1739. global_tray_id = tray_id
  1740. client.extrusion_cali_set(
  1741. tray_id=global_tray_id,
  1742. k_value=k_value,
  1743. nozzle_diameter=nozzle_diameter,
  1744. nozzle_temp=nozzle_temp_max,
  1745. filament_id=filament_id_for_kprofile,
  1746. setting_id=kprofile_setting_id or "",
  1747. name=tray_sub_brands or "",
  1748. cali_idx=cali_idx,
  1749. )
  1750. # Request fresh status push from printer so frontend gets updated data via WebSocket
  1751. logger.info("[configure_ams_slot] Requesting status update from printer")
  1752. update_result = client.request_status_update()
  1753. logger.info("[configure_ams_slot] Status update request result: %s", update_result)
  1754. return {
  1755. "success": True,
  1756. "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
  1757. }
  1758. @router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
  1759. async def reset_ams_slot(
  1760. printer_id: int,
  1761. ams_id: int,
  1762. tray_id: int,
  1763. db: AsyncSession = Depends(get_db),
  1764. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1765. ):
  1766. """Reset an AMS slot to empty/unconfigured state.
  1767. This clears the filament configuration from the slot.
  1768. """
  1769. # Get MQTT client for this printer
  1770. client = printer_manager.get_client(printer_id)
  1771. if not client:
  1772. raise HTTPException(status_code=400, detail="Printer not connected")
  1773. # Reset the slot
  1774. success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
  1775. if not success:
  1776. raise HTTPException(status_code=500, detail="Failed to send reset command")
  1777. # Also delete any saved slot preset mapping
  1778. result = await db.execute(
  1779. select(SlotPresetMapping).where(
  1780. SlotPresetMapping.printer_id == printer_id,
  1781. SlotPresetMapping.ams_id == ams_id,
  1782. SlotPresetMapping.tray_id == tray_id,
  1783. )
  1784. )
  1785. mapping = result.scalar_one_or_none()
  1786. if mapping:
  1787. await db.delete(mapping)
  1788. await db.commit()
  1789. # Request fresh status push from printer so frontend gets updated data via WebSocket
  1790. client.request_status_update()
  1791. return {
  1792. "success": True,
  1793. "message": f"Reset AMS {ams_id} tray {tray_id}",
  1794. }
  1795. @router.get("/{printer_id}/ams-labels")
  1796. async def get_ams_labels(
  1797. printer_id: int,
  1798. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1799. db: AsyncSession = Depends(get_db),
  1800. ):
  1801. """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
  1802. Labels are stored by AMS serial number. This endpoint resolves the current
  1803. serial-to-ams_id mapping from the live printer state so the response is still
  1804. keyed by ams_id for UI compatibility.
  1805. """
  1806. # Build serial -> ams_id map from live printer state
  1807. serial_to_ams_id: dict[str, int] = {}
  1808. state = printer_manager.get_status(printer_id)
  1809. if state and state.raw_data:
  1810. for ams_unit in state.raw_data.get("ams", []):
  1811. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  1812. if sn:
  1813. serial_to_ams_id[sn] = int(ams_unit.get("id", 0))
  1814. # Collect all known serials for this printer (live + synthetic fallback keys)
  1815. serials_to_query = set(serial_to_ams_id.keys())
  1816. # Fetch labels for all known serials
  1817. labels: dict[int, str] = {}
  1818. if serials_to_query:
  1819. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)))
  1820. for lbl in result.scalars().all():
  1821. aid = serial_to_ams_id.get(lbl.ams_serial_number)
  1822. if aid is not None:
  1823. labels[aid] = lbl.label
  1824. # Also fetch labels stored under synthetic keys for this printer (backward compat)
  1825. # Collect all synthetic keys first, then query with a single IN clause.
  1826. if state and state.raw_data:
  1827. synthetic_key_to_aid: dict[str, int] = {
  1828. f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0))
  1829. for ams_unit in state.raw_data.get("ams", [])
  1830. if int(ams_unit.get("id", 0)) not in labels
  1831. }
  1832. if synthetic_key_to_aid:
  1833. result = await db.execute(
  1834. select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys()))
  1835. )
  1836. for lbl in result.scalars().all():
  1837. aid = synthetic_key_to_aid.get(lbl.ams_serial_number)
  1838. if aid is not None:
  1839. labels[aid] = lbl.label
  1840. return labels
  1841. @router.put("/{printer_id}/ams-labels/{ams_id}")
  1842. async def save_ams_label(
  1843. printer_id: int,
  1844. ams_id: int,
  1845. body: AmsLabelBody,
  1846. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  1847. db: AsyncSession = Depends(get_db),
  1848. ):
  1849. """Create or update the friendly name for a specific AMS unit.
  1850. When ``ams_serial`` is provided the label is stored under that serial number so
  1851. it survives the AMS being moved to a different printer. When it is absent (e.g.
  1852. older firmware that does not report a serial) a synthetic key based on the
  1853. printer_id and ams_id is used as a fallback.
  1854. """
  1855. # Verify printer exists
  1856. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1857. if not result.scalar_one_or_none():
  1858. raise HTTPException(404, "Printer not found")
  1859. # Determine the serial key to store under
  1860. stripped = body.ams_serial.strip() if body.ams_serial else ""
  1861. serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
  1862. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
  1863. existing = result.scalar_one_or_none()
  1864. if existing:
  1865. existing.label = body.label
  1866. existing.ams_id = ams_id
  1867. else:
  1868. db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))
  1869. await db.commit()
  1870. return {"ams_id": ams_id, "label": body.label}
  1871. @router.delete("/{printer_id}/ams-labels/{ams_id}")
  1872. async def delete_ams_label(
  1873. printer_id: int,
  1874. ams_id: int,
  1875. ams_serial: str = Query(default="", max_length=50),
  1876. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  1877. db: AsyncSession = Depends(get_db),
  1878. ):
  1879. """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
  1880. stripped = ams_serial.strip() if ams_serial else ""
  1881. serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
  1882. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
  1883. existing = result.scalar_one_or_none()
  1884. if existing:
  1885. await db.delete(existing)
  1886. await db.commit()
  1887. return {"success": True}
  1888. @router.post("/{printer_id}/debug/simulate-print-complete")
  1889. async def debug_simulate_print_complete(
  1890. printer_id: int,
  1891. db: AsyncSession = Depends(get_db),
  1892. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1893. ):
  1894. """DEBUG: Simulate print completion to test freeze behavior.
  1895. This triggers the same code path as a real print completion,
  1896. without needing to wait for an actual print to finish.
  1897. """
  1898. from backend.app.main import _active_prints, on_print_complete
  1899. from backend.app.models.archive import PrintArchive
  1900. # Get the most recent archive for this printer
  1901. result = await db.execute(
  1902. select(PrintArchive)
  1903. .where(PrintArchive.printer_id == printer_id)
  1904. .order_by(PrintArchive.created_at.desc())
  1905. .limit(1)
  1906. )
  1907. archive = result.scalar_one_or_none()
  1908. if not archive:
  1909. raise HTTPException(status_code=404, detail="No archives found for this printer")
  1910. # Register this archive as "active" so on_print_complete can find it
  1911. filename = archive.file_path.split("/")[-1] if archive.file_path else "test.3mf"
  1912. subtask_name = archive.print_name or "Test Print"
  1913. _active_prints[(printer_id, filename)] = archive.id
  1914. _active_prints[(printer_id, subtask_name)] = archive.id
  1915. # Simulate print completion data
  1916. data = {
  1917. "status": "completed",
  1918. "filename": filename,
  1919. "subtask_name": subtask_name,
  1920. "timelapse_was_active": False,
  1921. }
  1922. logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id)
  1923. # Call the actual on_print_complete handler
  1924. await on_print_complete(printer_id, data)
  1925. return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
  1926. # =============================================================================
  1927. # Print Control Endpoints
  1928. # =============================================================================
  1929. @router.post("/{printer_id}/print/stop")
  1930. async def stop_print(
  1931. printer_id: int,
  1932. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1933. db: AsyncSession = Depends(get_db),
  1934. ):
  1935. """Stop/cancel the current print job."""
  1936. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1937. printer = result.scalar_one_or_none()
  1938. if not printer:
  1939. raise HTTPException(404, "Printer not found")
  1940. client = printer_manager.get_client(printer_id)
  1941. if not client:
  1942. raise HTTPException(400, "Printer not connected")
  1943. success = client.stop_print()
  1944. if not success:
  1945. raise HTTPException(500, "Failed to stop print")
  1946. return {"success": True, "message": "Print stop command sent"}
  1947. @router.post("/{printer_id}/clear-plate")
  1948. async def clear_plate(
  1949. printer_id: int,
  1950. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
  1951. db: AsyncSession = Depends(get_db),
  1952. ):
  1953. """Acknowledge that the build plate has been cleared after a finished/failed print.
  1954. Sets a plate-cleared flag so the scheduler can start the next queued print.
  1955. No MQTT command is sent to the printer — the scheduler's start_print command
  1956. will override the FINISH/FAILED state when it sends the next job.
  1957. """
  1958. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1959. printer = result.scalar_one_or_none()
  1960. if not printer:
  1961. raise HTTPException(404, "Printer not found")
  1962. if not printer_manager.is_connected(printer_id):
  1963. raise HTTPException(400, "Printer not connected")
  1964. # Accept the acknowledgment whenever the printer is awaiting it — not only when the
  1965. # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE
  1966. # but the awaiting flag persists, and the user still needs a way to ack it (#961).
  1967. state = printer_manager.get_status(printer_id)
  1968. awaiting = printer_manager.is_awaiting_plate_clear(printer_id)
  1969. if not awaiting and (not state or state.state not in ("FINISH", "FAILED")):
  1970. raise HTTPException(
  1971. 400,
  1972. f"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})",
  1973. )
  1974. printer_manager.set_awaiting_plate_clear(printer_id, False)
  1975. return {"success": True, "message": "Plate cleared, next print will start shortly"}
  1976. @router.post("/{printer_id}/print/pause")
  1977. async def pause_print(
  1978. printer_id: int,
  1979. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1980. db: AsyncSession = Depends(get_db),
  1981. ):
  1982. """Pause the current print job."""
  1983. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1984. printer = result.scalar_one_or_none()
  1985. if not printer:
  1986. raise HTTPException(404, "Printer not found")
  1987. client = printer_manager.get_client(printer_id)
  1988. if not client:
  1989. raise HTTPException(400, "Printer not connected")
  1990. success = client.pause_print()
  1991. if not success:
  1992. raise HTTPException(500, "Failed to pause print")
  1993. return {"success": True, "message": "Print pause command sent"}
  1994. @router.post("/{printer_id}/print/resume")
  1995. async def resume_print(
  1996. printer_id: int,
  1997. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1998. db: AsyncSession = Depends(get_db),
  1999. ):
  2000. """Resume a paused print job."""
  2001. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2002. printer = result.scalar_one_or_none()
  2003. if not printer:
  2004. raise HTTPException(404, "Printer not found")
  2005. client = printer_manager.get_client(printer_id)
  2006. if not client:
  2007. raise HTTPException(400, "Printer not connected")
  2008. success = client.resume_print()
  2009. if not success:
  2010. raise HTTPException(500, "Failed to resume print")
  2011. return {"success": True, "message": "Print resume command sent"}
  2012. @router.post("/{printer_id}/print-speed")
  2013. async def set_print_speed(
  2014. printer_id: int,
  2015. mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
  2016. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2017. db: AsyncSession = Depends(get_db),
  2018. ):
  2019. """Set the print speed mode."""
  2020. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2021. printer = result.scalar_one_or_none()
  2022. if not printer:
  2023. raise HTTPException(404, "Printer not found")
  2024. client = printer_manager.get_client(printer_id)
  2025. if not client:
  2026. raise HTTPException(400, "Printer not connected")
  2027. success = client.set_print_speed(mode)
  2028. if not success:
  2029. raise HTTPException(500, "Failed to set print speed")
  2030. speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
  2031. return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
  2032. @router.post("/{printer_id}/airduct-mode")
  2033. async def set_airduct_mode(
  2034. printer_id: int,
  2035. mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
  2036. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2037. db: AsyncSession = Depends(get_db),
  2038. ):
  2039. """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
  2040. if mode not in ("cooling", "heating"):
  2041. raise HTTPException(400, "Mode must be 'cooling' or 'heating'")
  2042. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2043. printer = result.scalar_one_or_none()
  2044. if not printer:
  2045. raise HTTPException(404, "Printer not found")
  2046. client = printer_manager.get_client(printer_id)
  2047. if not client:
  2048. raise HTTPException(400, "Printer not connected")
  2049. success = client.set_airduct_mode(mode)
  2050. if not success:
  2051. raise HTTPException(500, "Failed to set airduct mode")
  2052. return {"success": True, "message": f"Airduct mode set to {mode}"}
  2053. @router.post("/{printer_id}/chamber-light")
  2054. async def set_chamber_light(
  2055. printer_id: int,
  2056. on: bool = Query(..., description="True to turn on, False to turn off"),
  2057. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2058. db: AsyncSession = Depends(get_db),
  2059. ):
  2060. """Turn the chamber light on or off."""
  2061. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2062. printer = result.scalar_one_or_none()
  2063. if not printer:
  2064. raise HTTPException(404, "Printer not found")
  2065. client = printer_manager.get_client(printer_id)
  2066. if not client:
  2067. raise HTTPException(400, "Printer not connected")
  2068. success = client.set_chamber_light(on)
  2069. if not success:
  2070. raise HTTPException(500, "Failed to control chamber light")
  2071. return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
  2072. @router.post("/{printer_id}/bed-jog")
  2073. async def bed_jog(
  2074. printer_id: int,
  2075. distance: float = Query(
  2076. ..., description="Relative Z distance in mm (positive = bed down / nozzle further away, negative = bed up)"
  2077. ),
  2078. force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
  2079. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2080. db: AsyncSession = Depends(get_db),
  2081. ):
  2082. """Move the build plate along the Z axis by a relative distance.
  2083. Emits a short G-code sequence via MQTT. When ``force`` is true the soft
  2084. endstops are disabled for the duration of the move, matching the
  2085. "ignore and move anyway" option Bambu Studio offers when the printer
  2086. is not homed.
  2087. """
  2088. if distance == 0 or abs(distance) > 200:
  2089. raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
  2090. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2091. printer = result.scalar_one_or_none()
  2092. if not printer:
  2093. raise HTTPException(404, "Printer not found")
  2094. client = printer_manager.get_client(printer_id)
  2095. if not client:
  2096. raise HTTPException(400, "Printer not connected")
  2097. lines = []
  2098. if force:
  2099. lines.append("M211 S0")
  2100. lines += ["G91", f"G1 Z{distance:.2f} F600", "G90"]
  2101. if force:
  2102. lines.append("M211 S1")
  2103. if not client.send_gcode("\n".join(lines)):
  2104. raise HTTPException(500, "Failed to send bed-jog command")
  2105. return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
  2106. @router.post("/{printer_id}/home-axes")
  2107. async def home_axes(
  2108. printer_id: int,
  2109. axes: str = Query("z", description="Axes to home: 'z', 'xy', or 'all'"),
  2110. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2111. db: AsyncSession = Depends(get_db),
  2112. ):
  2113. """Home one or more axes via G28."""
  2114. axes = axes.lower()
  2115. if axes == "z":
  2116. gcode = "G28 Z"
  2117. elif axes == "xy":
  2118. gcode = "G28 X Y"
  2119. elif axes == "all":
  2120. gcode = "G28"
  2121. else:
  2122. raise HTTPException(400, "axes must be 'z', 'xy', or 'all'")
  2123. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2124. printer = result.scalar_one_or_none()
  2125. if not printer:
  2126. raise HTTPException(404, "Printer not found")
  2127. client = printer_manager.get_client(printer_id)
  2128. if not client:
  2129. raise HTTPException(400, "Printer not connected")
  2130. if not client.send_gcode(gcode):
  2131. raise HTTPException(500, "Failed to send home command")
  2132. return {"success": True, "message": f"Home {axes} command sent"}
  2133. @router.post("/{printer_id}/hms/clear")
  2134. async def clear_hms_errors(
  2135. printer_id: int,
  2136. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2137. db: AsyncSession = Depends(get_db),
  2138. ):
  2139. """Clear HMS/print errors on the printer."""
  2140. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2141. printer = result.scalar_one_or_none()
  2142. if not printer:
  2143. raise HTTPException(404, "Printer not found")
  2144. client = printer_manager.get_client(printer_id)
  2145. if not client:
  2146. raise HTTPException(400, "Printer not connected")
  2147. success = client.clear_hms_errors()
  2148. if not success:
  2149. raise HTTPException(500, "Failed to clear HMS errors")
  2150. return {"success": True, "message": "HMS errors cleared"}
  2151. @router.get("/{printer_id}/print/objects")
  2152. async def get_printable_objects(
  2153. printer_id: int,
  2154. reload: bool = False,
  2155. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  2156. db: AsyncSession = Depends(get_db),
  2157. ):
  2158. """Get the list of printable objects for the current print.
  2159. Returns a list of objects with id, name, position (if available), and skip status.
  2160. Objects that have already been skipped are marked in the skipped_objects list.
  2161. Args:
  2162. reload: If True, reload objects from the archive file (useful after restart)
  2163. """
  2164. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2165. printer = result.scalar_one_or_none()
  2166. if not printer:
  2167. raise HTTPException(404, "Printer not found")
  2168. client = printer_manager.get_client(printer_id)
  2169. if not client:
  2170. raise HTTPException(400, "Printer not connected")
  2171. # Reload objects from 3MF if requested or no objects loaded
  2172. if reload or not client.state.printable_objects:
  2173. subtask_name = client.state.subtask_name
  2174. if subtask_name:
  2175. from backend.app.services.archive import extract_printable_objects_from_3mf
  2176. from backend.app.services.bambu_ftp import download_file_try_paths_async
  2177. # Build possible 3MF filenames (try both .gcode.3mf and .3mf)
  2178. possible_filenames = []
  2179. if subtask_name.endswith(".3mf"):
  2180. possible_filenames.append(subtask_name)
  2181. else:
  2182. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  2183. possible_filenames.append(f"{subtask_name}.3mf")
  2184. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  2185. if " " in subtask_name:
  2186. normalized = subtask_name.replace(" ", "_")
  2187. if normalized.endswith(".3mf"):
  2188. possible_filenames.append(normalized)
  2189. else:
  2190. possible_filenames.append(f"{normalized}.gcode.3mf")
  2191. possible_filenames.append(f"{normalized}.3mf")
  2192. # Download 3MF from printer
  2193. temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}"
  2194. temp_path.parent.mkdir(parents=True, exist_ok=True)
  2195. # Build list of all remote paths to try
  2196. remote_paths = []
  2197. for filename in possible_filenames:
  2198. remote_paths.extend([f"/{filename}", f"/cache/{filename}", f"/model/{filename}"])
  2199. try:
  2200. downloaded = await download_file_try_paths_async(
  2201. printer.ip_address,
  2202. printer.access_code,
  2203. remote_paths,
  2204. temp_path,
  2205. printer_model=printer.model,
  2206. )
  2207. if downloaded and temp_path.exists():
  2208. with open(temp_path, "rb") as f:
  2209. data = f.read()
  2210. objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)
  2211. if objects:
  2212. client.state.printable_objects = objects
  2213. client.state.printable_objects_bbox_all = bbox_all
  2214. logger.info("Reloaded %s objects for printer %s", len(objects), printer_id)
  2215. except Exception as e:
  2216. logger.debug("Failed to reload objects from printer: %s", e)
  2217. finally:
  2218. if temp_path.exists():
  2219. temp_path.unlink()
  2220. # Return objects with their skip status and position data
  2221. objects = []
  2222. for obj_id, obj_data in client.state.printable_objects.items():
  2223. # Handle both old format (string name) and new format (dict with name, x, y)
  2224. if isinstance(obj_data, dict):
  2225. obj_entry = {
  2226. "id": obj_id,
  2227. "name": obj_data.get("name", f"Object {obj_id}"),
  2228. "x": obj_data.get("x"),
  2229. "y": obj_data.get("y"),
  2230. "skipped": obj_id in client.state.skipped_objects,
  2231. }
  2232. else:
  2233. # Legacy format: obj_data is just the name string
  2234. obj_entry = {
  2235. "id": obj_id,
  2236. "name": obj_data,
  2237. "x": None,
  2238. "y": None,
  2239. "skipped": obj_id in client.state.skipped_objects,
  2240. }
  2241. objects.append(obj_entry)
  2242. return {
  2243. "objects": objects,
  2244. "total": len(objects),
  2245. "skipped_count": len(client.state.skipped_objects),
  2246. "is_printing": client.state.state in ("RUNNING", "PAUSE"),
  2247. "bbox_all": getattr(client.state, "printable_objects_bbox_all", None),
  2248. }
  2249. @router.post("/{printer_id}/print/skip-objects")
  2250. async def skip_objects(
  2251. printer_id: int,
  2252. object_ids: list[int],
  2253. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2254. db: AsyncSession = Depends(get_db),
  2255. ):
  2256. """Skip specific objects during the current print.
  2257. Args:
  2258. object_ids: List of object identify_id values to skip
  2259. """
  2260. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2261. printer = result.scalar_one_or_none()
  2262. if not printer:
  2263. raise HTTPException(404, "Printer not found")
  2264. client = printer_manager.get_client(printer_id)
  2265. if not client:
  2266. raise HTTPException(400, "Printer not connected")
  2267. if not object_ids:
  2268. raise HTTPException(400, "No object IDs provided")
  2269. # Validate object IDs exist in printable_objects
  2270. invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]
  2271. if invalid_ids:
  2272. raise HTTPException(400, f"Invalid object IDs: {invalid_ids}")
  2273. success = client.skip_objects(object_ids)
  2274. if not success:
  2275. raise HTTPException(500, "Failed to skip objects")
  2276. # Get names of skipped objects for response (handle both old and new format)
  2277. skipped_names = []
  2278. for oid in object_ids:
  2279. obj_data = client.state.printable_objects.get(oid, str(oid))
  2280. if isinstance(obj_data, dict):
  2281. skipped_names.append(obj_data.get("name", str(oid)))
  2282. else:
  2283. skipped_names.append(obj_data)
  2284. return {
  2285. "success": True,
  2286. "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}",
  2287. "skipped_objects": object_ids,
  2288. }
  2289. # =============================================================================
  2290. # AMS Control Endpoints
  2291. # =============================================================================
  2292. @router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh")
  2293. async def refresh_ams_slot(
  2294. printer_id: int,
  2295. ams_id: int,
  2296. slot_id: int,
  2297. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
  2298. db: AsyncSession = Depends(get_db),
  2299. ):
  2300. """Re-read RFID for an AMS slot (triggers filament info refresh)."""
  2301. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2302. printer = result.scalar_one_or_none()
  2303. if not printer:
  2304. raise HTTPException(404, "Printer not found")
  2305. client = printer_manager.get_client(printer_id)
  2306. if not client:
  2307. raise HTTPException(400, "Printer not connected")
  2308. success, message = client.ams_refresh_tray(ams_id, slot_id)
  2309. if not success:
  2310. raise HTTPException(400, message)
  2311. # Apply PA profile after delay (RFID re-read takes a few seconds)
  2312. asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id))
  2313. return {"success": True, "message": message}
  2314. async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
  2315. """Apply PA profile after RFID re-read completes.
  2316. Waits for the printer to finish processing the RFID data, then selects
  2317. the K-profile via extrusion_cali_sel. Does NOT re-send ams_set_filament_setting
  2318. because that would overwrite the RFID-provided filament data.
  2319. """
  2320. await asyncio.sleep(5)
  2321. try:
  2322. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  2323. from backend.app.core.database import async_session
  2324. from backend.app.models.spool import Spool
  2325. from backend.app.models.spool_assignment import SpoolAssignment as SA
  2326. from backend.app.services.spool_tag_matcher import is_bambu_tag
  2327. client = printer_manager.get_client(printer_id)
  2328. if not client:
  2329. return
  2330. state = printer_manager.get_status(printer_id)
  2331. if not state or not state.raw_data:
  2332. return
  2333. # Find current tray data (should have RFID data by now)
  2334. ams_data = state.raw_data.get("ams", {})
  2335. ams_list = (
  2336. ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
  2337. )
  2338. tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)
  2339. if not tray or not tray.get("tray_type"):
  2340. logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id)
  2341. return
  2342. tag_uid = tray.get("tag_uid", "")
  2343. tray_uuid = tray.get("tray_uuid", "")
  2344. tray_info_idx = tray.get("tray_info_idx", "")
  2345. if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
  2346. return
  2347. async with async_session() as db:
  2348. from sqlalchemy import select as sa_select
  2349. from sqlalchemy.orm import selectinload
  2350. result = await db.execute(
  2351. sa_select(SA)
  2352. .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
  2353. .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
  2354. )
  2355. assignment = result.scalar_one_or_none()
  2356. if not assignment or not assignment.spool or not assignment.spool.k_profiles:
  2357. return
  2358. spool = assignment.spool
  2359. nozzle_diameter = "0.4"
  2360. if state.nozzles:
  2361. nd = state.nozzles[0].nozzle_diameter
  2362. if nd:
  2363. nozzle_diameter = nd
  2364. # Determine slot's extruder from ams_extruder_map
  2365. slot_extruder = None
  2366. if state.ams_extruder_map:
  2367. if ams_id == 255:
  2368. # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  2369. slot_extruder = 1 - slot_id # 0→1, 1→0
  2370. else:
  2371. slot_extruder = state.ams_extruder_map.get(str(ams_id))
  2372. matching_kp = None
  2373. for kp in spool.k_profiles:
  2374. if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
  2375. if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
  2376. continue
  2377. matching_kp = kp
  2378. break
  2379. if not matching_kp or matching_kp.cali_idx is None:
  2380. return
  2381. # The filament_id in extrusion_cali_sel must match the filament preset
  2382. # under which the K-profile was calibrated. Use spool.slicer_filament
  2383. # (the preset assigned in inventory), falling back to tray's RFID value.
  2384. kp_filament_id = spool.slicer_filament or tray_info_idx
  2385. logger.info(
  2386. "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
  2387. ams_id,
  2388. slot_id,
  2389. matching_kp.cali_idx,
  2390. kp_filament_id,
  2391. )
  2392. # 1. Select K-profile
  2393. # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
  2394. # "this is a manual config" which destroys the RFID-detected spool state
  2395. # (changes eye icon to pen icon in slicer).
  2396. client.extrusion_cali_sel(
  2397. ams_id=ams_id,
  2398. tray_id=slot_id,
  2399. cali_idx=matching_kp.cali_idx,
  2400. filament_id=kp_filament_id,
  2401. nozzle_diameter=nozzle_diameter,
  2402. )
  2403. # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
  2404. # selected the correct profile by cali_idx. Sending extrusion_cali_set with
  2405. # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
  2406. # nozzle_id, name), corrupting it.
  2407. logger.info(
  2408. "Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d",
  2409. matching_kp.cali_idx,
  2410. matching_kp.k_value or 0,
  2411. printer_id,
  2412. ams_id,
  2413. slot_id,
  2414. )
  2415. except Exception as e:
  2416. logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
  2417. @router.get("/{printer_id}/runtime-debug")
  2418. async def get_runtime_debug(
  2419. printer_id: int,
  2420. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  2421. db: AsyncSession = Depends(get_db),
  2422. ):
  2423. """Debug endpoint: Get runtime tracking status for a printer."""
  2424. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2425. printer = result.scalar_one_or_none()
  2426. if not printer:
  2427. raise HTTPException(404, "Printer not found")
  2428. state = printer_manager.get_status(printer_id)
  2429. return {
  2430. "printer_name": printer.name,
  2431. "runtime_seconds": printer.runtime_seconds,
  2432. "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
  2433. "print_hours_offset": printer.print_hours_offset,
  2434. "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
  2435. + (printer.print_hours_offset or 0),
  2436. "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
  2437. "mqtt_state": {
  2438. "connected": state.connected if state else False,
  2439. "state": state.state if state else None,
  2440. "progress": state.progress if state else None,
  2441. "gcode_file": state.gcode_file if state else None,
  2442. }
  2443. if state
  2444. else None,
  2445. "is_active": printer.is_active,
  2446. }