| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659 |
- import asyncio
- import logging
- import re
- import secrets
- import zipfile
- from pathlib import Path
- from fastapi import APIRouter, Depends, HTTPException, Query
- from fastapi.responses import FileResponse, Response
- from sqlalchemy import func, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from starlette.background import BackgroundTask
- from backend.app.core import database
- from backend.app.core.auth import (
- RequireCameraStreamTokenIfAuthEnabled,
- RequireOverlayTokenIfAuthEnabled,
- RequirePermissionIfAuthEnabled,
- RequirePrinterPermissionIfAuthEnabled,
- is_auth_enabled,
- )
- from backend.app.core.config import settings
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.core.tasks import spawn_background_task
- from backend.app.models.ams_label import AmsLabel
- from backend.app.models.printer import Printer
- from backend.app.models.slot_preset import SlotPresetMapping
- from backend.app.models.user import User
- from backend.app.schemas.printer import (
- AmsLabelBody,
- AMSTray,
- AMSUnit,
- DiagnosticRequest,
- ExtruderSlotResponse,
- FilaSwitchResponse,
- HmsActionBody,
- HMSErrorResponse,
- NozzleInfoResponse,
- NozzleRackSlot,
- PrinterCreate,
- PrinterDiagnosticResult,
- PrinterFilesDownloadRequest,
- PrinterFilesJobRequest,
- PrinterResponse,
- PrinterResponseWithSecret,
- PrinterStatus,
- PrinterUpdate,
- PrintOptionsResponse,
- )
- from backend.app.services import drying_preflight
- from backend.app.services.bambu_ftp import (
- cache_3mf_download,
- delete_file_async,
- download_file_bytes_async,
- download_file_try_paths_async,
- ftps_handshake_blocked,
- get_cached_3mf,
- get_storage_info_async,
- list_files_result_async,
- )
- from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp
- from backend.app.services.printer_diagnostic import run_connection_diagnostic
- from backend.app.services.printer_manager import (
- display_temperatures,
- drying_screen_only,
- get_derived_status_name,
- printer_manager,
- resolve_expected_tray,
- resolve_plate_id,
- supports_chamber_heater,
- supports_chamber_temp,
- supports_drying,
- supports_drying_while_printing,
- uniform_tray_filament_hint,
- )
- from backend.app.services.printer_media import (
- MAX_PRINTER_ZIP_PREPARE_SECONDS,
- PrinterFilesZipInsufficientSpaceError,
- PrinterFilesZipTooLargeError,
- build_printer_file,
- build_printer_files_zip,
- cancel_printer_files_job,
- get_printer_files_job,
- printer_file_path,
- printer_files_zip_path,
- remove_printer_files_zip,
- start_printer_files_job,
- )
- from backend.app.services.slot_nozzle import resolve_slot_nozzle
- from backend.app.utils.filament_ids import filament_id_to_setting_id
- from backend.app.utils.filament_types import printer_filament_type
- from backend.app.utils.fts_routing import slot_extruder
- from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
- from backend.app.utils.kprofile_lookup import build_slot_k_resolver
- from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/printers", tags=["printers"])
- # Seconds the /hms/execute-action route waits for a printer status push
- # confirming the command landed before reporting 502 to the UI. Module-level
- # so tests can monkeypatch a near-zero value instead of mocking asyncio.sleep.
- HMS_ACTION_ACK_WAIT_SECONDS = 2.5
- async def _caller_can_view_printer_secrets(user: User | None, db: AsyncSession) -> bool:
- """Whether the caller is trusted enough to see ``access_code`` on a printer
- response. Fail-CLOSED: anything that isn't an authenticated user holding
- PRINTERS_UPDATE returns False.
- - Auth disabled → True (single trust domain — same as today's local UI).
- - JWT user with PRINTERS_UPDATE → True (Admin or Operator; the same roles
- that already manage printers and the Virtual Printer card UX that
- surfaces a target's code for slicer configuration).
- - JWT Viewer → False (the bug fix: Viewers must not be able to read
- access_code via PRINTERS_READ and then go around Bambuddy to MQTT).
- - API-key principal (``user is None`` because the dep returns None for
- API keys) → False. PRINTERS_UPDATE is admin-only and absent from
- ``_APIKEY_SCOPE_BY_PERMISSION``, so no API key can hold it.
- """
- if not await is_auth_enabled(db):
- return True
- if user is None:
- return False
- return user.has_permission(Permission.PRINTERS_UPDATE.value)
- def _serialize_printer(printer: Printer, *, include_secret: bool):
- """Build the response shape that matches the caller's authority."""
- if include_secret:
- return PrinterResponseWithSecret.model_validate(printer)
- return PrinterResponse.model_validate(printer)
- @router.get("/")
- async def list_printers(
- user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """List all configured printers.
- ``access_code`` is included in each item only when the caller is trusted
- to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
- API keys never receive it.
- """
- result = await db.execute(select(Printer).order_by(Printer.name))
- printers = list(result.scalars().all())
- include_secret = await _caller_can_view_printer_secrets(user, db)
- return [_serialize_printer(p, include_secret=include_secret) for p in printers]
- @router.post("/", response_model=PrinterResponse)
- async def create_printer(
- printer_data: PrinterCreate,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Add a new printer.
- Verifies the MQTT connection succeeds before persisting. A wrong access
- code or unreachable IP would otherwise create a printer row that shows
- as an empty / never-connecting card on the dashboard — those reports
- were turning into support tickets that all traced back to a mistyped
- access code.
- """
- # Check if serial number already exists
- result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number))
- if result.scalar_one_or_none():
- raise HTTPException(400, "Printer with this serial number already exists")
- test_result = await printer_manager.test_connection(
- ip_address=printer_data.ip_address,
- serial_number=printer_data.serial_number,
- access_code=printer_data.access_code,
- )
- if not test_result.get("success"):
- # The frontend renders the user-facing message via i18n on `code`;
- # `message` is an English fallback for non-UI clients (curl / scripts).
- raise HTTPException(
- status_code=400,
- detail={
- "code": "printer_connection_failed",
- "message": (
- "Could not connect to the printer. Verify IP address, serial number, "
- "and access code, and confirm LAN-only mode is enabled. "
- "The printer was not added."
- ),
- },
- )
- printer = Printer(**printer_data.model_dump())
- db.add(printer)
- await db.commit()
- await db.refresh(printer)
- # Connect to the printer
- if printer.is_active:
- await printer_manager.connect_printer(printer)
- return printer
- @router.get("/usb-cameras")
- async def list_usb_cameras(
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- ):
- """List available USB cameras connected to the system.
- Returns a list of detected V4L2 video devices with their info.
- Only works on Linux systems with V4L2 support.
- Returns:
- List of dicts with {device: str, name: str, capabilities: list, formats?: list}
- """
- from backend.app.services.external_camera import list_usb_cameras
- cameras = list_usb_cameras()
- return {"cameras": cameras}
- @router.get("/available-filaments")
- async def get_available_filaments(
- model: str = Query(..., description="Target printer model"),
- location: str | None = Query(None, description="Optional location filter"),
- _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Get deduplicated list of filaments loaded across all active printers of a given model.
- Used by the frontend to offer filament override options for model-based queue assignment.
- """
- from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
- # Normalize model name
- normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model
- query = (
- select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True) # noqa: E712
- )
- if location:
- query = query.where(Printer.location == location)
- result = await db.execute(query)
- printers_list = list(result.scalars().all())
- if not printers_list:
- return []
- # Collect filaments from all matching printers
- # Dedup key includes extruder_id and tray_sub_brands so "PLA Basic" and "PLA Matte" appear separately
- seen: set[tuple[str, str, str, int | None]] = set() # (type_upper, color_normalized, sub_brands_upper, extruder_id)
- filaments = []
- for printer in printers_list:
- status = printer_manager.get_status(printer.id)
- if not status:
- continue
- # Get ams_extruder_map for dual-nozzle printers
- ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
- # AMS trays
- for ams_unit in status.raw_data.get("ams", []):
- ams_id = str(ams_unit.get("id", 0))
- extruder_id = ams_extruder_map.get(ams_id)
- for tray in ams_unit.get("tray", []):
- tray_type = tray.get("tray_type")
- if not tray_type:
- continue
- tray_color = tray.get("tray_color", "") or "808080"
- # Preserve the full RRGGBBAA so transparent filament (alpha=00)
- # reaches the frontend instead of collapsing to #000000 → black
- # (#1545). Opaque colours still round-trip as #RRGGBB. The
- # dedup key uses the 6-char RGB so two slots that share an RGB
- # but differ only in alpha still merge.
- stripped = tray_color.replace("#", "")
- rgb = stripped[:6].lower() or "808080"
- color = f"#{stripped}"
- tray_info_idx = tray.get("tray_info_idx", "")
- tray_sub_brands = tray.get("tray_sub_brands", "") or ""
- key = (tray_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
- if key not in seen:
- seen.add(key)
- filaments.append(
- {
- "type": tray_type,
- "color": color,
- "tray_info_idx": tray_info_idx,
- "tray_sub_brands": tray_sub_brands,
- "extruder_id": extruder_id,
- }
- )
- # External spools (vt_tray)
- for vt in status.raw_data.get("vt_tray") or []:
- vt_type = vt.get("tray_type")
- if not vt_type:
- continue
- vt_color = vt.get("tray_color", "") or "808080"
- # Same alpha-preserving handling as the AMS branch — see #1545.
- stripped = vt_color.replace("#", "")
- rgb = stripped[:6].lower() or "808080"
- color = f"#{stripped}"
- tray_info_idx = vt.get("tray_info_idx", "")
- tray_sub_brands = vt.get("tray_sub_brands", "") or ""
- vt_id = int(vt.get("id", 254))
- extruder_id = (255 - vt_id) if ams_extruder_map else None
- key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
- if key not in seen:
- seen.add(key)
- filaments.append(
- {
- "type": vt_type,
- "color": color,
- "tray_info_idx": tray_info_idx,
- "tray_sub_brands": tray_sub_brands,
- "extruder_id": extruder_id,
- }
- )
- return filaments
- @router.get("/developer-mode-warnings")
- async def get_developer_mode_warnings(
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Check if any connected printer lacks developer LAN mode."""
- result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
- printers = result.scalars().all()
- statuses = printer_manager.get_all_statuses()
- warnings = []
- for printer in printers:
- state = statuses.get(printer.id)
- if state and state.connected and state.developer_mode is False:
- warnings.append(
- {
- "printer_id": printer.id,
- "name": printer.name,
- }
- )
- return warnings
- @router.get("/{printer_id}")
- async def get_printer(
- printer_id: int,
- user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get a specific printer.
- ``access_code`` is included only when the caller is trusted to see it
- (Admin / Operator JWT, or auth-disabled mode). Viewers and API keys
- never receive it.
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- include_secret = await _caller_can_view_printer_secrets(user, db)
- return _serialize_printer(printer, include_secret=include_secret)
- @router.patch("/{printer_id}", response_model=PrinterResponse)
- async def update_printer(
- printer_id: int,
- printer_data: PrinterUpdate,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Update a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- update_data = printer_data.model_dump(exclude_unset=True)
- # Handle nested ROI object - flatten to individual columns
- if "plate_detection_roi" in update_data:
- roi = update_data.pop("plate_detection_roi")
- if roi:
- update_data["plate_detection_roi_x"] = roi.get("x")
- update_data["plate_detection_roi_y"] = roi.get("y")
- update_data["plate_detection_roi_w"] = roi.get("w")
- update_data["plate_detection_roi_h"] = roi.get("h")
- else:
- # Clear ROI if set to null
- update_data["plate_detection_roi_x"] = None
- update_data["plate_detection_roi_y"] = None
- update_data["plate_detection_roi_w"] = None
- update_data["plate_detection_roi_h"] = None
- for field, value in update_data.items():
- setattr(printer, field, value)
- await db.commit()
- await db.refresh(printer)
- # Reconnect if connection settings changed
- if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
- printer_manager.disconnect_printer(printer_id)
- if printer.is_active:
- await printer_manager.connect_printer(printer)
- return printer
- @router.delete("/{printer_id}")
- async def delete_printer(
- printer_id: int,
- delete_archives: bool = True,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
- db: AsyncSession = Depends(get_db),
- ):
- """Delete a printer.
- Args:
- printer_id: ID of the printer to delete
- delete_archives: If True (default), delete all print archives for this printer.
- If False, keep archives but remove their printer association.
- """
- from sqlalchemy import delete as sql_delete
- from backend.app.models.archive import PrintArchive
- from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
- from backend.app.models.scheduled_drying import ScheduledDrying
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- printer_manager.disconnect_printer(printer_id)
- if delete_archives:
- # Delete all archives for this printer
- await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
- else:
- # Orphan the archives instead of deleting them
- from sqlalchemy import update
- await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
- # Delete slot assignments for this printer (SQLite doesn't enforce FK cascades)
- await db.execute(sql_delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
- # Delete scheduled drying runs for this printer (SQLite doesn't enforce FK cascades)
- await db.execute(sql_delete(ScheduledDrying).where(ScheduledDrying.printer_id == printer_id))
- # Delete maintenance history and items for this printer
- # (SQLite doesn't enforce FK cascades, so do it explicitly)
- maintenance_ids = (
- (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id)))
- .scalars()
- .all()
- )
- if maintenance_ids:
- await db.execute(
- sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids))
- )
- await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
- await db.delete(printer)
- await db.commit()
- return {"status": "deleted", "archives_deleted": delete_archives}
- @router.get("/{printer_id}/status", response_model=PrinterStatus)
- async def get_printer_status(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get real-time status of a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- state = printer_manager.get_status(printer_id)
- if not state:
- # No MQTT client state — the printer was never connected this run, or it
- # was disconnected manually. The plate-clear gate is Bambuddy-side and
- # persisted, so it still has a truthful value here (#2864); reporting the
- # schema default instead told clients the plate was clean and hid the
- # only control that can release the gate.
- return PrinterStatus(
- id=printer_id,
- name=printer.name,
- connected=False,
- awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
- )
- # Determine cover URL if there's an active print (including paused)
- cover_url = None
- if state.state in ("RUNNING", "PAUSE") and state.gcode_file:
- cover_url = f"/api/v1/printers/{printer_id}/cover"
- # Convert HMS errors to response format
- hms_errors = [
- HMSErrorResponse(
- code=e.code,
- attr=e.attr,
- module=e.module,
- severity=e.severity,
- actions=e.actions,
- job_id=e.job_id,
- full_code=e.full_code,
- description=e.description,
- )
- for e in (state.hms_errors or [])
- ]
- # Parse AMS data from raw_data
- ams_units = []
- vt_tray = []
- ams_exists = False
- raw_data = state.raw_data or {}
- # K value for a slot's bound profile, resolved against its own nozzle.
- #
- # Keyed on more than cali_idx: the printer numbers its calibration table
- # per nozzle, so entry 16 exists on each and means a different profile on
- # each. A cali_idx-only map let whichever profile the printer happened to
- # list last overwrite the other, and the slot then displayed the wrong
- # nozzle's K — on the maintainer's H2C, 0.018 and 0.020 for the same spool.
- _kprofile_k = build_slot_k_resolver(state)
- # Cached active-cycle drying params (filament + target temp) we sent
- # last; Bambu doesn't echo them on the per-tick AMS push, so the badge
- # needs the cache to render "<filament> @ <temp>°C".
- drying_targets = printer_manager.get_drying_targets(printer_id) or {}
- if "ams" in raw_data and isinstance(raw_data["ams"], list):
- ams_exists = True
- for ams_data in raw_data["ams"]:
- # Skip if ams_data is not a dict (defensive check)
- if not isinstance(ams_data, dict):
- continue
- trays = []
- for tray_data in ams_data.get("tray", []):
- # Filter out empty/invalid tag values
- tag_uid = tray_data.get("tag_uid", "")
- if tag_uid in ("", "0000000000000000"):
- tag_uid = None
- tray_uuid = tray_data.get("tray_uuid", "")
- if tray_uuid in ("", "00000000000000000000000000000000"):
- tray_uuid = None
- # Get K value: first try tray's k field, then lookup from K-profiles
- k_value = tray_data.get("k")
- cali_idx = tray_data.get("cali_idx")
- if k_value is None:
- k_value = _kprofile_k(cali_idx, int(ams_data.get("id", 0)), int(tray_data.get("id", 0)))
- trays.append(
- AMSTray(
- id=tray_data.get("id", 0),
- tray_color=tray_data.get("tray_color"),
- tray_type=tray_data.get("tray_type"),
- tray_sub_brands=tray_data.get("tray_sub_brands"),
- tray_id_name=tray_data.get("tray_id_name"),
- tray_info_idx=tray_data.get("tray_info_idx"),
- remain=tray_data.get("remain", 0),
- k=k_value,
- cali_idx=cali_idx,
- tag_uid=tag_uid,
- tray_uuid=tray_uuid,
- nozzle_temp_min=tray_data.get("nozzle_temp_min"),
- nozzle_temp_max=tray_data.get("nozzle_temp_max"),
- drying_temp=tray_data.get("drying_temp"),
- drying_time=tray_data.get("drying_time"),
- state=tray_data.get("state"),
- exists=tray_data.get("exists"),
- )
- )
- # Prefer humidity_raw (percentage) over humidity (index 1-5)
- # humidity_raw is the actual percentage value from the sensor
- humidity_raw = ams_data.get("humidity_raw")
- humidity_idx = ams_data.get("humidity")
- humidity_value = None
- if humidity_raw is not None:
- try:
- humidity_value = int(humidity_raw)
- except (ValueError, TypeError):
- pass # Skip unparseable humidity; will try index fallback
- if humidity_value is None and humidity_idx is not None:
- try:
- humidity_value = int(humidity_idx)
- except (ValueError, TypeError):
- pass # Skip unparseable humidity index; humidity remains None
- # AMS-HT has 1 tray, regular AMS has 4 trays
- is_ams_ht = len(trays) == 1
- ams_id_int = int(ams_data.get("id", 0))
- target = drying_targets.get(ams_id_int) or {}
- dry_target_temp: int | None = None
- dry_filament: str | None = None
- target_temp_val = target.get("temp")
- target_fil_val = target.get("filament") or ""
- if target_temp_val is not None:
- try:
- dry_target_temp = int(target_temp_val)
- except (TypeError, ValueError):
- dry_target_temp = None
- if target_fil_val:
- dry_filament = str(target_fil_val)
- # Fallback: name the filament from the loaded trays when there is no
- # cached target (drying started in a previous backend session, or
- # the cache wasn't seeded), and only when they agree. The
- # temperature has no fallback — see uniform_tray_filament_hint.
- if not dry_filament:
- dry_filament = uniform_tray_filament_hint([tray.tray_type or "" for tray in trays])
- ams_units.append(
- AMSUnit(
- id=ams_id_int,
- humidity=humidity_value,
- temp=ams_data.get("temp"),
- is_ams_ht=is_ams_ht,
- tray=trays,
- # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
- serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
- # Firmware version: populated by _handle_version_info from info.module ams/* entries
- sw_ver=str(ams_data.get("sw_ver") or ""),
- # Drying: dry_time > 0 means drying is active (minutes remaining)
- dry_time=int(ams_data.get("dry_time") or 0),
- dry_target_temp=dry_target_temp,
- dry_filament=dry_filament,
- module_type=str(ams_data.get("module_type") or ""),
- )
- )
- # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
- if "vt_tray" in raw_data:
- for vt_data in raw_data["vt_tray"]:
- # Filter out empty/invalid tag values for vt_tray
- vt_tag_uid = vt_data.get("tag_uid", "")
- if vt_tag_uid in ("", "0000000000000000"):
- vt_tag_uid = None
- vt_tray_uuid = vt_data.get("tray_uuid", "")
- if vt_tray_uuid in ("", "00000000000000000000000000000000"):
- vt_tray_uuid = None
- # Get K value: first try tray's k field, then lookup from K-profiles
- vt_k_value = vt_data.get("k")
- vt_cali_idx = vt_data.get("cali_idx")
- if vt_k_value is None:
- # External holder: id 254 is Ext-L, 255 is Ext-R. slot_extruder
- # takes the 0/1 tray index, so normalise before asking.
- vt_id = int(vt_data.get("id", 254))
- vt_k_value = _kprofile_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
- tray_id = int(vt_data.get("id", 254))
- vt_tray.append(
- AMSTray(
- id=tray_id,
- tray_color=vt_data.get("tray_color"),
- tray_type=vt_data.get("tray_type"),
- tray_sub_brands=vt_data.get("tray_sub_brands"),
- tray_id_name=vt_data.get("tray_id_name"),
- tray_info_idx=vt_data.get("tray_info_idx"),
- remain=vt_data.get("remain", 0),
- k=vt_k_value,
- cali_idx=vt_cali_idx,
- tag_uid=vt_tag_uid,
- tray_uuid=vt_tray_uuid,
- nozzle_temp_min=vt_data.get("nozzle_temp_min"),
- nozzle_temp_max=vt_data.get("nozzle_temp_max"),
- )
- )
- # Convert nozzle info to response format
- nozzles = [
- NozzleInfoResponse(
- nozzle_type=n.nozzle_type,
- nozzle_diameter=n.nozzle_diameter,
- )
- for n in (state.nozzles or [])
- ]
- # H2C nozzle rack (tool-changer dock positions)
- nozzle_rack = [
- NozzleRackSlot(
- id=n.get("id", 0),
- nozzle_type=n.get("type", ""),
- nozzle_diameter=n.get("diameter", ""),
- wear=n.get("wear"),
- stat=n.get("stat"),
- max_temp=n.get("max_temp", 0),
- serial_number=n.get("serial_number", ""),
- filament_color=n.get("filament_color", ""),
- filament_id=n.get("filament_id", ""),
- filament_type=n.get("filament_type", ""),
- )
- for n in (state.nozzle_rack or [])
- ]
- # Convert print options to response format
- print_options = PrintOptionsResponse(
- spaghetti_detector=state.print_options.spaghetti_detector,
- print_halt=state.print_options.print_halt,
- halt_print_sensitivity=state.print_options.halt_print_sensitivity,
- first_layer_inspector=state.print_options.first_layer_inspector,
- printing_monitor=state.print_options.printing_monitor,
- buildplate_marker_detector=state.print_options.buildplate_marker_detector,
- allow_skip_parts=state.print_options.allow_skip_parts,
- nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,
- nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,
- pileup_detector=state.print_options.pileup_detector,
- pileup_sensitivity=state.print_options.pileup_sensitivity,
- airprint_detector=state.print_options.airprint_detector,
- airprint_sensitivity=state.print_options.airprint_sensitivity,
- auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,
- filament_tangle_detect=state.print_options.filament_tangle_detect,
- )
- # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
- ams_mapping = raw_data.get("ams_mapping", [])
- # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
- # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
- ams_extruder_map = state.ams_extruder_map or {}
- logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
- # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id
- # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID
- # No conversion needed - just use the raw value directly
- tray_now = state.tray_now
- logger.debug("Using tray_now directly as global ID: %s", tray_now)
- # Filter out chamber temp for models that don't have a real sensor
- # P1P, P1S, A1, A1Mini report meaningless chamber_temper values
- temperatures = state.temperatures
- if not supports_chamber_temp(printer.model):
- temperatures = {
- k: v for k, v in temperatures.items() if k not in ("chamber", "chamber_target", "chamber_heating")
- }
- # Resolve the active print's archive + plate (#881 follow-up): lets the
- # printer card show the actual plate name for multi-plate 3MFs instead of
- # just the 3MF filename. Only attempted for active prints, since subtask_id
- # is only meaningful then.
- current_archive_id: int | None = None
- current_plate_id: int | None = None
- if state.state in ("RUNNING", "PAUSE"):
- current_plate_id = resolve_plate_id(state)
- if state.subtask_id:
- from backend.app.models.archive import PrintArchive
- archive_row = await db.execute(
- select(PrintArchive.id)
- .where(PrintArchive.subtask_id == state.subtask_id)
- .where(PrintArchive.printer_id == printer_id)
- .order_by(PrintArchive.created_at.desc())
- .limit(1)
- )
- current_archive_id = archive_row.scalar_one_or_none()
- return PrinterStatus(
- id=printer_id,
- name=printer.name,
- connected=state.connected,
- state=state.state,
- current_print=state.current_print,
- subtask_name=state.subtask_name,
- gcode_file=state.gcode_file,
- progress=state.progress,
- remaining_time=state.remaining_time,
- layer_num=state.layer_num,
- total_layers=state.total_layers,
- temperatures=temperatures,
- cover_url=cover_url,
- hms_errors=hms_errors,
- ams=ams_units,
- ams_exists=ams_exists,
- vt_tray=vt_tray,
- sdcard=state.sdcard,
- store_to_sdcard=state.store_to_sdcard,
- timelapse=state.timelapse,
- ipcam=state.ipcam,
- wifi_signal=state.wifi_signal,
- wired_network=state.wired_network,
- door_open=state.door_open,
- nozzles=nozzles,
- nozzle_rack=nozzle_rack,
- print_options=print_options,
- stg_cur=state.stg_cur,
- stg_cur_name=get_derived_status_name(state, printer.model),
- stg=state.stg,
- airduct_mode=state.airduct_mode,
- speed_level=state.speed_level,
- chamber_light=state.chamber_light,
- active_extruder=state.active_extruder,
- ams_mapping=ams_mapping,
- ams_extruder_map=ams_extruder_map,
- # Only meaningful alongside an installed switch; without one the map is
- # empty anyway, but gating it keeps a stale binding from outliving the
- # accessory being unplugged.
- ams_switch_inlet=(dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
- # Which hotend holds which slot. Same first-load reasoning as
- # fila_switch.ready below — the AMS slot menu reads it to decide which
- # hotend it may offer, and an empty default would offer both.
- extruder_slots={
- str(ext_id): ExtruderSlotResponse(
- ams_id=slot.ams_id,
- slot_id=slot.slot_id,
- has_filament=slot.has_filament,
- )
- for ext_id, slot in state.extruder_slots.items()
- },
- tray_now=tray_now,
- # Runout guidance (#2587): resolve the firmware's target/previous slot to a
- # global tray ID, but only while PAUSED — the moment the operator needs it.
- expected_tray=(
- resolve_expected_tray(
- state.tray_tar,
- [(u.id, u.is_ams_ht) for u in ams_units],
- raw_data.get("mapping"),
- )
- if state.state == "PAUSE"
- else None
- ),
- previous_tray=(
- resolve_expected_tray(
- state.tray_pre,
- [(u.id, u.is_ams_ht) for u in ams_units],
- raw_data.get("mapping"),
- )
- if state.state == "PAUSE"
- else None
- ),
- ams_status_main=state.ams_status_main,
- ams_status_sub=state.ams_status_sub,
- mc_print_sub_stage=state.mc_print_sub_stage,
- last_ams_update=state.last_ams_update,
- printable_objects_count=len(state.printable_objects),
- cooling_fan_speed=state.cooling_fan_speed,
- big_fan1_speed=state.big_fan1_speed,
- big_fan2_speed=state.big_fan2_speed,
- heatbreak_fan_speed=state.heatbreak_fan_speed,
- left_aux_fan_speed=state.left_aux_fan_speed,
- exhaust_fan_present=state.exhaust_fan_present,
- firmware_version=state.firmware_version,
- developer_mode=state.developer_mode if state else None,
- ams_filament_backup=state.ams_filament_backup if state else None,
- awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
- supports_drying=supports_drying(printer.model, state.firmware_version),
- supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
- drying_screen_only=drying_screen_only(printer.model),
- supports_chamber_heater=supports_chamber_heater(printer.model),
- current_archive_id=current_archive_id,
- current_plate_id=current_plate_id,
- fila_switch=(
- FilaSwitchResponse(
- installed=state.fila_switch.installed,
- in_slots=list(state.fila_switch.in_slots),
- out_extruders=list(state.fila_switch.out_extruders),
- stat=state.fila_switch.stat,
- info=state.fila_switch.info,
- # Must be computed here as well as in printer_state_to_dict: this
- # is what the page gets on its first load, and the WebSocket only
- # corrects it on the next push. Defaulting it to False instead
- # would tell every correctly set-up machine that its switch is
- # not set up, until a push happened to arrive.
- ready=all(str(u.id) in state.ams_switch_inlet for u in ams_units),
- )
- if state.fila_switch and state.fila_switch.installed
- else None
- ),
- )
- @router.get("/{printer_id}/overlay-status")
- async def get_overlay_status(
- printer_id: int,
- _: None = RequireOverlayTokenIfAuthEnabled,
- db: AsyncSession = Depends(get_db),
- ) -> dict:
- """Everything the streaming overlay (#2613) draws for one printer.
- A token-authenticated sibling of ``get_printer_status`` for embeds with no
- login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
- Deliberately flat and minimal (name, camera rotation, live print state, and
- the one setting the overlay reads) rather than the full ``PrinterStatus``:
- a token holder gets exactly the fields the overlay renders, nothing more.
- Unlike the Cam Wall feed this *includes the print filename* — the overlay
- names the part on screen — which is why it sits behind its own ``overlay``
- scope rather than ``camwall``.
- """
- from backend.app.api.routes.settings import get_setting
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- time_format = await get_setting(db, "time_format") or "system"
- state = printer_manager.get_status(printer_id)
- if not state:
- # Never connected this run — mirror get_printer_status()'s disconnected
- # shape so the overlay renders its offline state rather than erroring.
- return {
- "id": printer_id,
- "name": printer.name,
- "camera_rotation": printer.camera_rotation or 0,
- "connected": False,
- "state": None,
- "current_print": None,
- "gcode_file": None,
- "progress": None,
- "remaining_time": None,
- "layer_num": None,
- "total_layers": None,
- "stg_cur_name": None,
- "temperatures": {},
- "time_format": time_format,
- }
- return {
- "id": printer_id,
- "name": printer.name,
- "camera_rotation": printer.camera_rotation or 0,
- "connected": state.connected,
- "state": state.state,
- "current_print": state.current_print,
- "gcode_file": state.gcode_file,
- "progress": state.progress,
- "remaining_time": state.remaining_time,
- "layer_num": state.layer_num,
- "total_layers": state.total_layers,
- "stg_cur_name": get_derived_status_name(state, printer.model),
- # Nozzle / bed / chamber readings for the overlay's temperature fields
- # (#1422). Filtered rather than passed through: see display_temperatures.
- "temperatures": display_temperatures(state.temperatures, printer.model),
- "time_format": time_format,
- }
- @router.get("/{printer_id}/current-print-user")
- async def get_current_print_user(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get the user who started the current print (for reprint tracking).
- Returns user info if available, empty object otherwise.
- This tracks users for reprints (which bypass the queue).
- For queue-based prints, use the queue item's created_by field instead.
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- user_info = printer_manager.get_current_print_user(printer_id)
- return user_info or {}
- @router.post("/{printer_id}/refresh-status")
- async def refresh_printer_status(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Request a full status refresh from the printer (sends pushall command)."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- success = printer_manager.request_status_update(printer_id)
- if not success:
- raise HTTPException(400, "Printer not connected")
- return {"status": "refresh_requested"}
- @router.post("/{printer_id}/connect")
- async def connect_printer(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Manually connect to a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- success = await printer_manager.connect_printer(printer)
- return {"connected": success}
- @router.post("/{printer_id}/disconnect")
- async def disconnect_printer(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Manually disconnect from a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- printer_manager.disconnect_printer(printer_id)
- return {"connected": False}
- @router.post("/test")
- async def test_printer_connection(
- ip_address: str,
- serial_number: str,
- access_code: str,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
- ):
- """Test connection to a printer without saving."""
- result = await printer_manager.test_connection(
- ip_address=ip_address,
- serial_number=serial_number,
- access_code=access_code,
- )
- return result
- @router.post("/diagnostic", response_model=PrinterDiagnosticResult)
- async def diagnose_connection(
- req: DiagnosticRequest,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
- ):
- """Run connection diagnostics for the Add-Printer flow (printer not yet saved).
- When serial_number + access_code are supplied the MQTT credential check
- also runs; otherwise only the network-level checks are performed.
- """
- return await run_connection_diagnostic(
- req.ip_address,
- serial_number=req.serial_number or None,
- access_code=req.access_code or None,
- )
- @router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
- async def diagnose_printer(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Run connection diagnostics for an existing saved printer.
- On-demand run from the UI: wait up to PUBLISH_WAIT_DEFAULT seconds for the
- printer to publish a status report so a fresh reconnect (counter reset to
- 0) isn't reported as `printer_publishing: fail` prematurely. The support
- package code path calls run_connection_diagnostic without the wait so
- bundling stays fast.
- """
- from backend.app.services.printer_diagnostic import PUBLISH_WAIT_DEFAULT
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- return await run_connection_diagnostic(
- printer.ip_address,
- printer=printer,
- wait_for_publish_seconds=PUBLISH_WAIT_DEFAULT,
- )
- # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
- # Cleared on every print start by main.py::on_print_start, so re-dispatches with
- # different plates always fetch a fresh thumbnail without needing plate in the key.
- _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
- # Negative cache (#1420): when a cover lookup exhausts every FTP path with 550
- # (file sliced on SD card, not on printer storage), remember the failure so the
- # next request short-circuits to 404 instead of re-hammering FTP 8 paths deep.
- # Cleared on print start alongside _cover_cache.
- _cover_404_cache: dict[int, set[tuple[str, str]]] = {}
- # In-flight cover downloads, keyed by (printer_id, subtask_name, view_key) (#2572).
- # The farm dashboard mounts a cover tile per printer card, so several browsers
- # request the same printer's cover in the same instant, all miss the cache, and
- # each runs the full multi-path FTP lookup + 3MF extraction (one observed live
- # transfer pulled an 81 MB 3MF while real print uploads were in flight). The
- # first request to miss becomes the leader; concurrent requests await its future
- # and then serve from the positive/negative cache it filled.
- _cover_inflight: dict[tuple[int, str, str], asyncio.Future] = {}
- def clear_cover_cache(printer_id: int) -> None:
- """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
- _cover_cache.pop(printer_id, None)
- _cover_404_cache.pop(printer_id, None)
- async def _running_print_archive_file(printer_id: int, state) -> Path | None:
- """Path to the 3MF of the print this printer is running, if we have it.
- Bambuddy archives the sliced file when the print starts, so the copy the
- printer is executing is usually already on disk. Anchored on ``subtask_id``,
- which the firmware mints per print: a leftover ``status="printing"`` row from
- a completion that was never seen must not lend its file to another job.
- Opens its own short-lived session, like the caller does, so the pooled
- connection is not held across the FTP work that follows.
- """
- subtask_id = str(getattr(state, "subtask_id", "") or "").strip()
- if subtask_id in ("", "0"):
- return None
- from backend.app.models.archive import PrintArchive
- async with database.async_session() as db:
- archive = await db.scalar(
- select(PrintArchive)
- .where(
- PrintArchive.printer_id == printer_id,
- PrintArchive.status == "printing",
- PrintArchive.subtask_id == subtask_id,
- )
- .order_by(PrintArchive.created_at.desc())
- .limit(1)
- )
- if archive is None or not archive.file_path:
- return None
- path = settings.base_dir / archive.file_path
- return path if path.is_file() and str(path).endswith(".3mf") else None
- @router.get("/{printer_id}/cover")
- async def get_printer_cover(
- printer_id: int,
- view: str | None = None,
- _: None = RequireCameraStreamTokenIfAuthEnabled,
- ):
- """Get the cover image for the current print job.
- Args:
- view: Optional view type. Use "top" for the top-down build plate view or
- "pick" for the slicer's object-ID mask used by skip objects.
- Default returns angled 3D perspective view.
- """
- # Fetch the printer in a short-lived session and release the pooled DB
- # connection BEFORE the FTP download below. Previously this route took its
- # row via Depends(get_db), whose session stays open for the whole request —
- # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes
- # under FTP contention) pinned one pooled connection idle-in-transaction the
- # entire time (issue #2572). db is used only for this one SELECT; everything
- # after reads already-loaded printer.* scalars (expire_on_commit=False keeps
- # them readable), printer_manager, and FTP/zip — no lazy loads.
- #
- # Reference async_session via the module so the maker is looked up at call
- # time — keeps it in sync with reinitialize_database() and lets the test
- # harness's patch of backend.app.core.database.async_session take effect.
- async with database.async_session() as db:
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- state = printer_manager.get_status(printer_id)
- if not state:
- raise HTTPException(404, "Printer not connected")
- # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)
- subtask_name = state.subtask_name
- if not subtask_name:
- raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
- # Resolve the active plate. Precedence (#1166):
- # 1. The plate Bambuddy dispatched (authoritative when we sent the print)
- # 2. plate_(\d+)\.gcode regex on state.gcode_file (works on firmware that
- # reflects the full path, e.g. some X1C builds)
- # 3. Scan the downloaded 3MF for a unique Metadata/plate_*.gcode (covers
- # per-plate archives sliced separately in Bambu Studio, where the
- # printer's gcode_file echo is just the .3mf filename)
- # 4. Fall back to plate 1
- # The 3MF-scan fallback runs later — after the file is on disk.
- plate_num = resolve_plate_id(state)
- if plate_num is not None:
- logger.info("Cover: resolved plate %s before download (subtask=%s)", plate_num, subtask_name)
- # Normalize view parameter
- view_key = view or "default"
- # Check cache. Cache by (subtask_name, view_key) only — clear_cover_cache()
- # runs on every print start, so a re-dispatch with a different plate gets
- # a fresh image regardless. Pre-#1166 the key included plate_num, but with
- # late plate resolution the cache check would always miss.
- cache_key = (subtask_name, view_key)
- if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
- return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
- # Negative-cache short-circuit (#1420): if a prior lookup for this same
- # subtask + view already failed, don't replay 8 FTP retries on every page
- # refresh. _cover_404_cache is cleared on print start.
- if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
- raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
- # Coalesce concurrent downloads for the same cover (#2572). The positive and
- # negative caches were just checked above; if another request is already
- # downloading this exact cover, wait for it and serve from the cache it fills
- # instead of launching a duplicate multi-path FTP + 3MF extraction.
- inflight_key = (printer_id, subtask_name, view_key)
- leader = _cover_inflight.get(inflight_key)
- if leader is not None:
- # shield() so our own cancellation can't cancel the shared leader.
- try:
- await asyncio.shield(leader)
- except Exception:
- pass
- if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
- return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
- if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
- raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
- # Leader finished without filling either cache (a transient 503) — fall
- # through and try the download ourselves.
- fut: asyncio.Future = asyncio.get_event_loop().create_future()
- _cover_inflight[inflight_key] = fut
- try:
- image_data = await _produce_cover_image(
- printer,
- printer_id,
- subtask_name,
- view,
- view_key,
- plate_num,
- cache_key,
- archive_path=await _running_print_archive_file(printer_id, state),
- )
- return Response(content=image_data, media_type="image/png")
- finally:
- if not fut.done():
- fut.set_result(None)
- _cover_inflight.pop(inflight_key, None)
- async def _produce_cover_image(
- printer: Printer,
- printer_id: int,
- subtask_name: str,
- view: str | None,
- view_key: str,
- plate_num: int | None,
- cache_key: tuple[str, str],
- archive_path: Path | None = None,
- ) -> bytes:
- """Download the active-print 3MF and extract its cover thumbnail (#2572).
- Split out of ``get_printer_cover`` so concurrent requests for the same cover
- can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
- on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
- failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
- work — the caller already released the pooled connection before this runs,
- which is also why ``archive_path`` arrives resolved rather than looked up
- here.
- """
- # Build possible 3MF filenames from subtask_name
- # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
- # or just "name.3mf" (uploaded directly)
- possible_filenames = []
- if subtask_name.endswith(".3mf"):
- possible_filenames.append(subtask_name)
- else:
- # Try both naming patterns
- possible_filenames.append(f"{subtask_name}.gcode.3mf")
- possible_filenames.append(f"{subtask_name}.3mf")
- # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
- if " " in subtask_name:
- normalized = subtask_name.replace(" ", "_")
- if normalized.endswith(".3mf"):
- possible_filenames.append(normalized)
- else:
- possible_filenames.append(f"{normalized}.gcode.3mf")
- possible_filenames.append(f"{normalized}.3mf")
- # Build list of all remote paths to try
- remote_paths = []
- for filename in possible_filenames:
- remote_paths.extend(
- [
- f"/{filename}", # Root directory (most common)
- f"/cache/{filename}",
- f"/model/{filename}",
- f"/data/{filename}",
- ]
- )
- # Use first filename for temp path (will be reused)
- temp_filename = possible_filenames[0]
- temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
- temp_path.parent.mkdir(parents=True, exist_ok=True)
- storage = print_file_reachable_over_ftp(printer_manager.get_status(printer_id))
- # Cache check (#972): the archive-metadata flow in main.py may have already
- # downloaded this 3MF during the print-start handler. Reusing that file
- # avoids a second 36MB transfer competing with the printer's single FTP
- # socket (which produces the 425 errors that feed the retry storm).
- #
- # The dispatch's own filename is a candidate too: it is what the archive
- # flow's probe cached the file under, and it does not always survive the
- # trip through subtask_name (#2856).
- downloaded = False
- using_cached = False
- def _cached_source() -> Path | None:
- """The 3MF another flow has already published for this print, if any."""
- for candidate_name in (*possible_filenames, storage.probe_filename):
- if not candidate_name:
- continue
- cached = get_cached_3mf(printer_id, candidate_name)
- if cached:
- return cached
- return None
- cached = _cached_source()
- if cached:
- logger.info("Cover using cached 3MF from %s (avoided duplicate FTP)", cached)
- temp_path = cached
- downloaded = True
- using_cached = True
- if not downloaded:
- # Same idea, one step further back: that in-memory cache dies with the
- # process, but the archive of the print that is still running holds the
- # very 3MF on disk. Without this, reopening a card or the skip-objects
- # plate after a restart pulls the whole file back off a printer that is
- # mid-print — measured at three concurrent fan-outs, thirteen seconds
- # and a 0-byte read on the maintainer's H2C, which is exactly the
- # single-socket contention #972 was about.
- if archive_path is not None:
- logger.info("Cover using the running print's archived 3MF at %s (no FTP)", archive_path)
- temp_path = archive_path
- downloaded = True
- using_cached = True
- if not downloaded:
- # The cover lives inside the 3MF, so it is only reachable if the 3MF is.
- # When the printer kept the print on internal storage there is nothing
- # at any of these paths, and walking all sixteen of them just to end on
- # a 404 that reads as "this print has no cover" helps nobody (#2780).
- #
- # Unless the printer is wrong about that, which an H2D with a card in
- # routinely is (#2856). The dispatch names the file, so when it does,
- # trade the immediate 404 for a five-path probe of that one name — same
- # single connection, and it is the only way this endpoint ever recovers
- # a cover for a print the archive flow did not see start.
- max_retries = 2
- if not storage.reachable:
- if not storage.probe_filename:
- _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
- raise HTTPException(
- 404,
- f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS "
- f"({storage.reason}), so it has no cover to extract.",
- )
- remote_paths = ftp_probe_paths(storage.probe_filename)
- # The dispatch's name is the authoritative one — a print whose
- # subtask_name has been normalized or truncated would otherwise be
- # cached under a key the archive flow never looks up.
- temp_filename = storage.probe_filename
- temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{temp_filename}"
- # One look, not three: the printer has already said this file is not
- # here, so a retry storm on top of a hunch is exactly what #2780 was.
- max_retries = 0
- logger.info(
- f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
- )
- # Retry logic for transient FTP failures
- last_error = None
- for attempt in range(max_retries + 1):
- if attempt:
- # Look again before spending another transfer. The entry check
- # above only settles the race when the two flows do not
- # overlap, and on a P1S at print start they overlap for
- # minutes: a reported run had the archive flow publish the file
- # 42 seconds into this endpoint's 2.5-minute retry sequence,
- # and the third attempt still pulled its own 5 MB copy of it
- # over the same socket the printer was serving the print from
- # (#2957).
- cached = _cached_source()
- if cached:
- logger.info(
- "Cover picked up the 3MF another flow finished downloading (%s) — skipping retry %s",
- cached,
- attempt + 1,
- )
- temp_path = cached
- downloaded = True
- using_cached = True
- break
- if ftps_handshake_blocked(printer.ip_address):
- # Nothing to retry: the printer is not completing a TLS
- # handshake on port 990, so no path and no attempt reaches it
- # (#2780). Report the real cause instead of the 404 below,
- # which would read as "this print has no cover".
- raise HTTPException(
- 503,
- f"Printer {printer.ip_address} is not answering its file service over TLS. "
- "Bambuddy will try again shortly.",
- )
- try:
- downloaded = await download_file_try_paths_async(
- printer.ip_address,
- printer.access_code,
- remote_paths,
- temp_path,
- printer_model=printer.model,
- )
- if downloaded:
- break
- except Exception as e:
- last_error = e
- if attempt < max_retries:
- logger.warning("FTP download attempt %s failed: %s, retrying...", attempt + 1, e)
- await asyncio.sleep(0.5 * (attempt + 1)) # Brief backoff
- else:
- logger.error("FTP download failed after %s attempts: %s", max_retries + 1, e)
- if last_error and not downloaded:
- raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
- if not downloaded:
- # Remember this failure so subsequent requests for the same print
- # skip the 8-path FTP fan-out (#1420).
- _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
- if not storage.reachable:
- # The probe looked and found nothing, so the printer's own
- # account of where the file went is the answer after all —
- # keep saying so rather than reporting a generic miss (#2780).
- raise HTTPException(
- 404,
- f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS "
- f"({storage.reason}), so it has no cover to extract.",
- )
- raise HTTPException(
- 404,
- f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
- )
- # Share the fresh download with the archive flow — unless the file is
- # already theirs, in which case re-registering it under this endpoint's
- # own name would only add a second key pointing at the same bytes.
- if not using_cached:
- cache_3mf_download(printer_id, temp_filename, temp_path)
- # Verify file actually exists and has content
- if not temp_path.exists():
- raise HTTPException(500, f"Download reported success but file not found: {temp_path}")
- file_size = temp_path.stat().st_size
- logger.info("Downloaded file size: %s bytes", file_size)
- if file_size == 0:
- if not using_cached:
- temp_path.unlink()
- raise HTTPException(500, f"Downloaded file is empty for '{subtask_name}'")
- # Offer the file to the archive flow before extracting the thumbnail. When
- # the print started inside the printer's FTPS cool-off, the archive flow
- # gave up without a single connection and this endpoint holds the very file
- # it wanted — which used to be read for a thumbnail and then deleted at
- # print completion, leaving a permanently empty archive (#2957). Covers the
- # cached branch as well as a fresh download: whoever fetched it, the running
- # print's archive should have it. A no-op unless that archive is a fallback.
- from backend.app.main import try_recover_fallback_archive
- await try_recover_fallback_archive(printer_id, temp_filename, temp_path)
- try:
- # Extract thumbnail from 3MF (which is a ZIP file)
- try:
- zf = zipfile.ZipFile(temp_path, "r")
- except zipfile.BadZipFile:
- raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
- except OSError as e:
- logger.error("Failed to open 3MF file: %s", e, exc_info=True)
- raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
- try:
- # 3MF-scan fallback for plate detection (#1166). Per-plate archives
- # sliced separately in Bambu Studio contain a single
- # Metadata/plate_N.gcode for the active plate, even though
- # thumbnails for all plates are bundled. Using that gcode's plate
- # number prevents falling back to plate_1.png.
- if plate_num is None:
- plate_gcodes = [name for name in zf.namelist() if re.match(r"^Metadata/plate_\d+\.gcode$", name)]
- if len(plate_gcodes) == 1:
- match = re.search(r"plate_(\d+)\.gcode", plate_gcodes[0])
- if match:
- plate_num = int(match.group(1))
- logger.info("Cover: detected plate %s from 3MF contents", plate_num)
- if plate_num is None:
- plate_num = 1
- # Try common thumbnail paths in 3MF files
- # Use plate_num to get the correct plate's thumbnail for multi-plate projects
- # Use top-down view if requested (better for skip objects modal)
- if view == "pick":
- # Only the active plate's mask, with no fallback: every other view
- # falls back to plate 1 because a slightly wrong picture is better
- # than none, but a mask is coordinates, not decoration. Plate 1's
- # mask over plate 3's layout would resolve clicks to whichever
- # object happened to occupy that pixel on a different plate.
- thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
- elif view == "top":
- thumbnail_paths = [
- f"Metadata/top_{plate_num}.png",
- # Fall back to plate 1 if specific plate not found
- "Metadata/top_1.png",
- f"Metadata/plate_{plate_num}.png",
- "Metadata/plate_1.png",
- "Metadata/thumbnail.png",
- ]
- else:
- thumbnail_paths = [
- f"Metadata/plate_{plate_num}.png",
- # Fall back to plate 1 if specific plate not found
- "Metadata/plate_1.png",
- "Metadata/thumbnail.png",
- f"Metadata/plate_{plate_num}_small.png",
- "Metadata/plate_1_small.png",
- "Thumbnails/thumbnail.png",
- "thumbnail.png",
- ]
- for thumb_path in thumbnail_paths:
- try:
- image_data = zf.read(thumb_path)
- if printer_id not in _cover_cache:
- _cover_cache[printer_id] = {}
- _cover_cache[printer_id][(subtask_name, view_key)] = image_data
- return image_data
- except KeyError:
- continue
- # If no specific thumbnail found, try any PNG in Metadata. Never for
- # "pick": handing back a rendered thumbnail in place of the object-ID
- # mask is worse than nothing, because the caller can't tell the
- # difference and decodes the render's pixel colours as object IDs —
- # dark pixels yield small integers that collide with real IDs, so a
- # click would select an arbitrary object and skip it irreversibly.
- # A 404 is what tells the UI to fall back to the checklist.
- if view != "pick":
- for name in zf.namelist():
- if name.startswith("Metadata/") and name.endswith(".png"):
- image_data = zf.read(name)
- if printer_id not in _cover_cache:
- _cover_cache[printer_id] = {}
- _cover_cache[printer_id][(subtask_name, view_key)] = image_data
- return image_data
- _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
- raise HTTPException(404, "No thumbnail found in 3MF file")
- finally:
- zf.close()
- finally:
- # Only delete when this invocation owns the file. A cached path is
- # shared with the archive flow — removing it would force a refetch
- # the next time either flow needs the 3MF.
- if not using_cached and temp_path.exists():
- temp_path.unlink()
- # ============================================
- # File Manager Endpoints
- # ============================================
- async def _load_printer_or_404(printer_id: int) -> Printer:
- """Load a printer in a short-lived session, releasing the pooled DB
- connection before the caller starts any FTP/network I/O (#2572).
- The file-manager and storage routes talk FTP to the printer, which can
- block for the full socket timeout — longer when a saturated FTP pool backs
- up. Holding the request's Depends(get_db) session across that FTP pinned one
- pooled connection idle-in-transaction per in-flight request, a top cause of
- pool exhaustion on large farms. The returned row's scalar columns stay
- readable after the session closes (expire_on_commit=False). Raises 404 when
- the printer doesn't exist.
- Reference async_session via the module so the maker is resolved at call time
- — keeps it in sync with reinitialize_database() and lets tests patch it.
- """
- async with database.async_session() as db:
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- return printer
- @router.get("/{printer_id}/files")
- async def list_printer_files(
- printer_id: int,
- path: str = "/",
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """List files on the printer at the specified path."""
- printer = await _load_printer_or_404(printer_id)
- listing = await list_files_result_async(
- printer.ip_address,
- printer.access_code,
- path,
- printer_model=printer.model,
- )
- files = listing.files
- # Add full path to each file
- for f in files:
- f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}"
- return {
- "path": path,
- "files": files,
- "warnings": [] if listing.available else ["printer_unavailable"],
- }
- @router.get("/{printer_id}/files/download")
- async def download_printer_file(
- printer_id: int,
- path: str,
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """Download a file from the printer."""
- printer = await _load_printer_or_404(printer_id)
- try:
- async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
- result = await build_printer_file(
- printer,
- path,
- None,
- bundle_key=f"single-{secrets.token_urlsafe(18)}",
- )
- except PrinterFilesZipTooLargeError as exc:
- raise HTTPException(413, str(exc)) from exc
- except PrinterFilesZipInsufficientSpaceError as exc:
- raise HTTPException(507, str(exc)) from exc
- except FileNotFoundError:
- raise HTTPException(404, f"File not found: {path}")
- except TimeoutError as exc:
- raise HTTPException(504, "Printer download exceeded the 30-minute limit") from exc
- # Determine content type based on extension
- filename = path.split("/")[-1]
- ext = filename.lower().split(".")[-1] if "." in filename else ""
- content_types = {
- "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
- "gcode": "text/plain",
- "mp4": "video/mp4",
- "avi": "video/x-msvideo",
- "png": "image/png",
- "jpg": "image/jpeg",
- "jpeg": "image/jpeg",
- "json": "application/json",
- "txt": "text/plain",
- }
- content_type = content_types.get(ext, "application/octet-stream")
- return FileResponse(
- path=result.path,
- filename=filename,
- media_type=content_type,
- headers={"Content-Disposition": build_content_disposition(filename)},
- background=BackgroundTask(remove_printer_files_zip, result.path),
- )
- @router.get("/{printer_id}/files/gcode")
- async def get_printer_file_gcode(
- printer_id: int,
- path: str,
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """Get gcode for a file stored on a printer (for preview)."""
- import io
- printer = await _load_printer_or_404(printer_id)
- data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
- if data is None:
- raise HTTPException(404, f"File not found: {path}")
- filename = path.split("/")[-1]
- lower = filename.lower()
- if lower.endswith(".gcode"):
- return Response(content=data, media_type="text/plain")
- if lower.endswith(".3mf"):
- try:
- with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
- gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
- if not gcode_files:
- raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
- gcode_content = zf.read(gcode_files[0])
- return Response(content=gcode_content, media_type="text/plain")
- except zipfile.BadZipFile:
- raise HTTPException(status_code=400, detail="Invalid 3MF file")
- raise HTTPException(status_code=400, detail="Unsupported file type")
- @router.get("/{printer_id}/files/plates")
- async def get_printer_file_plates(
- printer_id: int,
- path: str = Query(..., description="Full path to the 3MF file on the printer"),
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """Get available plates from a multi-plate 3MF file stored on a printer."""
- import io
- import json
- import defusedxml.ElementTree as ET
- printer = await _load_printer_or_404(printer_id)
- filename = path.split("/")[-1]
- if not filename.lower().endswith(".3mf"):
- return {
- "printer_id": printer_id,
- "path": path,
- "filename": filename,
- "plates": [],
- "is_multi_plate": False,
- }
- data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
- if data is None:
- raise HTTPException(404, f"File not found: {path}")
- plates = []
- try:
- with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
- namelist = zf.namelist()
- # Find all plate gcode files to determine available plates
- gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
- # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG
- plate_indices: list[int] = []
- if gcode_files:
- for gf in gcode_files:
- try:
- plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
- plate_indices.append(int(plate_str))
- except ValueError:
- pass # Skip gcode files with non-numeric plate indices
- else:
- plate_json_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".json")]
- plate_png_files = [
- n
- for n in namelist
- if n.startswith("Metadata/plate_")
- and n.endswith(".png")
- and "_small" not in n
- and "no_light" not in n
- ]
- plate_name_candidates = plate_json_files + plate_png_files
- plate_re = re.compile(r"^Metadata/plate_(\d+)\.(json|png)$")
- seen_indices: set[int] = set()
- for name in plate_name_candidates:
- match = plate_re.match(name)
- if match:
- try:
- index = int(match.group(1))
- except ValueError:
- continue
- if index in seen_indices:
- continue
- seen_indices.add(index)
- plate_indices.append(index)
- if not plate_indices:
- return {
- "printer_id": printer_id,
- "path": path,
- "filename": filename,
- "plates": [],
- "is_multi_plate": False,
- }
- plate_indices.sort()
- # Parse model_settings.config for plate names
- plate_names = {}
- if "Metadata/model_settings.config" in namelist:
- try:
- model_content = zf.read("Metadata/model_settings.config").decode()
- model_root = ET.fromstring(model_content)
- for plate_elem in model_root.findall(".//plate"):
- plater_id = None
- plater_name = None
- for meta in plate_elem.findall("metadata"):
- key = meta.get("key")
- value = meta.get("value")
- if key == "plater_id" and value:
- try:
- plater_id = int(value)
- except ValueError:
- pass # Skip plate with unparseable ID
- elif key == "plater_name" and value:
- plater_name = value.strip()
- if plater_id is not None and plater_name:
- plate_names[plater_id] = plater_name
- except Exception:
- pass # Plate names are optional; continue without them
- # Parse slice_info.config for plate metadata
- plate_metadata = {}
- if "Metadata/slice_info.config" in namelist:
- content = zf.read("Metadata/slice_info.config").decode()
- root = ET.fromstring(content)
- for plate_elem in root.findall(".//plate"):
- plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
- plate_index = None
- for meta in plate_elem.findall("metadata"):
- key = meta.get("key")
- value = meta.get("value")
- if key == "index" and value:
- try:
- plate_index = int(value)
- except ValueError:
- pass # Skip plate with unparseable index
- elif key == "prediction" and value:
- try:
- plate_info["prediction"] = int(value)
- except ValueError:
- pass # Skip unparseable prediction; leave as None
- elif key == "weight" and value:
- try:
- plate_info["weight"] = float(value)
- except ValueError:
- pass # Skip unparseable weight; leave as None
- # Get filaments used in this plate
- for filament_elem in plate_elem.findall("filament"):
- filament_id = filament_elem.get("id")
- filament_type = filament_elem.get("type", "")
- filament_color = filament_elem.get("color", "")
- used_g = filament_elem.get("used_g", "0")
- used_m = filament_elem.get("used_m", "0")
- try:
- used_grams = float(used_g)
- except (ValueError, TypeError):
- used_grams = 0
- if used_grams > 0 and filament_id:
- plate_info["filaments"].append(
- {
- "slot_id": int(filament_id),
- "type": filament_type,
- "color": filament_color,
- "used_grams": round(used_grams, 1),
- "used_meters": float(used_m) if used_m else 0,
- }
- )
- plate_info["filaments"].sort(key=lambda x: x["slot_id"])
- # Collect object names
- for obj_elem in plate_elem.findall("object"):
- obj_name = obj_elem.get("name")
- if obj_name and obj_name not in plate_info["objects"]:
- plate_info["objects"].append(obj_name)
- # Set plate name
- if plate_index is not None:
- custom_name = plate_names.get(plate_index)
- if custom_name:
- plate_info["name"] = custom_name
- elif plate_info["objects"]:
- plate_info["name"] = plate_info["objects"][0]
- plate_metadata[plate_index] = plate_info
- # Parse plate_*.json for object lists when slice_info is missing
- plate_json_objects: dict[int, list[str]] = {}
- for name in namelist:
- match = re.match(r"^Metadata/plate_(\d+)\.json$", name)
- if not match:
- continue
- try:
- plate_index = int(match.group(1))
- except ValueError:
- continue
- try:
- payload = json.loads(zf.read(name).decode())
- bbox_objects = payload.get("bbox_objects", [])
- names: list[str] = []
- for obj in bbox_objects:
- obj_name = obj.get("name") if isinstance(obj, dict) else None
- if obj_name and obj_name not in names:
- names.append(obj_name)
- if names:
- plate_json_objects[plate_index] = names
- except Exception:
- continue
- # Build plate list
- for idx in plate_indices:
- meta = plate_metadata.get(idx, {})
- has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
- objects = meta.get("objects", [])
- if not objects:
- objects = plate_json_objects.get(idx, [])
- plate_name = meta.get("name")
- if not plate_name:
- plate_name = plate_names.get(idx)
- if not plate_name and objects:
- plate_name = objects[0]
- plates.append(
- {
- "index": idx,
- "name": plate_name,
- "objects": objects,
- "object_count": len(objects),
- "has_thumbnail": has_thumbnail,
- "thumbnail_url": f"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}",
- "print_time_seconds": meta.get("prediction"),
- "filament_used_grams": meta.get("weight"),
- "filaments": meta.get("filaments", []),
- }
- )
- except Exception as e:
- logger.warning("Failed to parse plates from printer file %s: %s", path, e)
- return {
- "printer_id": printer_id,
- "path": path,
- "filename": filename,
- "plates": plates,
- "is_multi_plate": len(plates) > 1,
- }
- @router.get("/{printer_id}/files/plate-thumbnail/{plate_index}")
- async def get_printer_file_plate_thumbnail(
- printer_id: int,
- plate_index: int,
- path: str = Query(..., description="Full path to the 3MF file on the printer"),
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """Get a plate thumbnail image from a printer-stored 3MF file."""
- import io
- printer = await _load_printer_or_404(printer_id)
- data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
- if data is None:
- raise HTTPException(404, f"File not found: {path}")
- try:
- with zipfile.ZipFile(io.BytesIO(data), "r") as zf:
- thumb_path = f"Metadata/plate_{plate_index}.png"
- if thumb_path in zf.namelist():
- image_data = zf.read(thumb_path)
- return Response(content=image_data, media_type="image/png")
- except Exception:
- pass # Corrupt or unreadable 3MF; fall through to 404
- raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
- @router.post("/{printer_id}/files/download-zip")
- async def download_printer_files_as_zip(
- printer_id: int,
- request: PrinterFilesDownloadRequest,
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """Download multiple files using a disk-backed ZIP.
- Kept backward-compatible for API clients: relative paths are rooted,
- duplicate paths receive collision-safe names, and an all-failed request
- returns an empty ZIP as the historical endpoint did. The browser uses the
- asynchronous preparation endpoints below.
- """
- if not request.paths:
- raise HTTPException(400, "No files specified")
- printer = await _load_printer_or_404(printer_id)
- normalized_paths = [path if path.startswith("/") else f"/{path}" for path in request.paths]
- normalized_sizes = {path if path.startswith("/") else f"/{path}": size for path, size in request.sizes.items()}
- try:
- async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
- result = await build_printer_files_zip(
- printer,
- normalized_paths,
- normalized_sizes,
- preserve_paths=False,
- allow_empty=True,
- )
- except PrinterFilesZipTooLargeError as exc:
- raise HTTPException(413, str(exc)) from exc
- except PrinterFilesZipInsufficientSpaceError as exc:
- raise HTTPException(507, str(exc)) from exc
- except TimeoutError as exc:
- raise HTTPException(504, "Printer ZIP preparation exceeded the 30-minute limit") from exc
- return FileResponse(
- path=result.path,
- filename="printer-files.zip",
- media_type="application/zip",
- headers={
- "X-Bambuddy-Files-Requested": str(result.requested),
- "X-Bambuddy-Files-Downloaded": str(result.successful),
- "X-Bambuddy-Files-Failed": str(len(result.failed_paths)),
- },
- background=BackgroundTask(remove_printer_files_zip, result.path),
- )
- @router.post("/{printer_id}/files/download-job")
- async def create_printer_files_download_job(
- printer_id: int,
- request: PrinterFilesJobRequest,
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """Start a cancellable disk-backed preparation without holding the request."""
- if not request.paths:
- raise HTTPException(400, "No files specified")
- if len(set(request.paths)) != len(request.paths):
- raise HTTPException(400, "Selected printer paths must be unique")
- if not request.as_zip and len(request.paths) != 1:
- raise HTTPException(400, "Native downloads require exactly one file")
- printer = await _load_printer_or_404(printer_id)
- try:
- status = await start_printer_files_job(
- printer,
- request.paths,
- request.sizes,
- request.filename,
- as_zip=request.as_zip,
- )
- except PrinterFilesZipTooLargeError as exc:
- raise HTTPException(413, str(exc)) from exc
- except PrinterFilesZipInsufficientSpaceError as exc:
- raise HTTPException(507, str(exc)) from exc
- except ValueError as exc:
- raise HTTPException(400, str(exc)) from exc
- return status.__dict__
- @router.get("/{printer_id}/files/download-jobs/{job_id}")
- async def get_printer_files_download_job(
- printer_id: int,
- job_id: str,
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- status = await get_printer_files_job(job_id, printer_id)
- if status is None:
- raise HTTPException(404, "Printer download job not found")
- return status.__dict__
- @router.delete("/{printer_id}/files/download-jobs/{job_id}")
- async def cancel_printer_files_download_job(
- printer_id: int,
- job_id: str,
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- if not await cancel_printer_files_job(job_id, printer_id):
- raise HTTPException(404, "Printer download job not found")
- return {"status": "cancelled"}
- @router.get("/{printer_id}/files/dl/{token}/{filename}")
- async def download_prepared_printer_files(
- printer_id: int,
- token: str,
- filename: str,
- ):
- """Consume a resource-bound token and stream a prepared file natively."""
- from backend.app.core.auth import verify_slicer_download_token
- if not await verify_slicer_download_token(token, "printer-files", printer_id):
- return download_error_response(403, "This download link has already been used or has expired.")
- zip_path = printer_files_zip_path(printer_id, token)
- raw_path = printer_file_path(printer_id, token)
- if zip_path is not None and await asyncio.to_thread(zip_path.is_file):
- prepared_path = zip_path
- media_type = "application/zip"
- elif raw_path is not None and await asyncio.to_thread(raw_path.is_file):
- prepared_path = raw_path
- media_type = "application/octet-stream"
- else:
- return download_error_response(404, "The prepared download is no longer on the server.")
- safe_filename = safe_download_filename(filename, fallback="printer-download")
- return FileResponse(
- path=prepared_path,
- filename=safe_filename,
- media_type=media_type,
- headers={"Content-Disposition": build_content_disposition(safe_filename)},
- background=BackgroundTask(remove_printer_files_zip, prepared_path),
- )
- @router.delete("/{printer_id}/files")
- async def delete_printer_file(
- printer_id: int,
- path: str,
- _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
- ):
- """Delete a file from the printer."""
- printer = await _load_printer_or_404(printer_id)
- from backend.app.services.bambu_ftp import DeleteResult
- result = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
- if result == DeleteResult.NOT_FOUND:
- raise HTTPException(404, f"File not found on printer: {path}")
- if result == DeleteResult.FAILED:
- raise HTTPException(500, f"Failed to delete file: {path}")
- return {"status": "deleted", "path": path}
- @router.get("/{printer_id}/storage")
- async def get_printer_storage(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- ):
- """Get storage information from the printer."""
- printer = await _load_printer_or_404(printer_id)
- storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
- return storage_info or {"used_bytes": None, "free_bytes": None}
- # ============================================
- # MQTT Debug Logging Endpoints
- # ============================================
- @router.post("/{printer_id}/logging/enable")
- async def enable_mqtt_logging(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Enable MQTT message logging for a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- success = printer_manager.enable_logging(printer_id, True)
- if not success:
- raise HTTPException(400, "Printer not connected")
- return {"logging_enabled": True}
- @router.post("/{printer_id}/logging/disable")
- async def disable_mqtt_logging(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Disable MQTT message logging for a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- success = printer_manager.enable_logging(printer_id, False)
- if not success:
- raise HTTPException(400, "Printer not connected")
- return {"logging_enabled": False}
- @router.get("/{printer_id}/logging")
- async def get_mqtt_logs(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get MQTT message logs for a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- logs = printer_manager.get_logs(printer_id)
- return {
- "logging_enabled": printer_manager.is_logging_enabled(printer_id),
- "logs": [
- {
- "timestamp": log.timestamp,
- "topic": log.topic,
- "direction": log.direction,
- "payload": log.payload,
- }
- for log in logs
- ],
- }
- @router.delete("/{printer_id}/logging")
- async def clear_mqtt_logs(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Clear MQTT message logs for a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- printer_manager.clear_logs(printer_id)
- return {"status": "cleared"}
- # ============================================
- # AMS Drying Endpoints
- # ============================================
- # The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
- # — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
- # (#2533). Refuse the command rather than let the caller believe it landed.
- _DRYING_SCREEN_ONLY_DETAIL = drying_preflight.SCREEN_ONLY_DETAIL
- @router.post("/{printer_id}/drying/start")
- async def start_drying(
- printer_id: int,
- ams_id: int,
- temp: int = 45,
- duration: int = 4,
- filament: str = "",
- rotate_tray: bool = False,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Send AMS drying start command. temp=45-85, duration=hours."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Server-side guard: reject if this model/firmware doesn't support drying
- live_state = printer_manager.get_status(printer_id)
- firmware = live_state.firmware_version if live_state else None
- unsupported = drying_preflight.check_drying_supported(printer.model, firmware)
- if unsupported:
- raise HTTPException(400, unsupported)
- if temp < 45 or temp > 85:
- raise HTTPException(400, "Temperature must be 45-85°C")
- if duration < 1 or duration > 24:
- raise HTTPException(400, "Duration must be 1-24 hours")
- # Inspect the live AMS unit: surface blocking dry_sf_reasons (otherwise the
- # firmware silently ignores the command — #971) and backfill an empty
- # filament field from the first loaded tray so the printer doesn't reject
- # the payload.
- target_ams = drying_preflight.find_ams_unit(live_state, ams_id)
- blocking = drying_preflight.blocking_reason_codes(target_ams)
- if blocking:
- # Same pick the scheduled path makes, so both describe one blocked AMS
- # the same way rather than differing on which code the firmware listed
- # first.
- raise HTTPException(
- 409, drying_preflight.DRY_SF_REASON_MESSAGES[drying_preflight.primary_reason_code(blocking)]
- )
- filament = drying_preflight.resolve_filament(target_ams, filament)
- success = printer_manager.send_drying_command(
- printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray
- )
- if not success:
- raise HTTPException(400, "Printer not connected")
- return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
- @router.post("/{printer_id}/drying/stop")
- async def stop_drying(
- printer_id: int,
- ams_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Send AMS drying stop command."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Screen-only models ignore stop just as they ignore start — a cycle running on a
- # P1S was started at the printer and has to be ended there too (#2533).
- if drying_screen_only(printer.model):
- raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
- success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
- if not success:
- raise HTTPException(400, "Printer not connected")
- # A cycle the user stopped by hand tells us nothing about whether drying can
- # move the humidity reading, so it must not count towards the auto-drying
- # suspension (#2770). Imported here rather than at module scope to keep the
- # existing routes/scheduler import direction.
- from backend.app.services.print_scheduler import scheduler as print_scheduler
- print_scheduler.forget_auto_dry_cycle(printer_id, ams_id)
- return {"status": "drying_stopped", "ams_id": ams_id}
- # ============================================
- # Print Options (AI Detection) Endpoints
- # ============================================
- @router.post("/{printer_id}/print-options")
- async def set_print_option(
- printer_id: int,
- module_name: str,
- enabled: bool,
- print_halt: bool = True,
- sensitivity: str = "medium",
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Set an AI detection / print option on the printer.
- Valid module_name values:
- - spaghetti_detector: Spaghetti detection
- - first_layer_inspector: First layer inspection
- - printing_monitor: AI print quality monitoring
- - buildplate_marker_detector: Build plate marker detection
- - allow_skip_parts: Allow skipping failed parts
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client or not client.state.connected:
- raise HTTPException(400, "Printer not connected")
- # Validate module_name
- valid_modules = [
- "spaghetti_detector",
- "first_layer_inspector",
- "printing_monitor",
- "buildplate_marker_detector",
- "allow_skip_parts",
- "pileup_detector",
- "clump_detector",
- "airprint_detector",
- "auto_recovery_step_loss",
- ]
- if module_name not in valid_modules:
- raise HTTPException(400, f"Invalid module_name. Must be one of: {valid_modules}")
- # Validate sensitivity
- valid_sensitivities = ["low", "medium", "high", "never_halt"]
- if sensitivity not in valid_sensitivities:
- raise HTTPException(400, f"Invalid sensitivity. Must be one of: {valid_sensitivities}")
- success = client.set_xcam_option(
- module_name=module_name,
- enabled=enabled,
- print_halt=print_halt,
- sensitivity=sensitivity,
- )
- if not success:
- raise HTTPException(500, "Failed to send command to printer")
- return {
- "success": True,
- "module_name": module_name,
- "enabled": enabled,
- "print_halt": print_halt,
- "sensitivity": sensitivity,
- }
- @router.post("/{printer_id}/ams-backup")
- async def set_ams_backup(
- printer_id: int,
- enabled: bool,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client or not client.state.connected:
- raise HTTPException(400, "Printer not connected")
- success = client.set_ams_filament_backup(enabled)
- if not success:
- raise HTTPException(500, "Failed to send command to printer")
- return {"success": True, "ams_filament_backup": enabled}
- @router.get("/{printer_id}/inventory-remain")
- async def get_inventory_remain(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Per-globalTrayId remaining grams for slots bound to an inventory spool.
- Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal
- client can apply the same two-tier "Prefer Lowest Remaining Filament" sort
- the dispatcher uses (#1766). Works for both internal inventory and
- Spoolman; unbound slots are absent from the map (client falls back to the
- printer's MQTT `remain` for those).
- `slot_materials` carries the same bindings with their material identity and
- extruder side attached, which is what the modal's pre-flight filament check
- needs to pool spools under AMS Filament Backup the way the dispatcher does.
- It is deliberately server-computed: the identity rule lives in
- `filament_deficit`, and a client-side reimplementation of it is exactly how
- the modal came to block prints the dispatcher would have accepted. Unlike
- `inventory_remain_g` it covers every binding, not just currently-loaded
- slots — again matching what the dispatcher pools.
- """
- from backend.app.services.filament_deficit import build_slot_materials
- from backend.app.services.print_scheduler import PrintScheduler
- state = printer_manager.get_status(printer_id)
- if not state:
- return {"inventory_remain_g": {}, "slot_materials": []}
- scheduler = PrintScheduler()
- loaded = scheduler._build_loaded_filaments(state)
- overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
- slot_materials = await build_slot_materials(db, printer_id)
- return {
- "inventory_remain_g": {str(k): v for k, v in overrides.items()},
- "slot_materials": [s.to_dict() for s in slot_materials],
- }
- # ============================================
- # Calibration
- # ============================================
- @router.post("/{printer_id}/calibration")
- async def start_calibration(
- printer_id: int,
- bed_leveling: bool = False,
- vibration: bool = False,
- motor_noise: bool = False,
- nozzle_offset: bool = False,
- high_temp_heatbed: bool = False,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Start printer calibration with selected options.
- At least one option must be selected.
- Options:
- - bed_leveling: Run bed leveling calibration
- - vibration: Run vibration compensation calibration
- - motor_noise: Run motor noise cancellation calibration
- - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)
- - high_temp_heatbed: Run high-temperature heatbed calibration
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client or not client.state.connected:
- raise HTTPException(400, "Printer not connected")
- # Check that at least one option is selected
- if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):
- raise HTTPException(400, "At least one calibration option must be selected")
- success = client.start_calibration(
- bed_leveling=bed_leveling,
- vibration=vibration,
- motor_noise=motor_noise,
- nozzle_offset=nozzle_offset,
- high_temp_heatbed=high_temp_heatbed,
- )
- if not success:
- raise HTTPException(500, "Failed to send calibration command to printer")
- return {
- "success": True,
- "bed_leveling": bed_leveling,
- "vibration": vibration,
- "motor_noise": motor_noise,
- "nozzle_offset": nozzle_offset,
- "high_temp_heatbed": high_temp_heatbed,
- }
- # ============================================================================
- # Slot Preset Mapping Endpoints
- # ============================================================================
- def _slot_preset_key(ams_id: int, tray_id: int) -> int:
- # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed
- # by ams_id since each unit has a single slot and shares its global ID with
- # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id.
- if 128 <= ams_id <= 135:
- return ams_id
- return ams_id * 4 + tray_id
- @router.get("/{printer_id}/slot-presets")
- async def get_slot_presets(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get all saved slot-to-preset mappings for a printer."""
- result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))
- mappings = result.scalars().all()
- return {
- _slot_preset_key(mapping.ams_id, mapping.tray_id): {
- "ams_id": mapping.ams_id,
- "tray_id": mapping.tray_id,
- "preset_id": mapping.preset_id,
- "preset_name": mapping.preset_name,
- }
- for mapping in mappings
- }
- @router.get("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
- async def get_slot_preset(
- printer_id: int,
- ams_id: int,
- tray_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get the saved preset for a specific slot."""
- result = await db.execute(
- select(SlotPresetMapping).where(
- SlotPresetMapping.printer_id == printer_id,
- SlotPresetMapping.ams_id == ams_id,
- SlotPresetMapping.tray_id == tray_id,
- )
- )
- mapping = result.scalar_one_or_none()
- if not mapping:
- return None
- return {
- "ams_id": mapping.ams_id,
- "tray_id": mapping.tray_id,
- "preset_id": mapping.preset_id,
- "preset_name": mapping.preset_name,
- }
- @router.put("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
- async def save_slot_preset(
- printer_id: int,
- ams_id: int,
- tray_id: int,
- preset_id: str,
- preset_name: str,
- preset_source: str = "cloud",
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Save a preset mapping for a specific slot."""
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- if not result.scalar_one_or_none():
- raise HTTPException(404, "Printer not found")
- # Check for existing mapping
- result = await db.execute(
- select(SlotPresetMapping).where(
- SlotPresetMapping.printer_id == printer_id,
- SlotPresetMapping.ams_id == ams_id,
- SlotPresetMapping.tray_id == tray_id,
- )
- )
- mapping = result.scalar_one_or_none()
- if mapping:
- # Update existing
- mapping.preset_id = preset_id
- mapping.preset_name = preset_name
- mapping.preset_source = preset_source
- else:
- # Create new
- mapping = SlotPresetMapping(
- printer_id=printer_id,
- ams_id=ams_id,
- tray_id=tray_id,
- preset_id=preset_id,
- preset_name=preset_name,
- preset_source=preset_source,
- )
- db.add(mapping)
- await db.commit()
- await db.refresh(mapping)
- return {
- "ams_id": mapping.ams_id,
- "tray_id": mapping.tray_id,
- "preset_id": mapping.preset_id,
- "preset_name": mapping.preset_name,
- "preset_source": mapping.preset_source,
- }
- @router.delete("/{printer_id}/slot-presets/{ams_id}/{tray_id}")
- async def delete_slot_preset(
- printer_id: int,
- ams_id: int,
- tray_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Delete a saved preset mapping for a slot."""
- result = await db.execute(
- select(SlotPresetMapping).where(
- SlotPresetMapping.printer_id == printer_id,
- SlotPresetMapping.ams_id == ams_id,
- SlotPresetMapping.tray_id == tray_id,
- )
- )
- mapping = result.scalar_one_or_none()
- if mapping:
- await db.delete(mapping)
- await db.commit()
- return {"success": True}
- @router.get("/{printer_id}/slots/{ams_id}/{tray_id}/spool-defaults")
- async def get_slot_spool_defaults(
- printer_id: int,
- ams_id: int,
- tray_id: int,
- db: AsyncSession = Depends(get_db),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- ):
- """What the spool assigned to this slot is configured to use here.
- The Configure AMS Slot dialog opens on a slot that usually already holds an
- assigned spool, and that spool carries a filament preset per printer model
- and a K profile per hotend. Without this the dialog offered defaults derived
- from the slot's last manual configuration or from the tray's RFID data --
- ignoring the very values the spool was configured with, on the one screen
- that looks like it exists for them.
- Everything is resolved for the nozzle THIS slot feeds, so a dual-nozzle
- machine gets the answer for the correct hotend. Returns nulls rather than a
- 404 when the slot holds no known spool: "nothing configured" is an ordinary
- answer here and the dialog falls back to what it did before.
- """
- from backend.app.models.spool import Spool
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services.inventory_mode import spoolman_owns_assignments
- from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
- from backend.app.services.slot_nozzle import resolve_slot_nozzle
- from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
- state = printer_manager.get_status(printer_id)
- model = printer_manager.get_model(printer_id)
- slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, model)
- profile = await find_slot_kprofile_for_extruder(
- db,
- printer_id,
- ams_id,
- tray_id,
- slot_nozzle.extruder_or_default,
- slot_nozzle.diameter,
- model,
- slot_nozzle.flow,
- )
- slicer_filament: str | None = None
- slicer_filament_name: str | None = None
- spoolman_mode = await spoolman_owns_assignments(db)
- if spoolman_mode:
- sm_assignment = (
- await db.execute(
- select(SpoolmanSlotAssignment).where(
- SpoolmanSlotAssignment.printer_id == printer_id,
- SpoolmanSlotAssignment.ams_id == ams_id,
- SpoolmanSlotAssignment.tray_id == tray_id,
- )
- )
- ).scalar_one_or_none()
- if sm_assignment is not None:
- slicer_filament, slicer_filament_name = await resolve_spoolman_preset(
- db,
- spoolman_spool_id=sm_assignment.spoolman_spool_id,
- printer_model=model,
- nozzle_diameter=slot_nozzle.diameter,
- fallback_filament=None,
- fallback_name=None,
- )
- else:
- assignment = (
- await db.execute(
- select(SpoolAssignment).where(
- SpoolAssignment.printer_id == printer_id,
- SpoolAssignment.ams_id == ams_id,
- SpoolAssignment.tray_id == tray_id,
- )
- )
- ).scalar_one_or_none()
- if assignment is not None:
- spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
- if spool is not None:
- slicer_filament, slicer_filament_name = await resolve_spool_preset(
- db,
- spool_id=spool.id,
- printer_model=model,
- nozzle_diameter=slot_nozzle.diameter,
- fallback_filament=spool.slicer_filament,
- fallback_name=spool.slicer_filament_name,
- )
- return {
- "slicer_filament": slicer_filament,
- "slicer_filament_name": slicer_filament_name,
- "cali_idx": profile.cali_idx if profile else None,
- "k_value": profile.k_value if profile else None,
- "profile_name": profile.name if profile else None,
- "extruder": slot_nozzle.extruder,
- "nozzle_diameter": slot_nozzle.diameter,
- }
- @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
- async def configure_ams_slot(
- printer_id: int,
- ams_id: int,
- tray_id: int,
- tray_info_idx: str = Query(...),
- tray_type: str = Query(...),
- tray_sub_brands: str = Query(...),
- tray_color: str = Query(...),
- nozzle_temp_min: int = Query(...),
- nozzle_temp_max: int = Query(...),
- cali_idx: int = Query(-1),
- nozzle_diameter: str = Query("0.4"),
- setting_id: str = Query(""),
- kprofile_filament_id: str = Query(""),
- kprofile_setting_id: str = Query(""),
- k_value: float = Query(0.0),
- db: AsyncSession = Depends(get_db),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- ):
- """Configure an AMS slot with a specific filament setting and K profile.
- This sends two commands to the printer:
- 1. ams_filament_setting - sets filament type, color, temperature
- 2. extrusion_cali_sel - sets the K profile (pressure advance value)
- Args:
- printer_id: Database ID of the printer
- ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
- tray_id: Tray ID within the AMS (0-3)
- tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
- tray_type: Filament type (e.g., "PLA", "PETG")
- tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
- tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
- nozzle_temp_min: Minimum nozzle temperature
- nozzle_temp_max: Maximum nozzle temperature
- cali_idx: K profile calibration index (-1 for default 0.020)
- nozzle_diameter: Nozzle diameter string (e.g., "0.4")
- setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
- kprofile_filament_id: K profile's filament_id for proper K profile linking
- k_value: Direct K value to set (0.0 to skip direct K value setting)
- """
- logger = logging.getLogger(__name__)
- logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
- logger.info(
- f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
- )
- logger.info(
- f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
- )
- # The modal derives tray_type from a preset name or a spool's material, so
- # it can be a product line rather than a type ("PLA+", "PolyTerra PLA").
- # A slot carrying one of those satisfies nothing that asks for PLA, so the
- # slot gets the type and tray_sub_brands -- untouched here -- keeps the
- # name (issue #2902). The requested wording is kept for the id lookup
- # below, which knows some product lines the type table does not.
- requested_tray_type = tray_type
- tray_type = printer_filament_type(tray_type)
- if tray_type != requested_tray_type:
- logger.info("[configure_ams_slot] tray_type %r → %r", requested_tray_type, tray_type)
- # Get MQTT client for this printer
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(status_code=400, detail="Printer not connected")
- # Resolve tray_info_idx for the MQTT command.
- # Priority:
- # 1. Use the provided tray_info_idx if set (including cloud-synced
- # custom presets like PFUS* / P*).
- # 2. Reuse the slot's existing tray_info_idx if it's a specific
- # (non-generic) preset for the same material.
- # 3. Fall back to a generic Bambu filament ID.
- _GENERIC_FILAMENT_IDS = {
- "PLA": "GFL99",
- "PETG": "GFG99",
- "ABS": "GFB99",
- "ASA": "GFB98",
- "PC": "GFC99",
- "PA": "GFN99",
- "NYLON": "GFN99",
- "TPU": "GFU99",
- "PVA": "GFS99",
- "HIPS": "GFS98",
- "PLA-CF": "GFL98",
- "PETG-CF": "GFG98",
- "PA-CF": "GFN98",
- "PETG HF": "GFG96",
- }
- _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values())
- effective_tray_info_idx = tray_info_idx
- if not tray_info_idx:
- # No preset provided — try slot reuse or generic fallback
- current_tray_info_idx = ""
- current_tray_type = ""
- state = printer_manager.get_status(printer_id)
- if state and state.raw_data:
- from backend.app.api.routes.inventory import _find_tray_in_ams_data
- if ams_id == 255:
- vt_tray = state.raw_data.get("vt_tray") or []
- ext_id = tray_id + 254
- for vt in vt_tray:
- if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
- current_tray_info_idx = vt.get("tray_info_idx", "")
- current_tray_type = vt.get("tray_type", "")
- break
- else:
- ams_data = state.raw_data.get("ams", {})
- ams_list = (
- ams_data.get("ams", [])
- if isinstance(ams_data, dict)
- else ams_data
- if isinstance(ams_data, list)
- else []
- )
- cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
- if cur_tray:
- current_tray_info_idx = cur_tray.get("tray_info_idx", "")
- current_tray_type = cur_tray.get("tray_type", "")
- if (
- current_tray_info_idx
- and current_tray_info_idx not in _GENERIC_ID_VALUES
- and current_tray_type
- and current_tray_type.upper() == tray_type.upper()
- ):
- logger.info(
- "[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
- current_tray_info_idx,
- tray_type,
- )
- effective_tray_info_idx = current_tray_info_idx
- elif tray_type:
- # Requested wording first, reduced type only as a further fallback,
- # so a material that already resolves keeps resolving to the same
- # id: "PETG HF" has its own generic preset (GFG96) that reducing it
- # to "PETG" would trade away for GFG99.
- material = requested_tray_type.upper().strip()
- generic = (
- _GENERIC_FILAMENT_IDS.get(material)
- or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
- or _GENERIC_FILAMENT_IDS.get(tray_type.upper())
- or ""
- )
- if generic:
- logger.info("[configure_ams_slot] Falling back to generic %r for material %r", generic, tray_type)
- effective_tray_info_idx = generic
- # Send filament setting + K-profile commands
- filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
- # Realign the slot's filament context to the K-profile's calibration
- # context. The printer's calibration table is keyed by (filament_id,
- # cali_idx) — so for the cali_idx selected via extrusion_cali_sel to
- # actually stick to the slot, ams_filament_setting must declare the
- # slot under the SAME filament_id.
- #
- # Without this, configure_ams_slot would send:
- # ams_filament_setting → tray_info_idx=GFL99 (generic from material)
- # extrusion_cali_sel → filament_id=P4d64437 (kp's preset)
- # ...and the cali_idx would silently be dropped to default because the
- # slot's filament context (GFL99) doesn't match the kp's (P4d64437).
- #
- # This realignment fires only when the kp is targeted at a different
- # preset than the user's filament selection AND the kp's preset is a
- # valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user
- # which the slicer rejects in tray_info_idx).
- effective_setting_id = setting_id
- if (
- kprofile_filament_id
- and kprofile_filament_id != effective_tray_info_idx
- and not kprofile_filament_id.startswith("PFUS")
- ):
- logger.info(
- "[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r",
- effective_tray_info_idx,
- kprofile_filament_id,
- setting_id,
- kprofile_setting_id or setting_id,
- )
- effective_tray_info_idx = kprofile_filament_id
- if kprofile_setting_id:
- effective_setting_id = kprofile_setting_id
- # Back-fill setting_id from the resolved filament id when the client sent
- # none. Built-in / local / Orca-generic presets in the Configure AMS Slot
- # modal leave setting_id empty (they carry only a GF* tray_info_idx), and
- # the printer treats a filament-id-without-setting-id slot as half
- # configured: it shows the new material briefly, then reverts to its
- # previously stored profile (#2604). This mirrors the derivation the
- # inventory/assignment path already does (inventory.py). filament_id_to_
- # setting_id leaves P* user presets and already-GFS* values unchanged, so
- # only the empty-setting_id generic paths are affected.
- if effective_tray_info_idx and not effective_setting_id:
- effective_setting_id = filament_id_to_setting_id(effective_tray_info_idx)
- # Always send ams_set_filament_setting — the user explicitly clicked
- # "Configure Slot", so honor that. Previous versions skipped this for
- # RFID-tagged slots to preserve the slicer eye icon, but printers cache
- # stale tag_uid/tray_uuid after a BL spool is removed, causing the check
- # to false-positive on non-RFID slots and silently drop the command.
- success = client.ams_set_filament_setting(
- ams_id=ams_id,
- tray_id=tray_id,
- tray_info_idx=effective_tray_info_idx,
- tray_type=tray_type,
- tray_sub_brands=tray_sub_brands,
- tray_color=tray_color,
- nozzle_temp_min=nozzle_temp_min,
- nozzle_temp_max=nozzle_temp_max,
- setting_id=effective_setting_id,
- )
- if not success:
- raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
- # Method 1: Select existing calibration profile by cali_idx
- # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
- # and including it causes the firmware to mislink the profile on X1C/P1S.
- client.extrusion_cali_sel(
- ams_id=ams_id,
- tray_id=tray_id,
- cali_idx=cali_idx,
- filament_id=filament_id_for_kprofile,
- nozzle_diameter=nozzle_diameter,
- )
- # Method 2: Only send extrusion_cali_set when NO existing profile was selected
- # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the
- # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY
- # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
- # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
- # hardcoded extruder_id=0 and nozzle_id=HS00.
- if k_value > 0 and cali_idx < 0:
- # Calculate global tray ID for extrusion_cali_set
- if ams_id <= 3:
- global_tray_id = ams_id * 4 + tray_id
- elif ams_id >= 128 and ams_id <= 135:
- global_tray_id = (ams_id - 128) * 4 + tray_id
- else:
- global_tray_id = tray_id
- client.extrusion_cali_set(
- tray_id=global_tray_id,
- k_value=k_value,
- nozzle_diameter=nozzle_diameter,
- nozzle_temp=nozzle_temp_max,
- filament_id=filament_id_for_kprofile,
- setting_id=kprofile_setting_id or "",
- name=tray_sub_brands or "",
- cali_idx=cali_idx,
- )
- # Persist the user's K-profile choice so it survives RFID re-reads and
- # session restarts. Pre-Phase-13 this was ephemeral — the MQTT command
- # took effect on the printer but bambuddy never recorded it, so the next
- # `_apply_pa_after_refresh` cycle had no stored profile to re-assert.
- if cali_idx >= 0:
- try:
- from sqlalchemy.orm import selectinload
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spool_k_profile import SpoolKProfile
- from backend.app.models.spoolman_k_profile import SpoolmanKProfile
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- # Resolve the slot's extruder for the K-profile match key. On a
- # Filament Track Switch machine this comes from the AMS's inlet
- # binding, because every unit reports extruder 0xE there — without
- # that, the `else 0` below filed every profile under the right-hand
- # nozzle and a left-nozzle calibration was stored as a right one.
- slot_state = printer_manager.get_status(printer_id)
- resolved_extruder = slot_extruder(
- ams_id,
- tray_id,
- slot_state.ams_extruder_map if slot_state else None,
- slot_state.ams_switch_inlet if slot_state else None,
- )
- # Still 0 when nothing is known, which is right for a single-nozzle
- # printer — the resolver only returns None when it genuinely cannot
- # tell, and on those machines extruder 0 is the only one there is.
- kp_extruder = resolved_extruder if resolved_extruder is not None else 0
- # Only the active mode's assignment table decides where this
- # K-profile is stored. Reading Spoolman first and falling through
- # was safe while the inactive table was emptied on every mode
- # toggle; nothing is emptied since #2812, so a leftover Spoolman
- # row in built-in mode would file the calibration against a spool
- # the printer is not using and never write the local profile —
- # the calibration would appear to succeed and then not apply.
- from backend.app.services.inventory_mode import spoolman_owns_assignments
- spoolman_mode = await spoolman_owns_assignments(db)
- sm_assignment = None
- if spoolman_mode:
- # Spoolman SlotAssignment — has UniqueConstraint, idempotent.
- sm_result = await db.execute(
- select(SpoolmanSlotAssignment).where(
- SpoolmanSlotAssignment.printer_id == printer_id,
- SpoolmanSlotAssignment.ams_id == ams_id,
- SpoolmanSlotAssignment.tray_id == tray_id,
- )
- )
- sm_assignment = sm_result.scalar_one_or_none()
- if sm_assignment:
- existing = await db.execute(
- select(SpoolmanKProfile).where(
- SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
- SpoolmanKProfile.printer_id == printer_id,
- SpoolmanKProfile.extruder == kp_extruder,
- SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
- )
- )
- kp = existing.scalar_one_or_none()
- if kp:
- kp.cali_idx = cali_idx
- kp.k_value = k_value or 0.0
- kp.setting_id = kprofile_setting_id or None
- kp.name = tray_sub_brands or None
- else:
- db.add(
- SpoolmanKProfile(
- spoolman_spool_id=sm_assignment.spoolman_spool_id,
- printer_id=printer_id,
- extruder=kp_extruder,
- nozzle_diameter=nozzle_diameter,
- k_value=k_value or 0.0,
- name=tray_sub_brands or None,
- cali_idx=cali_idx,
- setting_id=kprofile_setting_id or None,
- )
- )
- await db.commit()
- logger.info(
- "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
- sm_assignment.spoolman_spool_id,
- printer_id,
- ams_id,
- tray_id,
- cali_idx,
- )
- elif not spoolman_mode:
- # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first()).
- # Skipped in Spoolman mode even when a local row survives: the
- # profile would be filed against a spool this printer is not
- # drawing on, and the mode's own table has nothing to bind to.
- local_result = await db.execute(
- select(SpoolAssignment)
- .options(selectinload(SpoolAssignment.spool))
- .where(
- SpoolAssignment.printer_id == printer_id,
- SpoolAssignment.ams_id == ams_id,
- SpoolAssignment.tray_id == tray_id,
- )
- )
- local_assignment = local_result.scalar_one_or_none()
- if local_assignment and local_assignment.spool:
- existing = await db.execute(
- select(SpoolKProfile).where(
- SpoolKProfile.spool_id == local_assignment.spool.id,
- SpoolKProfile.printer_id == printer_id,
- SpoolKProfile.extruder == kp_extruder,
- SpoolKProfile.nozzle_diameter == nozzle_diameter,
- )
- )
- # SpoolKProfile has no unique constraint on this tuple, so
- # multiple rows could theoretically exist (shouldn't, but
- # don't crash if they do). Update the first match, leave
- # any duplicates alone.
- kp = existing.scalars().first()
- if kp:
- kp.cali_idx = cali_idx
- kp.k_value = k_value or 0.0
- kp.setting_id = kprofile_setting_id or None
- kp.name = tray_sub_brands or None
- else:
- db.add(
- SpoolKProfile(
- spool_id=local_assignment.spool.id,
- printer_id=printer_id,
- extruder=kp_extruder,
- nozzle_diameter=nozzle_diameter,
- k_value=k_value or 0.0,
- name=tray_sub_brands or None,
- cali_idx=cali_idx,
- setting_id=kprofile_setting_id or None,
- )
- )
- await db.commit()
- logger.info(
- "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
- local_assignment.spool.id,
- printer_id,
- ams_id,
- tray_id,
- cali_idx,
- )
- except Exception:
- # MQTT command was already sent successfully — DB persist is best-effort.
- logger.exception(
- "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)",
- printer_id,
- ams_id,
- tray_id,
- cali_idx,
- )
- try:
- await db.rollback()
- except Exception:
- pass
- # Register a read-back verification (#2582) so the tray telemetry that the
- # status push below returns can confirm the printer accepted this manual
- # slot configuration. Mirrors the inventory/assignment path.
- client.register_assignment_verification(
- ams_id=ams_id,
- tray_id=tray_id,
- tray_info_idx=effective_tray_info_idx,
- tray_color=tray_color,
- cali_idx=cali_idx,
- )
- # Request fresh status push from printer so frontend gets updated data via WebSocket
- logger.info("[configure_ams_slot] Requesting status update from printer")
- update_result = client.request_status_update()
- logger.info("[configure_ams_slot] Status update request result: %s", update_result)
- return {
- "success": True,
- "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
- }
- @router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
- async def reset_ams_slot(
- printer_id: int,
- ams_id: int,
- tray_id: int,
- db: AsyncSession = Depends(get_db),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- ):
- """Reset an AMS slot to empty/unconfigured state.
- This clears the filament configuration from the slot.
- """
- # Get MQTT client for this printer
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(status_code=400, detail="Printer not connected")
- # Reset the slot
- success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
- if not success:
- raise HTTPException(status_code=500, detail="Failed to send reset command")
- # Also delete any saved slot preset mapping
- result = await db.execute(
- select(SlotPresetMapping).where(
- SlotPresetMapping.printer_id == printer_id,
- SlotPresetMapping.ams_id == ams_id,
- SlotPresetMapping.tray_id == tray_id,
- )
- )
- mapping = result.scalar_one_or_none()
- if mapping:
- await db.delete(mapping)
- await db.commit()
- # Request fresh status push from printer so frontend gets updated data via WebSocket
- client.request_status_update()
- return {
- "success": True,
- "message": f"Reset AMS {ams_id} tray {tray_id}",
- }
- @router.get("/{printer_id}/ams-labels")
- async def get_ams_labels(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
- Labels are stored by AMS serial number. This endpoint resolves the current
- serial-to-ams_id mapping from the live printer state so the response is still
- keyed by ams_id for UI compatibility.
- """
- # Build serial -> ams_id map from live printer state
- serial_to_ams_id: dict[str, int] = {}
- state = printer_manager.get_status(printer_id)
- if state and state.raw_data:
- for ams_unit in state.raw_data.get("ams", []):
- sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
- if sn:
- serial_to_ams_id[sn] = int(ams_unit.get("id", 0))
- # Collect all known serials for this printer (live + synthetic fallback keys)
- serials_to_query = set(serial_to_ams_id.keys())
- # Fetch labels for all known serials
- labels: dict[int, str] = {}
- if serials_to_query:
- result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)))
- for lbl in result.scalars().all():
- aid = serial_to_ams_id.get(lbl.ams_serial_number)
- if aid is not None:
- labels[aid] = lbl.label
- # Also fetch labels stored under synthetic keys for this printer (backward compat)
- # Collect all synthetic keys first, then query with a single IN clause.
- if state and state.raw_data:
- synthetic_key_to_aid: dict[str, int] = {
- f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0))
- for ams_unit in state.raw_data.get("ams", [])
- if int(ams_unit.get("id", 0)) not in labels
- }
- if synthetic_key_to_aid:
- result = await db.execute(
- select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys()))
- )
- for lbl in result.scalars().all():
- aid = synthetic_key_to_aid.get(lbl.ams_serial_number)
- if aid is not None:
- labels[aid] = lbl.label
- return labels
- @router.put("/{printer_id}/ams-labels/{ams_id}")
- async def save_ams_label(
- printer_id: int,
- ams_id: int,
- body: AmsLabelBody,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Create or update the friendly name for a specific AMS unit.
- When ``ams_serial`` is provided the label is stored under that serial number so
- it survives the AMS being moved to a different printer. When it is absent (e.g.
- older firmware that does not report a serial) a synthetic key based on the
- printer_id and ams_id is used as a fallback.
- """
- # Verify printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- if not result.scalar_one_or_none():
- raise HTTPException(404, "Printer not found")
- # Determine the serial key to store under
- stripped = body.ams_serial.strip() if body.ams_serial else ""
- serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
- result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
- existing = result.scalar_one_or_none()
- if existing:
- existing.label = body.label
- existing.ams_id = ams_id
- else:
- db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))
- await db.commit()
- return {"ams_id": ams_id, "label": body.label}
- @router.delete("/{printer_id}/ams-labels/{ams_id}")
- async def delete_ams_label(
- printer_id: int,
- ams_id: int,
- ams_serial: str = Query(default="", max_length=50),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
- stripped = ams_serial.strip() if ams_serial else ""
- serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
- result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
- existing = result.scalar_one_or_none()
- if existing:
- await db.delete(existing)
- await db.commit()
- return {"success": True}
- @router.post("/{printer_id}/debug/simulate-print-complete")
- async def debug_simulate_print_complete(
- printer_id: int,
- db: AsyncSession = Depends(get_db),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- ):
- """DEBUG: Simulate print completion to test freeze behavior.
- This triggers the same code path as a real print completion,
- without needing to wait for an actual print to finish.
- """
- from backend.app.main import _active_prints, on_print_complete
- from backend.app.models.archive import PrintArchive
- # Get the most recent archive for this printer
- result = await db.execute(
- select(PrintArchive)
- .where(PrintArchive.printer_id == printer_id)
- .order_by(PrintArchive.created_at.desc())
- .limit(1)
- )
- archive = result.scalar_one_or_none()
- if not archive:
- raise HTTPException(status_code=404, detail="No archives found for this printer")
- # Register this archive as "active" so on_print_complete can find it
- filename = archive.file_path.split("/")[-1] if archive.file_path else "test.3mf"
- subtask_name = archive.print_name or "Test Print"
- _active_prints[(printer_id, filename)] = archive.id
- _active_prints[(printer_id, subtask_name)] = archive.id
- # Simulate print completion data
- data = {
- "status": "completed",
- "filename": filename,
- "subtask_name": subtask_name,
- "timelapse_was_active": False,
- }
- logger.info("Simulating print complete for printer %s, archive %s", printer_id, archive.id)
- # Call the actual on_print_complete handler
- await on_print_complete(printer_id, data)
- return {"success": True, "archive_id": archive.id, "message": "Print completion simulated"}
- # =============================================================================
- # Print Control Endpoints
- # =============================================================================
- @router.post("/{printer_id}/print/stop")
- async def stop_print(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Stop/cancel the current print job."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.stop_print()
- if not success:
- raise HTTPException(500, "Failed to stop print")
- # Mark this printer as user-stopped so on_print_complete reclassifies
- # the resulting "failed"/"aborted" MQTT status as "cancelled" — otherwise
- # the HMS heuristic in _dispatch_archive_update mislabels user-cancels
- # (e.g. the H2D's cancel-sequence module-0x0C HMS) as "Layer shift".
- try:
- from backend.app.main import mark_printer_stopped_by_user
- mark_printer_stopped_by_user(printer_id)
- except Exception as _mark_err:
- logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
- return {"success": True, "message": "Print stop command sent"}
- @router.post("/{printer_id}/clear-plate")
- async def clear_plate(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
- db: AsyncSession = Depends(get_db),
- ):
- """Acknowledge that the build plate has been cleared after a finished/failed print.
- Sets a plate-cleared flag so the scheduler can start the next queued print.
- No MQTT command is sent to the printer — the scheduler's start_print command
- will override the FINISH/FAILED state when it sends the next job.
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Deliberately NOT gated on the printer being connected. Acknowledging the plate
- # only mutates Bambuddy-side state — no MQTT command is sent — and with Auto Power
- # Off the normal end-of-print state is exactly this: gate up, printer powered down.
- # The guard this replaces was inherited from the sibling stop/pause/resume handlers,
- # where reaching the printer IS required, and left farms with no way to release the
- # gate short of powering each printer back on by hand (#2864).
- # Accept the acknowledgment whenever the printer is awaiting it — not only when the
- # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE
- # but the awaiting flag persists, and the user still needs a way to ack it (#961).
- state = printer_manager.get_status(printer_id)
- awaiting = printer_manager.is_awaiting_plate_clear(printer_id)
- if not awaiting and (not state or state.state not in ("FINISH", "FAILED")):
- raise HTTPException(
- 400,
- f"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})",
- )
- printer_manager.set_awaiting_plate_clear(printer_id, False)
- return {"success": True, "message": "Plate cleared, next print will start shortly"}
- @router.post("/{printer_id}/print/pause")
- async def pause_print(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Pause the current print job."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.pause_print()
- if not success:
- raise HTTPException(500, "Failed to pause print")
- return {"success": True, "message": "Print pause command sent"}
- @router.post("/{printer_id}/print/resume")
- async def resume_print(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Resume a paused print job."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.resume_print()
- if not success:
- raise HTTPException(500, "Failed to resume print")
- return {"success": True, "message": "Print resume command sent"}
- @router.post("/{printer_id}/print-speed")
- async def set_print_speed(
- printer_id: int,
- mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Set the print speed mode."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.set_print_speed(mode)
- if not success:
- raise HTTPException(500, "Failed to set print speed")
- speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
- return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
- @router.post("/{printer_id}/temperature/nozzle")
- async def set_nozzle_temperature(
- printer_id: int,
- target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
- nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Set a nozzle target temperature."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.set_nozzle_temperature(target, nozzle)
- if not success:
- raise HTTPException(500, "Failed to set nozzle temperature")
- return {"success": True, "message": f"Nozzle temperature set to {target}°C"}
- @router.post("/{printer_id}/temperature/bed")
- async def set_bed_temperature(
- printer_id: int,
- target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Set the bed target temperature."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.set_bed_temperature(target)
- if not success:
- raise HTTPException(500, "Failed to set bed temperature")
- return {"success": True, "message": f"Bed temperature set to {target}°C"}
- @router.post("/{printer_id}/temperature/chamber")
- async def set_chamber_temperature(
- printer_id: int,
- target: int = Query(
- ...,
- ge=0,
- le=MAX_CHAMBER_TEMP_C,
- description="Target chamber temperature in Celsius; 0 turns heating off",
- ),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Set the chamber target temperature.
- Gated on `supports_chamber_heater(model)`: only H2C, H2D, H2D Pro, H2S,
- and X2D have an active chamber heater. Sensor-only models (X1C, X1E,
- P2S) report chamber temp but silently swallow M141, so we 400 here
- rather than send a no-op.
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- if not supports_chamber_heater(printer.model):
- raise HTTPException(400, f"Model {printer.model or 'unknown'} does not have an active chamber heater")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.set_chamber_temperature(target)
- if not success:
- raise HTTPException(500, "Failed to set chamber temperature")
- return {"success": True, "message": f"Chamber temperature set to {target}°C"}
- @router.post("/{printer_id}/fan-speed")
- async def set_fan_speed(
- printer_id: int,
- fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
- speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Set a fan speed by percentage.
- Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
- P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
- profile gcode does. It only exists when the printer reports airduct part 10,
- so the request is rejected rather than sending M106 P10 into the void on a
- machine that has no such fan.
- That gate also rejects for the short window between connecting and the
- first airduct push, when nothing is known about the fan yet. The card hides
- the badge over the same window, so there is no control to click; a direct
- API caller gets a 400 and should retry once the status reports the fan.
- """
- fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
- fan_id = fan_ids.get(fan)
- if fan_id is None:
- raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'")
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- # Presence gate for the accessory fan. Without this, aux2 is accepted for
- # every model and an A1 would be sent M106 P10 for a fan it does not have.
- # The UI already hides the badge; this closes the same hole on the API.
- if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
- raise HTTPException(
- 400,
- "This printer does not report a left auxiliary fan "
- "(no airduct part 10). The fan is an accessory kit on the P2S "
- "and factory-fitted on the X2D.",
- )
- pwm_speed = round(speed * 255 / 100)
- success = client.set_fan_speed(fan_id, pwm_speed)
- if not success:
- raise HTTPException(500, "Failed to set fan speed")
- # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
- # match whatever the printer card badge shows so the toast agrees with the
- # control the user just clicked.
- fan_names = {
- "part": "Part cooling fan",
- "aux": "Auxiliary fan",
- "aux2": "Left auxiliary fan",
- "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
- }
- return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
- @router.post("/{printer_id}/select-extruder")
- async def select_extruder(
- printer_id: int,
- extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Select the active extruder/nozzle on dual-nozzle printers."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.select_extruder(extruder)
- if not success:
- raise HTTPException(500, "Failed to select nozzle")
- return {"success": True, "message": f"{'Left' if extruder == 1 else 'Right'} nozzle selected"}
- @router.post("/{printer_id}/airduct-mode")
- async def set_airduct_mode(
- printer_id: int,
- mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
- if mode not in ("cooling", "heating"):
- raise HTTPException(400, "Mode must be 'cooling' or 'heating'")
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.set_airduct_mode(mode)
- if not success:
- raise HTTPException(500, "Failed to set airduct mode")
- return {"success": True, "message": f"Airduct mode set to {mode}"}
- @router.post("/{printer_id}/chamber-light")
- async def set_chamber_light(
- printer_id: int,
- on: bool = Query(..., description="True to turn on, False to turn off"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Turn the chamber light on or off."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.set_chamber_light(on)
- if not success:
- raise HTTPException(500, "Failed to control chamber light")
- return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
- @router.post("/{printer_id}/bed-jog")
- async def bed_jog(
- printer_id: int,
- distance: float = Query(
- ...,
- description=(
- "Signed nozzle-bed gap adjustment in mm. Negative = decrease gap "
- '("up" arrow in the UI: bed up on bed-on-Z models, toolhead down '
- "on A1 bed-slingers). Positive = increase gap. The backend "
- "translates this into the right G-code Z sign per printer model."
- ),
- ),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Adjust the nozzle-bed gap by a relative distance.
- Emits a short G-code sequence via MQTT.
- Soft-endstop policy (#2579). The printer's software travel limits are the
- only thing between a jog button and a bed crash — on Bambu machines the
- physical endstops are homing-only (there is no runtime limit switch in the
- travel path), so once they are disabled nothing stops the move. The old
- code disabled them (``M211 S0``) around every forced jog, and the UI sent
- ``force`` on every jog, so the limits were off on every bed move — that is
- what let a jog drive the nozzle into the bed on all models (#2579). This
- endpoint now emits a **bare relative move and never touches ``M211`` at
- all** — byte-for-byte what the printer's own touchscreen jog sends, which
- stops at the travel limit. Bambuddy no longer disables the firmware's soft
- endstops, and it no longer sends ``M211 S1`` either: that was an unverified
- attempt to re-enable a printer left disabled by an older build, and on real
- hardware the jog moved past the limit *with* it. If a printer still jogs
- past its limits, its endstops were disabled at the firmware level by the old
- build — power-cycle it once to restore them; from then on Bambuddy leaves
- them alone.
- Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
- is the Z-axis, and Bambu's home convention puts Z=0 at the top with
- Z+ moving the bed down — so a frontend "Up" (decrease gap) maps
- naturally to ``G1 Z-``. On bed-slingers (A1 / A1 Mini) the Z-axis is
- the *toolhead*, and ``G1 Z-`` instead drives the nozzle DOWN into the
- bed (#1334 reported exactly that crash). For those models we invert
- the sign before emitting the G-code, so the UI semantics stay the
- same regardless of which part physically moves.
- """
- if distance == 0 or abs(distance) > 200:
- raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- from backend.app.services.printer_manager import is_bed_slinger
- gcode_distance = -distance if is_bed_slinger(printer.model) else distance
- # Bare relative move — exactly what the touchscreen sends. Never touch M211
- # (#2579): the firmware keeps its soft endstops on by default and clamps the
- # move at the travel limit.
- lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
- if not client.send_gcode("\n".join(lines)):
- raise HTTPException(500, "Failed to send bed-jog command")
- return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
- @router.post("/{printer_id}/xy-jog")
- async def xy_jog(
- printer_id: int,
- x: float = Query(0, description="Signed relative X movement in mm"),
- y: float = Query(0, description="Signed relative Y movement in mm"),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Move the toolhead by a relative X/Y distance."""
- if (x == 0 and y == 0) or abs(x) > 200 or abs(y) > 200:
- raise HTTPException(400, "X/Y movement must be non-zero and ≤ 200 mm per axis")
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- axes = []
- if x:
- axes.append(f"X{x:.2f}")
- if y:
- axes.append(f"Y{y:.2f}")
- # Bare relative move — never touch M211 (#2579). The firmware keeps its soft
- # endstops on by default and clamps the move at the travel limit; a printer
- # left disabled by an older build is recovered with a power cycle.
- if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
- raise HTTPException(500, "Failed to send XY jog command")
- return {"success": True, "message": f"XY jog X{x:+.1f} Y{y:+.1f} mm sent"}
- @router.post("/{printer_id}/extruder-jog")
- async def extruder_jog(
- printer_id: int,
- distance: float = Query(
- ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
- ),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Extrude or retract filament by a relative distance.
- No client-side cold-extrude guard: Bambu firmware refuses extrusion
- below its min-extrude temperature, so a cold call is rejected at the
- printer, not silently damaging the extruder gear.
- """
- if distance == 0 or abs(distance) > 100:
- raise HTTPException(400, "Extruder movement must be non-zero and ≤ 100 mm")
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- if not client.send_gcode("\n".join(["M83", f"G1 E{distance:.2f} F300", "M82"])):
- raise HTTPException(500, "Failed to send extruder jog command")
- return {"success": True, "message": f"Extruder jog {distance:+.1f} mm sent"}
- @router.post("/{printer_id}/home-axes")
- async def home_axes(
- printer_id: int,
- axes: str = Query(
- "all",
- description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.",
- ),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Run the printer's full auto-home sequence via bare `G28`.
- Bambu printers (H2C / H2D / H2S / X1 family) home the Z axis by moving
- the BED UP toward an endstop at the top of travel. If the toolhead is
- not already parked out of the way, a bare `G28 Z` will crash the bed
- into the toolhead — #1052 reported exactly that on H2C: the bed rose
- without stopping at a safe height because `G28 Z` skipped the
- toolhead-park step that a full `G28` runs first.
- The endpoint therefore ignores the `axes` argument and always sends a
- bare `G28`, which the firmware expands into a safe multi-step sequence
- (park toolhead → home XY → home Z). The argument is kept only for
- backward-compat with existing clients; sending an invalid value still
- returns 400 so typos surface instead of silently proceeding.
- """
- axes = axes.lower()
- if axes not in ("z", "xy", "all"):
- raise HTTPException(400, "axes must be 'z', 'xy', or 'all'")
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- if not client.send_gcode("G28"):
- raise HTTPException(500, "Failed to send home command")
- return {"success": True, "message": "Full auto-home sequence sent"}
- @router.post("/{printer_id}/hms/clear")
- async def clear_hms_errors(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Clear HMS/print errors on the printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.clear_hms_errors()
- if not success:
- raise HTTPException(500, "Failed to clear HMS errors")
- return {"success": True, "message": "HMS errors cleared"}
- @router.get("/{printer_id}/print/objects")
- async def get_printable_objects(
- printer_id: int,
- reload: bool = False,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Get the list of printable objects for the current print.
- Returns a list of objects with id, name, position (if available), and skip status.
- Objects that have already been skipped are marked in the skipped_objects list.
- Args:
- reload: If True, reload objects from the archive file (useful after restart)
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- # Reload objects from 3MF if requested or no objects loaded
- if reload or not client.state.printable_objects:
- # The archive of a running print normally holds the very file the
- # printer is executing, so ask the disk before asking the printer:
- # the fan-out below pulls the whole 3MF over FTPS from a machine that
- # is mid-print — 15 MB on the print this was written for — and on a
- # printer that kept the file on internal storage it cannot succeed at
- # all. skipped_objects is deliberately left alone: a reload is
- # not a new print, and the list of what the user already skipped only
- # lives here.
- from backend.app.models.archive import PrintArchive
- from backend.app.services.archive import extract_printable_objects_from_archive
- subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip()
- if subtask_id not in ("", "0"):
- archive = await db.scalar(
- select(PrintArchive)
- .where(
- PrintArchive.printer_id == printer_id,
- PrintArchive.status == "printing",
- PrintArchive.subtask_id == subtask_id,
- )
- .order_by(PrintArchive.created_at.desc())
- .limit(1)
- )
- if archive is not None:
- objects, bbox_all = extract_printable_objects_from_archive(
- settings.base_dir / archive.file_path,
- plate_number=resolve_plate_id(client.state),
- )
- if objects:
- client.state.printable_objects = objects
- client.state.printable_objects_bbox_all = bbox_all
- logger.info(
- "Reloaded %s objects for printer %s from archive %s",
- len(objects),
- printer_id,
- archive.id,
- )
- # Only when the disk could not answer: a `reload=true` that the archive
- # satisfied has already refreshed from the file the printer is running.
- if not client.state.printable_objects:
- subtask_name = client.state.subtask_name
- if subtask_name:
- from backend.app.services.archive import extract_printable_objects_from_3mf
- from backend.app.services.bambu_ftp import download_file_try_paths_async
- # Build possible 3MF filenames (try both .gcode.3mf and .3mf)
- possible_filenames = []
- if subtask_name.endswith(".3mf"):
- possible_filenames.append(subtask_name)
- else:
- possible_filenames.append(f"{subtask_name}.gcode.3mf")
- possible_filenames.append(f"{subtask_name}.3mf")
- # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)
- if " " in subtask_name:
- normalized = subtask_name.replace(" ", "_")
- if normalized.endswith(".3mf"):
- possible_filenames.append(normalized)
- else:
- possible_filenames.append(f"{normalized}.gcode.3mf")
- possible_filenames.append(f"{normalized}.3mf")
- # Download 3MF from printer
- temp_path = settings.archive_dir / "temp" / f"objects_{printer_id}_{possible_filenames[0]}"
- temp_path.parent.mkdir(parents=True, exist_ok=True)
- # Build list of all remote paths to try
- remote_paths = []
- for filename in possible_filenames:
- remote_paths.extend([f"/{filename}", f"/cache/{filename}", f"/model/{filename}"])
- try:
- downloaded = await download_file_try_paths_async(
- printer.ip_address,
- printer.access_code,
- remote_paths,
- temp_path,
- printer_model=printer.model,
- )
- if downloaded and temp_path.exists():
- with open(temp_path, "rb") as f:
- data = f.read()
- # Scope to the running plate: an all-plates 3MF lists every
- # plate's objects, and offering plate 1's while the printer
- # runs plate 2 makes every skip a misfire (#2522).
- objects, bbox_all = extract_printable_objects_from_3mf(
- data,
- plate_number=resolve_plate_id(client.state),
- include_positions=True,
- )
- if objects:
- client.state.printable_objects = objects
- client.state.printable_objects_bbox_all = bbox_all
- logger.info("Reloaded %s objects for printer %s", len(objects), printer_id)
- except Exception as e:
- logger.debug("Failed to reload objects from printer: %s", e)
- finally:
- if temp_path.exists():
- temp_path.unlink()
- # Return objects with their skip status and position data
- objects = []
- for obj_id, obj_data in client.state.printable_objects.items():
- # Handle both old format (string name) and new format (dict with name, x, y)
- if isinstance(obj_data, dict):
- obj_entry = {
- "id": obj_id,
- "name": obj_data.get("name", f"Object {obj_id}"),
- "x": obj_data.get("x"),
- "y": obj_data.get("y"),
- "skipped": obj_id in client.state.skipped_objects,
- }
- else:
- # Legacy format: obj_data is just the name string
- obj_entry = {
- "id": obj_id,
- "name": obj_data,
- "x": None,
- "y": None,
- "skipped": obj_id in client.state.skipped_objects,
- }
- objects.append(obj_entry)
- return {
- "objects": objects,
- "total": len(objects),
- "skipped_count": len(client.state.skipped_objects),
- "is_printing": client.state.state in ("RUNNING", "PAUSE"),
- "bbox_all": getattr(client.state, "printable_objects_bbox_all", None),
- }
- @router.post("/{printer_id}/print/skip-objects")
- async def skip_objects(
- printer_id: int,
- object_ids: list[int],
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Skip specific objects during the current print.
- Args:
- object_ids: List of object identify_id values to skip
- """
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- if not object_ids:
- raise HTTPException(400, "No object IDs provided")
- # Validate object IDs exist in printable_objects
- invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]
- if invalid_ids:
- raise HTTPException(400, f"Invalid object IDs: {invalid_ids}")
- success = client.skip_objects(object_ids)
- if not success:
- raise HTTPException(500, "Failed to skip objects")
- # Get names of skipped objects for response (handle both old and new format)
- skipped_names = []
- for oid in object_ids:
- obj_data = client.state.printable_objects.get(oid, str(oid))
- if isinstance(obj_data, dict):
- skipped_names.append(obj_data.get("name", str(oid)))
- else:
- skipped_names.append(obj_data)
- return {
- "success": True,
- "message": f"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}",
- "skipped_objects": object_ids,
- }
- # =============================================================================
- # AMS Control Endpoints
- # =============================================================================
- @router.post("/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh")
- async def refresh_ams_slot(
- printer_id: int,
- ams_id: int,
- slot_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
- db: AsyncSession = Depends(get_db),
- ):
- """Re-read RFID for an AMS slot (triggers filament info refresh)."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success, message = client.ams_refresh_tray(ams_id, slot_id)
- if not success:
- raise HTTPException(400, message)
- # Apply PA profile after delay (RFID re-read takes a few seconds)
- spawn_background_task(
- _apply_pa_after_refresh(printer_id, ams_id, slot_id),
- name=f"apply-pa-after-refresh-{printer_id}-{ams_id}-{slot_id}",
- )
- return {"success": True, "message": message}
- async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
- """Apply PA profile after RFID re-read completes.
- Waits for the printer to finish processing the RFID data, then selects
- the K-profile via extrusion_cali_sel. Does NOT re-send ams_set_filament_setting
- because that would overwrite the RFID-provided filament data.
- """
- await asyncio.sleep(5)
- try:
- from backend.app.api.routes.inventory import _find_tray_in_ams_data
- from backend.app.core.database import async_session
- from backend.app.models.spool import Spool
- from backend.app.models.spool_assignment import SpoolAssignment as SA
- from backend.app.models.spoolman_k_profile import SpoolmanKProfile
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services.spool_tag_matcher import (
- ZERO_TAG_UID,
- ZERO_TRAY_UUID,
- is_bambu_tag,
- )
- from backend.app.utils.tag_normalization import (
- normalize_tag_uid,
- normalize_tray_uuid,
- )
- client = printer_manager.get_client(printer_id)
- if not client:
- return
- state = printer_manager.get_status(printer_id)
- if not state or not state.raw_data:
- return
- # Find current tray data (should have RFID data by now)
- ams_data = state.raw_data.get("ams", {})
- ams_list = (
- ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
- )
- tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)
- if not tray or not tray.get("tray_type"):
- logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id)
- return
- tag_uid = tray.get("tag_uid", "")
- tray_uuid = tray.get("tray_uuid", "")
- tray_info_idx = tray.get("tray_info_idx", "")
- if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
- return
- # Compute nozzle/extruder once — used by both local and Spoolman lookup.
- # Shared with every other slot-configuring path (services.slot_nozzle),
- # so the diameter this cascade filters on is the one the slot's own
- # hotend actually has.
- slot_nozzle = resolve_slot_nozzle(state, ams_id, slot_id, printer_manager.get_model(printer_id))
- nozzle_diameter = slot_nozzle.diameter
- resolved_extruder = slot_nozzle.extruder
- # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
- # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
- # and exited silently if no SpoolKProfile match; Spoolman-assigned slots
- # were ignored entirely and live cali_idx was never re-asserted.
- matching_cali_idx: int | None = None
- matching_filament_id: str = tray_info_idx
- async with async_session() as db:
- from sqlalchemy import or_, select as sa_select
- from sqlalchemy.orm import selectinload
- # Stage 1: local SpoolAssignment + SpoolKProfile match
- result = await db.execute(
- sa_select(SA)
- .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
- .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
- )
- assignment = result.scalar_one_or_none()
- spool: Spool | None = assignment.spool if assignment else None
- # Stage 1b: tag-based fallback. The slot may have just been reset
- # (SpoolAssignment row deleted) before the user triggered a re-read.
- # The live tray already carries the spool's tray_uuid/tag_uid from
- # the RFID re-read, but the SA row hasn't been re-created yet.
- # Without this fallback we miss the stored SpoolKProfile and Stage 3
- # ends up re-asserting whatever cali_idx the firmware reset to
- # (typically the default profile).
- if spool is None:
- norm_uuid = normalize_tray_uuid(tray_uuid) if tray_uuid else ""
- norm_tag = normalize_tag_uid(tag_uid) if tag_uid else ""
- tag_filters = []
- if norm_uuid and norm_uuid != ZERO_TRAY_UUID:
- tag_filters.append(Spool.tray_uuid == norm_uuid)
- if norm_tag and norm_tag != ZERO_TAG_UID:
- tag_filters.append(Spool.tag_uid == norm_tag)
- if tag_filters:
- tag_lookup = await db.execute(
- select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
- )
- spool = tag_lookup.scalar_one_or_none()
- if spool is not None:
- logger.info(
- "PA re-apply AMS%d-T%d: matched spool %d via tag fallback "
- "(SpoolAssignment row missing, likely after slot reset)",
- ams_id,
- slot_id,
- spool.id,
- )
- if spool is not None and spool.k_profiles:
- # Prefer exact extruder match, fall back to extruder-agnostic kp
- # for the same printer + nozzle. Hard-skipping on extruder
- # mismatch made the cascade refuse perfectly valid stored
- # profiles whenever the AMS-extruder mapping had shifted since
- # calibration time, falling all the way through to Stage 3 and
- # re-asserting the firmware default.
- exact_kp = None
- fallback_kp = None
- for kp in spool.k_profiles:
- if not slot_nozzle.flow_matches(kp.nozzle_type):
- continue
- if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
- continue
- if resolved_extruder is not None and kp.extruder is not None and kp.extruder == resolved_extruder:
- exact_kp = kp
- break
- if fallback_kp is None:
- fallback_kp = kp
- chosen_kp = exact_kp or fallback_kp
- if chosen_kp is not None:
- matching_cali_idx = chosen_kp.cali_idx
- # The filament_id in extrusion_cali_sel must match the preset
- # under which the K-profile was calibrated. Prefer the spool's
- # slicer_filament setting, falling back to the tray's RFID value.
- matching_filament_id = spool.slicer_filament or tray_info_idx
- # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match
- # (only when no local spool was matched — local takes priority,
- # including the tag-based fallback above)
- if matching_cali_idx is None and spool is None:
- sm_result = await db.execute(
- select(SpoolmanSlotAssignment).where(
- SpoolmanSlotAssignment.printer_id == printer_id,
- SpoolmanSlotAssignment.ams_id == ams_id,
- SpoolmanSlotAssignment.tray_id == slot_id,
- )
- )
- sm_assignment = sm_result.scalar_one_or_none()
- if sm_assignment:
- kp_result = await db.execute(
- sa_select(SpoolmanKProfile).where(
- SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
- SpoolmanKProfile.printer_id == printer_id,
- )
- )
- for kp in kp_result.scalars().all():
- if kp.nozzle_diameter == nozzle_diameter:
- if (
- resolved_extruder is not None
- and kp.extruder is not None
- and kp.extruder != resolved_extruder
- ):
- continue
- if kp.cali_idx is not None:
- matching_cali_idx = kp.cali_idx
- # Spoolman has no slicer_filament — use the tray's RFID value
- matching_filament_id = tray_info_idx
- break
- # Stage 3: live tray.cali_idx fallback. Re-asserts the printer's current
- # selection so the value sticks across the RFID re-read (otherwise some
- # firmwares clear cali_idx back to -1 mid-cycle).
- if matching_cali_idx is None:
- live_cali_idx = tray.get("cali_idx")
- if live_cali_idx is not None and live_cali_idx >= 0:
- matching_cali_idx = live_cali_idx
- if matching_cali_idx is None:
- logger.debug(
- "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT",
- ams_id,
- slot_id,
- )
- return
- logger.info(
- "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
- ams_id,
- slot_id,
- matching_cali_idx,
- matching_filament_id,
- )
- # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
- # "this is a manual config" which destroys the RFID-detected spool state
- # (changes eye icon to pen icon in slicer).
- client.extrusion_cali_sel(
- ams_id=ams_id,
- tray_id=slot_id,
- cali_idx=matching_cali_idx,
- filament_id=matching_filament_id,
- nozzle_diameter=nozzle_diameter,
- )
- # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
- # selected the correct profile by cali_idx. Sending extrusion_cali_set with
- # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
- # nozzle_id, name), corrupting it.
- logger.info(
- "Applied PA profile cali_idx=%d to printer %d AMS%d-T%d",
- matching_cali_idx,
- printer_id,
- ams_id,
- slot_id,
- )
- except Exception as e:
- logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
- # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
- # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
- _LOAD_TRAY_ID_ERROR = "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
- def _is_valid_load_tray_id(tray_id: int) -> bool:
- """Whether ``tray_id`` names a slot the load/unload commands can address."""
- return tray_id in range(16) or tray_id in range(24, 28) or tray_id in (254, 255)
- @router.post("/{printer_id}/ams/load")
- async def ams_load(
- printer_id: int,
- tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
- extruder_id: int | None = Query(
- None,
- ge=0,
- le=1,
- description=(
- "Hotend to feed: 0 = right/main, 1 = left/deputy. Only meaningful "
- "on a printer with a Filament Track Switch fitted, where the AMS is "
- "bound to a switch inlet rather than a hotend and the firmware "
- "cannot work the target out for itself. Omit on every other printer "
- "— the field is absent from BambuStudio's own command there too."
- ),
- ),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Load filament from a specific AMS slot or external spool.
- Tray ID encoding (matches Bambu firmware convention):
- - 0..15: AMS slot, computed as ams_id * 4 + slot_id
- - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
- - 255: Ext-R on dual-nozzle H2D
- """
- if not _is_valid_load_tray_id(tray_id):
- raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.ams_load_filament(tray_id, extruder_id=extruder_id)
- if not success:
- raise HTTPException(500, "Failed to send load command")
- if tray_id == 254:
- target = "external spool"
- elif tray_id == 255:
- target = "Ext-R"
- else:
- target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
- return {"success": True, "message": f"Loading filament from {target}"}
- @router.post("/{printer_id}/ams/unload")
- async def ams_unload(
- printer_id: int,
- tray_id: int | None = Query(
- None,
- description=(
- "Tray ID of the slot to unload, same encoding as the load endpoint. "
- "Identifies which hotend to unload on a dual-nozzle printer, where "
- "both can hold filament at once and the printer's single tray_now "
- "field names only one of them. Omit to unload whatever tray_now "
- "names, which is the only option a single-nozzle printer has."
- ),
- ),
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Unload the filament in a given slot, or the currently loaded one."""
- if tray_id is not None and not _is_valid_load_tray_id(tray_id):
- raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- success = client.ams_unload_filament(tray_id)
- if not success:
- # A named slot that no hotend is fed from is a no-op, not a fault: the
- # menu is per-slot and the operator may well have clicked one that is
- # not loaded. Say so instead of returning a 500 they cannot act on.
- if tray_id is not None:
- raise HTTPException(409, "No hotend is loaded from that slot")
- raise HTTPException(500, "Failed to send unload command")
- return {"success": True, "message": "Unloading filament"}
- @router.get("/{printer_id}/runtime-debug")
- async def get_runtime_debug(
- printer_id: int,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
- db: AsyncSession = Depends(get_db),
- ):
- """Debug endpoint: Get runtime tracking status for a printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- state = printer_manager.get_status(printer_id)
- return {
- "printer_name": printer.name,
- "runtime_seconds": printer.runtime_seconds,
- "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
- "print_hours_offset": printer.print_hours_offset,
- "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
- + (printer.print_hours_offset or 0),
- "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
- "mqtt_state": {
- "connected": state.connected if state else False,
- "state": state.state if state else None,
- "progress": state.progress if state else None,
- "gcode_file": state.gcode_file if state else None,
- }
- if state
- else None,
- "is_active": printer.is_active,
- }
- @router.post("/{printer_id}/hms/execute-action")
- async def execute_hms_action(
- printer_id: int,
- body: HmsActionBody,
- _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
- db: AsyncSession = Depends(get_db),
- ):
- """Execute an HMS action on the printer."""
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- client = printer_manager.get_client(printer_id)
- if not client:
- raise HTTPException(400, "Printer not connected")
- # Snapshot pre-state so we can verify the printer actually acted on the
- # command. publish() success is NOT the same as printer-ack: Bambu's
- # firmware silently rejects malformed HMS commands at QoS 1 (the broker
- # ACKs the publish, but the printer drops it). Verified end-to-end against
- # a live H2D — see #1830 §(3).
- #
- # We probe `_last_message_time` (bumped on every MQTT push) rather than a
- # (gcode_state, hms_errors-length) diff. The old diff missed the
- # wrong-plate IGNORE_RESUME case where the printer briefly resumes and
- # re-pauses with the same fault inside the 2.5s window: both fields
- # round-trip to their pre-publish values → false 502 even though the
- # firmware fully ack'd the resume. Every accepted command triggers a
- # pushall response within ~100-500ms, so a fresh inbound message after
- # the publish is the robust ack signal.
- pre_last_message = client._last_message_time
- success = client.execute_hms_action(body.print_error, body.action, body.job_id)
- if not success:
- raise HTTPException(400, "Failed to execute HMS action")
- # Give the printer time to push a state update. The dispatch helper already
- # publishes a pushall after every command, so a fresh status should arrive
- # within ~1s; the default 2.5s covers slower firmware variants without
- # making the UI feel hung. Plain sleep is fine — paho's MQTT callback
- # runs in its own thread and updates state regardless of whether this
- # coroutine is awaiting.
- await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
- acked = client._last_message_time > pre_last_message
- if not acked:
- # Publish succeeded but the printer sent nothing back. Almost always
- # firmware-side silent rejection (err mismatch, command/state mismatch)
- # or a dropped MQTT route. 502 makes it visible at the UI instead of
- # the 200-but-broken loop #1830 reported.
- raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
- return {"success": True, "message": "HMS action executed"}
|