printers.py 203 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775
  1. import asyncio
  2. import logging
  3. import re
  4. import secrets
  5. import zipfile
  6. from pathlib import Path
  7. from fastapi import APIRouter, Depends, HTTPException, Query
  8. from fastapi.responses import FileResponse, Response
  9. from sqlalchemy import delete, func, select
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from starlette.background import BackgroundTask
  12. from backend.app.core import database
  13. from backend.app.core.auth import (
  14. RequestPrinterScope,
  15. RequireOverlayTokenIfAuthEnabled,
  16. RequirePermissionIfAuthEnabled,
  17. RequirePrinterPermissionIfAuthEnabled,
  18. is_auth_enabled,
  19. require_media_token_printer_permission,
  20. )
  21. from backend.app.core.config import settings
  22. from backend.app.core.database import get_db
  23. from backend.app.core.permissions import Permission
  24. from backend.app.core.printer_scope import PrinterScope, location_grantees
  25. from backend.app.core.tasks import spawn_background_task
  26. from backend.app.core.websocket import ws_manager
  27. from backend.app.models.ams_label import AmsLabel
  28. from backend.app.models.printer import Printer
  29. from backend.app.models.slot_preset import SlotPresetMapping
  30. from backend.app.models.user import User
  31. from backend.app.schemas.printer import (
  32. AmsLabelBody,
  33. AMSTray,
  34. AMSUnit,
  35. DiagnosticRequest,
  36. ExtruderSlotResponse,
  37. FilaSwitchResponse,
  38. HmsActionBody,
  39. NextPause,
  40. NozzleInfoResponse,
  41. NozzleRackSlot,
  42. PrinterCreate,
  43. PrinterDiagnosticResult,
  44. PrinterFilesDownloadRequest,
  45. PrinterFilesJobRequest,
  46. PrinterResponse,
  47. PrinterResponseWithSecret,
  48. PrinterStatus,
  49. PrinterUpdate,
  50. PrintOptionsResponse,
  51. hms_error_responses,
  52. )
  53. from backend.app.services import camera_light as camera_light_service, drying_preflight, kprofile_drift
  54. from backend.app.services.bambu_ftp import (
  55. DownloadDeadlineExceeded,
  56. FileNotOnPrinterError,
  57. cache_3mf_download,
  58. delete_file_async,
  59. download_file_bytes_async,
  60. download_file_try_paths_async,
  61. ftps_handshake_blocked,
  62. get_cached_3mf,
  63. get_storage_info_async,
  64. list_files_result_async,
  65. )
  66. from backend.app.services.hms_actions import HMSAction
  67. from backend.app.services.pause_eta import next_pause
  68. from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp
  69. from backend.app.services.printer_diagnostic import run_connection_diagnostic
  70. from backend.app.services.printer_file_cache import printer_file_cache
  71. from backend.app.services.printer_manager import (
  72. ams_units_to_dicts,
  73. display_temperatures,
  74. drying_screen_only,
  75. get_derived_status_name,
  76. nozzle_wiring,
  77. print_ams_mapping,
  78. printer_manager,
  79. resolve_expected_tray,
  80. resolve_plate_id,
  81. supports_chamber_heater,
  82. supports_chamber_temp,
  83. supports_drying,
  84. supports_drying_while_printing,
  85. vt_trays_to_dicts,
  86. )
  87. from backend.app.services.printer_media import (
  88. MAX_PRINTER_ZIP_PREPARE_SECONDS,
  89. PrinterFileDownloadError,
  90. PrinterFilesZipInsufficientSpaceError,
  91. PrinterFilesZipTooLargeError,
  92. build_printer_file,
  93. build_printer_files_zip,
  94. cancel_printer_files_job,
  95. get_printer_files_job,
  96. printer_file_path,
  97. printer_files_zip_path,
  98. remove_printer_files_zip,
  99. start_printer_files_job,
  100. )
  101. from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
  102. from backend.app.services.slot_nozzle import reports_two_nozzles, resolve_slot_nozzle
  103. from backend.app.utils.filament_finish import different_finish, finish_generic_id
  104. from backend.app.utils.filament_ids import (
  105. GENERIC_FILAMENT_IDS,
  106. GENERIC_IDS_REPLACED_ON_REUSE,
  107. filament_id_to_setting_id,
  108. )
  109. from backend.app.utils.filament_types import is_material_name, printer_filament_type
  110. from backend.app.utils.fts_routing import slot_extruder
  111. from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
  112. from backend.app.utils.kprofile_lookup import build_slot_k_resolver
  113. from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
  114. logger = logging.getLogger(__name__)
  115. router = APIRouter(prefix="/printers", tags=["printers"])
  116. # Seconds the /hms/execute-action route waits for a printer status push
  117. # confirming the command landed before reporting 502 to the UI. Module-level
  118. # so tests can monkeypatch a near-zero value instead of mocking asyncio.sleep.
  119. HMS_ACTION_ACK_WAIT_SECONDS = 2.5
  120. async def _caller_can_view_printer_secrets(user: User | None, db: AsyncSession) -> bool:
  121. """Whether the caller is trusted enough to see ``access_code`` on a printer
  122. response. Fail-CLOSED: anything that isn't an authenticated user holding
  123. PRINTERS_UPDATE returns False.
  124. - Auth disabled → True (single trust domain — same as today's local UI).
  125. - JWT user with PRINTERS_UPDATE → True (Admin or Operator; the same roles
  126. that already manage printers and the Virtual Printer card UX that
  127. surfaces a target's code for slicer configuration).
  128. - JWT Viewer → False (the bug fix: Viewers must not be able to read
  129. access_code via PRINTERS_READ and then go around Bambuddy to MQTT).
  130. - API-key principal (``user is None`` because the dep returns None for
  131. API keys) → False. PRINTERS_UPDATE is admin-only and absent from
  132. ``_APIKEY_SCOPE_BY_PERMISSION``, so no API key can hold it.
  133. """
  134. if not await is_auth_enabled(db):
  135. return True
  136. if user is None:
  137. return False
  138. return user.has_permission(Permission.PRINTERS_UPDATE.value)
  139. def _serialize_printer(printer: Printer, *, include_secret: bool):
  140. """Build the response shape that matches the caller's authority."""
  141. if include_secret:
  142. return PrinterResponseWithSecret.model_validate(printer)
  143. return PrinterResponse.model_validate(printer)
  144. @router.get("/")
  145. async def list_printers(
  146. user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  147. printer_scope: PrinterScope = RequestPrinterScope,
  148. db: AsyncSession = Depends(get_db, scope="function"),
  149. ):
  150. """List the configured printers the caller may see (#1727).
  151. ``access_code`` is included in each item only when the caller is trusted
  152. to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
  153. API keys never receive it.
  154. """
  155. query = select(Printer).order_by(Printer.name)
  156. if (clause := printer_scope.where_strict(Printer.id)) is not None:
  157. query = query.where(clause)
  158. result = await db.execute(query)
  159. printers = list(result.scalars().all())
  160. include_secret = await _caller_can_view_printer_secrets(user, db)
  161. return [_serialize_printer(p, include_secret=include_secret) for p in printers]
  162. @router.post("/", response_model=PrinterResponse)
  163. async def create_printer(
  164. printer_data: PrinterCreate,
  165. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  166. db: AsyncSession = Depends(get_db, scope="function"),
  167. ):
  168. """Add a new printer.
  169. Verifies the MQTT connection succeeds before persisting. A wrong access
  170. code or unreachable IP would otherwise create a printer row that shows
  171. as an empty / never-connecting card on the dashboard — those reports
  172. were turning into support tickets that all traced back to a mistyped
  173. access code.
  174. """
  175. # Check if serial number already exists
  176. result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number))
  177. if result.scalar_one_or_none():
  178. raise HTTPException(400, "Printer with this serial number already exists")
  179. test_result = await printer_manager.test_connection(
  180. ip_address=printer_data.ip_address,
  181. serial_number=printer_data.serial_number,
  182. access_code=printer_data.access_code,
  183. )
  184. if not test_result.get("success"):
  185. # The frontend renders the user-facing message via i18n on `code`;
  186. # `message` is an English fallback for non-UI clients (curl / scripts).
  187. raise HTTPException(
  188. status_code=400,
  189. detail={
  190. "code": "printer_connection_failed",
  191. "message": (
  192. "Could not connect to the printer. Verify IP address, serial number, "
  193. "and access code, and confirm LAN-only mode is enabled. "
  194. "The printer was not added."
  195. ),
  196. },
  197. )
  198. printer = Printer(**printer_data.model_dump())
  199. db.add(printer)
  200. await db.commit()
  201. await db.refresh(printer)
  202. # A new printer has no plate references: any under its id are a deleted
  203. # printer's (SQLite reuses ids; deletes before this left them behind).
  204. from backend.app.services.plate_detection import remove_printer_references
  205. remove_printer_references(printer.id)
  206. # A group given this location reaches the new printer straight away (#1727)
  207. if await location_grantees(db, [printer.location]):
  208. await ws_manager.refresh_printer_scopes()
  209. # Connect to the printer
  210. if printer.is_active:
  211. await printer_manager.connect_printer(printer)
  212. return printer
  213. @router.get("/usb-cameras")
  214. async def list_usb_cameras(
  215. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  216. ):
  217. """List available USB cameras connected to the system.
  218. Returns a list of detected V4L2 video devices with their info.
  219. Only works on Linux systems with V4L2 support.
  220. Returns:
  221. List of dicts with {device: str, name: str, capabilities: list, formats?: list}
  222. """
  223. from backend.app.services import external_camera
  224. # v4l2-ctl runs twice per device, with a 5 s timeout each.
  225. cameras = await asyncio.to_thread(external_camera.list_usb_cameras)
  226. return {"cameras": cameras}
  227. @router.get("/available-filaments")
  228. async def get_available_filaments(
  229. model: str = Query(..., description="Target printer model"),
  230. location: str | None = Query(None, description="Optional location filter"),
  231. _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
  232. printer_scope: PrinterScope = RequestPrinterScope,
  233. db: AsyncSession = Depends(get_db, scope="function"),
  234. ):
  235. """Get deduplicated list of filaments loaded across all active printers of a given model.
  236. Used by the frontend to offer filament override options for model-based queue assignment.
  237. """
  238. from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
  239. # Normalize model name
  240. normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model
  241. query = (
  242. select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True) # noqa: E712
  243. )
  244. if location:
  245. query = query.where(Printer.location == location)
  246. result = await db.execute(query)
  247. printers_list = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
  248. if not printers_list:
  249. return []
  250. # Collect filaments from all matching printers
  251. # Dedup key includes extruder_id and tray_sub_brands so "PLA Basic" and "PLA Matte" appear separately
  252. seen: set[tuple[str, str, str, int | None]] = set() # (type_upper, color_normalized, sub_brands_upper, extruder_id)
  253. filaments = []
  254. for printer in printers_list:
  255. status = printer_manager.get_status(printer.id)
  256. if not status:
  257. continue
  258. # Get ams_extruder_map for dual-nozzle printers
  259. ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
  260. # Same signal the scheduler matches with (#2771): without an AMS the map
  261. # is empty on a dual-nozzle printer too.
  262. is_dual_nozzle = reports_two_nozzles(status)
  263. # AMS trays
  264. for ams_unit in status.raw_data.get("ams", []):
  265. ams_id = str(ams_unit.get("id", 0))
  266. extruder_id = ams_extruder_map.get(ams_id)
  267. for tray in ams_unit.get("tray", []):
  268. tray_type = tray.get("tray_type")
  269. if not tray_type:
  270. continue
  271. tray_color = tray.get("tray_color", "") or "808080"
  272. # Preserve the full RRGGBBAA so transparent filament (alpha=00)
  273. # reaches the frontend instead of collapsing to #000000 → black
  274. # (#1545). Opaque colours still round-trip as #RRGGBB. The
  275. # dedup key uses the 6-char RGB so two slots that share an RGB
  276. # but differ only in alpha still merge.
  277. stripped = tray_color.replace("#", "")
  278. rgb = stripped[:6].lower() or "808080"
  279. color = f"#{stripped}"
  280. tray_info_idx = tray.get("tray_info_idx", "")
  281. tray_sub_brands = tray.get("tray_sub_brands", "") or ""
  282. key = (tray_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
  283. if key not in seen:
  284. seen.add(key)
  285. filaments.append(
  286. {
  287. "type": tray_type,
  288. "color": color,
  289. "tray_info_idx": tray_info_idx,
  290. "tray_sub_brands": tray_sub_brands,
  291. "extruder_id": extruder_id,
  292. }
  293. )
  294. # External spools (vt_tray)
  295. for vt in status.raw_data.get("vt_tray") or []:
  296. vt_type = vt.get("tray_type")
  297. if not vt_type:
  298. continue
  299. vt_color = vt.get("tray_color", "") or "808080"
  300. # Same alpha-preserving handling as the AMS branch — see #1545.
  301. stripped = vt_color.replace("#", "")
  302. rgb = stripped[:6].lower() or "808080"
  303. color = f"#{stripped}"
  304. tray_info_idx = vt.get("tray_info_idx", "")
  305. tray_sub_brands = vt.get("tray_sub_brands", "") or ""
  306. vt_id = int(vt.get("id", 254))
  307. extruder_id = (255 - vt_id) if is_dual_nozzle else None
  308. key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
  309. if key not in seen:
  310. seen.add(key)
  311. filaments.append(
  312. {
  313. "type": vt_type,
  314. "color": color,
  315. "tray_info_idx": tray_info_idx,
  316. "tray_sub_brands": tray_sub_brands,
  317. "extruder_id": extruder_id,
  318. }
  319. )
  320. return filaments
  321. @router.get("/developer-mode-warnings")
  322. async def get_developer_mode_warnings(
  323. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
  324. printer_scope: PrinterScope = RequestPrinterScope,
  325. db: AsyncSession = Depends(get_db, scope="function"),
  326. ):
  327. """Check if any connected printer the caller can see lacks developer LAN mode."""
  328. result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
  329. printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
  330. statuses = printer_manager.get_all_statuses()
  331. warnings = []
  332. for printer in printers:
  333. state = statuses.get(printer.id)
  334. if state and state.connected and state.developer_mode is False:
  335. warnings.append(
  336. {
  337. "printer_id": printer.id,
  338. "name": printer.name,
  339. }
  340. )
  341. return warnings
  342. @router.get("/{printer_id}")
  343. async def get_printer(
  344. printer_id: int,
  345. user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  346. db: AsyncSession = Depends(get_db, scope="function"),
  347. ):
  348. """Get a specific printer.
  349. ``access_code`` is included only when the caller is trusted to see it
  350. (Admin / Operator JWT, or auth-disabled mode). Viewers and API keys
  351. never receive it.
  352. """
  353. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  354. printer = result.scalar_one_or_none()
  355. if not printer:
  356. raise HTTPException(404, "Printer not found")
  357. include_secret = await _caller_can_view_printer_secrets(user, db)
  358. return _serialize_printer(printer, include_secret=include_secret)
  359. @router.patch("/{printer_id}", response_model=PrinterResponse)
  360. async def update_printer(
  361. printer_id: int,
  362. printer_data: PrinterUpdate,
  363. user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  364. db: AsyncSession = Depends(get_db, scope="function"),
  365. ):
  366. """Update a printer."""
  367. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  368. printer = result.scalar_one_or_none()
  369. if not printer:
  370. raise HTTPException(404, "Printer not found")
  371. update_data = printer_data.model_dump(exclude_unset=True)
  372. # Groups can be given a location (#1727), so moving a printer between
  373. # locations changes who can reach it. That is an access change and only an
  374. # admin may make it; an API key never counts as one.
  375. access_moved = False
  376. if "location" in update_data and update_data["location"] != printer.location:
  377. access_moved = bool(await location_grantees(db, [printer.location, update_data["location"]]))
  378. if access_moved and await is_auth_enabled(db) and not (user is not None and user.is_admin):
  379. raise HTTPException(
  380. 403,
  381. "Moving this printer to another location changes which groups can access it. Only an admin can do that.",
  382. )
  383. # Handle nested ROI object - flatten to individual columns
  384. if "plate_detection_roi" in update_data:
  385. roi = update_data.pop("plate_detection_roi")
  386. if roi:
  387. update_data["plate_detection_roi_x"] = roi.get("x")
  388. update_data["plate_detection_roi_y"] = roi.get("y")
  389. update_data["plate_detection_roi_w"] = roi.get("w")
  390. update_data["plate_detection_roi_h"] = roi.get("h")
  391. else:
  392. # Clear ROI if set to null
  393. update_data["plate_detection_roi_x"] = None
  394. update_data["plate_detection_roi_y"] = None
  395. update_data["plate_detection_roi_w"] = None
  396. update_data["plate_detection_roi_h"] = None
  397. ip_changed = "ip_address" in update_data and update_data["ip_address"] != printer.ip_address
  398. for field, value in update_data.items():
  399. setattr(printer, field, value)
  400. await db.commit()
  401. await db.refresh(printer)
  402. if access_moved:
  403. await ws_manager.refresh_printer_scopes()
  404. # Proxy virtual printers forward to the IP they were started with; the
  405. # sync restarts the ones aimed at this printer on the new one.
  406. if ip_changed:
  407. from backend.app.services.virtual_printer import virtual_printer_manager
  408. try:
  409. await virtual_printer_manager.sync_from_db()
  410. except Exception as e:
  411. logger.error("Failed to sync virtual printers after printer IP change: %s", e)
  412. # Reconnect if connection settings changed
  413. if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
  414. printer_manager.disconnect_printer(printer_id)
  415. if printer.is_active:
  416. await printer_manager.connect_printer(printer)
  417. return printer
  418. @router.delete("/{printer_id}")
  419. async def delete_printer(
  420. printer_id: int,
  421. delete_archives: bool = True,
  422. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
  423. db: AsyncSession = Depends(get_db, scope="function"),
  424. ):
  425. """Delete a printer.
  426. Args:
  427. printer_id: ID of the printer to delete
  428. delete_archives: If True (default), delete all print archives for this printer.
  429. If False, keep archives but remove their printer association.
  430. """
  431. from sqlalchemy import delete as sql_delete
  432. from backend.app.models.archive import PrintArchive
  433. from backend.app.models.group import group_printers
  434. from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
  435. from backend.app.models.scheduled_drying import ScheduledDrying
  436. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  437. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  438. printer = result.scalar_one_or_none()
  439. if not printer:
  440. raise HTTPException(404, "Printer not found")
  441. if delete_archives:
  442. # Deleting an archive removes its queue items; one that is printing
  443. # (a reprint on another printer, say) must not go from under the
  444. # dispatcher -- the same 409 as DELETE /archives/{id} (#1734).
  445. from backend.app.models.print_queue import PrintQueueItem
  446. printing = await db.scalar(
  447. select(func.count())
  448. .select_from(PrintQueueItem)
  449. .where(
  450. PrintQueueItem.status == "printing",
  451. PrintQueueItem.archive_id.in_(select(PrintArchive.id).where(PrintArchive.printer_id == printer_id)),
  452. )
  453. )
  454. if printing:
  455. raise HTTPException(
  456. 409,
  457. f"Cannot delete this printer's archives — {printing} queue item(s) using them are "
  458. f"currently printing. Stop the print first, or keep the archives.",
  459. )
  460. # What was limited to this printer goes with it, rather than reaching every
  461. # printer (a provider's NULL printer means all) or, on SQLite, whichever
  462. # printer is given this id next (an API key's list keeps the id). Providers
  463. # first: retiring a Bambuddy Notify one commits.
  464. from backend.app.api.routes.notifications import after_providers_deleted, delete_provider
  465. from backend.app.core.printer_scope import drop_printers_from_api_keys, drop_printers_from_obico
  466. from backend.app.models.notification import NotificationProvider
  467. notify = False
  468. scoped = await db.execute(select(NotificationProvider).where(NotificationProvider.printer_id == printer_id))
  469. for provider in scoped.scalars().all():
  470. notify = await delete_provider(db, provider) or notify
  471. printer_manager.disconnect_printer(printer_id)
  472. # Locked from the archive sweep to the commit (PostgreSQL; SQLite's single
  473. # writer serialises this anyway). A print start adopting or writing an
  474. # archive for this printer meanwhile then waits and is refused by the
  475. # printer's foreign key, instead of passing it and being deleted by the
  476. # ORM delete's cascade below: also an archive the user chose to keep, or
  477. # another printer's (measured). Not earlier: retiring a Notify provider
  478. # above commits, which ends a lock; nothing commits from here on.
  479. await db.execute(select(Printer.id).where(Printer.id == printer_id).with_for_update())
  480. if delete_archives:
  481. # Delete all archives for this printer
  482. await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
  483. else:
  484. # Orphan the archives instead of deleting them
  485. from sqlalchemy import update
  486. await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
  487. # Delete slot assignments for this printer (SQLite doesn't enforce FK cascades)
  488. await db.execute(sql_delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
  489. # Delete scheduled drying runs for this printer (SQLite doesn't enforce FK cascades)
  490. await db.execute(sql_delete(ScheduledDrying).where(ScheduledDrying.printer_id == printer_id))
  491. # Drop it from printer-scoped groups (SQLite doesn't enforce FK cascades)
  492. await db.execute(sql_delete(group_printers).where(group_printers.c.printer_id == printer_id))
  493. # Delete maintenance history and items for this printer
  494. # (SQLite doesn't enforce FK cascades, so do it explicitly)
  495. maintenance_ids = (
  496. (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id)))
  497. .scalars()
  498. .all()
  499. )
  500. if maintenance_ids:
  501. await db.execute(
  502. sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids))
  503. )
  504. await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
  505. await drop_printers_from_api_keys(db, [printer_id])
  506. await drop_printers_from_obico(db, [printer_id])
  507. # A virtual printer aimed at it goes too: without a target a queue-mode one
  508. # takes jobs for any printer of its model (deleted below, with its plugs).
  509. from backend.app.api.routes.virtual_printers import drop_virtual_printer, remove_upload_dir
  510. from backend.app.models.virtual_printer import VirtualPrinter
  511. from backend.app.services.virtual_printer import virtual_printer_manager
  512. aimed = (
  513. (await db.execute(select(VirtualPrinter.id).where(VirtualPrinter.target_printer_id == printer_id)))
  514. .scalars()
  515. .all()
  516. )
  517. # Its smart plugs too: without a printer a plug is everyone's, and users and
  518. # keys limited to other printers could switch what it still powers.
  519. from backend.app.api.routes.smart_plugs import delete_plug
  520. from backend.app.models.smart_plug import SmartPlug
  521. for plug in (await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))).scalars().all():
  522. await delete_plug(db, plug)
  523. upload_dirs = []
  524. for vp_id in aimed:
  525. await virtual_printer_manager.remove_instance(vp_id)
  526. upload_dirs.append(await drop_virtual_printer(db, vp_id))
  527. await db.delete(printer)
  528. await db.commit()
  529. await after_providers_deleted(notify=notify)
  530. for upload_dir in upload_dirs:
  531. remove_upload_dir(upload_dir)
  532. if aimed:
  533. try:
  534. await virtual_printer_manager.sync_from_db()
  535. except Exception as e:
  536. logger.error("Failed to sync virtual printers after printer delete: %s", e)
  537. # Open connections worked out their printers at connect.
  538. await ws_manager.refresh_printer_scopes()
  539. # What is kept for its id in memory and on disk: SQLite gives the id to the
  540. # next printer created.
  541. from backend.app.main import forget_printer
  542. await forget_printer(printer_id)
  543. return {"status": "deleted", "archives_deleted": delete_archives}
  544. @router.get("/{printer_id}/status", response_model=PrinterStatus)
  545. async def get_printer_status(
  546. printer_id: int,
  547. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  548. db: AsyncSession = Depends(get_db, scope="function"),
  549. ):
  550. """Get real-time status of a printer."""
  551. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  552. printer = result.scalar_one_or_none()
  553. if not printer:
  554. raise HTTPException(404, "Printer not found")
  555. return await _printer_status(db, printer)
  556. async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
  557. """The live status of *printer*, as the status endpoints report it."""
  558. printer_id = printer.id
  559. state = printer_manager.get_status(printer_id)
  560. if not state:
  561. # No MQTT client state — the printer was never connected this run, or it
  562. # was disconnected manually. The plate-clear gate is Bambuddy-side and
  563. # persisted, so it still has a truthful value here (#2864); reporting the
  564. # schema default instead told clients the plate was clean and hid the
  565. # only control that can release the gate.
  566. return PrinterStatus(
  567. id=printer_id,
  568. name=printer.name,
  569. connected=False,
  570. awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
  571. )
  572. # Determine cover URL if there's an active print (including paused)
  573. cover_url = None
  574. if state.state in ("RUNNING", "PAUSE") and state.gcode_file:
  575. cover_url = f"/api/v1/printers/{printer_id}/cover"
  576. hms_errors = hms_error_responses(state.hms_errors)
  577. # Parse AMS data from raw_data
  578. ams_units = []
  579. vt_tray = []
  580. ams_exists = False
  581. raw_data = state.raw_data or {}
  582. # K value for a slot's bound profile, preferring the slot's own nozzle.
  583. #
  584. # cali_idx alone is not enough: two profiles can share an index and differ
  585. # by extruder, and a cali_idx-only map let whichever the printer listed
  586. # last overwrite the other — on the maintainer's H2C, 0.018 and 0.020 for
  587. # the same spool. Nor is the extruder a requirement: one profile can be
  588. # what both extruders' slots point at, and demanding a match blanked every
  589. # slot on a second AMS (#3044). The resolver does both in order.
  590. _kprofile_k = build_slot_k_resolver(state)
  591. # Cached active-cycle drying params (filament + target temp) we sent
  592. # last; Bambu doesn't echo them on the per-tick AMS push, so the badge
  593. # needs the cache to render "<filament> @ <temp>°C".
  594. drying_targets = printer_manager.get_drying_targets(printer_id) or {}
  595. if "ams" in raw_data and isinstance(raw_data["ams"], list):
  596. ams_exists = True
  597. # Same serialiser as the WebSocket push, so the card cannot lose a
  598. # field between page load and the first push.
  599. ams_units = [AMSUnit(**unit) for unit in ams_units_to_dicts(raw_data["ams"], _kprofile_k, drying_targets)]
  600. # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
  601. if "vt_tray" in raw_data:
  602. vt_tray = [AMSTray(**vt) for vt in vt_trays_to_dicts(raw_data["vt_tray"], _kprofile_k)]
  603. # Convert nozzle info to response format
  604. nozzles = [
  605. NozzleInfoResponse(
  606. nozzle_type=n.nozzle_type,
  607. nozzle_diameter=n.nozzle_diameter,
  608. )
  609. for n in (state.nozzles or [])
  610. ]
  611. # H2C nozzle rack (tool-changer dock positions)
  612. nozzle_rack = [
  613. NozzleRackSlot(
  614. id=n.get("id", 0),
  615. nozzle_type=n.get("type", ""),
  616. nozzle_diameter=n.get("diameter", ""),
  617. wear=n.get("wear"),
  618. stat=n.get("stat"),
  619. max_temp=n.get("max_temp", 0),
  620. serial_number=n.get("serial_number", ""),
  621. filament_color=n.get("filament_color", ""),
  622. filament_id=n.get("filament_id", ""),
  623. filament_type=n.get("filament_type", ""),
  624. )
  625. for n in (state.nozzle_rack or [])
  626. ]
  627. # Convert print options to response format
  628. print_options = PrintOptionsResponse(
  629. spaghetti_detector=state.print_options.spaghetti_detector,
  630. print_halt=state.print_options.print_halt,
  631. halt_print_sensitivity=state.print_options.halt_print_sensitivity,
  632. first_layer_inspector=state.print_options.first_layer_inspector,
  633. printing_monitor=state.print_options.printing_monitor,
  634. buildplate_marker_detector=state.print_options.buildplate_marker_detector,
  635. allow_skip_parts=state.print_options.allow_skip_parts,
  636. nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
  637. nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
  638. pileup_detector=state.print_options.pileup_detector,
  639. pileup_sensitivity=state.print_options.pileup_sensitivity,
  640. airprint_detector=state.print_options.airprint_detector,
  641. airprint_sensitivity=state.print_options.airprint_sensitivity,
  642. auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
  643. filament_tangle_detect=state.print_options.filament_tangle_detect,
  644. )
  645. # The running print's AMS mapping, for the slot pills (#1762).
  646. ams_mapping = print_ams_mapping(state)
  647. # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
  648. # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
  649. ams_extruder_map = state.ams_extruder_map or {}
  650. logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
  651. # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
  652. # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
  653. # No conversion needed - just use the raw value directly
  654. tray_now = state.tray_now
  655. logger.debug("Using tray_now directly as global ID: %s", tray_now)
  656. # Filter out chamber temp for models that don't have a real sensor
  657. # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
  658. temperatures = state.temperatures
  659. if not supports_chamber_temp(printer.model):
  660. temperatures = {
  661. k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
  662. }
  663. # Resolve the active print's archive + plate (#881 follow-up): lets the
  664. # printer card show the actual plate name for multi-plate 3MFs instead of
  665. # just the 3MF filename. Only attempted for active prints, since subtask_id
  666. # is only meaningful then. A print started outside the cloud reports
  667. # subtask_id "0" and is linked by its running archive instead (#3327).
  668. # A deleted archive is never named.
  669. current_archive_id: int | None = None
  670. current_plate_id: int | None = None
  671. if state.state in ("RUNNING", "PAUSE"):
  672. current_plate_id = resolve_plate_id(state)
  673. from backend.app.main import running_print_archive
  674. archive = await running_print_archive(db, printer_id, state, printing_only=False, by_name=True)
  675. current_archive_id = archive.id if archive is not None else None
  676. return PrinterStatus(
  677. id=printer_id,
  678. name=printer.name,
  679. connected=state.connected,
  680. state=state.state,
  681. current_print=state.current_print,
  682. subtask_name=state.subtask_name,
  683. gcode_file=state.gcode_file,
  684. progress=state.progress,
  685. remaining_time=state.remaining_time,
  686. layer_num=state.layer_num,
  687. total_layers=state.total_layers,
  688. temperatures=temperatures,
  689. cover_url=cover_url,
  690. hms_errors=hms_errors,
  691. ams=ams_units,
  692. ams_exists=ams_exists,
  693. vt_tray=vt_tray,
  694. sdcard=state.sdcard,
  695. store_to_sdcard=state.store_to_sdcard,
  696. timelapse=state.timelapse,
  697. ipcam=state.ipcam,
  698. wifi_signal=state.wifi_signal,
  699. wired_network=state.wired_network,
  700. door_open=state.door_open,
  701. nozzles=nozzles,
  702. nozzle_rack=nozzle_rack,
  703. print_options=print_options,
  704. stg_cur=state.stg_cur,
  705. stg_cur_name=get_derived_status_name(state, printer.model),
  706. stg=state.stg,
  707. airduct_mode=state.airduct_mode,
  708. speed_level=state.speed_level,
  709. chamber_light=state.chamber_light,
  710. active_extruder=state.active_extruder,
  711. ams_mapping=ams_mapping,
  712. ams_extruder_map=ams_extruder_map,
  713. # Only meaningful alongside an installed switch; without one the map is
  714. # empty anyway, but gating it keeps a stale binding from outliving the
  715. # accessory being unplugged.
  716. ams_switch_inlet=(dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
  717. # Which hotend holds which slot. Same first-load reasoning as
  718. # fila_switch.ready below — the AMS slot menu reads it to decide which
  719. # hotend it may offer, and an empty default would offer both.
  720. extruder_slots={
  721. str(ext_id): ExtruderSlotResponse(
  722. ams_id=slot.ams_id,
  723. slot_id=slot.slot_id,
  724. has_filament=slot.has_filament,
  725. )
  726. for ext_id, slot in state.extruder_slots.items()
  727. },
  728. tray_now=tray_now,
  729. tray_change_log=[[tray, layer] for tray, layer in state.tray_change_log],
  730. # Runout guidance (#2587): resolve the firmware's target/previous slot to a
  731. # global tray ID, but only while PAUSED — the moment the operator needs it.
  732. expected_tray=(
  733. resolve_expected_tray(
  734. state.tray_tar,
  735. [(u.id, u.is_ams_ht) for u in ams_units],
  736. raw_data.get("mapping"),
  737. active_extruder=state.active_extruder,
  738. ams_extruder_map=nozzle_wiring(state),
  739. )
  740. if state.state == "PAUSE"
  741. else None
  742. ),
  743. previous_tray=(
  744. resolve_expected_tray(
  745. state.tray_pre,
  746. [(u.id, u.is_ams_ht) for u in ams_units],
  747. raw_data.get("mapping"),
  748. active_extruder=state.active_extruder,
  749. ams_extruder_map=nozzle_wiring(state),
  750. )
  751. if state.state == "PAUSE"
  752. else None
  753. ),
  754. ams_status_main=state.ams_status_main,
  755. ams_status_sub=state.ams_status_sub,
  756. mc_print_sub_stage=state.mc_print_sub_stage,
  757. last_ams_update=state.last_ams_update,
  758. printable_objects_count=len(state.printable_objects),
  759. next_pause=NextPause(**upcoming) if (upcoming := next_pause(state)) else None,
  760. cooling_fan_speed=state.cooling_fan_speed,
  761. big_fan1_speed=state.big_fan1_speed,
  762. big_fan2_speed=state.big_fan2_speed,
  763. heatbreak_fan_speed=state.heatbreak_fan_speed,
  764. left_aux_fan_speed=state.left_aux_fan_speed,
  765. exhaust_fan_present=state.exhaust_fan_present,
  766. firmware_version=state.firmware_version,
  767. developer_mode=state.developer_mode if state else None,
  768. ams_filament_backup=state.ams_filament_backup if state else None,
  769. awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
  770. supports_drying=supports_drying(printer.model, state.firmware_version),
  771. supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
  772. drying_screen_only=drying_screen_only(printer.model),
  773. supports_chamber_heater=supports_chamber_heater(printer.model),
  774. current_archive_id=current_archive_id,
  775. current_plate_id=current_plate_id,
  776. fila_switch=(
  777. FilaSwitchResponse(
  778. installed=state.fila_switch.installed,
  779. in_slots=list(state.fila_switch.in_slots),
  780. out_extruders=list(state.fila_switch.out_extruders),
  781. stat=state.fila_switch.stat,
  782. info=state.fila_switch.info,
  783. # Must be computed here as well as in printer_state_to_dict: this
  784. # is what the page gets on its first load, and the WebSocket only
  785. # corrects it on the next push. Defaulting it to False instead
  786. # would tell every correctly set-up machine that its switch is
  787. # not set up, until a push happened to arrive.
  788. ready=all(str(u.id) in state.ams_switch_inlet for u in ams_units),
  789. )
  790. if state.fila_switch and state.fila_switch.installed
  791. else None
  792. ),
  793. )
  794. @router.get("/{printer_id}/overlay-status")
  795. async def get_overlay_status(
  796. printer_id: int,
  797. _: None = RequireOverlayTokenIfAuthEnabled,
  798. db: AsyncSession = Depends(get_db, scope="function"),
  799. ) -> dict:
  800. """Everything the streaming overlay (#2613) draws for one printer.
  801. A token-authenticated sibling of ``get_printer_status`` for embeds with no
  802. login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
  803. Deliberately flat and minimal (name, model, camera rotation, live print state, and
  804. the one setting the overlay reads) rather than the full ``PrinterStatus``:
  805. a token holder gets exactly the fields the overlay renders, nothing more.
  806. Unlike the Cam Wall feed this *includes the print filename* — the overlay
  807. names the part on screen — which is why it sits behind its own ``overlay``
  808. scope rather than ``camwall``.
  809. """
  810. from backend.app.api.routes.settings import get_setting
  811. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  812. printer = result.scalar_one_or_none()
  813. if not printer:
  814. raise HTTPException(404, "Printer not found")
  815. time_format = await get_setting(db, "time_format") or "system"
  816. state = printer_manager.get_status(printer_id)
  817. if not state:
  818. # Never connected this run — mirror get_printer_status()'s disconnected
  819. # shape so the overlay renders its offline state rather than erroring.
  820. return {
  821. "id": printer_id,
  822. "name": printer.name,
  823. "model": printer.model,
  824. "camera_rotation": printer.camera_rotation or 0,
  825. "connected": False,
  826. "state": None,
  827. "current_print": None,
  828. "gcode_file": None,
  829. "progress": None,
  830. "remaining_time": None,
  831. "layer_num": None,
  832. "total_layers": None,
  833. "stg_cur_name": None,
  834. "temperatures": {},
  835. "time_format": time_format,
  836. }
  837. return {
  838. "id": printer_id,
  839. "name": printer.name,
  840. "model": printer.model,
  841. "camera_rotation": printer.camera_rotation or 0,
  842. "connected": state.connected,
  843. "state": state.state,
  844. "current_print": state.current_print,
  845. "gcode_file": state.gcode_file,
  846. "progress": state.progress,
  847. "remaining_time": state.remaining_time,
  848. "layer_num": state.layer_num,
  849. "total_layers": state.total_layers,
  850. "stg_cur_name": get_derived_status_name(state, printer.model),
  851. # Nozzle / bed / chamber readings for the overlay's temperature fields
  852. # (#1422). Filtered rather than passed through: see display_temperatures.
  853. "temperatures": display_temperatures(state.temperatures, printer.model),
  854. "time_format": time_format,
  855. }
  856. @router.get("/{printer_id}/current-print-user")
  857. async def get_current_print_user(
  858. printer_id: int,
  859. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  860. db: AsyncSession = Depends(get_db, scope="function"),
  861. ):
  862. """Get the user who started the current print (for reprint tracking).
  863. Returns user info if available, empty object otherwise.
  864. This tracks users for reprints (which bypass the queue).
  865. For queue-based prints, use the queue item's created_by field instead.
  866. """
  867. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  868. printer = result.scalar_one_or_none()
  869. if not printer:
  870. raise HTTPException(404, "Printer not found")
  871. user_info = printer_manager.get_current_print_user(printer_id)
  872. if not user_info:
  873. return {}
  874. return {"user_id": user_info["user_id"], "username": user_info["username"]}
  875. @router.post("/{printer_id}/refresh-status")
  876. async def refresh_printer_status(
  877. printer_id: int,
  878. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  879. db: AsyncSession = Depends(get_db, scope="function"),
  880. ):
  881. """Request a full status refresh from the printer (sends pushall command)."""
  882. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  883. printer = result.scalar_one_or_none()
  884. if not printer:
  885. raise HTTPException(404, "Printer not found")
  886. success = printer_manager.request_status_update(printer_id)
  887. if not success:
  888. raise HTTPException(400, "Printer not connected")
  889. return {"status": "refresh_requested"}
  890. @router.post("/{printer_id}/connect")
  891. async def connect_printer(
  892. printer_id: int,
  893. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  894. db: AsyncSession = Depends(get_db, scope="function"),
  895. ):
  896. """Manually connect to a printer."""
  897. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  898. printer = result.scalar_one_or_none()
  899. if not printer:
  900. raise HTTPException(404, "Printer not found")
  901. success = await printer_manager.connect_printer(printer)
  902. return {"connected": success}
  903. @router.post("/{printer_id}/disconnect")
  904. async def disconnect_printer(
  905. printer_id: int,
  906. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  907. db: AsyncSession = Depends(get_db, scope="function"),
  908. ):
  909. """Manually disconnect from a printer."""
  910. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  911. printer = result.scalar_one_or_none()
  912. if not printer:
  913. raise HTTPException(404, "Printer not found")
  914. printer_manager.disconnect_printer(printer_id)
  915. return {"connected": False}
  916. @router.post("/diagnostic", response_model=PrinterDiagnosticResult)
  917. async def diagnose_connection(
  918. req: DiagnosticRequest,
  919. _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
  920. ):
  921. """Run connection diagnostics for the Add-Printer flow (printer not yet saved).
  922. When serial_number + access_code are supplied the MQTT credential check
  923. also runs; otherwise only the network-level checks are performed.
  924. """
  925. return await run_connection_diagnostic(
  926. req.ip_address,
  927. serial_number=req.serial_number or None,
  928. access_code=req.access_code or None,
  929. )
  930. @router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
  931. async def diagnose_printer(
  932. printer_id: int,
  933. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  934. ):
  935. """Run connection diagnostics for an existing saved printer.
  936. On-demand run from the UI: wait up to PUBLISH_WAIT_DEFAULT seconds for the
  937. printer to publish a status report so a fresh reconnect (counter reset to
  938. 0) isn't reported as `printer_publishing: fail` prematurely. The support
  939. package code path calls run_connection_diagnostic without the wait so
  940. bundling stays fast.
  941. """
  942. from backend.app.services.printer_diagnostic import PUBLISH_WAIT_DEFAULT
  943. # A short session, released before the probes and the wait (as RC-C9).
  944. async with database.async_session() as db:
  945. printer = (await db.execute(select(Printer).where(Printer.id == printer_id))).scalar_one_or_none()
  946. if not printer:
  947. raise HTTPException(404, "Printer not found")
  948. return await run_connection_diagnostic(
  949. printer.ip_address,
  950. printer=printer,
  951. wait_for_publish_seconds=PUBLISH_WAIT_DEFAULT,
  952. )
  953. # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
  954. # Cleared on every print start by main.py::on_print_start, so re-dispatches with
  955. # different plates always fetch a fresh thumbnail without needing plate in the key.
  956. _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
  957. # Negative cache (#1420): when a cover lookup exhausts every FTP path with 550
  958. # (file sliced on SD card, not on printer storage), remember the failure so the
  959. # next request short-circuits to 404 instead of re-hammering FTP 8 paths deep.
  960. # Cleared on print start alongside _cover_cache.
  961. _cover_404_cache: dict[int, set[tuple[str, str]]] = {}
  962. # In-flight cover downloads, keyed by (printer_id, subtask_name, view_key) (#2572).
  963. # The farm dashboard mounts a cover tile per printer card, so several browsers
  964. # request the same printer's cover in the same instant, all miss the cache, and
  965. # each runs the full multi-path FTP lookup + 3MF extraction (one observed live
  966. # transfer pulled an 81 MB 3MF while real print uploads were in flight). The
  967. # first request to miss becomes the leader; concurrent requests await its future
  968. # and then serve from the positive/negative cache it filled.
  969. _cover_inflight: dict[tuple[int, str, str], asyncio.Future] = {}
  970. def clear_cover_cache(printer_id: int) -> None:
  971. """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
  972. _cover_cache.pop(printer_id, None)
  973. _cover_404_cache.pop(printer_id, None)
  974. async def _running_print_archive_file(printer_id: int, state) -> Path | None:
  975. """Path to the 3MF of the print this printer is running, if we have it.
  976. Bambuddy archives the sliced file when the print starts, so the copy the
  977. printer is executing is usually already on disk. Found as
  978. ``running_print_archive`` finds it: by ``subtask_id``, which the firmware
  979. mints per print, so a leftover ``status="printing"`` row from a completion
  980. that was never seen must not lend its file to another job, and for a print
  981. that reports none by its binding or name (#3327); a wrong guess there costs
  982. only the picture.
  983. Opens its own short-lived session, like the caller does, so the pooled
  984. connection is not held across the FTP work that follows.
  985. """
  986. from backend.app.main import running_print_archive
  987. async with database.async_session() as db:
  988. archive = await running_print_archive(db, printer_id, state, printing_only=True, by_name=True)
  989. if archive is None or not archive.file_path:
  990. return None
  991. path = settings.base_dir / archive.file_path
  992. return path if path.is_file() and str(path).endswith(".3mf") else None
  993. @router.get("/{printer_id}/cover")
  994. async def get_printer_cover(
  995. printer_id: int,
  996. view: str | None = None,
  997. _: User | None = Depends(require_media_token_printer_permission(Permission.PRINTERS_READ)),
  998. ):
  999. """Get the cover image for the current print job.
  1000. Requires a media token query param (?token=xxx) when auth is enabled, plus
  1001. ``printers:read`` -- the permission that governs every other read of this
  1002. printer. It used to require ``camera:view`` by way of the camera-stream
  1003. token, which is a different question from "may this user see what is on the
  1004. plate" (#3025).
  1005. Args:
  1006. view: Optional view type. Use "top" for the top-down build plate view or
  1007. "pick" for the slicer's object-ID mask used by skip objects.
  1008. Default returns angled 3D perspective view.
  1009. """
  1010. # Fetch the printer in a short-lived session and release the pooled DB
  1011. # connection BEFORE the FTP download below. Previously this route took its
  1012. # row via Depends(get_db, scope="function"), whose session stays open for the whole request —
  1013. # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes
  1014. # under FTP contention) pinned one pooled connection idle-in-transaction the
  1015. # entire time (issue #2572). db is used only for this one SELECT; everything
  1016. # after reads already-loaded printer.* scalars (expire_on_commit=False keeps
  1017. # them readable), printer_manager, and FTP/zip — no lazy loads.
  1018. #
  1019. # Reference async_session via the module so the maker is looked up at call
  1020. # time — keeps it in sync with reinitialize_database() and lets the test
  1021. # harness's patch of backend.app.core.database.async_session take effect.
  1022. async with database.async_session() as db:
  1023. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1024. printer = result.scalar_one_or_none()
  1025. if not printer:
  1026. raise HTTPException(404, "Printer not found")
  1027. state = printer_manager.get_status(printer_id)
  1028. if not state:
  1029. raise HTTPException(404, "Printer not connected")
  1030. # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
  1031. subtask_name = state.subtask_name
  1032. if not subtask_name:
  1033. raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
  1034. # Resolve the active plate. Precedence (#1166):
  1035. # 1. The plate Bambuddy dispatched (authoritative when we sent the print)
  1036. # 2. plate_(\d+)\.gcode regex on state.gcode_file (works on firmware that
  1037. # reflects the full path, e.g. some X1C builds)
  1038. # 3. Scan the downloaded 3MF for a unique Metadata/plate_*.gcode (covers
  1039. # per-plate archives sliced separately in Bambu Studio, where the
  1040. # printer's gcode_file echo is just the .3mf filename)
  1041. # 4. Fall back to plate 1
  1042. # The 3MF-scan fallback runs later — after the file is on disk.
  1043. plate_num = resolve_plate_id(state)
  1044. if plate_num is not None:
  1045. logger.info("Cover: resolved plate %s before download (subtask=%s)", plate_num, subtask_name)
  1046. # Normalize view parameter
  1047. view_key = view or "default"
  1048. # Check cache. Cache by (subtask_name, view_key) only — clear_cover_cache()
  1049. # runs on every print start, so a re-dispatch with a different plate gets
  1050. # a fresh image regardless. Pre-#1166 the key included plate_num, but with
  1051. # late plate resolution the cache check would always miss.
  1052. cache_key = (subtask_name, view_key)
  1053. if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
  1054. return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
  1055. # Negative-cache short-circuit (#1420): if a prior lookup for this same
  1056. # subtask + view already failed, don't replay 8 FTP retries on every page
  1057. # refresh. _cover_404_cache is cleared on print start.
  1058. if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
  1059. raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
  1060. # Coalesce concurrent downloads for the same cover (#2572). The positive and
  1061. # negative caches were just checked above; if another request is already
  1062. # downloading this exact cover, wait for it and serve from the cache it fills
  1063. # instead of launching a duplicate multi-path FTP + 3MF extraction.
  1064. inflight_key = (printer_id, subtask_name, view_key)
  1065. leader = _cover_inflight.get(inflight_key)
  1066. if leader is not None:
  1067. # shield() so our own cancellation can't cancel the shared leader.
  1068. try:
  1069. await asyncio.shield(leader)
  1070. except Exception:
  1071. pass
  1072. if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
  1073. return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
  1074. if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
  1075. raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
  1076. # Leader finished without filling either cache (a transient 503) — fall
  1077. # through and try the download ourselves.
  1078. fut: asyncio.Future = asyncio.get_event_loop().create_future()
  1079. _cover_inflight[inflight_key] = fut
  1080. try:
  1081. image_data = await _produce_cover_image(
  1082. printer,
  1083. printer_id,
  1084. subtask_name,
  1085. view,
  1086. view_key,
  1087. plate_num,
  1088. cache_key,
  1089. archive_path=await _running_print_archive_file(printer_id, state),
  1090. )
  1091. return Response(content=image_data, media_type="image/png")
  1092. finally:
  1093. if not fut.done():
  1094. fut.set_result(None)
  1095. _cover_inflight.pop(inflight_key, None)
  1096. async def _produce_cover_image(
  1097. printer: Printer,
  1098. printer_id: int,
  1099. subtask_name: str,
  1100. view: str | None,
  1101. view_key: str,
  1102. plate_num: int | None,
  1103. cache_key: tuple[str, str],
  1104. archive_path: Path | None = None,
  1105. ) -> bytes:
  1106. """Download the active-print 3MF and extract its cover thumbnail (#2572).
  1107. Split out of ``get_printer_cover`` so concurrent requests for the same cover
  1108. can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
  1109. on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
  1110. failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
  1111. work — the caller already released the pooled connection before this runs,
  1112. which is also why ``archive_path`` arrives resolved rather than looked up
  1113. here.
  1114. """
  1115. # Build possible 3MF filenames from subtask_name
  1116. # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
  1117. # or just "name.3mf" (uploaded directly)
  1118. possible_filenames = []
  1119. if subtask_name.endswith(".3mf"):
  1120. possible_filenames.append(subtask_name)
  1121. else:
  1122. # Try both naming patterns
  1123. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  1124. possible_filenames.append(f"{subtask_name}.3mf")
  1125. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  1126. if " " in subtask_name:
  1127. normalized = subtask_name.replace(" ", "_")
  1128. if normalized.endswith(".3mf"):
  1129. possible_filenames.append(normalized)
  1130. else:
  1131. possible_filenames.append(f"{normalized}.gcode.3mf")
  1132. possible_filenames.append(f"{normalized}.3mf")
  1133. # Build list of all remote paths to try
  1134. remote_paths = []
  1135. for filename in possible_filenames:
  1136. remote_paths.extend(
  1137. [
  1138. f"/{filename}", # Root directory (most common)
  1139. f"/cache/{filename}",
  1140. f"/model/{filename}",
  1141. f"/data/{filename}",
  1142. ]
  1143. )
  1144. # Use first filename for temp path (will be reused)
  1145. temp_filename = possible_filenames[0]
  1146. temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
  1147. temp_path.parent.mkdir(parents=True, exist_ok=True)
  1148. storage = print_file_reachable_over_ftp(printer_manager.get_status(printer_id))
  1149. # Cache check (#972): the archive-metadata flow in main.py may have already
  1150. # downloaded this 3MF during the print-start handler. Reusing that file
  1151. # avoids a second 36MB transfer competing with the printer's single FTP
  1152. # socket (which produces the 425 errors that feed the retry storm).
  1153. #
  1154. # The dispatch's own filename is a candidate too: it is what the archive
  1155. # flow's probe cached the file under, and it does not always survive the
  1156. # trip through subtask_name (#2856).
  1157. downloaded = False
  1158. using_cached = False
  1159. def _cached_source() -> Path | None:
  1160. """The 3MF another flow has already published for this print, if any."""
  1161. for candidate_name in (*possible_filenames, storage.probe_filename):
  1162. if not candidate_name:
  1163. continue
  1164. cached = get_cached_3mf(printer_id, candidate_name)
  1165. if cached:
  1166. return cached
  1167. return None
  1168. cached = _cached_source()
  1169. if cached:
  1170. logger.info("Cover using cached 3MF from %s (avoided duplicate FTP)", cached)
  1171. temp_path = cached
  1172. downloaded = True
  1173. using_cached = True
  1174. if not downloaded:
  1175. # Same idea, one step further back: that in-memory cache dies with the
  1176. # process, but the archive of the print that is still running holds the
  1177. # very 3MF on disk. Without this, reopening a card or the skip-objects
  1178. # plate after a restart pulls the whole file back off a printer that is
  1179. # mid-print — measured at three concurrent fan-outs, thirteen seconds
  1180. # and a 0-byte read on the maintainer's H2C, which is exactly the
  1181. # single-socket contention #972 was about.
  1182. if archive_path is not None:
  1183. logger.info("Cover using the running print's archived 3MF at %s (no FTP)", archive_path)
  1184. temp_path = archive_path
  1185. downloaded = True
  1186. using_cached = True
  1187. if not downloaded:
  1188. # The cover lives inside the 3MF, so it is only reachable if the 3MF is.
  1189. # When the printer kept the print on internal storage there is nothing
  1190. # at any of these paths, and walking all sixteen of them just to end on
  1191. # a 404 that reads as "this print has no cover" helps nobody (#2780).
  1192. #
  1193. # Unless the printer is wrong about that, which an H2D with a card in
  1194. # routinely is (#2856). The dispatch names the file, so when it does,
  1195. # trade the immediate 404 for a five-path probe of that one name — same
  1196. # single connection, and it is the only way this endpoint ever recovers
  1197. # a cover for a print the archive flow did not see start.
  1198. max_retries = 2
  1199. if not storage.reachable:
  1200. if not storage.probe_filename:
  1201. _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
  1202. raise HTTPException(
  1203. 404,
  1204. f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS "
  1205. f"({storage.reason}), so it has no cover to extract.",
  1206. )
  1207. remote_paths = ftp_probe_paths(storage.probe_filename)
  1208. # The dispatch's name is the authoritative one — a print whose
  1209. # subtask_name has been normalized or truncated would otherwise be
  1210. # cached under a key the archive flow never looks up.
  1211. temp_filename = storage.probe_filename
  1212. temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
  1213. # One look, not three: the printer has already said this file is not
  1214. # here, so a retry storm on top of a hunch is exactly what #2780 was.
  1215. max_retries = 0
  1216. logger.info(
  1217. f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
  1218. )
  1219. # Retry logic for transient FTP failures
  1220. last_error = None
  1221. for attempt in range(max_retries + 1):
  1222. if attempt:
  1223. # Look again before spending another transfer. The entry check
  1224. # above only settles the race when the two flows do not
  1225. # overlap, and on a P1S at print start they overlap for
  1226. # minutes: a reported run had the archive flow publish the file
  1227. # 42 seconds into this endpoint's 2.5-minute retry sequence,
  1228. # and the third attempt still pulled its own 5 MB copy of it
  1229. # over the same socket the printer was serving the print from
  1230. # (#2957).
  1231. cached = _cached_source()
  1232. if cached:
  1233. logger.info(
  1234. "Cover picked up the 3MF another flow finished downloading (%s) — skipping retry %s",
  1235. cached,
  1236. attempt + 1,
  1237. )
  1238. temp_path = cached
  1239. downloaded = True
  1240. using_cached = True
  1241. break
  1242. if ftps_handshake_blocked(printer.ip_address):
  1243. # Nothing to retry: the printer is not completing a TLS
  1244. # handshake on port 990, so no path and no attempt reaches it
  1245. # (#2780). Report the real cause instead of the 404 below,
  1246. # which would read as "this print has no cover".
  1247. raise HTTPException(
  1248. 503,
  1249. f"Printer {printer.ip_address} is not answering its file service over TLS. "
  1250. "Bambuddy will try again shortly.",
  1251. )
  1252. try:
  1253. downloaded = await download_file_try_paths_async(
  1254. printer.ip_address,
  1255. printer.access_code,
  1256. remote_paths,
  1257. temp_path,
  1258. printer_model=printer.model,
  1259. )
  1260. if downloaded:
  1261. break
  1262. except Exception as e:
  1263. last_error = e
  1264. if attempt < max_retries:
  1265. logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
  1266. await asyncio.sleep(0.5 * (attempt + 1)) # Brief backoff
  1267. else:
  1268. logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
  1269. if last_error and not downloaded:
  1270. raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
  1271. if not downloaded:
  1272. # Remember this failure so subsequent requests for the same print
  1273. # skip the 8-path FTP fan-out (#1420).
  1274. _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
  1275. if not storage.reachable:
  1276. # The probe looked and found nothing, so the printer's own
  1277. # account of where the file went is the answer after all —
  1278. # keep saying so rather than reporting a generic miss (#2780).
  1279. raise HTTPException(
  1280. 404,
  1281. f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS "
  1282. f"({storage.reason}), so it has no cover to extract.",
  1283. )
  1284. raise HTTPException(
  1285. 404,
  1286. f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
  1287. )
  1288. # Share the fresh download with the archive flow — unless the file is
  1289. # already theirs, in which case re-registering it under this endpoint's
  1290. # own name would only add a second key pointing at the same bytes.
  1291. if not using_cached:
  1292. cache_3mf_download(printer_id, temp_filename, temp_path)
  1293. # Verify file actually exists and has content
  1294. if not temp_path.exists():
  1295. raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
  1296. file_size = temp_path.stat().st_size
  1297. logger.info("Downloaded file size: %s bytes", file_size)
  1298. if file_size == 0:
  1299. if not using_cached:
  1300. temp_path.unlink()
  1301. raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'")
  1302. # Offer the file to the archive flow before extracting the thumbnail. When
  1303. # the print started inside the printer's FTPS cool-off, the archive flow
  1304. # gave up without a single connection and this endpoint holds the very file
  1305. # it wanted — which used to be read for a thumbnail and then deleted at
  1306. # print completion, leaving a permanently empty archive (#2957). Covers the
  1307. # cached branch as well as a fresh download: whoever fetched it, the running
  1308. # print's archive should have it. A no-op unless that archive is a fallback.
  1309. from backend.app.main import try_recover_fallback_archive
  1310. await try_recover_fallback_archive(printer_id, temp_filename, temp_path)
  1311. try:
  1312. # Extract thumbnail from 3MF (which is a ZIP file)
  1313. try:
  1314. zf = zipfile.ZipFile(temp_path, "r")
  1315. except zipfile.BadZipFile:
  1316. raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
  1317. except OSError as e:
  1318. logger.error("Failed to open 3MF file: %s", e, exc_info=True)
  1319. raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
  1320. try:
  1321. # 3MF-scan fallback for plate detection (#1166). Per-plate archives
  1322. # sliced separately in Bambu Studio contain a single
  1323. # Metadata/plate_N.gcode for the active plate, even though
  1324. # thumbnails for all plates are bundled. Using that gcode's plate
  1325. # number prevents falling back to plate_1.png.
  1326. if plate_num is None:
  1327. plate_gcodes = [name for name in zf.namelist() if re.match(r"^Metadata/plate_\d+\.gcode$", name)]
  1328. if len(plate_gcodes) == 1:
  1329. match = re.search(r"plate_(\d+)\.gcode", plate_gcodes[0])
  1330. if match:
  1331. plate_num = int(match.group(1))
  1332. logger.info("Cover: detected plate %s from 3MF contents", plate_num)
  1333. if plate_num is None:
  1334. plate_num = 1
  1335. # Try common thumbnail paths in 3MF files
  1336. # Use plate_num to get the correct plate's thumbnail for multi-plate projects
  1337. # Use top-down view if requested (better for skip objects modal)
  1338. if view == "pick":
  1339. # Only the active plate's mask, with no fallback: every other view
  1340. # falls back to plate 1 because a slightly wrong picture is better
  1341. # than none, but a mask is coordinates, not decoration. Plate 1's
  1342. # mask over plate 3's layout would resolve clicks to whichever
  1343. # object happened to occupy that pixel on a different plate.
  1344. thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
  1345. elif view == "top":
  1346. thumbnail_paths = [
  1347. f"Metadata/top_{plate_num}.png",
  1348. # Fall back to plate 1 if specific plate not found
  1349. "Metadata/top_1.png",
  1350. f"Metadata/plate_{plate_num}.png",
  1351. "Metadata/plate_1.png",
  1352. "Metadata/thumbnail.png",
  1353. ]
  1354. else:
  1355. thumbnail_paths = [
  1356. f"Metadata/plate_{plate_num}.png",
  1357. # Fall back to plate 1 if specific plate not found
  1358. "Metadata/plate_1.png",
  1359. "Metadata/thumbnail.png",
  1360. f"Metadata/plate_{plate_num}_small.png",
  1361. "Metadata/plate_1_small.png",
  1362. "Thumbnails/thumbnail.png",
  1363. "thumbnail.png",
  1364. ]
  1365. for thumb_path in thumbnail_paths:
  1366. try:
  1367. image_data = zf.read(thumb_path)
  1368. if printer_id not in _cover_cache:
  1369. _cover_cache[printer_id] = {}
  1370. _cover_cache[printer_id][(subtask_name, view_key)] = image_data
  1371. return image_data
  1372. except KeyError:
  1373. continue
  1374. # If no specific thumbnail found, try any PNG in Metadata. Never for
  1375. # "pick": handing back a rendered thumbnail in place of the object-ID
  1376. # mask is worse than nothing, because the caller can't tell the
  1377. # difference and decodes the render's pixel colours as object IDs —
  1378. # dark pixels yield small integers that collide with real IDs, so a
  1379. # click would select an arbitrary object and skip it irreversibly.
  1380. # A 404 is what tells the UI to fall back to the checklist.
  1381. if view != "pick":
  1382. for name in zf.namelist():
  1383. if name.startswith("Metadata/") and name.endswith(".png"):
  1384. image_data = zf.read(name)
  1385. if printer_id not in _cover_cache:
  1386. _cover_cache[printer_id] = {}
  1387. _cover_cache[printer_id][(subtask_name, view_key)] = image_data
  1388. return image_data
  1389. _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
  1390. raise HTTPException(404, "No thumbnail found in 3MF file")
  1391. finally:
  1392. zf.close()
  1393. finally:
  1394. # Only delete when this invocation owns the file. A cached path is
  1395. # shared with the archive flow — removing it would force a refetch
  1396. # the next time either flow needs the 3MF.
  1397. if not using_cached and temp_path.exists():
  1398. temp_path.unlink()
  1399. # ============================================
  1400. # File Manager Endpoints
  1401. # ============================================
  1402. async def _load_printer_or_404(printer_id: int) -> Printer:
  1403. """Load a printer in a short-lived session, releasing the pooled DB
  1404. connection before the caller starts any FTP/network I/O (#2572).
  1405. The file-manager and storage routes talk FTP to the printer, which can
  1406. block for the full socket timeout — longer when a saturated FTP pool backs
  1407. up. Holding the request's Depends(get_db, scope="function") session across that FTP pinned one
  1408. pooled connection idle-in-transaction per in-flight request, a top cause of
  1409. pool exhaustion on large farms. The returned row's scalar columns stay
  1410. readable after the session closes (expire_on_commit=False). Raises 404 when
  1411. the printer doesn't exist.
  1412. Reference async_session via the module so the maker is resolved at call time
  1413. — keeps it in sync with reinitialize_database() and lets tests patch it.
  1414. """
  1415. async with database.async_session() as db:
  1416. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1417. printer = result.scalar_one_or_none()
  1418. if not printer:
  1419. raise HTTPException(404, "Printer not found")
  1420. return printer
  1421. @router.get("/{printer_id}/files")
  1422. async def list_printer_files(
  1423. printer_id: int,
  1424. path: str = "/",
  1425. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1426. ):
  1427. """List files on the printer at the specified path."""
  1428. printer = await _load_printer_or_404(printer_id)
  1429. listing = await list_files_result_async(
  1430. printer.ip_address,
  1431. printer.access_code,
  1432. path,
  1433. printer_model=printer.model,
  1434. )
  1435. files = listing.files
  1436. # Add full path to each file
  1437. for f in files:
  1438. f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
  1439. return {
  1440. "path": path,
  1441. "files": files,
  1442. # Slow is not gone: a large folder on a slow card can outlast the wait (TO-C13).
  1443. "warnings": (
  1444. [] if listing.available else ["listing_timed_out"] if listing.timed_out else ["printer_unavailable"]
  1445. ),
  1446. }
  1447. @router.get("/{printer_id}/files/download")
  1448. async def download_printer_file(
  1449. printer_id: int,
  1450. path: str,
  1451. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1452. ):
  1453. """Download a file from the printer."""
  1454. printer = await _load_printer_or_404(printer_id)
  1455. try:
  1456. async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
  1457. result = await build_printer_file(
  1458. printer,
  1459. path,
  1460. None,
  1461. bundle_key=f"single-{secrets.token_urlsafe(18)}",
  1462. )
  1463. except PrinterFilesZipTooLargeError as exc:
  1464. raise HTTPException(413, str(exc)) from exc
  1465. except PrinterFilesZipInsufficientSpaceError as exc:
  1466. raise HTTPException(507, str(exc)) from exc
  1467. except PrinterFileDownloadError as exc:
  1468. raise HTTPException(502, f"Could not download {path} from the printer") from exc
  1469. except FileNotFoundError:
  1470. raise HTTPException(404, f"File not found: {path}")
  1471. except TimeoutError as exc:
  1472. raise HTTPException(504, str(exc) or "Printer download exceeded the 30-minute limit") from exc
  1473. # Determine content type based on extension
  1474. filename = path.split("/")[-1]
  1475. ext = filename.lower().split(".")[-1] if "." in filename else ""
  1476. content_types = {
  1477. "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  1478. "gcode": "text/plain",
  1479. "mp4": "video/mp4",
  1480. "avi": "video/x-msvideo",
  1481. "png": "image/png",
  1482. "jpg": "image/jpeg",
  1483. "jpeg": "image/jpeg",
  1484. "json": "application/json",
  1485. "txt": "text/plain",
  1486. }
  1487. content_type = content_types.get(ext, "application/octet-stream")
  1488. return FileResponse(
  1489. path=result.path,
  1490. filename=filename,
  1491. media_type=content_type,
  1492. headers={"Content-Disposition": build_content_disposition(filename)},
  1493. background=BackgroundTask(remove_printer_files_zip, result.path),
  1494. )
  1495. async def _download_printer_file_bytes(printer, path: str) -> bytes:
  1496. """Download a printer file for a preview: 404 missing, 502 failed, 504 too slow.
  1497. A failed download used to be reported as 404 "File not found" for a file the
  1498. browser had just listed. One FTP session, no listing first, and shared: the
  1499. plate picker asks once per plate, all at once, and each request downloaded
  1500. the whole 3MF again (see services/printer_file_cache).
  1501. """
  1502. async def fetch() -> bytes:
  1503. try:
  1504. data = await download_file_bytes_async(
  1505. printer.ip_address,
  1506. printer.access_code,
  1507. path,
  1508. printer_model=printer.model,
  1509. report_missing=True,
  1510. # The size the printer reports buys a slow link its time, up to
  1511. # the file browser's budget: print start's 300 s ceiling would
  1512. # add none.
  1513. max_total_timeout=MAX_PRINTER_ZIP_PREPARE_SECONDS,
  1514. )
  1515. except FileNotOnPrinterError as exc:
  1516. raise HTTPException(404, f"File not found: {path}") from exc
  1517. except DownloadDeadlineExceeded as exc:
  1518. raise HTTPException(504, f"Downloading {path} from the printer took longer than its size allows") from exc
  1519. if data is None:
  1520. raise HTTPException(502, f"Could not download {path} from the printer")
  1521. return data
  1522. return await printer_file_cache.get(printer.id, path, fetch)
  1523. @router.get("/{printer_id}/files/gcode")
  1524. async def get_printer_file_gcode(
  1525. printer_id: int,
  1526. path: str,
  1527. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1528. ):
  1529. """Get gcode for a file stored on a printer (for preview)."""
  1530. import io
  1531. printer = await _load_printer_or_404(printer_id)
  1532. data = await _download_printer_file_bytes(printer, path)
  1533. filename = path.split("/")[-1]
  1534. lower = filename.lower()
  1535. if lower.endswith(".gcode"):
  1536. return Response(content=data, media_type="text/plain")
  1537. if lower.endswith(".3mf"):
  1538. try:
  1539. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  1540. gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
  1541. if not gcode_files:
  1542. raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
  1543. gcode_content = zf.read(gcode_files[0])
  1544. return Response(content=gcode_content, media_type="text/plain")
  1545. except zipfile.BadZipFile:
  1546. raise HTTPException(status_code=400, detail="Invalid 3MF file")
  1547. raise HTTPException(status_code=400, detail="Unsupported file type")
  1548. @router.get("/{printer_id}/files/plates")
  1549. async def get_printer_file_plates(
  1550. printer_id: int,
  1551. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  1552. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1553. ):
  1554. """Get available plates from a multi-plate 3MF file stored on a printer."""
  1555. import io
  1556. import json
  1557. import defusedxml.ElementTree as ET
  1558. printer = await _load_printer_or_404(printer_id)
  1559. filename = path.split("/")[-1]
  1560. if not filename.lower().endswith(".3mf"):
  1561. return {
  1562. "printer_id": printer_id,
  1563. "path": path,
  1564. "filename": filename,
  1565. "plates": [],
  1566. "is_multi_plate": False,
  1567. }
  1568. data = await _download_printer_file_bytes(printer, path)
  1569. plates = []
  1570. try:
  1571. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  1572. namelist = zf.namelist()
  1573. # Find all plate gcode files to determine available plates
  1574. gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
  1575. # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
  1576. plate_indices: list[int] = []
  1577. if gcode_files:
  1578. for gf in gcode_files:
  1579. try:
  1580. plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
  1581. plate_indices.append(int(plate_str))
  1582. except ValueError:
  1583. pass # Skip gcode files with non-numeric plate indices
  1584. else:
  1585. plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
  1586. plate_png_files = [
  1587. n
  1588. for n in namelist
  1589. if n.startswith("Metadata/plate_")
  1590. and n.endswith(".png")
  1591. and "_small" not in n
  1592. and "no_light" not in n
  1593. ]
  1594. plate_name_candidates = plate_json_files + plate_png_files
  1595. plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
  1596. seen_indices: set[int] = set()
  1597. for name in plate_name_candidates:
  1598. match = plate_re.match(name)
  1599. if match:
  1600. try:
  1601. index = int(match.group(1))
  1602. except ValueError:
  1603. continue
  1604. if index in seen_indices:
  1605. continue
  1606. seen_indices.add(index)
  1607. plate_indices.append(index)
  1608. if not plate_indices:
  1609. return {
  1610. "printer_id": printer_id,
  1611. "path": path,
  1612. "filename": filename,
  1613. "plates": [],
  1614. "is_multi_plate": False,
  1615. }
  1616. plate_indices.sort()
  1617. # Parse model_settings.config for plate names
  1618. plate_names = {}
  1619. if "Metadata/model_settings.config" in namelist:
  1620. try:
  1621. model_content = zf.read("Metadata/model_settings.config").decode()
  1622. model_root = ET.fromstring(model_content)
  1623. for plate_elem in model_root.findall(".//plate"):
  1624. plater_id = None
  1625. plater_name = None
  1626. for meta in plate_elem.findall("metadata"):
  1627. key = meta.get("key")
  1628. value = meta.get("value")
  1629. if key == "plater_id" and value:
  1630. try:
  1631. plater_id = int(value)
  1632. except ValueError:
  1633. pass # Skip plate with unparseable ID
  1634. elif key == "plater_name" and value:
  1635. plater_name = value.strip()
  1636. if plater_id is not None and plater_name:
  1637. plate_names[plater_id] = plater_name
  1638. except Exception:
  1639. pass # Plate names are optional; continue without them
  1640. # Parse slice_info.config for plate metadata
  1641. plate_metadata = {}
  1642. if "Metadata/slice_info.config" in namelist:
  1643. content = zf.read("Metadata/slice_info.config").decode()
  1644. root = ET.fromstring(content)
  1645. for plate_elem in root.findall(".//plate"):
  1646. plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
  1647. plate_index = None
  1648. for meta in plate_elem.findall("metadata"):
  1649. key = meta.get("key")
  1650. value = meta.get("value")
  1651. if key == "index" and value:
  1652. try:
  1653. plate_index = int(value)
  1654. except ValueError:
  1655. pass # Skip plate with unparseable index
  1656. elif key == "prediction" and value:
  1657. try:
  1658. plate_info["prediction"] = int(value)
  1659. except ValueError:
  1660. pass # Skip unparseable prediction; leave as None
  1661. elif key == "weight" and value:
  1662. try:
  1663. plate_info["weight"] = float(value)
  1664. except ValueError:
  1665. pass # Skip unparseable weight; leave as None
  1666. # Get filaments used in this plate
  1667. for filament_elem in plate_elem.findall("filament"):
  1668. filament_id = filament_elem.get("id")
  1669. filament_type = filament_elem.get("type", "")
  1670. filament_color = filament_elem.get("color", "")
  1671. used_g = filament_elem.get("used_g", "0")
  1672. used_m = filament_elem.get("used_m", "0")
  1673. try:
  1674. used_grams = float(used_g)
  1675. except (ValueError, TypeError):
  1676. used_grams = 0
  1677. if used_grams > 0 and filament_id:
  1678. plate_info["filaments"].append(
  1679. {
  1680. "slot_id": int(filament_id),
  1681. "type": filament_type,
  1682. "color": filament_color,
  1683. # The preset the slicer assigned (GFA01, ...): names the product line
  1684. "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
  1685. "used_grams": round(used_grams, 1),
  1686. "used_meters": float(used_m) if used_m else 0,
  1687. }
  1688. )
  1689. plate_info["filaments"].sort(key=lambda x: x["slot_id"])
  1690. # Collect object names
  1691. for obj_elem in plate_elem.findall("object"):
  1692. obj_name = obj_elem.get("name")
  1693. if obj_name and obj_name not in plate_info["objects"]:
  1694. plate_info["objects"].append(obj_name)
  1695. # Set plate name
  1696. if plate_index is not None:
  1697. custom_name = plate_names.get(plate_index)
  1698. if custom_name:
  1699. plate_info["name"] = custom_name
  1700. elif plate_info["objects"]:
  1701. plate_info["name"] = plate_info["objects"][0]
  1702. plate_metadata[plate_index] = plate_info
  1703. # Parse plate_*.json for object lists when slice_info is missing
  1704. plate_json_objects: dict[int, list[str]] = {}
  1705. for name in namelist:
  1706. match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
  1707. if not match:
  1708. continue
  1709. try:
  1710. plate_index = int(match.group(1))
  1711. except ValueError:
  1712. continue
  1713. try:
  1714. payload = json.loads(zf.read(name).decode())
  1715. bbox_objects = payload.get("bbox_objects", [])
  1716. names: list[str] = []
  1717. for obj in bbox_objects:
  1718. obj_name = obj.get("name") if isinstance(obj, dict) else None
  1719. if obj_name and obj_name not in names:
  1720. names.append(obj_name)
  1721. if names:
  1722. plate_json_objects[plate_index] = names
  1723. except Exception:
  1724. continue
  1725. # Build plate list
  1726. for idx in plate_indices:
  1727. meta = plate_metadata.get(idx, {})
  1728. has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
  1729. objects = meta.get("objects", [])
  1730. if not objects:
  1731. objects = plate_json_objects.get(idx, [])
  1732. plate_name = meta.get("name")
  1733. if not plate_name:
  1734. plate_name = plate_names.get(idx)
  1735. if not plate_name and objects:
  1736. plate_name = objects[0]
  1737. plates.append(
  1738. {
  1739. "index": idx,
  1740. "name": plate_name,
  1741. "objects": objects,
  1742. "object_count": len(objects),
  1743. "has_thumbnail": has_thumbnail,
  1744. "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
  1745. "print_time_seconds": meta.get("prediction"),
  1746. "filament_used_grams": meta.get("weight"),
  1747. "filaments": meta.get("filaments", []),
  1748. }
  1749. )
  1750. except Exception as e:
  1751. logger.warning("Failed to parse plates from printer file %s: %s", path, e)
  1752. return {
  1753. "printer_id": printer_id,
  1754. "path": path,
  1755. "filename": filename,
  1756. "plates": plates,
  1757. "is_multi_plate": len(plates) > 1,
  1758. }
  1759. @router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
  1760. async def get_printer_file_plate_thumbnail(
  1761. printer_id: int,
  1762. plate_index: int,
  1763. path: str = Query(..., description="Full path to the 3MF file on the printer"),
  1764. _=Depends(require_media_token_printer_permission(Permission.PRINTERS_FILES)),
  1765. ):
  1766. """Get a plate thumbnail image from a printer-stored 3MF file."""
  1767. import io
  1768. printer = await _load_printer_or_404(printer_id)
  1769. data = await _download_printer_file_bytes(printer, path)
  1770. try:
  1771. with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
  1772. thumb_path = f"Metadata/plate_{plate_index}.png"
  1773. if thumb_path in zf.namelist():
  1774. image_data = zf.read(thumb_path)
  1775. return Response(content=image_data, media_type="image/png")
  1776. except Exception:
  1777. pass # Corrupt or unreadable 3MF; fall through to 404
  1778. raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
  1779. @router.post("/{printer_id}/files/download-zip")
  1780. async def download_printer_files_as_zip(
  1781. printer_id: int,
  1782. request: PrinterFilesDownloadRequest,
  1783. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1784. ):
  1785. """Download multiple files using a disk-backed ZIP.
  1786. Kept backward-compatible for API clients: relative paths are rooted,
  1787. duplicate paths receive collision-safe names, and an all-failed request
  1788. returns an empty ZIP as the historical endpoint did. The browser uses the
  1789. asynchronous preparation endpoints below.
  1790. """
  1791. if not request.paths:
  1792. raise HTTPException(400, "No files specified")
  1793. printer = await _load_printer_or_404(printer_id)
  1794. normalized_paths = [path if path.startswith("/") else f"/{path}" for path in request.paths]
  1795. normalized_sizes = {path if path.startswith("/") else f"/{path}": size for path, size in request.sizes.items()}
  1796. try:
  1797. async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
  1798. result = await build_printer_files_zip(
  1799. printer,
  1800. normalized_paths,
  1801. normalized_sizes,
  1802. preserve_paths=False,
  1803. allow_empty=True,
  1804. )
  1805. except PrinterFilesZipTooLargeError as exc:
  1806. raise HTTPException(413, str(exc)) from exc
  1807. except PrinterFilesZipInsufficientSpaceError as exc:
  1808. raise HTTPException(507, str(exc)) from exc
  1809. except TimeoutError as exc:
  1810. raise HTTPException(504, "Printer ZIP preparation exceeded the 30-minute limit") from exc
  1811. return FileResponse(
  1812. path=result.path,
  1813. filename="printer-files.zip",
  1814. media_type="application/zip",
  1815. headers={
  1816. "X-Bambuddy-Files-Requested": str(result.requested),
  1817. "X-Bambuddy-Files-Downloaded": str(result.successful),
  1818. "X-Bambuddy-Files-Failed": str(len(result.failed_paths)),
  1819. },
  1820. background=BackgroundTask(remove_printer_files_zip, result.path),
  1821. )
  1822. @router.post("/{printer_id}/files/download-job")
  1823. async def create_printer_files_download_job(
  1824. printer_id: int,
  1825. request: PrinterFilesJobRequest,
  1826. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1827. ):
  1828. """Start a cancellable disk-backed preparation without holding the request."""
  1829. if not request.paths:
  1830. raise HTTPException(400, "No files specified")
  1831. if len(set(request.paths)) != len(request.paths):
  1832. raise HTTPException(400, "Selected printer paths must be unique")
  1833. if not request.as_zip and len(request.paths) != 1:
  1834. raise HTTPException(400, "Native downloads require exactly one file")
  1835. printer = await _load_printer_or_404(printer_id)
  1836. try:
  1837. status = await start_printer_files_job(
  1838. printer,
  1839. request.paths,
  1840. request.sizes,
  1841. request.filename,
  1842. as_zip=request.as_zip,
  1843. )
  1844. except PrinterFilesZipTooLargeError as exc:
  1845. raise HTTPException(413, str(exc)) from exc
  1846. except PrinterFilesZipInsufficientSpaceError as exc:
  1847. raise HTTPException(507, str(exc)) from exc
  1848. except ValueError as exc:
  1849. raise HTTPException(400, str(exc)) from exc
  1850. return status.__dict__
  1851. @router.get("/{printer_id}/files/download-jobs/{job_id}")
  1852. async def get_printer_files_download_job(
  1853. printer_id: int,
  1854. job_id: str,
  1855. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1856. ):
  1857. status = await get_printer_files_job(job_id, printer_id)
  1858. if status is None:
  1859. raise HTTPException(404, "Printer download job not found")
  1860. return status.__dict__
  1861. @router.delete("/{printer_id}/files/download-jobs/{job_id}")
  1862. async def cancel_printer_files_download_job(
  1863. printer_id: int,
  1864. job_id: str,
  1865. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1866. ):
  1867. if not await cancel_printer_files_job(job_id, printer_id):
  1868. raise HTTPException(404, "Printer download job not found")
  1869. return {"status": "cancelled"}
  1870. @router.get("/{printer_id}/files/dl/{token}/{filename}")
  1871. async def download_prepared_printer_files(
  1872. printer_id: int,
  1873. token: str,
  1874. filename: str,
  1875. ):
  1876. """Consume a resource-bound token and stream a prepared file natively."""
  1877. from backend.app.core.auth import verify_slicer_download_token
  1878. if not await verify_slicer_download_token(token, "printer-files", printer_id):
  1879. return download_error_response(403, "This download link has already been used or has expired.")
  1880. zip_path = printer_files_zip_path(printer_id, token)
  1881. raw_path = printer_file_path(printer_id, token)
  1882. if zip_path is not None and await asyncio.to_thread(zip_path.is_file):
  1883. prepared_path = zip_path
  1884. media_type = "application/zip"
  1885. elif raw_path is not None and await asyncio.to_thread(raw_path.is_file):
  1886. prepared_path = raw_path
  1887. media_type = "application/octet-stream"
  1888. else:
  1889. return download_error_response(404, "The prepared download is no longer on the server.")
  1890. safe_filename = safe_download_filename(filename, fallback="printer-download")
  1891. return FileResponse(
  1892. path=prepared_path,
  1893. filename=safe_filename,
  1894. media_type=media_type,
  1895. headers={"Content-Disposition": build_content_disposition(safe_filename)},
  1896. background=BackgroundTask(remove_printer_files_zip, prepared_path),
  1897. )
  1898. @router.delete("/{printer_id}/files")
  1899. async def delete_printer_file(
  1900. printer_id: int,
  1901. path: str,
  1902. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
  1903. ):
  1904. """Delete a file from the printer."""
  1905. printer = await _load_printer_or_404(printer_id)
  1906. from backend.app.services.bambu_ftp import DeleteResult
  1907. result = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
  1908. if result != DeleteResult.FAILED:
  1909. # Gone from the printer: a preview must not be served from the old copy.
  1910. printer_file_cache.forget(printer_id, path)
  1911. if result == DeleteResult.NOT_FOUND:
  1912. raise HTTPException(404, f"File not found on printer: {path}")
  1913. if result == DeleteResult.FAILED:
  1914. raise HTTPException(500, f"Failed to delete file: {path}")
  1915. return {"status": "deleted", "path": path}
  1916. @router.get("/{printer_id}/storage")
  1917. async def get_printer_storage(
  1918. printer_id: int,
  1919. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1920. ):
  1921. """Get storage information from the printer."""
  1922. printer = await _load_printer_or_404(printer_id)
  1923. storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
  1924. return storage_info or {"used_bytes": None, "free_bytes": None}
  1925. # ============================================
  1926. # MQTT Debug Logging Endpoints
  1927. # ============================================
  1928. @router.post("/{printer_id}/logging/enable")
  1929. async def enable_mqtt_logging(
  1930. printer_id: int,
  1931. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1932. db: AsyncSession = Depends(get_db, scope="function"),
  1933. ):
  1934. """Enable MQTT message logging for a printer."""
  1935. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1936. printer = result.scalar_one_or_none()
  1937. if not printer:
  1938. raise HTTPException(404, "Printer not found")
  1939. success = printer_manager.enable_logging(printer_id, True)
  1940. if not success:
  1941. raise HTTPException(400, "Printer not connected")
  1942. return {"logging_enabled": True}
  1943. @router.post("/{printer_id}/logging/disable")
  1944. async def disable_mqtt_logging(
  1945. printer_id: int,
  1946. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1947. db: AsyncSession = Depends(get_db, scope="function"),
  1948. ):
  1949. """Disable MQTT message logging for a printer."""
  1950. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1951. printer = result.scalar_one_or_none()
  1952. if not printer:
  1953. raise HTTPException(404, "Printer not found")
  1954. success = printer_manager.enable_logging(printer_id, False)
  1955. if not success:
  1956. raise HTTPException(400, "Printer not connected")
  1957. return {"logging_enabled": False}
  1958. @router.get("/{printer_id}/logging")
  1959. async def get_mqtt_logs(
  1960. printer_id: int,
  1961. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  1962. db: AsyncSession = Depends(get_db, scope="function"),
  1963. ):
  1964. """Get MQTT message logs for a printer."""
  1965. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1966. printer = result.scalar_one_or_none()
  1967. if not printer:
  1968. raise HTTPException(404, "Printer not found")
  1969. logs = printer_manager.get_logs(printer_id)
  1970. return {
  1971. "logging_enabled": printer_manager.is_logging_enabled(printer_id),
  1972. "logs": [
  1973. {
  1974. "timestamp": log.timestamp,
  1975. "topic": log.topic,
  1976. "direction": log.direction,
  1977. "payload": log.payload,
  1978. }
  1979. for log in logs
  1980. ],
  1981. }
  1982. @router.delete("/{printer_id}/logging")
  1983. async def clear_mqtt_logs(
  1984. printer_id: int,
  1985. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  1986. db: AsyncSession = Depends(get_db, scope="function"),
  1987. ):
  1988. """Clear MQTT message logs for a printer."""
  1989. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1990. printer = result.scalar_one_or_none()
  1991. if not printer:
  1992. raise HTTPException(404, "Printer not found")
  1993. printer_manager.clear_logs(printer_id)
  1994. return {"status": "cleared"}
  1995. # ============================================
  1996. # AMS Drying Endpoints
  1997. # ============================================
  1998. # The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
  1999. # — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
  2000. # (#2533). Refuse the command rather than let the caller believe it landed.
  2001. _DRYING_SCREEN_ONLY_DETAIL = drying_preflight.SCREEN_ONLY_DETAIL
  2002. @router.post("/{printer_id}/drying/start")
  2003. async def start_drying(
  2004. printer_id: int,
  2005. ams_id: int,
  2006. temp: int = 45,
  2007. duration: int = 4,
  2008. filament: str = "",
  2009. rotate_tray: bool = False,
  2010. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2011. db: AsyncSession = Depends(get_db, scope="function"),
  2012. ):
  2013. """Send AMS drying start command. temp=45-85, duration=hours."""
  2014. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2015. printer = result.scalar_one_or_none()
  2016. if not printer:
  2017. raise HTTPException(404, "Printer not found")
  2018. # Server-side guard: reject if this model/firmware doesn't support drying
  2019. live_state = printer_manager.get_status(printer_id)
  2020. firmware = live_state.firmware_version if live_state else None
  2021. unsupported = drying_preflight.check_drying_supported(printer.model, firmware)
  2022. if unsupported:
  2023. raise HTTPException(400, unsupported)
  2024. if temp < 45 or temp > 85:
  2025. raise HTTPException(400, "Temperature must be 45-85°C")
  2026. if duration < 1 or duration > 24:
  2027. raise HTTPException(400, "Duration must be 1-24 hours")
  2028. # Inspect the live AMS unit: surface blocking dry_sf_reasons (otherwise the
  2029. # firmware silently ignores the command — #971) and backfill an empty
  2030. # filament field from the first loaded tray so the printer doesn't reject
  2031. # the payload.
  2032. target_ams = drying_preflight.find_ams_unit(live_state, ams_id)
  2033. blocking = drying_preflight.blocking_reason_codes(target_ams)
  2034. if blocking:
  2035. # Same pick the scheduled path makes, so both describe one blocked AMS
  2036. # the same way rather than differing on which code the firmware listed
  2037. # first.
  2038. raise HTTPException(
  2039. 409, drying_preflight.DRY_SF_REASON_MESSAGES[drying_preflight.primary_reason_code(blocking)]
  2040. )
  2041. filament = drying_preflight.resolve_filament(target_ams, filament)
  2042. success = printer_manager.send_drying_command(
  2043. printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray
  2044. )
  2045. if not success:
  2046. raise HTTPException(400, "Printer not connected")
  2047. return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
  2048. @router.post("/{printer_id}/drying/stop")
  2049. async def stop_drying(
  2050. printer_id: int,
  2051. ams_id: int,
  2052. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2053. db: AsyncSession = Depends(get_db, scope="function"),
  2054. ):
  2055. """Send AMS drying stop command."""
  2056. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2057. printer = result.scalar_one_or_none()
  2058. if not printer:
  2059. raise HTTPException(404, "Printer not found")
  2060. # Screen-only models ignore stop just as they ignore start — a cycle running on a
  2061. # P1S was started at the printer and has to be ended there too (#2533).
  2062. if drying_screen_only(printer.model):
  2063. raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
  2064. success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
  2065. if not success:
  2066. raise HTTPException(400, "Printer not connected")
  2067. # A cycle the user stopped by hand tells us nothing about whether drying can
  2068. # move the humidity reading, so it must not count towards the auto-drying
  2069. # suspension (#2770). Imported here rather than at module scope to keep the
  2070. # existing routes/scheduler import direction.
  2071. from backend.app.services.print_scheduler import scheduler as print_scheduler
  2072. print_scheduler.forget_auto_dry_cycle(printer_id, ams_id)
  2073. return {"status": "drying_stopped", "ams_id": ams_id}
  2074. # ============================================
  2075. # Print Options (AI Detection) Endpoints
  2076. # ============================================
  2077. @router.post("/{printer_id}/print-options")
  2078. async def set_print_option(
  2079. printer_id: int,
  2080. module_name: str,
  2081. enabled: bool,
  2082. print_halt: bool = True,
  2083. sensitivity: str = "medium",
  2084. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2085. db: AsyncSession = Depends(get_db, scope="function"),
  2086. ):
  2087. """Set an AI detection / print option on the printer.
  2088. Valid module_name values:
  2089. - spaghetti_detector: Spaghetti detection
  2090. - first_layer_inspector: First layer inspection
  2091. - printing_monitor: AI print quality monitoring
  2092. - buildplate_marker_detector: Build plate marker detection
  2093. - allow_skip_parts: Allow skipping failed parts
  2094. """
  2095. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2096. printer = result.scalar_one_or_none()
  2097. if not printer:
  2098. raise HTTPException(404, "Printer not found")
  2099. client = printer_manager.get_client(printer_id)
  2100. if not client or not client.state.connected:
  2101. raise HTTPException(400, "Printer not connected")
  2102. # Validate module_name
  2103. valid_modules = [
  2104. "spaghetti_detector",
  2105. "first_layer_inspector",
  2106. "printing_monitor",
  2107. "buildplate_marker_detector",
  2108. "allow_skip_parts",
  2109. "pileup_detector",
  2110. "clump_detector",
  2111. "airprint_detector",
  2112. "auto_recovery_step_loss",
  2113. ]
  2114. if module_name not in valid_modules:
  2115. raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
  2116. # Validate sensitivity
  2117. valid_sensitivities = ["low", "medium", "high", "never_halt"]
  2118. if sensitivity not in valid_sensitivities:
  2119. raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
  2120. success = client.set_xcam_option(
  2121. module_name=module_name,
  2122. enabled=enabled,
  2123. print_halt=print_halt,
  2124. sensitivity=sensitivity,
  2125. )
  2126. if not success:
  2127. raise HTTPException(500, "Failed to send command to printer")
  2128. return {
  2129. "success": True,
  2130. "module_name": module_name,
  2131. "enabled": enabled,
  2132. "print_halt": print_halt,
  2133. "sensitivity": sensitivity,
  2134. }
  2135. @router.post("/{printer_id}/ams-backup")
  2136. async def set_ams_backup(
  2137. printer_id: int,
  2138. enabled: bool,
  2139. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2140. db: AsyncSession = Depends(get_db, scope="function"),
  2141. ):
  2142. """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
  2143. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2144. printer = result.scalar_one_or_none()
  2145. if not printer:
  2146. raise HTTPException(404, "Printer not found")
  2147. client = printer_manager.get_client(printer_id)
  2148. if not client or not client.state.connected:
  2149. raise HTTPException(400, "Printer not connected")
  2150. success = client.set_ams_filament_backup(enabled)
  2151. if not success:
  2152. raise HTTPException(500, "Failed to send command to printer")
  2153. return {"success": True, "ams_filament_backup": enabled}
  2154. @router.get("/{printer_id}/inventory-remain")
  2155. async def get_inventory_remain(
  2156. printer_id: int,
  2157. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  2158. db: AsyncSession = Depends(get_db, scope="function"),
  2159. ):
  2160. """Per-globalTrayId remaining grams for slots bound to an inventory spool.
  2161. Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal
  2162. client can apply the same two-tier "Prefer Lowest Remaining Filament" sort
  2163. the dispatcher uses (#1766). Works for both internal inventory and
  2164. Spoolman; unbound slots are absent from the map (client falls back to the
  2165. printer's MQTT `remain` for those).
  2166. `slot_materials` carries the same bindings with their material identity and
  2167. extruder side attached, which is what the modal's pre-flight filament check
  2168. needs to pool spools under AMS Filament Backup the way the dispatcher does.
  2169. It is deliberately server-computed: the identity rule lives in
  2170. `filament_deficit`, and a client-side reimplementation of it is exactly how
  2171. the modal came to block prints the dispatcher would have accepted. Unlike
  2172. `inventory_remain_g` it covers every binding, not just currently-loaded
  2173. slots — again matching what the dispatcher pools.
  2174. """
  2175. from backend.app.services.filament_deficit import build_slot_materials
  2176. from backend.app.services.print_scheduler import PrintScheduler
  2177. state = printer_manager.get_status(printer_id)
  2178. if not state:
  2179. return {"inventory_remain_g": {}, "slot_materials": []}
  2180. scheduler = PrintScheduler()
  2181. loaded = scheduler._build_loaded_filaments(state)
  2182. overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
  2183. slot_materials = await build_slot_materials(db, printer_id)
  2184. return {
  2185. "inventory_remain_g": {str(k): v for k, v in overrides.items()},
  2186. "slot_materials": [s.to_dict() for s in slot_materials],
  2187. }
  2188. # ============================================
  2189. # Calibration
  2190. # ============================================
  2191. @router.post("/{printer_id}/calibration")
  2192. async def start_calibration(
  2193. printer_id: int,
  2194. bed_leveling: bool = False,
  2195. vibration: bool = False,
  2196. motor_noise: bool = False,
  2197. nozzle_offset: bool = False,
  2198. high_temp_heatbed: bool = False,
  2199. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2200. db: AsyncSession = Depends(get_db, scope="function"),
  2201. ):
  2202. """Start printer calibration with selected options.
  2203. At least one option must be selected.
  2204. Options:
  2205. - bed_leveling: Run bed leveling calibration
  2206. - vibration: Run vibration compensation calibration
  2207. - motor_noise: Run motor noise cancellation calibration
  2208. - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
  2209. - high_temp_heatbed: Run high-temperature heatbed calibration
  2210. """
  2211. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2212. printer = result.scalar_one_or_none()
  2213. if not printer:
  2214. raise HTTPException(404, "Printer not found")
  2215. client = printer_manager.get_client(printer_id)
  2216. if not client or not client.state.connected:
  2217. raise HTTPException(400, "Printer not connected")
  2218. # Check that at least one option is selected
  2219. if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
  2220. raise HTTPException(400, "At least one calibration option must be selected")
  2221. success = client.start_calibration(
  2222. bed_leveling=bed_leveling,
  2223. vibration=vibration,
  2224. motor_noise=motor_noise,
  2225. nozzle_offset=nozzle_offset,
  2226. high_temp_heatbed=high_temp_heatbed,
  2227. )
  2228. if not success:
  2229. raise HTTPException(500, "Failed to send calibration command to printer")
  2230. return {
  2231. "success": True,
  2232. "bed_leveling": bed_leveling,
  2233. "vibration": vibration,
  2234. "motor_noise": motor_noise,
  2235. "nozzle_offset": nozzle_offset,
  2236. "high_temp_heatbed": high_temp_heatbed,
  2237. }
  2238. # ============================================================================
  2239. # Slot Preset Mapping Endpoints
  2240. # ============================================================================
  2241. def _slot_preset_key(ams_id: int, tray_id: int) -> int:
  2242. # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed
  2243. # by ams_id since each unit has a single slot and shares its global ID with
  2244. # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id.
  2245. if 128 <= ams_id <= 135:
  2246. return ams_id
  2247. return ams_id * 4 + tray_id
  2248. @router.get("/{printer_id}/slot-presets")
  2249. async def get_slot_presets(
  2250. printer_id: int,
  2251. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  2252. db: AsyncSession = Depends(get_db, scope="function"),
  2253. ):
  2254. """Get all saved slot-to-preset mappings for a printer."""
  2255. result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))
  2256. return _slot_presets_by_key(result.scalars().all())
  2257. def _slot_presets_by_key(mappings) -> dict:
  2258. """One printer's slot-to-preset mappings, keyed as ``get_slot_presets`` returns them."""
  2259. return {
  2260. _slot_preset_key(mapping.ams_id, mapping.tray_id): {
  2261. "ams_id": mapping.ams_id,
  2262. "tray_id": mapping.tray_id,
  2263. "preset_id": mapping.preset_id,
  2264. "preset_name": mapping.preset_name,
  2265. "tray_info_idx": mapping.tray_info_idx,
  2266. }
  2267. for mapping in mappings
  2268. }
  2269. @router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  2270. async def get_slot_preset(
  2271. printer_id: int,
  2272. ams_id: int,
  2273. tray_id: int,
  2274. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  2275. db: AsyncSession = Depends(get_db, scope="function"),
  2276. ):
  2277. """Get the saved preset for a specific slot."""
  2278. result = await db.execute(
  2279. select(SlotPresetMapping).where(
  2280. SlotPresetMapping.printer_id == printer_id,
  2281. SlotPresetMapping.ams_id == ams_id,
  2282. SlotPresetMapping.tray_id == tray_id,
  2283. )
  2284. )
  2285. mapping = result.scalar_one_or_none()
  2286. if not mapping:
  2287. return None
  2288. return {
  2289. "ams_id": mapping.ams_id,
  2290. "tray_id": mapping.tray_id,
  2291. "preset_id": mapping.preset_id,
  2292. "preset_name": mapping.preset_name,
  2293. "tray_info_idx": mapping.tray_info_idx,
  2294. }
  2295. @router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  2296. async def save_slot_preset(
  2297. printer_id: int,
  2298. ams_id: int,
  2299. tray_id: int,
  2300. preset_id: str,
  2301. preset_name: str,
  2302. preset_source: str = "cloud",
  2303. tray_info_idx: str | None = None,
  2304. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  2305. db: AsyncSession = Depends(get_db, scope="function"),
  2306. ):
  2307. """Save a preset mapping for a specific slot.
  2308. ``tray_info_idx`` is the filament id the slot was configured with (what
  2309. ``configure`` returns). Recording it lets the slot card notice a later
  2310. re-configuration from elsewhere and stop showing this preset (#3216).
  2311. Omitted, it is cleared, so a stale id never outlives the preset it came with.
  2312. """
  2313. # Longer than the column is not an id the printer holds: record nothing,
  2314. # which keeps the row shown, rather than refuse the whole save.
  2315. tray_info_idx = tray_info_idx if tray_info_idx and len(tray_info_idx) <= 32 else None
  2316. # Check printer exists
  2317. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  2318. if not result.scalar_one_or_none():
  2319. raise HTTPException(404, "Printer not found")
  2320. # Check for existing mapping
  2321. result = await db.execute(
  2322. select(SlotPresetMapping).where(
  2323. SlotPresetMapping.printer_id == printer_id,
  2324. SlotPresetMapping.ams_id == ams_id,
  2325. SlotPresetMapping.tray_id == tray_id,
  2326. )
  2327. )
  2328. mapping = result.scalar_one_or_none()
  2329. if mapping:
  2330. # Update existing
  2331. mapping.preset_id = preset_id
  2332. mapping.preset_name = preset_name
  2333. mapping.preset_source = preset_source
  2334. mapping.tray_info_idx = tray_info_idx
  2335. else:
  2336. # Create new
  2337. mapping = SlotPresetMapping(
  2338. printer_id=printer_id,
  2339. ams_id=ams_id,
  2340. tray_id=tray_id,
  2341. preset_id=preset_id,
  2342. preset_name=preset_name,
  2343. preset_source=preset_source,
  2344. tray_info_idx=tray_info_idx,
  2345. )
  2346. db.add(mapping)
  2347. await db.commit()
  2348. await db.refresh(mapping)
  2349. return {
  2350. "ams_id": mapping.ams_id,
  2351. "tray_id": mapping.tray_id,
  2352. "preset_id": mapping.preset_id,
  2353. "preset_name": mapping.preset_name,
  2354. "preset_source": mapping.preset_source,
  2355. "tray_info_idx": mapping.tray_info_idx,
  2356. }
  2357. @router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
  2358. async def delete_slot_preset(
  2359. printer_id: int,
  2360. ams_id: int,
  2361. tray_id: int,
  2362. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  2363. db: AsyncSession = Depends(get_db, scope="function"),
  2364. ):
  2365. """Delete a saved preset mapping for a slot."""
  2366. result = await db.execute(
  2367. select(SlotPresetMapping).where(
  2368. SlotPresetMapping.printer_id == printer_id,
  2369. SlotPresetMapping.ams_id == ams_id,
  2370. SlotPresetMapping.tray_id == tray_id,
  2371. )
  2372. )
  2373. mapping = result.scalar_one_or_none()
  2374. if mapping:
  2375. await db.delete(mapping)
  2376. await db.commit()
  2377. return {"success": True}
  2378. @router.get("/{printer_id}/slots/{ams_id}/{tray_id}/spool-defaults")
  2379. async def get_slot_spool_defaults(
  2380. printer_id: int,
  2381. ams_id: int,
  2382. tray_id: int,
  2383. db: AsyncSession = Depends(get_db, scope="function"),
  2384. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  2385. ):
  2386. """What the spool assigned to this slot is configured to use here.
  2387. The Configure AMS Slot dialog opens on a slot that usually already holds an
  2388. assigned spool, and that spool carries a filament preset per printer model
  2389. and a K profile per hotend. Without this the dialog offered defaults derived
  2390. from the slot's last manual configuration or from the tray's RFID data --
  2391. ignoring the very values the spool was configured with, on the one screen
  2392. that looks like it exists for them.
  2393. Everything is resolved for the nozzle THIS slot feeds, so a dual-nozzle
  2394. machine gets the answer for the correct hotend. Returns nulls rather than a
  2395. 404 when the slot holds no known spool: "nothing configured" is an ordinary
  2396. answer here and the dialog falls back to what it did before.
  2397. """
  2398. from backend.app.models.spool import Spool
  2399. from backend.app.models.spool_assignment import SpoolAssignment
  2400. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2401. from backend.app.services.inventory_mode import spoolman_owns_assignments
  2402. from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
  2403. from backend.app.services.slot_nozzle import resolve_slot_nozzle
  2404. from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset_with_own
  2405. state = printer_manager.get_status(printer_id)
  2406. model = printer_manager.get_model(printer_id)
  2407. slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, model)
  2408. profile = await find_slot_kprofile_for_extruder(
  2409. db,
  2410. printer_id,
  2411. ams_id,
  2412. tray_id,
  2413. slot_nozzle.extruder_or_default,
  2414. slot_nozzle.diameter,
  2415. model,
  2416. slot_nozzle.flow,
  2417. )
  2418. slicer_filament: str | None = None
  2419. slicer_filament_name: str | None = None
  2420. spoolman_mode = await spoolman_owns_assignments(db)
  2421. if spoolman_mode:
  2422. sm_assignment = (
  2423. await db.execute(
  2424. select(SpoolmanSlotAssignment).where(
  2425. SpoolmanSlotAssignment.printer_id == printer_id,
  2426. SpoolmanSlotAssignment.ams_id == ams_id,
  2427. SpoolmanSlotAssignment.tray_id == tray_id,
  2428. )
  2429. )
  2430. ).scalar_one_or_none()
  2431. if sm_assignment is not None:
  2432. slicer_filament, slicer_filament_name = await resolve_spoolman_preset_with_own(
  2433. db,
  2434. spoolman_spool_id=sm_assignment.spoolman_spool_id,
  2435. printer_model=model,
  2436. nozzle_diameter=slot_nozzle.diameter,
  2437. )
  2438. else:
  2439. assignment = (
  2440. await db.execute(
  2441. select(SpoolAssignment).where(
  2442. SpoolAssignment.printer_id == printer_id,
  2443. SpoolAssignment.ams_id == ams_id,
  2444. SpoolAssignment.tray_id == tray_id,
  2445. )
  2446. )
  2447. ).scalar_one_or_none()
  2448. if assignment is not None:
  2449. spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
  2450. if spool is not None:
  2451. slicer_filament, slicer_filament_name = await resolve_spool_preset(
  2452. db,
  2453. spool_id=spool.id,
  2454. printer_model=model,
  2455. nozzle_diameter=slot_nozzle.diameter,
  2456. fallback_filament=spool.slicer_filament,
  2457. fallback_name=spool.slicer_filament_name,
  2458. )
  2459. return {
  2460. "slicer_filament": slicer_filament,
  2461. "slicer_filament_name": slicer_filament_name,
  2462. "cali_idx": profile.cali_idx if profile else None,
  2463. "k_value": profile.k_value if profile else None,
  2464. "profile_name": profile.name if profile else None,
  2465. "extruder": slot_nozzle.extruder,
  2466. "nozzle_diameter": slot_nozzle.diameter,
  2467. }
  2468. def _orca_generic_filament_id(material: str) -> str:
  2469. """The generic for a material, tried as given, without a CF suffix, without
  2470. a trailing "+", then by its first word -- the dialog's order."""
  2471. material = (material or "").upper().strip()
  2472. return (
  2473. GENERIC_FILAMENT_IDS.get(material)
  2474. or GENERIC_FILAMENT_IDS.get(re.sub(r"[-\s]?CF$", "", material))
  2475. or GENERIC_FILAMENT_IDS.get(re.sub(r"\+$", "", material))
  2476. or GENERIC_FILAMENT_IDS.get(re.split(r"[-\s]", material)[0])
  2477. or ""
  2478. )
  2479. @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
  2480. async def configure_ams_slot(
  2481. printer_id: int,
  2482. ams_id: int,
  2483. tray_id: int,
  2484. tray_info_idx: str = Query(...),
  2485. tray_type: str = Query(...),
  2486. tray_sub_brands: str = Query(...),
  2487. tray_color: str = Query(...),
  2488. nozzle_temp_min: int = Query(...),
  2489. nozzle_temp_max: int = Query(...),
  2490. cali_idx: int = Query(-1),
  2491. nozzle_diameter: str = Query("0.4"),
  2492. setting_id: str = Query(""),
  2493. kprofile_filament_id: str = Query(""),
  2494. kprofile_setting_id: str = Query(""),
  2495. k_value: float = Query(0.0),
  2496. orca_profile_id: str = Query(""),
  2497. db: AsyncSession = Depends(get_db, scope="function"),
  2498. current_user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2499. ):
  2500. """Configure an AMS slot with a specific filament setting and K profile.
  2501. This sends two commands to the printer:
  2502. 1. ams_filament_setting - sets filament type, color, temperature
  2503. 2. extrusion_cali_sel - sets the K profile (pressure advance value)
  2504. Args:
  2505. printer_id: Database ID of the printer
  2506. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
  2507. tray_id: Tray ID within the AMS (0-3)
  2508. tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
  2509. tray_type: Filament type (e.g., "PLA", "PETG")
  2510. tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
  2511. tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
  2512. nozzle_temp_min: Minimum nozzle temperature
  2513. nozzle_temp_max: Maximum nozzle temperature
  2514. cali_idx: K profile calibration index (-1 for default 0.020)
  2515. nozzle_diameter: Nozzle diameter string (e.g., "0.4")
  2516. setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
  2517. kprofile_filament_id: K profile's filament_id for proper K profile linking
  2518. k_value: Direct K value to set (0.0 to skip direct K value setting)
  2519. orca_profile_id: Orca Cloud profile the slot is being set to. With no
  2520. tray_info_idx, the profile's own filament id is looked up here
  2521. (#3216); the response says when none was found.
  2522. """
  2523. logger = logging.getLogger(__name__)
  2524. logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
  2525. logger.info(
  2526. f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
  2527. )
  2528. logger.info(
  2529. f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
  2530. )
  2531. # The modal derives tray_type from a preset name or a spool's material, so
  2532. # it can be a product line rather than a type ("PLA+", "PolyTerra PLA").
  2533. # A slot carrying one of those satisfies nothing that asks for PLA, so the
  2534. # slot gets the type and tray_sub_brands -- untouched here -- keeps the
  2535. # name (issue #2902). The requested wording is kept for the id lookup
  2536. # below, which knows some product lines the type table does not.
  2537. requested_tray_type = tray_type
  2538. tray_type = printer_filament_type(tray_type)
  2539. if tray_type != requested_tray_type:
  2540. logger.info("[configure_ams_slot] tray_type %r → %r", requested_tray_type, tray_type)
  2541. # Get MQTT client for this printer
  2542. client = printer_manager.get_client(printer_id)
  2543. if not client:
  2544. raise HTTPException(status_code=400, detail="Printer not connected")
  2545. # Discard a tray_info_idx the printer cannot store (#3003).
  2546. #
  2547. # The field is 8 characters wide. A local preset id ("P" + 7 hex) is
  2548. # exactly 8, which is presumably why nobody noticed -- but a cloud
  2549. # *setting* id is 18, and the firmware keeps the first 8 and reports
  2550. # success. Measured on @marivo's A1 in the #3003 bundle:
  2551. #
  2552. # sent tray_info_idx=PFUS9ddc938fe3ab8f
  2553. # printer Assignment NOT confirmed: tray shows PFUS9DDC
  2554. #
  2555. # `PFUS9ddc` resolves to nothing anywhere, so the slot came out of the
  2556. # Configure modal as "Generic <material>" in the slicer -- strictly worse
  2557. # than the base filament it would have got from the fallback below, and it
  2558. # also breaks the calibration table, which is keyed by this field.
  2559. #
  2560. # Blanking it here is what hands the slot to the reuse / generic branch.
  2561. # The preset reference is not lost: it stays in setting_id, the field that
  2562. # does accept a PFUS. Same four rejected shapes, and the same reasoning, as
  2563. # `slicer_filament_resolver`'s closing guard -- which the assignment path
  2564. # has run since #1815 while Configure had none. The Orca profile UUID is on
  2565. # the list for the same reason as the rest: the modal no longer sends one,
  2566. # but this route is public API and 36 characters is the worst of the four
  2567. # against an 8-character field.
  2568. if tray_info_idx and (
  2569. tray_info_idx.startswith("PFUS")
  2570. or tray_info_idx.startswith("PFCN")
  2571. or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
  2572. or is_material_name(tray_info_idx)
  2573. ):
  2574. logger.info(
  2575. "[configure_ams_slot] tray_info_idx %r is not storable as a filament id — "
  2576. "falling back to slot reuse / generic (kept as setting_id %r)",
  2577. tray_info_idx,
  2578. setting_id or tray_info_idx,
  2579. )
  2580. if not setting_id and (tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")):
  2581. setting_id = tray_info_idx
  2582. tray_info_idx = ""
  2583. # An Orca Cloud profile: look up the filament id it puts in the slot. It is
  2584. # the one value OrcaSlicer's "Sync filaments" matches a slot by, so a
  2585. # generic here is what turned every Orca custom filament into "Generic
  2586. # <material>" in the slicer (#3216). Done here rather than in the browser so
  2587. # the outcome is in the log, and so a failure can be reported back.
  2588. orca_fallback_reason = ""
  2589. if orca_profile_id and not tray_info_idx:
  2590. orca_lookup = await lookup_orca_filament_id(db, current_user, orca_profile_id)
  2591. found = orca_lookup.filament_id
  2592. reason = orca_lookup.reason
  2593. if found and (
  2594. found.startswith("PFUS")
  2595. or found.startswith("PFCN")
  2596. or _ORCA_PROFILE_ID.fullmatch(found)
  2597. or is_material_name(found)
  2598. ):
  2599. # Same refusal as the guard above: never put an id the printer
  2600. # cannot store in the slot.
  2601. found, reason = "", "no_filament_id"
  2602. if found:
  2603. tray_info_idx = found
  2604. else:
  2605. orca_fallback_reason = reason or "no_filament_id"
  2606. # The generic the Configure dialog picked for an Orca profile before
  2607. # the lookup moved here, with the dialog's own lookup order.
  2608. tray_info_idx = _orca_generic_filament_id(requested_tray_type)
  2609. logger.info(
  2610. "[configure_ams_slot] Orca profile %r → tray_info_idx=%r (%s)",
  2611. orca_profile_id,
  2612. tray_info_idx,
  2613. orca_lookup.source or orca_fallback_reason,
  2614. )
  2615. # Resolve tray_info_idx for the MQTT command.
  2616. # Priority:
  2617. # 1. Use the provided tray_info_idx if set, once the guard above has had
  2618. # its say (so: a GF* official or P* local id, never a PFUS/PFCN one).
  2619. # 2. Reuse the slot's existing tray_info_idx if it's a specific
  2620. # (non-generic) preset for the same material.
  2621. # 3. Fall back to a generic Bambu filament ID.
  2622. effective_tray_info_idx = tray_info_idx
  2623. if not tray_info_idx:
  2624. # No preset provided — try slot reuse or generic fallback
  2625. current_tray_info_idx = ""
  2626. current_tray_type = ""
  2627. current_tray_sub_brands = ""
  2628. state = printer_manager.get_status(printer_id)
  2629. if state and state.raw_data:
  2630. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  2631. if ams_id == 255:
  2632. vt_tray = state.raw_data.get("vt_tray") or []
  2633. ext_id = tray_id + 254
  2634. for vt in vt_tray:
  2635. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  2636. current_tray_info_idx = vt.get("tray_info_idx", "")
  2637. current_tray_type = vt.get("tray_type", "")
  2638. current_tray_sub_brands = vt.get("tray_sub_brands", "")
  2639. break
  2640. else:
  2641. ams_data = state.raw_data.get("ams", {})
  2642. ams_list = (
  2643. ams_data.get("ams", [])
  2644. if isinstance(ams_data, dict)
  2645. else ams_data
  2646. if isinstance(ams_data, list)
  2647. else []
  2648. )
  2649. cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
  2650. if cur_tray:
  2651. current_tray_info_idx = cur_tray.get("tray_info_idx", "")
  2652. current_tray_type = cur_tray.get("tray_type", "")
  2653. current_tray_sub_brands = cur_tray.get("tray_sub_brands", "")
  2654. if (
  2655. # An Orca profile without an id must not inherit whatever specific
  2656. # filament the slot held before: that would put the previous spool's
  2657. # preset in front of the slicer. It gets the generic for its material.
  2658. not orca_profile_id
  2659. and current_tray_info_idx
  2660. and current_tray_info_idx not in GENERIC_IDS_REPLACED_ON_REUSE
  2661. and current_tray_type
  2662. and current_tray_type.upper() == tray_type.upper()
  2663. # The same type in another finish is another preset.
  2664. and not different_finish(tray_sub_brands, current_tray_sub_brands)
  2665. ):
  2666. logger.info(
  2667. "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
  2668. current_tray_info_idx,
  2669. tray_type,
  2670. )
  2671. effective_tray_info_idx = current_tray_info_idx
  2672. elif tray_type:
  2673. # Requested wording first, reduced type only as a further fallback,
  2674. # so a material that already resolves keeps resolving to the same
  2675. # id: "PETG HF" has its own generic preset (GFG96) that reducing it
  2676. # to "PETG" would trade away for GFG99.
  2677. material = requested_tray_type.upper().strip()
  2678. generic = (
  2679. finish_generic_id(tray_type, f"{requested_tray_type} {tray_sub_brands}")
  2680. or GENERIC_FILAMENT_IDS.get(material)
  2681. or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  2682. or GENERIC_FILAMENT_IDS.get(tray_type.upper())
  2683. or ""
  2684. )
  2685. if generic:
  2686. logger.info("[configure_ams_slot] Falling back to generic %r for material %r", generic, tray_type)
  2687. effective_tray_info_idx = generic
  2688. # Send filament setting + K-profile commands
  2689. filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
  2690. # Realign the slot's filament context to the K-profile's calibration
  2691. # context. The printer's calibration table is keyed by (filament_id,
  2692. # cali_idx) — so for the cali_idx selected via extrusion_cali_sel to
  2693. # actually stick to the slot, ams_filament_setting must declare the
  2694. # slot under the SAME filament_id.
  2695. #
  2696. # Without this, configure_ams_slot would send:
  2697. # ams_filament_setting → tray_info_idx=GFL99 (generic from material)
  2698. # extrusion_cali_sel → filament_id=P4d64437 (kp's preset)
  2699. # ...and the cali_idx would silently be dropped to default because the
  2700. # slot's filament context (GFL99) doesn't match the kp's (P4d64437).
  2701. #
  2702. # This realignment fires only when the kp is targeted at a different
  2703. # preset than the user's filament selection AND the kp's preset is a
  2704. # valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user
  2705. # which the slicer rejects in tray_info_idx).
  2706. effective_setting_id = setting_id
  2707. realigned_to_kprofile = False
  2708. if (
  2709. kprofile_filament_id
  2710. and kprofile_filament_id != effective_tray_info_idx
  2711. and not kprofile_filament_id.startswith("PFUS")
  2712. ):
  2713. realigned_to_kprofile = True
  2714. logger.info(
  2715. "[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r",
  2716. effective_tray_info_idx,
  2717. kprofile_filament_id,
  2718. setting_id,
  2719. kprofile_setting_id or setting_id,
  2720. )
  2721. effective_tray_info_idx = kprofile_filament_id
  2722. if kprofile_setting_id:
  2723. effective_setting_id = kprofile_setting_id
  2724. # Back-fill setting_id from the resolved filament id when the client sent
  2725. # none. Built-in / local / Orca-generic presets in the Configure AMS Slot
  2726. # modal leave setting_id empty (they carry only a GF* tray_info_idx), and
  2727. # the printer treats a filament-id-without-setting-id slot as half
  2728. # configured: it shows the new material briefly, then reverts to its
  2729. # previously stored profile (#2604). This mirrors the derivation the
  2730. # inventory/assignment path already does (inventory.py). filament_id_to_
  2731. # setting_id leaves P* user presets and already-GFS* values unchanged, so
  2732. # only the empty-setting_id generic paths are affected.
  2733. if effective_tray_info_idx and not effective_setting_id:
  2734. effective_setting_id = filament_id_to_setting_id(effective_tray_info_idx)
  2735. # Always send ams_set_filament_setting — the user explicitly clicked
  2736. # "Configure Slot", so honor that. Previous versions skipped this for
  2737. # RFID-tagged slots to preserve the slicer eye icon, but printers cache
  2738. # stale tag_uid/tray_uuid after a BL spool is removed, causing the check
  2739. # to false-positive on non-RFID slots and silently drop the command.
  2740. success = client.ams_set_filament_setting(
  2741. ams_id=ams_id,
  2742. tray_id=tray_id,
  2743. tray_info_idx=effective_tray_info_idx,
  2744. tray_type=tray_type,
  2745. tray_sub_brands=tray_sub_brands,
  2746. tray_color=tray_color,
  2747. nozzle_temp_min=nozzle_temp_min,
  2748. nozzle_temp_max=nozzle_temp_max,
  2749. setting_id=effective_setting_id,
  2750. )
  2751. if not success:
  2752. raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
  2753. # Method 1: Select existing calibration profile by cali_idx
  2754. # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
  2755. # and including it causes the firmware to mislink the profile on X1C/P1S.
  2756. client.extrusion_cali_sel(
  2757. ams_id=ams_id,
  2758. tray_id=tray_id,
  2759. cali_idx=cali_idx,
  2760. filament_id=filament_id_for_kprofile,
  2761. nozzle_diameter=nozzle_diameter,
  2762. )
  2763. # A deliberate Default pick must not be "restored" to the spool's stored
  2764. # profile by the lost-selection check (#3219).
  2765. kprofile_drift.note_slot_configured(printer_id, ams_id, tray_id, cali_idx)
  2766. # Method 2: Only send extrusion_cali_set when NO existing profile was selected
  2767. # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the
  2768. # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY
  2769. # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
  2770. # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
  2771. # hardcoded extruder_id=0 and nozzle_id=HS00.
  2772. k_value_unsent = False
  2773. if k_value > 0 and cali_idx < 0:
  2774. # Calculate global tray ID for extrusion_cali_set
  2775. if ams_id <= 3:
  2776. global_tray_id = ams_id * 4 + tray_id
  2777. elif ams_id >= 128 and ams_id <= 135:
  2778. global_tray_id = (ams_id - 128) * 4 + tray_id
  2779. else:
  2780. global_tray_id = tray_id
  2781. k_value_unsent = not client.extrusion_cali_set(
  2782. tray_id=global_tray_id,
  2783. k_value=k_value,
  2784. nozzle_diameter=nozzle_diameter,
  2785. nozzle_temp=nozzle_temp_max,
  2786. filament_id=filament_id_for_kprofile,
  2787. setting_id=kprofile_setting_id or "",
  2788. name=tray_sub_brands or "",
  2789. cali_idx=cali_idx,
  2790. )
  2791. # Persist the user's K-profile choice so it survives RFID re-reads and
  2792. # session restarts. Pre-Phase-13 this was ephemeral — the MQTT command
  2793. # took effect on the printer but bambuddy never recorded it, so the next
  2794. # `_apply_pa_after_refresh` cycle had no stored profile to re-assert.
  2795. if cali_idx >= 0:
  2796. try:
  2797. from sqlalchemy.orm import selectinload
  2798. from backend.app.models.spool_assignment import SpoolAssignment
  2799. from backend.app.models.spool_k_profile import SpoolKProfile
  2800. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2801. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2802. # Resolve the slot's extruder for the K-profile match key. On a
  2803. # Filament Track Switch machine this comes from the AMS's inlet
  2804. # binding, because every unit reports extruder 0xE there — without
  2805. # that, the `else 0` below filed every profile under the right-hand
  2806. # nozzle and a left-nozzle calibration was stored as a right one.
  2807. slot_state = printer_manager.get_status(printer_id)
  2808. resolved_extruder = slot_extruder(
  2809. ams_id,
  2810. tray_id,
  2811. slot_state.ams_extruder_map if slot_state else None,
  2812. slot_state.ams_switch_inlet if slot_state else None,
  2813. )
  2814. # Still 0 when nothing is known, which is right for a single-nozzle
  2815. # printer — the resolver only returns None when it genuinely cannot
  2816. # tell, and on those machines extruder 0 is the only one there is.
  2817. kp_extruder = resolved_extruder if resolved_extruder is not None else 0
  2818. # Only the active mode's assignment table decides where this
  2819. # K-profile is stored. Reading Spoolman first and falling through
  2820. # was safe while the inactive table was emptied on every mode
  2821. # toggle; nothing is emptied since #2812, so a leftover Spoolman
  2822. # row in built-in mode would file the calibration against a spool
  2823. # the printer is not using and never write the local profile —
  2824. # the calibration would appear to succeed and then not apply.
  2825. from backend.app.services.inventory_mode import spoolman_owns_assignments
  2826. spoolman_mode = await spoolman_owns_assignments(db)
  2827. sm_assignment = None
  2828. if spoolman_mode:
  2829. # Spoolman SlotAssignment — has UniqueConstraint, idempotent.
  2830. sm_result = await db.execute(
  2831. select(SpoolmanSlotAssignment).where(
  2832. SpoolmanSlotAssignment.printer_id == printer_id,
  2833. SpoolmanSlotAssignment.ams_id == ams_id,
  2834. SpoolmanSlotAssignment.tray_id == tray_id,
  2835. )
  2836. )
  2837. sm_assignment = sm_result.scalar_one_or_none()
  2838. if sm_assignment:
  2839. existing = await db.execute(
  2840. select(SpoolmanKProfile).where(
  2841. SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
  2842. SpoolmanKProfile.printer_id == printer_id,
  2843. SpoolmanKProfile.extruder == kp_extruder,
  2844. SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
  2845. )
  2846. )
  2847. kp = existing.scalar_one_or_none()
  2848. if kp:
  2849. kp.cali_idx = cali_idx
  2850. kp.k_value = k_value or 0.0
  2851. kp.setting_id = kprofile_setting_id or None
  2852. kp.name = tray_sub_brands or None
  2853. else:
  2854. db.add(
  2855. SpoolmanKProfile(
  2856. spoolman_spool_id=sm_assignment.spoolman_spool_id,
  2857. printer_id=printer_id,
  2858. extruder=kp_extruder,
  2859. nozzle_diameter=nozzle_diameter,
  2860. k_value=k_value or 0.0,
  2861. name=tray_sub_brands or None,
  2862. cali_idx=cali_idx,
  2863. setting_id=kprofile_setting_id or None,
  2864. )
  2865. )
  2866. await db.commit()
  2867. logger.info(
  2868. "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
  2869. sm_assignment.spoolman_spool_id,
  2870. printer_id,
  2871. ams_id,
  2872. tray_id,
  2873. cali_idx,
  2874. )
  2875. elif not spoolman_mode:
  2876. # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first()).
  2877. # Skipped in Spoolman mode even when a local row survives: the
  2878. # profile would be filed against a spool this printer is not
  2879. # drawing on, and the mode's own table has nothing to bind to.
  2880. local_result = await db.execute(
  2881. select(SpoolAssignment)
  2882. .options(selectinload(SpoolAssignment.spool))
  2883. .where(
  2884. SpoolAssignment.printer_id == printer_id,
  2885. SpoolAssignment.ams_id == ams_id,
  2886. SpoolAssignment.tray_id == tray_id,
  2887. )
  2888. )
  2889. local_assignment = local_result.scalar_one_or_none()
  2890. if local_assignment and local_assignment.spool:
  2891. existing = await db.execute(
  2892. select(SpoolKProfile).where(
  2893. SpoolKProfile.spool_id == local_assignment.spool.id,
  2894. SpoolKProfile.printer_id == printer_id,
  2895. SpoolKProfile.extruder == kp_extruder,
  2896. SpoolKProfile.nozzle_diameter == nozzle_diameter,
  2897. )
  2898. )
  2899. # SpoolKProfile has no unique constraint on this tuple, so
  2900. # multiple rows could theoretically exist (shouldn't, but
  2901. # don't crash if they do). Update the first match, leave
  2902. # any duplicates alone.
  2903. kp = existing.scalars().first()
  2904. if kp:
  2905. kp.cali_idx = cali_idx
  2906. kp.k_value = k_value or 0.0
  2907. kp.setting_id = kprofile_setting_id or None
  2908. kp.name = tray_sub_brands or None
  2909. else:
  2910. db.add(
  2911. SpoolKProfile(
  2912. spool_id=local_assignment.spool.id,
  2913. printer_id=printer_id,
  2914. extruder=kp_extruder,
  2915. nozzle_diameter=nozzle_diameter,
  2916. k_value=k_value or 0.0,
  2917. name=tray_sub_brands or None,
  2918. cali_idx=cali_idx,
  2919. setting_id=kprofile_setting_id or None,
  2920. )
  2921. )
  2922. await db.commit()
  2923. logger.info(
  2924. "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
  2925. local_assignment.spool.id,
  2926. printer_id,
  2927. ams_id,
  2928. tray_id,
  2929. cali_idx,
  2930. )
  2931. except Exception:
  2932. # MQTT command was already sent successfully — DB persist is best-effort.
  2933. logger.exception(
  2934. "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)",
  2935. printer_id,
  2936. ams_id,
  2937. tray_id,
  2938. cali_idx,
  2939. )
  2940. try:
  2941. await db.rollback()
  2942. except Exception:
  2943. pass
  2944. # Register a read-back verification (#2582) so the tray telemetry that the
  2945. # status push below returns can confirm the printer accepted this manual
  2946. # slot configuration. Mirrors the inventory/assignment path.
  2947. client.register_assignment_verification(
  2948. ams_id=ams_id,
  2949. tray_id=tray_id,
  2950. tray_info_idx=effective_tray_info_idx,
  2951. tray_color=tray_color,
  2952. cali_idx=cali_idx,
  2953. )
  2954. # Request fresh status push from printer so frontend gets updated data via WebSocket
  2955. logger.info("[configure_ams_slot] Requesting status update from printer")
  2956. update_result = client.request_status_update()
  2957. logger.info("[configure_ams_slot] Status update request result: %s", update_result)
  2958. return {
  2959. "success": True,
  2960. "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
  2961. # What the slot was actually given, for the slot preset row (#3216).
  2962. "tray_info_idx": effective_tray_info_idx,
  2963. # Set when an Orca profile's own filament id could not be used, so the
  2964. # slicer will see the generic for the material: "no_filament_id",
  2965. # "lookup_failed" or "no_permission". Cleared when a K-profile realigned
  2966. # the slot to its own specific filament after all.
  2967. "orca_fallback_reason": "" if realigned_to_kprofile else orca_fallback_reason,
  2968. # The filament setting went out but a manual K value did not (FW-C5).
  2969. # Only the send is known: the printer's answer to it is not trusted --
  2970. # an X1C answers "fail" to a K value it applied.
  2971. "k_value_unsent": k_value_unsent,
  2972. }
  2973. @router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
  2974. async def reset_ams_slot(
  2975. printer_id: int,
  2976. ams_id: int,
  2977. tray_id: int,
  2978. db: AsyncSession = Depends(get_db, scope="function"),
  2979. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  2980. ):
  2981. """Reset an AMS slot to empty/unconfigured state.
  2982. This clears the filament configuration from the slot.
  2983. """
  2984. # Get MQTT client for this printer
  2985. client = printer_manager.get_client(printer_id)
  2986. if not client:
  2987. raise HTTPException(status_code=400, detail="Printer not connected")
  2988. # A running print may be feeding from this tray.
  2989. if client.state and client.state.state in printer_manager.ACTIVE_PRINT_STATES:
  2990. raise HTTPException(status_code=409, detail="The printer is printing; reset the slot once it is idle")
  2991. # Reset the slot
  2992. success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
  2993. if not success:
  2994. raise HTTPException(status_code=500, detail="Failed to send reset command")
  2995. # Also delete any saved slot preset mapping
  2996. result = await db.execute(
  2997. select(SlotPresetMapping).where(
  2998. SlotPresetMapping.printer_id == printer_id,
  2999. SlotPresetMapping.ams_id == ams_id,
  3000. SlotPresetMapping.tray_id == tray_id,
  3001. )
  3002. )
  3003. mapping = result.scalar_one_or_none()
  3004. if mapping:
  3005. await db.delete(mapping)
  3006. await db.commit()
  3007. # Request fresh status push from printer so frontend gets updated data via WebSocket
  3008. client.request_status_update()
  3009. return {
  3010. "success": True,
  3011. "message": f"Reset AMS {ams_id} tray {tray_id}",
  3012. }
  3013. @router.get("/{printer_id}/ams-labels")
  3014. async def get_ams_labels(
  3015. printer_id: int,
  3016. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  3017. db: AsyncSession = Depends(get_db, scope="function"),
  3018. ):
  3019. """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
  3020. Labels are stored by AMS serial number. This endpoint resolves the current
  3021. serial-to-ams_id mapping from the live printer state so the response is still
  3022. keyed by ams_id for UI compatibility.
  3023. """
  3024. return await _ams_labels(db, printer_id)
  3025. async def _ams_labels(db: AsyncSession, printer_id: int) -> dict[int, str]:
  3026. """*printer_id*'s AMS labels keyed by AMS unit id; see ``get_ams_labels``."""
  3027. # Build serial -> ams_id map from live printer state
  3028. serial_to_ams_id: dict[str, int] = {}
  3029. state = printer_manager.get_status(printer_id)
  3030. if state and state.raw_data:
  3031. for ams_unit in state.raw_data.get("ams", []):
  3032. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  3033. if sn:
  3034. serial_to_ams_id[sn] = int(ams_unit.get("id", 0))
  3035. # Collect all known serials for this printer (live + synthetic fallback keys)
  3036. serials_to_query = set(serial_to_ams_id.keys())
  3037. # Fetch labels for all known serials
  3038. labels: dict[int, str] = {}
  3039. if serials_to_query:
  3040. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)))
  3041. for lbl in result.scalars().all():
  3042. aid = serial_to_ams_id.get(lbl.ams_serial_number)
  3043. if aid is not None:
  3044. labels[aid] = lbl.label
  3045. # Also fetch labels stored under synthetic keys for this printer (backward compat)
  3046. # Collect all synthetic keys first, then query with a single IN clause.
  3047. if state and state.raw_data:
  3048. synthetic_key_to_aid: dict[str, int] = {
  3049. f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0))
  3050. for ams_unit in state.raw_data.get("ams", [])
  3051. if int(ams_unit.get("id", 0)) not in labels
  3052. }
  3053. if synthetic_key_to_aid:
  3054. result = await db.execute(
  3055. select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys()))
  3056. )
  3057. for lbl in result.scalars().all():
  3058. aid = synthetic_key_to_aid.get(lbl.ams_serial_number)
  3059. if aid is not None:
  3060. labels[aid] = lbl.label
  3061. return labels
  3062. @router.put("/{printer_id}/ams-labels/{ams_id}")
  3063. async def save_ams_label(
  3064. printer_id: int,
  3065. ams_id: int,
  3066. body: AmsLabelBody,
  3067. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  3068. db: AsyncSession = Depends(get_db, scope="function"),
  3069. ):
  3070. """Create or update the friendly name for a specific AMS unit.
  3071. When ``ams_serial`` is provided the label is stored under that serial number so
  3072. it survives the AMS being moved to a different printer. When it is absent (e.g.
  3073. older firmware that does not report a serial) a synthetic key based on the
  3074. printer_id and ams_id is used as a fallback.
  3075. """
  3076. # Verify printer exists
  3077. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3078. if not result.scalar_one_or_none():
  3079. raise HTTPException(404, "Printer not found")
  3080. # Determine the serial key to store under
  3081. stripped = body.ams_serial.strip() if body.ams_serial else ""
  3082. position_key = f"p{printer_id}a{ams_id}"
  3083. serial_key = stripped if stripped else position_key
  3084. result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
  3085. existing = result.scalar_one_or_none()
  3086. if existing:
  3087. existing.label = body.label
  3088. existing.ams_id = ams_id
  3089. else:
  3090. db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))
  3091. # A unit that reports its serial only now (the AMS Lite before #3348) may
  3092. # still have its label under the position key; the serial's label replaces it.
  3093. if serial_key != position_key:
  3094. await db.execute(delete(AmsLabel).where(AmsLabel.ams_serial_number == position_key))
  3095. await db.commit()
  3096. return {"ams_id": ams_id, "label": body.label}
  3097. @router.delete("/{printer_id}/ams-labels/{ams_id}")
  3098. async def delete_ams_label(
  3099. printer_id: int,
  3100. ams_id: int,
  3101. ams_serial: str = Query(default="", max_length=50),
  3102. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
  3103. db: AsyncSession = Depends(get_db, scope="function"),
  3104. ):
  3105. """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
  3106. # The position key too: a label saved before the unit reported its serial
  3107. # is shown as a fallback, and would otherwise come back once cleared.
  3108. stripped = ams_serial.strip() if ams_serial else ""
  3109. keys = {stripped, f"p{printer_id}a{ams_id}"} - {""}
  3110. await db.execute(delete(AmsLabel).where(AmsLabel.ams_serial_number.in_(keys)))
  3111. await db.commit()
  3112. return {"success": True}
  3113. # =============================================================================
  3114. # Print Control Endpoints
  3115. # =============================================================================
  3116. @router.post("/{printer_id}/print/stop")
  3117. async def stop_print(
  3118. printer_id: int,
  3119. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3120. db: AsyncSession = Depends(get_db, scope="function"),
  3121. ):
  3122. """Stop/cancel the current print job."""
  3123. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3124. printer = result.scalar_one_or_none()
  3125. if not printer:
  3126. raise HTTPException(404, "Printer not found")
  3127. client = printer_manager.get_client(printer_id)
  3128. if not client:
  3129. raise HTTPException(400, "Printer not connected")
  3130. # Sends the stop and marks the printer as stopped by the user, so the
  3131. # resulting "failed"/"aborted" status ends the print as cancelled.
  3132. from backend.app.services.print_control import stop_print_by_user
  3133. if not stop_print_by_user(printer_id):
  3134. raise HTTPException(500, "Failed to stop print")
  3135. return {"success": True, "message": "Print stop command sent"}
  3136. @router.post("/{printer_id}/clear-plate")
  3137. async def clear_plate(
  3138. printer_id: int,
  3139. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
  3140. db: AsyncSession = Depends(get_db, scope="function"),
  3141. ):
  3142. """Acknowledge that the build plate has been cleared after a finished/failed print.
  3143. Sets a plate-cleared flag so the scheduler can start the next queued print.
  3144. No MQTT command is sent to the printer — the scheduler's start_print command
  3145. will override the FINISH/FAILED state when it sends the next job.
  3146. """
  3147. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3148. printer = result.scalar_one_or_none()
  3149. if not printer:
  3150. raise HTTPException(404, "Printer not found")
  3151. # Deliberately NOT gated on the printer being connected. Acknowledging the plate
  3152. # only mutates Bambuddy-side state — no MQTT command is sent — and with Auto Power
  3153. # Off the normal end-of-print state is exactly this: gate up, printer powered down.
  3154. # The guard this replaces was inherited from the sibling stop/pause/resume handlers,
  3155. # where reaching the printer IS required, and left farms with no way to release the
  3156. # gate short of powering each printer back on by hand (#2864).
  3157. # Accept the acknowledgment whenever the printer is awaiting it — not only when the
  3158. # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE
  3159. # but the awaiting flag persists, and the user still needs a way to ack it (#961).
  3160. state = printer_manager.get_status(printer_id)
  3161. awaiting = printer_manager.is_awaiting_plate_clear(printer_id)
  3162. if not awaiting and (not state or state.state not in ("FINISH", "FAILED")):
  3163. raise HTTPException(
  3164. 400,
  3165. f"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})",
  3166. )
  3167. printer_manager.set_awaiting_plate_clear(printer_id, False)
  3168. # #1898: releasing the plate without answering the outcome prompt can
  3169. # count as "good" (opt-in setting) — this is the moment the operator
  3170. # moves on, so an unanswered prompt would otherwise linger unconfirmed.
  3171. from backend.app.api.routes.settings import get_setting, setting_is_true
  3172. # setting_is_true rather than a comparison of our own: one reader deciding
  3173. # for itself what "on" spells is how two parts of the app end up
  3174. # disagreeing about the same row.
  3175. if setting_is_true(await get_setting(db, "confirm_default_good_on_plate_clear")):
  3176. from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
  3177. resolved = await resolve_pending_confirmation_as_good(db, printer_id)
  3178. if resolved is not None:
  3179. await db.commit()
  3180. return {"success": True, "message": "Plate cleared, next print will start shortly"}
  3181. @router.post("/{printer_id}/print/pause")
  3182. async def pause_print(
  3183. printer_id: int,
  3184. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3185. db: AsyncSession = Depends(get_db, scope="function"),
  3186. ):
  3187. """Pause the current print job."""
  3188. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3189. printer = result.scalar_one_or_none()
  3190. if not printer:
  3191. raise HTTPException(404, "Printer not found")
  3192. client = printer_manager.get_client(printer_id)
  3193. if not client:
  3194. raise HTTPException(400, "Printer not connected")
  3195. success = client.pause_print()
  3196. if not success:
  3197. raise HTTPException(500, "Failed to pause print")
  3198. return {"success": True, "message": "Print pause command sent"}
  3199. @router.post("/{printer_id}/print/resume")
  3200. async def resume_print(
  3201. printer_id: int,
  3202. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3203. db: AsyncSession = Depends(get_db, scope="function"),
  3204. ):
  3205. """Resume a paused print job."""
  3206. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3207. printer = result.scalar_one_or_none()
  3208. if not printer:
  3209. raise HTTPException(404, "Printer not found")
  3210. client = printer_manager.get_client(printer_id)
  3211. if not client:
  3212. raise HTTPException(400, "Printer not connected")
  3213. success = client.resume_print()
  3214. if not success:
  3215. raise HTTPException(500, "Failed to resume print")
  3216. return {"success": True, "message": "Print resume command sent"}
  3217. @router.post("/{printer_id}/print-speed")
  3218. async def set_print_speed(
  3219. printer_id: int,
  3220. mode: int = Query(..., ge=1, le=4, description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
  3221. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3222. db: AsyncSession = Depends(get_db, scope="function"),
  3223. ):
  3224. """Set the print speed mode."""
  3225. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3226. printer = result.scalar_one_or_none()
  3227. if not printer:
  3228. raise HTTPException(404, "Printer not found")
  3229. client = printer_manager.get_client(printer_id)
  3230. if not client:
  3231. raise HTTPException(400, "Printer not connected")
  3232. success = client.set_print_speed(mode)
  3233. if not success:
  3234. raise HTTPException(500, "Failed to set print speed")
  3235. speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
  3236. return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
  3237. @router.post("/{printer_id}/temperature/nozzle")
  3238. async def set_nozzle_temperature(
  3239. printer_id: int,
  3240. target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
  3241. nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
  3242. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3243. db: AsyncSession = Depends(get_db, scope="function"),
  3244. ):
  3245. """Set a nozzle target temperature."""
  3246. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3247. printer = result.scalar_one_or_none()
  3248. if not printer:
  3249. raise HTTPException(404, "Printer not found")
  3250. client = printer_manager.get_client(printer_id)
  3251. if not client:
  3252. raise HTTPException(400, "Printer not connected")
  3253. success = client.set_nozzle_temperature(target, nozzle)
  3254. if not success:
  3255. raise HTTPException(500, "Failed to set nozzle temperature")
  3256. return {"success": True, "message": f"Nozzle temperature set to {target}°C"}
  3257. @router.post("/{printer_id}/temperature/bed")
  3258. async def set_bed_temperature(
  3259. printer_id: int,
  3260. target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
  3261. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3262. db: AsyncSession = Depends(get_db, scope="function"),
  3263. ):
  3264. """Set the bed target temperature."""
  3265. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3266. printer = result.scalar_one_or_none()
  3267. if not printer:
  3268. raise HTTPException(404, "Printer not found")
  3269. client = printer_manager.get_client(printer_id)
  3270. if not client:
  3271. raise HTTPException(400, "Printer not connected")
  3272. success = client.set_bed_temperature(target)
  3273. if not success:
  3274. raise HTTPException(500, "Failed to set bed temperature")
  3275. return {"success": True, "message": f"Bed temperature set to {target}°C"}
  3276. @router.post("/{printer_id}/temperature/chamber")
  3277. async def set_chamber_temperature(
  3278. printer_id: int,
  3279. target: int = Query(
  3280. ...,
  3281. ge=0,
  3282. le=MAX_CHAMBER_TEMP_C,
  3283. description="Target chamber temperature in Celsius; 0 turns heating off",
  3284. ),
  3285. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3286. db: AsyncSession = Depends(get_db, scope="function"),
  3287. ):
  3288. """Set the chamber target temperature.
  3289. Gated on `supports_chamber_heater(model)`: only H2C, H2D, H2D Pro, H2S,
  3290. X1E and X2D have an active chamber heater. Sensor-only models (X1C, P2S)
  3291. report chamber temp but silently swallow M141, so we 400 here rather
  3292. than send a no-op.
  3293. """
  3294. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3295. printer = result.scalar_one_or_none()
  3296. if not printer:
  3297. raise HTTPException(404, "Printer not found")
  3298. if not supports_chamber_heater(printer.model):
  3299. raise HTTPException(400, f"Model {printer.model or 'unknown'} does not have an active chamber heater")
  3300. client = printer_manager.get_client(printer_id)
  3301. if not client:
  3302. raise HTTPException(400, "Printer not connected")
  3303. success = client.set_chamber_temperature(target)
  3304. if not success:
  3305. raise HTTPException(500, "Failed to set chamber temperature")
  3306. return {"success": True, "message": f"Chamber temperature set to {target}°C"}
  3307. @router.post("/{printer_id}/fan-speed")
  3308. async def set_fan_speed(
  3309. printer_id: int,
  3310. fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
  3311. speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
  3312. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3313. db: AsyncSession = Depends(get_db, scope="function"),
  3314. ):
  3315. """Set a fan speed by percentage.
  3316. Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
  3317. P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
  3318. profile gcode does. It only exists when the printer reports airduct part 10,
  3319. so the request is rejected rather than sending M106 P10 into the void on a
  3320. machine that has no such fan.
  3321. That gate also rejects for the short window between connecting and the
  3322. first airduct push, when nothing is known about the fan yet. The card hides
  3323. the badge over the same window, so there is no control to click; a direct
  3324. API caller gets a 400 and should retry once the status reports the fan.
  3325. """
  3326. fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
  3327. fan_id = fan_ids.get(fan)
  3328. if fan_id is None:
  3329. raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'")
  3330. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3331. printer = result.scalar_one_or_none()
  3332. if not printer:
  3333. raise HTTPException(404, "Printer not found")
  3334. client = printer_manager.get_client(printer_id)
  3335. if not client:
  3336. raise HTTPException(400, "Printer not connected")
  3337. # Presence gate for the accessory fan. Without this, aux2 is accepted for
  3338. # every model and an A1 would be sent M106 P10 for a fan it does not have.
  3339. # The UI already hides the badge; this closes the same hole on the API.
  3340. if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
  3341. raise HTTPException(
  3342. 400,
  3343. "This printer does not report a left auxiliary fan "
  3344. "(no airduct part 10). The fan is an accessory kit on the P2S "
  3345. "and factory-fitted on the X2D.",
  3346. )
  3347. pwm_speed = round(speed * 255 / 100)
  3348. success = client.set_fan_speed(fan_id, pwm_speed)
  3349. if not success:
  3350. raise HTTPException(500, "Failed to set fan speed")
  3351. # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
  3352. # match whatever the printer card badge shows so the toast agrees with the
  3353. # control the user just clicked.
  3354. fan_names = {
  3355. "part": "Part cooling fan",
  3356. "aux": "Auxiliary fan",
  3357. "aux2": "Left auxiliary fan",
  3358. "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
  3359. }
  3360. return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
  3361. @router.post("/{printer_id}/select-extruder")
  3362. async def select_extruder(
  3363. printer_id: int,
  3364. extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
  3365. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3366. db: AsyncSession = Depends(get_db, scope="function"),
  3367. ):
  3368. """Select the active extruder/nozzle on dual-nozzle printers."""
  3369. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3370. printer = result.scalar_one_or_none()
  3371. if not printer:
  3372. raise HTTPException(404, "Printer not found")
  3373. client = printer_manager.get_client(printer_id)
  3374. if not client:
  3375. raise HTTPException(400, "Printer not connected")
  3376. success = client.select_extruder(extruder)
  3377. if not success:
  3378. raise HTTPException(500, "Failed to select nozzle")
  3379. return {"success": True, "message": f"{'Left' if extruder == 1 else 'Right'} nozzle selected"}
  3380. @router.post("/{printer_id}/airduct-mode")
  3381. async def set_airduct_mode(
  3382. printer_id: int,
  3383. mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
  3384. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3385. db: AsyncSession = Depends(get_db, scope="function"),
  3386. ):
  3387. """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
  3388. if mode not in ("cooling", "heating"):
  3389. raise HTTPException(400, "Mode must be 'cooling' or 'heating'")
  3390. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3391. printer = result.scalar_one_or_none()
  3392. if not printer:
  3393. raise HTTPException(404, "Printer not found")
  3394. client = printer_manager.get_client(printer_id)
  3395. if not client:
  3396. raise HTTPException(400, "Printer not connected")
  3397. success = client.set_airduct_mode(mode)
  3398. if not success:
  3399. raise HTTPException(500, "Failed to set airduct mode")
  3400. return {"success": True, "message": f"Airduct mode set to {mode}"}
  3401. @router.post("/{printer_id}/chamber-light")
  3402. async def set_chamber_light(
  3403. printer_id: int,
  3404. on: bool = Query(..., description="True to turn on, False to turn off"),
  3405. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3406. db: AsyncSession = Depends(get_db, scope="function"),
  3407. ):
  3408. """Turn the chamber light on or off."""
  3409. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3410. printer = result.scalar_one_or_none()
  3411. if not printer:
  3412. raise HTTPException(404, "Printer not found")
  3413. client = printer_manager.get_client(printer_id)
  3414. if not client:
  3415. raise HTTPException(400, "Printer not connected")
  3416. success = client.set_chamber_light(on)
  3417. if not success:
  3418. raise HTTPException(500, "Failed to control chamber light")
  3419. # The light is the user's now; the camera must not switch it off (#1655).
  3420. camera_light_service.hand_back(printer_id)
  3421. return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
  3422. @router.post("/{printer_id}/bed-jog")
  3423. async def bed_jog(
  3424. printer_id: int,
  3425. distance: float = Query(
  3426. ...,
  3427. description=(
  3428. "Signed nozzle-bed gap adjustment in mm, identical on every model: "
  3429. "positive opens the gap (more clearance), negative closes it. Sent "
  3430. "to the printer as the G-code Z value unchanged — G-code Z is the "
  3431. "nozzle-to-bed distance whether the bed moves (X1 / P1 / H2) or the "
  3432. "toolhead does (A1 / A2L), so no per-model sign translation exists "
  3433. "or is needed."
  3434. ),
  3435. ),
  3436. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3437. db: AsyncSession = Depends(get_db, scope="function"),
  3438. ):
  3439. """Adjust the nozzle-bed gap by a relative distance.
  3440. Emits a short G-code sequence via MQTT.
  3441. Soft-endstop policy (#2579). **Nothing clamps this move.** Bambu's firmware
  3442. does not enforce its soft endstops on G-code arriving over MQTT — measured
  3443. by logging the exact bytes to an H2D sitting at its Z limit: a clean
  3444. ``G91 / G1 Z-1.00 F600 / G90`` with no ``M211`` ran straight past, while the
  3445. printer's own touchscreen refuses the identical move, because the
  3446. touchscreen goes through the motion planner and ``gcode_line`` does not.
  3447. Push-status carries no axis position either, so there is nothing to clamp
  3448. against on this side. Treat every jog as unguarded; the jog popover says so
  3449. to the user, and a dead-reckoning clamp (track Z from a home, refuse
  3450. out-of-range moves) is the only real fix and is not built.
  3451. What Bambuddy stopped doing is making it worse. The old code wrapped every
  3452. move in ``M211 S0`` / ``M211 S1`` and the UI sent ``force`` on every jog, so
  3453. the limits came off on every bed move — and ``M211 S0`` disables them
  3454. *globally*, which broke the touchscreen's protection too until the printer
  3455. was power-cycled. That is the one genuine Bambuddy bug in #2579. This
  3456. endpoint now emits a bare relative move and never touches ``M211`` at all,
  3457. which leaves the touchscreen protected. It does not send ``M211 S1``
  3458. either: that was an unverified attempt to re-enable a printer an older
  3459. build had disabled, and on real hardware the jog moved past the limit
  3460. *with* it. A printer left in that state is recovered with one power cycle.
  3461. Direction (#1334, and the API half of it reported by @AQU4R1U5). ``Z``
  3462. is the nozzle-to-bed gap on every Bambu model, by definition of the
  3463. coordinate system rather than by convention: ``G1 Z+`` opens the gap
  3464. whether the bed drops away (X1 / P1 / H2, where Bambu's end G-code
  3465. parks with ``G1 Z{max_layer_z + 100}``) or the toolhead rises
  3466. (A1 / A1 Mini / A2L). The finish-photo plate restore relies on exactly
  3467. that and needs no model branch — see ``_restore_plate_for_finish_photo``.
  3468. So ``distance`` goes onto the wire unchanged, and one API call means one
  3469. physical thing on every printer: positive is always the safe direction.
  3470. This endpoint used to invert the sign on A1 models, which made a
  3471. documented model-independent parameter mean the opposite thing there —
  3472. ``distance=5``, asking for clearance, drove the toolhead at the plate.
  3473. What #1334 actually reported is a *label* problem, and it belongs to the
  3474. UI: the arrow says "move the plate up", and on a bed-slinger the plate
  3475. does not move in Z at all, so closing the gap shows up as the toolhead
  3476. diving. Which way an arrow points is a question about the machine in
  3477. front of the user, not about the G-code, so the printer card decides it
  3478. (``isBedSlinger`` in ``frontend/src/utils/bedSlinger.ts``) and sends the
  3479. gap it wants. Nothing here needs to know the model.
  3480. """
  3481. if distance == 0 or abs(distance) > 200:
  3482. raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
  3483. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3484. printer = result.scalar_one_or_none()
  3485. if not printer:
  3486. raise HTTPException(404, "Printer not found")
  3487. client = printer_manager.get_client(printer_id)
  3488. if not client:
  3489. raise HTTPException(400, "Printer not connected")
  3490. # Bare relative move, never M211 (#2579). Not because a bare move is safe —
  3491. # the firmware ignores soft endstops on MQTT G-code either way — but because
  3492. # M211 S0 disabled them globally, taking the touchscreen's limits with it.
  3493. lines = ["G91", f"G1 Z{distance:.2f} F600", "G90"]
  3494. if not client.send_gcode("\n".join(lines)):
  3495. raise HTTPException(500, "Failed to send bed-jog command")
  3496. return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
  3497. @router.post("/{printer_id}/xy-jog")
  3498. async def xy_jog(
  3499. printer_id: int,
  3500. x: float = Query(0, description="Signed relative X movement in mm"),
  3501. y: float = Query(0, description="Signed relative Y movement in mm"),
  3502. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3503. db: AsyncSession = Depends(get_db, scope="function"),
  3504. ):
  3505. """Move the toolhead by a relative X/Y distance."""
  3506. if (x == 0 and y == 0) or abs(x) > 200 or abs(y) > 200:
  3507. raise HTTPException(400, "X/Y movement must be non-zero and ≤ 200 mm per axis")
  3508. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3509. printer = result.scalar_one_or_none()
  3510. if not printer:
  3511. raise HTTPException(404, "Printer not found")
  3512. client = printer_manager.get_client(printer_id)
  3513. if not client:
  3514. raise HTTPException(400, "Printer not connected")
  3515. axes = []
  3516. if x:
  3517. axes.append(f"X{x:.2f}")
  3518. if y:
  3519. axes.append(f"Y{y:.2f}")
  3520. # Bare relative move, never M211 (#2579) — see the bed-jog docstring. The
  3521. # firmware does not enforce soft endstops on MQTT G-code, so this move is
  3522. # unguarded; M211 S0 only widened that to the touchscreen as well.
  3523. if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
  3524. raise HTTPException(500, "Failed to send XY jog command")
  3525. return {"success": True, "message": f"XY jog X{x:+.1f} Y{y:+.1f} mm sent"}
  3526. @router.post("/{printer_id}/extruder-jog")
  3527. async def extruder_jog(
  3528. printer_id: int,
  3529. distance: float = Query(
  3530. ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
  3531. ),
  3532. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3533. db: AsyncSession = Depends(get_db, scope="function"),
  3534. ):
  3535. """Extrude or retract filament by a relative distance.
  3536. No client-side cold-extrude guard: Bambu firmware refuses extrusion
  3537. below its min-extrude temperature, so a cold call is rejected at the
  3538. printer, not silently damaging the extruder gear.
  3539. """
  3540. if distance == 0 or abs(distance) > 100:
  3541. raise HTTPException(400, "Extruder movement must be non-zero and ≤ 100 mm")
  3542. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3543. printer = result.scalar_one_or_none()
  3544. if not printer:
  3545. raise HTTPException(404, "Printer not found")
  3546. client = printer_manager.get_client(printer_id)
  3547. if not client:
  3548. raise HTTPException(400, "Printer not connected")
  3549. if not client.send_gcode("\n".join(["M83", f"G1 E{distance:.2f} F300", "M82"])):
  3550. raise HTTPException(500, "Failed to send extruder jog command")
  3551. return {"success": True, "message": f"Extruder jog {distance:+.1f} mm sent"}
  3552. @router.post("/{printer_id}/home-axes")
  3553. async def home_axes(
  3554. printer_id: int,
  3555. axes: str = Query(
  3556. "all",
  3557. description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.",
  3558. ),
  3559. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3560. db: AsyncSession = Depends(get_db, scope="function"),
  3561. ):
  3562. """Run the printer's full auto-home sequence via bare `G28`.
  3563. Bambu printers (H2C / H2D / H2S / X1 family) home the Z axis by moving
  3564. the BED UP toward an endstop at the top of travel. If the toolhead is
  3565. not already parked out of the way, a bare `G28 Z` will crash the bed
  3566. into the toolhead — #1052 reported exactly that on H2C: the bed rose
  3567. without stopping at a safe height because `G28 Z` skipped the
  3568. toolhead-park step that a full `G28` runs first.
  3569. The endpoint therefore ignores the `axes` argument and always sends a
  3570. bare `G28`, which the firmware expands into a safe multi-step sequence
  3571. (park toolhead → home XY → home Z). The argument is kept only for
  3572. backward-compat with existing clients; sending an invalid value still
  3573. returns 400 so typos surface instead of silently proceeding.
  3574. """
  3575. axes = axes.lower()
  3576. if axes not in ("z", "xy", "all"):
  3577. raise HTTPException(400, "axes must be 'z', 'xy', or 'all'")
  3578. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3579. printer = result.scalar_one_or_none()
  3580. if not printer:
  3581. raise HTTPException(404, "Printer not found")
  3582. client = printer_manager.get_client(printer_id)
  3583. if not client:
  3584. raise HTTPException(400, "Printer not connected")
  3585. if not client.send_gcode("G28"):
  3586. raise HTTPException(500, "Failed to send home command")
  3587. return {"success": True, "message": "Full auto-home sequence sent"}
  3588. @router.post("/{printer_id}/hms/clear")
  3589. async def clear_hms_errors(
  3590. printer_id: int,
  3591. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3592. db: AsyncSession = Depends(get_db, scope="function"),
  3593. ):
  3594. """Clear HMS/print errors on the printer."""
  3595. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3596. printer = result.scalar_one_or_none()
  3597. if not printer:
  3598. raise HTTPException(404, "Printer not found")
  3599. client = printer_manager.get_client(printer_id)
  3600. if not client:
  3601. raise HTTPException(400, "Printer not connected")
  3602. success = client.clear_hms_errors()
  3603. if not success:
  3604. raise HTTPException(500, "Failed to clear HMS errors")
  3605. return {"success": True, "message": "HMS errors cleared"}
  3606. @router.get("/{printer_id}/print/objects")
  3607. async def get_printable_objects(
  3608. printer_id: int,
  3609. reload: bool = False,
  3610. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
  3611. ):
  3612. """Get the list of printable objects for the current print.
  3613. Returns a list of objects with id, name, position (if available), and skip status.
  3614. Objects that have already been skipped are marked in the skipped_objects list.
  3615. Args:
  3616. reload: If True, reload objects from the archive file (useful after restart)
  3617. """
  3618. # Short sessions, released before the FTP download below (RC-C9): it can
  3619. # take a while from a printer that is mid-print.
  3620. async with database.async_session() as db:
  3621. printer = (await db.execute(select(Printer).where(Printer.id == printer_id))).scalar_one_or_none()
  3622. if not printer:
  3623. raise HTTPException(404, "Printer not found")
  3624. client = printer_manager.get_client(printer_id)
  3625. if not client:
  3626. raise HTTPException(400, "Printer not connected")
  3627. # Reload objects from 3MF if requested or no objects loaded
  3628. if reload or not client.state.printable_objects:
  3629. # The archive of a running print normally holds the very file the
  3630. # printer is executing, so ask the disk before asking the printer:
  3631. # the fan-out below pulls the whole 3MF over FTPS from a machine that
  3632. # is mid-print — 15 MB on the print this was written for — and on a
  3633. # printer that kept the file on internal storage it cannot succeed at
  3634. # all. skipped_objects is deliberately left alone: a reload is
  3635. # not a new print, and the list of what the user already skipped only
  3636. # lives here.
  3637. from backend.app.main import running_print_archive
  3638. from backend.app.services.archive import extract_printable_objects_from_archive
  3639. # Never by name: an earlier run of the same name left printing would
  3640. # offer its objects to skip on this one (#3327).
  3641. async with database.async_session() as db:
  3642. archive = await running_print_archive(db, printer_id, client.state, printing_only=True, by_name=False)
  3643. if archive is not None and archive.file_path:
  3644. objects, bbox_all = extract_printable_objects_from_archive(
  3645. settings.base_dir / archive.file_path,
  3646. plate_number=resolve_plate_id(client.state),
  3647. )
  3648. if objects:
  3649. client.state.printable_objects = objects
  3650. client.state.printable_objects_bbox_all = bbox_all
  3651. logger.info(
  3652. "Reloaded %s objects for printer %s from archive %s",
  3653. len(objects),
  3654. printer_id,
  3655. archive.id,
  3656. )
  3657. # Only when the disk could not answer: a `reload=true` that the archive
  3658. # satisfied has already refreshed from the file the printer is running.
  3659. if not client.state.printable_objects:
  3660. subtask_name = client.state.subtask_name
  3661. if subtask_name:
  3662. from backend.app.services.archive import extract_printable_objects_from_3mf
  3663. from backend.app.services.bambu_ftp import download_file_try_paths_async
  3664. # Build possible 3MF filenames (try both .gcode.3mf and .3mf)
  3665. possible_filenames = []
  3666. if subtask_name.endswith(".3mf"):
  3667. possible_filenames.append(subtask_name)
  3668. else:
  3669. possible_filenames.append(f"{subtask_name}.gcode.3mf")
  3670. possible_filenames.append(f"{subtask_name}.3mf")
  3671. # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
  3672. if " " in subtask_name:
  3673. normalized = subtask_name.replace(" ", "_")
  3674. if normalized.endswith(".3mf"):
  3675. possible_filenames.append(normalized)
  3676. else:
  3677. possible_filenames.append(f"{normalized}.gcode.3mf")
  3678. possible_filenames.append(f"{normalized}.3mf")
  3679. # Download 3MF from printer
  3680. temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}"
  3681. temp_path.parent.mkdir(parents=True, exist_ok=True)
  3682. # Build list of all remote paths to try
  3683. remote_paths = []
  3684. for filename in possible_filenames:
  3685. remote_paths.extend([f"/{filename}", f"/cache/{filename}", f"/model/{filename}"])
  3686. try:
  3687. downloaded = await download_file_try_paths_async(
  3688. printer.ip_address,
  3689. printer.access_code,
  3690. remote_paths,
  3691. temp_path,
  3692. printer_model=printer.model,
  3693. )
  3694. if downloaded and temp_path.exists():
  3695. with open(temp_path, "rb") as f:
  3696. data = f.read()
  3697. # Scope to the running plate: an all-plates 3MF lists every
  3698. # plate's objects, and offering plate 1's while the printer
  3699. # runs plate 2 makes every skip a misfire (#2522).
  3700. objects, bbox_all = extract_printable_objects_from_3mf(
  3701. data,
  3702. plate_number=resolve_plate_id(client.state),
  3703. include_positions=True,
  3704. )
  3705. if objects:
  3706. client.state.printable_objects = objects
  3707. client.state.printable_objects_bbox_all = bbox_all
  3708. logger.info("Reloaded %s objects for printer %s", len(objects), printer_id)
  3709. except Exception as e:
  3710. logger.debug("Failed to reload objects from printer: %s", e)
  3711. finally:
  3712. if temp_path.exists():
  3713. temp_path.unlink()
  3714. # Return objects with their skip status and position data
  3715. objects = []
  3716. for obj_id, obj_data in client.state.printable_objects.items():
  3717. # Handle both old format (string name) and new format (dict with name, x, y)
  3718. if isinstance(obj_data, dict):
  3719. obj_entry = {
  3720. "id": obj_id,
  3721. "name": obj_data.get("name", f"Object {obj_id}"),
  3722. "x": obj_data.get("x"),
  3723. "y": obj_data.get("y"),
  3724. "skipped": obj_id in client.state.skipped_objects,
  3725. }
  3726. else:
  3727. # Legacy format: obj_data is just the name string
  3728. obj_entry = {
  3729. "id": obj_id,
  3730. "name": obj_data,
  3731. "x": None,
  3732. "y": None,
  3733. "skipped": obj_id in client.state.skipped_objects,
  3734. }
  3735. objects.append(obj_entry)
  3736. return {
  3737. "objects": objects,
  3738. "total": len(objects),
  3739. "skipped_count": len(client.state.skipped_objects),
  3740. "is_printing": client.state.state in ("RUNNING", "PAUSE"),
  3741. "bbox_all": getattr(client.state, "printable_objects_bbox_all", None),
  3742. }
  3743. @router.post("/{printer_id}/print/skip-objects")
  3744. async def skip_objects(
  3745. printer_id: int,
  3746. object_ids: list[int],
  3747. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  3748. db: AsyncSession = Depends(get_db, scope="function"),
  3749. ):
  3750. """Skip specific objects during the current print.
  3751. Args:
  3752. object_ids: List of object identify_id values to skip
  3753. """
  3754. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3755. printer = result.scalar_one_or_none()
  3756. if not printer:
  3757. raise HTTPException(404, "Printer not found")
  3758. client = printer_manager.get_client(printer_id)
  3759. if not client:
  3760. raise HTTPException(400, "Printer not connected")
  3761. if not object_ids:
  3762. raise HTTPException(400, "No object IDs provided")
  3763. # Validate object IDs exist in printable_objects
  3764. invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]
  3765. if invalid_ids:
  3766. raise HTTPException(400, f"Invalid object IDs: {invalid_ids}")
  3767. success = client.skip_objects(object_ids)
  3768. if not success:
  3769. raise HTTPException(500, "Failed to skip objects")
  3770. # Get names of skipped objects for response (handle both old and new format)
  3771. skipped_names = []
  3772. for oid in object_ids:
  3773. obj_data = client.state.printable_objects.get(oid, str(oid))
  3774. if isinstance(obj_data, dict):
  3775. skipped_names.append(obj_data.get("name", str(oid)))
  3776. else:
  3777. skipped_names.append(obj_data)
  3778. return {
  3779. "success": True,
  3780. "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}",
  3781. "skipped_objects": object_ids,
  3782. }
  3783. # =============================================================================
  3784. # AMS Control Endpoints
  3785. # =============================================================================
  3786. @router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh")
  3787. async def refresh_ams_slot(
  3788. printer_id: int,
  3789. ams_id: int,
  3790. slot_id: int,
  3791. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
  3792. db: AsyncSession = Depends(get_db, scope="function"),
  3793. ):
  3794. """Re-read RFID for an AMS slot (triggers filament info refresh)."""
  3795. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  3796. printer = result.scalar_one_or_none()
  3797. if not printer:
  3798. raise HTTPException(404, "Printer not found")
  3799. client = printer_manager.get_client(printer_id)
  3800. if not client:
  3801. raise HTTPException(400, "Printer not connected")
  3802. success, message = await client.ams_refresh_tray(ams_id, slot_id)
  3803. if not success:
  3804. raise HTTPException(400, message)
  3805. # Apply PA profile after delay (RFID re-read takes a few seconds)
  3806. spawn_background_task(
  3807. _apply_pa_after_refresh(printer_id, ams_id, slot_id),
  3808. name=f"apply-pa-after-refresh-{printer_id}-{ams_id}-{slot_id}",
  3809. )
  3810. return {"success": True, "message": message}
  3811. async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
  3812. """Apply PA profile after RFID re-read completes.
  3813. Waits for the printer to finish processing the RFID data, then selects
  3814. the K-profile via extrusion_cali_sel. Does NOT re-send ams_set_filament_setting
  3815. because that would overwrite the RFID-provided filament data.
  3816. """
  3817. await asyncio.sleep(5)
  3818. try:
  3819. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  3820. from backend.app.core.database import async_session
  3821. from backend.app.models.spool import Spool
  3822. from backend.app.models.spool_assignment import SpoolAssignment as SA
  3823. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  3824. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  3825. from backend.app.services.spool_tag_matcher import (
  3826. ZERO_TAG_UID,
  3827. ZERO_TRAY_UUID,
  3828. is_bambu_tag,
  3829. )
  3830. from backend.app.utils.tag_normalization import (
  3831. normalize_tag_uid,
  3832. normalize_tray_uuid,
  3833. )
  3834. client = printer_manager.get_client(printer_id)
  3835. if not client:
  3836. return
  3837. state = printer_manager.get_status(printer_id)
  3838. if not state or not state.raw_data:
  3839. return
  3840. # Find current tray data (should have RFID data by now)
  3841. ams_data = state.raw_data.get("ams", {})
  3842. ams_list = (
  3843. ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
  3844. )
  3845. tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)
  3846. if not tray or not tray.get("tray_type"):
  3847. logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id)
  3848. return
  3849. tag_uid = tray.get("tag_uid", "")
  3850. tray_uuid = tray.get("tray_uuid", "")
  3851. tray_info_idx = tray.get("tray_info_idx", "")
  3852. if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
  3853. return
  3854. # Compute nozzle/extruder once — used by both local and Spoolman lookup.
  3855. # Shared with every other slot-configuring path (services.slot_nozzle),
  3856. # so the diameter this cascade filters on is the one the slot's own
  3857. # hotend actually has.
  3858. slot_nozzle = resolve_slot_nozzle(state, ams_id, slot_id, printer_manager.get_model(printer_id))
  3859. nozzle_diameter = slot_nozzle.diameter
  3860. resolved_extruder = slot_nozzle.extruder
  3861. # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
  3862. # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
  3863. # and exited silently if no SpoolKProfile match; Spoolman-assigned slots
  3864. # were ignored entirely and live cali_idx was never re-asserted.
  3865. matching_cali_idx: int | None = None
  3866. matching_filament_id: str = tray_info_idx
  3867. async with async_session() as db:
  3868. from sqlalchemy import or_, select as sa_select
  3869. from sqlalchemy.orm import selectinload
  3870. # Stage 1: local SpoolAssignment + SpoolKProfile match
  3871. result = await db.execute(
  3872. sa_select(SA)
  3873. .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
  3874. .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
  3875. )
  3876. assignment = result.scalar_one_or_none()
  3877. spool: Spool | None = assignment.spool if assignment else None
  3878. # Stage 1b: tag-based fallback. The slot may have just been reset
  3879. # (SpoolAssignment row deleted) before the user triggered a re-read.
  3880. # The live tray already carries the spool's tray_uuid/tag_uid from
  3881. # the RFID re-read, but the SA row hasn't been re-created yet.
  3882. # Without this fallback we miss the stored SpoolKProfile and Stage 3
  3883. # ends up re-asserting whatever cali_idx the firmware reset to
  3884. # (typically the default profile).
  3885. if spool is None:
  3886. norm_uuid = normalize_tray_uuid(tray_uuid) if tray_uuid else ""
  3887. norm_tag = normalize_tag_uid(tag_uid) if tag_uid else ""
  3888. tag_filters = []
  3889. if norm_uuid and norm_uuid != ZERO_TRAY_UUID:
  3890. tag_filters.append(Spool.tray_uuid == norm_uuid)
  3891. if norm_tag and norm_tag != ZERO_TAG_UID:
  3892. tag_filters.append(Spool.tag_uid == norm_tag)
  3893. if tag_filters:
  3894. tag_lookup = await db.execute(
  3895. select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
  3896. )
  3897. spool = tag_lookup.scalar_one_or_none()
  3898. if spool is not None:
  3899. logger.info(
  3900. "PA re-apply AMS%d-T%d: matched spool %d via tag fallback "
  3901. "(SpoolAssignment row missing, likely after slot reset)",
  3902. ams_id,
  3903. slot_id,
  3904. spool.id,
  3905. )
  3906. if spool is not None and spool.k_profiles:
  3907. # Prefer exact extruder match, fall back to extruder-agnostic kp
  3908. # for the same printer + nozzle. Hard-skipping on extruder
  3909. # mismatch made the cascade refuse perfectly valid stored
  3910. # profiles whenever the AMS-extruder mapping had shifted since
  3911. # calibration time, falling all the way through to Stage 3 and
  3912. # re-asserting the firmware default.
  3913. exact_kp = None
  3914. fallback_kp = None
  3915. for kp in spool.k_profiles:
  3916. if not slot_nozzle.flow_matches(kp.nozzle_type):
  3917. continue
  3918. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
  3919. continue
  3920. if resolved_extruder is not None and kp.extruder is not None and kp.extruder == resolved_extruder:
  3921. exact_kp = kp
  3922. break
  3923. if fallback_kp is None:
  3924. fallback_kp = kp
  3925. chosen_kp = exact_kp or fallback_kp
  3926. if chosen_kp is not None:
  3927. matching_cali_idx = chosen_kp.cali_idx
  3928. # The filament_id in extrusion_cali_sel must match the preset
  3929. # under which the K-profile was calibrated. Prefer the spool's
  3930. # slicer_filament setting, falling back to the tray's RFID value.
  3931. matching_filament_id = spool.slicer_filament or tray_info_idx
  3932. # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match
  3933. # (only when no local spool was matched — local takes priority,
  3934. # including the tag-based fallback above)
  3935. if matching_cali_idx is None and spool is None:
  3936. sm_result = await db.execute(
  3937. select(SpoolmanSlotAssignment).where(
  3938. SpoolmanSlotAssignment.printer_id == printer_id,
  3939. SpoolmanSlotAssignment.ams_id == ams_id,
  3940. SpoolmanSlotAssignment.tray_id == slot_id,
  3941. )
  3942. )
  3943. sm_assignment = sm_result.scalar_one_or_none()
  3944. if sm_assignment:
  3945. kp_result = await db.execute(
  3946. sa_select(SpoolmanKProfile).where(
  3947. SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
  3948. SpoolmanKProfile.printer_id == printer_id,
  3949. )
  3950. )
  3951. for kp in kp_result.scalars().all():
  3952. if kp.nozzle_diameter == nozzle_diameter:
  3953. if (
  3954. resolved_extruder is not None
  3955. and kp.extruder is not None
  3956. and kp.extruder != resolved_extruder
  3957. ):
  3958. continue
  3959. if kp.cali_idx is not None:
  3960. matching_cali_idx = kp.cali_idx
  3961. # Spoolman has no slicer_filament — use the tray's RFID value
  3962. matching_filament_id = tray_info_idx
  3963. break
  3964. # Stage 3: live tray.cali_idx fallback. Re-asserts the printer's current
  3965. # selection so the value sticks across the RFID re-read (otherwise some
  3966. # firmwares clear cali_idx back to -1 mid-cycle).
  3967. if matching_cali_idx is None:
  3968. live_cali_idx = tray.get("cali_idx")
  3969. if live_cali_idx is not None and live_cali_idx >= 0:
  3970. matching_cali_idx = live_cali_idx
  3971. if matching_cali_idx is None:
  3972. logger.debug(
  3973. "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT",
  3974. ams_id,
  3975. slot_id,
  3976. )
  3977. return
  3978. logger.info(
  3979. "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
  3980. ams_id,
  3981. slot_id,
  3982. matching_cali_idx,
  3983. matching_filament_id,
  3984. )
  3985. # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
  3986. # "this is a manual config" which destroys the RFID-detected spool state
  3987. # (changes eye icon to pen icon in slicer).
  3988. client.extrusion_cali_sel(
  3989. ams_id=ams_id,
  3990. tray_id=slot_id,
  3991. cali_idx=matching_cali_idx,
  3992. filament_id=matching_filament_id,
  3993. nozzle_diameter=nozzle_diameter,
  3994. )
  3995. # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
  3996. # selected the correct profile by cali_idx. Sending extrusion_cali_set with
  3997. # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
  3998. # nozzle_id, name), corrupting it.
  3999. logger.info(
  4000. "Applied PA profile cali_idx=%d to printer %d AMS%d-T%d",
  4001. matching_cali_idx,
  4002. printer_id,
  4003. ams_id,
  4004. slot_id,
  4005. )
  4006. except Exception as e:
  4007. logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
  4008. # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
  4009. # a2l-am-unit-16. An AMS-HT holds one tray, numbered by its unit id 128-135, as
  4010. # the printer's tray_now reports it. All are valid alongside the regular 0-15.
  4011. _LOAD_TRAY_ID_ERROR = (
  4012. "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 128..135 (AMS-HT), 254 (external / Ext-L), or 255 (Ext-R)"
  4013. )
  4014. def _is_valid_load_tray_id(tray_id: int) -> bool:
  4015. """Whether ``tray_id`` names a slot the load/unload commands can address."""
  4016. return tray_id in range(16) or tray_id in range(24, 28) or tray_id in range(128, 136) or tray_id in (254, 255)
  4017. @router.post("/{printer_id}/ams/load")
  4018. async def ams_load(
  4019. printer_id: int,
  4020. tray_id: int = Query(
  4021. ...,
  4022. description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 128-135 for an AMS-HT (its unit id), "
  4023. "254 for external spool",
  4024. ),
  4025. extruder_id: int | None = Query(
  4026. None,
  4027. ge=0,
  4028. le=1,
  4029. description=(
  4030. "Hotend to feed: 0 = right/main, 1 = left/deputy. Only meaningful "
  4031. "on a printer with a Filament Track Switch fitted, where the AMS is "
  4032. "bound to a switch inlet rather than a hotend and the firmware "
  4033. "cannot work the target out for itself. Omit on every other printer "
  4034. "— the field is absent from BambuStudio's own command there too."
  4035. ),
  4036. ),
  4037. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  4038. db: AsyncSession = Depends(get_db, scope="function"),
  4039. ):
  4040. """Load filament from a specific AMS slot or external spool.
  4041. Tray ID encoding (matches Bambu firmware convention):
  4042. - 0..15: AMS slot, computed as ams_id * 4 + slot_id
  4043. - 128..135: AMS-HT, the unit id (one tray per unit)
  4044. - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
  4045. - 255: Ext-R on dual-nozzle H2D
  4046. """
  4047. if not _is_valid_load_tray_id(tray_id):
  4048. raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
  4049. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  4050. printer = result.scalar_one_or_none()
  4051. if not printer:
  4052. raise HTTPException(404, "Printer not found")
  4053. client = printer_manager.get_client(printer_id)
  4054. if not client:
  4055. raise HTTPException(400, "Printer not connected")
  4056. success = client.ams_load_filament(tray_id, extruder_id=extruder_id)
  4057. if not success:
  4058. raise HTTPException(500, "Failed to send load command")
  4059. if tray_id == 254:
  4060. target = "external spool"
  4061. elif tray_id == 255:
  4062. target = "Ext-R"
  4063. elif tray_id >= 128:
  4064. target = f"AMS HT-{chr(ord('A') + tray_id - 128)}"
  4065. else:
  4066. target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
  4067. return {"success": True, "message": f"Loading filament from {target}"}
  4068. @router.post("/{printer_id}/ams/unload")
  4069. async def ams_unload(
  4070. printer_id: int,
  4071. tray_id: int | None = Query(
  4072. None,
  4073. description=(
  4074. "Tray ID of the slot to unload, same encoding as the load endpoint. "
  4075. "Identifies which hotend to unload on a dual-nozzle printer, where "
  4076. "both can hold filament at once and the printer's single tray_now "
  4077. "field names only one of them. Omit to unload whatever tray_now "
  4078. "names, which is the only option a single-nozzle printer has."
  4079. ),
  4080. ),
  4081. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  4082. db: AsyncSession = Depends(get_db, scope="function"),
  4083. ):
  4084. """Unload the filament in a given slot, or the currently loaded one."""
  4085. if tray_id is not None and not _is_valid_load_tray_id(tray_id):
  4086. raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
  4087. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  4088. printer = result.scalar_one_or_none()
  4089. if not printer:
  4090. raise HTTPException(404, "Printer not found")
  4091. client = printer_manager.get_client(printer_id)
  4092. if not client:
  4093. raise HTTPException(400, "Printer not connected")
  4094. success = client.ams_unload_filament(tray_id)
  4095. if not success:
  4096. # A named slot that no hotend is fed from is a no-op, not a fault: the
  4097. # menu is per-slot and the operator may well have clicked one that is
  4098. # not loaded. Say so instead of returning a 500 they cannot act on.
  4099. if tray_id is not None:
  4100. raise HTTPException(409, "No hotend is loaded from that slot")
  4101. raise HTTPException(500, "Failed to send unload command")
  4102. return {"success": True, "message": "Unloading filament"}
  4103. @router.post("/{printer_id}/hms/execute-action")
  4104. async def execute_hms_action(
  4105. printer_id: int,
  4106. body: HmsActionBody,
  4107. _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
  4108. db: AsyncSession = Depends(get_db, scope="function"),
  4109. ):
  4110. """Execute an HMS action on the printer."""
  4111. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  4112. printer = result.scalar_one_or_none()
  4113. if not printer:
  4114. raise HTTPException(404, "Printer not found")
  4115. client = printer_manager.get_client(printer_id)
  4116. if not client:
  4117. raise HTTPException(400, "Printer not connected")
  4118. # Snapshot pre-state so we can verify the printer actually acted on the
  4119. # command. publish() success is NOT the same as printer-ack: Bambu's
  4120. # firmware silently rejects malformed HMS commands at QoS 1 (the broker
  4121. # ACKs the publish, but the printer drops it). Verified end-to-end against
  4122. # a live H2D — see #1830 §(3).
  4123. #
  4124. # We probe `_last_message_time` (bumped on every MQTT push) rather than a
  4125. # (gcode_state, hms_errors-length) diff. The old diff missed the
  4126. # wrong-plate IGNORE_RESUME case where the printer briefly resumes and
  4127. # re-pauses with the same fault inside the 2.5s window: both fields
  4128. # round-trip to their pre-publish values → false 502 even though the
  4129. # firmware fully ack'd the resume. Every accepted command triggers a
  4130. # pushall response within ~100-500ms, so a fresh inbound message after
  4131. # the publish is the robust ack signal.
  4132. pre_last_message = client._last_message_time
  4133. success = client.execute_hms_action(body.print_error, body.action, body.job_id)
  4134. if not success:
  4135. raise HTTPException(400, "Failed to execute HMS action")
  4136. if body.action == HMSAction.STOP_PRINTING:
  4137. # A user stop like the Stop button: mark it before the wait below, so a
  4138. # completion arriving meanwhile ends the print as cancelled, not failed.
  4139. from backend.app.services.print_control import mark_stopped_by_user
  4140. mark_stopped_by_user(printer_id)
  4141. # Give the printer time to push a state update. The dispatch helper already
  4142. # publishes a pushall after every command, so a fresh status should arrive
  4143. # within ~1s; the default 2.5s covers slower firmware variants without
  4144. # making the UI feel hung. Plain sleep is fine — paho's MQTT callback
  4145. # runs in its own thread and updates state regardless of whether this
  4146. # coroutine is awaiting.
  4147. await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
  4148. acked = client._last_message_time > pre_last_message
  4149. if not acked:
  4150. # Publish succeeded but the printer sent nothing back. Almost always
  4151. # firmware-side silent rejection (err mismatch, command/state mismatch)
  4152. # or a dropped MQTT route. 502 makes it visible at the UI instead of
  4153. # the 200-but-broken loop #1830 reported.
  4154. raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
  4155. return {"success": True, "message": "HMS action executed"}