printers.py 165 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116
  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 import database
  10. from backend.app.core.auth import (
  11. RequireCameraStreamTokenIfAuthEnabled,
  12. RequireOverlayTokenIfAuthEnabled,
  13. RequirePermissionIfAuthEnabled,
  14. is_auth_enabled,
  15. )
  16. from backend.app.core.config import settings
  17. from backend.app.core.database import get_db
  18. from backend.app.core.permissions import Permission
  19. from backend.app.core.tasks import spawn_background_task
  20. from backend.app.models.ams_label import AmsLabel
  21. from backend.app.models.printer import Printer
  22. from backend.app.models.slot_preset import SlotPresetMapping
  23. from backend.app.models.user import User
  24. from backend.app.schemas.printer import (
  25. AmsLabelBody,
  26. AMSTray,
  27. AMSUnit,
  28. DiagnosticRequest,
  29. FilaSwitchResponse,
  30. HmsActionBody,
  31. HMSErrorResponse,
  32. NozzleInfoResponse,
  33. NozzleRackSlot,
  34. PrinterCreate,
  35. PrinterDiagnosticResult,
  36. PrinterResponse,
  37. PrinterResponseWithSecret,
  38. PrinterStatus,
  39. PrinterUpdate,
  40. PrintOptionsResponse,
  41. )
  42. from backend.app.services.bambu_ftp import (
  43. cache_3mf_download,
  44. delete_file_async,
  45. download_file_bytes_async,
  46. download_file_try_paths_async,
  47. get_cached_3mf,
  48. get_storage_info_async,
  49. list_files_async,
  50. )
  51. from backend.app.services.printer_diagnostic import run_connection_diagnostic
  52. from backend.app.services.printer_manager import (
  53. drying_screen_only,
  54. get_derived_status_name,
  55. printer_manager,
  56. resolve_expected_tray,
  57. resolve_plate_id,
  58. supports_chamber_heater,
  59. supports_chamber_temp,
  60. supports_drying,
  61. supports_drying_while_printing,
  62. )
  63. from backend.app.utils.filament_ids import filament_id_to_setting_id
  64. from backend.app.utils.http import build_content_disposition
  65. from backend.app.utils.printer_models import uses_exhaust_fan_label
  66. logger = logging.getLogger(__name__)
  67. router = APIRouter(prefix="/printers", tags=["printers"])
  68. # Seconds the /hms/execute-action route waits for a printer status push
  69. # confirming the command landed before reporting 502 to the UI. Module-level
  70. # so tests can monkeypatch a near-zero value instead of mocking asyncio.sleep.
  71. HMS_ACTION_ACK_WAIT_SECONDS = 2.5
  72. async def _caller_can_view_printer_secrets(user: User | None, db: AsyncSession) -> bool:
  73. """Whether the caller is trusted enough to see ``access_code`` on a printer
  74. response. Fail-CLOSED: anything that isn't an authenticated user holding
  75. PRINTERS_UPDATE returns False.
  76. - Auth disabled → True (single trust domain — same as today's local UI).
  77. - JWT user with PRINTERS_UPDATE → True (Admin or Operator; the same roles
  78. that already manage printers and the Virtual Printer card UX that
  79. surfaces a target's code for slicer configuration).
  80. - JWT Viewer → False (the bug fix: Viewers must not be able to read
  81. access_code via PRINTERS_READ and then go around Bambuddy to MQTT).
  82. - API-key principal (``user is None`` because the dep returns None for
  83. API keys) → False. PRINTERS_UPDATE is admin-only and absent from
  84. ``_APIKEY_SCOPE_BY_PERMISSION``, so no API key can hold it.
  85. """
  86. if not await is_auth_enabled(db):
  87. return True
  88. if user is None:
  89. return False
  90. return user.has_permission(Permission.PRINTERS_UPDATE.value)
  91. def _serialize_printer(printer: Printer, *, include_secret: bool):
  92. """Build the response shape that matches the caller's authority."""
  93. if include_secret:
  94. return PrinterResponseWithSecret.model_validate(printer)
  95. return PrinterResponse.model_validate(printer)
  96. @router.get("/")
  97. async def list_printers(
  98. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  99. db: AsyncSession = Depends(get_db),
  100. ):
  101. """List all configured printers.
  102. ``access_code`` is included in each item only when the caller is trusted
  103. to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
  104. API keys never receive it.
  105. """
  106. result = await db.execute(select(Printer).order_by(Printer.name))
  107. printers = list(result.scalars().all())
  108. include_secret = await _caller_can_view_printer_secrets(user, db)
  109. return [_serialize_printer(p, include_secret=include_secret) for p in printers]
  110. @router.post("/", response_model=PrinterResponse)
  111. async def create_printer(
  112. printer_data: PrinterCreate,
  113. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  114. db: AsyncSession = Depends(get_db),
  115. ):
  116. """Add a new printer.
  117. Verifies the MQTT connection succeeds before persisting. A wrong access
  118. code or unreachable IP would otherwise create a printer row that shows
  119. as an empty / never-connecting card on the dashboard — those reports
  120. were turning into support tickets that all traced back to a mistyped
  121. access code.
  122. """
  123. # Check if serial number already exists
  124. result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number))
  125. if result.scalar_one_or_none():
  126. raise HTTPException(400, "Printer with this serial number already exists")
  127. test_result = await printer_manager.test_connection(
  128. ip_address=printer_data.ip_address,
  129. serial_number=printer_data.serial_number,
  130. access_code=printer_data.access_code,
  131. )
  132. if not test_result.get("success"):
  133. # The frontend renders the user-facing message via i18n on `code`;
  134. # `message` is an English fallback for non-UI clients (curl / scripts).
  135. raise HTTPException(
  136. status_code=400,
  137. detail={
  138. "code": "printer_connection_failed",
  139. "message": (
  140. "Could not connect to the printer. Verify IP address, serial number, "
  141. "and access code, and confirm LAN-only mode is enabled. "
  142. "The printer was not added."
  143. ),
  144. },
  145. )
  146. printer = Printer(**printer_data.model_dump())
  147. db.add(printer)
  148. await db.commit()
  149. await db.refresh(printer)
  150. # Connect to the printer
  151. if printer.is_active:
  152. await printer_manager.connect_printer(printer)
  153. return printer
  154. @router.get("/usb-cameras")
  155. async def list_usb_cameras(
  156. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  157. ):
  158. """List available USB cameras connected to the system.
  159. Returns a list of detected V4L2 video devices with their info.
  160. Only works on Linux systems with V4L2 support.
  161. Returns:
  162. List of dicts with {device: str, name: str, capabilities: list, formats?: list}
  163. """
  164. from backend.app.services.external_camera import list_usb_cameras
  165. cameras = list_usb_cameras()
  166. return {"cameras": cameras}
  167. @router.get("/available-filaments")
  168. async def get_available_filaments(
  169. model: str = Query(..., description="Target printer model"),
  170. location: str | None = Query(None, description="Optional location filter"),
  171. _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
  172. db: AsyncSession = Depends(get_db),
  173. ):
  174. """Get deduplicated list of filaments loaded across all active printers of a given model.
  175. Used by the frontend to offer filament override options for model-based queue assignment.
  176. """
  177. from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
  178. # Normalize model name
  179. normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model
  180. query = (
  181. select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True) # noqa: E712
  182. )
  183. if location:
  184. query = query.where(Printer.location == location)
  185. result = await db.execute(query)
  186. printers_list = list(result.scalars().all())
  187. if not printers_list:
  188. return []
  189. # Collect filaments from all matching printers
  190. # Dedup key includes extruder_id and tray_sub_brands so "PLA Basic" and "PLA Matte" appear separately
  191. seen: set[tuple[str, str, str, int | None]] = set() # (type_upper, color_normalized, sub_brands_upper, extruder_id)
  192. filaments = []
  193. for printer in printers_list:
  194. status = printer_manager.get_status(printer.id)
  195. if not status:
  196. continue
  197. # Get ams_extruder_map for dual-nozzle printers
  198. ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
  199. # AMS trays
  200. for ams_unit in status.raw_data.get("ams", []):
  201. ams_id = str(ams_unit.get("id", 0))
  202. extruder_id = ams_extruder_map.get(ams_id)
  203. for tray in ams_unit.get("tray", []):
  204. tray_type = tray.get("tray_type")
  205. if not tray_type:
  206. continue
  207. tray_color = tray.get("tray_color", "") or "808080"
  208. # Preserve the full RRGGBBAA so transparent filament (alpha=00)
  209. # reaches the frontend instead of collapsing to #000000 → black
  210. # (#1545). Opaque colours still round-trip as #RRGGBB. The
  211. # dedup key uses the 6-char RGB so two slots that share an RGB
  212. # but differ only in alpha still merge.
  213. stripped = tray_color.replace("#", "")
  214. rgb = stripped[:6].lower() or "808080"
  215. color = f"#{stripped}"
  216. tray_info_idx = tray.get("tray_info_idx", "")
  217. tray_sub_brands = tray.get("tray_sub_brands", "") or ""
  218. key = (tray_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
  219. if key not in seen:
  220. seen.add(key)
  221. filaments.append(
  222. {
  223. "type": tray_type,
  224. "color": color,
  225. "tray_info_idx": tray_info_idx,
  226. "tray_sub_brands": tray_sub_brands,
  227. "extruder_id": extruder_id,
  228. }
  229. )
  230. # External spools (vt_tray)
  231. for vt in status.raw_data.get("vt_tray") or []:
  232. vt_type = vt.get("tray_type")
  233. if not vt_type:
  234. continue
  235. vt_color = vt.get("tray_color", "") or "808080"
  236. # Same alpha-preserving handling as the AMS branch — see #1545.
  237. stripped = vt_color.replace("#", "")
  238. rgb = stripped[:6].lower() or "808080"
  239. color = f"#{stripped}"
  240. tray_info_idx = vt.get("tray_info_idx", "")
  241. tray_sub_brands = vt.get("tray_sub_brands", "") or ""
  242. vt_id = int(vt.get("id", 254))
  243. extruder_id = (255 - vt_id) if ams_extruder_map else None
  244. key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
  245. if key not in seen:
  246. seen.add(key)
  247. filaments.append(
  248. {
  249. "type": vt_type,
  250. "color": color,
  251. "tray_info_idx": tray_info_idx,
  252. "tray_sub_brands": tray_sub_brands,
  253. "extruder_id": extruder_id,
  254. }
  255. )
  256. return filaments
  257. @router.get("/developer-mode-warnings")
  258. async def get_developer_mode_warnings(
  259. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  260. db: AsyncSession = Depends(get_db),
  261. ):
  262. """Check if any connected printer lacks developer LAN mode."""
  263. result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
  264. printers = result.scalars().all()
  265. statuses = printer_manager.get_all_statuses()
  266. warnings = []
  267. for printer in printers:
  268. state = statuses.get(printer.id)
  269. if state and state.connected and state.developer_mode is False:
  270. warnings.append(
  271. {
  272. "printer_id": printer.id,
  273. "name": printer.name,
  274. }
  275. )
  276. return warnings
  277. @router.get("/{printer_id}")
  278. async def get_printer(
  279. printer_id: int,
  280. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  281. db: AsyncSession = Depends(get_db),
  282. ):
  283. """Get a specific printer.
  284. ``access_code`` is included only when the caller is trusted to see it
  285. (Admin / Operator JWT, or auth-disabled mode). Viewers and API keys
  286. never receive it.
  287. """
  288. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  289. printer = result.scalar_one_or_none()
  290. if not printer:
  291. raise HTTPException(404, "Printer not found")
  292. include_secret = await _caller_can_view_printer_secrets(user, db)
  293. return _serialize_printer(printer, include_secret=include_secret)
  294. @router.patch("/{printer_id}", response_model=PrinterResponse)
  295. async def update_printer(
  296. printer_id: int,
  297. printer_data: PrinterUpdate,
  298. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  299. db: AsyncSession = Depends(get_db),
  300. ):
  301. """Update a printer."""
  302. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  303. printer = result.scalar_one_or_none()
  304. if not printer:
  305. raise HTTPException(404, "Printer not found")
  306. update_data = printer_data.model_dump(exclude_unset=True)
  307. # Handle nested ROI object - flatten to individual columns
  308. if "plate_detection_roi" in update_data:
  309. roi = update_data.pop("plate_detection_roi")
  310. if roi:
  311. update_data["plate_detection_roi_x"] = roi.get("x")
  312. update_data["plate_detection_roi_y"] = roi.get("y")
  313. update_data["plate_detection_roi_w"] = roi.get("w")
  314. update_data["plate_detection_roi_h"] = roi.get("h")
  315. else:
  316. # Clear ROI if set to null
  317. update_data["plate_detection_roi_x"] = None
  318. update_data["plate_detection_roi_y"] = None
  319. update_data["plate_detection_roi_w"] = None
  320. update_data["plate_detection_roi_h"] = None
  321. for field, value in update_data.items():
  322. setattr(printer, field, value)
  323. await db.commit()
  324. await db.refresh(printer)
  325. # Reconnect if connection settings changed
  326. if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
  327. printer_manager.disconnect_printer(printer_id)
  328. if printer.is_active:
  329. await printer_manager.connect_printer(printer)
  330. return printer
  331. @router.delete("/{printer_id}")
  332. async def delete_printer(
  333. printer_id: int,
  334. delete_archives: bool = True,
  335. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
  336. db: AsyncSession = Depends(get_db),
  337. ):
  338. """Delete a printer.
  339. Args:
  340. printer_id: ID of the printer to delete
  341. delete_archives: If True (default), delete all print archives for this printer.
  342. If False, keep archives but remove their printer association.
  343. """
  344. from sqlalchemy import delete as sql_delete
  345. from backend.app.models.archive import PrintArchive
  346. from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
  347. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  348. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  349. printer = result.scalar_one_or_none()
  350. if not printer:
  351. raise HTTPException(404, "Printer not found")
  352. printer_manager.disconnect_printer(printer_id)
  353. if delete_archives:
  354. # Delete all archives for this printer
  355. await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
  356. else:
  357. # Orphan the archives instead of deleting them
  358. from sqlalchemy import update
  359. await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
  360. # Delete slot assignments for this printer (SQLite doesn't enforce FK cascades)
  361. await db.execute(sql_delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
  362. # Delete maintenance history and items for this printer
  363. # (SQLite doesn't enforce FK cascades, so do it explicitly)
  364. maintenance_ids = (
  365. (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id)))
  366. .scalars()
  367. .all()
  368. )
  369. if maintenance_ids:
  370. await db.execute(
  371. sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids))
  372. )
  373. await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
  374. await db.delete(printer)
  375. await db.commit()
  376. return {"status": "deleted", "archives_deleted": delete_archives}
  377. @router.get("/{printer_id}/status", response_model=PrinterStatus)
  378. async def get_printer_status(
  379. printer_id: int,
  380. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  381. db: AsyncSession = Depends(get_db),
  382. ):
  383. """Get real-time status of a printer."""
  384. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  385. printer = result.scalar_one_or_none()
  386. if not printer:
  387. raise HTTPException(404, "Printer not found")
  388. state = printer_manager.get_status(printer_id)
  389. if not state:
  390. return PrinterStatus(
  391. id=printer_id,
  392. name=printer.name,
  393. connected=False,
  394. )
  395. # Determine cover URL if there's an active print (including paused)
  396. cover_url = None
  397. if state.state in ("RUNNING", "PAUSE") and state.gcode_file:
  398. cover_url = f"/api/v1/printers/{printer_id}/cover"
  399. # Convert HMS errors to response format
  400. hms_errors = [
  401. HMSErrorResponse(
  402. code=e.code,
  403. attr=e.attr,
  404. module=e.module,
  405. severity=e.severity,
  406. actions=e.actions,
  407. job_id=e.job_id,
  408. full_code=e.full_code,
  409. )
  410. for e in (state.hms_errors or [])
  411. ]
  412. # Parse AMS data from raw_data
  413. ams_units = []
  414. vt_tray = []
  415. ams_exists = False
  416. raw_data = state.raw_data or {}
  417. # Build K-profile lookup map: cali_idx -> k_value
  418. # This allows looking up the calibrated K value for each AMS slot
  419. kprofile_map: dict[int, float] = {}
  420. for kp in state.kprofiles or []:
  421. if kp.slot_id is not None and kp.k_value:
  422. try:
  423. kprofile_map[kp.slot_id] = float(kp.k_value)
  424. except (ValueError, TypeError):
  425. pass # Skip K-profile entries with unparseable values
  426. # Cached active-cycle drying params (filament + target temp) we sent
  427. # last; Bambu doesn't echo them on the per-tick AMS push, so the badge
  428. # needs the cache to render "<filament> @ <temp>°C".
  429. drying_targets = printer_manager.get_drying_targets(printer_id) or {}
  430. if "ams" in raw_data and isinstance(raw_data["ams"], list):
  431. ams_exists = True
  432. for ams_data in raw_data["ams"]:
  433. # Skip if ams_data is not a dict (defensive check)
  434. if not isinstance(ams_data, dict):
  435. continue
  436. trays = []
  437. for tray_data in ams_data.get("tray", []):
  438. # Filter out empty/invalid tag values
  439. tag_uid = tray_data.get("tag_uid", "")
  440. if tag_uid in ("", "0000000000000000"):
  441. tag_uid = None
  442. tray_uuid = tray_data.get("tray_uuid", "")
  443. if tray_uuid in ("", "00000000000000000000000000000000"):
  444. tray_uuid = None
  445. # Get K value: first try tray's k field, then lookup from K-profiles
  446. k_value = tray_data.get("k")
  447. cali_idx = tray_data.get("cali_idx")
  448. if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
  449. k_value = kprofile_map[cali_idx]
  450. trays.append(
  451. AMSTray(
  452. id=tray_data.get("id", 0),
  453. tray_color=tray_data.get("tray_color"),
  454. tray_type=tray_data.get("tray_type"),
  455. tray_sub_brands=tray_data.get("tray_sub_brands"),
  456. tray_id_name=tray_data.get("tray_id_name"),
  457. tray_info_idx=tray_data.get("tray_info_idx"),
  458. remain=tray_data.get("remain", 0),
  459. k=k_value,
  460. cali_idx=cali_idx,
  461. tag_uid=tag_uid,
  462. tray_uuid=tray_uuid,
  463. nozzle_temp_min=tray_data.get("nozzle_temp_min"),
  464. nozzle_temp_max=tray_data.get("nozzle_temp_max"),
  465. drying_temp=tray_data.get("drying_temp"),
  466. drying_time=tray_data.get("drying_time"),
  467. state=tray_data.get("state"),
  468. exists=tray_data.get("exists"),
  469. )
  470. )
  471. # Prefer humidity_raw (percentage) over humidity (index 1-5)
  472. # humidity_raw is the actual percentage value from the sensor
  473. humidity_raw = ams_data.get("humidity_raw")
  474. humidity_idx = ams_data.get("humidity")
  475. humidity_value = None
  476. if humidity_raw is not None:
  477. try:
  478. humidity_value = int(humidity_raw)
  479. except (ValueError, TypeError):
  480. pass # Skip unparseable humidity; will try index fallback
  481. if humidity_value is None and humidity_idx is not None:
  482. try:
  483. humidity_value = int(humidity_idx)
  484. except (ValueError, TypeError):
  485. pass # Skip unparseable humidity index; humidity remains None
  486. # AMS-HT has 1 tray, regular AMS has 4 trays
  487. is_ams_ht = len(trays) == 1
  488. ams_id_int = int(ams_data.get("id", 0))
  489. target = drying_targets.get(ams_id_int) or {}
  490. dry_target_temp: int | None = None
  491. dry_filament: str | None = None
  492. target_temp_val = target.get("temp")
  493. target_fil_val = target.get("filament") or ""
  494. if target_temp_val is not None:
  495. try:
  496. dry_target_temp = int(target_temp_val)
  497. except (TypeError, ValueError):
  498. dry_target_temp = None
  499. if target_fil_val:
  500. dry_filament = str(target_fil_val)
  501. # Fallback: derive from first loaded tray when no cached target
  502. # (drying started in a previous backend session, or cache wasn't
  503. # seeded). Mirrors the popover seed heuristic.
  504. if dry_target_temp is None or not dry_filament:
  505. for tray in trays:
  506. if tray.tray_type:
  507. if not dry_filament:
  508. dry_filament = str(tray.tray_type)
  509. if dry_target_temp is None and tray.drying_temp:
  510. try:
  511. dry_target_temp = int(tray.drying_temp)
  512. except (TypeError, ValueError):
  513. pass
  514. break
  515. ams_units.append(
  516. AMSUnit(
  517. id=ams_id_int,
  518. humidity=humidity_value,
  519. temp=ams_data.get("temp"),
  520. is_ams_ht=is_ams_ht,
  521. tray=trays,
  522. # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
  523. serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
  524. # Firmware version: populated by _handle_version_info from info.module ams/* entries
  525. sw_ver=str(ams_data.get("sw_ver") or ""),
  526. # Drying: dry_time > 0 means drying is active (minutes remaining)
  527. dry_time=int(ams_data.get("dry_time") or 0),
  528. dry_target_temp=dry_target_temp,
  529. dry_filament=dry_filament,
  530. module_type=str(ams_data.get("module_type") or ""),
  531. )
  532. )
  533. # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
  534. if "vt_tray" in raw_data:
  535. for vt_data in raw_data["vt_tray"]:
  536. # Filter out empty/invalid tag values for vt_tray
  537. vt_tag_uid = vt_data.get("tag_uid", "")
  538. if vt_tag_uid in ("", "0000000000000000"):
  539. vt_tag_uid = None
  540. vt_tray_uuid = vt_data.get("tray_uuid", "")
  541. if vt_tray_uuid in ("", "00000000000000000000000000000000"):
  542. vt_tray_uuid = None
  543. # Get K value: first try tray's k field, then lookup from K-profiles
  544. vt_k_value = vt_data.get("k")
  545. vt_cali_idx = vt_data.get("cali_idx")
  546. if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
  547. vt_k_value = kprofile_map[vt_cali_idx]
  548. tray_id = int(vt_data.get("id", 254))
  549. vt_tray.append(
  550. AMSTray(
  551. id=tray_id,
  552. tray_color=vt_data.get("tray_color"),
  553. tray_type=vt_data.get("tray_type"),
  554. tray_sub_brands=vt_data.get("tray_sub_brands"),
  555. tray_id_name=vt_data.get("tray_id_name"),
  556. tray_info_idx=vt_data.get("tray_info_idx"),
  557. remain=vt_data.get("remain", 0),
  558. k=vt_k_value,
  559. cali_idx=vt_cali_idx,
  560. tag_uid=vt_tag_uid,
  561. tray_uuid=vt_tray_uuid,
  562. nozzle_temp_min=vt_data.get("nozzle_temp_min"),
  563. nozzle_temp_max=vt_data.get("nozzle_temp_max"),
  564. )
  565. )
  566. # Convert nozzle info to response format
  567. nozzles = [
  568. NozzleInfoResponse(
  569. nozzle_type=n.nozzle_type,
  570. nozzle_diameter=n.nozzle_diameter,
  571. )
  572. for n in (state.nozzles or [])
  573. ]
  574. # H2C nozzle rack (tool-changer dock positions)
  575. nozzle_rack = [
  576. NozzleRackSlot(
  577. id=n.get("id", 0),
  578. nozzle_type=n.get("type", ""),
  579. nozzle_diameter=n.get("diameter", ""),
  580. wear=n.get("wear"),
  581. stat=n.get("stat"),
  582. max_temp=n.get("max_temp", 0),
  583. serial_number=n.get("serial_number", ""),
  584. filament_color=n.get("filament_color", ""),
  585. filament_id=n.get("filament_id", ""),
  586. filament_type=n.get("filament_type", ""),
  587. )
  588. for n in (state.nozzle_rack or [])
  589. ]
  590. # Convert print options to response format
  591. print_options = PrintOptionsResponse(
  592. spaghetti_detector=state.print_options.spaghetti_detector,
  593. print_halt=state.print_options.print_halt,
  594. halt_print_sensitivity=state.print_options.halt_print_sensitivity,
  595. first_layer_inspector=state.print_options.first_layer_inspector,
  596. printing_monitor=state.print_options.printing_monitor,
  597. buildplate_marker_detector=state.print_options.buildplate_marker_detector,
  598. allow_skip_parts=state.print_options.allow_skip_parts,
  599. nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
  600. nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
  601. pileup_detector=state.print_options.pileup_detector,
  602. pileup_sensitivity=state.print_options.pileup_sensitivity,
  603. airprint_detector=state.print_options.airprint_detector,
  604. airprint_sensitivity=state.print_options.airprint_sensitivity,
  605. auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
  606. filament_tangle_detect=state.print_options.filament_tangle_detect,
  607. )
  608. # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
  609. ams_mapping = raw_data.get("ams_mapping", [])
  610. # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
  611. # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
  612. ams_extruder_map = state.ams_extruder_map or {}
  613. logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
  614. # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
  615. # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
  616. # No conversion needed - just use the raw value directly
  617. tray_now = state.tray_now
  618. logger.debug("Using tray_now directly as global ID: %s", tray_now)
  619. # Filter out chamber temp for models that don't have a real sensor
  620. # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
  621. temperatures = state.temperatures
  622. if not supports_chamber_temp(printer.model):
  623. temperatures = {
  624. k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
  625. }
  626. # Resolve the active print's archive + plate (#881 follow-up): lets the
  627. # printer card show the actual plate name for multi-plate 3MFs instead of
  628. # just the 3MF filename. Only attempted for active prints, since subtask_id
  629. # is only meaningful then.
  630. current_archive_id: int | None = None
  631. current_plate_id: int | None = None
  632. if state.state in ("RUNNING", "PAUSE"):
  633. current_plate_id = resolve_plate_id(state)
  634. if state.subtask_id:
  635. from backend.app.models.archive import PrintArchive
  636. archive_row = await db.execute(
  637. select(PrintArchive.id)
  638. .where(PrintArchive.subtask_id == state.subtask_id)
  639. .where(PrintArchive.printer_id == printer_id)
  640. .order_by(PrintArchive.created_at.desc())
  641. .limit(1)
  642. )
  643. current_archive_id = archive_row.scalar_one_or_none()
  644. return PrinterStatus(
  645. id=printer_id,
  646. name=printer.name,
  647. connected=state.connected,
  648. state=state.state,
  649. current_print=state.current_print,
  650. subtask_name=state.subtask_name,
  651. gcode_file=state.gcode_file,
  652. progress=state.progress,
  653. remaining_time=state.remaining_time,
  654. layer_num=state.layer_num,
  655. total_layers=state.total_layers,
  656. temperatures=temperatures,
  657. cover_url=cover_url,
  658. hms_errors=hms_errors,
  659. ams=ams_units,
  660. ams_exists=ams_exists,
  661. vt_tray=vt_tray,
  662. sdcard=state.sdcard,
  663. store_to_sdcard=state.store_to_sdcard,
  664. timelapse=state.timelapse,
  665. ipcam=state.ipcam,
  666. wifi_signal=state.wifi_signal,
  667. wired_network=state.wired_network,
  668. door_open=state.door_open,
  669. nozzles=nozzles,
  670. nozzle_rack=nozzle_rack,
  671. print_options=print_options,
  672. stg_cur=state.stg_cur,
  673. stg_cur_name=get_derived_status_name(state, printer.model),
  674. stg=state.stg,
  675. airduct_mode=state.airduct_mode,
  676. speed_level=state.speed_level,
  677. chamber_light=state.chamber_light,
  678. active_extruder=state.active_extruder,
  679. ams_mapping=ams_mapping,
  680. ams_extruder_map=ams_extruder_map,
  681. tray_now=tray_now,
  682. # Runout guidance (#2587): resolve the firmware's target/previous slot to a
  683. # global tray ID, but only while PAUSED — the moment the operator needs it.
  684. expected_tray=(
  685. resolve_expected_tray(
  686. state.tray_tar,
  687. [(u.id, u.is_ams_ht) for u in ams_units],
  688. raw_data.get("mapping"),
  689. )
  690. if state.state == "PAUSE"
  691. else None
  692. ),
  693. previous_tray=(
  694. resolve_expected_tray(
  695. state.tray_pre,
  696. [(u.id, u.is_ams_ht) for u in ams_units],
  697. raw_data.get("mapping"),
  698. )
  699. if state.state == "PAUSE"
  700. else None
  701. ),
  702. ams_status_main=state.ams_status_main,
  703. ams_status_sub=state.ams_status_sub,
  704. mc_print_sub_stage=state.mc_print_sub_stage,
  705. last_ams_update=state.last_ams_update,
  706. printable_objects_count=len(state.printable_objects),
  707. cooling_fan_speed=state.cooling_fan_speed,
  708. big_fan1_speed=state.big_fan1_speed,
  709. big_fan2_speed=state.big_fan2_speed,
  710. heatbreak_fan_speed=state.heatbreak_fan_speed,
  711. left_aux_fan_speed=state.left_aux_fan_speed,
  712. exhaust_fan_present=state.exhaust_fan_present,
  713. firmware_version=state.firmware_version,
  714. developer_mode=state.developer_mode if state else None,
  715. ams_filament_backup=state.ams_filament_backup if state else None,
  716. awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
  717. supports_drying=supports_drying(printer.model, state.firmware_version),
  718. supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
  719. drying_screen_only=drying_screen_only(printer.model),
  720. supports_chamber_heater=supports_chamber_heater(printer.model),
  721. current_archive_id=current_archive_id,
  722. current_plate_id=current_plate_id,
  723. fila_switch=(
  724. FilaSwitchResponse(
  725. installed=state.fila_switch.installed,
  726. in_slots=list(state.fila_switch.in_slots),
  727. out_extruders=list(state.fila_switch.out_extruders),
  728. stat=state.fila_switch.stat,
  729. info=state.fila_switch.info,
  730. )
  731. if state.fila_switch and state.fila_switch.installed
  732. else None
  733. ),
  734. )
  735. @router.get("/{printer_id}/overlay-status")
  736. async def get_overlay_status(
  737. printer_id: int,
  738. _: None = RequireOverlayTokenIfAuthEnabled,
  739. db: AsyncSession = Depends(get_db),
  740. ) -> dict:
  741. """Everything the streaming overlay (#2613) draws for one printer.
  742. A token-authenticated sibling of ``get_printer_status`` for embeds with no
  743. login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
  744. Deliberately flat and minimal (name, camera rotation, live print state, and
  745. the one setting the overlay reads) rather than the full ``PrinterStatus``:
  746. a token holder gets exactly the fields the overlay renders, nothing more.
  747. Unlike the Cam Wall feed this *includes the print filename* — the overlay
  748. names the part on screen — which is why it sits behind its own ``overlay``
  749. scope rather than ``camwall``.
  750. """
  751. from backend.app.api.routes.settings import get_setting
  752. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  753. printer = result.scalar_one_or_none()
  754. if not printer:
  755. raise HTTPException(404, "Printer not found")
  756. time_format = await get_setting(db, "time_format") or "system"
  757. state = printer_manager.get_status(printer_id)
  758. if not state:
  759. # Never connected this run — mirror get_printer_status()'s disconnected
  760. # shape so the overlay renders its offline state rather than erroring.
  761. return {
  762. "id": printer_id,
  763. "name": printer.name,
  764. "camera_rotation": printer.camera_rotation or 0,
  765. "connected": False,
  766. "state": None,
  767. "current_print": None,
  768. "gcode_file": None,
  769. "progress": None,
  770. "remaining_time": None,
  771. "layer_num": None,
  772. "total_layers": None,
  773. "stg_cur_name": None,
  774. "time_format": time_format,
  775. }
  776. return {
  777. "id": printer_id,
  778. "name": printer.name,
  779. "camera_rotation": printer.camera_rotation or 0,
  780. "connected": state.connected,
  781. "state": state.state,
  782. "current_print": state.current_print,
  783. "gcode_file": state.gcode_file,
  784. "progress": state.progress,
  785. "remaining_time": state.remaining_time,
  786. "layer_num": state.layer_num,
  787. "total_layers": state.total_layers,
  788. "stg_cur_name": get_derived_status_name(state, printer.model),
  789. "time_format": time_format,
  790. }
  791. @router.get("/{printer_id}/current-print-user")
  792. async def get_current_print_user(
  793. printer_id: int,
  794. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  795. db: AsyncSession = Depends(get_db),
  796. ):
  797. """Get the user who started the current print (for reprint tracking).
  798. Returns user info if available, empty object otherwise.
  799. This tracks users for reprints (which bypass the queue).
  800. For queue-based prints, use the queue item's created_by field instead.
  801. """
  802. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  803. printer = result.scalar_one_or_none()
  804. if not printer:
  805. raise HTTPException(404, "Printer not found")
  806. user_info = printer_manager.get_current_print_user(printer_id)
  807. return user_info or {}
  808. @router.post("/{printer_id}/refresh-status")
  809. async def refresh_printer_status(
  810. printer_id: int,
  811. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  812. db: AsyncSession = Depends(get_db),
  813. ):
  814. """Request a full status refresh from the printer (sends pushall command)."""
  815. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  816. printer = result.scalar_one_or_none()
  817. if not printer:
  818. raise HTTPException(404, "Printer not found")
  819. success = printer_manager.request_status_update(printer_id)
  820. if not success:
  821. raise HTTPException(400, "Printer not connected")
  822. return {"status": "refresh_requested"}
  823. @router.post("/{printer_id}/connect")
  824. async def connect_printer(
  825. printer_id: int,
  826. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  827. db: AsyncSession = Depends(get_db),
  828. ):
  829. """Manually connect to a printer."""
  830. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  831. printer = result.scalar_one_or_none()
  832. if not printer:
  833. raise HTTPException(404, "Printer not found")
  834. success = await printer_manager.connect_printer(printer)
  835. return {"connected": success}
  836. @router.post("/{printer_id}/disconnect")
  837. async def disconnect_printer(
  838. printer_id: int,
  839. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  840. db: AsyncSession = Depends(get_db),
  841. ):
  842. """Manually disconnect from a printer."""
  843. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  844. printer = result.scalar_one_or_none()
  845. if not printer:
  846. raise HTTPException(404, "Printer not found")
  847. printer_manager.disconnect_printer(printer_id)
  848. return {"connected": False}
  849. @router.post("/test")
  850. async def test_printer_connection(
  851. ip_address: str,
  852. serial_number: str,
  853. access_code: str,
  854. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  855. ):
  856. """Test connection to a printer without saving."""
  857. result = await printer_manager.test_connection(
  858. ip_address=ip_address,
  859. serial_number=serial_number,
  860. access_code=access_code,
  861. )
  862. return result
  863. @router.post("/diagnostic", response_model=PrinterDiagnosticResult)
  864. async def diagnose_connection(
  865. req: DiagnosticRequest,
  866. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  867. ):
  868. """Run connection diagnostics for the Add-Printer flow (printer not yet saved).
  869. When serial_number + access_code are supplied the MQTT credential check
  870. also runs; otherwise only the network-level checks are performed.
  871. """
  872. return await run_connection_diagnostic(
  873. req.ip_address,
  874. serial_number=req.serial_number or None,
  875. access_code=req.access_code or None,
  876. )
  877. @router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
  878. async def diagnose_printer(
  879. printer_id: int,
  880. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  881. db: AsyncSession = Depends(get_db),
  882. ):
  883. """Run connection diagnostics for an existing saved printer.
  884. On-demand run from the UI: wait up to PUBLISH_WAIT_DEFAULT seconds for the
  885. printer to publish a status report so a fresh reconnect (counter reset to
  886. 0) isn't reported as `printer_publishing: fail` prematurely. The support
  887. package code path calls run_connection_diagnostic without the wait so
  888. bundling stays fast.
  889. """
  890. from backend.app.services.printer_diagnostic import PUBLISH_WAIT_DEFAULT
  891. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  892. printer = result.scalar_one_or_none()
  893. if not printer:
  894. raise HTTPException(404, "Printer not found")
  895. return await run_connection_diagnostic(
  896. printer.ip_address,
  897. printer=printer,
  898. wait_for_publish_seconds=PUBLISH_WAIT_DEFAULT,
  899. )
  900. # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
  901. # Cleared on every print start by main.py::on_print_start, so re-dispatches with
  902. # different plates always fetch a fresh thumbnail without needing plate in the key.
  903. _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
  904. # Negative cache (#1420): when a cover lookup exhausts every FTP path with 550
  905. # (file sliced on SD card, not on printer storage), remember the failure so the
  906. # next request short-circuits to 404 instead of re-hammering FTP 8 paths deep.
  907. # Cleared on print start alongside _cover_cache.
  908. _cover_404_cache: dict[int, set[tuple[str, str]]] = {}
  909. # In-flight cover downloads, keyed by (printer_id, subtask_name, view_key) (#2572).
  910. # The farm dashboard mounts a cover tile per printer card, so several browsers
  911. # request the same printer's cover in the same instant, all miss the cache, and
  912. # each runs the full multi-path FTP lookup + 3MF extraction (one observed live
  913. # transfer pulled an 81 MB 3MF while real print uploads were in flight). The
  914. # first request to miss becomes the leader; concurrent requests await its future
  915. # and then serve from the positive/negative cache it filled.
  916. _cover_inflight: dict[tuple[int, str, str], asyncio.Future] = {}
  917. def clear_cover_cache(printer_id: int) -> None:
  918. """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
  919. _cover_cache.pop(printer_id, None)
  920. _cover_404_cache.pop(printer_id, None)
  921. @router.get("/{printer_id}/cover")
  922. async def get_printer_cover(
  923. printer_id: int,
  924. view: str | None = None,
  925. _: None = RequireCameraStreamTokenIfAuthEnabled,
  926. ):
  927. """Get the cover image for the current print job.
  928. Args:
  929. view: Optional view type. Use "top" for the top-down build plate view or
  930. "pick" for the slicer's object-ID mask used by skip objects.
  931. Default returns angled 3D perspective view.
  932. """
  933. # Fetch the printer in a short-lived session and release the pooled DB
  934. # connection BEFORE the FTP download below. Previously this route took its
  935. # row via Depends(get_db), whose session stays open for the whole request —
  936. # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes
  937. # under FTP contention) pinned one pooled connection idle-in-transaction the
  938. # entire time (issue #2572). db is used only for this one SELECT; everything
  939. # after reads already-loaded printer.* scalars (expire_on_commit=False keeps
  940. # them readable), printer_manager, and FTP/zip — no lazy loads.
  941. #
  942. # Reference async_session via the module so the maker is looked up at call
  943. # time — keeps it in sync with reinitialize_database() and lets the test
  944. # harness's patch of backend.app.core.database.async_session take effect.
  945. async with database.async_session() as db:
  946. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  947. printer = result.scalar_one_or_none()
  948. if not printer:
  949. raise HTTPException(404, "Printer not found")
  950. state = printer_manager.get_status(printer_id)
  951. if not state:
  952. raise HTTPException(404, "Printer not connected")
  953. # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
  954. subtask_name = state.subtask_name
  955. if not subtask_name:
  956. raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
  957. # Resolve the active plate. Precedence (#1166):
  958. # 1. The plate Bambuddy dispatched (authoritative when we sent the print)
  959. # 2. plate_(\d+)\.gcode regex on state.gcode_file (works on firmware that
  960. # reflects the full path, e.g. some X1C builds)
  961. # 3. Scan the downloaded 3MF for a unique Metadata/plate_*.gcode (covers
  962. # per-plate archives sliced separately in Bambu Studio, where the
  963. # printer's gcode_file echo is just the .3mf filename)
  964. # 4. Fall back to plate 1
  965. # The 3MF-scan fallback runs later — after the file is on disk.
  966. plate_num = resolve_plate_id(state)
  967. if plate_num is not None:
  968. logger.info("Cover: resolved plate %s before download (subtask=%s)", plate_num, subtask_name)
  969. # Normalize view parameter
  970. view_key = view or "default"
  971. # Check cache. Cache by (subtask_name, view_key) only — clear_cover_cache()
  972. # runs on every print start, so a re-dispatch with a different plate gets
  973. # a fresh image regardless. Pre-#1166 the key included plate_num, but with
  974. # late plate resolution the cache check would always miss.
  975. cache_key = (subtask_name, view_key)
  976. if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
  977. return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
  978. # Negative-cache short-circuit (#1420): if a prior lookup for this same
  979. # subtask + view already failed, don't replay 8 FTP retries on every page
  980. # refresh. _cover_404_cache is cleared on print start.
  981. if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
  982. raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
  983. # Coalesce concurrent downloads for the same cover (#2572). The positive and
  984. # negative caches were just checked above; if another request is already
  985. # downloading this exact cover, wait for it and serve from the cache it fills
  986. # instead of launching a duplicate multi-path FTP + 3MF extraction.
  987. inflight_key = (printer_id, subtask_name, view_key)
  988. leader = _cover_inflight.get(inflight_key)
  989. if leader is not None:
  990. # shield() so our own cancellation can't cancel the shared leader.
  991. try:
  992. await asyncio.shield(leader)
  993. except Exception:
  994. pass
  995. if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
  996. return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
  997. if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
  998. raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
  999. # Leader finished without filling either cache (a transient 503) — fall
  1000. # through and try the download ourselves.
  1001. fut: asyncio.Future = asyncio.get_event_loop().create_future()
  1002. _cover_inflight[inflight_key] = fut
  1003. try:
  1004. image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key)
  1005. return Response(content=image_data, media_type="image/png")
  1006. finally:
  1007. if not fut.done():
  1008. fut.set_result(None)
  1009. _cover_inflight.pop(inflight_key, None)
  1010. async def _produce_cover_image(
  1011. printer: Printer,
  1012. printer_id: int,
  1013. subtask_name: str,
  1014. view: str | None,
  1015. view_key: str,
  1016. plate_num: int | None,
  1017. cache_key: tuple[str, str],
  1018. ) -> bytes:
  1019. """Download the active-print 3MF and extract its cover thumbnail (#2572).
  1020. Split out of ``get_printer_cover`` so concurrent requests for the same cover
  1021. can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
  1022. on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
  1023. failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
  1024. work — the caller already released the pooled connection before this runs.
  1025. """
  1026. # Build possible 3MF filenames from subtask_name
  1027. # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
  1028. # or just "name.3mf" (uploaded directly)
  1029. possible_filenames = []
  1030. if subtask_name.endswith(".3mf"):
  1031. possible_filenames.append(subtask_name)
  1032. else:
  1033. # Try both naming patterns
  1034. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  1035. possible_filenames.append(f"{subtask_name}.3mf")
  1036. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  1037. if " " in subtask_name:
  1038. normalized = subtask_name.replace(" ", "_")
  1039. if normalized.endswith(".3mf"):
  1040. possible_filenames.append(normalized)
  1041. else:
  1042. possible_filenames.append(f"{normalized}.gcode.3mf")
  1043. possible_filenames.append(f"{normalized}.3mf")
  1044. # Build list of all remote paths to try
  1045. remote_paths = []
  1046. for filename in possible_filenames:
  1047. remote_paths.extend(
  1048. [
  1049. f"/{filename}", # Root directory (most common)
  1050. f"/cache/{filename}",
  1051. f"/model/{filename}",
  1052. f"/data/{filename}",
  1053. ]
  1054. )
  1055. # Use first filename for temp path (will be reused)
  1056. temp_filename = possible_filenames[0]
  1057. temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
  1058. temp_path.parent.mkdir(parents=True, exist_ok=True)
  1059. # Cache check (#972): the archive-metadata flow in main.py may have already
  1060. # downloaded this 3MF during the print-start handler. Reusing that file
  1061. # avoids a second 36MB transfer competing with the printer's single FTP
  1062. # socket (which produces the 425 errors that feed the retry storm).
  1063. downloaded = False
  1064. using_cached = False
  1065. for candidate_name in possible_filenames:
  1066. cached = get_cached_3mf(printer_id, candidate_name)
  1067. if cached:
  1068. logger.info("Cover using cached 3MF from %s (avoided duplicate FTP)", cached)
  1069. temp_path = cached
  1070. downloaded = True
  1071. using_cached = True
  1072. break
  1073. if not downloaded:
  1074. logger.info(
  1075. f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
  1076. )
  1077. # Retry logic for transient FTP failures
  1078. max_retries = 2
  1079. last_error = None
  1080. for attempt in range(max_retries + 1):
  1081. try:
  1082. downloaded = await download_file_try_paths_async(
  1083. printer.ip_address,
  1084. printer.access_code,
  1085. remote_paths,
  1086. temp_path,
  1087. printer_model=printer.model,
  1088. )
  1089. if downloaded:
  1090. break
  1091. except Exception as e:
  1092. last_error = e
  1093. if attempt < max_retries:
  1094. logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
  1095. await asyncio.sleep(0.5 * (attempt + 1)) # Brief backoff
  1096. else:
  1097. logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
  1098. if last_error and not downloaded:
  1099. raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
  1100. if not downloaded:
  1101. # Remember this failure so subsequent requests for the same print
  1102. # skip the 8-path FTP fan-out (#1420).
  1103. _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
  1104. raise HTTPException(
  1105. 404,
  1106. f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
  1107. )
  1108. # Share the fresh download with the archive flow.
  1109. cache_3mf_download(printer_id, temp_filename, temp_path)
  1110. # Verify file actually exists and has content
  1111. if not temp_path.exists():
  1112. raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
  1113. file_size = temp_path.stat().st_size
  1114. logger.info("Downloaded file size: %s bytes", file_size)
  1115. if file_size == 0:
  1116. if not using_cached:
  1117. temp_path.unlink()
  1118. raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'")
  1119. try:
  1120. # Extract thumbnail from 3MF (which is a ZIP file)
  1121. try:
  1122. zf = zipfile.ZipFile(temp_path, "r")
  1123. except zipfile.BadZipFile:
  1124. raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
  1125. except OSError as e:
  1126. logger.error("Failed to open 3MF file: %s", e, exc_info=True)
  1127. raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
  1128. try:
  1129. # 3MF-scan fallback for plate detection (#1166). Per-plate archives
  1130. # sliced separately in Bambu Studio contain a single
  1131. # Metadata/plate_N.gcode for the active plate, even though
  1132. # thumbnails for all plates are bundled. Using that gcode's plate
  1133. # number prevents falling back to plate_1.png.
  1134. if plate_num is None:
  1135. plate_gcodes = [name for name in zf.namelist() if re.match(r"^Metadata/plate_\d+\.gcode$", name)]
  1136. if len(plate_gcodes) == 1:
  1137. match = re.search(r"plate_(\d+)\.gcode", plate_gcodes[0])
  1138. if match:
  1139. plate_num = int(match.group(1))
  1140. logger.info("Cover: detected plate %s from 3MF contents", plate_num)
  1141. if plate_num is None:
  1142. plate_num = 1
  1143. # Try common thumbnail paths in 3MF files
  1144. # Use plate_num to get the correct plate's thumbnail for multi-plate projects
  1145. # Use top-down view if requested (better for skip objects modal)
  1146. if view == "pick":
  1147. # Only the active plate's mask, with no fallback: every other view
  1148. # falls back to plate 1 because a slightly wrong picture is better
  1149. # than none, but a mask is coordinates, not decoration. Plate 1's
  1150. # mask over plate 3's layout would resolve clicks to whichever
  1151. # object happened to occupy that pixel on a different plate.
  1152. thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
  1153. elif view == "top":
  1154. thumbnail_paths = [
  1155. f"Metadata/top_{plate_num}.png",
  1156. # Fall back to plate 1 if specific plate not found
  1157. "Metadata/top_1.png",
  1158. f"Metadata/plate_{plate_num}.png",
  1159. "Metadata/plate_1.png",
  1160. "Metadata/thumbnail.png",
  1161. ]
  1162. else:
  1163. thumbnail_paths = [
  1164. f"Metadata/plate_{plate_num}.png",
  1165. # Fall back to plate 1 if specific plate not found
  1166. "Metadata/plate_1.png",
  1167. "Metadata/thumbnail.png",
  1168. f"Metadata/plate_{plate_num}_small.png",
  1169. "Metadata/plate_1_small.png",
  1170. "Thumbnails/thumbnail.png",
  1171. "thumbnail.png",
  1172. ]
  1173. for thumb_path in thumbnail_paths:
  1174. try:
  1175. image_data = zf.read(thumb_path)
  1176. if printer_id not in _cover_cache:
  1177. _cover_cache[printer_id] = {}
  1178. _cover_cache[printer_id][(subtask_name, view_key)] = image_data
  1179. return image_data
  1180. except KeyError:
  1181. continue
  1182. # If no specific thumbnail found, try any PNG in Metadata. Never for
  1183. # "pick": handing back a rendered thumbnail in place of the object-ID
  1184. # mask is worse than nothing, because the caller can't tell the
  1185. # difference and decodes the render's pixel colours as object IDs —
  1186. # dark pixels yield small integers that collide with real IDs, so a
  1187. # click would select an arbitrary object and skip it irreversibly.
  1188. # A 404 is what tells the UI to fall back to the checklist.
  1189. if view != "pick":
  1190. for name in zf.namelist():
  1191. if name.startswith("Metadata/") and name.endswith(".png"):
  1192. image_data = zf.read(name)
  1193. if printer_id not in _cover_cache:
  1194. _cover_cache[printer_id] = {}
  1195. _cover_cache[printer_id][(subtask_name, view_key)] = image_data
  1196. return image_data
  1197. _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
  1198. raise HTTPException(404, "No thumbnail found in 3MF file")
  1199. finally:
  1200. zf.close()
  1201. finally:
  1202. # Only delete when this invocation owns the file. A cached path is
  1203. # shared with the archive flow — removing it would force a refetch
  1204. # the next time either flow needs the 3MF.
  1205. if not using_cached and temp_path.exists():
  1206. temp_path.unlink()
  1207. # ============================================
  1208. # File Manager Endpoints
  1209. # ============================================
  1210. async def _load_printer_or_404(printer_id: int) -> Printer:
  1211. """Load a printer in a short-lived session, releasing the pooled DB
  1212. connection before the caller starts any FTP/network I/O (#2572).
  1213. The file-manager and storage routes talk FTP to the printer, which can
  1214. block for the full socket timeout — longer when a saturated FTP pool backs
  1215. up. Holding the request's Depends(get_db) session across that FTP pinned one
  1216. pooled connection idle-in-transaction per in-flight request, a top cause of
  1217. pool exhaustion on large farms. The returned row's scalar columns stay
  1218. readable after the session closes (expire_on_commit=False). Raises 404 when
  1219. the printer doesn't exist.
  1220. Reference async_session via the module so the maker is resolved at call time
  1221. — keeps it in sync with reinitialize_database() and lets tests patch it.
  1222. """
  1223. async with database.async_session() as db:
  1224. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1225. printer = result.scalar_one_or_none()
  1226. if not printer:
  1227. raise HTTPException(404, "Printer not found")
  1228. return printer
  1229. @router.get("/{printer_id}/files")
  1230. async def list_printer_files(
  1231. printer_id: int,
  1232. path: str = "/",
  1233. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1234. ):
  1235. """List files on the printer at the specified path."""
  1236. printer = await _load_printer_or_404(printer_id)
  1237. files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1238. # Add full path to each file
  1239. for f in files:
  1240. f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
  1241. return {
  1242. "path": path,
  1243. "files": files,
  1244. }
  1245. @router.get("/{printer_id}/files/download")
  1246. async def download_printer_file(
  1247. printer_id: int,
  1248. path: str,
  1249. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1250. ):
  1251. """Download a file from the printer."""
  1252. printer = await _load_printer_or_404(printer_id)
  1253. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1254. if data is None:
  1255. raise HTTPException(404, f"File not found: {path}")
  1256. # Determine content type based on extension
  1257. filename = path.split("/")[-1]
  1258. ext = filename.lower().split(".")[-1] if "." in filename else ""
  1259. content_types = {
  1260. "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  1261. "gcode": "text/plain",
  1262. "mp4": "video/mp4",
  1263. "avi": "video/x-msvideo",
  1264. "png": "image/png",
  1265. "jpg": "image/jpeg",
  1266. "jpeg": "image/jpeg",
  1267. "json": "application/json",
  1268. "txt": "text/plain",
  1269. }
  1270. content_type = content_types.get(ext, "application/octet-stream")
  1271. return Response(
  1272. content=data,
  1273. media_type=content_type,
  1274. headers={"Content-Disposition": build_content_disposition(filename)},
  1275. )
  1276. @router.get("/{printer_id}/files/gcode")
  1277. async def get_printer_file_gcode(
  1278. printer_id: int,
  1279. path: str,
  1280. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1281. ):
  1282. """Get gcode for a file stored on a printer (for preview)."""
  1283. import io
  1284. printer = await _load_printer_or_404(printer_id)
  1285. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1286. if data is None:
  1287. raise HTTPException(404, f"File not found: {path}")
  1288. filename = path.split("/")[-1]
  1289. lower = filename.lower()
  1290. if lower.endswith(".gcode"):
  1291. return Response(content=data, media_type="text/plain")
  1292. if lower.endswith(".3mf"):
  1293. try:
  1294. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  1295. gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
  1296. if not gcode_files:
  1297. raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
  1298. gcode_content = zf.read(gcode_files[0])
  1299. return Response(content=gcode_content, media_type="text/plain")
  1300. except zipfile.BadZipFile:
  1301. raise HTTPException(status_code=400, detail="Invalid 3MF file")
  1302. raise HTTPException(status_code=400, detail="Unsupported file type")
  1303. @router.get("/{printer_id}/files/plates")
  1304. async def get_printer_file_plates(
  1305. printer_id: int,
  1306. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  1307. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1308. ):
  1309. """Get available plates from a multi-plate 3MF file stored on a printer."""
  1310. import io
  1311. import json
  1312. import defusedxml.ElementTree as ET
  1313. printer = await _load_printer_or_404(printer_id)
  1314. filename = path.split("/")[-1]
  1315. if not filename.lower().endswith(".3mf"):
  1316. return {
  1317. "printer_id": printer_id,
  1318. "path": path,
  1319. "filename": filename,
  1320. "plates": [],
  1321. "is_multi_plate": False,
  1322. }
  1323. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1324. if data is None:
  1325. raise HTTPException(404, f"File not found: {path}")
  1326. plates = []
  1327. try:
  1328. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  1329. namelist = zf.namelist()
  1330. # Find all plate gcode files to determine available plates
  1331. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  1332. # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
  1333. plate_indices: list[int] = []
  1334. if gcode_files:
  1335. for gf in gcode_files:
  1336. try:
  1337. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  1338. plate_indices.append(int(plate_str))
  1339. except ValueError:
  1340. pass # Skip gcode files with non-numeric plate indices
  1341. else:
  1342. plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
  1343. plate_png_files = [
  1344. n
  1345. for n in namelist
  1346. if n.startswith("Metadata/plate_")
  1347. and n.endswith(".png")
  1348. and "_small" not in n
  1349. and "no_light" not in n
  1350. ]
  1351. plate_name_candidates = plate_json_files + plate_png_files
  1352. plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
  1353. seen_indices: set[int] = set()
  1354. for name in plate_name_candidates:
  1355. match = plate_re.match(name)
  1356. if match:
  1357. try:
  1358. index = int(match.group(1))
  1359. except ValueError:
  1360. continue
  1361. if index in seen_indices:
  1362. continue
  1363. seen_indices.add(index)
  1364. plate_indices.append(index)
  1365. if not plate_indices:
  1366. return {
  1367. "printer_id": printer_id,
  1368. "path": path,
  1369. "filename": filename,
  1370. "plates": [],
  1371. "is_multi_plate": False,
  1372. }
  1373. plate_indices.sort()
  1374. # Parse model_settings.config for plate names
  1375. plate_names = {}
  1376. if "Metadata/model_settings.config" in namelist:
  1377. try:
  1378. model_content = zf.read("Metadata/model_settings.config").decode()
  1379. model_root = ET.fromstring(model_content)
  1380. for plate_elem in model_root.findall(".//plate"):
  1381. plater_id = None
  1382. plater_name = None
  1383. for meta in plate_elem.findall("metadata"):
  1384. key = meta.get("key")
  1385. value = meta.get("value")
  1386. if key == "plater_id" and value:
  1387. try:
  1388. plater_id = int(value)
  1389. except ValueError:
  1390. pass # Skip plate with unparseable ID
  1391. elif key == "plater_name" and value:
  1392. plater_name = value.strip()
  1393. if plater_id is not None and plater_name:
  1394. plate_names[plater_id] = plater_name
  1395. except Exception:
  1396. pass # Plate names are optional; continue without them
  1397. # Parse slice_info.config for plate metadata
  1398. plate_metadata = {}
  1399. if "Metadata/slice_info.config" in namelist:
  1400. content = zf.read("Metadata/slice_info.config").decode()
  1401. root = ET.fromstring(content)
  1402. for plate_elem in root.findall(".//plate"):
  1403. plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
  1404. plate_index = None
  1405. for meta in plate_elem.findall("metadata"):
  1406. key = meta.get("key")
  1407. value = meta.get("value")
  1408. if key == "index" and value:
  1409. try:
  1410. plate_index = int(value)
  1411. except ValueError:
  1412. pass # Skip plate with unparseable index
  1413. elif key == "prediction" and value:
  1414. try:
  1415. plate_info["prediction"] = int(value)
  1416. except ValueError:
  1417. pass # Skip unparseable prediction; leave as None
  1418. elif key == "weight" and value:
  1419. try:
  1420. plate_info["weight"] = float(value)
  1421. except ValueError:
  1422. pass # Skip unparseable weight; leave as None
  1423. # Get filaments used in this plate
  1424. for filament_elem in plate_elem.findall("filament"):
  1425. filament_id = filament_elem.get("id")
  1426. filament_type = filament_elem.get("type", "")
  1427. filament_color = filament_elem.get("color", "")
  1428. used_g = filament_elem.get("used_g", "0")
  1429. used_m = filament_elem.get("used_m", "0")
  1430. try:
  1431. used_grams = float(used_g)
  1432. except (ValueError, TypeError):
  1433. used_grams = 0
  1434. if used_grams > 0 and filament_id:
  1435. plate_info["filaments"].append(
  1436. {
  1437. "slot_id": int(filament_id),
  1438. "type": filament_type,
  1439. "color": filament_color,
  1440. "used_grams": round(used_grams, 1),
  1441. "used_meters": float(used_m) if used_m else 0,
  1442. }
  1443. )
  1444. plate_info["filaments"].sort(key=lambda x: x["slot_id"])
  1445. # Collect object names
  1446. for obj_elem in plate_elem.findall("object"):
  1447. obj_name = obj_elem.get("name")
  1448. if obj_name and obj_name not in plate_info["objects"]:
  1449. plate_info["objects"].append(obj_name)
  1450. # Set plate name
  1451. if plate_index is not None:
  1452. custom_name = plate_names.get(plate_index)
  1453. if custom_name:
  1454. plate_info["name"] = custom_name
  1455. elif plate_info["objects"]:
  1456. plate_info["name"] = plate_info["objects"][0]
  1457. plate_metadata[plate_index] = plate_info
  1458. # Parse plate_*.json for object lists when slice_info is missing
  1459. plate_json_objects: dict[int, list[str]] = {}
  1460. for name in namelist:
  1461. match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
  1462. if not match:
  1463. continue
  1464. try:
  1465. plate_index = int(match.group(1))
  1466. except ValueError:
  1467. continue
  1468. try:
  1469. payload = json.loads(zf.read(name).decode())
  1470. bbox_objects = payload.get("bbox_objects", [])
  1471. names: list[str] = []
  1472. for obj in bbox_objects:
  1473. obj_name = obj.get("name") if isinstance(obj, dict) else None
  1474. if obj_name and obj_name not in names:
  1475. names.append(obj_name)
  1476. if names:
  1477. plate_json_objects[plate_index] = names
  1478. except Exception:
  1479. continue
  1480. # Build plate list
  1481. for idx in plate_indices:
  1482. meta = plate_metadata.get(idx, {})
  1483. has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
  1484. objects = meta.get("objects", [])
  1485. if not objects:
  1486. objects = plate_json_objects.get(idx, [])
  1487. plate_name = meta.get("name")
  1488. if not plate_name:
  1489. plate_name = plate_names.get(idx)
  1490. if not plate_name and objects:
  1491. plate_name = objects[0]
  1492. plates.append(
  1493. {
  1494. "index": idx,
  1495. "name": plate_name,
  1496. "objects": objects,
  1497. "object_count": len(objects),
  1498. "has_thumbnail": has_thumbnail,
  1499. "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
  1500. "print_time_seconds": meta.get("prediction"),
  1501. "filament_used_grams": meta.get("weight"),
  1502. "filaments": meta.get("filaments", []),
  1503. }
  1504. )
  1505. except Exception as e:
  1506. logger.warning("Failed to parse plates from printer file %s: %s", path, e)
  1507. return {
  1508. "printer_id": printer_id,
  1509. "path": path,
  1510. "filename": filename,
  1511. "plates": plates,
  1512. "is_multi_plate": len(plates) > 1,
  1513. }
  1514. @router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
  1515. async def get_printer_file_plate_thumbnail(
  1516. printer_id: int,
  1517. plate_index: int,
  1518. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  1519. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1520. ):
  1521. """Get a plate thumbnail image from a printer-stored 3MF file."""
  1522. import io
  1523. printer = await _load_printer_or_404(printer_id)
  1524. data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1525. if data is None:
  1526. raise HTTPException(404, f"File not found: {path}")
  1527. try:
  1528. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  1529. thumb_path = f"Metadata/plate_{plate_index}.png"
  1530. if thumb_path in zf.namelist():
  1531. image_data = zf.read(thumb_path)
  1532. return Response(content=image_data, media_type="image/png")
  1533. except Exception:
  1534. pass # Corrupt or unreadable 3MF; fall through to 404
  1535. raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
  1536. @router.post("/{printer_id}/files/download-zip")
  1537. async def download_printer_files_as_zip(
  1538. printer_id: int,
  1539. request: dict,
  1540. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1541. ):
  1542. """Download multiple files from the printer as a ZIP archive."""
  1543. import io
  1544. paths = request.get("paths", [])
  1545. if not paths:
  1546. raise HTTPException(400, "No files specified")
  1547. printer = await _load_printer_or_404(printer_id)
  1548. # Create ZIP in memory
  1549. zip_buffer = io.BytesIO()
  1550. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1551. for path in paths:
  1552. try:
  1553. data = await download_file_bytes_async(
  1554. printer.ip_address, printer.access_code, path, printer_model=printer.model
  1555. )
  1556. if data:
  1557. filename = path.split("/")[-1]
  1558. zf.writestr(filename, data)
  1559. except Exception as e:
  1560. logging.warning("Failed to add %s to ZIP: %s", path, e)
  1561. continue
  1562. zip_buffer.seek(0)
  1563. zip_data = zip_buffer.read()
  1564. if len(zip_data) == 0:
  1565. raise HTTPException(404, "No files could be downloaded")
  1566. return Response(
  1567. content=zip_data,
  1568. media_type="application/zip",
  1569. headers={"Content-Disposition": 'attachment; filename="printer-files.zip"'},
  1570. )
  1571. @router.delete("/{printer_id}/files")
  1572. async def delete_printer_file(
  1573. printer_id: int,
  1574. path: str,
  1575. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1576. ):
  1577. """Delete a file from the printer."""
  1578. printer = await _load_printer_or_404(printer_id)
  1579. from backend.app.services.bambu_ftp import DeleteResult
  1580. result = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1581. if result == DeleteResult.NOT_FOUND:
  1582. raise HTTPException(404, f"File not found on printer: {path}")
  1583. if result == DeleteResult.FAILED:
  1584. raise HTTPException(500, f"Failed to delete file: {path}")
  1585. return {"status": "deleted", "path": path}
  1586. @router.get("/{printer_id}/storage")
  1587. async def get_printer_storage(
  1588. printer_id: int,
  1589. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1590. ):
  1591. """Get storage information from the printer."""
  1592. printer = await _load_printer_or_404(printer_id)
  1593. storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
  1594. return storage_info or {"used_bytes": None, "free_bytes": None}
  1595. # ============================================
  1596. # MQTT Debug Logging Endpoints
  1597. # ============================================
  1598. @router.post("/{printer_id}/logging/enable")
  1599. async def enable_mqtt_logging(
  1600. printer_id: int,
  1601. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1602. db: AsyncSession = Depends(get_db),
  1603. ):
  1604. """Enable MQTT message logging for a printer."""
  1605. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1606. printer = result.scalar_one_or_none()
  1607. if not printer:
  1608. raise HTTPException(404, "Printer not found")
  1609. success = printer_manager.enable_logging(printer_id, True)
  1610. if not success:
  1611. raise HTTPException(400, "Printer not connected")
  1612. return {"logging_enabled": True}
  1613. @router.post("/{printer_id}/logging/disable")
  1614. async def disable_mqtt_logging(
  1615. printer_id: int,
  1616. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1617. db: AsyncSession = Depends(get_db),
  1618. ):
  1619. """Disable MQTT message logging for a printer."""
  1620. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1621. printer = result.scalar_one_or_none()
  1622. if not printer:
  1623. raise HTTPException(404, "Printer not found")
  1624. success = printer_manager.enable_logging(printer_id, False)
  1625. if not success:
  1626. raise HTTPException(400, "Printer not connected")
  1627. return {"logging_enabled": False}
  1628. @router.get("/{printer_id}/logging")
  1629. async def get_mqtt_logs(
  1630. printer_id: int,
  1631. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1632. db: AsyncSession = Depends(get_db),
  1633. ):
  1634. """Get MQTT message logs for a printer."""
  1635. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1636. printer = result.scalar_one_or_none()
  1637. if not printer:
  1638. raise HTTPException(404, "Printer not found")
  1639. logs = printer_manager.get_logs(printer_id)
  1640. return {
  1641. "logging_enabled": printer_manager.is_logging_enabled(printer_id),
  1642. "logs": [
  1643. {
  1644. "timestamp": log.timestamp,
  1645. "topic": log.topic,
  1646. "direction": log.direction,
  1647. "payload": log.payload,
  1648. }
  1649. for log in logs
  1650. ],
  1651. }
  1652. @router.delete("/{printer_id}/logging")
  1653. async def clear_mqtt_logs(
  1654. printer_id: int,
  1655. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1656. db: AsyncSession = Depends(get_db),
  1657. ):
  1658. """Clear MQTT message logs for a printer."""
  1659. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1660. printer = result.scalar_one_or_none()
  1661. if not printer:
  1662. raise HTTPException(404, "Printer not found")
  1663. printer_manager.clear_logs(printer_id)
  1664. return {"status": "cleared"}
  1665. # ============================================
  1666. # AMS Drying Endpoints
  1667. # ============================================
  1668. # The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
  1669. # — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
  1670. # (#2533). Refuse the command rather than let the caller believe it landed.
  1671. _DRYING_SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
  1672. @router.post("/{printer_id}/drying/start")
  1673. async def start_drying(
  1674. printer_id: int,
  1675. ams_id: int,
  1676. temp: int = 45,
  1677. duration: int = 4,
  1678. filament: str = "",
  1679. rotate_tray: bool = False,
  1680. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1681. db: AsyncSession = Depends(get_db),
  1682. ):
  1683. """Send AMS drying start command. temp=45-85, duration=hours."""
  1684. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1685. printer = result.scalar_one_or_none()
  1686. if not printer:
  1687. raise HTTPException(404, "Printer not found")
  1688. # Server-side guard: reject if this model/firmware doesn't support drying
  1689. live_state = printer_manager.get_status(printer_id)
  1690. firmware = live_state.firmware_version if live_state else None
  1691. if drying_screen_only(printer.model):
  1692. raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
  1693. if not supports_drying(printer.model, firmware):
  1694. raise HTTPException(400, "Drying not supported for this printer model or firmware version")
  1695. if temp < 45 or temp > 85:
  1696. raise HTTPException(400, "Temperature must be 45-85°C")
  1697. if duration < 1 or duration > 24:
  1698. raise HTTPException(400, "Duration must be 1-24 hours")
  1699. # Inspect the live AMS unit: surface blocking dry_sf_reasons (otherwise the
  1700. # firmware silently ignores the command — #971) and backfill an empty
  1701. # filament field from the first loaded tray so the printer doesn't reject
  1702. # the payload.
  1703. target_ams: dict | None = None
  1704. for unit in (live_state.raw_data.get("ams") if live_state else None) or []:
  1705. try:
  1706. if int(unit.get("id", -1)) == ams_id:
  1707. target_ams = unit
  1708. break
  1709. except (TypeError, ValueError):
  1710. continue
  1711. if target_ams is not None:
  1712. reason_messages = {
  1713. 0: "Printer is busy",
  1714. 1: "Insufficient power — too many AMS drying or external PSU required",
  1715. 2: "AMS is busy",
  1716. 3: "Filament is at the AMS outlet — retract it first",
  1717. 4: "AMS is already starting a drying cycle",
  1718. 5: "Not supported in 2D mode",
  1719. 6: "AMS is already drying",
  1720. 7: "AMS firmware is upgrading",
  1721. 8: "Plug in the external AMS power adapter to start drying",
  1722. }
  1723. for code in target_ams.get("dry_sf_reason") or []:
  1724. try:
  1725. code_int = int(code)
  1726. except (TypeError, ValueError):
  1727. continue
  1728. if code_int in reason_messages:
  1729. raise HTTPException(409, reason_messages[code_int])
  1730. if not filament:
  1731. for tray in target_ams.get("tray") or []:
  1732. tray_type = tray.get("tray_type")
  1733. if tray_type:
  1734. filament = str(tray_type)
  1735. break
  1736. if not filament:
  1737. filament = "PLA"
  1738. success = printer_manager.send_drying_command(
  1739. printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray
  1740. )
  1741. if not success:
  1742. raise HTTPException(400, "Printer not connected")
  1743. return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
  1744. @router.post("/{printer_id}/drying/stop")
  1745. async def stop_drying(
  1746. printer_id: int,
  1747. ams_id: int,
  1748. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1749. db: AsyncSession = Depends(get_db),
  1750. ):
  1751. """Send AMS drying stop command."""
  1752. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1753. printer = result.scalar_one_or_none()
  1754. if not printer:
  1755. raise HTTPException(404, "Printer not found")
  1756. # Screen-only models ignore stop just as they ignore start — a cycle running on a
  1757. # P1S was started at the printer and has to be ended there too (#2533).
  1758. if drying_screen_only(printer.model):
  1759. raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
  1760. success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
  1761. if not success:
  1762. raise HTTPException(400, "Printer not connected")
  1763. return {"status": "drying_stopped", "ams_id": ams_id}
  1764. # ============================================
  1765. # Print Options (AI Detection) Endpoints
  1766. # ============================================
  1767. @router.post("/{printer_id}/print-options")
  1768. async def set_print_option(
  1769. printer_id: int,
  1770. module_name: str,
  1771. enabled: bool,
  1772. print_halt: bool = True,
  1773. sensitivity: str = "medium",
  1774. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1775. db: AsyncSession = Depends(get_db),
  1776. ):
  1777. """Set an AI detection / print option on the printer.
  1778. Valid module_name values:
  1779. - spaghetti_detector: Spaghetti detection
  1780. - first_layer_inspector: First layer inspection
  1781. - printing_monitor: AI print quality monitoring
  1782. - buildplate_marker_detector: Build plate marker detection
  1783. - allow_skip_parts: Allow skipping failed parts
  1784. """
  1785. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1786. printer = result.scalar_one_or_none()
  1787. if not printer:
  1788. raise HTTPException(404, "Printer not found")
  1789. client = printer_manager.get_client(printer_id)
  1790. if not client or not client.state.connected:
  1791. raise HTTPException(400, "Printer not connected")
  1792. # Validate module_name
  1793. valid_modules = [
  1794. "spaghetti_detector",
  1795. "first_layer_inspector",
  1796. "printing_monitor",
  1797. "buildplate_marker_detector",
  1798. "allow_skip_parts",
  1799. "pileup_detector",
  1800. "clump_detector",
  1801. "airprint_detector",
  1802. "auto_recovery_step_loss",
  1803. ]
  1804. if module_name not in valid_modules:
  1805. raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
  1806. # Validate sensitivity
  1807. valid_sensitivities = ["low", "medium", "high", "never_halt"]
  1808. if sensitivity not in valid_sensitivities:
  1809. raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
  1810. success = client.set_xcam_option(
  1811. module_name=module_name,
  1812. enabled=enabled,
  1813. print_halt=print_halt,
  1814. sensitivity=sensitivity,
  1815. )
  1816. if not success:
  1817. raise HTTPException(500, "Failed to send command to printer")
  1818. return {
  1819. "success": True,
  1820. "module_name": module_name,
  1821. "enabled": enabled,
  1822. "print_halt": print_halt,
  1823. "sensitivity": sensitivity,
  1824. }
  1825. @router.post("/{printer_id}/ams-backup")
  1826. async def set_ams_backup(
  1827. printer_id: int,
  1828. enabled: bool,
  1829. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1830. db: AsyncSession = Depends(get_db),
  1831. ):
  1832. """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
  1833. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1834. printer = result.scalar_one_or_none()
  1835. if not printer:
  1836. raise HTTPException(404, "Printer not found")
  1837. client = printer_manager.get_client(printer_id)
  1838. if not client or not client.state.connected:
  1839. raise HTTPException(400, "Printer not connected")
  1840. success = client.set_ams_filament_backup(enabled)
  1841. if not success:
  1842. raise HTTPException(500, "Failed to send command to printer")
  1843. return {"success": True, "ams_filament_backup": enabled}
  1844. @router.get("/{printer_id}/inventory-remain")
  1845. async def get_inventory_remain(
  1846. printer_id: int,
  1847. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1848. db: AsyncSession = Depends(get_db),
  1849. ):
  1850. """Per-globalTrayId remaining grams for slots bound to an inventory spool.
  1851. Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal
  1852. client can apply the same two-tier "Prefer Lowest Remaining Filament" sort
  1853. the dispatcher uses (#1766). Works for both internal inventory and
  1854. Spoolman; unbound slots are absent from the map (client falls back to the
  1855. printer's MQTT `remain` for those).
  1856. """
  1857. from backend.app.services.print_scheduler import PrintScheduler
  1858. state = printer_manager.get_status(printer_id)
  1859. if not state:
  1860. return {"inventory_remain_g": {}}
  1861. scheduler = PrintScheduler()
  1862. loaded = scheduler._build_loaded_filaments(state)
  1863. overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
  1864. return {"inventory_remain_g": {str(k): v for k, v in overrides.items()}}
  1865. # ============================================
  1866. # Calibration
  1867. # ============================================
  1868. @router.post("/{printer_id}/calibration")
  1869. async def start_calibration(
  1870. printer_id: int,
  1871. bed_leveling: bool = False,
  1872. vibration: bool = False,
  1873. motor_noise: bool = False,
  1874. nozzle_offset: bool = False,
  1875. high_temp_heatbed: bool = False,
  1876. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1877. db: AsyncSession = Depends(get_db),
  1878. ):
  1879. """Start printer calibration with selected options.
  1880. At least one option must be selected.
  1881. Options:
  1882. - bed_leveling: Run bed leveling calibration
  1883. - vibration: Run vibration compensation calibration
  1884. - motor_noise: Run motor noise cancellation calibration
  1885. - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
  1886. - high_temp_heatbed: Run high-temperature heatbed calibration
  1887. """
  1888. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1889. printer = result.scalar_one_or_none()
  1890. if not printer:
  1891. raise HTTPException(404, "Printer not found")
  1892. client = printer_manager.get_client(printer_id)
  1893. if not client or not client.state.connected:
  1894. raise HTTPException(400, "Printer not connected")
  1895. # Check that at least one option is selected
  1896. if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
  1897. raise HTTPException(400, "At least one calibration option must be selected")
  1898. success = client.start_calibration(
  1899. bed_leveling=bed_leveling,
  1900. vibration=vibration,
  1901. motor_noise=motor_noise,
  1902. nozzle_offset=nozzle_offset,
  1903. high_temp_heatbed=high_temp_heatbed,
  1904. )
  1905. if not success:
  1906. raise HTTPException(500, "Failed to send calibration command to printer")
  1907. return {
  1908. "success": True,
  1909. "bed_leveling": bed_leveling,
  1910. "vibration": vibration,
  1911. "motor_noise": motor_noise,
  1912. "nozzle_offset": nozzle_offset,
  1913. "high_temp_heatbed": high_temp_heatbed,
  1914. }
  1915. # ============================================================================
  1916. # Slot Preset Mapping Endpoints
  1917. # ============================================================================
  1918. def _slot_preset_key(ams_id: int, tray_id: int) -> int:
  1919. # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed
  1920. # by ams_id since each unit has a single slot and shares its global ID with
  1921. # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id.
  1922. if 128 <= ams_id <= 135:
  1923. return ams_id
  1924. return ams_id * 4 + tray_id
  1925. @router.get("/{printer_id}/slot-presets")
  1926. async def get_slot_presets(
  1927. printer_id: int,
  1928. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1929. db: AsyncSession = Depends(get_db),
  1930. ):
  1931. """Get all saved slot-to-preset mappings for a printer."""
  1932. result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))
  1933. mappings = result.scalars().all()
  1934. return {
  1935. _slot_preset_key(mapping.ams_id, mapping.tray_id): {
  1936. "ams_id": mapping.ams_id,
  1937. "tray_id": mapping.tray_id,
  1938. "preset_id": mapping.preset_id,
  1939. "preset_name": mapping.preset_name,
  1940. }
  1941. for mapping in mappings
  1942. }
  1943. @router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1944. async def get_slot_preset(
  1945. printer_id: int,
  1946. ams_id: int,
  1947. tray_id: int,
  1948. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1949. db: AsyncSession = Depends(get_db),
  1950. ):
  1951. """Get the saved preset for a specific slot."""
  1952. result = await db.execute(
  1953. select(SlotPresetMapping).where(
  1954. SlotPresetMapping.printer_id == printer_id,
  1955. SlotPresetMapping.ams_id == ams_id,
  1956. SlotPresetMapping.tray_id == tray_id,
  1957. )
  1958. )
  1959. mapping = result.scalar_one_or_none()
  1960. if not mapping:
  1961. return None
  1962. return {
  1963. "ams_id": mapping.ams_id,
  1964. "tray_id": mapping.tray_id,
  1965. "preset_id": mapping.preset_id,
  1966. "preset_name": mapping.preset_name,
  1967. }
  1968. @router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  1969. async def save_slot_preset(
  1970. printer_id: int,
  1971. ams_id: int,
  1972. tray_id: int,
  1973. preset_id: str,
  1974. preset_name: str,
  1975. preset_source: str = "cloud",
  1976. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  1977. db: AsyncSession = Depends(get_db),
  1978. ):
  1979. """Save a preset mapping for a specific slot."""
  1980. # Check printer exists
  1981. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1982. if not result.scalar_one_or_none():
  1983. raise HTTPException(404, "Printer not found")
  1984. # Check for existing mapping
  1985. result = await db.execute(
  1986. select(SlotPresetMapping).where(
  1987. SlotPresetMapping.printer_id == printer_id,
  1988. SlotPresetMapping.ams_id == ams_id,
  1989. SlotPresetMapping.tray_id == tray_id,
  1990. )
  1991. )
  1992. mapping = result.scalar_one_or_none()
  1993. if mapping:
  1994. # Update existing
  1995. mapping.preset_id = preset_id
  1996. mapping.preset_name = preset_name
  1997. mapping.preset_source = preset_source
  1998. else:
  1999. # Create new
  2000. mapping = SlotPresetMapping(
  2001. printer_id=printer_id,
  2002. ams_id=ams_id,
  2003. tray_id=tray_id,
  2004. preset_id=preset_id,
  2005. preset_name=preset_name,
  2006. preset_source=preset_source,
  2007. )
  2008. db.add(mapping)
  2009. await db.commit()
  2010. await db.refresh(mapping)
  2011. return {
  2012. "ams_id": mapping.ams_id,
  2013. "tray_id": mapping.tray_id,
  2014. "preset_id": mapping.preset_id,
  2015. "preset_name": mapping.preset_name,
  2016. "preset_source": mapping.preset_source,
  2017. }
  2018. @router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  2019. async def delete_slot_preset(
  2020. printer_id: int,
  2021. ams_id: int,
  2022. tray_id: int,
  2023. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  2024. db: AsyncSession = Depends(get_db),
  2025. ):
  2026. """Delete a saved preset mapping for a slot."""
  2027. result = await db.execute(
  2028. select(SlotPresetMapping).where(
  2029. SlotPresetMapping.printer_id == printer_id,
  2030. SlotPresetMapping.ams_id == ams_id,
  2031. SlotPresetMapping.tray_id == tray_id,
  2032. )
  2033. )
  2034. mapping = result.scalar_one_or_none()
  2035. if mapping:
  2036. await db.delete(mapping)
  2037. await db.commit()
  2038. return {"success": True}
  2039. @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
  2040. async def configure_ams_slot(
  2041. printer_id: int,
  2042. ams_id: int,
  2043. tray_id: int,
  2044. tray_info_idx: str = Query(...),
  2045. tray_type: str = Query(...),
  2046. tray_sub_brands: str = Query(...),
  2047. tray_color: str = Query(...),
  2048. nozzle_temp_min: int = Query(...),
  2049. nozzle_temp_max: int = Query(...),
  2050. cali_idx: int = Query(-1),
  2051. nozzle_diameter: str = Query("0.4"),
  2052. setting_id: str = Query(""),
  2053. kprofile_filament_id: str = Query(""),
  2054. kprofile_setting_id: str = Query(""),
  2055. k_value: float = Query(0.0),
  2056. db: AsyncSession = Depends(get_db),
  2057. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2058. ):
  2059. """Configure an AMS slot with a specific filament setting and K profile.
  2060. This sends two commands to the printer:
  2061. 1. ams_filament_setting - sets filament type, color, temperature
  2062. 2. extrusion_cali_sel - sets the K profile (pressure advance value)
  2063. Args:
  2064. printer_id: Database ID of the printer
  2065. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
  2066. tray_id: Tray ID within the AMS (0-3)
  2067. tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
  2068. tray_type: Filament type (e.g., "PLA", "PETG")
  2069. tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
  2070. tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
  2071. nozzle_temp_min: Minimum nozzle temperature
  2072. nozzle_temp_max: Maximum nozzle temperature
  2073. cali_idx: K profile calibration index (-1 for default 0.020)
  2074. nozzle_diameter: Nozzle diameter string (e.g., "0.4")
  2075. setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
  2076. kprofile_filament_id: K profile's filament_id for proper K profile linking
  2077. k_value: Direct K value to set (0.0 to skip direct K value setting)
  2078. """
  2079. logger = logging.getLogger(__name__)
  2080. logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
  2081. logger.info(
  2082. f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
  2083. )
  2084. logger.info(
  2085. f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
  2086. )
  2087. # Get MQTT client for this printer
  2088. client = printer_manager.get_client(printer_id)
  2089. if not client:
  2090. raise HTTPException(status_code=400, detail="Printer not connected")
  2091. # Resolve tray_info_idx for the MQTT command.
  2092. # Priority:
  2093. # 1. Use the provided tray_info_idx if set (including cloud-synced
  2094. # custom presets like PFUS* / P*).
  2095. # 2. Reuse the slot's existing tray_info_idx if it's a specific
  2096. # (non-generic) preset for the same material.
  2097. # 3. Fall back to a generic Bambu filament ID.
  2098. _GENERIC_FILAMENT_IDS = {
  2099. "PLA": "GFL99",
  2100. "PETG": "GFG99",
  2101. "ABS": "GFB99",
  2102. "ASA": "GFB98",
  2103. "PC": "GFC99",
  2104. "PA": "GFN99",
  2105. "NYLON": "GFN99",
  2106. "TPU": "GFU99",
  2107. "PVA": "GFS99",
  2108. "HIPS": "GFS98",
  2109. "PLA-CF": "GFL98",
  2110. "PETG-CF": "GFG98",
  2111. "PA-CF": "GFN98",
  2112. "PETG HF": "GFG96",
  2113. }
  2114. _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values())
  2115. effective_tray_info_idx = tray_info_idx
  2116. if not tray_info_idx:
  2117. # No preset provided — try slot reuse or generic fallback
  2118. current_tray_info_idx = ""
  2119. current_tray_type = ""
  2120. state = printer_manager.get_status(printer_id)
  2121. if state and state.raw_data:
  2122. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  2123. if ams_id == 255:
  2124. vt_tray = state.raw_data.get("vt_tray") or []
  2125. ext_id = tray_id + 254
  2126. for vt in vt_tray:
  2127. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  2128. current_tray_info_idx = vt.get("tray_info_idx", "")
  2129. current_tray_type = vt.get("tray_type", "")
  2130. break
  2131. else:
  2132. ams_data = state.raw_data.get("ams", {})
  2133. ams_list = (
  2134. ams_data.get("ams", [])
  2135. if isinstance(ams_data, dict)
  2136. else ams_data
  2137. if isinstance(ams_data, list)
  2138. else []
  2139. )
  2140. cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
  2141. if cur_tray:
  2142. current_tray_info_idx = cur_tray.get("tray_info_idx", "")
  2143. current_tray_type = cur_tray.get("tray_type", "")
  2144. if (
  2145. current_tray_info_idx
  2146. and current_tray_info_idx not in _GENERIC_ID_VALUES
  2147. and current_tray_type
  2148. and current_tray_type.upper() == tray_type.upper()
  2149. ):
  2150. logger.info(
  2151. "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
  2152. current_tray_info_idx,
  2153. tray_type,
  2154. )
  2155. effective_tray_info_idx = current_tray_info_idx
  2156. elif tray_type:
  2157. material = tray_type.upper().strip()
  2158. generic = (
  2159. _GENERIC_FILAMENT_IDS.get(material)
  2160. or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  2161. or ""
  2162. )
  2163. if generic:
  2164. logger.info("[configure_ams_slot] Falling back to generic %r for material %r", generic, tray_type)
  2165. effective_tray_info_idx = generic
  2166. # Send filament setting + K-profile commands
  2167. filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
  2168. # Realign the slot's filament context to the K-profile's calibration
  2169. # context. The printer's calibration table is keyed by (filament_id,
  2170. # cali_idx) — so for the cali_idx selected via extrusion_cali_sel to
  2171. # actually stick to the slot, ams_filament_setting must declare the
  2172. # slot under the SAME filament_id.
  2173. #
  2174. # Without this, configure_ams_slot would send:
  2175. # ams_filament_setting → tray_info_idx=GFL99 (generic from material)
  2176. # extrusion_cali_sel → filament_id=P4d64437 (kp's preset)
  2177. # ...and the cali_idx would silently be dropped to default because the
  2178. # slot's filament context (GFL99) doesn't match the kp's (P4d64437).
  2179. #
  2180. # This realignment fires only when the kp is targeted at a different
  2181. # preset than the user's filament selection AND the kp's preset is a
  2182. # valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user
  2183. # which the slicer rejects in tray_info_idx).
  2184. effective_setting_id = setting_id
  2185. if (
  2186. kprofile_filament_id
  2187. and kprofile_filament_id != effective_tray_info_idx
  2188. and not kprofile_filament_id.startswith("PFUS")
  2189. ):
  2190. logger.info(
  2191. "[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r",
  2192. effective_tray_info_idx,
  2193. kprofile_filament_id,
  2194. setting_id,
  2195. kprofile_setting_id or setting_id,
  2196. )
  2197. effective_tray_info_idx = kprofile_filament_id
  2198. if kprofile_setting_id:
  2199. effective_setting_id = kprofile_setting_id
  2200. # Back-fill setting_id from the resolved filament id when the client sent
  2201. # none. Built-in / local / Orca-generic presets in the Configure AMS Slot
  2202. # modal leave setting_id empty (they carry only a GF* tray_info_idx), and
  2203. # the printer treats a filament-id-without-setting-id slot as half
  2204. # configured: it shows the new material briefly, then reverts to its
  2205. # previously stored profile (#2604). This mirrors the derivation the
  2206. # inventory/assignment path already does (inventory.py). filament_id_to_
  2207. # setting_id leaves P* user presets and already-GFS* values unchanged, so
  2208. # only the empty-setting_id generic paths are affected.
  2209. if effective_tray_info_idx and not effective_setting_id:
  2210. effective_setting_id = filament_id_to_setting_id(effective_tray_info_idx)
  2211. # Always send ams_set_filament_setting — the user explicitly clicked
  2212. # "Configure Slot", so honor that. Previous versions skipped this for
  2213. # RFID-tagged slots to preserve the slicer eye icon, but printers cache
  2214. # stale tag_uid/tray_uuid after a BL spool is removed, causing the check
  2215. # to false-positive on non-RFID slots and silently drop the command.
  2216. success = client.ams_set_filament_setting(
  2217. ams_id=ams_id,
  2218. tray_id=tray_id,
  2219. tray_info_idx=effective_tray_info_idx,
  2220. tray_type=tray_type,
  2221. tray_sub_brands=tray_sub_brands,
  2222. tray_color=tray_color,
  2223. nozzle_temp_min=nozzle_temp_min,
  2224. nozzle_temp_max=nozzle_temp_max,
  2225. setting_id=effective_setting_id,
  2226. )
  2227. if not success:
  2228. raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
  2229. # Method 1: Select existing calibration profile by cali_idx
  2230. # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
  2231. # and including it causes the firmware to mislink the profile on X1C/P1S.
  2232. client.extrusion_cali_sel(
  2233. ams_id=ams_id,
  2234. tray_id=tray_id,
  2235. cali_idx=cali_idx,
  2236. filament_id=filament_id_for_kprofile,
  2237. nozzle_diameter=nozzle_diameter,
  2238. )
  2239. # Method 2: Only send extrusion_cali_set when NO existing profile was selected
  2240. # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the
  2241. # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY
  2242. # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
  2243. # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
  2244. # hardcoded extruder_id=0 and nozzle_id=HS00.
  2245. if k_value > 0 and cali_idx < 0:
  2246. # Calculate global tray ID for extrusion_cali_set
  2247. if ams_id <= 3:
  2248. global_tray_id = ams_id * 4 + tray_id
  2249. elif ams_id >= 128 and ams_id <= 135:
  2250. global_tray_id = (ams_id - 128) * 4 + tray_id
  2251. else:
  2252. global_tray_id = tray_id
  2253. client.extrusion_cali_set(
  2254. tray_id=global_tray_id,
  2255. k_value=k_value,
  2256. nozzle_diameter=nozzle_diameter,
  2257. nozzle_temp=nozzle_temp_max,
  2258. filament_id=filament_id_for_kprofile,
  2259. setting_id=kprofile_setting_id or "",
  2260. name=tray_sub_brands or "",
  2261. cali_idx=cali_idx,
  2262. )
  2263. # Persist the user's K-profile choice so it survives RFID re-reads and
  2264. # session restarts. Pre-Phase-13 this was ephemeral — the MQTT command
  2265. # took effect on the printer but bambuddy never recorded it, so the next
  2266. # `_apply_pa_after_refresh` cycle had no stored profile to re-assert.
  2267. if cali_idx >= 0:
  2268. try:
  2269. from sqlalchemy.orm import selectinload
  2270. from backend.app.models.spool_assignment import SpoolAssignment
  2271. from backend.app.models.spool_k_profile import SpoolKProfile
  2272. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2273. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2274. # Resolve slot's extruder index for the K-profile match key. Same
  2275. # logic as _apply_pa_after_refresh: external slots invert tray→extruder,
  2276. # AMS slots come from ams_extruder_map. Falls back to 0 (single-nozzle).
  2277. slot_state = printer_manager.get_status(printer_id)
  2278. slot_extruder: int | None = None
  2279. if slot_state and slot_state.ams_extruder_map:
  2280. if ams_id == 255:
  2281. slot_extruder = 1 - tray_id
  2282. else:
  2283. slot_extruder = slot_state.ams_extruder_map.get(str(ams_id))
  2284. kp_extruder = slot_extruder if slot_extruder is not None else 0
  2285. # Spoolman SlotAssignment first — has UniqueConstraint, idempotent.
  2286. sm_result = await db.execute(
  2287. select(SpoolmanSlotAssignment).where(
  2288. SpoolmanSlotAssignment.printer_id == printer_id,
  2289. SpoolmanSlotAssignment.ams_id == ams_id,
  2290. SpoolmanSlotAssignment.tray_id == tray_id,
  2291. )
  2292. )
  2293. sm_assignment = sm_result.scalar_one_or_none()
  2294. if sm_assignment:
  2295. existing = await db.execute(
  2296. select(SpoolmanKProfile).where(
  2297. SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
  2298. SpoolmanKProfile.printer_id == printer_id,
  2299. SpoolmanKProfile.extruder == kp_extruder,
  2300. SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
  2301. )
  2302. )
  2303. kp = existing.scalar_one_or_none()
  2304. if kp:
  2305. kp.cali_idx = cali_idx
  2306. kp.k_value = k_value or 0.0
  2307. kp.setting_id = kprofile_setting_id or None
  2308. kp.name = tray_sub_brands or None
  2309. else:
  2310. db.add(
  2311. SpoolmanKProfile(
  2312. spoolman_spool_id=sm_assignment.spoolman_spool_id,
  2313. printer_id=printer_id,
  2314. extruder=kp_extruder,
  2315. nozzle_diameter=nozzle_diameter,
  2316. k_value=k_value or 0.0,
  2317. name=tray_sub_brands or None,
  2318. cali_idx=cali_idx,
  2319. setting_id=kprofile_setting_id or None,
  2320. )
  2321. )
  2322. await db.commit()
  2323. logger.info(
  2324. "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
  2325. sm_assignment.spoolman_spool_id,
  2326. printer_id,
  2327. ams_id,
  2328. tray_id,
  2329. cali_idx,
  2330. )
  2331. else:
  2332. # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first())
  2333. local_result = await db.execute(
  2334. select(SpoolAssignment)
  2335. .options(selectinload(SpoolAssignment.spool))
  2336. .where(
  2337. SpoolAssignment.printer_id == printer_id,
  2338. SpoolAssignment.ams_id == ams_id,
  2339. SpoolAssignment.tray_id == tray_id,
  2340. )
  2341. )
  2342. local_assignment = local_result.scalar_one_or_none()
  2343. if local_assignment and local_assignment.spool:
  2344. existing = await db.execute(
  2345. select(SpoolKProfile).where(
  2346. SpoolKProfile.spool_id == local_assignment.spool.id,
  2347. SpoolKProfile.printer_id == printer_id,
  2348. SpoolKProfile.extruder == kp_extruder,
  2349. SpoolKProfile.nozzle_diameter == nozzle_diameter,
  2350. )
  2351. )
  2352. # SpoolKProfile has no unique constraint on this tuple, so
  2353. # multiple rows could theoretically exist (shouldn't, but
  2354. # don't crash if they do). Update the first match, leave
  2355. # any duplicates alone.
  2356. kp = existing.scalars().first()
  2357. if kp:
  2358. kp.cali_idx = cali_idx
  2359. kp.k_value = k_value or 0.0
  2360. kp.setting_id = kprofile_setting_id or None
  2361. kp.name = tray_sub_brands or None
  2362. else:
  2363. db.add(
  2364. SpoolKProfile(
  2365. spool_id=local_assignment.spool.id,
  2366. printer_id=printer_id,
  2367. extruder=kp_extruder,
  2368. nozzle_diameter=nozzle_diameter,
  2369. k_value=k_value or 0.0,
  2370. name=tray_sub_brands or None,
  2371. cali_idx=cali_idx,
  2372. setting_id=kprofile_setting_id or None,
  2373. )
  2374. )
  2375. await db.commit()
  2376. logger.info(
  2377. "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
  2378. local_assignment.spool.id,
  2379. printer_id,
  2380. ams_id,
  2381. tray_id,
  2382. cali_idx,
  2383. )
  2384. except Exception:
  2385. # MQTT command was already sent successfully — DB persist is best-effort.
  2386. logger.exception(
  2387. "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)",
  2388. printer_id,
  2389. ams_id,
  2390. tray_id,
  2391. cali_idx,
  2392. )
  2393. try:
  2394. await db.rollback()
  2395. except Exception:
  2396. pass
  2397. # Register a read-back verification (#2582) so the tray telemetry that the
  2398. # status push below returns can confirm the printer accepted this manual
  2399. # slot configuration. Mirrors the inventory/assignment path.
  2400. client.register_assignment_verification(
  2401. ams_id=ams_id,
  2402. tray_id=tray_id,
  2403. tray_info_idx=effective_tray_info_idx,
  2404. tray_color=tray_color,
  2405. cali_idx=cali_idx,
  2406. )
  2407. # Request fresh status push from printer so frontend gets updated data via WebSocket
  2408. logger.info("[configure_ams_slot] Requesting status update from printer")
  2409. update_result = client.request_status_update()
  2410. logger.info("[configure_ams_slot] Status update request result: %s", update_result)
  2411. return {
  2412. "success": True,
  2413. "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
  2414. }
  2415. @router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
  2416. async def reset_ams_slot(
  2417. printer_id: int,
  2418. ams_id: int,
  2419. tray_id: int,
  2420. db: AsyncSession = Depends(get_db),
  2421. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2422. ):
  2423. """Reset an AMS slot to empty/unconfigured state.
  2424. This clears the filament configuration from the slot.
  2425. """
  2426. # Get MQTT client for this printer
  2427. client = printer_manager.get_client(printer_id)
  2428. if not client:
  2429. raise HTTPException(status_code=400, detail="Printer not connected")
  2430. # Reset the slot
  2431. success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
  2432. if not success:
  2433. raise HTTPException(status_code=500, detail="Failed to send reset command")
  2434. # Also delete any saved slot preset mapping
  2435. result = await db.execute(
  2436. select(SlotPresetMapping).where(
  2437. SlotPresetMapping.printer_id == printer_id,
  2438. SlotPresetMapping.ams_id == ams_id,
  2439. SlotPresetMapping.tray_id == tray_id,
  2440. )
  2441. )
  2442. mapping = result.scalar_one_or_none()
  2443. if mapping:
  2444. await db.delete(mapping)
  2445. await db.commit()
  2446. # Request fresh status push from printer so frontend gets updated data via WebSocket
  2447. client.request_status_update()
  2448. return {
  2449. "success": True,
  2450. "message": f"Reset AMS {ams_id} tray {tray_id}",
  2451. }
  2452. @router.get("/{printer_id}/ams-labels")
  2453. async def get_ams_labels(
  2454. printer_id: int,
  2455. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  2456. db: AsyncSession = Depends(get_db),
  2457. ):
  2458. """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
  2459. Labels are stored by AMS serial number. This endpoint resolves the current
  2460. serial-to-ams_id mapping from the live printer state so the response is still
  2461. keyed by ams_id for UI compatibility.
  2462. """
  2463. # Build serial -> ams_id map from live printer state
  2464. serial_to_ams_id: dict[str, int] = {}
  2465. state = printer_manager.get_status(printer_id)
  2466. if state and state.raw_data:
  2467. for ams_unit in state.raw_data.get("ams", []):
  2468. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  2469. if sn:
  2470. serial_to_ams_id[sn] = int(ams_unit.get("id", 0))
  2471. # Collect all known serials for this printer (live + synthetic fallback keys)
  2472. serials_to_query = set(serial_to_ams_id.keys())
  2473. # Fetch labels for all known serials
  2474. labels: dict[int, str] = {}
  2475. if serials_to_query:
  2476. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)))
  2477. for lbl in result.scalars().all():
  2478. aid = serial_to_ams_id.get(lbl.ams_serial_number)
  2479. if aid is not None:
  2480. labels[aid] = lbl.label
  2481. # Also fetch labels stored under synthetic keys for this printer (backward compat)
  2482. # Collect all synthetic keys first, then query with a single IN clause.
  2483. if state and state.raw_data:
  2484. synthetic_key_to_aid: dict[str, int] = {
  2485. f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0))
  2486. for ams_unit in state.raw_data.get("ams", [])
  2487. if int(ams_unit.get("id", 0)) not in labels
  2488. }
  2489. if synthetic_key_to_aid:
  2490. result = await db.execute(
  2491. select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys()))
  2492. )
  2493. for lbl in result.scalars().all():
  2494. aid = synthetic_key_to_aid.get(lbl.ams_serial_number)
  2495. if aid is not None:
  2496. labels[aid] = lbl.label
  2497. return labels
  2498. @router.put("/{printer_id}/ams-labels/{ams_id}")
  2499. async def save_ams_label(
  2500. printer_id: int,
  2501. ams_id: int,
  2502. body: AmsLabelBody,
  2503. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  2504. db: AsyncSession = Depends(get_db),
  2505. ):
  2506. """Create or update the friendly name for a specific AMS unit.
  2507. When ``ams_serial`` is provided the label is stored under that serial number so
  2508. it survives the AMS being moved to a different printer. When it is absent (e.g.
  2509. older firmware that does not report a serial) a synthetic key based on the
  2510. printer_id and ams_id is used as a fallback.
  2511. """
  2512. # Verify printer exists
  2513. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2514. if not result.scalar_one_or_none():
  2515. raise HTTPException(404, "Printer not found")
  2516. # Determine the serial key to store under
  2517. stripped = body.ams_serial.strip() if body.ams_serial else ""
  2518. serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
  2519. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
  2520. existing = result.scalar_one_or_none()
  2521. if existing:
  2522. existing.label = body.label
  2523. existing.ams_id = ams_id
  2524. else:
  2525. db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))
  2526. await db.commit()
  2527. return {"ams_id": ams_id, "label": body.label}
  2528. @router.delete("/{printer_id}/ams-labels/{ams_id}")
  2529. async def delete_ams_label(
  2530. printer_id: int,
  2531. ams_id: int,
  2532. ams_serial: str = Query(default="", max_length=50),
  2533. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  2534. db: AsyncSession = Depends(get_db),
  2535. ):
  2536. """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
  2537. stripped = ams_serial.strip() if ams_serial else ""
  2538. serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
  2539. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
  2540. existing = result.scalar_one_or_none()
  2541. if existing:
  2542. await db.delete(existing)
  2543. await db.commit()
  2544. return {"success": True}
  2545. @router.post("/{printer_id}/debug/simulate-print-complete")
  2546. async def debug_simulate_print_complete(
  2547. printer_id: int,
  2548. db: AsyncSession = Depends(get_db),
  2549. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2550. ):
  2551. """DEBUG: Simulate print completion to test freeze behavior.
  2552. This triggers the same code path as a real print completion,
  2553. without needing to wait for an actual print to finish.
  2554. """
  2555. from backend.app.main import _active_prints, on_print_complete
  2556. from backend.app.models.archive import PrintArchive
  2557. # Get the most recent archive for this printer
  2558. result = await db.execute(
  2559. select(PrintArchive)
  2560. .where(PrintArchive.printer_id == printer_id)
  2561. .order_by(PrintArchive.created_at.desc())
  2562. .limit(1)
  2563. )
  2564. archive = result.scalar_one_or_none()
  2565. if not archive:
  2566. raise HTTPException(status_code=404, detail="No archives found for this printer")
  2567. # Register this archive as "active" so on_print_complete can find it
  2568. filename = archive.file_path.split("/")[-1] if archive.file_path else "test.3mf"
  2569. subtask_name = archive.print_name or "Test Print"
  2570. _active_prints[(printer_id, filename)] = archive.id
  2571. _active_prints[(printer_id, subtask_name)] = archive.id
  2572. # Simulate print completion data
  2573. data = {
  2574. "status": "completed",
  2575. "filename": filename,
  2576. "subtask_name": subtask_name,
  2577. "timelapse_was_active": False,
  2578. }
  2579. logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id)
  2580. # Call the actual on_print_complete handler
  2581. await on_print_complete(printer_id, data)
  2582. return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
  2583. # =============================================================================
  2584. # Print Control Endpoints
  2585. # =============================================================================
  2586. @router.post("/{printer_id}/print/stop")
  2587. async def stop_print(
  2588. printer_id: int,
  2589. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2590. db: AsyncSession = Depends(get_db),
  2591. ):
  2592. """Stop/cancel the current print job."""
  2593. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2594. printer = result.scalar_one_or_none()
  2595. if not printer:
  2596. raise HTTPException(404, "Printer not found")
  2597. client = printer_manager.get_client(printer_id)
  2598. if not client:
  2599. raise HTTPException(400, "Printer not connected")
  2600. success = client.stop_print()
  2601. if not success:
  2602. raise HTTPException(500, "Failed to stop print")
  2603. # Mark this printer as user-stopped so on_print_complete reclassifies
  2604. # the resulting "failed"/"aborted" MQTT status as "cancelled" — otherwise
  2605. # the HMS heuristic in _dispatch_archive_update mislabels user-cancels
  2606. # (e.g. the H2D's cancel-sequence module-0x0C HMS) as "Layer shift".
  2607. try:
  2608. from backend.app.main import mark_printer_stopped_by_user
  2609. mark_printer_stopped_by_user(printer_id)
  2610. except Exception as _mark_err:
  2611. logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
  2612. return {"success": True, "message": "Print stop command sent"}
  2613. @router.post("/{printer_id}/clear-plate")
  2614. async def clear_plate(
  2615. printer_id: int,
  2616. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
  2617. db: AsyncSession = Depends(get_db),
  2618. ):
  2619. """Acknowledge that the build plate has been cleared after a finished/failed print.
  2620. Sets a plate-cleared flag so the scheduler can start the next queued print.
  2621. No MQTT command is sent to the printer — the scheduler's start_print command
  2622. will override the FINISH/FAILED state when it sends the next job.
  2623. """
  2624. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2625. printer = result.scalar_one_or_none()
  2626. if not printer:
  2627. raise HTTPException(404, "Printer not found")
  2628. if not printer_manager.is_connected(printer_id):
  2629. raise HTTPException(400, "Printer not connected")
  2630. # Accept the acknowledgment whenever the printer is awaiting it — not only when the
  2631. # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE
  2632. # but the awaiting flag persists, and the user still needs a way to ack it (#961).
  2633. state = printer_manager.get_status(printer_id)
  2634. awaiting = printer_manager.is_awaiting_plate_clear(printer_id)
  2635. if not awaiting and (not state or state.state not in ("FINISH", "FAILED")):
  2636. raise HTTPException(
  2637. 400,
  2638. f"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})",
  2639. )
  2640. printer_manager.set_awaiting_plate_clear(printer_id, False)
  2641. return {"success": True, "message": "Plate cleared, next print will start shortly"}
  2642. @router.post("/{printer_id}/print/pause")
  2643. async def pause_print(
  2644. printer_id: int,
  2645. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2646. db: AsyncSession = Depends(get_db),
  2647. ):
  2648. """Pause the current print job."""
  2649. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2650. printer = result.scalar_one_or_none()
  2651. if not printer:
  2652. raise HTTPException(404, "Printer not found")
  2653. client = printer_manager.get_client(printer_id)
  2654. if not client:
  2655. raise HTTPException(400, "Printer not connected")
  2656. success = client.pause_print()
  2657. if not success:
  2658. raise HTTPException(500, "Failed to pause print")
  2659. return {"success": True, "message": "Print pause command sent"}
  2660. @router.post("/{printer_id}/print/resume")
  2661. async def resume_print(
  2662. printer_id: int,
  2663. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2664. db: AsyncSession = Depends(get_db),
  2665. ):
  2666. """Resume a paused print job."""
  2667. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2668. printer = result.scalar_one_or_none()
  2669. if not printer:
  2670. raise HTTPException(404, "Printer not found")
  2671. client = printer_manager.get_client(printer_id)
  2672. if not client:
  2673. raise HTTPException(400, "Printer not connected")
  2674. success = client.resume_print()
  2675. if not success:
  2676. raise HTTPException(500, "Failed to resume print")
  2677. return {"success": True, "message": "Print resume command sent"}
  2678. @router.post("/{printer_id}/print-speed")
  2679. async def set_print_speed(
  2680. printer_id: int,
  2681. mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
  2682. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2683. db: AsyncSession = Depends(get_db),
  2684. ):
  2685. """Set the print speed mode."""
  2686. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2687. printer = result.scalar_one_or_none()
  2688. if not printer:
  2689. raise HTTPException(404, "Printer not found")
  2690. client = printer_manager.get_client(printer_id)
  2691. if not client:
  2692. raise HTTPException(400, "Printer not connected")
  2693. success = client.set_print_speed(mode)
  2694. if not success:
  2695. raise HTTPException(500, "Failed to set print speed")
  2696. speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
  2697. return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
  2698. @router.post("/{printer_id}/temperature/nozzle")
  2699. async def set_nozzle_temperature(
  2700. printer_id: int,
  2701. target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
  2702. nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
  2703. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2704. db: AsyncSession = Depends(get_db),
  2705. ):
  2706. """Set a nozzle target temperature."""
  2707. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2708. printer = result.scalar_one_or_none()
  2709. if not printer:
  2710. raise HTTPException(404, "Printer not found")
  2711. client = printer_manager.get_client(printer_id)
  2712. if not client:
  2713. raise HTTPException(400, "Printer not connected")
  2714. success = client.set_nozzle_temperature(target, nozzle)
  2715. if not success:
  2716. raise HTTPException(500, "Failed to set nozzle temperature")
  2717. return {"success": True, "message": f"Nozzle temperature set to {target}°C"}
  2718. @router.post("/{printer_id}/temperature/bed")
  2719. async def set_bed_temperature(
  2720. printer_id: int,
  2721. target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
  2722. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2723. db: AsyncSession = Depends(get_db),
  2724. ):
  2725. """Set the bed target temperature."""
  2726. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2727. printer = result.scalar_one_or_none()
  2728. if not printer:
  2729. raise HTTPException(404, "Printer not found")
  2730. client = printer_manager.get_client(printer_id)
  2731. if not client:
  2732. raise HTTPException(400, "Printer not connected")
  2733. success = client.set_bed_temperature(target)
  2734. if not success:
  2735. raise HTTPException(500, "Failed to set bed temperature")
  2736. return {"success": True, "message": f"Bed temperature set to {target}°C"}
  2737. @router.post("/{printer_id}/temperature/chamber")
  2738. async def set_chamber_temperature(
  2739. printer_id: int,
  2740. target: int = Query(..., ge=0, le=60, description="Target chamber temperature in Celsius; 0 turns heating off"),
  2741. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2742. db: AsyncSession = Depends(get_db),
  2743. ):
  2744. """Set the chamber target temperature.
  2745. Gated on `supports_chamber_heater(model)`: only H2C, H2D, H2D Pro, H2S,
  2746. and X2D have an active chamber heater. Sensor-only models (X1C, X1E,
  2747. P2S) report chamber temp but silently swallow M141, so we 400 here
  2748. rather than send a no-op.
  2749. """
  2750. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2751. printer = result.scalar_one_or_none()
  2752. if not printer:
  2753. raise HTTPException(404, "Printer not found")
  2754. if not supports_chamber_heater(printer.model):
  2755. raise HTTPException(400, f"Model {printer.model or 'unknown'} does not have an active chamber heater")
  2756. client = printer_manager.get_client(printer_id)
  2757. if not client:
  2758. raise HTTPException(400, "Printer not connected")
  2759. success = client.set_chamber_temperature(target)
  2760. if not success:
  2761. raise HTTPException(500, "Failed to set chamber temperature")
  2762. return {"success": True, "message": f"Chamber temperature set to {target}°C"}
  2763. @router.post("/{printer_id}/fan-speed")
  2764. async def set_fan_speed(
  2765. printer_id: int,
  2766. fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
  2767. speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
  2768. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2769. db: AsyncSession = Depends(get_db),
  2770. ):
  2771. """Set a fan speed by percentage.
  2772. Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
  2773. P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
  2774. profile gcode does. It only exists when the printer reports airduct part 10,
  2775. so the request is rejected rather than sending M106 P10 into the void on a
  2776. machine that has no such fan.
  2777. That gate also rejects for the short window between connecting and the
  2778. first airduct push, when nothing is known about the fan yet. The card hides
  2779. the badge over the same window, so there is no control to click; a direct
  2780. API caller gets a 400 and should retry once the status reports the fan.
  2781. """
  2782. fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
  2783. fan_id = fan_ids.get(fan)
  2784. if fan_id is None:
  2785. raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'")
  2786. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2787. printer = result.scalar_one_or_none()
  2788. if not printer:
  2789. raise HTTPException(404, "Printer not found")
  2790. client = printer_manager.get_client(printer_id)
  2791. if not client:
  2792. raise HTTPException(400, "Printer not connected")
  2793. # Presence gate for the accessory fan. Without this, aux2 is accepted for
  2794. # every model and an A1 would be sent M106 P10 for a fan it does not have.
  2795. # The UI already hides the badge; this closes the same hole on the API.
  2796. if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
  2797. raise HTTPException(
  2798. 400,
  2799. "This printer does not report a left auxiliary fan "
  2800. "(no airduct part 10). The fan is an accessory kit on the P2S "
  2801. "and factory-fitted on the X2D.",
  2802. )
  2803. pwm_speed = round(speed * 255 / 100)
  2804. success = client.set_fan_speed(fan_id, pwm_speed)
  2805. if not success:
  2806. raise HTTPException(500, "Failed to set fan speed")
  2807. # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
  2808. # match whatever the printer card badge shows so the toast agrees with the
  2809. # control the user just clicked.
  2810. fan_names = {
  2811. "part": "Part cooling fan",
  2812. "aux": "Auxiliary fan",
  2813. "aux2": "Left auxiliary fan",
  2814. "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
  2815. }
  2816. return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
  2817. @router.post("/{printer_id}/select-extruder")
  2818. async def select_extruder(
  2819. printer_id: int,
  2820. extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
  2821. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2822. db: AsyncSession = Depends(get_db),
  2823. ):
  2824. """Select the active extruder/nozzle on dual-nozzle printers."""
  2825. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2826. printer = result.scalar_one_or_none()
  2827. if not printer:
  2828. raise HTTPException(404, "Printer not found")
  2829. client = printer_manager.get_client(printer_id)
  2830. if not client:
  2831. raise HTTPException(400, "Printer not connected")
  2832. success = client.select_extruder(extruder)
  2833. if not success:
  2834. raise HTTPException(500, "Failed to select nozzle")
  2835. return {"success": True, "message": f"{'Left' if extruder == 1 else 'Right'} nozzle selected"}
  2836. @router.post("/{printer_id}/airduct-mode")
  2837. async def set_airduct_mode(
  2838. printer_id: int,
  2839. mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
  2840. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2841. db: AsyncSession = Depends(get_db),
  2842. ):
  2843. """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
  2844. if mode not in ("cooling", "heating"):
  2845. raise HTTPException(400, "Mode must be 'cooling' or 'heating'")
  2846. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2847. printer = result.scalar_one_or_none()
  2848. if not printer:
  2849. raise HTTPException(404, "Printer not found")
  2850. client = printer_manager.get_client(printer_id)
  2851. if not client:
  2852. raise HTTPException(400, "Printer not connected")
  2853. success = client.set_airduct_mode(mode)
  2854. if not success:
  2855. raise HTTPException(500, "Failed to set airduct mode")
  2856. return {"success": True, "message": f"Airduct mode set to {mode}"}
  2857. @router.post("/{printer_id}/chamber-light")
  2858. async def set_chamber_light(
  2859. printer_id: int,
  2860. on: bool = Query(..., description="True to turn on, False to turn off"),
  2861. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2862. db: AsyncSession = Depends(get_db),
  2863. ):
  2864. """Turn the chamber light on or off."""
  2865. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2866. printer = result.scalar_one_or_none()
  2867. if not printer:
  2868. raise HTTPException(404, "Printer not found")
  2869. client = printer_manager.get_client(printer_id)
  2870. if not client:
  2871. raise HTTPException(400, "Printer not connected")
  2872. success = client.set_chamber_light(on)
  2873. if not success:
  2874. raise HTTPException(500, "Failed to control chamber light")
  2875. return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
  2876. @router.post("/{printer_id}/bed-jog")
  2877. async def bed_jog(
  2878. printer_id: int,
  2879. distance: float = Query(
  2880. ...,
  2881. description=(
  2882. "Signed nozzle-bed gap adjustment in mm. Negative = decrease gap "
  2883. '("up" arrow in the UI: bed up on bed-on-Z models, toolhead down '
  2884. "on A1 bed-slingers). Positive = increase gap. The backend "
  2885. "translates this into the right G-code Z sign per printer model."
  2886. ),
  2887. ),
  2888. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2889. db: AsyncSession = Depends(get_db),
  2890. ):
  2891. """Adjust the nozzle-bed gap by a relative distance.
  2892. Emits a short G-code sequence via MQTT.
  2893. Soft-endstop policy (#2579). The printer's software travel limits are the
  2894. only thing between a jog button and a bed crash — on Bambu machines the
  2895. physical endstops are homing-only (there is no runtime limit switch in the
  2896. travel path), so once they are disabled nothing stops the move. The old
  2897. code disabled them (``M211 S0``) around every forced jog, and the UI sent
  2898. ``force`` on every jog, so the limits were off on every bed move — that is
  2899. what let a jog drive the nozzle into the bed on all models (#2579). This
  2900. endpoint now emits a **bare relative move and never touches ``M211`` at
  2901. all** — byte-for-byte what the printer's own touchscreen jog sends, which
  2902. stops at the travel limit. Bambuddy no longer disables the firmware's soft
  2903. endstops, and it no longer sends ``M211 S1`` either: that was an unverified
  2904. attempt to re-enable a printer left disabled by an older build, and on real
  2905. hardware the jog moved past the limit *with* it. If a printer still jogs
  2906. past its limits, its endstops were disabled at the firmware level by the old
  2907. build — power-cycle it once to restore them; from then on Bambuddy leaves
  2908. them alone.
  2909. Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
  2910. is the Z-axis, and Bambu's home convention puts Z=0 at the top with
  2911. Z+ moving the bed down — so a frontend "Up" (decrease gap) maps
  2912. naturally to ``G1 Z-``. On bed-slingers (A1 / A1 Mini) the Z-axis is
  2913. the *toolhead*, and ``G1 Z-`` instead drives the nozzle DOWN into the
  2914. bed (#1334 reported exactly that crash). For those models we invert
  2915. the sign before emitting the G-code, so the UI semantics stay the
  2916. same regardless of which part physically moves.
  2917. """
  2918. if distance == 0 or abs(distance) > 200:
  2919. raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
  2920. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2921. printer = result.scalar_one_or_none()
  2922. if not printer:
  2923. raise HTTPException(404, "Printer not found")
  2924. client = printer_manager.get_client(printer_id)
  2925. if not client:
  2926. raise HTTPException(400, "Printer not connected")
  2927. from backend.app.services.printer_manager import is_bed_slinger
  2928. gcode_distance = -distance if is_bed_slinger(printer.model) else distance
  2929. # Bare relative move — exactly what the touchscreen sends. Never touch M211
  2930. # (#2579): the firmware keeps its soft endstops on by default and clamps the
  2931. # move at the travel limit.
  2932. lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
  2933. if not client.send_gcode("\n".join(lines)):
  2934. raise HTTPException(500, "Failed to send bed-jog command")
  2935. return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
  2936. @router.post("/{printer_id}/xy-jog")
  2937. async def xy_jog(
  2938. printer_id: int,
  2939. x: float = Query(0, description="Signed relative X movement in mm"),
  2940. y: float = Query(0, description="Signed relative Y movement in mm"),
  2941. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2942. db: AsyncSession = Depends(get_db),
  2943. ):
  2944. """Move the toolhead by a relative X/Y distance."""
  2945. if (x == 0 and y == 0) or abs(x) > 200 or abs(y) > 200:
  2946. raise HTTPException(400, "X/Y movement must be non-zero and ≤ 200 mm per axis")
  2947. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2948. printer = result.scalar_one_or_none()
  2949. if not printer:
  2950. raise HTTPException(404, "Printer not found")
  2951. client = printer_manager.get_client(printer_id)
  2952. if not client:
  2953. raise HTTPException(400, "Printer not connected")
  2954. axes = []
  2955. if x:
  2956. axes.append(f"X{x:.2f}")
  2957. if y:
  2958. axes.append(f"Y{y:.2f}")
  2959. # Bare relative move — never touch M211 (#2579). The firmware keeps its soft
  2960. # endstops on by default and clamps the move at the travel limit; a printer
  2961. # left disabled by an older build is recovered with a power cycle.
  2962. if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
  2963. raise HTTPException(500, "Failed to send XY jog command")
  2964. return {"success": True, "message": f"XY jog X{x:+.1f} Y{y:+.1f} mm sent"}
  2965. @router.post("/{printer_id}/extruder-jog")
  2966. async def extruder_jog(
  2967. printer_id: int,
  2968. distance: float = Query(
  2969. ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
  2970. ),
  2971. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2972. db: AsyncSession = Depends(get_db),
  2973. ):
  2974. """Extrude or retract filament by a relative distance.
  2975. No client-side cold-extrude guard: Bambu firmware refuses extrusion
  2976. below its min-extrude temperature, so a cold call is rejected at the
  2977. printer, not silently damaging the extruder gear.
  2978. """
  2979. if distance == 0 or abs(distance) > 100:
  2980. raise HTTPException(400, "Extruder movement must be non-zero and ≤ 100 mm")
  2981. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2982. printer = result.scalar_one_or_none()
  2983. if not printer:
  2984. raise HTTPException(404, "Printer not found")
  2985. client = printer_manager.get_client(printer_id)
  2986. if not client:
  2987. raise HTTPException(400, "Printer not connected")
  2988. if not client.send_gcode("\n".join(["M83", f"G1 E{distance:.2f} F300", "M82"])):
  2989. raise HTTPException(500, "Failed to send extruder jog command")
  2990. return {"success": True, "message": f"Extruder jog {distance:+.1f} mm sent"}
  2991. @router.post("/{printer_id}/home-axes")
  2992. async def home_axes(
  2993. printer_id: int,
  2994. axes: str = Query(
  2995. "all",
  2996. description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.",
  2997. ),
  2998. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2999. db: AsyncSession = Depends(get_db),
  3000. ):
  3001. """Run the printer's full auto-home sequence via bare `G28`.
  3002. Bambu printers (H2C / H2D / H2S / X1 family) home the Z axis by moving
  3003. the BED UP toward an endstop at the top of travel. If the toolhead is
  3004. not already parked out of the way, a bare `G28 Z` will crash the bed
  3005. into the toolhead — #1052 reported exactly that on H2C: the bed rose
  3006. without stopping at a safe height because `G28 Z` skipped the
  3007. toolhead-park step that a full `G28` runs first.
  3008. The endpoint therefore ignores the `axes` argument and always sends a
  3009. bare `G28`, which the firmware expands into a safe multi-step sequence
  3010. (park toolhead → home XY → home Z). The argument is kept only for
  3011. backward-compat with existing clients; sending an invalid value still
  3012. returns 400 so typos surface instead of silently proceeding.
  3013. """
  3014. axes = axes.lower()
  3015. if axes not in ("z", "xy", "all"):
  3016. raise HTTPException(400, "axes must be 'z', 'xy', or 'all'")
  3017. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3018. printer = result.scalar_one_or_none()
  3019. if not printer:
  3020. raise HTTPException(404, "Printer not found")
  3021. client = printer_manager.get_client(printer_id)
  3022. if not client:
  3023. raise HTTPException(400, "Printer not connected")
  3024. if not client.send_gcode("G28"):
  3025. raise HTTPException(500, "Failed to send home command")
  3026. return {"success": True, "message": "Full auto-home sequence sent"}
  3027. @router.post("/{printer_id}/hms/clear")
  3028. async def clear_hms_errors(
  3029. printer_id: int,
  3030. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3031. db: AsyncSession = Depends(get_db),
  3032. ):
  3033. """Clear HMS/print errors on the printer."""
  3034. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3035. printer = result.scalar_one_or_none()
  3036. if not printer:
  3037. raise HTTPException(404, "Printer not found")
  3038. client = printer_manager.get_client(printer_id)
  3039. if not client:
  3040. raise HTTPException(400, "Printer not connected")
  3041. success = client.clear_hms_errors()
  3042. if not success:
  3043. raise HTTPException(500, "Failed to clear HMS errors")
  3044. return {"success": True, "message": "HMS errors cleared"}
  3045. @router.get("/{printer_id}/print/objects")
  3046. async def get_printable_objects(
  3047. printer_id: int,
  3048. reload: bool = False,
  3049. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  3050. db: AsyncSession = Depends(get_db),
  3051. ):
  3052. """Get the list of printable objects for the current print.
  3053. Returns a list of objects with id, name, position (if available), and skip status.
  3054. Objects that have already been skipped are marked in the skipped_objects list.
  3055. Args:
  3056. reload: If True, reload objects from the archive file (useful after restart)
  3057. """
  3058. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3059. printer = result.scalar_one_or_none()
  3060. if not printer:
  3061. raise HTTPException(404, "Printer not found")
  3062. client = printer_manager.get_client(printer_id)
  3063. if not client:
  3064. raise HTTPException(400, "Printer not connected")
  3065. # Reload objects from 3MF if requested or no objects loaded
  3066. if reload or not client.state.printable_objects:
  3067. subtask_name = client.state.subtask_name
  3068. if subtask_name:
  3069. from backend.app.services.archive import extract_printable_objects_from_3mf
  3070. from backend.app.services.bambu_ftp import download_file_try_paths_async
  3071. # Build possible 3MF filenames (try both .gcode.3mf and .3mf)
  3072. possible_filenames = []
  3073. if subtask_name.endswith(".3mf"):
  3074. possible_filenames.append(subtask_name)
  3075. else:
  3076. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  3077. possible_filenames.append(f"{subtask_name}.3mf")
  3078. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  3079. if " " in subtask_name:
  3080. normalized = subtask_name.replace(" ", "_")
  3081. if normalized.endswith(".3mf"):
  3082. possible_filenames.append(normalized)
  3083. else:
  3084. possible_filenames.append(f"{normalized}.gcode.3mf")
  3085. possible_filenames.append(f"{normalized}.3mf")
  3086. # Download 3MF from printer
  3087. temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}"
  3088. temp_path.parent.mkdir(parents=True, exist_ok=True)
  3089. # Build list of all remote paths to try
  3090. remote_paths = []
  3091. for filename in possible_filenames:
  3092. remote_paths.extend([f"/{filename}", f"/cache/{filename}", f"/model/{filename}"])
  3093. try:
  3094. downloaded = await download_file_try_paths_async(
  3095. printer.ip_address,
  3096. printer.access_code,
  3097. remote_paths,
  3098. temp_path,
  3099. printer_model=printer.model,
  3100. )
  3101. if downloaded and temp_path.exists():
  3102. with open(temp_path, "rb") as f:
  3103. data = f.read()
  3104. # Scope to the running plate: an all-plates 3MF lists every
  3105. # plate's objects, and offering plate 1's while the printer
  3106. # runs plate 2 makes every skip a misfire (#2522).
  3107. objects, bbox_all = extract_printable_objects_from_3mf(
  3108. data,
  3109. plate_number=resolve_plate_id(client.state),
  3110. include_positions=True,
  3111. )
  3112. if objects:
  3113. client.state.printable_objects = objects
  3114. client.state.printable_objects_bbox_all = bbox_all
  3115. logger.info("Reloaded %s objects for printer %s", len(objects), printer_id)
  3116. except Exception as e:
  3117. logger.debug("Failed to reload objects from printer: %s", e)
  3118. finally:
  3119. if temp_path.exists():
  3120. temp_path.unlink()
  3121. # Return objects with their skip status and position data
  3122. objects = []
  3123. for obj_id, obj_data in client.state.printable_objects.items():
  3124. # Handle both old format (string name) and new format (dict with name, x, y)
  3125. if isinstance(obj_data, dict):
  3126. obj_entry = {
  3127. "id": obj_id,
  3128. "name": obj_data.get("name", f"Object {obj_id}"),
  3129. "x": obj_data.get("x"),
  3130. "y": obj_data.get("y"),
  3131. "skipped": obj_id in client.state.skipped_objects,
  3132. }
  3133. else:
  3134. # Legacy format: obj_data is just the name string
  3135. obj_entry = {
  3136. "id": obj_id,
  3137. "name": obj_data,
  3138. "x": None,
  3139. "y": None,
  3140. "skipped": obj_id in client.state.skipped_objects,
  3141. }
  3142. objects.append(obj_entry)
  3143. return {
  3144. "objects": objects,
  3145. "total": len(objects),
  3146. "skipped_count": len(client.state.skipped_objects),
  3147. "is_printing": client.state.state in ("RUNNING", "PAUSE"),
  3148. "bbox_all": getattr(client.state, "printable_objects_bbox_all", None),
  3149. }
  3150. @router.post("/{printer_id}/print/skip-objects")
  3151. async def skip_objects(
  3152. printer_id: int,
  3153. object_ids: list[int],
  3154. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3155. db: AsyncSession = Depends(get_db),
  3156. ):
  3157. """Skip specific objects during the current print.
  3158. Args:
  3159. object_ids: List of object identify_id values to skip
  3160. """
  3161. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3162. printer = result.scalar_one_or_none()
  3163. if not printer:
  3164. raise HTTPException(404, "Printer not found")
  3165. client = printer_manager.get_client(printer_id)
  3166. if not client:
  3167. raise HTTPException(400, "Printer not connected")
  3168. if not object_ids:
  3169. raise HTTPException(400, "No object IDs provided")
  3170. # Validate object IDs exist in printable_objects
  3171. invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]
  3172. if invalid_ids:
  3173. raise HTTPException(400, f"Invalid object IDs: {invalid_ids}")
  3174. success = client.skip_objects(object_ids)
  3175. if not success:
  3176. raise HTTPException(500, "Failed to skip objects")
  3177. # Get names of skipped objects for response (handle both old and new format)
  3178. skipped_names = []
  3179. for oid in object_ids:
  3180. obj_data = client.state.printable_objects.get(oid, str(oid))
  3181. if isinstance(obj_data, dict):
  3182. skipped_names.append(obj_data.get("name", str(oid)))
  3183. else:
  3184. skipped_names.append(obj_data)
  3185. return {
  3186. "success": True,
  3187. "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}",
  3188. "skipped_objects": object_ids,
  3189. }
  3190. # =============================================================================
  3191. # AMS Control Endpoints
  3192. # =============================================================================
  3193. @router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh")
  3194. async def refresh_ams_slot(
  3195. printer_id: int,
  3196. ams_id: int,
  3197. slot_id: int,
  3198. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
  3199. db: AsyncSession = Depends(get_db),
  3200. ):
  3201. """Re-read RFID for an AMS slot (triggers filament info refresh)."""
  3202. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3203. printer = result.scalar_one_or_none()
  3204. if not printer:
  3205. raise HTTPException(404, "Printer not found")
  3206. client = printer_manager.get_client(printer_id)
  3207. if not client:
  3208. raise HTTPException(400, "Printer not connected")
  3209. success, message = client.ams_refresh_tray(ams_id, slot_id)
  3210. if not success:
  3211. raise HTTPException(400, message)
  3212. # Apply PA profile after delay (RFID re-read takes a few seconds)
  3213. spawn_background_task(
  3214. _apply_pa_after_refresh(printer_id, ams_id, slot_id),
  3215. name=f"apply-pa-after-refresh-{printer_id}-{ams_id}-{slot_id}",
  3216. )
  3217. return {"success": True, "message": message}
  3218. async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
  3219. """Apply PA profile after RFID re-read completes.
  3220. Waits for the printer to finish processing the RFID data, then selects
  3221. the K-profile via extrusion_cali_sel. Does NOT re-send ams_set_filament_setting
  3222. because that would overwrite the RFID-provided filament data.
  3223. """
  3224. await asyncio.sleep(5)
  3225. try:
  3226. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  3227. from backend.app.core.database import async_session
  3228. from backend.app.models.spool import Spool
  3229. from backend.app.models.spool_assignment import SpoolAssignment as SA
  3230. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  3231. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  3232. from backend.app.services.spool_tag_matcher import (
  3233. ZERO_TAG_UID,
  3234. ZERO_TRAY_UUID,
  3235. is_bambu_tag,
  3236. )
  3237. from backend.app.utils.tag_normalization import (
  3238. normalize_tag_uid,
  3239. normalize_tray_uuid,
  3240. )
  3241. client = printer_manager.get_client(printer_id)
  3242. if not client:
  3243. return
  3244. state = printer_manager.get_status(printer_id)
  3245. if not state or not state.raw_data:
  3246. return
  3247. # Find current tray data (should have RFID data by now)
  3248. ams_data = state.raw_data.get("ams", {})
  3249. ams_list = (
  3250. ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
  3251. )
  3252. tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)
  3253. if not tray or not tray.get("tray_type"):
  3254. logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id)
  3255. return
  3256. tag_uid = tray.get("tag_uid", "")
  3257. tray_uuid = tray.get("tray_uuid", "")
  3258. tray_info_idx = tray.get("tray_info_idx", "")
  3259. if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
  3260. return
  3261. # Compute nozzle/extruder once — used by both local and Spoolman lookup.
  3262. nozzle_diameter = "0.4"
  3263. if state.nozzles:
  3264. nd = state.nozzles[0].nozzle_diameter
  3265. if nd:
  3266. nozzle_diameter = nd
  3267. slot_extruder = None
  3268. if state.ams_extruder_map:
  3269. if ams_id == 255:
  3270. # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  3271. slot_extruder = 1 - slot_id
  3272. else:
  3273. slot_extruder = state.ams_extruder_map.get(str(ams_id))
  3274. # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
  3275. # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
  3276. # and exited silently if no SpoolKProfile match; Spoolman-assigned slots
  3277. # were ignored entirely and live cali_idx was never re-asserted.
  3278. matching_cali_idx: int | None = None
  3279. matching_filament_id: str = tray_info_idx
  3280. async with async_session() as db:
  3281. from sqlalchemy import or_, select as sa_select
  3282. from sqlalchemy.orm import selectinload
  3283. # Stage 1: local SpoolAssignment + SpoolKProfile match
  3284. result = await db.execute(
  3285. sa_select(SA)
  3286. .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
  3287. .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
  3288. )
  3289. assignment = result.scalar_one_or_none()
  3290. spool: Spool | None = assignment.spool if assignment else None
  3291. # Stage 1b: tag-based fallback. The slot may have just been reset
  3292. # (SpoolAssignment row deleted) before the user triggered a re-read.
  3293. # The live tray already carries the spool's tray_uuid/tag_uid from
  3294. # the RFID re-read, but the SA row hasn't been re-created yet.
  3295. # Without this fallback we miss the stored SpoolKProfile and Stage 3
  3296. # ends up re-asserting whatever cali_idx the firmware reset to
  3297. # (typically the default profile).
  3298. if spool is None:
  3299. norm_uuid = normalize_tray_uuid(tray_uuid) if tray_uuid else ""
  3300. norm_tag = normalize_tag_uid(tag_uid) if tag_uid else ""
  3301. tag_filters = []
  3302. if norm_uuid and norm_uuid != ZERO_TRAY_UUID:
  3303. tag_filters.append(Spool.tray_uuid == norm_uuid)
  3304. if norm_tag and norm_tag != ZERO_TAG_UID:
  3305. tag_filters.append(Spool.tag_uid == norm_tag)
  3306. if tag_filters:
  3307. tag_lookup = await db.execute(
  3308. sa_select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
  3309. )
  3310. spool = tag_lookup.scalar_one_or_none()
  3311. if spool is not None:
  3312. logger.info(
  3313. "PA re-apply AMS%d-T%d: matched spool %d via tag fallback "
  3314. "(SpoolAssignment row missing, likely after slot reset)",
  3315. ams_id,
  3316. slot_id,
  3317. spool.id,
  3318. )
  3319. if spool is not None and spool.k_profiles:
  3320. # Prefer exact extruder match, fall back to extruder-agnostic kp
  3321. # for the same printer + nozzle. Hard-skipping on extruder
  3322. # mismatch made the cascade refuse perfectly valid stored
  3323. # profiles whenever the AMS-extruder mapping had shifted since
  3324. # calibration time, falling all the way through to Stage 3 and
  3325. # re-asserting the firmware default.
  3326. exact_kp = None
  3327. fallback_kp = None
  3328. for kp in spool.k_profiles:
  3329. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
  3330. continue
  3331. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  3332. exact_kp = kp
  3333. break
  3334. if fallback_kp is None:
  3335. fallback_kp = kp
  3336. chosen_kp = exact_kp or fallback_kp
  3337. if chosen_kp is not None:
  3338. matching_cali_idx = chosen_kp.cali_idx
  3339. # The filament_id in extrusion_cali_sel must match the preset
  3340. # under which the K-profile was calibrated. Prefer the spool's
  3341. # slicer_filament setting, falling back to the tray's RFID value.
  3342. matching_filament_id = spool.slicer_filament or tray_info_idx
  3343. # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match
  3344. # (only when no local spool was matched — local takes priority,
  3345. # including the tag-based fallback above)
  3346. if matching_cali_idx is None and spool is None:
  3347. sm_result = await db.execute(
  3348. sa_select(SpoolmanSlotAssignment).where(
  3349. SpoolmanSlotAssignment.printer_id == printer_id,
  3350. SpoolmanSlotAssignment.ams_id == ams_id,
  3351. SpoolmanSlotAssignment.tray_id == slot_id,
  3352. )
  3353. )
  3354. sm_assignment = sm_result.scalar_one_or_none()
  3355. if sm_assignment:
  3356. kp_result = await db.execute(
  3357. sa_select(SpoolmanKProfile).where(
  3358. SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
  3359. SpoolmanKProfile.printer_id == printer_id,
  3360. )
  3361. )
  3362. for kp in kp_result.scalars().all():
  3363. if kp.nozzle_diameter == nozzle_diameter:
  3364. if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
  3365. continue
  3366. if kp.cali_idx is not None:
  3367. matching_cali_idx = kp.cali_idx
  3368. # Spoolman has no slicer_filament — use the tray's RFID value
  3369. matching_filament_id = tray_info_idx
  3370. break
  3371. # Stage 3: live tray.cali_idx fallback. Re-asserts the printer's current
  3372. # selection so the value sticks across the RFID re-read (otherwise some
  3373. # firmwares clear cali_idx back to -1 mid-cycle).
  3374. if matching_cali_idx is None:
  3375. live_cali_idx = tray.get("cali_idx")
  3376. if live_cali_idx is not None and live_cali_idx >= 0:
  3377. matching_cali_idx = live_cali_idx
  3378. if matching_cali_idx is None:
  3379. logger.debug(
  3380. "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT",
  3381. ams_id,
  3382. slot_id,
  3383. )
  3384. return
  3385. logger.info(
  3386. "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
  3387. ams_id,
  3388. slot_id,
  3389. matching_cali_idx,
  3390. matching_filament_id,
  3391. )
  3392. # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
  3393. # "this is a manual config" which destroys the RFID-detected spool state
  3394. # (changes eye icon to pen icon in slicer).
  3395. client.extrusion_cali_sel(
  3396. ams_id=ams_id,
  3397. tray_id=slot_id,
  3398. cali_idx=matching_cali_idx,
  3399. filament_id=matching_filament_id,
  3400. nozzle_diameter=nozzle_diameter,
  3401. )
  3402. # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
  3403. # selected the correct profile by cali_idx. Sending extrusion_cali_set with
  3404. # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
  3405. # nozzle_id, name), corrupting it.
  3406. logger.info(
  3407. "Applied PA profile cali_idx=%d to printer %d AMS%d-T%d",
  3408. matching_cali_idx,
  3409. printer_id,
  3410. ams_id,
  3411. slot_id,
  3412. )
  3413. except Exception as e:
  3414. logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
  3415. @router.post("/{printer_id}/ams/load")
  3416. async def ams_load(
  3417. printer_id: int,
  3418. tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
  3419. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3420. db: AsyncSession = Depends(get_db),
  3421. ):
  3422. """Load filament from a specific AMS slot or external spool.
  3423. Tray ID encoding (matches Bambu firmware convention):
  3424. - 0..15: AMS slot, computed as ams_id * 4 + slot_id
  3425. - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
  3426. - 255: Ext-R on dual-nozzle H2D
  3427. """
  3428. # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
  3429. # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
  3430. if tray_id not in range(16) and tray_id not in range(24, 28) and tray_id not in (254, 255):
  3431. raise HTTPException(
  3432. 400, "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
  3433. )
  3434. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3435. printer = result.scalar_one_or_none()
  3436. if not printer:
  3437. raise HTTPException(404, "Printer not found")
  3438. client = printer_manager.get_client(printer_id)
  3439. if not client:
  3440. raise HTTPException(400, "Printer not connected")
  3441. success = client.ams_load_filament(tray_id)
  3442. if not success:
  3443. raise HTTPException(500, "Failed to send load command")
  3444. if tray_id == 254:
  3445. target = "external spool"
  3446. elif tray_id == 255:
  3447. target = "Ext-R"
  3448. else:
  3449. target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
  3450. return {"success": True, "message": f"Loading filament from {target}"}
  3451. @router.post("/{printer_id}/ams/unload")
  3452. async def ams_unload(
  3453. printer_id: int,
  3454. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3455. db: AsyncSession = Depends(get_db),
  3456. ):
  3457. """Unload the currently loaded filament."""
  3458. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3459. printer = result.scalar_one_or_none()
  3460. if not printer:
  3461. raise HTTPException(404, "Printer not found")
  3462. client = printer_manager.get_client(printer_id)
  3463. if not client:
  3464. raise HTTPException(400, "Printer not connected")
  3465. success = client.ams_unload_filament()
  3466. if not success:
  3467. raise HTTPException(500, "Failed to send unload command")
  3468. return {"success": True, "message": "Unloading filament"}
  3469. @router.get("/{printer_id}/runtime-debug")
  3470. async def get_runtime_debug(
  3471. printer_id: int,
  3472. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  3473. db: AsyncSession = Depends(get_db),
  3474. ):
  3475. """Debug endpoint: Get runtime tracking status for a printer."""
  3476. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3477. printer = result.scalar_one_or_none()
  3478. if not printer:
  3479. raise HTTPException(404, "Printer not found")
  3480. state = printer_manager.get_status(printer_id)
  3481. return {
  3482. "printer_name": printer.name,
  3483. "runtime_seconds": printer.runtime_seconds,
  3484. "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
  3485. "print_hours_offset": printer.print_hours_offset,
  3486. "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
  3487. + (printer.print_hours_offset or 0),
  3488. "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
  3489. "mqtt_state": {
  3490. "connected": state.connected if state else False,
  3491. "state": state.state if state else None,
  3492. "progress": state.progress if state else None,
  3493. "gcode_file": state.gcode_file if state else None,
  3494. }
  3495. if state
  3496. else None,
  3497. "is_active": printer.is_active,
  3498. }
  3499. @router.post("/{printer_id}/hms/execute-action")
  3500. async def execute_hms_action(
  3501. printer_id: int,
  3502. body: HmsActionBody,
  3503. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3504. db: AsyncSession = Depends(get_db),
  3505. ):
  3506. """Execute an HMS action on the printer."""
  3507. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3508. printer = result.scalar_one_or_none()
  3509. if not printer:
  3510. raise HTTPException(404, "Printer not found")
  3511. client = printer_manager.get_client(printer_id)
  3512. if not client:
  3513. raise HTTPException(400, "Printer not connected")
  3514. # Snapshot pre-state so we can verify the printer actually acted on the
  3515. # command. publish() success is NOT the same as printer-ack: Bambu's
  3516. # firmware silently rejects malformed HMS commands at QoS 1 (the broker
  3517. # ACKs the publish, but the printer drops it). Verified end-to-end against
  3518. # a live H2D — see #1830 §(3).
  3519. #
  3520. # We probe `_last_message_time` (bumped on every MQTT push) rather than a
  3521. # (gcode_state, hms_errors-length) diff. The old diff missed the
  3522. # wrong-plate IGNORE_RESUME case where the printer briefly resumes and
  3523. # re-pauses with the same fault inside the 2.5s window: both fields
  3524. # round-trip to their pre-publish values → false 502 even though the
  3525. # firmware fully ack'd the resume. Every accepted command triggers a
  3526. # pushall response within ~100-500ms, so a fresh inbound message after
  3527. # the publish is the robust ack signal.
  3528. pre_last_message = client._last_message_time
  3529. success = client.execute_hms_action(body.print_error, body.action, body.job_id)
  3530. if not success:
  3531. raise HTTPException(400, "Failed to execute HMS action")
  3532. # Give the printer time to push a state update. The dispatch helper already
  3533. # publishes a pushall after every command, so a fresh status should arrive
  3534. # within ~1s; the default 2.5s covers slower firmware variants without
  3535. # making the UI feel hung. Plain sleep is fine — paho's MQTT callback
  3536. # runs in its own thread and updates state regardless of whether this
  3537. # coroutine is awaiting.
  3538. await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
  3539. acked = client._last_message_time > pre_last_message
  3540. if not acked:
  3541. # Publish succeeded but the printer sent nothing back. Almost always
  3542. # firmware-side silent rejection (err mismatch, command/state mismatch)
  3543. # or a dropped MQTT route. 502 makes it visible at the UI instead of
  3544. # the 200-but-broken loop #1830 reported.
  3545. raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
  3546. return {"success": True, "message": "HMS action executed"}