test_bambu_mqtt.py 352 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943794479457946794779487949795079517952795379547955795679577958795979607961796279637964796579667967796879697970797179727973797479757976797779787979798079817982798379847985798679877988798979907991799279937994799579967997799879998000800180028003800480058006800780088009801080118012801380148015801680178018801980208021802280238024802580268027802880298030803180328033803480358036803780388039804080418042804380448045804680478048804980508051805280538054805580568057805880598060806180628063806480658066806780688069807080718072807380748075807680778078807980808081808280838084808580868087808880898090809180928093809480958096809780988099810081018102810381048105810681078108810981108111811281138114811581168117811881198120812181228123812481258126812781288129813081318132813381348135813681378138813981408141814281438144814581468147814881498150815181528153815481558156815781588159816081618162816381648165816681678168816981708171817281738174817581768177817881798180818181828183818481858186818781888189819081918192819381948195819681978198819982008201820282038204820582068207820882098210821182128213821482158216821782188219822082218222822382248225822682278228822982308231823282338234823582368237823882398240824182428243824482458246824782488249825082518252825382548255825682578258825982608261826282638264826582668267826882698270827182728273827482758276827782788279828082818282828382848285828682878288828982908291829282938294829582968297829882998300830183028303830483058306830783088309831083118312831383148315831683178318831983208321832283238324832583268327832883298330833183328333833483358336833783388339834083418342834383448345834683478348834983508351835283538354835583568357835883598360836183628363836483658366836783688369837083718372837383748375837683778378837983808381838283838384838583868387838883898390839183928393839483958396839783988399840084018402840384048405840684078408840984108411841284138414841584168417841884198420842184228423842484258426842784288429
  1. """
  2. Tests for the BambuMQTTClient service.
  3. These tests focus on timelapse tracking during prints.
  4. """
  5. import asyncio
  6. import json
  7. import logging
  8. import time
  9. from unittest.mock import MagicMock
  10. import pytest
  11. class TestTimelapseTracking:
  12. """Tests for timelapse state tracking during prints."""
  13. @pytest.fixture
  14. def mqtt_client(self):
  15. """Create a BambuMQTTClient instance for testing."""
  16. from backend.app.services.bambu_mqtt import BambuMQTTClient
  17. client = BambuMQTTClient(
  18. ip_address="192.168.1.100",
  19. serial_number="TEST123",
  20. access_code="12345678",
  21. )
  22. return client
  23. def test_timelapse_flag_initializes_to_false(self, mqtt_client):
  24. """Verify _timelapse_during_print starts as False."""
  25. assert mqtt_client._timelapse_during_print is False
  26. def test_timelapse_flag_set_when_timelapse_active_during_running(self, mqtt_client):
  27. """Verify timelapse flag is set when timelapse is active while printing."""
  28. # Simulate print running
  29. mqtt_client._was_running = True
  30. mqtt_client.state.timelapse = False
  31. # Simulate xcam data showing timelapse is enabled
  32. xcam_data = {"timelapse": "enable"}
  33. mqtt_client._parse_xcam_data(xcam_data)
  34. assert mqtt_client.state.timelapse is True
  35. assert mqtt_client._timelapse_during_print is True
  36. def test_timelapse_flag_not_set_when_not_running(self, mqtt_client):
  37. """Verify timelapse flag is NOT set when printer not running."""
  38. # Printer is idle (not running)
  39. mqtt_client._was_running = False
  40. mqtt_client.state.timelapse = False
  41. # Timelapse is enabled but we're not printing
  42. xcam_data = {"timelapse": "enable"}
  43. mqtt_client._parse_xcam_data(xcam_data)
  44. assert mqtt_client.state.timelapse is True
  45. # Flag should NOT be set since we're not printing
  46. assert mqtt_client._timelapse_during_print is False
  47. def test_timelapse_flag_persists_after_timelapse_stops(self, mqtt_client):
  48. """Verify timelapse flag stays True even after recording stops."""
  49. # Simulate print running with timelapse
  50. mqtt_client._was_running = True
  51. # Enable timelapse during print
  52. xcam_data = {"timelapse": "enable"}
  53. mqtt_client._parse_xcam_data(xcam_data)
  54. assert mqtt_client._timelapse_during_print is True
  55. # Disable timelapse (recording stops at end of print)
  56. xcam_data = {"timelapse": "disable"}
  57. mqtt_client._parse_xcam_data(xcam_data)
  58. # Flag should still be True (persists until reset)
  59. assert mqtt_client.state.timelapse is False
  60. assert mqtt_client._timelapse_during_print is True
  61. def test_timelapse_flag_from_print_data(self, mqtt_client):
  62. """Verify timelapse flag is set from print data (not just xcam)."""
  63. # Simulate print running
  64. mqtt_client._was_running = True
  65. mqtt_client.state.timelapse = False
  66. mqtt_client._timelapse_during_print = False
  67. # Manually test the timelapse parsing logic from _parse_print_data
  68. # This tests the "timelapse" field in the main print data
  69. data = {"timelapse": True}
  70. mqtt_client.state.timelapse = data["timelapse"] is True
  71. if mqtt_client.state.timelapse and mqtt_client._was_running:
  72. mqtt_client._timelapse_during_print = True
  73. assert mqtt_client._timelapse_during_print is True
  74. class TestPrintCompletionWithTimelapse:
  75. """Tests for print completion including timelapse flag."""
  76. @pytest.fixture
  77. def mqtt_client(self):
  78. """Create a BambuMQTTClient instance for testing."""
  79. from backend.app.services.bambu_mqtt import BambuMQTTClient
  80. client = BambuMQTTClient(
  81. ip_address="192.168.1.100",
  82. serial_number="TEST123",
  83. access_code="12345678",
  84. )
  85. return client
  86. def test_print_complete_includes_timelapse_flag(self, mqtt_client):
  87. """Verify print complete callback includes timelapse_was_active."""
  88. # Set up completion callback
  89. callback_data = {}
  90. def on_complete(data):
  91. callback_data.update(data)
  92. mqtt_client.on_print_complete = on_complete
  93. # Simulate a print that had timelapse active
  94. mqtt_client._was_running = True
  95. mqtt_client._completion_triggered = False
  96. mqtt_client._timelapse_during_print = True
  97. mqtt_client._previous_gcode_state = "RUNNING"
  98. mqtt_client._previous_gcode_file = "test.gcode"
  99. mqtt_client.state.subtask_name = "Test Print"
  100. # Simulate print finish
  101. mqtt_client.state.state = "FINISH"
  102. # Manually trigger the completion logic (simplified)
  103. # In real code this happens in _parse_print_data
  104. should_trigger = (
  105. mqtt_client.state.state in ("FINISH", "FAILED")
  106. and not mqtt_client._completion_triggered
  107. and mqtt_client.on_print_complete
  108. and mqtt_client._previous_gcode_state == "RUNNING"
  109. )
  110. if should_trigger:
  111. status = "completed" if mqtt_client.state.state == "FINISH" else "failed"
  112. timelapse_was_active = mqtt_client._timelapse_during_print
  113. mqtt_client._completion_triggered = True
  114. mqtt_client._was_running = False
  115. mqtt_client._timelapse_during_print = False
  116. mqtt_client.on_print_complete(
  117. {
  118. "status": status,
  119. "filename": mqtt_client._previous_gcode_file,
  120. "subtask_name": mqtt_client.state.subtask_name,
  121. "timelapse_was_active": timelapse_was_active,
  122. }
  123. )
  124. assert "timelapse_was_active" in callback_data
  125. assert callback_data["timelapse_was_active"] is True
  126. def test_print_complete_timelapse_flag_false_when_no_timelapse(self, mqtt_client):
  127. """Verify timelapse_was_active is False when no timelapse during print."""
  128. callback_data = {}
  129. def on_complete(data):
  130. callback_data.update(data)
  131. mqtt_client.on_print_complete = on_complete
  132. # Print without timelapse
  133. mqtt_client._was_running = True
  134. mqtt_client._completion_triggered = False
  135. mqtt_client._timelapse_during_print = False # No timelapse
  136. mqtt_client._previous_gcode_state = "RUNNING"
  137. mqtt_client._previous_gcode_file = "test.gcode"
  138. mqtt_client.state.subtask_name = "Test Print"
  139. mqtt_client.state.state = "FINISH"
  140. # Trigger completion
  141. timelapse_was_active = mqtt_client._timelapse_during_print
  142. mqtt_client.on_print_complete(
  143. {
  144. "status": "completed",
  145. "filename": mqtt_client._previous_gcode_file,
  146. "subtask_name": mqtt_client.state.subtask_name,
  147. "timelapse_was_active": timelapse_was_active,
  148. }
  149. )
  150. assert callback_data["timelapse_was_active"] is False
  151. def test_timelapse_flag_reset_after_completion(self, mqtt_client):
  152. """Verify _timelapse_during_print is reset after print completion."""
  153. mqtt_client._timelapse_during_print = True
  154. mqtt_client._was_running = True
  155. mqtt_client._completion_triggered = False
  156. # Simulate completion reset
  157. mqtt_client._completion_triggered = True
  158. mqtt_client._was_running = False
  159. mqtt_client._timelapse_during_print = False
  160. assert mqtt_client._timelapse_during_print is False
  161. class TestRealisticMessageFlow:
  162. """Tests that simulate realistic MQTT message sequences.
  163. These tests process messages through _process_message to test the full flow,
  164. including the order of xcam parsing vs state detection.
  165. """
  166. @pytest.fixture
  167. def mqtt_client(self):
  168. """Create a BambuMQTTClient instance for testing."""
  169. from backend.app.services.bambu_mqtt import BambuMQTTClient
  170. client = BambuMQTTClient(
  171. ip_address="192.168.1.100",
  172. serial_number="TEST123",
  173. access_code="12345678",
  174. )
  175. return client
  176. def test_timelapse_detected_at_print_start_in_same_message(self, mqtt_client):
  177. """Test that timelapse is detected when xcam and state come in same message.
  178. This is the critical race condition test - xcam data is parsed BEFORE
  179. state detection, so the timelapse flag must be set AFTER _was_running is True.
  180. """
  181. # Callbacks to track events
  182. start_callback_data = {}
  183. def on_start(data):
  184. start_callback_data.update(data)
  185. mqtt_client.on_print_start = on_start
  186. # Initial state - idle
  187. mqtt_client._was_running = False
  188. mqtt_client._timelapse_during_print = False
  189. mqtt_client._previous_gcode_state = None
  190. # Simulate first message when print starts - contains both xcam and gcode_state
  191. # This is the realistic scenario from the printer
  192. # NOTE: Real MQTT messages wrap print data inside a "print" key
  193. payload = {
  194. "print": {
  195. "gcode_state": "RUNNING",
  196. "gcode_file": "/data/Metadata/test_print.gcode",
  197. "subtask_name": "Test_Print",
  198. "xcam": {
  199. "timelapse": "enable", # Timelapse is enabled in this print
  200. "printing_monitor": True,
  201. },
  202. "mc_percent": 0,
  203. "mc_remaining_time": 3600,
  204. }
  205. }
  206. # Process the message (this is what happens in real MQTT flow)
  207. mqtt_client._process_message(payload)
  208. # Verify timelapse was detected even though xcam is parsed before state
  209. assert mqtt_client._was_running is True, "_was_running should be True after RUNNING state"
  210. assert mqtt_client.state.timelapse is True, "state.timelapse should be True"
  211. assert mqtt_client._timelapse_during_print is True, (
  212. "timelapse_during_print should be True when timelapse is in the same message as RUNNING state"
  213. )
  214. def test_timelapse_not_detected_when_disabled(self, mqtt_client):
  215. """Test that timelapse is NOT detected when disabled in xcam data."""
  216. mqtt_client.on_print_start = lambda data: None
  217. # Initial state - idle
  218. mqtt_client._was_running = False
  219. mqtt_client._timelapse_during_print = False
  220. mqtt_client._previous_gcode_state = None
  221. # Print starts without timelapse
  222. payload = {
  223. "print": {
  224. "gcode_state": "RUNNING",
  225. "gcode_file": "/data/Metadata/test_print.gcode",
  226. "subtask_name": "Test_Print",
  227. "xcam": {
  228. "timelapse": "disable", # Timelapse is disabled
  229. "printing_monitor": True,
  230. },
  231. }
  232. }
  233. mqtt_client._process_message(payload)
  234. assert mqtt_client._was_running is True
  235. assert mqtt_client.state.timelapse is False
  236. assert mqtt_client._timelapse_during_print is False
  237. def test_timelapse_detected_when_enabled_after_print_start(self, mqtt_client):
  238. """Test timelapse detected when enabled in a message after print starts."""
  239. mqtt_client.on_print_start = lambda data: None
  240. # First message - print starts without timelapse info
  241. payload_start = {
  242. "print": {
  243. "gcode_state": "RUNNING",
  244. "gcode_file": "/data/Metadata/test_print.gcode",
  245. "subtask_name": "Test_Print",
  246. }
  247. }
  248. mqtt_client._process_message(payload_start)
  249. assert mqtt_client._was_running is True
  250. assert mqtt_client._timelapse_during_print is False # Not detected yet
  251. # Second message - xcam data arrives with timelapse enabled
  252. payload_xcam = {
  253. "print": {
  254. "gcode_state": "RUNNING",
  255. "gcode_file": "/data/Metadata/test_print.gcode",
  256. "subtask_name": "Test_Print",
  257. "xcam": {
  258. "timelapse": "enable",
  259. },
  260. }
  261. }
  262. mqtt_client._process_message(payload_xcam)
  263. # Now timelapse should be detected because _was_running is already True
  264. assert mqtt_client._timelapse_during_print is True
  265. def test_print_complete_includes_timelapse_flag_full_flow(self, mqtt_client):
  266. """Test full print lifecycle with timelapse - from start to completion."""
  267. start_data = {}
  268. complete_data = {}
  269. def on_start(data):
  270. start_data.update(data)
  271. def on_complete(data):
  272. complete_data.update(data)
  273. mqtt_client.on_print_start = on_start
  274. mqtt_client.on_print_complete = on_complete
  275. # Seed a prior state so the first RUNNING push is treated as a real
  276. # state transition rather than a Bambuddy-restart catch-up (#1304).
  277. mqtt_client._previous_gcode_state = "IDLE"
  278. # 1. Print starts with timelapse
  279. mqtt_client._process_message(
  280. {
  281. "print": {
  282. "gcode_state": "RUNNING",
  283. "gcode_file": "/data/Metadata/test.gcode",
  284. "subtask_name": "Test",
  285. "xcam": {"timelapse": "enable"},
  286. }
  287. }
  288. )
  289. assert mqtt_client._timelapse_during_print is True
  290. assert "subtask_name" in start_data
  291. # 2. Print continues (multiple messages)
  292. for _ in range(3):
  293. mqtt_client._process_message(
  294. {
  295. "print": {
  296. "gcode_state": "RUNNING",
  297. "gcode_file": "/data/Metadata/test.gcode",
  298. "subtask_name": "Test",
  299. "mc_percent": 50,
  300. }
  301. }
  302. )
  303. # Timelapse flag should still be True
  304. assert mqtt_client._timelapse_during_print is True
  305. # 3. Print completes
  306. mqtt_client._process_message(
  307. {
  308. "print": {
  309. "gcode_state": "FINISH",
  310. "gcode_file": "/data/Metadata/test.gcode",
  311. "subtask_name": "Test",
  312. }
  313. }
  314. )
  315. # Verify completion callback received timelapse flag
  316. assert "timelapse_was_active" in complete_data
  317. assert complete_data["timelapse_was_active"] is True
  318. assert complete_data["status"] == "completed"
  319. # Flags should be reset after completion
  320. assert mqtt_client._timelapse_during_print is False
  321. assert mqtt_client._was_running is False
  322. def test_print_failed_includes_timelapse_flag(self, mqtt_client):
  323. """Test that failed print also includes timelapse flag."""
  324. complete_data = {}
  325. def on_complete(data):
  326. complete_data.update(data)
  327. mqtt_client.on_print_start = lambda data: None
  328. mqtt_client.on_print_complete = on_complete
  329. # Start with timelapse
  330. mqtt_client._process_message(
  331. {
  332. "print": {
  333. "gcode_state": "RUNNING",
  334. "gcode_file": "/data/Metadata/test.gcode",
  335. "subtask_name": "Test",
  336. "xcam": {"timelapse": "enable"},
  337. }
  338. }
  339. )
  340. # Print fails
  341. mqtt_client._process_message(
  342. {
  343. "print": {
  344. "gcode_state": "FAILED",
  345. "gcode_file": "/data/Metadata/test.gcode",
  346. "subtask_name": "Test",
  347. }
  348. }
  349. )
  350. assert complete_data["timelapse_was_active"] is True
  351. assert complete_data["status"] == "failed"
  352. class TestPrePrintFailureCompletion:
  353. """Tests for completion detection when the print errors before reaching RUNNING (#1111).
  354. Common trigger: a file sliced for the wrong nozzle diameter is dispatched. The
  355. printer transitions IDLE -> PREPARE -> FAILED without ever entering RUNNING, so
  356. the legacy completion detection (which required _previous_gcode_state == 'RUNNING'
  357. or _was_running == True) left the queue item stuck at 'printing' forever.
  358. """
  359. @pytest.fixture
  360. def mqtt_client(self):
  361. from backend.app.services.bambu_mqtt import BambuMQTTClient
  362. return BambuMQTTClient(
  363. ip_address="192.168.1.100",
  364. serial_number="TEST123",
  365. access_code="12345678",
  366. )
  367. def test_prepare_to_failed_triggers_completion(self, mqtt_client):
  368. """PREPARE -> FAILED must fire on_print_complete (wrong nozzle size etc.)."""
  369. complete_data = {}
  370. mqtt_client.on_print_start = lambda data: None
  371. mqtt_client.on_print_complete = lambda data: complete_data.update(data)
  372. mqtt_client._previous_gcode_state = "PREPARE"
  373. mqtt_client._was_running = False
  374. mqtt_client._completion_triggered = False
  375. mqtt_client._process_message(
  376. {
  377. "print": {
  378. "gcode_state": "FAILED",
  379. "gcode_file": "/data/Metadata/plate_1.gcode",
  380. "subtask_name": "WrongNozzle",
  381. }
  382. }
  383. )
  384. assert complete_data.get("status") == "failed"
  385. def test_slicing_to_failed_triggers_completion(self, mqtt_client):
  386. """SLICING -> FAILED also treated as a pre-print failure."""
  387. complete_data = {}
  388. mqtt_client.on_print_start = lambda data: None
  389. mqtt_client.on_print_complete = lambda data: complete_data.update(data)
  390. mqtt_client._previous_gcode_state = "SLICING"
  391. mqtt_client._was_running = False
  392. mqtt_client._completion_triggered = False
  393. mqtt_client._process_message(
  394. {
  395. "print": {
  396. "gcode_state": "FAILED",
  397. "gcode_file": "/data/Metadata/plate_1.gcode",
  398. "subtask_name": "WrongNozzle",
  399. }
  400. }
  401. )
  402. assert complete_data.get("status") == "failed"
  403. def test_initial_failed_does_not_trigger_completion(self, mqtt_client):
  404. """First message arriving with FAILED (no prior state) must NOT fire completion.
  405. Protects against a stale FAILED on reconnect being mistaken for a fresh failure
  406. and marking an unrelated queue item as failed.
  407. """
  408. calls = []
  409. mqtt_client.on_print_start = lambda data: None
  410. mqtt_client.on_print_complete = lambda data: calls.append(data)
  411. assert mqtt_client._previous_gcode_state is None
  412. assert mqtt_client._was_running is False
  413. mqtt_client._process_message(
  414. {
  415. "print": {
  416. "gcode_state": "FAILED",
  417. "gcode_file": "/data/Metadata/plate_1.gcode",
  418. "subtask_name": "Stale",
  419. }
  420. }
  421. )
  422. assert calls == []
  423. def test_idle_to_failed_does_not_trigger_completion(self, mqtt_client):
  424. """IDLE -> FAILED (no print ever dispatched) must NOT fire completion."""
  425. calls = []
  426. mqtt_client.on_print_start = lambda data: None
  427. mqtt_client.on_print_complete = lambda data: calls.append(data)
  428. mqtt_client._previous_gcode_state = "IDLE"
  429. mqtt_client._was_running = False
  430. mqtt_client._completion_triggered = False
  431. mqtt_client._process_message(
  432. {
  433. "print": {
  434. "gcode_state": "FAILED",
  435. "subtask_name": "Stale",
  436. }
  437. }
  438. )
  439. assert calls == []
  440. def test_prepare_to_failed_includes_hms_errors_in_callback(self, mqtt_client):
  441. """Pre-print FAILED callback should carry the current HMS error list so the
  442. queue handler can populate a meaningful error_message."""
  443. complete_data = {}
  444. mqtt_client.on_print_start = lambda data: None
  445. mqtt_client.on_print_complete = lambda data: complete_data.update(data)
  446. mqtt_client._previous_gcode_state = "PREPARE"
  447. mqtt_client._was_running = False
  448. # Message carries HMS data for a nozzle-size mismatch (0500_4038) and the
  449. # PREPARE -> FAILED gcode_state transition in a single update.
  450. mqtt_client._process_message(
  451. {
  452. "print": {
  453. "gcode_state": "FAILED",
  454. "gcode_file": "/data/Metadata/plate_1.gcode",
  455. "hms": [{"attr": 0x05000000, "code": 0x4038}],
  456. }
  457. }
  458. )
  459. assert complete_data.get("status") == "failed"
  460. errs = complete_data.get("hms_errors") or []
  461. assert any(e.get("code") == "0x4038" for e in errs)
  462. class TestAMSDataMerging:
  463. """Tests for AMS data merging, particularly handling empty slots."""
  464. @pytest.fixture
  465. def mqtt_client(self):
  466. """Create a BambuMQTTClient instance for testing."""
  467. from backend.app.services.bambu_mqtt import BambuMQTTClient
  468. client = BambuMQTTClient(
  469. ip_address="192.168.1.100",
  470. serial_number="TEST123",
  471. access_code="12345678",
  472. )
  473. return client
  474. def test_empty_slot_clears_tray_type(self, mqtt_client):
  475. """Test that empty slot update clears tray_type (Issue #147).
  476. When a spool is removed from an old AMS, the printer sends empty values.
  477. These must overwrite the previous values to show the slot as empty.
  478. """
  479. # Initial state: AMS unit with a loaded spool
  480. initial_ams = {
  481. "ams": [
  482. {
  483. "id": 0,
  484. "tray": [
  485. {
  486. "id": 0,
  487. "tray_type": "PLA",
  488. "tray_sub_brands": "Bambu PLA Basic",
  489. "tray_color": "FF0000",
  490. "tag_uid": "1234567890ABCDEF",
  491. "remain": 80,
  492. }
  493. ],
  494. }
  495. ]
  496. }
  497. mqtt_client._handle_ams_data(initial_ams)
  498. # Verify initial state
  499. ams_data = mqtt_client.state.raw_data.get("ams", [])
  500. assert len(ams_data) == 1
  501. tray = ams_data[0]["tray"][0]
  502. assert tray["tray_type"] == "PLA"
  503. assert tray["tray_color"] == "FF0000"
  504. # Now simulate spool removal - printer sends empty values
  505. empty_update = {
  506. "ams": [
  507. {
  508. "id": 0,
  509. "tray": [
  510. {
  511. "id": 0,
  512. "tray_type": "", # Empty = slot is empty
  513. "tray_sub_brands": "",
  514. "tray_color": "",
  515. "tag_uid": "0000000000000000", # Zero UID
  516. "remain": 0,
  517. }
  518. ],
  519. }
  520. ]
  521. }
  522. mqtt_client._handle_ams_data(empty_update)
  523. # Verify empty values were applied (not ignored by merge logic)
  524. ams_data = mqtt_client.state.raw_data.get("ams", [])
  525. tray = ams_data[0]["tray"][0]
  526. assert tray["tray_type"] == "", "tray_type should be cleared when slot is empty"
  527. assert tray["tray_color"] == "", "tray_color should be cleared when slot is empty"
  528. assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
  529. assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
  530. def test_bitmask_only_ht_removal_fires_on_ams_change(self, mqtt_client):
  531. """#2670: an AMS-HT whose spool is removed can be signalled by
  532. tray_exist_bits alone while the firmware keeps echoing the stale
  533. tray_type/tag_uid/remain in the tray payload. apply_tray_exist_bits
  534. clears the merged slot, but a change-hash built from the RAW payload
  535. never flips (the echoed fields are unchanged), so on_ams_change would
  536. not fire and the spool_assignment row would stay bound to an empty slot.
  537. Hashing the MERGED state fixes it.
  538. """
  539. from unittest.mock import Mock
  540. mqtt_client.on_ams_change = Mock()
  541. # Loaded HT-A: bit 16 set (0x10000). Fires once as the initial state.
  542. loaded = {
  543. "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tag_uid": "C7EFC10300000100", "remain": 75}]}],
  544. "tray_exist_bits": "10000",
  545. "power_on_flag": True,
  546. }
  547. mqtt_client._handle_ams_data(loaded)
  548. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == "PLA"
  549. mqtt_client.on_ams_change.reset_mock()
  550. # Removal signalled ONLY by the bitmask: bit 16 now clear, but the tray
  551. # payload STILL echoes the same PLA/tag/remain — a raw-based hash would
  552. # be byte-identical to the loaded push above and never fire.
  553. bitmask_only_removal = {
  554. "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tag_uid": "C7EFC10300000100", "remain": 75}]}],
  555. "tray_exist_bits": "0",
  556. "power_on_flag": True,
  557. }
  558. mqtt_client._handle_ams_data(bitmask_only_removal)
  559. # Merged slot cleared, and the callback fired off the merged-state hash.
  560. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
  561. mqtt_client.on_ams_change.assert_called_once()
  562. def test_partial_update_preserves_other_fields(self, mqtt_client):
  563. """Test that partial updates still preserve non-slot-status fields."""
  564. # Initial state with full data
  565. initial_ams = {
  566. "ams": [
  567. {
  568. "id": 0,
  569. "humidity": "3",
  570. "temp": "25.5",
  571. "tray": [
  572. {
  573. "id": 0,
  574. "tray_type": "PLA",
  575. "tray_color": "00FF00",
  576. "remain": 90,
  577. "k": 0.02,
  578. }
  579. ],
  580. }
  581. ]
  582. }
  583. mqtt_client._handle_ams_data(initial_ams)
  584. # Partial update - only remain changes
  585. partial_update = {
  586. "ams": [
  587. {
  588. "id": 0,
  589. "tray": [
  590. {
  591. "id": 0,
  592. "remain": 85, # Only this changed
  593. }
  594. ],
  595. }
  596. ]
  597. }
  598. mqtt_client._handle_ams_data(partial_update)
  599. # Verify remain was updated but other fields preserved
  600. ams_data = mqtt_client.state.raw_data.get("ams", [])
  601. tray = ams_data[0]["tray"][0]
  602. assert tray["remain"] == 85, "remain should be updated"
  603. assert tray["tray_type"] == "PLA", "tray_type should be preserved"
  604. assert tray["tray_color"] == "00FF00", "tray_color should be preserved"
  605. assert tray["k"] == 0.02, "k should be preserved"
  606. def test_tray_exist_bits_clears_empty_slots(self, mqtt_client):
  607. """Test that tray_exist_bits clears slots marked as empty (Issue #147).
  608. New AMS models (AMS 2 Pro) don't send empty tray data when a spool is removed.
  609. Instead, they update tray_exist_bits to indicate which slots have spools.
  610. """
  611. # Initial state: AMS 0 and AMS 1 with loaded spools
  612. initial_ams = {
  613. "ams": [
  614. {
  615. "id": 0,
  616. "tray": [
  617. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
  618. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
  619. {"id": 2, "tray_type": "ABS", "tray_color": "0000FF", "remain": 40},
  620. {"id": 3, "tray_type": "TPU", "tray_color": "FFFF00", "remain": 20},
  621. ],
  622. },
  623. {
  624. "id": 1,
  625. "tray": [
  626. {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFF", "remain": 90},
  627. {"id": 1, "tray_type": "PLA", "tray_color": "000000", "remain": 70},
  628. {"id": 2, "tray_type": "PLA", "tray_color": "FF00FF", "remain": 50},
  629. {"id": 3, "tray_type": "PLA", "tray_color": "00FFFF", "remain": 30},
  630. ],
  631. },
  632. ],
  633. "tray_exist_bits": "ff", # All 8 slots have spools (0xFF = 11111111)
  634. }
  635. mqtt_client._handle_ams_data(initial_ams)
  636. # Verify initial state
  637. ams_data = mqtt_client.state.raw_data.get("ams", [])
  638. assert ams_data[1]["tray"][3]["tray_type"] == "PLA" # AMS 1 slot 3 (B4) has spool
  639. # Now simulate spool removal from AMS 1 slot 3 (B4)
  640. # tray_exist_bits: 0x7f = 01111111 (bit 7 = 0 means AMS 1 slot 3 is empty)
  641. update_ams = {
  642. "ams": [
  643. {"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
  644. {"id": 1, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
  645. ],
  646. "tray_exist_bits": "7f", # Bit 7 = 0 -> AMS 1 slot 3 is empty
  647. }
  648. mqtt_client._handle_ams_data(update_ams)
  649. # Verify AMS 1 slot 3 was cleared
  650. ams_data = mqtt_client.state.raw_data.get("ams", [])
  651. b4_tray = ams_data[1]["tray"][3]
  652. assert b4_tray["tray_type"] == "", "tray_type should be cleared for empty slot"
  653. assert b4_tray["remain"] == 0, "remain should be 0 for empty slot"
  654. # Verify other slots are preserved
  655. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
  656. assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
  657. def test_tray_exist_bits_promotes_empty_slot_to_state_9(self, mqtt_client):
  658. """#1322 follow-up by @RosdasHH: the previous fix only caught the bare
  659. {"id": N} payload firmware sends right after a printer restart. In
  660. steady-state operation firmware sends a populated payload and signals
  661. emptiness via tray_exist_bits — the canonical BambuStudio detection.
  662. The bitmask handler now promotes empty slots to state=9 so the rest
  663. of the app (API serializer, inventory short-circuit, AMS card) sees
  664. one signal instead of guessing from payload shape.
  665. State must be int 9, not "9" — `tray_state in {9, 10}` downstream
  666. uses `==` comparison and would silently miss a string.
  667. """
  668. initial_ams = {
  669. "ams": [
  670. {
  671. "id": 0,
  672. "tray": [
  673. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "state": 11, "remain": 80},
  674. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "state": 11, "remain": 60},
  675. ],
  676. }
  677. ],
  678. "tray_exist_bits": "3", # both slots occupied (0b11)
  679. }
  680. mqtt_client._handle_ams_data(initial_ams)
  681. # Slot 1 goes empty — populated payload, only the bitmask says so.
  682. update_ams = {
  683. "ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}],
  684. "tray_exist_bits": "1", # slot 1 now empty (0b01)
  685. }
  686. mqtt_client._handle_ams_data(update_ams)
  687. slot1 = mqtt_client.state.raw_data["ams"][0]["tray"][1]
  688. assert slot1["state"] == 9, "empty-by-bitmask slot must report state=9"
  689. assert isinstance(slot1["state"], int), "state must be int for downstream == comparison"
  690. # Loaded slot keeps its firmware state unchanged.
  691. slot0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  692. assert slot0["state"] == 11, "loaded slot must keep its firmware state"
  693. def test_tray_exist_bits_does_not_change_state_on_loaded_slots(self, mqtt_client):
  694. """Belt and suspenders for the negative path: the new state=9
  695. promotion must fire ONLY when the bitmask bit is 0. A loaded slot
  696. with state=3 (or any other non-9 firmware value) must pass through
  697. untouched, or we'd corrupt every printer that sends transitional
  698. states like 'unloading'."""
  699. initial_ams = {
  700. "ams": [
  701. {
  702. "id": 0,
  703. "tray": [
  704. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "state": 3, "remain": 80},
  705. ],
  706. }
  707. ],
  708. "tray_exist_bits": "1", # slot occupied
  709. }
  710. mqtt_client._handle_ams_data(initial_ams)
  711. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["state"] == 3
  712. def test_shutdown_message_preserves_ams_data(self, mqtt_client):
  713. """Printer shutdown (power_on_flag=False) must not wipe AMS slot data (#765).
  714. When a printer shuts down it sends a final MQTT message with
  715. tray_exist_bits='0' and power_on_flag=False. This all-zero value
  716. previously caused every slot to be cleared, which then triggered
  717. auto-unlink of all spool assignments on reconnect.
  718. """
  719. # Initial state: two AMS units with loaded spools
  720. initial_ams = {
  721. "ams": [
  722. {
  723. "id": 0,
  724. "tray": [
  725. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "remain": 80},
  726. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF", "remain": 60},
  727. ],
  728. },
  729. {
  730. "id": 1,
  731. "tray": [
  732. {"id": 0, "tray_type": "PETG", "tray_color": "DBDDD9FF", "remain": 90},
  733. {"id": 1, "tray_type": "PETG", "tray_color": "67DB25FF", "remain": 70},
  734. ],
  735. },
  736. ],
  737. "tray_exist_bits": "33", # Slots 0,1 of each AMS (0b00110011)
  738. "power_on_flag": True,
  739. }
  740. mqtt_client._handle_ams_data(initial_ams)
  741. # Verify initial state
  742. ams_data = mqtt_client.state.raw_data["ams"]
  743. assert ams_data[0]["tray"][0]["tray_type"] == "PLA"
  744. assert ams_data[1]["tray"][0]["tray_type"] == "PETG"
  745. # Simulate printer shutdown — all-zero bits with power_on_flag=False
  746. shutdown_ams = {
  747. "ams_exist_bits": "0",
  748. "tray_exist_bits": "0",
  749. "power_on_flag": False,
  750. "insert_flag": False,
  751. "tray_now": "0",
  752. "version": 0,
  753. }
  754. mqtt_client._handle_ams_data(shutdown_ams)
  755. # AMS slot data MUST be preserved — shutdown should not clear it
  756. ams_data = mqtt_client.state.raw_data["ams"]
  757. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Shutdown must not clear AMS 0 slot 0"
  758. assert ams_data[0]["tray"][0]["tray_color"] == "FF0000FF", "Shutdown must not clear AMS 0 slot 0 color"
  759. assert ams_data[0]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 0 slot 1"
  760. assert ams_data[1]["tray"][0]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 0"
  761. assert ams_data[1]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 1"
  762. def test_genuine_removal_still_clears_with_power_on(self, mqtt_client):
  763. """Genuine spool removal (power_on_flag=True) must still clear slot data.
  764. Ensures the #765 fix doesn't break normal spool removal detection.
  765. """
  766. # Initial state: AMS with loaded spool
  767. initial_ams = {
  768. "ams": [
  769. {
  770. "id": 0,
  771. "tray": [
  772. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
  773. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
  774. ],
  775. },
  776. ],
  777. "tray_exist_bits": "3", # Both slots occupied (0b11)
  778. "power_on_flag": True,
  779. }
  780. mqtt_client._handle_ams_data(initial_ams)
  781. # Spool removed from slot 1 while printer is running
  782. removal_ams = {
  783. "ams": [
  784. {
  785. "id": 0,
  786. "tray": [{"id": 0}, {"id": 1}],
  787. },
  788. ],
  789. "tray_exist_bits": "1", # Only slot 0 occupied (0b01)
  790. "power_on_flag": True,
  791. }
  792. mqtt_client._handle_ams_data(removal_ams)
  793. # Slot 0 preserved, slot 1 cleared
  794. ams_data = mqtt_client.state.raw_data["ams"]
  795. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Slot 0 should be preserved"
  796. assert ams_data[0]["tray"][1]["tray_type"] == "", "Slot 1 should be cleared on removal"
  797. assert ams_data[0]["tray"][1]["tray_color"] == "", "Slot 1 color should be cleared"
  798. def test_power_on_flag_defaults_true_when_absent(self, mqtt_client):
  799. """When power_on_flag is not in the MQTT data, clearing must proceed normally.
  800. Ensures backwards compatibility with firmware that doesn't send power_on_flag.
  801. """
  802. # Initial state
  803. initial_ams = {
  804. "ams": [
  805. {
  806. "id": 0,
  807. "tray": [
  808. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
  809. ],
  810. },
  811. ],
  812. "tray_exist_bits": "1",
  813. }
  814. mqtt_client._handle_ams_data(initial_ams)
  815. # Update WITHOUT power_on_flag — should still clear when bit=0
  816. update_ams = {
  817. "ams": [{"id": 0, "tray": [{"id": 0}]}],
  818. "tray_exist_bits": "0",
  819. # No power_on_flag key at all
  820. }
  821. mqtt_client._handle_ams_data(update_ams)
  822. ams_data = mqtt_client.state.raw_data["ams"]
  823. assert ams_data[0]["tray"][0]["tray_type"] == "", (
  824. "Without power_on_flag, clearing should proceed (defaults to True)"
  825. )
  826. def test_idle_printer_with_power_off_and_nonzero_bits_clears_removed_slot(self, mqtt_client):
  827. """Spool removal on an idle X1C must be detected even when power_on_flag=False (#1365).
  828. On some X1C firmware (e.g. 01.08.02.00 reported by an3k) the AMS keeps
  829. publishing push_status with `power_on_flag: False` while the printer
  830. sits idle between prints — but `tray_exist_bits` continues to reflect
  831. the real slot inventory. The original #765 guard skipped clearing
  832. whenever power_on_flag was false, so the bit transition that would
  833. mark a slot empty was discarded and the only way to refresh state
  834. was a manual reconnect (pushall). The guard now skips clearing only
  835. on the exact shutdown pattern (zero bits + power_on_flag=False).
  836. """
  837. # Initial state: two AMS units, slot 1 of AMS 0 loaded (the one
  838. # we'll later remove).
  839. initial_ams = {
  840. "ams": [
  841. {
  842. "id": 0,
  843. "tray": [
  844. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "remain": 80},
  845. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF", "remain": 60},
  846. ],
  847. },
  848. {
  849. "id": 1,
  850. "tray": [
  851. {"id": 0, "tray_type": "PETG", "tray_color": "DBDDD9FF", "remain": 90},
  852. ],
  853. },
  854. ],
  855. "tray_exist_bits": "13", # 0b00010011 — AMS0 slots 0+1, AMS1 slot 0
  856. "power_on_flag": True,
  857. }
  858. mqtt_client._handle_ams_data(initial_ams)
  859. assert mqtt_client.state.raw_data["ams"][0]["tray"][1]["tray_type"] == "PETG"
  860. # Spool pulled from AMS 0 slot 1 while the printer is idle.
  861. # tray_exist_bits goes from 0x13 -> 0x11, but firmware still reports
  862. # power_on_flag=False because the printer is between prints. The real
  863. # push_status payloads on the affected X1C still carry the full `ams`
  864. # list (matches the bug-report log) — the slot inventory shrinks via
  865. # the bitfield rather than via per-tray content updates.
  866. removal_ams = {
  867. "ams": [
  868. {"id": 0, "tray": [{"id": 0}, {"id": 1}]},
  869. {"id": 1, "tray": [{"id": 0}]},
  870. ],
  871. "tray_exist_bits": "11", # 0b00010001 — slot 1 now empty
  872. "power_on_flag": False,
  873. "insert_flag": True,
  874. }
  875. mqtt_client._handle_ams_data(removal_ams)
  876. ams_data = mqtt_client.state.raw_data["ams"]
  877. assert ams_data[0]["tray"][1]["tray_type"] == "", (
  878. "Removal must be detected even with power_on_flag=False when bits are non-zero (#1365)"
  879. )
  880. assert ams_data[0]["tray"][1]["tray_color"] == "", "Removed slot color must be cleared"
  881. # Other slots untouched.
  882. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "AMS0 slot 0 preserved"
  883. assert ams_data[1]["tray"][0]["tray_type"] == "PETG", "AMS1 slot 0 preserved"
  884. class TestAMSTrayStateClearning:
  885. """Tests for AMS tray state-based clearing (#784).
  886. Some printers (e.g. H2D) only send {id, state} in incremental MQTT
  887. updates when a tray is not fully loaded. state=11 means loaded;
  888. other values (9=empty, 10=spool present but filament not in feeder)
  889. should clear stale tray data that was set from an earlier pushall.
  890. """
  891. @pytest.fixture
  892. def mqtt_client(self):
  893. from backend.app.services.bambu_mqtt import BambuMQTTClient
  894. client = BambuMQTTClient(
  895. ip_address="192.168.1.100",
  896. serial_number="TEST_H2D",
  897. access_code="12345678",
  898. )
  899. return client
  900. def _seed_loaded_tray(self, mqtt_client):
  901. """Seed AMS 0 with a fully loaded tray (state=11) and an empty slot."""
  902. initial = {
  903. "ams": [
  904. {
  905. "id": 0,
  906. "tray": [
  907. {
  908. "id": 0,
  909. "tray_type": "PETG",
  910. "tray_sub_brands": "PETG HF",
  911. "tray_color": "00FF00FF",
  912. "tray_id_name": "A00-G1",
  913. "tray_info_idx": "GFG99",
  914. "tag_uid": "AABBCCDD11223344",
  915. "tray_uuid": "AABBCCDD11223344AABBCCDD11223344",
  916. "remain": 75,
  917. "k": 0.02,
  918. "cali_idx": 5,
  919. "state": 11,
  920. },
  921. {
  922. "id": 1,
  923. "tray_type": "PLA",
  924. "tray_color": "FF0000FF",
  925. "remain": 50,
  926. "state": 11,
  927. },
  928. ],
  929. }
  930. ],
  931. "power_on_flag": False, # H2D always sends False
  932. }
  933. mqtt_client._handle_ams_data(initial)
  934. ams = mqtt_client.state.raw_data["ams"]
  935. assert ams[0]["tray"][0]["tray_type"] == "PETG"
  936. assert ams[0]["tray"][1]["tray_type"] == "PLA"
  937. def test_state_10_clears_stale_tray_data(self, mqtt_client):
  938. """Incremental update with state=10 (spool present, not loaded) clears tray."""
  939. self._seed_loaded_tray(mqtt_client)
  940. # H2D sends only {id, state} when filament is retracted
  941. update = {
  942. "ams": [
  943. {
  944. "id": 0,
  945. "tray": [
  946. {"id": 0, "state": 10},
  947. {"id": 1, "state": 11}, # slot 1 still loaded
  948. ],
  949. }
  950. ],
  951. "power_on_flag": False,
  952. }
  953. mqtt_client._handle_ams_data(update)
  954. ams = mqtt_client.state.raw_data["ams"]
  955. tray0 = ams[0]["tray"][0]
  956. tray1 = ams[0]["tray"][1]
  957. # Tray 0 should be cleared
  958. assert tray0["tray_type"] == "", "tray_type must be cleared on state=10"
  959. assert tray0["tray_color"] == "", "tray_color must be cleared"
  960. assert tray0["tray_sub_brands"] == "", "tray_sub_brands must be cleared"
  961. assert tray0["tray_id_name"] == "", "tray_id_name must be cleared"
  962. assert tray0["tray_info_idx"] == "", "tray_info_idx must be cleared"
  963. assert tray0["tag_uid"] == "0000000000000000", "tag_uid must be cleared"
  964. assert tray0["tray_uuid"] == "00000000000000000000000000000000", "tray_uuid must be cleared"
  965. assert tray0["remain"] == 0, "remain must be 0"
  966. assert tray0["k"] is None, "k must be cleared"
  967. assert tray0["cali_idx"] is None, "cali_idx must be cleared"
  968. assert tray0["state"] == 10, "state should be preserved"
  969. # Tray 1 should be untouched
  970. assert tray1["tray_type"] == "PLA", "Loaded slot must be preserved"
  971. assert tray1["remain"] == 50
  972. def test_state_9_clears_stale_tray_data(self, mqtt_client):
  973. """Incremental update with state=9 (empty, no spool) clears tray."""
  974. self._seed_loaded_tray(mqtt_client)
  975. update = {
  976. "ams": [
  977. {
  978. "id": 0,
  979. "tray": [
  980. {"id": 0, "state": 9},
  981. {"id": 1, "state": 11},
  982. ],
  983. }
  984. ],
  985. "power_on_flag": False,
  986. }
  987. mqtt_client._handle_ams_data(update)
  988. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  989. assert tray0["tray_type"] == "", "state=9 must clear tray_type"
  990. assert tray0["remain"] == 0
  991. def test_state_11_preserves_tray_data(self, mqtt_client):
  992. """Incremental update with state=11 (loaded) must NOT clear tray."""
  993. self._seed_loaded_tray(mqtt_client)
  994. update = {
  995. "ams": [
  996. {
  997. "id": 0,
  998. "tray": [
  999. {"id": 0, "state": 11},
  1000. {"id": 1, "state": 11},
  1001. ],
  1002. }
  1003. ],
  1004. "power_on_flag": False,
  1005. }
  1006. mqtt_client._handle_ams_data(update)
  1007. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  1008. assert tray0["tray_type"] == "PETG", "state=11 must preserve tray data"
  1009. assert tray0["tray_color"] == "00FF00FF"
  1010. assert tray0["remain"] == 75
  1011. def test_no_clearing_when_tray_type_already_empty(self, mqtt_client):
  1012. """Don't re-clear a tray that's already empty (avoids log spam)."""
  1013. self._seed_loaded_tray(mqtt_client)
  1014. # First unload clears
  1015. update = {
  1016. "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}],
  1017. "power_on_flag": False,
  1018. }
  1019. mqtt_client._handle_ams_data(update)
  1020. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
  1021. # Second identical update should not trigger clearing again
  1022. # (merged_tray.get("tray_type") is already empty/falsy)
  1023. mqtt_client._handle_ams_data(update)
  1024. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
  1025. def test_reload_after_unload_restores_data(self, mqtt_client):
  1026. """After clearing via state=10, a full update with state=11 restores data."""
  1027. self._seed_loaded_tray(mqtt_client)
  1028. # Unload
  1029. mqtt_client._handle_ams_data(
  1030. {
  1031. "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}],
  1032. "power_on_flag": False,
  1033. }
  1034. )
  1035. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
  1036. # Reload — full tray data arrives again
  1037. mqtt_client._handle_ams_data(
  1038. {
  1039. "ams": [
  1040. {
  1041. "id": 0,
  1042. "tray": [
  1043. {
  1044. "id": 0,
  1045. "tray_type": "PETG",
  1046. "tray_sub_brands": "PETG HF",
  1047. "tray_color": "00FF00FF",
  1048. "remain": 75,
  1049. "state": 11,
  1050. },
  1051. {"id": 1, "state": 11},
  1052. ],
  1053. }
  1054. ],
  1055. "power_on_flag": False,
  1056. }
  1057. )
  1058. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  1059. assert tray0["tray_type"] == "PETG", "Reload must restore tray data"
  1060. assert tray0["tray_color"] == "00FF00FF"
  1061. assert tray0["remain"] == 75
  1062. def _seed_loaded_ht_tray(self, mqtt_client):
  1063. """Seed an AMS-HT unit (id 128, single tray) whose loaded tray reports
  1064. state=9 — the real HT resting state, unlike a 4-slot AMS's state=11."""
  1065. initial = {
  1066. "ams": [
  1067. {
  1068. "id": 128,
  1069. "tray": [
  1070. {
  1071. "id": 0,
  1072. "tray_type": "PA",
  1073. "tray_color": "161616FF",
  1074. "tray_info_idx": "GFG99",
  1075. "tag_uid": "AABBCCDD11223344",
  1076. "tray_uuid": "AABBCCDD11223344AABBCCDD11223344",
  1077. "remain": 60,
  1078. "state": 9,
  1079. }
  1080. ],
  1081. }
  1082. ],
  1083. "power_on_flag": True,
  1084. }
  1085. mqtt_client._handle_ams_data(initial)
  1086. def test_ht_unit_state_9_preserves_tray_data(self, mqtt_client):
  1087. """#2594: an AMS-HT reports its loaded tray as state=9. A partial
  1088. {id, state=9} for the HT unit must NOT be read as 'empty' and wipe the
  1089. present spool — that made the HT-A spool vanish on every power-on."""
  1090. self._seed_loaded_ht_tray(mqtt_client)
  1091. # Printer sends a partial {id, state} for the HT tray on power-on.
  1092. update = {
  1093. "ams": [{"id": 128, "tray": [{"id": 0, "state": 9}]}],
  1094. "power_on_flag": True,
  1095. }
  1096. mqtt_client._handle_ams_data(update)
  1097. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  1098. assert tray0["tray_type"] == "PA", "HT state=9 must NOT clear a present spool (#2594)"
  1099. assert tray0["tray_uuid"] == "AABBCCDD11223344AABBCCDD11223344", "HT RFID must survive"
  1100. assert tray0["remain"] == 60
  1101. def test_ht_unit_explicit_empty_still_clears(self, mqtt_client):
  1102. """A genuine HT spool removal (explicit tray_type='') must still clear —
  1103. the #2594 fix only skips the state-heuristic, not the explicit path."""
  1104. self._seed_loaded_ht_tray(mqtt_client)
  1105. update = {
  1106. "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": ""}]}],
  1107. "power_on_flag": False,
  1108. }
  1109. mqtt_client._handle_ams_data(update)
  1110. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  1111. assert tray0["tray_type"] == "", "explicit tray_type='' must still clear an HT tray"
  1112. class TestApplyTrayExistBitsHelper:
  1113. """Direct contract pinning for the shared ``apply_tray_exist_bits`` helper.
  1114. The same logic is exercised end-to-end via ``_handle_ams_data`` in the
  1115. internal-state suite and via ``_on_printer_raw`` in the bridge suite,
  1116. but those go through the merge / cache layers — the helper itself
  1117. deserves direct coverage so future refactors don't silently change
  1118. the contract both callers depend on (#1726).
  1119. """
  1120. def test_returns_zero_on_missing_bits(self):
  1121. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1122. units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]
  1123. assert apply_tray_exist_bits(units, None) == 0
  1124. assert apply_tray_exist_bits(units, "") == 0
  1125. # Untouched.
  1126. assert units[0]["tray"][0]["tray_type"] == "PLA"
  1127. def test_returns_zero_on_unparseable_bits(self):
  1128. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1129. units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]
  1130. assert apply_tray_exist_bits(units, "garbage") == 0
  1131. assert units[0]["tray"][0]["tray_type"] == "PLA"
  1132. def test_shutdown_guard_zero_bits_with_power_off_skips(self):
  1133. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1134. units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}]
  1135. cleared = apply_tray_exist_bits(units, "0", power_on_flag=False)
  1136. assert cleared == 0
  1137. # Slot preserved — wiping here would propagate phantom empties on
  1138. # every printer-off push.
  1139. assert units[0]["tray"][0]["tray_type"] == "PLA"
  1140. def test_zero_bits_with_power_on_still_clears(self):
  1141. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1142. units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}]
  1143. cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
  1144. # Slot is genuinely empty per the printer's report.
  1145. assert cleared == 1
  1146. assert units[0]["tray"][0]["state"] == 9
  1147. assert units[0]["tray"][0]["tray_type"] == ""
  1148. def test_nonzero_bits_with_power_off_still_clears_removed_slot(self):
  1149. """#1365: X1C reports power_on_flag=False between prints while the
  1150. AMS keeps reporting its actual slot inventory. The guard must skip
  1151. ONLY the all-zero + power-off combination, not nonzero + power-off.
  1152. """
  1153. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1154. units = [
  1155. {
  1156. "id": 0,
  1157. "tray": [
  1158. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"},
  1159. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF"},
  1160. ],
  1161. }
  1162. ]
  1163. # 0x1 = slot 0 loaded, slot 1 empty. Power off (steady-state idle).
  1164. cleared = apply_tray_exist_bits(units, "1", power_on_flag=False)
  1165. assert cleared == 1
  1166. assert units[0]["tray"][0]["tray_type"] == "PLA"
  1167. assert units[0]["tray"][1]["tray_type"] == ""
  1168. def test_promotes_state_to_int_nine(self):
  1169. """Downstream `tray_state in {9, 10}` uses `==` — int 9, not "9"."""
  1170. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1171. units = [{"id": 0, "tray": [{"id": 0, "state": "11"}]}]
  1172. apply_tray_exist_bits(units, "0", power_on_flag=True)
  1173. assert units[0]["tray"][0]["state"] == 9
  1174. assert isinstance(units[0]["tray"][0]["state"], int)
  1175. def test_ams_ht_unit_now_handled_not_skipped(self):
  1176. """AMS-HT (id 128-135) is no longer skipped: it uses its own bit at
  1177. 16+(ams_id-128). With all bits 0 the HT slot clears like any other
  1178. (#2670 — the old skip left the HT permanently stale). See the dedicated
  1179. HT bit-math tests below for the encoding."""
  1180. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1181. units = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]}]
  1182. cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
  1183. assert cleared == 1
  1184. assert units[0]["tray"][0]["tray_type"] == ""
  1185. def test_string_ids_handled(self):
  1186. """Bridge cache stores ids as strings (JSON wire format)."""
  1187. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1188. units = [
  1189. {
  1190. "id": "0",
  1191. "tray": [
  1192. {"id": "0", "tray_type": "PLA"},
  1193. {"id": "1", "tray_type": "PETG"},
  1194. ],
  1195. }
  1196. ]
  1197. # 0x1 = bit 0 set (slot 0), bit 1 clear (slot 1 empty).
  1198. cleared = apply_tray_exist_bits(units, "1", power_on_flag=True)
  1199. assert cleared == 1
  1200. assert units[0]["tray"][0]["tray_type"] == "PLA"
  1201. assert units[0]["tray"][1]["tray_type"] == ""
  1202. def test_multi_ams_global_bit_math(self):
  1203. """global_bit = ams_id * 4 + tray_id. Verify AMS 1 slots use
  1204. bits 4-7 of the mask, not bits 0-3."""
  1205. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1206. units = [
  1207. {"id": 0, "tray": [{"id": i, "tray_type": "PLA"} for i in range(4)]},
  1208. {"id": 1, "tray": [{"id": i, "tray_type": "PETG"} for i in range(4)]},
  1209. ]
  1210. # 0x0f: all slots of AMS 0 loaded, all slots of AMS 1 empty.
  1211. cleared = apply_tray_exist_bits(units, "f", power_on_flag=True)
  1212. assert cleared == 4
  1213. for i in range(4):
  1214. assert units[0]["tray"][i]["tray_type"] == "PLA"
  1215. assert units[1]["tray"][i]["tray_type"] == ""
  1216. def test_state_promoted_even_when_no_stale_data(self):
  1217. """Slot without `tray_type` still gets state=9 — the bitmask is
  1218. authoritative, the field wipe just avoids extra log lines.
  1219. """
  1220. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1221. units = [{"id": 0, "tray": [{"id": 0, "state": "11"}]}]
  1222. cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
  1223. # No tray_type to clear → cleared counter stays 0 but state is set.
  1224. assert cleared == 0
  1225. assert units[0]["tray"][0]["state"] == 9
  1226. def test_annotate_exists_marks_present_and_absent(self):
  1227. """#2527: annotate_exists writes the tray_exist_bits presence bit onto
  1228. every slot so a non-RFID spool (present, no tray_type) is distinguishable
  1229. from a truly-empty slot. 0x5 = slots 0,2 present; slots 1,3 absent."""
  1230. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1231. units = [{"id": 0, "tray": [{"id": i} for i in range(4)]}]
  1232. apply_tray_exist_bits(units, "5", power_on_flag=True, annotate_exists=True)
  1233. exists = [t["exists"] for t in units[0]["tray"]]
  1234. assert exists == [True, False, True, False]
  1235. def test_annotate_exists_present_unknown_slot_not_cleared(self):
  1236. """A present slot with no tray_type (fresh non-RFID spool) keeps its
  1237. state and is marked exists=True — the UI then shows "?" not "Empty"."""
  1238. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1239. # 0x1 = slot 0 present. Slot 0 has no tray_type (unidentified spool).
  1240. units = [{"id": 0, "tray": [{"id": 0, "state": 9}]}]
  1241. cleared = apply_tray_exist_bits(units, "1", power_on_flag=True, annotate_exists=True)
  1242. assert cleared == 0
  1243. assert units[0]["tray"][0]["exists"] is True
  1244. # Present slot is left untouched (only absent slots get state=9 forced).
  1245. assert units[0]["tray"][0]["state"] == 9
  1246. def test_annotate_exists_off_by_default_keeps_wire_clean(self):
  1247. """The VP bridge calls this without annotate_exists, so the slicer-facing
  1248. tray dict must NOT gain a non-standard `exists` key."""
  1249. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1250. units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1}]}]
  1251. apply_tray_exist_bits(units, "1", power_on_flag=True)
  1252. assert "exists" not in units[0]["tray"][0]
  1253. assert "exists" not in units[0]["tray"][1]
  1254. def test_ht_unit_presence_bit_is_16_not_ams_id_times_4(self):
  1255. """#2670: AMS-HT (n3s, id 128) is single-tray; its presence bit is
  1256. 16+(ams_id-128)=bit 16, NOT ams_id*4 (=512, which the old code skipped
  1257. outright, so the HT slot never cleared). Real H2D capture: loaded HT-A
  1258. reports tray_exist_bits 0x10f7f (bit 16 set); after unload it reports
  1259. 0xf7f (bit 16 clear). Verified against OrcaSlicer DevFilaSystem.cpp."""
  1260. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1261. # Loaded (bit 16 set) → slot preserved, exists=True.
  1262. loaded = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "000000FF"}]}]
  1263. assert apply_tray_exist_bits(loaded, "10f7f", power_on_flag=False, annotate_exists=True) == 0
  1264. assert loaded[0]["tray"][0]["tray_type"] == "PLA"
  1265. assert loaded[0]["tray"][0]["exists"] is True
  1266. # Empty (bit 16 clear) → slot cleared, state forced to 9, exists=False.
  1267. empty = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "000000FF"}]}]
  1268. assert apply_tray_exist_bits(empty, "f7f", power_on_flag=False, annotate_exists=True) == 1
  1269. assert empty[0]["tray"][0]["tray_type"] == ""
  1270. assert empty[0]["tray"][0]["state"] == 9
  1271. assert empty[0]["tray"][0]["exists"] is False
  1272. def test_ht_second_unit_is_bit_17_not_bit_20(self):
  1273. """#2670: multiple AMS-HT units pack into CONSECUTIVE bits — HT-A=16,
  1274. HT-B=17 — NOT the 4-strided bit 20 a naive ams_id*4-style extrapolation
  1275. would give. This pins the exact encoding OrcaSlicer uses and guards every
  1276. dual-HT printer. 0x20000 sets ONLY bit 17: a bit-20 implementation would
  1277. wrongly clear this loaded HT-B."""
  1278. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1279. units = [{"id": 129, "tray": [{"id": 0, "tray_type": "PETG", "tray_color": "00FF00FF"}]}]
  1280. assert apply_tray_exist_bits(units, "20000", power_on_flag=False, annotate_exists=True) == 0
  1281. assert units[0]["tray"][0]["tray_type"] == "PETG"
  1282. assert units[0]["tray"][0]["exists"] is True
  1283. def test_ht_dual_unit_clears_only_the_empty_one(self):
  1284. """HT-A loaded + HT-B empty in one push, disambiguated by their
  1285. consecutive bits. 0x10000 = bit 16 only → HT-A (128) present, HT-B
  1286. (129, bit 17) empty."""
  1287. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1288. units = [
  1289. {"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]},
  1290. {"id": 129, "tray": [{"id": 0, "tray_type": "PETG"}]},
  1291. ]
  1292. assert apply_tray_exist_bits(units, "10000", power_on_flag=False, annotate_exists=True) == 1
  1293. assert units[0]["tray"][0]["tray_type"] == "PLA" # HT-A present
  1294. assert units[0]["tray"][0]["exists"] is True
  1295. assert units[1]["tray"][0]["tray_type"] == "" # HT-B cleared
  1296. assert units[1]["tray"][0]["exists"] is False
  1297. def test_ht_and_regular_ams_use_their_own_formulas_together(self):
  1298. """Regular AMS keeps ams_id*4+tray_id while HT uses 16+(ams_id-128) in a
  1299. single call. bits 0x1 = regular AMS0 slot0 present (bit 0); HT-A empty
  1300. (bit 16 clear)."""
  1301. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1302. units = [
  1303. {"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]},
  1304. {"id": 128, "tray": [{"id": 0, "tray_type": "ASA"}]},
  1305. ]
  1306. assert apply_tray_exist_bits(units, "1", power_on_flag=True, annotate_exists=True) == 1
  1307. assert units[0]["tray"][0]["tray_type"] == "PLA" # regular AMS0 slot0 (bit 0) present
  1308. assert units[0]["tray"][0]["exists"] is True
  1309. assert units[1]["tray"][0]["tray_type"] == "" # HT-A (bit 16) empty
  1310. assert units[1]["tray"][0]["exists"] is False
  1311. def test_unknown_ams_id_range_is_left_untouched(self):
  1312. """An id outside regular (0-15) and HT (128-135) has no known bit layout
  1313. — the helper must NOT guess a bit and must NOT clear the slot."""
  1314. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  1315. units = [{"id": 200, "tray": [{"id": 0, "tray_type": "PLA"}]}]
  1316. assert apply_tray_exist_bits(units, "0", power_on_flag=True, annotate_exists=True) == 0
  1317. assert units[0]["tray"][0]["tray_type"] == "PLA"
  1318. assert "exists" not in units[0]["tray"][0]
  1319. class TestNozzleRackData:
  1320. """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
  1321. @pytest.fixture
  1322. def mqtt_client(self):
  1323. """Create a BambuMQTTClient instance for testing."""
  1324. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1325. client = BambuMQTTClient(
  1326. ip_address="192.168.1.100",
  1327. serial_number="TEST123",
  1328. access_code="12345678",
  1329. )
  1330. return client
  1331. def test_h2c_nozzle_rack_populated_with_8_entries(self, mqtt_client):
  1332. """H2C provides 8 nozzle entries: IDs 0,1 (L/R hotend) + 16-21 (rack)."""
  1333. payload = {
  1334. "print": {
  1335. "device": {
  1336. "nozzle": {
  1337. "info": [
  1338. {
  1339. "id": 0,
  1340. "type": "HS",
  1341. "diameter": "0.4",
  1342. "wear": 5,
  1343. "stat": 1,
  1344. "max_temp": 300,
  1345. "serial_number": "SN-L",
  1346. },
  1347. {
  1348. "id": 1,
  1349. "type": "HS",
  1350. "diameter": "0.4",
  1351. "wear": 3,
  1352. "stat": 0,
  1353. "max_temp": 300,
  1354. "serial_number": "SN-R",
  1355. },
  1356. {
  1357. "id": 16,
  1358. "type": "HS",
  1359. "diameter": "0.4",
  1360. "wear": 10,
  1361. "stat": 0,
  1362. "max_temp": 300,
  1363. "serial_number": "SN-16",
  1364. },
  1365. {
  1366. "id": 17,
  1367. "type": "HH01",
  1368. "diameter": "0.6",
  1369. "wear": 0,
  1370. "stat": 0,
  1371. "max_temp": 300,
  1372. "serial_number": "SN-17",
  1373. },
  1374. {
  1375. "id": 18,
  1376. "type": "HS",
  1377. "diameter": "0.4",
  1378. "wear": 2,
  1379. "stat": 0,
  1380. "max_temp": 300,
  1381. "serial_number": "SN-18",
  1382. },
  1383. {
  1384. "id": 19,
  1385. "type": "",
  1386. "diameter": "",
  1387. "wear": None,
  1388. "stat": None,
  1389. "max_temp": 0,
  1390. "serial_number": "",
  1391. },
  1392. {
  1393. "id": 20,
  1394. "type": "",
  1395. "diameter": "",
  1396. "wear": None,
  1397. "stat": None,
  1398. "max_temp": 0,
  1399. "serial_number": "",
  1400. },
  1401. {
  1402. "id": 21,
  1403. "type": "",
  1404. "diameter": "",
  1405. "wear": None,
  1406. "stat": None,
  1407. "max_temp": 0,
  1408. "serial_number": "",
  1409. },
  1410. ]
  1411. }
  1412. }
  1413. }
  1414. }
  1415. mqtt_client._process_message(payload)
  1416. assert len(mqtt_client.state.nozzle_rack) == 8
  1417. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  1418. assert ids == [0, 1, 16, 17, 18, 19, 20, 21]
  1419. def test_h2d_nozzle_rack_populated_with_2_entries(self, mqtt_client):
  1420. """H2D provides 2 nozzle entries: IDs 0,1 (L/R hotend) — no rack slots."""
  1421. payload = {
  1422. "print": {
  1423. "device": {
  1424. "nozzle": {
  1425. "info": [
  1426. {
  1427. "id": 0,
  1428. "type": "HS",
  1429. "diameter": "0.4",
  1430. "wear": 5,
  1431. "stat": 1,
  1432. "max_temp": 300,
  1433. "serial_number": "SN-L",
  1434. },
  1435. {
  1436. "id": 1,
  1437. "type": "HS",
  1438. "diameter": "0.4",
  1439. "wear": 3,
  1440. "stat": 1,
  1441. "max_temp": 300,
  1442. "serial_number": "SN-R",
  1443. },
  1444. ]
  1445. }
  1446. }
  1447. }
  1448. }
  1449. mqtt_client._process_message(payload)
  1450. assert len(mqtt_client.state.nozzle_rack) == 2
  1451. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  1452. assert ids == [0, 1]
  1453. def test_single_nozzle_h2s_populated(self, mqtt_client):
  1454. """H2S provides 1 nozzle entry: ID 0 only — single nozzle printer."""
  1455. payload = {
  1456. "print": {
  1457. "device": {
  1458. "nozzle": {
  1459. "info": [
  1460. {
  1461. "id": 0,
  1462. "type": "HS",
  1463. "diameter": "0.4",
  1464. "wear": 2,
  1465. "stat": 1,
  1466. "max_temp": 300,
  1467. "serial_number": "SN-0",
  1468. },
  1469. ]
  1470. }
  1471. }
  1472. }
  1473. }
  1474. mqtt_client._process_message(payload)
  1475. assert len(mqtt_client.state.nozzle_rack) == 1
  1476. assert mqtt_client.state.nozzle_rack[0]["id"] == 0
  1477. def test_empty_nozzle_info_does_not_populate_rack(self, mqtt_client):
  1478. """Empty nozzle info list should not populate nozzle_rack."""
  1479. payload = {"print": {"device": {"nozzle": {"info": []}}}}
  1480. mqtt_client._process_message(payload)
  1481. assert mqtt_client.state.nozzle_rack == []
  1482. def test_nozzle_rack_sorted_by_id(self, mqtt_client):
  1483. """Nozzle rack entries should be sorted by ID regardless of input order."""
  1484. payload = {
  1485. "print": {
  1486. "device": {
  1487. "nozzle": {
  1488. "info": [
  1489. {"id": 17, "type": "HS", "diameter": "0.6"},
  1490. {"id": 0, "type": "HS", "diameter": "0.4"},
  1491. {"id": 16, "type": "HS", "diameter": "0.4"},
  1492. {"id": 1, "type": "HS", "diameter": "0.4"},
  1493. ]
  1494. }
  1495. }
  1496. }
  1497. }
  1498. mqtt_client._process_message(payload)
  1499. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  1500. assert ids == [0, 1, 16, 17]
  1501. def test_nozzle_rack_field_mapping(self, mqtt_client):
  1502. """Verify field mapping from MQTT nozzle_info to nozzle_rack dict keys."""
  1503. payload = {
  1504. "print": {
  1505. "device": {
  1506. "nozzle": {
  1507. "info": [
  1508. {
  1509. "id": 16,
  1510. "type": "HH01",
  1511. "diameter": "0.6",
  1512. "wear": 15,
  1513. "stat": 0,
  1514. "max_temp": 320,
  1515. "serial_number": "SN-ABC123",
  1516. "filament_colour": "FF8800",
  1517. "filament_id": "F42",
  1518. "tray_type": "ABS",
  1519. }
  1520. ]
  1521. }
  1522. }
  1523. }
  1524. }
  1525. mqtt_client._process_message(payload)
  1526. slot = mqtt_client.state.nozzle_rack[0]
  1527. assert slot["id"] == 16
  1528. assert slot["type"] == "HH01"
  1529. assert slot["diameter"] == "0.6"
  1530. assert slot["wear"] == 15
  1531. assert slot["stat"] == 0
  1532. assert slot["max_temp"] == 320
  1533. assert slot["serial_number"] == "SN-ABC123"
  1534. assert slot["filament_color"] == "FF8800"
  1535. assert slot["filament_id"] == "F42"
  1536. assert slot["filament_type"] == "ABS"
  1537. def test_nozzle_info_updates_nozzle_state(self, mqtt_client):
  1538. """Nozzle info for IDs 0,1 should also update nozzle state (type/diameter)."""
  1539. payload = {
  1540. "print": {
  1541. "device": {
  1542. "nozzle": {
  1543. "info": [
  1544. {"id": 0, "type": "HS", "diameter": "0.4"},
  1545. {"id": 1, "type": "HH01", "diameter": "0.6"},
  1546. ]
  1547. }
  1548. }
  1549. }
  1550. }
  1551. mqtt_client._process_message(payload)
  1552. assert mqtt_client.state.nozzles[0].nozzle_type == "HS"
  1553. assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
  1554. assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
  1555. assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
  1556. class TestRequestTopicFailSafe:
  1557. """Tests for graceful degradation when broker rejects request topic subscription."""
  1558. @pytest.fixture(autouse=True)
  1559. def clear_request_topic_cache(self):
  1560. """Clear class-level cache before each test to avoid cross-test pollution."""
  1561. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1562. BambuMQTTClient._request_topic_cache.clear()
  1563. BambuMQTTClient._request_topic_probe_failures.clear()
  1564. @pytest.fixture
  1565. def mqtt_client(self):
  1566. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1567. client = BambuMQTTClient(
  1568. ip_address="192.168.1.100",
  1569. serial_number="TEST123",
  1570. access_code="12345678",
  1571. )
  1572. return client
  1573. def test_request_topic_supported_by_default(self, mqtt_client):
  1574. """Request topic subscription is attempted by default."""
  1575. assert mqtt_client._request_topic_supported is True
  1576. assert mqtt_client._request_topic_confirmed is False
  1577. def test_on_subscribe_confirms_success(self, mqtt_client):
  1578. """Successful SUBACK marks request topic as confirmed."""
  1579. from paho.mqtt.reasoncodes import ReasonCode
  1580. mqtt_client._request_topic_sub_mid = 42
  1581. rc = ReasonCode(9, identifier=0) # SUBACK packetType=9, QoS 0 = success
  1582. mqtt_client._on_subscribe(None, None, 42, [rc], None)
  1583. assert mqtt_client._request_topic_confirmed is True
  1584. assert mqtt_client._request_topic_supported is True
  1585. assert mqtt_client._request_topic_sub_mid is None
  1586. assert mqtt_client._request_topic_sub_time == 0.0
  1587. def test_on_subscribe_detects_rejection(self, mqtt_client):
  1588. """SUBACK with failure code disables request topic."""
  1589. from paho.mqtt.reasoncodes import ReasonCode
  1590. mqtt_client._request_topic_sub_mid = 42
  1591. rc = ReasonCode(9, identifier=0x80) # SUBACK packetType=9, 0x80 = failure
  1592. mqtt_client._on_subscribe(None, None, 42, [rc], None)
  1593. assert mqtt_client._request_topic_supported is False
  1594. assert mqtt_client._request_topic_confirmed is False
  1595. def test_on_subscribe_ignores_other_mids(self, mqtt_client):
  1596. """SUBACK for other subscriptions (e.g. report topic) is ignored."""
  1597. from paho.mqtt.reasoncodes import ReasonCode
  1598. mqtt_client._request_topic_sub_mid = 42
  1599. rc = ReasonCode(9, identifier=0x80)
  1600. mqtt_client._on_subscribe(None, None, 99, [rc], None)
  1601. # Not affected — mid doesn't match
  1602. assert mqtt_client._request_topic_supported is True
  1603. def test_disconnect_after_subscription_disables_topic(self, mqtt_client):
  1604. """Repeated disconnects within 10s of a subscription attempt disable the
  1605. request topic.
  1606. One is not enough (#2953). Any drop inside the window looks the same as
  1607. the broker refusing the topic, so the first is treated as circumstantial
  1608. and the subscription is retried on the next connection. A printer that
  1609. really does refuse answers the same way every time.
  1610. """
  1611. import time
  1612. mqtt_client._request_topic_sub_time = time.time()
  1613. mqtt_client._request_topic_confirmed = False
  1614. mqtt_client._last_message_time = 0.0
  1615. mqtt_client._on_disconnect(None, None)
  1616. assert mqtt_client._request_topic_supported is True
  1617. assert mqtt_client._request_topic_sub_time == 0.0
  1618. mqtt_client._request_topic_sub_time = time.time()
  1619. mqtt_client._on_disconnect(None, None)
  1620. assert mqtt_client._request_topic_supported is False
  1621. assert mqtt_client._request_topic_sub_time == 0.0
  1622. def test_disconnect_after_confirmation_does_not_disable(self, mqtt_client):
  1623. """Disconnect after SUBACK confirmation keeps request topic enabled."""
  1624. import time
  1625. mqtt_client._request_topic_sub_time = time.time()
  1626. mqtt_client._request_topic_confirmed = True
  1627. mqtt_client._last_message_time = 0.0
  1628. mqtt_client._on_disconnect(None, None)
  1629. assert mqtt_client._request_topic_supported is True
  1630. def test_late_disconnect_does_not_disable(self, mqtt_client):
  1631. """Disconnect long after subscription (>10s) doesn't blame request topic."""
  1632. import time
  1633. mqtt_client._request_topic_sub_time = time.time() - 30.0
  1634. mqtt_client._request_topic_confirmed = False
  1635. mqtt_client._last_message_time = 0.0
  1636. mqtt_client._on_disconnect(None, None)
  1637. assert mqtt_client._request_topic_supported is True
  1638. def test_on_connect_skips_request_topic_when_unsupported(self, mqtt_client):
  1639. """After marking unsupported, reconnect skips request topic subscription."""
  1640. mqtt_client._request_topic_supported = False
  1641. subscribe_calls = []
  1642. mock_client = type(
  1643. "MockClient",
  1644. (),
  1645. {
  1646. "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
  1647. },
  1648. )()
  1649. mqtt_client._on_connect(mock_client, None, None, 0)
  1650. # Only report topic subscribed, not request topic
  1651. assert len(subscribe_calls) == 1
  1652. assert subscribe_calls[0] == mqtt_client.topic_subscribe
  1653. def test_cache_persists_across_instances(self):
  1654. """New client instance inherits request topic unsupported state from cache."""
  1655. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1656. client1 = BambuMQTTClient(
  1657. ip_address="192.168.1.100",
  1658. serial_number="TEST_CACHE",
  1659. access_code="12345678",
  1660. )
  1661. assert client1._request_topic_supported is True
  1662. # Simulate disconnect-after-subscribe disabling the topic. Takes two
  1663. # (#2953) — the first drop is not treated as an answer.
  1664. client1._request_topic_confirmed = False
  1665. client1._last_message_time = 0.0
  1666. for _ in range(BambuMQTTClient._REQUEST_TOPIC_PROBE_LIMIT):
  1667. client1._request_topic_sub_time = __import__("time").time()
  1668. client1._on_disconnect(None, None)
  1669. assert client1._request_topic_supported is False
  1670. # New instance for same serial should inherit the cached state
  1671. client2 = BambuMQTTClient(
  1672. ip_address="192.168.1.100",
  1673. serial_number="TEST_CACHE",
  1674. access_code="12345678",
  1675. )
  1676. assert client2._request_topic_supported is False
  1677. def test_cache_does_not_affect_different_serial(self):
  1678. """Cache is per-serial — different printer is unaffected."""
  1679. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1680. BambuMQTTClient._request_topic_cache["SERIAL_A"] = False
  1681. client = BambuMQTTClient(
  1682. ip_address="192.168.1.100",
  1683. serial_number="SERIAL_B",
  1684. access_code="12345678",
  1685. )
  1686. assert client._request_topic_supported is True
  1687. def test_cache_updated_on_suback_success(self):
  1688. """Successful SUBACK caches positive confirmation."""
  1689. from paho.mqtt.reasoncodes import ReasonCode
  1690. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1691. client = BambuMQTTClient(
  1692. ip_address="192.168.1.100",
  1693. serial_number="TEST_SUBACK",
  1694. access_code="12345678",
  1695. )
  1696. client._request_topic_sub_mid = 42
  1697. rc = ReasonCode(9, identifier=0) # Success
  1698. client._on_subscribe(None, None, 42, [rc], None)
  1699. assert BambuMQTTClient._request_topic_cache["TEST_SUBACK"] is True
  1700. def test_cache_updated_on_suback_rejection(self):
  1701. """SUBACK rejection caches negative state."""
  1702. from paho.mqtt.reasoncodes import ReasonCode
  1703. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1704. client = BambuMQTTClient(
  1705. ip_address="192.168.1.100",
  1706. serial_number="TEST_REJECT",
  1707. access_code="12345678",
  1708. )
  1709. client._request_topic_sub_mid = 42
  1710. rc = ReasonCode(9, identifier=0x80) # Failure
  1711. client._on_subscribe(None, None, 42, [rc], None)
  1712. assert BambuMQTTClient._request_topic_cache["TEST_REJECT"] is False
  1713. class TestRequestTopicIsCaptured:
  1714. """The MQTT debug log has to show commands going *to* the printer.
  1715. Bambuddy subscribes to the request topic as well as the report topic, so
  1716. every command the printer is given crosses this client -- ours echoed back
  1717. by the broker, and whatever Bambu Studio sends. Those messages used to
  1718. return from _on_message before the logging block, which left a capture able
  1719. to prove only what the printer said and never what it was told. Answering
  1720. "what does Studio put in this field?" from a user's log depends on it
  1721. (#2774).
  1722. """
  1723. @pytest.fixture
  1724. def mqtt_client(self):
  1725. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1726. client = BambuMQTTClient(
  1727. ip_address="192.168.1.100",
  1728. serial_number="TEST123",
  1729. access_code="12345678",
  1730. )
  1731. client.enable_logging(True)
  1732. return client
  1733. @staticmethod
  1734. def _deliver(client, topic, payload):
  1735. class _Msg:
  1736. pass
  1737. msg = _Msg()
  1738. msg.topic = topic
  1739. msg.payload = json.dumps(payload).encode()
  1740. client._on_message(None, None, msg)
  1741. def test_a_command_on_the_request_topic_is_logged(self, mqtt_client):
  1742. payload = {
  1743. "print": {
  1744. "command": "ams_filament_drying",
  1745. "ams_id": 128,
  1746. "temp": 45,
  1747. "duration": 12,
  1748. "filament": "PLA",
  1749. }
  1750. }
  1751. self._deliver(mqtt_client, mqtt_client.topic_publish, payload)
  1752. logs = mqtt_client.get_logs()
  1753. assert len(logs) == 1
  1754. assert logs[0].topic == mqtt_client.topic_publish
  1755. # Filed with the commands rather than with telemetry: the direction
  1756. # filter is how someone finds what was sent to the printer.
  1757. assert logs[0].direction == "out"
  1758. assert logs[0].payload == payload
  1759. def test_the_payload_is_kept_whole(self, mqtt_client):
  1760. """The point of the capture is the fields we don't parse."""
  1761. payload = {"print": {"command": "ams_filament_drying", "unparsed_field": "keep me"}}
  1762. self._deliver(mqtt_client, mqtt_client.topic_publish, payload)
  1763. assert mqtt_client.get_logs()[0].payload["print"]["unparsed_field"] == "keep me"
  1764. def test_request_topic_messages_are_still_parsed(self, mqtt_client):
  1765. """Logging is additive -- the ams_mapping capture must survive it."""
  1766. self._deliver(
  1767. mqtt_client,
  1768. mqtt_client.topic_publish,
  1769. {"print": {"command": "project_file", "ams_mapping": [0, 4, -1, -1]}},
  1770. )
  1771. assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
  1772. assert len(mqtt_client.get_logs()) == 1
  1773. def test_nothing_is_logged_while_logging_is_off(self, mqtt_client):
  1774. mqtt_client.enable_logging(False)
  1775. self._deliver(
  1776. mqtt_client,
  1777. mqtt_client.topic_publish,
  1778. {"print": {"command": "project_file", "ams_mapping": [0, -1, -1, -1]}},
  1779. )
  1780. assert mqtt_client.get_logs() == []
  1781. assert mqtt_client._captured_ams_mapping == [0, -1, -1, -1]
  1782. def test_the_report_topic_is_still_logged_as_incoming(self, mqtt_client):
  1783. """Telemetry keeps its direction -- the two must stay distinguishable."""
  1784. self._deliver(mqtt_client, mqtt_client.topic_subscribe, {"print": {"gcode_state": "IDLE"}})
  1785. logs = mqtt_client.get_logs()
  1786. assert len(logs) == 1
  1787. assert logs[0].direction == "in"
  1788. class TestRequestTopicAmsMapping:
  1789. """Tests for capturing ams_mapping from the MQTT request topic."""
  1790. @pytest.fixture
  1791. def mqtt_client(self):
  1792. """Create a BambuMQTTClient instance for testing."""
  1793. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1794. client = BambuMQTTClient(
  1795. ip_address="192.168.1.100",
  1796. serial_number="TEST123",
  1797. access_code="12345678",
  1798. )
  1799. return client
  1800. def test_captured_ams_mapping_initializes_to_none(self, mqtt_client):
  1801. """Verify _captured_ams_mapping starts as None."""
  1802. assert mqtt_client._captured_ams_mapping is None
  1803. def test_handle_request_message_captures_ams_mapping(self, mqtt_client):
  1804. """project_file command with ams_mapping stores the mapping."""
  1805. data = {
  1806. "print": {
  1807. "command": "project_file",
  1808. "ams_mapping": [0, 4, -1, -1],
  1809. "url": "ftp://192.168.1.100/test.3mf",
  1810. }
  1811. }
  1812. mqtt_client._handle_request_message(data)
  1813. assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
  1814. def test_handle_request_message_ignores_non_print_commands(self, mqtt_client):
  1815. """Non-project_file commands don't store ams_mapping."""
  1816. data = {
  1817. "print": {
  1818. "command": "pause",
  1819. }
  1820. }
  1821. mqtt_client._handle_request_message(data)
  1822. assert mqtt_client._captured_ams_mapping is None
  1823. def test_handle_request_message_ignores_missing_ams_mapping(self, mqtt_client):
  1824. """project_file command without ams_mapping doesn't store anything."""
  1825. data = {
  1826. "print": {
  1827. "command": "project_file",
  1828. "url": "ftp://192.168.1.100/test.3mf",
  1829. }
  1830. }
  1831. mqtt_client._handle_request_message(data)
  1832. assert mqtt_client._captured_ams_mapping is None
  1833. def test_handle_request_message_ignores_non_dict_print(self, mqtt_client):
  1834. """Non-dict print value is safely ignored."""
  1835. data = {"print": "not_a_dict"}
  1836. mqtt_client._handle_request_message(data)
  1837. assert mqtt_client._captured_ams_mapping is None
  1838. def test_handle_request_message_ignores_missing_print(self, mqtt_client):
  1839. """Message without print key is safely ignored."""
  1840. data = {"pushing": {"command": "pushall"}}
  1841. mqtt_client._handle_request_message(data)
  1842. assert mqtt_client._captured_ams_mapping is None
  1843. def test_captured_mapping_overwrites_previous(self, mqtt_client):
  1844. """A new print command overwrites a previously captured mapping."""
  1845. mqtt_client._captured_ams_mapping = [0, -1, -1, -1]
  1846. data = {
  1847. "print": {
  1848. "command": "project_file",
  1849. "ams_mapping": [4, 8, -1, -1],
  1850. }
  1851. }
  1852. mqtt_client._handle_request_message(data)
  1853. assert mqtt_client._captured_ams_mapping == [4, 8, -1, -1]
  1854. def test_print_start_callback_includes_ams_mapping(self, mqtt_client):
  1855. """on_print_start callback data includes captured ams_mapping."""
  1856. start_data = {}
  1857. def on_start(data):
  1858. start_data.update(data)
  1859. mqtt_client.on_print_start = on_start
  1860. mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
  1861. # Seed a prior state so the first RUNNING push is treated as a real
  1862. # state transition rather than a Bambuddy-restart catch-up (#1304).
  1863. mqtt_client._previous_gcode_state = "IDLE"
  1864. # Trigger print start
  1865. mqtt_client._process_message(
  1866. {
  1867. "print": {
  1868. "gcode_state": "RUNNING",
  1869. "gcode_file": "/data/Metadata/test.gcode",
  1870. "subtask_name": "Test",
  1871. }
  1872. }
  1873. )
  1874. assert start_data.get("ams_mapping") == [0, 4, -1, -1]
  1875. def test_print_start_callback_ams_mapping_none_when_not_captured(self, mqtt_client):
  1876. """on_print_start callback has ams_mapping=None when no mapping captured."""
  1877. start_data = {}
  1878. def on_start(data):
  1879. start_data.update(data)
  1880. mqtt_client.on_print_start = on_start
  1881. # Seed a prior state so the first RUNNING push is treated as a real
  1882. # state transition rather than a Bambuddy-restart catch-up (#1304).
  1883. mqtt_client._previous_gcode_state = "IDLE"
  1884. mqtt_client._process_message(
  1885. {
  1886. "print": {
  1887. "gcode_state": "RUNNING",
  1888. "gcode_file": "/data/Metadata/test.gcode",
  1889. "subtask_name": "Test",
  1890. }
  1891. }
  1892. )
  1893. assert "ams_mapping" in start_data
  1894. assert start_data["ams_mapping"] is None
  1895. def test_first_running_push_after_bambuddy_restart_does_not_fire_print_start(self, mqtt_client):
  1896. """Regression for #1304: Bambuddy restart mid-print misfired plate check + archive.
  1897. When Bambuddy restarts while a print is already in progress, the freshly
  1898. constructed BambuMQTTClient has `_previous_gcode_state = None`. The first
  1899. push_status the printer sends reports `gcode_state: RUNNING`. Before the
  1900. fix, the (None → RUNNING) transition satisfied is_new_print's guard and
  1901. fired on_print_start, which then ran plate detection (objects on plate →
  1902. paused the live print) AND re-archived the file (duplicate archive).
  1903. With the fix in place the on_print_start callback must NOT be called for
  1904. this catch-up push, but `_was_running` still tracks the print so
  1905. completion detection works the same way as before.
  1906. """
  1907. start_data = {}
  1908. def on_start(data):
  1909. start_data.update(data)
  1910. mqtt_client.on_print_start = on_start
  1911. # Explicit: this simulates a fresh Bambuddy process attaching to a
  1912. # printer that's already in the middle of a print.
  1913. mqtt_client._previous_gcode_state = None
  1914. mqtt_client._was_running = False
  1915. mqtt_client._process_message(
  1916. {
  1917. "print": {
  1918. "gcode_state": "RUNNING",
  1919. "gcode_file": "/data/Metadata/big_print.gcode",
  1920. "subtask_name": "big_print",
  1921. }
  1922. }
  1923. )
  1924. assert start_data == {}, "on_print_start must not fire on Bambuddy-restart catch-up"
  1925. # Completion detection still needs to know we're tracking a running job.
  1926. assert mqtt_client._was_running is True
  1927. # And the state-update bookkeeping ran so the NEXT push won't keep
  1928. # treating the first RUNNING as fresh.
  1929. assert mqtt_client._previous_gcode_state == "RUNNING"
  1930. def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):
  1931. """on_print_complete callback data includes captured ams_mapping."""
  1932. complete_data = {}
  1933. def on_complete(data):
  1934. complete_data.update(data)
  1935. mqtt_client.on_print_start = lambda d: None
  1936. mqtt_client.on_print_complete = on_complete
  1937. mqtt_client._captured_ams_mapping = [0, 9, -1, -1]
  1938. # Start print
  1939. mqtt_client._process_message(
  1940. {
  1941. "print": {
  1942. "gcode_state": "RUNNING",
  1943. "gcode_file": "/data/Metadata/test.gcode",
  1944. "subtask_name": "Test",
  1945. }
  1946. }
  1947. )
  1948. # Complete print
  1949. mqtt_client._process_message(
  1950. {
  1951. "print": {
  1952. "gcode_state": "FINISH",
  1953. "gcode_file": "/data/Metadata/test.gcode",
  1954. "subtask_name": "Test",
  1955. }
  1956. }
  1957. )
  1958. assert complete_data.get("ams_mapping") == [0, 9, -1, -1]
  1959. def test_captured_mapping_cleared_after_print_complete(self, mqtt_client):
  1960. """_captured_ams_mapping is reset to None after print completion."""
  1961. mqtt_client.on_print_start = lambda d: None
  1962. mqtt_client.on_print_complete = lambda d: None
  1963. mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
  1964. # Start print
  1965. mqtt_client._process_message(
  1966. {
  1967. "print": {
  1968. "gcode_state": "RUNNING",
  1969. "gcode_file": "/data/Metadata/test.gcode",
  1970. "subtask_name": "Test",
  1971. }
  1972. }
  1973. )
  1974. # Complete print
  1975. mqtt_client._process_message(
  1976. {
  1977. "print": {
  1978. "gcode_state": "FINISH",
  1979. "gcode_file": "/data/Metadata/test.gcode",
  1980. "subtask_name": "Test",
  1981. }
  1982. }
  1983. )
  1984. assert mqtt_client._captured_ams_mapping is None
  1985. def test_full_flow_capture_and_deliver(self, mqtt_client):
  1986. """Full flow: slicer sends print command → MQTT captures mapping → completion delivers it."""
  1987. complete_data = {}
  1988. def on_complete(data):
  1989. complete_data.update(data)
  1990. mqtt_client.on_print_start = lambda d: None
  1991. mqtt_client.on_print_complete = on_complete
  1992. # 1. Slicer sends print command (captured from request topic)
  1993. mqtt_client._handle_request_message(
  1994. {
  1995. "print": {
  1996. "command": "project_file",
  1997. "ams_mapping": [4, 9, -1, -1],
  1998. "url": "ftp://192.168.1.100/model.3mf",
  1999. }
  2000. }
  2001. )
  2002. assert mqtt_client._captured_ams_mapping == [4, 9, -1, -1]
  2003. # 2. Printer reports RUNNING
  2004. mqtt_client._process_message(
  2005. {
  2006. "print": {
  2007. "gcode_state": "RUNNING",
  2008. "gcode_file": "/data/Metadata/model.gcode",
  2009. "subtask_name": "Model",
  2010. }
  2011. }
  2012. )
  2013. # 3. Printer reports FINISH
  2014. mqtt_client._process_message(
  2015. {
  2016. "print": {
  2017. "gcode_state": "FINISH",
  2018. "gcode_file": "/data/Metadata/model.gcode",
  2019. "subtask_name": "Model",
  2020. }
  2021. }
  2022. )
  2023. assert complete_data["ams_mapping"] == [4, 9, -1, -1]
  2024. assert complete_data["status"] == "completed"
  2025. # Mapping cleared after completion
  2026. assert mqtt_client._captured_ams_mapping is None
  2027. # ---------------------------------------------------------------------------
  2028. # tray_now disambiguation helpers
  2029. # ---------------------------------------------------------------------------
  2030. def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None, ams_exist_bits=None):
  2031. """Build minimal print.ams payload for tray_now disambiguation tests."""
  2032. ams = {"tray_now": str(tray_now)}
  2033. if ams_units is not None:
  2034. ams["ams"] = ams_units
  2035. if tray_exist_bits is not None:
  2036. ams["tray_exist_bits"] = tray_exist_bits
  2037. if ams_exist_bits is not None:
  2038. ams["ams_exist_bits"] = ams_exist_bits
  2039. return {"print": {"ams": ams}}
  2040. def _extruder_info_payload(extruders):
  2041. """Build device.extruder.info payload (dual-nozzle detection + snow).
  2042. Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
  2043. """
  2044. return {
  2045. "print": {
  2046. "device": {
  2047. "extruder": {
  2048. "info": extruders,
  2049. }
  2050. }
  2051. }
  2052. }
  2053. def _extruder_state_payload(state_val):
  2054. """Build device.extruder.state payload (active extruder via bit 8)."""
  2055. return {
  2056. "print": {
  2057. "device": {
  2058. "extruder": {
  2059. "state": state_val,
  2060. }
  2061. }
  2062. }
  2063. }
  2064. # ---------------------------------------------------------------------------
  2065. # 1. Single-nozzle X1E — direct passthrough
  2066. # ---------------------------------------------------------------------------
  2067. class TestTrayNowSingleNozzleX1E:
  2068. """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
  2069. @pytest.fixture
  2070. def mqtt_client(self):
  2071. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2072. return BambuMQTTClient(
  2073. ip_address="192.168.1.100",
  2074. serial_number="TEST_X1E",
  2075. access_code="12345678",
  2076. )
  2077. def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
  2078. """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
  2079. for slot in range(4):
  2080. mqtt_client._process_message(_ams_payload(slot))
  2081. assert mqtt_client.state.tray_now == slot
  2082. def test_tray_now_255_means_unloaded(self, mqtt_client):
  2083. """tray_now=255 means no filament loaded."""
  2084. mqtt_client._process_message(_ams_payload(255))
  2085. assert mqtt_client.state.tray_now == 255
  2086. def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
  2087. """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
  2088. mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
  2089. assert mqtt_client._is_dual_nozzle is False
  2090. def test_last_loaded_tray_survives_unload(self, mqtt_client):
  2091. """Load tray 2, unload → last_loaded_tray stays 2."""
  2092. mqtt_client._process_message(_ams_payload(2))
  2093. assert mqtt_client.state.last_loaded_tray == 2
  2094. mqtt_client._process_message(_ams_payload(255))
  2095. assert mqtt_client.state.tray_now == 255
  2096. assert mqtt_client.state.last_loaded_tray == 2
  2097. # ---------------------------------------------------------------------------
  2098. # 2. Single-nozzle P2S — multiple AMS, global IDs pass through
  2099. # ---------------------------------------------------------------------------
  2100. class TestTrayNowSingleNozzleP2S:
  2101. """Single-nozzle, 2 AMS — tray_now > 3 passes through as global ID."""
  2102. @pytest.fixture
  2103. def mqtt_client(self):
  2104. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2105. return BambuMQTTClient(
  2106. ip_address="192.168.1.100",
  2107. serial_number="TEST_P2S",
  2108. access_code="12345678",
  2109. )
  2110. def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
  2111. """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
  2112. for global_id in range(4, 8):
  2113. mqtt_client._process_message(_ams_payload(global_id))
  2114. assert mqtt_client.state.tray_now == global_id
  2115. def test_tray_change_across_ams_units(self, mqtt_client):
  2116. """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
  2117. mqtt_client._process_message(_ams_payload(1))
  2118. assert mqtt_client.state.tray_now == 1
  2119. mqtt_client._process_message(_ams_payload(6))
  2120. assert mqtt_client.state.tray_now == 6
  2121. # ---------------------------------------------------------------------------
  2122. # 2b. Single-nozzle P2S — multi-AMS local slot disambiguation (#420)
  2123. # ---------------------------------------------------------------------------
  2124. class TestTrayNowP2SMultiAmsDisambiguation:
  2125. """P2S firmware sends local slot IDs (0-3) in tray_now even with dual AMS.
  2126. When ams_exist_bits indicates >1 AMS unit and tray_now is 0-3, the backend
  2127. should use the MQTT mapping field (snow-encoded) to resolve the correct
  2128. global tray ID.
  2129. """
  2130. @pytest.fixture
  2131. def mqtt_client(self):
  2132. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2133. client = BambuMQTTClient(
  2134. ip_address="192.168.1.100",
  2135. serial_number="TEST_P2S_DUAL",
  2136. access_code="12345678",
  2137. )
  2138. return client
  2139. def test_resolves_ams1_slot1_from_mapping(self, mqtt_client):
  2140. """tray_now=1 with mapping=[257] → global ID 5 (AMS1-T1).
  2141. 257 snow-decoded: ams_hw_id=1, slot=1 → global 1*4+1=5.
  2142. """
  2143. # Set mapping field in raw_data (as the MQTT handler would)
  2144. mqtt_client.state.raw_data["mapping"] = [257]
  2145. mqtt_client._process_message(
  2146. _ams_payload(1, ams_exist_bits="3") # '3' = 0b11 → AMS 0 and 1
  2147. )
  2148. assert mqtt_client.state.tray_now == 5
  2149. def test_resolves_ams1_slot0_from_mapping(self, mqtt_client):
  2150. """tray_now=0 with mapping=[256] → global ID 4 (AMS1-T0).
  2151. 256 snow-decoded: ams_hw_id=1, slot=0 → global 1*4+0=4.
  2152. """
  2153. mqtt_client.state.raw_data["mapping"] = [256]
  2154. mqtt_client._process_message(_ams_payload(0, ams_exist_bits="3"))
  2155. assert mqtt_client.state.tray_now == 4
  2156. def test_resolves_ams1_slot3_from_mapping(self, mqtt_client):
  2157. """tray_now=3 with mapping=[259] → global ID 7 (AMS1-T3).
  2158. 259 snow-decoded: ams_hw_id=1, slot=3 → global 1*4+3=7.
  2159. """
  2160. mqtt_client.state.raw_data["mapping"] = [259]
  2161. mqtt_client._process_message(_ams_payload(3, ams_exist_bits="3"))
  2162. assert mqtt_client.state.tray_now == 7
  2163. def test_ams0_slot_unchanged_when_mapping_confirms_ams0(self, mqtt_client):
  2164. """tray_now=1 with mapping=[1] → stays 1 (AMS0-T1).
  2165. 1 snow-decoded: ams_hw_id=0, slot=1 → global 0*4+1=1.
  2166. """
  2167. mqtt_client.state.raw_data["mapping"] = [1]
  2168. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  2169. assert mqtt_client.state.tray_now == 1
  2170. def test_multicolor_resolves_ams1_from_multi_entry_mapping(self, mqtt_client):
  2171. """Multi-color print: mapping=[0, 257] → tray_now=1 resolves to AMS1-T1 (5).
  2172. Entry 0: ams_hw_id=0, slot=0 (local 0) — doesn't match tray_now=1.
  2173. Entry 257: ams_hw_id=1, slot=1 (local 1) — matches tray_now=1 → global 5.
  2174. """
  2175. mqtt_client.state.raw_data["mapping"] = [0, 257]
  2176. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  2177. assert mqtt_client.state.tray_now == 5
  2178. def test_multicolor_four_slot_mapping(self, mqtt_client):
  2179. """mapping=[65535, 65535, 65535, 257] → tray_now=1 resolves to global 5.
  2180. Only entry 257 has local slot=1, other entries are unmapped (65535).
  2181. Reproduces exact data from issue #420 support package.
  2182. """
  2183. mqtt_client.state.raw_data["mapping"] = [65535, 65535, 65535, 257]
  2184. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  2185. assert mqtt_client.state.tray_now == 5
  2186. def test_ambiguous_mapping_falls_back_to_local_slot(self, mqtt_client):
  2187. """Two AMS units with same local slot in mapping → ambiguous, keep local slot.
  2188. mapping=[1, 257]: both have local slot 1 (AMS0-T1 and AMS1-T1).
  2189. Cannot disambiguate → fall back to tray_now=1.
  2190. """
  2191. mqtt_client.state.raw_data["mapping"] = [1, 257]
  2192. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  2193. assert mqtt_client.state.tray_now == 1
  2194. def test_no_mapping_falls_back_to_local_slot(self, mqtt_client):
  2195. """No mapping field available → fall back to raw tray_now."""
  2196. # No mapping in raw_data (e.g. manual filament load, not during print)
  2197. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  2198. assert mqtt_client.state.tray_now == 1
  2199. def test_empty_mapping_falls_back_to_local_slot(self, mqtt_client):
  2200. """Empty mapping list → fall back to raw tray_now."""
  2201. mqtt_client.state.raw_data["mapping"] = []
  2202. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  2203. assert mqtt_client.state.tray_now == 1
  2204. def test_single_ams_passthrough(self, mqtt_client):
  2205. """Single AMS (ams_exist_bits='1') → tray_now 0-3 is direct global ID."""
  2206. mqtt_client._process_message(_ams_payload(2, ams_exist_bits="1"))
  2207. assert mqtt_client.state.tray_now == 2
  2208. def test_no_ams_exist_bits_passthrough(self, mqtt_client):
  2209. """No ams_exist_bits in payload → fall back to raw tray_now."""
  2210. mqtt_client._process_message(_ams_payload(1))
  2211. assert mqtt_client.state.tray_now == 1
  2212. def test_tray_now_255_unaffected_by_multi_ams(self, mqtt_client):
  2213. """tray_now=255 (unloaded) passes through regardless of AMS count."""
  2214. mqtt_client.state.raw_data["mapping"] = [257]
  2215. mqtt_client._process_message(_ams_payload(255, ams_exist_bits="3"))
  2216. assert mqtt_client.state.tray_now == 255
  2217. def test_tray_now_above_3_unaffected(self, mqtt_client):
  2218. """tray_now > 3 is already a global ID and passes through directly."""
  2219. mqtt_client._process_message(_ams_payload(6, ams_exist_bits="3"))
  2220. assert mqtt_client.state.tray_now == 6
  2221. def test_last_loaded_tray_uses_resolved_global_id(self, mqtt_client):
  2222. """last_loaded_tray should reflect the resolved global ID, not local slot."""
  2223. mqtt_client.state.raw_data["mapping"] = [257]
  2224. mqtt_client.state.state = "RUNNING"
  2225. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  2226. assert mqtt_client.state.tray_now == 5
  2227. assert mqtt_client.state.last_loaded_tray == 5
  2228. class TestResolveLocalSlotFromMapping:
  2229. """Unit tests for _resolve_local_slot_from_mapping static method."""
  2230. def test_single_match_ams0(self):
  2231. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2232. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1]) == 1
  2233. def test_single_match_ams1(self):
  2234. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2235. # 257 = 1*256 + 1 → AMS1 slot1 → global 5
  2236. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [257]) == 5
  2237. def test_single_match_ams2(self):
  2238. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2239. # 514 = 2*256 + 2 → AMS2 slot2 → global 10
  2240. assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514]) == 10
  2241. def test_unmapped_entries_skipped(self):
  2242. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2243. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [65535, 65535, 65535, 257]) == 5
  2244. def test_no_match_returns_none(self):
  2245. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2246. # mapping has slot 0 only, looking for slot 2
  2247. assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [0]) is None
  2248. def test_ambiguous_returns_none(self):
  2249. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2250. # Both AMS0 slot1 (1) and AMS1 slot1 (257) → ambiguous
  2251. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 257]) is None
  2252. def test_none_mapping_returns_none(self):
  2253. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2254. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, None) is None
  2255. def test_empty_mapping_returns_none(self):
  2256. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2257. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, []) is None
  2258. def test_ams_ht_slot0_match(self):
  2259. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2260. # AMS-HT id=128: snow = 128*256 + 0 = 32768
  2261. assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128
  2262. def test_units_narrow_ambiguous_match(self):
  2263. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2264. # AMS0 slot1 and AMS2 slot1 both mapped; only AMS 2 is on this extruder
  2265. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 513], [1, 2]) == 9
  2266. def test_units_do_not_drop_a_single_match(self):
  2267. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2268. # One candidate stands even when the extruder map doesn't list its unit
  2269. assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514], [0]) == 10
  2270. def test_units_still_ambiguous_returns_none(self):
  2271. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2272. assert BambuMQTTClient._resolve_local_slot_from_mapping(3, [3, 259], [0, 1]) is None
  2273. # ---------------------------------------------------------------------------
  2274. # 3. H2D Pro — initial state detection
  2275. # ---------------------------------------------------------------------------
  2276. class TestTrayNowDualNozzleH2DSetup:
  2277. """H2D Pro initial state detection."""
  2278. @pytest.fixture
  2279. def mqtt_client(self):
  2280. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2281. return BambuMQTTClient(
  2282. ip_address="192.168.1.100",
  2283. serial_number="TEST_H2D",
  2284. access_code="12345678",
  2285. )
  2286. def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
  2287. """2 entries in device.extruder.info → _is_dual_nozzle=True."""
  2288. mqtt_client._process_message(
  2289. _extruder_info_payload(
  2290. [
  2291. {"id": 0, "snow": 0xFF00FF},
  2292. {"id": 1, "snow": 0xFF00FF},
  2293. ]
  2294. )
  2295. )
  2296. assert mqtt_client._is_dual_nozzle is True
  2297. def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
  2298. """AMS info field is hex: 0x2003 → ext 0 (right), 0x2104 → ext 1 (left)."""
  2299. # MQTT sends info as string; BambuStudio parses as hex via stoull(str, 16)
  2300. ams_units = [
  2301. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  2302. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  2303. ]
  2304. payload = {
  2305. "print": {
  2306. "ams": {
  2307. "ams": ams_units,
  2308. "tray_now": "255",
  2309. "tray_exist_bits": "1000f",
  2310. },
  2311. }
  2312. }
  2313. mqtt_client._process_message(payload)
  2314. # 0x2003: bits 8-11 = (0x2003 >> 8) & 0xF = 0x20 & 0xF = 0 → extruder 0 (right)
  2315. # 0x2104: bits 8-11 = (0x2104 >> 8) & 0xF = 0x21 & 0xF = 1 → extruder 1 (left)
  2316. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  2317. def test_ams_extruder_map_real_h2d_values(self, mqtt_client):
  2318. """Real H2D MQTT values: AMS2 Pro on right, AMS-HT on left."""
  2319. ams_units = [
  2320. {"id": 0, "info": "10001003", "tray": [{"id": i} for i in range(4)]},
  2321. {"id": 128, "info": "10002104", "tray": [{"id": 0}]},
  2322. ]
  2323. payload = {
  2324. "print": {
  2325. "ams": {
  2326. "ams": ams_units,
  2327. "tray_now": "255",
  2328. "tray_exist_bits": "1000a",
  2329. },
  2330. }
  2331. }
  2332. mqtt_client._process_message(payload)
  2333. # 0x10001003: bits 8-11 = (0x10001003 >> 8) & 0xF = 0x10 & 0xF = 0 → right
  2334. # 0x10002104: bits 8-11 = (0x10002104 >> 8) & 0xF = 0x21 & 0xF = 1 → left
  2335. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  2336. def test_ams_extruder_map_skips_uninitialized(self, mqtt_client):
  2337. """extruder_id 0xE means uninitialized AMS — should be skipped."""
  2338. ams_units = [
  2339. {"id": 0, "info": "e03", "tray": [{"id": i} for i in range(4)]},
  2340. ]
  2341. payload = {
  2342. "print": {
  2343. "ams": {
  2344. "ams": ams_units,
  2345. "tray_now": "255",
  2346. "tray_exist_bits": "f",
  2347. },
  2348. }
  2349. }
  2350. mqtt_client._process_message(payload)
  2351. assert mqtt_client.state.ams_extruder_map == {}
  2352. def test_ams_extruder_map_partial_update_preserves_entries(self, mqtt_client):
  2353. """Partial MQTT update with one AMS should not overwrite other entries."""
  2354. # First: full update with both AMS units
  2355. full_payload = {
  2356. "print": {
  2357. "ams": {
  2358. "ams": [
  2359. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  2360. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  2361. ],
  2362. "tray_now": "255",
  2363. "tray_exist_bits": "1000f",
  2364. },
  2365. }
  2366. }
  2367. mqtt_client._process_message(full_payload)
  2368. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  2369. # Then: partial update with only AMS 0 (no info field this time)
  2370. partial_payload = {
  2371. "print": {
  2372. "ams": {
  2373. "ams": [
  2374. {"id": 0, "tray": [{"id": 0, "remain": 50}]},
  2375. ],
  2376. "tray_now": "0",
  2377. "tray_exist_bits": "1000f",
  2378. },
  2379. }
  2380. }
  2381. mqtt_client._process_message(partial_payload)
  2382. # Both entries should still be present
  2383. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  2384. def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
  2385. """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
  2386. If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
  2387. """
  2388. payload = {
  2389. "print": {
  2390. "device": {
  2391. "extruder": {
  2392. "info": [
  2393. {"id": 0, "snow": 0xFF00FF},
  2394. {"id": 1, "snow": 0xFF00FF},
  2395. ],
  2396. "state": 0x0001,
  2397. }
  2398. },
  2399. "ams": {
  2400. "ams": [
  2401. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  2402. ],
  2403. "tray_now": "2",
  2404. "tray_exist_bits": "f",
  2405. },
  2406. }
  2407. }
  2408. mqtt_client._process_message(payload)
  2409. # Dual-nozzle was detected; snow is 0xFF00FF (unloaded), so the
  2410. # ams_extruder_map fallback runs. The map is built from this same
  2411. # report only after tray_now, so the slot can't be placed yet and
  2412. # nothing is guessed (#3242).
  2413. assert mqtt_client._is_dual_nozzle is True
  2414. assert mqtt_client.state.ams_extruder_map == {"0": 0}
  2415. assert mqtt_client.state.tray_now == 255
  2416. # The next report finds AMS 0 alone on the active extruder → 0*4+2 = 2
  2417. mqtt_client._process_message(_ams_payload(2))
  2418. assert mqtt_client.state.tray_now == 2
  2419. # ---------------------------------------------------------------------------
  2420. # Shared H2D fixture for classes 4-8
  2421. # ---------------------------------------------------------------------------
  2422. class _H2DFixtureMixin:
  2423. """Mixin providing a pre-configured H2D Pro client."""
  2424. @pytest.fixture
  2425. def mqtt_client(self):
  2426. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2427. return BambuMQTTClient(
  2428. ip_address="192.168.1.100",
  2429. serial_number="TEST_H2D",
  2430. access_code="12345678",
  2431. )
  2432. @pytest.fixture
  2433. def h2d_client(self, mqtt_client):
  2434. """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
  2435. mqtt_client._process_message(
  2436. {
  2437. "print": {
  2438. "device": {
  2439. "extruder": {
  2440. "info": [
  2441. {"id": 0, "snow": 0xFF00FF},
  2442. {"id": 1, "snow": 0xFF00FF},
  2443. ],
  2444. "state": 0x0001, # right extruder active
  2445. }
  2446. },
  2447. "ams": {
  2448. "ams": [
  2449. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  2450. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  2451. ],
  2452. "tray_now": "255",
  2453. "tray_exist_bits": "1000f",
  2454. },
  2455. }
  2456. }
  2457. )
  2458. assert mqtt_client._is_dual_nozzle is True
  2459. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  2460. return mqtt_client
  2461. # ---------------------------------------------------------------------------
  2462. # 4. H2D Snow field disambiguation
  2463. # ---------------------------------------------------------------------------
  2464. class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
  2465. """Snow field disambiguation (primary path)."""
  2466. def test_snow_disambiguates_ams0_slot(self, h2d_client):
  2467. """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
  2468. # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
  2469. # so we need it in a prior message).
  2470. snow_val = 0 << 8 | 2 # AMS 0 slot 2 = raw 2
  2471. h2d_client._process_message(
  2472. _extruder_info_payload(
  2473. [
  2474. {"id": 0, "snow": snow_val},
  2475. {"id": 1, "snow": 0xFF00FF},
  2476. ]
  2477. )
  2478. )
  2479. assert h2d_client.state.h2d_extruder_snow.get(0) == 2
  2480. # Now send tray_now=2
  2481. h2d_client._process_message(_ams_payload(2))
  2482. assert h2d_client.state.tray_now == 2
  2483. def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
  2484. """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
  2485. # Snow: extruder 1 → AMS 128 slot 0
  2486. snow_val = 128 << 8 | 0 # = 32768
  2487. h2d_client._process_message(
  2488. _extruder_info_payload(
  2489. [
  2490. {"id": 0, "snow": 0xFF00FF},
  2491. {"id": 1, "snow": snow_val},
  2492. ]
  2493. )
  2494. )
  2495. assert h2d_client.state.h2d_extruder_snow.get(1) == 128
  2496. # Switch to left extruder
  2497. h2d_client._process_message(_extruder_state_payload(0x0100))
  2498. assert h2d_client.state.active_extruder == 1
  2499. # tray_now="0" with left extruder active, snow says AMS HT (128)
  2500. # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
  2501. h2d_client._process_message(_ams_payload(0))
  2502. assert h2d_client.state.tray_now == 128
  2503. def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
  2504. """Verify state.h2d_extruder_snow dict is populated correctly."""
  2505. snow_ext0 = 1 << 8 | 3 # AMS 1 slot 3 → global 7
  2506. snow_ext1 = 0 << 8 | 0 # AMS 0 slot 0 → global 0
  2507. h2d_client._process_message(
  2508. _extruder_info_payload(
  2509. [
  2510. {"id": 0, "snow": snow_ext0},
  2511. {"id": 1, "snow": snow_ext1},
  2512. ]
  2513. )
  2514. )
  2515. assert h2d_client.state.h2d_extruder_snow[0] == 7
  2516. assert h2d_client.state.h2d_extruder_snow[1] == 0
  2517. def test_snow_unloaded_value(self, h2d_client):
  2518. """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
  2519. h2d_client._process_message(
  2520. _extruder_info_payload(
  2521. [
  2522. {"id": 0, "snow": 0xFFFF},
  2523. {"id": 1, "snow": 0xFFFF},
  2524. ]
  2525. )
  2526. )
  2527. assert h2d_client.state.h2d_extruder_snow[0] == 255
  2528. assert h2d_client.state.h2d_extruder_snow[1] == 255
  2529. def test_snow_initial_sentinel_not_stored(self, h2d_client):
  2530. """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
  2531. # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
  2532. h2d_client._process_message(
  2533. _extruder_info_payload(
  2534. [
  2535. {"id": 0, "snow": 0xFF00FF},
  2536. {"id": 1, "snow": 0xFF00FF},
  2537. ]
  2538. )
  2539. )
  2540. # Snow dict should remain empty (no matching branch)
  2541. assert h2d_client.state.h2d_extruder_snow == {}
  2542. # ---------------------------------------------------------------------------
  2543. # 5. H2D Pending target disambiguation
  2544. # ---------------------------------------------------------------------------
  2545. class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
  2546. """Pending target disambiguation (when Bambuddy initiates load)."""
  2547. def test_pending_target_matches_slot(self, h2d_client):
  2548. """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
  2549. h2d_client.state.pending_tray_target = 5
  2550. h2d_client._process_message(_ams_payload(1))
  2551. assert h2d_client.state.tray_now == 5
  2552. assert h2d_client.state.pending_tray_target is None # cleared
  2553. def test_pending_target_slot_mismatch(self, h2d_client):
  2554. """pending=5, tray_now='2' → uses raw slot, clears pending."""
  2555. h2d_client.state.pending_tray_target = 5
  2556. h2d_client._process_message(_ams_payload(2))
  2557. # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
  2558. assert h2d_client.state.tray_now == 2
  2559. assert h2d_client.state.pending_tray_target is None
  2560. def test_pending_target_takes_priority_over_snow(self, h2d_client):
  2561. """When both pending and snow are set, pending wins."""
  2562. # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
  2563. snow_val = 0 << 8 | 1
  2564. h2d_client._process_message(
  2565. _extruder_info_payload(
  2566. [
  2567. {"id": 0, "snow": snow_val},
  2568. {"id": 1, "snow": 0xFF00FF},
  2569. ]
  2570. )
  2571. )
  2572. assert h2d_client.state.h2d_extruder_snow.get(0) == 1
  2573. # Set pending target to AMS 1 slot 1 (global 5)
  2574. h2d_client.state.pending_tray_target = 5
  2575. # tray_now="1" — matches pending (5%4=1), pending should win over snow
  2576. h2d_client._process_message(_ams_payload(1))
  2577. assert h2d_client.state.tray_now == 5
  2578. # ---------------------------------------------------------------------------
  2579. # 6. H2D ams_extruder_map fallback
  2580. # ---------------------------------------------------------------------------
  2581. class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
  2582. """ams_extruder_map fallback (no pending, no snow)."""
  2583. def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
  2584. """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
  2585. # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
  2586. h2d_client._process_message(_ams_payload(2))
  2587. # AMS 0 is the only AMS on extruder 0 (right, active by default)
  2588. # Fallback: single AMS → global = 0*4+2 = 2
  2589. assert h2d_client.state.tray_now == 2
  2590. def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
  2591. """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
  2592. # Set up: two AMS units on the same extruder (right, ext 0)
  2593. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
  2594. # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
  2595. h2d_client.state.tray_now = 5
  2596. # tray_now="1" → 5%4=1 matches → keep current=5
  2597. h2d_client._process_message(_ams_payload(1))
  2598. assert h2d_client.state.tray_now == 5
  2599. def test_no_ams_on_extruder_keeps_current(self, h2d_client):
  2600. """No AMS mapped to the active extruder → the slot can't be placed, keep current (#3242)."""
  2601. # All AMS on left extruder, but right is active
  2602. h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
  2603. h2d_client.state.tray_now = 255
  2604. h2d_client._process_message(_ams_payload(2))
  2605. assert h2d_client.state.tray_now == 255
  2606. def test_no_ams_on_extruder_resolved_from_mapping(self, h2d_client):
  2607. """No AMS the map knows of, but the print's mapping names one tray at the slot (#3242)."""
  2608. h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
  2609. h2d_client.state.raw_data["mapping"] = [514] # AMS 2 slot 2
  2610. h2d_client._was_running = True
  2611. h2d_client._process_message(_ams_payload(2))
  2612. assert h2d_client.state.tray_now == 10
  2613. def test_single_ams_ht_on_extruder_returns_unit_id(self, h2d_client):
  2614. """AMS-HT 128 alone on left extruder, slot 0 → global ID 128 (not 512)."""
  2615. # Switch to left extruder (where AMS-HT 128 is mapped)
  2616. h2d_client._process_message(_extruder_state_payload(0x0100))
  2617. # Only AMS-HT 128 on left extruder; no snow available
  2618. h2d_client._process_message(_ams_payload(0))
  2619. assert h2d_client.state.tray_now == 128
  2620. def test_single_ams_ht_ignores_nonzero_slot(self, h2d_client):
  2621. """AMS-HT has single slot; even if printer reports slot 1, global ID = unit ID."""
  2622. h2d_client.state.ams_extruder_map = {"129": 0}
  2623. h2d_client._process_message(_ams_payload(1))
  2624. # AMS-HT 129: global ID = 129, not 129*4+1=517
  2625. assert h2d_client.state.tray_now == 129
  2626. def test_multiple_ams_keeps_current_ams_ht(self, h2d_client):
  2627. """Current tray is AMS-HT 128, slot 0 reported → keeps 128."""
  2628. h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
  2629. h2d_client.state.tray_now = 128
  2630. h2d_client._process_message(_ams_payload(0))
  2631. assert h2d_client.state.tray_now == 128
  2632. def test_multiple_ams_slot_nonzero_excludes_ams_ht(self, h2d_client):
  2633. """Slot > 0 eliminates AMS-HT candidates; single regular AMS left → resolves."""
  2634. # AMS 0 + AMS-HT 128 both on right extruder
  2635. h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
  2636. h2d_client.state.tray_now = 255 # no current match
  2637. # Slot 2 → can't be AMS-HT → only AMS 0 → global = 0*4+2 = 2
  2638. h2d_client._process_message(_ams_payload(2))
  2639. assert h2d_client.state.tray_now == 2
  2640. def test_multiple_ams_slot_nonzero_narrows_to_single_ht_excluded(self, h2d_client):
  2641. """Two regular AMS + one AMS-HT, slot > 0 → AMS-HT excluded but still ambiguous."""
  2642. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "128": 0}
  2643. h2d_client.state.tray_now = 255
  2644. # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
  2645. h2d_client._process_message(_ams_payload(3))
  2646. assert h2d_client.state.tray_now == 255 # kept, never the bare slot (#3242)
  2647. def test_multiple_ams_resolved_from_mapping(self, h2d_client):
  2648. """#3242: three AMS on one extruder, the print feeds from AMS 2 slot 1.
  2649. The printer's mapping names that tray, so AMS 0 slot 1 is never marked."""
  2650. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "2": 0}
  2651. h2d_client.state.raw_data["mapping"] = [65535, 513] # AMS 2 slot 1
  2652. h2d_client._was_running = True
  2653. h2d_client._process_message(_ams_payload(1))
  2654. assert h2d_client.state.tray_now == 9
  2655. def test_mapping_ignored_while_idle(self, h2d_client):
  2656. """An idle H2 keeps reporting the previous print's mapping. A spool
  2657. swap at AMS 0 slot 1 must not be placed on that print's AMS 2 tray."""
  2658. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "2": 0}
  2659. h2d_client.state.raw_data["mapping"] = [513] # left over: AMS 2 slot 1
  2660. h2d_client.state.tray_now = 255
  2661. assert h2d_client._was_running is False
  2662. h2d_client._process_message(_ams_payload(1))
  2663. assert h2d_client.state.tray_now == 255
  2664. def test_mapping_ignored_after_print_completed(self, h2d_client):
  2665. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "2": 0}
  2666. h2d_client.state.raw_data["mapping"] = [513]
  2667. h2d_client.state.tray_now = 255
  2668. h2d_client._was_running = True
  2669. h2d_client._completion_triggered = True
  2670. h2d_client._process_message(_ams_payload(1))
  2671. assert h2d_client.state.tray_now == 255
  2672. def test_mapping_narrowed_to_active_extruder(self, h2d_client):
  2673. """Two mapped trays share the slot; only one is on the active extruder."""
  2674. h2d_client.state.ams_extruder_map = {"0": 1, "1": 0, "2": 0}
  2675. h2d_client.state.raw_data["mapping"] = [1, 513] # AMS 0 slot 1 (left), AMS 2 slot 1 (right)
  2676. h2d_client._was_running = True
  2677. h2d_client._process_message(_ams_payload(1))
  2678. assert h2d_client.state.tray_now == 9
  2679. def test_ambiguous_mapping_keeps_current(self, h2d_client):
  2680. """Multi-colour print: the mapping has the slot on two units of this extruder.
  2681. Keep the tray that was loaded until snow names the new one."""
  2682. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
  2683. h2d_client.state.raw_data["mapping"] = [3, 259, 256] # AMS 0 slot 3, AMS 1 slot 3, AMS 1 slot 0
  2684. h2d_client.state.tray_now = 4
  2685. h2d_client._was_running = True
  2686. h2d_client._process_message(_ams_payload(3))
  2687. assert h2d_client.state.tray_now == 4
  2688. def test_ambiguous_slot_stays_out_of_tray_change_log(self, h2d_client):
  2689. """#3242: a mid-print filament change reports the slot about a second
  2690. before snow. The guess must not land in the usage change log."""
  2691. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
  2692. h2d_client.state.tray_now = 5
  2693. h2d_client.state.last_loaded_tray = 5
  2694. h2d_client.state.tray_change_log = [(5, 0)]
  2695. h2d_client._was_running = True
  2696. h2d_client._completion_triggered = False
  2697. h2d_client.state.layer_num = 120
  2698. h2d_client._process_message(_ams_payload(3))
  2699. assert h2d_client.state.tray_now == 5
  2700. assert h2d_client.state.tray_change_log == [(5, 0)]
  2701. # Snow arrives and names AMS 1 slot 3
  2702. h2d_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0x0103}, {"id": 1, "snow": 0xFFFF}]))
  2703. h2d_client._process_message(_ams_payload(3))
  2704. assert h2d_client.state.tray_now == 7
  2705. assert h2d_client.state.tray_change_log == [(5, 0), (7, 120)]
  2706. # ---------------------------------------------------------------------------
  2707. # 6b. H2D last_loaded_tray validation
  2708. # ---------------------------------------------------------------------------
  2709. class TestLastLoadedTrayValidation(_H2DFixtureMixin):
  2710. """last_loaded_tray only stores physically valid tray IDs."""
  2711. def test_regular_ams_tray_stored(self, h2d_client):
  2712. """Valid regular AMS tray (0-15) → stored in last_loaded_tray."""
  2713. h2d_client.state.tray_now = 7
  2714. # Trigger tray_now processing via AMS message
  2715. h2d_client._process_message(
  2716. _extruder_info_payload(
  2717. [
  2718. {"id": 0, "snow": 1 << 8 | 3}, # AMS 1 slot 3 → global 7
  2719. {"id": 1, "snow": 0xFF00FF},
  2720. ]
  2721. )
  2722. )
  2723. h2d_client._process_message(_ams_payload(3))
  2724. assert h2d_client.state.tray_now == 7
  2725. assert h2d_client.state.last_loaded_tray == 7
  2726. def test_ams_ht_tray_stored(self, h2d_client):
  2727. """Valid AMS-HT tray (128-135) → stored in last_loaded_tray."""
  2728. h2d_client._process_message(_extruder_state_payload(0x0100))
  2729. h2d_client._process_message(
  2730. _extruder_info_payload(
  2731. [
  2732. {"id": 0, "snow": 0xFF00FF},
  2733. {"id": 1, "snow": 128 << 8 | 0},
  2734. ]
  2735. )
  2736. )
  2737. h2d_client._process_message(_ams_payload(0))
  2738. assert h2d_client.state.tray_now == 128
  2739. assert h2d_client.state.last_loaded_tray == 128
  2740. def test_unloaded_not_stored(self, h2d_client):
  2741. """tray_now=255 (unloaded) → last_loaded_tray unchanged."""
  2742. h2d_client.state.last_loaded_tray = 5
  2743. h2d_client._process_message(_ams_payload(255))
  2744. assert h2d_client.state.tray_now == 255
  2745. assert h2d_client.state.last_loaded_tray == 5
  2746. # ---------------------------------------------------------------------------
  2747. # 7. H2D Active extruder switching
  2748. # ---------------------------------------------------------------------------
  2749. class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
  2750. """Active extruder switching via device.extruder.state bit 8."""
  2751. def test_active_extruder_right_by_default(self, h2d_client):
  2752. """Initial state.active_extruder == 0 (right)."""
  2753. assert h2d_client.state.active_extruder == 0
  2754. def test_extruder_state_bit8_switches_to_left(self, h2d_client):
  2755. """state=0x100 → active_extruder=1 (left)."""
  2756. h2d_client._process_message(_extruder_state_payload(0x0100))
  2757. assert h2d_client.state.active_extruder == 1
  2758. def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
  2759. """Cycle 0 → 1 → 0."""
  2760. h2d_client._process_message(_extruder_state_payload(0x0100))
  2761. assert h2d_client.state.active_extruder == 1
  2762. h2d_client._process_message(_extruder_state_payload(0x0001))
  2763. assert h2d_client.state.active_extruder == 0
  2764. def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
  2765. """Snow on both extruders; switching active changes which snow is used."""
  2766. # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
  2767. h2d_client._process_message(
  2768. _extruder_info_payload(
  2769. [
  2770. {"id": 0, "snow": 0 << 8 | 1}, # AMS 0 slot 1 → global 1
  2771. {"id": 1, "snow": 128 << 8 | 0}, # AMS HT → global 128
  2772. ]
  2773. )
  2774. )
  2775. # Right active (default) — tray_now="1" → snow ext[0] says global 1
  2776. h2d_client._process_message(_ams_payload(1))
  2777. assert h2d_client.state.tray_now == 1
  2778. # Switch to left
  2779. h2d_client._process_message(_extruder_state_payload(0x0100))
  2780. # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
  2781. h2d_client._process_message(_ams_payload(0))
  2782. assert h2d_client.state.tray_now == 128
  2783. # ---------------------------------------------------------------------------
  2784. # 8. Device identification probe (#1684 enabler)
  2785. # ---------------------------------------------------------------------------
  2786. class TestDeviceIdentificationProbe:
  2787. """One-shot INFO log of any device.* identification fields the firmware
  2788. sends. Lets a new-model support bundle self-disclose the internal model
  2789. code (e.g. dev_model_name='N2L') without a separate debug build.
  2790. """
  2791. @pytest.fixture
  2792. def mqtt_client(self):
  2793. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2794. return BambuMQTTClient(
  2795. ip_address="192.168.1.100",
  2796. serial_number="TEST_PROBE",
  2797. access_code="12345678",
  2798. )
  2799. def _device_payload(self, device):
  2800. return {"print": {"device": device}}
  2801. def test_logs_known_id_fields_once(self, mqtt_client, caplog):
  2802. import logging
  2803. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  2804. mqtt_client._process_message(
  2805. self._device_payload({"dev_model_name": "N2S", "dev_product_name": "Bambu Lab A1"})
  2806. )
  2807. matches = [r for r in caplog.records if "Device identification" in r.getMessage()]
  2808. assert len(matches) == 1
  2809. msg = matches[0].getMessage()
  2810. assert "dev_model_name" in msg and "N2S" in msg
  2811. assert "dev_product_name" in msg
  2812. def test_one_shot_does_not_repeat(self, mqtt_client, caplog):
  2813. import logging
  2814. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  2815. payload = self._device_payload({"dev_model_name": "N2S"})
  2816. mqtt_client._process_message(payload)
  2817. mqtt_client._process_message(payload)
  2818. mqtt_client._process_message(payload)
  2819. matches = [r for r in caplog.records if "Device identification" in r.getMessage()]
  2820. assert len(matches) == 1
  2821. def test_fallback_dumps_keys_when_no_known_fields(self, mqtt_client, caplog):
  2822. """Future Bambu rename (e.g. model_name without dev_ prefix) still surfaces."""
  2823. import logging
  2824. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  2825. mqtt_client._process_message(self._device_payload({"model_name": "MysteryModel", "extruder": {"state": 0}}))
  2826. matches = [r for r in caplog.records if "Device identification" in r.getMessage()]
  2827. assert len(matches) == 1
  2828. msg = matches[0].getMessage()
  2829. assert "no known id fields" in msg
  2830. assert "model_name" in msg and "extruder" in msg
  2831. # ---------------------------------------------------------------------------
  2832. # 8. H2D Full multi-message sequences
  2833. # ---------------------------------------------------------------------------
  2834. class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
  2835. """Multi-message sequences simulating real H2D Pro prints."""
  2836. def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
  2837. """Setup → load AMS 0 slot 1 → verify tray_now=1."""
  2838. # Snow update: extruder 0 loading AMS 0 slot 1
  2839. h2d_client._process_message(
  2840. _extruder_info_payload(
  2841. [
  2842. {"id": 0, "snow": 0 << 8 | 1},
  2843. {"id": 1, "snow": 0xFF00FF},
  2844. ]
  2845. )
  2846. )
  2847. # Printer reports tray_now="1"
  2848. h2d_client._process_message(_ams_payload(1))
  2849. assert h2d_client.state.tray_now == 1
  2850. assert h2d_client.state.last_loaded_tray == 1
  2851. def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
  2852. """Setup → switch left → load AMS HT → verify tray_now=128."""
  2853. # Switch to left extruder
  2854. h2d_client._process_message(_extruder_state_payload(0x0100))
  2855. # Snow: ext 1 → AMS HT slot 0
  2856. h2d_client._process_message(
  2857. _extruder_info_payload(
  2858. [
  2859. {"id": 0, "snow": 0xFF00FF},
  2860. {"id": 1, "snow": 128 << 8 | 0},
  2861. ]
  2862. )
  2863. )
  2864. # Printer reports tray_now="0" (AMS HT single slot)
  2865. h2d_client._process_message(_ams_payload(0))
  2866. assert h2d_client.state.tray_now == 128
  2867. assert h2d_client.state.last_loaded_tray == 128
  2868. def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
  2869. """Multi-color print alternating between right and left nozzles.
  2870. Sequence:
  2871. 1. Right loads AMS 0 slot 0 (tray=0)
  2872. 2. Switch left, load AMS HT (tray=128)
  2873. 3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
  2874. 4. Unload (255)
  2875. """
  2876. # Step 1: Right extruder loads AMS 0 slot 0
  2877. h2d_client._process_message(
  2878. _extruder_info_payload(
  2879. [
  2880. {"id": 0, "snow": 0 << 8 | 0},
  2881. {"id": 1, "snow": 0xFF00FF},
  2882. ]
  2883. )
  2884. )
  2885. h2d_client._process_message(_ams_payload(0))
  2886. assert h2d_client.state.tray_now == 0
  2887. # Step 2: Switch to left, load AMS HT
  2888. h2d_client._process_message(_extruder_state_payload(0x0100))
  2889. h2d_client._process_message(
  2890. _extruder_info_payload(
  2891. [
  2892. {"id": 0, "snow": 0 << 8 | 0},
  2893. {"id": 1, "snow": 128 << 8 | 0},
  2894. ]
  2895. )
  2896. )
  2897. h2d_client._process_message(_ams_payload(0))
  2898. assert h2d_client.state.tray_now == 128
  2899. # Step 3: Switch back to right, load AMS 0 slot 2
  2900. h2d_client._process_message(_extruder_state_payload(0x0001))
  2901. h2d_client._process_message(
  2902. _extruder_info_payload(
  2903. [
  2904. {"id": 0, "snow": 0 << 8 | 2},
  2905. {"id": 1, "snow": 128 << 8 | 0},
  2906. ]
  2907. )
  2908. )
  2909. h2d_client._process_message(_ams_payload(2))
  2910. assert h2d_client.state.tray_now == 2
  2911. # Step 4: Unload
  2912. h2d_client._process_message(_ams_payload(255))
  2913. assert h2d_client.state.tray_now == 255
  2914. assert h2d_client.state.last_loaded_tray == 2
  2915. class TestTrayChangeLog:
  2916. """Tests for tray_change_log tracking during prints (mid-print tray switch)."""
  2917. @pytest.fixture
  2918. def mqtt_client(self):
  2919. """Create a BambuMQTTClient instance for testing."""
  2920. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2921. client = BambuMQTTClient(
  2922. ip_address="192.168.1.100",
  2923. serial_number="TRAYLOG1",
  2924. access_code="12345678",
  2925. )
  2926. return client
  2927. def test_tray_change_log_defaults_empty(self, mqtt_client):
  2928. """tray_change_log starts as an empty list."""
  2929. assert mqtt_client.state.tray_change_log == []
  2930. def test_tray_change_log_seeded_on_print_start(self, mqtt_client):
  2931. """Print start clears log and seeds with initial tray at layer 0."""
  2932. mqtt_client.state.tray_now = 2
  2933. mqtt_client.state.last_loaded_tray = 2
  2934. mqtt_client._previous_gcode_state = "IDLE"
  2935. # Transition to RUNNING via _process_message
  2936. mqtt_client._process_message(
  2937. {
  2938. "print": {
  2939. "gcode_state": "RUNNING",
  2940. "gcode_file": "test.3mf",
  2941. }
  2942. }
  2943. )
  2944. assert mqtt_client.state.tray_change_log == [(2, 0)]
  2945. def test_tray_change_log_cleared_on_new_print(self, mqtt_client):
  2946. """Old log entries are cleared when a new print starts."""
  2947. mqtt_client.state.tray_change_log = [(5, 0), (3, 100)]
  2948. mqtt_client.state.tray_now = 1
  2949. mqtt_client.state.last_loaded_tray = 1
  2950. mqtt_client._previous_gcode_state = "IDLE"
  2951. mqtt_client._process_message(
  2952. {
  2953. "print": {
  2954. "gcode_state": "RUNNING",
  2955. "gcode_file": "new.3mf",
  2956. }
  2957. }
  2958. )
  2959. assert mqtt_client.state.tray_change_log == [(1, 0)]
  2960. # Helper that mirrors the production gate at bambu_mqtt.py:1571 — tests
  2961. # below replicate the gate so they validate the *contract* without needing
  2962. # to feed a synthetic AMS push through the full _process_message path.
  2963. @staticmethod
  2964. def _record_if_change(client, tn: int) -> None:
  2965. if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
  2966. if tn != client.state.last_loaded_tray and client._was_running and not client._completion_triggered:
  2967. client.state.tray_change_log.append((tn, client.state.layer_num))
  2968. client.state.last_loaded_tray = tn
  2969. def test_tray_change_recorded_during_running(self, mqtt_client):
  2970. """Tray change while RUNNING is appended to the log."""
  2971. mqtt_client.state.state = "RUNNING"
  2972. mqtt_client._was_running = True
  2973. mqtt_client._completion_triggered = False
  2974. mqtt_client.state.layer_num = 50
  2975. mqtt_client.state.last_loaded_tray = 0
  2976. mqtt_client.state.tray_change_log = [(0, 0)]
  2977. mqtt_client.state.tray_now = 1
  2978. self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
  2979. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]
  2980. def test_tray_change_not_recorded_when_idle(self, mqtt_client):
  2981. """Tray changes outside an active print are NOT logged."""
  2982. # IDLE between prints — both lifecycle flags in the cleared state.
  2983. mqtt_client.state.state = "IDLE"
  2984. mqtt_client._was_running = False
  2985. mqtt_client._completion_triggered = False
  2986. mqtt_client.state.layer_num = 0
  2987. mqtt_client.state.last_loaded_tray = 0
  2988. mqtt_client.state.tray_change_log = []
  2989. mqtt_client.state.tray_now = 3
  2990. self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
  2991. assert mqtt_client.state.tray_change_log == []
  2992. def test_tray_change_recorded_during_pause(self, mqtt_client):
  2993. """Tray change while PAUSE is also logged (AMS can swap during pause)."""
  2994. mqtt_client.state.state = "PAUSE"
  2995. mqtt_client._was_running = True
  2996. mqtt_client._completion_triggered = False
  2997. mqtt_client.state.layer_num = 75
  2998. mqtt_client.state.last_loaded_tray = 2
  2999. mqtt_client.state.tray_change_log = [(2, 0)]
  3000. mqtt_client.state.tray_now = 5
  3001. self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
  3002. assert mqtt_client.state.tray_change_log == [(2, 0), (5, 75)]
  3003. def test_tray_change_recorded_during_intermediate_state(self, mqtt_client):
  3004. """Tray change during a transient non-RUNNING state mid-print is logged.
  3005. Regression for #957: P2S firmware briefly transitions out of RUNNING
  3006. (e.g. into LOADING) when the AMS auto-falls-back from an empty spool to
  3007. a same-material sibling. The previous gate ``state in ("RUNNING",
  3008. "PAUSE")`` missed this transition entirely, so the usage tracker had no
  3009. evidence of the switch and double-credited the original tray with the
  3010. full 3MF estimate while the remain%-delta path added the fallback
  3011. weight on top. The new gate keys on the print-lifecycle flags
  3012. (``_was_running and not _completion_triggered``) so any tray change
  3013. between print start and completion is captured regardless of the
  3014. momentary gcode_state string.
  3015. """
  3016. mqtt_client.state.state = "LOADING" # not RUNNING/PAUSE — old gate would skip
  3017. mqtt_client._was_running = True
  3018. mqtt_client._completion_triggered = False
  3019. mqtt_client.state.layer_num = 42
  3020. mqtt_client.state.last_loaded_tray = 0
  3021. mqtt_client.state.tray_change_log = [(0, 0)]
  3022. # AMS auto-fallback: T0 ran out, swapped to T1 of same material
  3023. mqtt_client.state.tray_now = 1
  3024. self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
  3025. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 42)]
  3026. def test_tray_change_not_recorded_after_completion(self, mqtt_client):
  3027. """Once on_print_complete has fired, further tray changes don't pollute the log."""
  3028. mqtt_client.state.state = "FINISH"
  3029. mqtt_client._was_running = True
  3030. mqtt_client._completion_triggered = True # completion already triggered
  3031. mqtt_client.state.layer_num = 0
  3032. mqtt_client.state.last_loaded_tray = 1
  3033. mqtt_client.state.tray_change_log = [(0, 0), (1, 50)]
  3034. mqtt_client.state.tray_now = 2
  3035. self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
  3036. # Log unchanged — completion already triggered so post-print tray
  3037. # movement (e.g. printer self-cleaning) doesn't bleed into the next
  3038. # print's attribution.
  3039. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]
  3040. def test_same_tray_not_logged_twice(self, mqtt_client):
  3041. """Same tray value doesn't create duplicate log entries."""
  3042. mqtt_client.state.state = "RUNNING"
  3043. mqtt_client._was_running = True
  3044. mqtt_client._completion_triggered = False
  3045. mqtt_client.state.layer_num = 30
  3046. mqtt_client.state.last_loaded_tray = 2
  3047. mqtt_client.state.tray_change_log = [(2, 0)]
  3048. mqtt_client.state.tray_now = 2
  3049. self._record_if_change(mqtt_client, mqtt_client.state.tray_now)
  3050. assert mqtt_client.state.tray_change_log == [(2, 0)]
  3051. def test_multiple_tray_changes(self, mqtt_client):
  3052. """Multiple tray changes create a full history."""
  3053. mqtt_client.state.state = "RUNNING"
  3054. mqtt_client._was_running = True
  3055. mqtt_client._completion_triggered = False
  3056. mqtt_client.state.last_loaded_tray = 0
  3057. mqtt_client.state.tray_change_log = [(0, 0)]
  3058. for tray, layer in [(1, 50), (3, 120), (0, 200)]:
  3059. mqtt_client.state.tray_now = tray
  3060. mqtt_client.state.layer_num = layer
  3061. self._record_if_change(mqtt_client, tray)
  3062. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50), (3, 120), (0, 200)]
  3063. class TestDeveloperModeDetection:
  3064. """Tests for developer LAN mode detection from MQTT 'fun' field."""
  3065. @pytest.fixture
  3066. def mqtt_client(self):
  3067. """Create a BambuMQTTClient instance for testing."""
  3068. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3069. client = BambuMQTTClient(
  3070. ip_address="192.168.1.100",
  3071. serial_number="TEST123",
  3072. access_code="12345678",
  3073. )
  3074. return client
  3075. def test_developer_mode_initially_none(self, mqtt_client):
  3076. """Verify developer_mode starts as None (unknown)."""
  3077. assert mqtt_client.state.developer_mode is None
  3078. def test_developer_mode_on_when_bit_clear(self, mqtt_client):
  3079. """Verify developer_mode is True when bit 0x20000000 is clear."""
  3080. # Bit 29 clear in lower 32 bits = developer mode ON
  3081. payload = {
  3082. "print": {
  3083. "gcode_state": "IDLE",
  3084. "fun": "1C8187FF9CFF",
  3085. }
  3086. }
  3087. mqtt_client._process_message(payload)
  3088. assert mqtt_client.state.developer_mode is True
  3089. def test_developer_mode_off_when_bit_set(self, mqtt_client):
  3090. """Verify developer_mode is False when bit 0x20000000 is set."""
  3091. # Bit 29 set in lower 32 bits = developer mode OFF (encryption required)
  3092. payload = {
  3093. "print": {
  3094. "gcode_state": "IDLE",
  3095. "fun": "1C81A7FF9CFF",
  3096. }
  3097. }
  3098. mqtt_client._process_message(payload)
  3099. assert mqtt_client.state.developer_mode is False
  3100. def test_developer_mode_exact_bit_check(self, mqtt_client):
  3101. """Verify only bit 0x20000000 matters, not other bits."""
  3102. # 0x20000000 in hex = bit 29. Set ONLY that bit.
  3103. payload = {
  3104. "print": {
  3105. "gcode_state": "IDLE",
  3106. "fun": "000020000000",
  3107. }
  3108. }
  3109. mqtt_client._process_message(payload)
  3110. assert mqtt_client.state.developer_mode is False
  3111. # All zeros = all bits clear = developer mode ON
  3112. payload["print"]["fun"] = "000000000000"
  3113. mqtt_client._process_message(payload)
  3114. assert mqtt_client.state.developer_mode is True
  3115. def test_developer_mode_invalid_fun_ignored(self, mqtt_client):
  3116. """Verify invalid fun values don't crash or change state."""
  3117. mqtt_client.state.developer_mode = True
  3118. payload = {
  3119. "print": {
  3120. "gcode_state": "IDLE",
  3121. "fun": "not_a_hex_value",
  3122. }
  3123. }
  3124. mqtt_client._process_message(payload)
  3125. # Should remain unchanged
  3126. assert mqtt_client.state.developer_mode is True
  3127. def test_developer_mode_missing_fun_preserves_state(self, mqtt_client):
  3128. """Verify messages without fun field don't reset developer_mode."""
  3129. mqtt_client.state.developer_mode = False
  3130. payload = {
  3131. "print": {
  3132. "gcode_state": "RUNNING",
  3133. "mc_percent": 50,
  3134. }
  3135. }
  3136. mqtt_client._process_message(payload)
  3137. assert mqtt_client.state.developer_mode is False
  3138. def test_developer_mode_persists_across_messages(self, mqtt_client):
  3139. """Verify developer_mode set by fun persists across messages without fun."""
  3140. # First message sets developer_mode
  3141. mqtt_client._process_message(
  3142. {
  3143. "print": {
  3144. "gcode_state": "IDLE",
  3145. "fun": "3EC1AFFF9CFF",
  3146. }
  3147. }
  3148. )
  3149. assert mqtt_client.state.developer_mode is False
  3150. # Subsequent messages without fun don't change it
  3151. for _ in range(3):
  3152. mqtt_client._process_message(
  3153. {
  3154. "print": {
  3155. "gcode_state": "RUNNING",
  3156. "mc_percent": 50,
  3157. }
  3158. }
  3159. )
  3160. assert mqtt_client.state.developer_mode is False
  3161. class TestMqttCommandVerificationFailed:
  3162. """HMS 0500_0500_0001_0007 is the printer refusing to verify our commands (#2732).
  3163. A P1S on firmware 01.10.00.00 answers queries normally while dropping every
  3164. control command, so nothing else in the connection looks wrong. This HMS is
  3165. the only direct evidence, which makes it authoritative over the probe.
  3166. """
  3167. @pytest.fixture
  3168. def mqtt_client(self):
  3169. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3170. return BambuMQTTClient(
  3171. ip_address="192.168.1.100",
  3172. serial_number="01S00A000000000",
  3173. access_code="12345678",
  3174. )
  3175. @staticmethod
  3176. def _hms_payload(*entries):
  3177. return {"print": {"gcode_state": "IDLE", "hms": list(entries)}}
  3178. # attr 0x05000500, code 0x00010007 — the values a real P1S sends.
  3179. VERIFY_FAILED = {"attr": 83887360, "code": 65543}
  3180. OTHER_FAULT = {"attr": 0x03000200, "code": 0x00018012}
  3181. def test_hms_forces_developer_mode_false(self, mqtt_client):
  3182. mqtt_client.state.developer_mode = True # what the probe wrongly concluded
  3183. mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
  3184. assert mqtt_client.state.developer_mode is False
  3185. def test_hms_is_surfaced_with_its_full_code(self, mqtt_client):
  3186. """The short code collapses to a useless 0500_0007 — full_code must survive."""
  3187. from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
  3188. mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
  3189. assert [e.full_code for e in mqtt_client.state.hms_errors] == [HMS_MQTT_VERIFY_FAILED]
  3190. def test_unrelated_hms_does_not_touch_developer_mode(self, mqtt_client):
  3191. mqtt_client.state.developer_mode = True
  3192. mqtt_client._process_message(self._hms_payload(self.OTHER_FAULT))
  3193. assert mqtt_client.state.developer_mode is True
  3194. def test_clearing_the_hms_re_arms_the_probe(self, mqtt_client):
  3195. """Enabling Developer Mode and restarting must not leave a stuck False."""
  3196. mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
  3197. assert mqtt_client.state.developer_mode is False
  3198. mqtt_client._dev_mode_probed = True
  3199. mqtt_client._process_message(self._hms_payload())
  3200. assert mqtt_client.state.developer_mode is None
  3201. assert mqtt_client._dev_mode_probed is False
  3202. def test_empty_hms_leaves_a_probe_verdict_alone(self, mqtt_client):
  3203. """Only the HMS-derived latch self-clears; a probe's False is not ours to undo."""
  3204. mqtt_client.state.developer_mode = False # from an explicit probe refusal
  3205. mqtt_client._process_message(self._hms_payload())
  3206. assert mqtt_client.state.developer_mode is False
  3207. def test_inconclusive_probe_does_not_overwrite_the_hms_verdict(self, mqtt_client):
  3208. mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
  3209. mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
  3210. assert mqtt_client.state.developer_mode is False
  3211. class TestDeveloperModeProbeInconclusive:
  3212. """An empty probe response proves nothing and must not read as ENABLED (#2732)."""
  3213. @pytest.fixture
  3214. def mqtt_client(self):
  3215. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3216. return BambuMQTTClient(
  3217. ip_address="192.168.1.100",
  3218. serial_number="TEST123",
  3219. access_code="12345678",
  3220. )
  3221. def test_empty_result_stays_unknown(self, mqtt_client):
  3222. """P1S 01.10.00.00 echoes the command back with no `result` field at all."""
  3223. mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
  3224. assert mqtt_client.state.developer_mode is None
  3225. def test_explicit_success_still_enables(self, mqtt_client):
  3226. mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": "success"})
  3227. assert mqtt_client.state.developer_mode is True
  3228. def test_verify_failure_still_disables(self, mqtt_client):
  3229. mqtt_client._handle_dev_mode_probe_response(
  3230. {"sequence_id": "3", "result": "failed", "reason": "mqtt message verify failed"}
  3231. )
  3232. assert mqtt_client.state.developer_mode is False
  3233. def test_inconclusive_response_still_clears_probe_bookkeeping(self, mqtt_client):
  3234. """Whatever the verdict, the response ends the probe (no retry storm)."""
  3235. mqtt_client._dev_mode_probe_seq = "3"
  3236. mqtt_client._dev_mode_probe_failures = 1
  3237. mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": ""})
  3238. assert mqtt_client._dev_mode_probe_seq is None
  3239. assert mqtt_client._dev_mode_probe_failures == 0
  3240. class TestDeveloperModeProbeTimeout:
  3241. """Tests for developer mode probe timeout, retry, and forced reconnect (#887).
  3242. When a printer's MQTT session is half-broken (sends status but ignores
  3243. commands), the developer mode probe gets no response. The timeout logic
  3244. retries once, then force-closes the socket on the second failure.
  3245. """
  3246. @pytest.fixture
  3247. def mqtt_client(self):
  3248. import time
  3249. from unittest.mock import MagicMock
  3250. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3251. client = BambuMQTTClient(
  3252. ip_address="192.168.1.100",
  3253. serial_number="TEST123",
  3254. access_code="12345678",
  3255. )
  3256. # Simulate connected state with a mock MQTT client
  3257. client.state.connected = True
  3258. mock_paho = MagicMock()
  3259. mock_paho.socket.return_value = MagicMock()
  3260. client._client = mock_paho
  3261. # Set connect time in the past so the 5s probe delay is satisfied
  3262. client._connect_time = time.monotonic() - 10.0
  3263. return client
  3264. def _make_pushall_data(self):
  3265. """Create a print data dict with >30 keys (triggers probe) and no 'fun' field."""
  3266. return {f"key_{i}": i for i in range(35)}
  3267. def test_first_timeout_allows_retry(self, mqtt_client):
  3268. """After first probe timeout, _dev_mode_probed resets to allow retry."""
  3269. import time
  3270. data = self._make_pushall_data()
  3271. # First pushall triggers the probe
  3272. mqtt_client._update_state(data)
  3273. assert mqtt_client._dev_mode_probed is True
  3274. assert mqtt_client._dev_mode_probe_seq is not None
  3275. assert mqtt_client.state.developer_mode is None
  3276. # Simulate 11 seconds passing
  3277. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  3278. # Next status message detects the timeout
  3279. mqtt_client._update_state(data)
  3280. assert mqtt_client._dev_mode_probe_failures == 1
  3281. assert mqtt_client._dev_mode_probe_seq is None
  3282. # Should allow retry on next full message
  3283. assert mqtt_client._dev_mode_probed is False
  3284. # Connection should NOT be force-closed after 1 failure
  3285. assert mqtt_client.state.connected is True
  3286. def test_second_timeout_forces_reconnect(self, mqtt_client):
  3287. """After two consecutive probe timeouts, force-close the socket.
  3288. Probe timeout detection runs from paho's network thread (no asyncio
  3289. loop), so force_reconnect_stale_session routes through socket-close
  3290. rather than hard-reset (loop_stop from inside the loop deadlocks)."""
  3291. import time
  3292. data = self._make_pushall_data()
  3293. state_change_called = []
  3294. mqtt_client.on_state_change = lambda s: state_change_called.append(True)
  3295. # First probe + timeout
  3296. mqtt_client._update_state(data)
  3297. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  3298. mqtt_client._update_state(data)
  3299. assert mqtt_client._dev_mode_probe_failures == 1
  3300. # Second probe (retry) + timeout
  3301. mqtt_client._update_state(data) # triggers new probe
  3302. assert mqtt_client._dev_mode_probed is True
  3303. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  3304. mqtt_client._update_state(data) # detects second timeout
  3305. assert mqtt_client._dev_mode_probe_failures == 2
  3306. assert mqtt_client.state.connected is False
  3307. assert mqtt_client._stale_reconnecting is True
  3308. # Sync test → no running loop → socket-close fallback path
  3309. mqtt_client._client.socket().close.assert_called()
  3310. assert len(state_change_called) > 0
  3311. def test_successful_probe_resets_failure_counter(self, mqtt_client):
  3312. """A probe response after a previous failure resets the counter."""
  3313. import time
  3314. data = self._make_pushall_data()
  3315. # First probe + timeout → failure=1
  3316. mqtt_client._update_state(data)
  3317. seq = mqtt_client._dev_mode_probe_seq
  3318. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  3319. mqtt_client._update_state(data)
  3320. assert mqtt_client._dev_mode_probe_failures == 1
  3321. # Retry probe
  3322. mqtt_client._update_state(data)
  3323. new_seq = mqtt_client._dev_mode_probe_seq
  3324. assert new_seq is not None
  3325. assert new_seq != seq
  3326. # Simulate successful response
  3327. mqtt_client._handle_dev_mode_probe_response(
  3328. {
  3329. "command": "ams_filament_setting",
  3330. "sequence_id": new_seq,
  3331. "result": "success",
  3332. }
  3333. )
  3334. assert mqtt_client._dev_mode_probe_failures == 0
  3335. assert mqtt_client.state.developer_mode is True
  3336. assert mqtt_client._dev_mode_probe_seq is None
  3337. def test_no_timeout_when_probe_not_sent(self, mqtt_client):
  3338. """The timeout branch is only entered when a probe is pending."""
  3339. # No probe sent — _dev_mode_probed is False, _dev_mode_probe_seq is None
  3340. data = {"gcode_state": "IDLE", "mc_percent": 0} # < 30 keys
  3341. mqtt_client._update_state(data)
  3342. assert mqtt_client._dev_mode_probe_failures == 0
  3343. def test_on_connect_resets_probe_state_but_preserves_developer_mode(self, mqtt_client):
  3344. """_on_connect resets probe tracking but preserves cached developer_mode."""
  3345. import time
  3346. mqtt_client._dev_mode_probed = True
  3347. mqtt_client._dev_mode_probe_seq = "42"
  3348. mqtt_client._dev_mode_probe_time = time.monotonic()
  3349. mqtt_client._dev_mode_probe_failures = 2
  3350. mqtt_client.state.developer_mode = True
  3351. # subscribe() must return (result, mid) tuple
  3352. mqtt_client._client.subscribe.return_value = (0, 1)
  3353. mqtt_client._on_connect(mqtt_client._client, None, None, 0)
  3354. # developer_mode is preserved across reconnects (#887)
  3355. assert mqtt_client.state.developer_mode is True
  3356. assert mqtt_client._dev_mode_probed is False
  3357. assert mqtt_client._dev_mode_probe_seq is None
  3358. assert mqtt_client._dev_mode_probe_time == 0.0
  3359. assert mqtt_client._dev_mode_probe_failures == 0
  3360. assert mqtt_client._connect_time > 0
  3361. def test_probe_deferred_when_connect_too_recent(self, mqtt_client):
  3362. """Probe is deferred if less than 5s have passed since _on_connect."""
  3363. import time
  3364. data = self._make_pushall_data()
  3365. # Set connect time to 1 second ago — too recent for probe
  3366. mqtt_client._connect_time = time.monotonic() - 1.0
  3367. mqtt_client._update_state(data)
  3368. # Pushall seen, so needs_probe is set, but probe NOT fired yet
  3369. assert mqtt_client._dev_mode_needs_probe is True
  3370. assert mqtt_client._dev_mode_probed is False
  3371. assert mqtt_client._dev_mode_probe_seq is None
  3372. def test_probe_fires_after_delay(self, mqtt_client):
  3373. """Probe fires once 5s have passed since _on_connect."""
  3374. import time
  3375. data = self._make_pushall_data()
  3376. # Set connect time to 6 seconds ago — delay satisfied
  3377. mqtt_client._connect_time = time.monotonic() - 6.0
  3378. mqtt_client._update_state(data)
  3379. # Probe should have fired
  3380. assert mqtt_client._dev_mode_needs_probe is True
  3381. assert mqtt_client._dev_mode_probed is True
  3382. assert mqtt_client._dev_mode_probe_seq is not None
  3383. def test_probe_fires_on_incremental_after_delay(self, mqtt_client):
  3384. """After seeing a pushall within 5s, probe fires on later incremental message."""
  3385. import time
  3386. pushall_data = self._make_pushall_data()
  3387. incremental_data = {"gcode_state": "IDLE", "mc_percent": 0} # < 30 keys
  3388. # Pushall arrives 1s after connect — too early for probe
  3389. mqtt_client._connect_time = time.monotonic() - 1.0
  3390. mqtt_client._update_state(pushall_data)
  3391. assert mqtt_client._dev_mode_needs_probe is True
  3392. assert mqtt_client._dev_mode_probed is False
  3393. # 5s later, an incremental update arrives — probe fires now
  3394. mqtt_client._connect_time = time.monotonic() - 6.0
  3395. mqtt_client._update_state(incremental_data)
  3396. assert mqtt_client._dev_mode_probed is True
  3397. assert mqtt_client._dev_mode_probe_seq is not None
  3398. def test_no_reprobe_when_developer_mode_cached(self, mqtt_client):
  3399. """Auto-reconnect preserves developer_mode, skipping reprobe."""
  3400. import time
  3401. data = self._make_pushall_data()
  3402. # Simulate known developer_mode from previous connection
  3403. mqtt_client.state.developer_mode = True
  3404. mqtt_client._connect_time = time.monotonic() - 10.0
  3405. mqtt_client._update_state(data)
  3406. # Should NOT probe — developer_mode is already known
  3407. assert mqtt_client._dev_mode_needs_probe is False
  3408. assert mqtt_client._dev_mode_probed is False
  3409. assert mqtt_client._dev_mode_probe_seq is None
  3410. assert mqtt_client.state.developer_mode is True
  3411. def test_on_connect_resets_needs_probe(self, mqtt_client):
  3412. """_on_connect resets _dev_mode_needs_probe for a clean start."""
  3413. mqtt_client._dev_mode_needs_probe = True
  3414. mqtt_client._client.subscribe.return_value = (0, 1)
  3415. mqtt_client._on_connect(mqtt_client._client, None, None, 0)
  3416. assert mqtt_client._dev_mode_needs_probe is False
  3417. class TestVtTrayNormalization:
  3418. """Tests for vt_tray dict→list normalization in _update_state.
  3419. MQTT sends vt_tray as a dict for single-slot printers, but all consumers
  3420. expect a list. _update_state must normalize it before any callback can
  3421. read raw_data, because the dev-mode probe may release the GIL and let
  3422. the event loop read the partially-updated state.
  3423. """
  3424. @pytest.fixture
  3425. def mqtt_client(self):
  3426. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3427. client = BambuMQTTClient(
  3428. ip_address="192.168.1.100",
  3429. serial_number="TEST123",
  3430. access_code="12345678",
  3431. )
  3432. return client
  3433. def test_vt_tray_dict_normalized_in_update_state(self, mqtt_client):
  3434. """Verify _update_state wraps a raw vt_tray dict into a list."""
  3435. vt_dict = {
  3436. "id": "254",
  3437. "tray_color": "FF0000",
  3438. "tray_type": "PLA",
  3439. "tag_uid": "0000000000000000",
  3440. "tray_uuid": "00000000000000000000000000000000",
  3441. }
  3442. data = {"gcode_state": "IDLE", "vt_tray": vt_dict}
  3443. mqtt_client._update_state(data)
  3444. stored = mqtt_client.state.raw_data.get("vt_tray")
  3445. assert isinstance(stored, list)
  3446. assert len(stored) == 1
  3447. assert stored[0]["tray_color"] == "FF0000"
  3448. def test_vt_tray_list_unchanged_in_update_state(self, mqtt_client):
  3449. """Verify _update_state keeps an already-list vt_tray unchanged."""
  3450. vt_list = [
  3451. {"id": "254", "tray_type": "PLA"},
  3452. {"id": "255", "tray_type": "PETG"},
  3453. ]
  3454. data = {"gcode_state": "IDLE", "vt_tray": vt_list}
  3455. mqtt_client._update_state(data)
  3456. stored = mqtt_client.state.raw_data.get("vt_tray")
  3457. assert isinstance(stored, list)
  3458. assert len(stored) == 2
  3459. def test_preserved_vt_tray_restored_before_probe(self, mqtt_client):
  3460. """Verify preserved vt_tray is restored before dev-mode probe runs.
  3461. On the first message, the incremental handler wraps vt_tray into a list
  3462. and stores it. _update_state then replaces raw_data with the full data
  3463. dict, but must restore preserved fields BEFORE the probe publishes
  3464. (which can release the GIL).
  3465. """
  3466. # Simulate: incremental handler already stored a wrapped list
  3467. mqtt_client.state.raw_data = {
  3468. "vt_tray": [{"id": "254", "tray_type": "PLA", "tray_color": "00FF00"}],
  3469. }
  3470. # Now _update_state runs with new data that has vt_tray as dict
  3471. new_data = {
  3472. "gcode_state": "IDLE",
  3473. "vt_tray": {"id": "254", "tray_type": "PETG", "tray_color": "FF0000"},
  3474. }
  3475. mqtt_client._update_state(new_data)
  3476. # The preserved list (PLA/green) should take priority over new data
  3477. stored = mqtt_client.state.raw_data["vt_tray"]
  3478. assert isinstance(stored, list)
  3479. assert stored[0]["tray_type"] == "PLA"
  3480. assert stored[0]["tray_color"] == "00FF00"
  3481. def test_first_message_vt_tray_dict_becomes_list(self, mqtt_client):
  3482. """Verify on the very first message, vt_tray dict is still a list.
  3483. When there's no previously preserved data, the normalized dict should
  3484. remain as a list in raw_data.
  3485. """
  3486. # raw_data starts empty — no preserved vt_tray
  3487. mqtt_client.state.raw_data = {}
  3488. data = {
  3489. "gcode_state": "IDLE",
  3490. "vt_tray": {"id": "254", "tray_type": "ABS"},
  3491. }
  3492. mqtt_client._update_state(data)
  3493. stored = mqtt_client.state.raw_data["vt_tray"]
  3494. assert isinstance(stored, list)
  3495. assert stored[0]["tray_type"] == "ABS"
  3496. class TestSendDryingCommand:
  3497. """Tests for send_drying_command MQTT payload construction."""
  3498. @pytest.fixture
  3499. def mqtt_client(self):
  3500. """Create a BambuMQTTClient with a mock MQTT client."""
  3501. from unittest.mock import MagicMock
  3502. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3503. client = BambuMQTTClient(
  3504. ip_address="192.168.1.100",
  3505. serial_number="TEST123",
  3506. access_code="12345678",
  3507. )
  3508. client._client = MagicMock()
  3509. return client
  3510. def test_rotate_tray_false_by_default(self, mqtt_client):
  3511. """Verify rotate_tray defaults to False in the MQTT payload."""
  3512. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
  3513. call_args = mqtt_client._client.publish.call_args
  3514. payload = json.loads(call_args[0][1])
  3515. assert payload["print"]["rotate_tray"] is False
  3516. def test_rotate_tray_true_when_enabled(self, mqtt_client):
  3517. """Verify rotate_tray is True when explicitly enabled."""
  3518. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA", rotate_tray=True)
  3519. call_args = mqtt_client._client.publish.call_args
  3520. payload = json.loads(call_args[0][1])
  3521. assert payload["print"]["rotate_tray"] is True
  3522. def test_rotate_tray_false_on_stop(self, mqtt_client):
  3523. """Verify rotate_tray is False when stopping drying (mode=0)."""
  3524. mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
  3525. call_args = mqtt_client._client.publish.call_args
  3526. payload = json.loads(call_args[0][1])
  3527. assert payload["print"]["rotate_tray"] is False
  3528. def test_all_required_fields_present(self, mqtt_client):
  3529. """Verify all required MQTT fields are present in the drying command."""
  3530. mqtt_client.send_drying_command(ams_id=128, temp=75, duration=8, mode=1, filament="ABS", rotate_tray=True)
  3531. call_args = mqtt_client._client.publish.call_args
  3532. payload = json.loads(call_args[0][1])
  3533. cmd = payload["print"]
  3534. assert cmd["command"] == "ams_filament_drying"
  3535. assert cmd["ams_id"] == 128
  3536. assert cmd["temp"] == 75
  3537. assert cmd["duration"] == 8
  3538. assert cmd["mode"] == 1
  3539. assert cmd["rotate_tray"] is True
  3540. assert cmd["filament"] == "ABS"
  3541. assert cmd["cooling_temp"] == 20
  3542. assert cmd["humidity"] == 0
  3543. assert cmd["close_power_conflict"] is False
  3544. assert "sequence_id" in cmd
  3545. def test_publishes_with_qos_1(self, mqtt_client):
  3546. """Verify drying commands are published with QoS 1."""
  3547. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4)
  3548. call_args = mqtt_client._client.publish.call_args
  3549. # qos may be positional arg [2] or keyword
  3550. qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
  3551. assert qos == 1
  3552. def test_start_caches_target_for_badge(self, mqtt_client):
  3553. """mode=1 send populates _drying_targets so the badge can render it."""
  3554. mqtt_client.send_drying_command(ams_id=2, temp=65, duration=12, mode=1, filament="PETG")
  3555. assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65, "duration_hours": 12}
  3556. def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
  3557. """A second start on the same AMS replaces the cached target."""
  3558. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
  3559. mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
  3560. assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70, "duration_hours": 6}
  3561. def test_stop_clears_target(self, mqtt_client):
  3562. """mode=0 send drops the cache so the badge stops showing the target."""
  3563. mqtt_client.send_drying_command(ams_id=1, temp=55, duration=4, mode=1, filament="PLA")
  3564. assert 1 in mqtt_client._drying_targets
  3565. mqtt_client.send_drying_command(ams_id=1, temp=0, duration=0, mode=0)
  3566. assert 1 not in mqtt_client._drying_targets
  3567. def test_targets_isolated_per_ams_id(self, mqtt_client):
  3568. """Stopping one AMS doesn't affect another AMS's cached target."""
  3569. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
  3570. mqtt_client.send_drying_command(ams_id=128, temp=80, duration=6, mode=1, filament="PA-CF")
  3571. mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
  3572. assert 0 not in mqtt_client._drying_targets
  3573. assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80, "duration_hours": 6}
  3574. class TestStartPrintAmsMapping:
  3575. """Tests for ams_mapping/ams_mapping2 construction in start_print().
  3576. BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
  3577. ams_mapping and puts the real external spool info only in ams_mapping2.
  3578. Passing raw 254/255 in the flat array causes H2D firmware to fail
  3579. with 0700_8012 "Failed to get AMS mapping table".
  3580. """
  3581. @pytest.fixture
  3582. def mqtt_client(self):
  3583. from unittest.mock import MagicMock
  3584. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3585. client = BambuMQTTClient(
  3586. ip_address="192.168.1.100",
  3587. serial_number="TEST123",
  3588. access_code="12345678",
  3589. )
  3590. client._client = MagicMock()
  3591. client.state.connected = True
  3592. return client
  3593. def _get_published_command(self, mqtt_client):
  3594. """Extract the parsed print command from the last publish call."""
  3595. call_args = mqtt_client._client.publish.call_args
  3596. return json.loads(call_args[0][1])["print"]
  3597. def test_regular_ams_trays_preserved_in_flat_mapping(self, mqtt_client):
  3598. """Regular AMS tray IDs pass through unchanged in flat ams_mapping."""
  3599. mqtt_client.start_print("test.3mf", ams_mapping=[0, 5, 11])
  3600. cmd = self._get_published_command(mqtt_client)
  3601. assert cmd["ams_mapping"] == [0, 5, 11]
  3602. assert cmd["ams_mapping2"] == [
  3603. {"ams_id": 0, "slot_id": 0},
  3604. {"ams_id": 1, "slot_id": 1},
  3605. {"ams_id": 2, "slot_id": 3},
  3606. ]
  3607. def test_unmapped_slots(self, mqtt_client):
  3608. """Unmapped slots (-1) produce -1 in flat and 0xFF/0xFF in mapping2."""
  3609. mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1])
  3610. cmd = self._get_published_command(mqtt_client)
  3611. assert cmd["ams_mapping"] == [-1, -1]
  3612. assert cmd["ams_mapping2"] == [
  3613. {"ams_id": 255, "slot_id": 255},
  3614. {"ams_id": 255, "slot_id": 255},
  3615. ]
  3616. def test_external_main_nozzle_becomes_minus_one_in_flat(self, mqtt_client):
  3617. """Virtual tray 255 (main nozzle) must be -1 in flat mapping."""
  3618. mqtt_client.start_print("test.3mf", ams_mapping=[255])
  3619. cmd = self._get_published_command(mqtt_client)
  3620. assert cmd["ams_mapping"] == [-1]
  3621. assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
  3622. def test_single_nozzle_external_spool_uses_main_id(self, mqtt_client):
  3623. """Single-nozzle external spool (254) maps to ams_id=255 (VIRTUAL_TRAY_MAIN_ID).
  3624. Firmware reports tray_now=254 for external spool, but the print command
  3625. must use ams_id=255 in ams_mapping2. Sending 254 causes the firmware to
  3626. target AMS tray 0 instead of external spool (07FF_8012 error).
  3627. """
  3628. mqtt_client.start_print("test.3mf", ams_mapping=[254])
  3629. cmd = self._get_published_command(mqtt_client)
  3630. assert cmd["ams_mapping"] == [-1]
  3631. assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
  3632. def test_h2d_external_spool_mixed_with_ams(self, mqtt_client):
  3633. """H2D scenario: AMS trays + unmapped + external deputy nozzle."""
  3634. # Reproduces the exact scenario from issue #797:
  3635. # 5-slot 3MF, only slot 5 assigned to external deputy nozzle (254)
  3636. mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1, -1, -1, 255])
  3637. cmd = self._get_published_command(mqtt_client)
  3638. # Flat mapping: all -1 (external converted, unmapped stay -1)
  3639. assert cmd["ams_mapping"] == [-1, -1, -1, -1, -1]
  3640. # Detailed mapping: unmapped slots use 0xFF, external uses real ams_id
  3641. assert cmd["ams_mapping2"] == [
  3642. {"ams_id": 255, "slot_id": 255},
  3643. {"ams_id": 255, "slot_id": 255},
  3644. {"ams_id": 255, "slot_id": 255},
  3645. {"ams_id": 255, "slot_id": 255},
  3646. {"ams_id": 255, "slot_id": 0},
  3647. ]
  3648. def test_ams_ht_trays_preserved_in_flat_mapping(self, mqtt_client):
  3649. """AMS-HT tray IDs (>=128) pass through in flat mapping."""
  3650. mqtt_client.start_print("test.3mf", ams_mapping=[128, 131])
  3651. cmd = self._get_published_command(mqtt_client)
  3652. assert cmd["ams_mapping"] == [128, 131]
  3653. assert cmd["ams_mapping2"] == [
  3654. {"ams_id": 128, "slot_id": 0},
  3655. {"ams_id": 131, "slot_id": 0},
  3656. ]
  3657. def test_non_h2d_both_external_maps_to_main_id(self, mqtt_client):
  3658. """Non-H2D: both 254 and 255 map to ams_id=255 (single nozzle)."""
  3659. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
  3660. cmd = self._get_published_command(mqtt_client)
  3661. assert cmd["ams_mapping"] == [-1, -1]
  3662. assert cmd["ams_mapping2"] == [
  3663. {"ams_id": 255, "slot_id": 0},
  3664. {"ams_id": 255, "slot_id": 0},
  3665. ]
  3666. def test_h2d_external_preserves_deputy_id(self, mqtt_client):
  3667. """H2D dual-nozzle: 254 (deputy) stays 254, 255 (main) stays 255."""
  3668. mqtt_client.model = "H2D"
  3669. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
  3670. cmd = self._get_published_command(mqtt_client)
  3671. assert cmd["ams_mapping"] == [-1, -1]
  3672. assert cmd["ams_mapping2"] == [
  3673. {"ams_id": 254, "slot_id": 0},
  3674. {"ams_id": 255, "slot_id": 0},
  3675. ]
  3676. def test_h2d_single_external_deputy(self, mqtt_client):
  3677. """H2D: single external spool on deputy nozzle (254) keeps ams_id=254."""
  3678. mqtt_client.model = "H2D Pro"
  3679. mqtt_client.start_print("test.3mf", ams_mapping=[254])
  3680. cmd = self._get_published_command(mqtt_client)
  3681. assert cmd["ams_mapping"] == [-1]
  3682. assert cmd["ams_mapping2"] == [{"ams_id": 254, "slot_id": 0}]
  3683. def test_external_spool_only_sets_use_ams_false(self, mqtt_client):
  3684. """Single external spool on non-H2D printer sets use_ams=False."""
  3685. mqtt_client.start_print("test.3mf", ams_mapping=[254], use_ams=True)
  3686. cmd = self._get_published_command(mqtt_client)
  3687. assert cmd["use_ams"] is False
  3688. def test_all_unresolved_keeps_use_ams_true(self, mqtt_client):
  3689. """All-unresolved (-1) is NOT external — must keep use_ams=True (#2589).
  3690. A stored [-1] comes from an unresolved mapping (e.g. a frontend
  3691. status-load race), not an explicit external-spool selection. Treating it
  3692. as external silently started the print against the empty external feed.
  3693. Only >=254 may downgrade to use_ams=False.
  3694. """
  3695. mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1], use_ams=True)
  3696. cmd = self._get_published_command(mqtt_client)
  3697. assert cmd["use_ams"] is True
  3698. def test_mixed_ams_and_external_keeps_use_ams_true(self, mqtt_client):
  3699. """AMS tray + external spool keeps use_ams=True."""
  3700. mqtt_client.start_print("test.3mf", ams_mapping=[0, 254], use_ams=True)
  3701. cmd = self._get_published_command(mqtt_client)
  3702. assert cmd["use_ams"] is True
  3703. def test_h2d_both_external_keeps_use_ams_true(self, mqtt_client):
  3704. """H2D with both external spools keeps use_ams=True (nozzle routing)."""
  3705. mqtt_client.model = "H2D"
  3706. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255], use_ams=True)
  3707. cmd = self._get_published_command(mqtt_client)
  3708. assert cmd["use_ams"] is True
  3709. def test_empty_ams_mapping_keeps_use_ams_true(self, mqtt_client):
  3710. """Empty ams_mapping list does not override use_ams."""
  3711. mqtt_client.start_print("test.3mf", ams_mapping=[], use_ams=True)
  3712. cmd = self._get_published_command(mqtt_client)
  3713. assert cmd["use_ams"] is True
  3714. def test_no_ams_mapping_omits_fields(self, mqtt_client):
  3715. """When ams_mapping is None, neither field is in the command."""
  3716. mqtt_client.start_print("test.3mf", ams_mapping=None)
  3717. cmd = self._get_published_command(mqtt_client)
  3718. assert "ams_mapping" not in cmd
  3719. assert "ams_mapping2" not in cmd
  3720. def test_x2d_external_preserves_deputy_id(self, mqtt_client):
  3721. """X2D dual-nozzle (#988): 254 (deputy) stays 254, like H2D family.
  3722. X2D launched April 2026 and shares the H2D-style dual-extruder
  3723. firmware convention — external spool on the deputy (left) nozzle
  3724. is addressed as ams_id=254, not coerced to 255.
  3725. """
  3726. mqtt_client.model = "X2D"
  3727. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
  3728. cmd = self._get_published_command(mqtt_client)
  3729. assert cmd["ams_mapping"] == [-1, -1]
  3730. assert cmd["ams_mapping2"] == [
  3731. {"ams_id": 254, "slot_id": 0},
  3732. {"ams_id": 255, "slot_id": 0},
  3733. ]
  3734. def test_x2d_uses_boolean_format_for_calibration_fields(self, mqtt_client):
  3735. """X2D sends calibration fields as JSON booleans, like every model (#1478).
  3736. An earlier revision integer-encoded these for the H2 family on the
  3737. belief that H2 firmware required 0/1. A BambuStudio request-topic
  3738. capture from a real H2D disproved it — BambuStudio sends plain
  3739. booleans — so X2D follows the same boolean format.
  3740. """
  3741. mqtt_client.model = "X2D"
  3742. mqtt_client.start_print(
  3743. "test.3mf",
  3744. timelapse=True,
  3745. bed_levelling="off",
  3746. flow_cali="on",
  3747. vibration_cali=False,
  3748. layer_inspect=True,
  3749. )
  3750. cmd = self._get_published_command(mqtt_client)
  3751. assert cmd["timelapse"] is True
  3752. # bed_leveling stays a bool (true only for "on"); the tri-state rides on
  3753. # the auto_bed_leveling int.
  3754. assert cmd["bed_leveling"] is False
  3755. assert cmd["auto_bed_leveling"] == 0
  3756. assert cmd["flow_cali"] is True
  3757. assert cmd["vibration_cali"] is False
  3758. assert cmd["layer_inspect"] is True
  3759. # flow_cali on → extrude_cali_flag must request the calibration pass.
  3760. assert cmd["extrude_cali_flag"] == 1
  3761. def test_p2s_uses_boolean_format(self, mqtt_client):
  3762. """P2S sends calibration fields as JSON booleans (single-nozzle, like X1C/A1/P1)."""
  3763. mqtt_client.model = "P2S"
  3764. mqtt_client.start_print("test.3mf", timelapse=True, flow_cali="off")
  3765. cmd = self._get_published_command(mqtt_client)
  3766. assert cmd["timelapse"] is True
  3767. assert cmd["flow_cali"] is False
  3768. # flow_cali "off" → extrude_cali_flag=0 (firmware skips the pre-print
  3769. # calibration stage entirely). "auto" would send 2 instead.
  3770. assert cmd["extrude_cali_flag"] == 0
  3771. def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
  3772. """H2S is single-nozzle (#1386): external spool (254) → ams_id=255.
  3773. H2S shares serial prefix "094" and the H-family firmware-format
  3774. quirks with H2D, but it has a single extruder (nozzle_count=1
  3775. confirmed across 9+ support bundles). Routing the deputy-nozzle
  3776. sentinel (254) through to firmware on a single-nozzle printer
  3777. causes 07FF_8012 "Failed to get AMS mapping table" — exactly the
  3778. symptom reporter krootstijn hit when printing without an AMS.
  3779. """
  3780. mqtt_client.model = "H2S"
  3781. mqtt_client.start_print("test.3mf", ams_mapping=[254])
  3782. cmd = self._get_published_command(mqtt_client)
  3783. assert cmd["ams_mapping"] == [-1]
  3784. assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
  3785. def test_h2s_no_ams_forces_use_ams_false(self, mqtt_client):
  3786. """H2S with only external spool must drop into the use_ams=False
  3787. fallback, like P1S/P1P. The dual-nozzle bypass kept this path
  3788. unreachable before #1386 — the firmware then rejected the print
  3789. with 07FF_8012 because there was no AMS mapping table.
  3790. """
  3791. mqtt_client.model = "H2S"
  3792. mqtt_client.start_print("test.3mf", ams_mapping=[254], use_ams=True)
  3793. cmd = self._get_published_command(mqtt_client)
  3794. assert cmd["use_ams"] is False
  3795. def test_h2s_uses_boolean_format_for_calibration_fields(self, mqtt_client):
  3796. """H2S sends calibration fields as JSON booleans (#1478).
  3797. The H2S was previously integer-encoded as part of the H2 family. That
  3798. made it accept the print command but silently skip flow-dynamics
  3799. calibration — the reporter saw poor corner quality from a stale K
  3800. value. BambuStudio sends booleans for these fields and pairs flow_cali
  3801. with extrude_cali_flag=1 to actually run the calibration pass.
  3802. """
  3803. mqtt_client.model = "H2S"
  3804. mqtt_client.start_print(
  3805. "test.3mf",
  3806. timelapse=True,
  3807. bed_levelling="off",
  3808. flow_cali="on",
  3809. vibration_cali=False,
  3810. layer_inspect=True,
  3811. )
  3812. cmd = self._get_published_command(mqtt_client)
  3813. assert cmd["timelapse"] is True
  3814. assert cmd["bed_leveling"] is False
  3815. assert cmd["flow_cali"] is True
  3816. assert cmd["vibration_cali"] is False
  3817. assert cmd["layer_inspect"] is True
  3818. # flow_cali on → extrude_cali_flag=1 so the printer runs the
  3819. # flow-dynamics calibration instead of reusing the stored PA value.
  3820. assert cmd["extrude_cali_flag"] == 1
  3821. def test_bed_leveling_auto_sends_int_two(self, mqtt_client):
  3822. """`bed_levelling="auto"` → bool false + auto_bed_leveling=2.
  3823. Matches BambuStudio's ops_auto wire shape: the bool is true only for the
  3824. explicit "on" state; "auto" carries its intent in the int (2 = run only
  3825. if the bed wasn't levelled recently).
  3826. """
  3827. mqtt_client.model = "X1C"
  3828. mqtt_client.start_print("test.3mf", bed_levelling="auto")
  3829. cmd = self._get_published_command(mqtt_client)
  3830. assert cmd["bed_leveling"] is False
  3831. assert cmd["auto_bed_leveling"] == 2
  3832. def test_bed_leveling_on_sends_int_one(self, mqtt_client):
  3833. """`bed_levelling="on"` → bool true + auto_bed_leveling=1 (force)."""
  3834. mqtt_client.model = "X1C"
  3835. mqtt_client.start_print("test.3mf", bed_levelling="on")
  3836. cmd = self._get_published_command(mqtt_client)
  3837. assert cmd["bed_leveling"] is True
  3838. assert cmd["auto_bed_leveling"] == 1
  3839. def test_flow_cali_auto_sends_int_two(self, mqtt_client):
  3840. """`flow_cali="auto"` → bool false + extrude_cali_flag=2.
  3841. #1721 saw stage 8 stay queued on 2 — that is the auto contract (queued,
  3842. skipped at runtime if the filament was calibrated recently), which is
  3843. exactly what "auto" should do.
  3844. """
  3845. mqtt_client.model = "X1C"
  3846. mqtt_client.start_print("test.3mf", flow_cali="auto")
  3847. cmd = self._get_published_command(mqtt_client)
  3848. assert cmd["flow_cali"] is False
  3849. assert cmd["extrude_cali_flag"] == 2
  3850. def test_nozzle_offset_cali_default_auto_gated_on_single_nozzle(self, mqtt_client):
  3851. """Default (auto) on a single-nozzle printer → wire value `0`.
  3852. The default is now "auto", but single-nozzle machines have no second
  3853. head to calibrate, so the MQTT layer gates any state to `0` there.
  3854. """
  3855. mqtt_client.model = "P1S"
  3856. mqtt_client.start_print("test.3mf")
  3857. cmd = self._get_published_command(mqtt_client)
  3858. assert cmd["nozzle_offset_cali"] == 0
  3859. def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client):
  3860. """Single-nozzle printer: `nozzle_offset_cali="on"` is silently dropped.
  3861. H2S is in the H2 firmware family but single-nozzle. The toggle has
  3862. no physical meaning on single-nozzle machines and the UI gates it
  3863. behind `nozzle_count==2`. Even if a stale queue item from when the
  3864. printer was misidentified as dual carries the flag, the MQTT layer
  3865. must downgrade it so firmware never tries to calibrate a head it
  3866. doesn't have (#1682).
  3867. """
  3868. mqtt_client.model = "P1S"
  3869. mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
  3870. cmd = self._get_published_command(mqtt_client)
  3871. assert cmd["nozzle_offset_cali"] == 0
  3872. def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client):
  3873. """Dual-nozzle printer (H2D): `nozzle_offset_cali="on"` → wire value `1`.
  3874. H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the
  3875. printer runs the nozzle-offset calibration pass before the print
  3876. starts. "on"=1 (force), "auto"=2, "off"=0 (#1682).
  3877. """
  3878. mqtt_client.model = "H2D"
  3879. mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
  3880. cmd = self._get_published_command(mqtt_client)
  3881. assert cmd["nozzle_offset_cali"] == 1
  3882. def test_nozzle_offset_cali_auto_on_dual_nozzle(self, mqtt_client):
  3883. """Dual-nozzle printer (H2D): `nozzle_offset_cali="auto"` → wire value `2`."""
  3884. mqtt_client.model = "H2D"
  3885. mqtt_client.start_print("test.3mf", nozzle_offset_cali="auto")
  3886. cmd = self._get_published_command(mqtt_client)
  3887. assert cmd["nozzle_offset_cali"] == 2
  3888. def test_nozzle_offset_cali_off_on_dual_nozzle(self, mqtt_client):
  3889. """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali="off"` → `0` (skip).
  3890. Critical for users like #1682 who run diamond nozzles and need to
  3891. keep the calibration off.
  3892. """
  3893. mqtt_client.model = "H2D Pro"
  3894. mqtt_client.start_print("test.3mf", nozzle_offset_cali="off")
  3895. cmd = self._get_published_command(mqtt_client)
  3896. assert cmd["nozzle_offset_cali"] == 0
  3897. class TestStartPrintUniqueIdentityFields:
  3898. """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
  3899. Hardcoded "0" values caused third-party MQTT observers (e.g. OctoEverywhere)
  3900. to treat archive reprints as continuations of the same job and report
  3901. compounding durations on repeat replays. Each start_print call must produce
  3902. a distinct, non-zero identity triplet so the printer emits a fresh state
  3903. transition. md5 is deliberately left empty — historically firmware treats
  3904. "" as "skip validation" and we don't have the file's real digest here.
  3905. """
  3906. @pytest.fixture
  3907. def mqtt_client(self):
  3908. from unittest.mock import MagicMock
  3909. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3910. client = BambuMQTTClient(
  3911. ip_address="192.168.1.100",
  3912. serial_number="TEST123",
  3913. access_code="12345678",
  3914. )
  3915. client._client = MagicMock()
  3916. client.state.connected = True
  3917. return client
  3918. def _get_published_command(self, mqtt_client):
  3919. call_args = mqtt_client._client.publish.call_args
  3920. return json.loads(call_args[0][1])["print"]
  3921. def test_identity_fields_are_non_zero(self, mqtt_client):
  3922. mqtt_client.start_print("test.3mf")
  3923. cmd = self._get_published_command(mqtt_client)
  3924. assert cmd["project_id"] != "0"
  3925. assert cmd["subtask_id"] != "0"
  3926. assert cmd["task_id"] != "0"
  3927. def test_identity_fields_are_all_equal_per_submission(self, mqtt_client):
  3928. """All three IDs come from the same submission timestamp — Studio also
  3929. uses a single identity per submission across the three fields."""
  3930. mqtt_client.start_print("test.3mf")
  3931. cmd = self._get_published_command(mqtt_client)
  3932. assert cmd["project_id"] == cmd["subtask_id"] == cmd["task_id"]
  3933. def test_md5_stays_empty(self, mqtt_client):
  3934. """Deliberate: synthetic md5 risks activating firmware validation."""
  3935. mqtt_client.start_print("test.3mf")
  3936. cmd = self._get_published_command(mqtt_client)
  3937. assert cmd["md5"] == ""
  3938. def test_identity_fields_change_between_submissions(self, mqtt_client):
  3939. """Two successive start_print calls must produce different IDs.
  3940. Without this, the printer can't tell replays apart and reuses
  3941. gcode_start_time from the prior job.
  3942. """
  3943. mqtt_client.start_print("test.3mf")
  3944. first = self._get_published_command(mqtt_client)
  3945. time.sleep(0.002)
  3946. mqtt_client.start_print("test.3mf")
  3947. second = self._get_published_command(mqtt_client)
  3948. assert first["task_id"] != second["task_id"]
  3949. assert first["subtask_id"] != second["subtask_id"]
  3950. assert first["project_id"] != second["project_id"]
  3951. def test_submission_id_is_numeric_string(self, mqtt_client):
  3952. """ID format: digits-only string. Studio uses cloud task IDs that are
  3953. also numeric-looking strings; the DB column is VARCHAR(64) and
  3954. Bambuddy's own subtask_id parser treats '0'/'' as absent — any valid
  3955. digit string that isn't '0' is fine."""
  3956. mqtt_client.start_print("test.3mf")
  3957. cmd = self._get_published_command(mqtt_client)
  3958. assert cmd["task_id"].isdigit()
  3959. assert int(cmd["task_id"]) > 0
  3960. assert len(cmd["task_id"]) <= 64
  3961. def test_last_dispatch_subtask_id_records_the_minted_id(self, mqtt_client):
  3962. """#1485: start_print records the minted id on the client so
  3963. on_print_start can persist it on the archive before the printer
  3964. echoes subtask_id back — letting a later restart resume by id."""
  3965. assert mqtt_client.last_dispatch_subtask_id is None
  3966. mqtt_client.start_print("test.3mf")
  3967. cmd = self._get_published_command(mqtt_client)
  3968. assert mqtt_client.last_dispatch_subtask_id == cmd["subtask_id"]
  3969. def test_last_dispatch_subtask_id_updates_per_submission(self, mqtt_client):
  3970. """Each dispatch overwrites the recorded id with the new submission's."""
  3971. mqtt_client.start_print("test.3mf")
  3972. first = mqtt_client.last_dispatch_subtask_id
  3973. time.sleep(0.002)
  3974. mqtt_client.start_print("test.3mf")
  3975. assert mqtt_client.last_dispatch_subtask_id != first
  3976. assert mqtt_client.last_dispatch_subtask_id == self._get_published_command(mqtt_client)["subtask_id"]
  3977. def test_submission_id_fits_signed_int32(self, mqtt_client):
  3978. """Regression for #1042: P1S firmware clamps oversized task identity
  3979. fields to signed int32 max (2**31-1 = 2147483647). If we send raw
  3980. epoch-ms (~1.7e12), the printer sees a saturated constant on every
  3981. submission and treats fresh dispatches as continuations of the last
  3982. FAILED job — never leaves IDLE. Keep below 2**31.
  3983. """
  3984. mqtt_client.start_print("test.3mf")
  3985. cmd = self._get_published_command(mqtt_client)
  3986. assert int(cmd["task_id"]) < 2**31
  3987. assert int(cmd["project_id"]) < 2**31
  3988. assert int(cmd["subtask_id"]) < 2**31
  3989. def test_unrelated_payload_fields_untouched(self, mqtt_client):
  3990. """Regression guard: fix only touches identity fields; everything else
  3991. (sequence_id, command verb, calibration defaults, profile_id) must be
  3992. unchanged to avoid silently breaking printer behavior."""
  3993. mqtt_client.start_print("test.3mf")
  3994. cmd = self._get_published_command(mqtt_client)
  3995. assert cmd["sequence_id"] == "20000"
  3996. assert cmd["command"] == "project_file"
  3997. assert cmd["param"] == "Metadata/plate_1.gcode"
  3998. assert cmd["url"] == "ftp://test.3mf"
  3999. assert cmd["file"] == "test.3mf"
  4000. assert cmd["profile_id"] == "0"
  4001. assert cmd["subtask_name"] == "test"
  4002. # The device-config bitmask is not a per-job field and is no longer
  4003. # sent; the printer echoed it back and we read it as telemetry (#3040).
  4004. assert "cfg" not in cmd
  4005. class TestDeleteKProfileDualNozzleDetection:
  4006. """Regression guard: dual-nozzle detection for K-profile delete.
  4007. delete_kprofile branches on dual-nozzle status to pick the wire format.
  4008. Source of truth is the runtime `_is_dual_nozzle` flag (set from
  4009. device.extruder.info); model name is the fallback used before push
  4010. data arrives. Serial-prefix detection alone is wrong — H2S shares
  4011. prefix "094" with H2D but is single-nozzle (#1386).
  4012. """
  4013. def _make_client(self, *, serial: str = "TEST", model: str | None = None, dual_runtime: bool = False):
  4014. from unittest.mock import MagicMock
  4015. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4016. client = BambuMQTTClient(
  4017. ip_address="192.168.1.100",
  4018. serial_number=serial,
  4019. access_code="12345678",
  4020. )
  4021. client._client = MagicMock()
  4022. client.state.connected = True
  4023. client.model = model
  4024. client._is_dual_nozzle = dual_runtime
  4025. return client
  4026. def _published(self, client):
  4027. return json.loads(client._client.publish.call_args[0][1])["print"]
  4028. def test_h2d_model_uses_dual_nozzle_format(self):
  4029. client = self._make_client(serial="09400A000000001", model="H2D")
  4030. client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
  4031. cmd = self._published(client)
  4032. # Dual-nozzle command omits setting_id.
  4033. assert "setting_id" not in cmd
  4034. assert cmd["extruder_id"] == 0
  4035. def test_x2d_model_uses_dual_nozzle_format(self):
  4036. client = self._make_client(serial="20P90A000000001", model="X2D")
  4037. client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
  4038. cmd = self._published(client)
  4039. assert "setting_id" not in cmd
  4040. assert cmd["extruder_id"] == 0
  4041. def test_h2c_model_uses_dual_nozzle_format(self):
  4042. """Post-2026 H2C batches ship with '31B8B' prefix instead of '094' (#1105).
  4043. Model-name detection works regardless of serial prefix."""
  4044. client = self._make_client(serial="31B8BP000000001", model="H2C")
  4045. client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
  4046. cmd = self._published(client)
  4047. assert "setting_id" not in cmd
  4048. assert cmd["extruder_id"] == 0
  4049. def test_runtime_dual_nozzle_flag_uses_dual_format(self):
  4050. """When _is_dual_nozzle is set from device.extruder.info, the model
  4051. fallback isn't needed (covers future dual-nozzle models we haven't
  4052. seen yet)."""
  4053. client = self._make_client(serial="UNKNOWN", model=None, dual_runtime=True)
  4054. client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
  4055. cmd = self._published(client)
  4056. assert "setting_id" not in cmd
  4057. def test_h2s_uses_single_nozzle_format(self):
  4058. """H2S shares serial prefix "094" with H2D but is single-nozzle (#1386).
  4059. Must take the single-nozzle branch with setting_id included.
  4060. """
  4061. client = self._make_client(serial="09400S000000001", model="H2S")
  4062. client.delete_kprofile(
  4063. cali_idx=1,
  4064. filament_id="GFA00",
  4065. nozzle_id="HH00-0.4",
  4066. setting_id="PFB123",
  4067. )
  4068. cmd = self._published(client)
  4069. assert cmd["setting_id"] == "PFB123"
  4070. def test_p2s_uses_single_nozzle_format(self):
  4071. """P2S is single-nozzle — must NOT take the dual-nozzle branch."""
  4072. client = self._make_client(serial="22E00A000000001", model="P2S")
  4073. client.delete_kprofile(
  4074. cali_idx=1,
  4075. filament_id="GFA00",
  4076. nozzle_id="HH00-0.4",
  4077. setting_id="PFB123",
  4078. )
  4079. cmd = self._published(client)
  4080. # Single-nozzle command includes setting_id.
  4081. assert cmd["setting_id"] == "PFB123"
  4082. def test_x1c_uses_single_nozzle_format(self):
  4083. client = self._make_client(serial="00M00A000000001", model="X1C")
  4084. client.delete_kprofile(
  4085. cali_idx=1,
  4086. filament_id="GFA00",
  4087. nozzle_id="HH00-0.4",
  4088. setting_id="PFB123",
  4089. )
  4090. cmd = self._published(client)
  4091. assert cmd["setting_id"] == "PFB123"
  4092. class TestStaleReconnect:
  4093. """Tests for stale connection detection and reconnect without UI bouncing."""
  4094. @pytest.fixture
  4095. def mqtt_client(self):
  4096. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4097. client = BambuMQTTClient(
  4098. ip_address="192.168.1.100",
  4099. serial_number="TEST_STALE",
  4100. access_code="12345678",
  4101. )
  4102. return client
  4103. def test_check_staleness_sets_flag_and_broadcasts_once(self, mqtt_client):
  4104. """check_staleness() should set connected=False, broadcast, and set _stale_reconnecting."""
  4105. import time
  4106. state_changes = []
  4107. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  4108. mqtt_client.state.connected = True
  4109. mqtt_client._last_message_time = time.time() - 120 # well past 60s threshold
  4110. result = mqtt_client.check_staleness()
  4111. assert result is False
  4112. assert mqtt_client.state.connected is False
  4113. assert mqtt_client._stale_reconnecting is True
  4114. assert state_changes == [False] # Exactly one broadcast
  4115. def test_check_staleness_noop_when_not_connected(self, mqtt_client):
  4116. """check_staleness() should not set flag when already disconnected."""
  4117. import time
  4118. mqtt_client.state.connected = False
  4119. mqtt_client._last_message_time = time.time() - 120
  4120. mqtt_client.check_staleness()
  4121. assert mqtt_client._stale_reconnecting is False
  4122. def test_check_staleness_noop_when_not_stale(self, mqtt_client):
  4123. """check_staleness() should not set flag when messages are recent."""
  4124. import time
  4125. mqtt_client.state.connected = True
  4126. mqtt_client._last_message_time = time.time() - 5 # 5s ago, well within 60s
  4127. result = mqtt_client.check_staleness()
  4128. assert result is True
  4129. assert mqtt_client.state.connected is True
  4130. assert mqtt_client._stale_reconnecting is False
  4131. def test_check_staleness_logs_serial_hint_when_no_reports(self, mqtt_client, caplog):
  4132. """#1465 — a stale connection that never received a status report logs
  4133. an actionable serial-number hint, exactly once."""
  4134. import logging
  4135. import time
  4136. mqtt_client.state.connected = True
  4137. mqtt_client._last_message_time = time.time() - 120
  4138. mqtt_client._report_messages_since_connect = 0
  4139. with caplog.at_level(logging.WARNING):
  4140. mqtt_client.check_staleness()
  4141. assert mqtt_client._zero_report_hint_logged is True
  4142. assert any("zero status reports" in r.getMessage() for r in caplog.records)
  4143. # Re-arm staleness — the hint must not log a second time.
  4144. caplog.clear()
  4145. mqtt_client.state.connected = True
  4146. mqtt_client._last_message_time = time.time() - 120
  4147. mqtt_client._last_stale_reconnect = 0.0 # bypass the reconnect cooldown
  4148. with caplog.at_level(logging.WARNING):
  4149. mqtt_client.check_staleness()
  4150. assert not any("zero status reports" in r.getMessage() for r in caplog.records)
  4151. def test_check_staleness_no_serial_hint_right_after_reconnect(self, mqtt_client, caplog):
  4152. """#2732 — _report_messages_since_connect is reset by _on_connect, so a
  4153. reconnect landing just before the staleness check leaves it at 0 for
  4154. reasons that have nothing to do with the serial. A healthy P1S was being
  4155. told to check its serial number 1 ms after reconnecting."""
  4156. import logging
  4157. import time
  4158. mqtt_client.state.connected = True
  4159. mqtt_client._last_message_time = time.time() - 120
  4160. mqtt_client._report_messages_since_connect = 0
  4161. mqtt_client._connect_time = time.monotonic() # fresh session
  4162. with caplog.at_level(logging.WARNING):
  4163. mqtt_client.check_staleness()
  4164. assert mqtt_client._zero_report_hint_logged is False
  4165. assert not any("zero status reports" in r.getMessage() for r in caplog.records)
  4166. # The stale reconnect itself still happens — only the hint is suppressed.
  4167. assert mqtt_client._stale_reconnecting is True
  4168. def test_check_staleness_serial_hint_when_session_old_enough(self, mqtt_client, caplog):
  4169. """A session that has been up past the stale window and still received
  4170. nothing is the case the hint was written for."""
  4171. import logging
  4172. import time
  4173. mqtt_client.state.connected = True
  4174. mqtt_client._last_message_time = time.time() - 120
  4175. mqtt_client._report_messages_since_connect = 0
  4176. mqtt_client._connect_time = time.monotonic() - 120
  4177. with caplog.at_level(logging.WARNING):
  4178. mqtt_client.check_staleness()
  4179. assert mqtt_client._zero_report_hint_logged is True
  4180. assert any("zero status reports" in r.getMessage() for r in caplog.records)
  4181. def test_check_staleness_no_serial_hint_when_reports_received(self, mqtt_client, caplog):
  4182. """A stale connection that DID receive reports (a normal mid-session
  4183. quiet gap) must not log the serial-number hint."""
  4184. import logging
  4185. import time
  4186. mqtt_client.state.connected = True
  4187. mqtt_client._last_message_time = time.time() - 120
  4188. mqtt_client._report_messages_since_connect = 5
  4189. with caplog.at_level(logging.WARNING):
  4190. mqtt_client.check_staleness()
  4191. assert mqtt_client._zero_report_hint_logged is False
  4192. assert not any("zero status reports" in r.getMessage() for r in caplog.records)
  4193. def test_on_disconnect_skipped_during_stale_reconnect(self, mqtt_client):
  4194. """_on_disconnect should not broadcast state when _stale_reconnecting is set."""
  4195. state_changes = []
  4196. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  4197. mqtt_client._stale_reconnecting = True
  4198. mqtt_client.state.connected = False
  4199. mqtt_client._on_disconnect(None, None)
  4200. # No state change broadcast — check_staleness() already did it
  4201. assert state_changes == []
  4202. assert mqtt_client.state.connected is False
  4203. def test_on_disconnect_fires_event_during_stale_reconnect(self, mqtt_client):
  4204. """_on_disconnect must still fire _disconnection_event even during stale reconnect.
  4205. If disconnect() is called while _stale_reconnecting is True (e.g. user removes
  4206. the printer before paho reconnects), the event must fire so disconnect() doesn't hang.
  4207. """
  4208. import threading
  4209. mqtt_client._stale_reconnecting = True
  4210. mqtt_client._disconnection_event = threading.Event()
  4211. mqtt_client._on_disconnect(None, None)
  4212. assert mqtt_client._disconnection_event.is_set()
  4213. def test_on_connect_clears_stale_reconnecting_flag(self, mqtt_client):
  4214. """_on_connect should clear _stale_reconnecting and restore connected=True."""
  4215. mqtt_client._stale_reconnecting = True
  4216. mqtt_client.state.connected = False
  4217. subscribe_calls = []
  4218. mock_client = type(
  4219. "MockClient",
  4220. (),
  4221. {
  4222. "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
  4223. },
  4224. )()
  4225. mqtt_client._on_connect(mock_client, None, None, 0)
  4226. assert mqtt_client._stale_reconnecting is False
  4227. assert mqtt_client.state.connected is True
  4228. def test_full_stale_reconnect_cycle_no_bounce(self, mqtt_client):
  4229. """Full cycle: stale → disconnect callback → reconnect. UI should see exactly one disconnect."""
  4230. import time
  4231. state_changes = []
  4232. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  4233. mqtt_client.state.connected = True
  4234. mqtt_client._last_message_time = time.time() - 120
  4235. # Step 1: Stale detection triggers
  4236. mqtt_client.check_staleness()
  4237. assert state_changes == [False]
  4238. # Step 2: Paho fires disconnect callback (from socket close)
  4239. mqtt_client._on_disconnect(None, None)
  4240. # Should NOT add another state change
  4241. assert state_changes == [False]
  4242. # Step 3: Paho reconnects
  4243. subscribe_calls = []
  4244. mock_client = type(
  4245. "MockClient",
  4246. (),
  4247. {
  4248. "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
  4249. },
  4250. )()
  4251. mqtt_client._on_connect(mock_client, None, None, 0)
  4252. assert state_changes == [False, True] # Now connected again
  4253. assert mqtt_client._stale_reconnecting is False
  4254. def test_spurious_disconnect_suppressed_when_recent_messages(self, mqtt_client):
  4255. """Non-error disconnect with recent messages should be suppressed."""
  4256. import time
  4257. state_changes = []
  4258. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  4259. mqtt_client.state.connected = True
  4260. mqtt_client._last_message_time = time.time() - 3 # 3s ago
  4261. # Non-error disconnect (rc=None)
  4262. mqtt_client._on_disconnect(None, None)
  4263. assert state_changes == []
  4264. assert mqtt_client.state.connected is True
  4265. def test_error_disconnect_not_suppressed_despite_recent_messages(self, mqtt_client):
  4266. """Error disconnect should always be processed, even with recent messages."""
  4267. import time
  4268. import paho.mqtt.client as mqtt
  4269. from paho.mqtt.reasoncodes import ReasonCode
  4270. state_changes = []
  4271. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  4272. mqtt_client.state.connected = True
  4273. mqtt_client._last_message_time = time.time() - 3 # 3s ago
  4274. # Error disconnect (rc.is_failure = True)
  4275. rc = ReasonCode(mqtt.CONNACK >> 4, identifier=0x80) # Failure code
  4276. mqtt_client._on_disconnect(None, None, rc=rc)
  4277. assert state_changes == [False]
  4278. assert mqtt_client.state.connected is False
  4279. class TestDoorOpenParsing:
  4280. """Tests for enclosure door state parsing (X1 home_flag bit 23 vs others stat bit 23)."""
  4281. def _make_client(self, model: str):
  4282. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4283. return BambuMQTTClient(
  4284. ip_address="192.168.1.100",
  4285. serial_number="TEST",
  4286. access_code="12345678",
  4287. model=model,
  4288. )
  4289. def test_x1c_door_open_from_home_flag(self):
  4290. client = self._make_client("X1C")
  4291. # bit 23 set
  4292. client._update_state({"home_flag": 0xC0E5CD98})
  4293. assert client.state.door_open is True
  4294. def test_x1c_door_closed_from_home_flag(self):
  4295. client = self._make_client("X1C")
  4296. client.state.door_open = True # start "open"
  4297. client._update_state({"home_flag": 0xC065CD98})
  4298. assert client.state.door_open is False
  4299. def test_x1c_ignores_stat_field(self):
  4300. # X1C must NOT use stat (bit 23 in stat is unrelated for X1)
  4301. client = self._make_client("X1C")
  4302. client._update_state({"home_flag": 0xC065CD98, "stat": "47A58000"})
  4303. assert client.state.door_open is False # home_flag wins
  4304. def test_h2d_door_open_from_stat(self):
  4305. client = self._make_client("H2D")
  4306. client._update_state({"stat": "640A58000"}) # bit 23 set
  4307. assert client.state.door_open is True
  4308. def test_h2d_door_closed_from_stat(self):
  4309. client = self._make_client("H2D")
  4310. client.state.door_open = True
  4311. client._update_state({"stat": "640258000"}) # bit 23 cleared
  4312. assert client.state.door_open is False
  4313. def test_h2d_ignores_home_flag(self):
  4314. # Non-X1 must NOT consume home_flag for door state
  4315. client = self._make_client("H2D")
  4316. client._update_state({"home_flag": 0xC0E5CD98, "stat": "640258000"})
  4317. assert client.state.door_open is False # stat wins
  4318. def test_invalid_stat_does_not_raise(self):
  4319. client = self._make_client("H2D")
  4320. client._update_state({"stat": "not-hex"})
  4321. assert client.state.door_open is False
  4322. class TestSdCardParsing:
  4323. """SD-card state is only set from the top-level `sdcard` field (bool/int/
  4324. string variants). home_flag is NOT consulted — heartbeat pushes clear those
  4325. bits even when a card is inserted, and the prior badge feature was removed
  4326. entirely because no reliable heartbeat-vs-full-push heuristic existed."""
  4327. def _make_client(self, model: str = "H2D"):
  4328. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4329. return BambuMQTTClient(
  4330. ip_address="192.168.1.100",
  4331. serial_number="TEST",
  4332. access_code="12345678",
  4333. model=model,
  4334. )
  4335. def test_home_flag_alone_does_not_touch_sdcard(self):
  4336. client = self._make_client()
  4337. client.state.sdcard = True
  4338. for home_flag in (0x00000000, 0x00000100, 0x00000200):
  4339. client._update_state({"home_flag": home_flag})
  4340. assert client.state.sdcard is True
  4341. def test_sdcard_string_fallback_when_no_home_flag(self):
  4342. client = self._make_client()
  4343. client._update_state({"sdcard": "HAS_SDCARD_NORMAL"})
  4344. assert client.state.sdcard is True
  4345. def test_sdcard_int_fallback_when_no_home_flag(self):
  4346. # `1 is True` is False — the old strict check flapped here.
  4347. client = self._make_client()
  4348. client._update_state({"sdcard": 1})
  4349. assert client.state.sdcard is True
  4350. def test_sdcard_bool_fallback_when_no_home_flag(self):
  4351. client = self._make_client()
  4352. client._update_state({"sdcard": True})
  4353. assert client.state.sdcard is True
  4354. client._update_state({"sdcard": False})
  4355. assert client.state.sdcard is False
  4356. class TestZombieSessionDetection:
  4357. """Tests for ams_filament_setting response tracking (#887).
  4358. When a printer's MQTT session degrades so that telemetry flows but
  4359. published commands never reach the printer, the zombie detector
  4360. counts consecutive unanswered ams_filament_setting commands and
  4361. force-reconnects after two.
  4362. """
  4363. @pytest.fixture
  4364. def mqtt_client(self):
  4365. import time
  4366. from unittest.mock import MagicMock
  4367. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4368. client = BambuMQTTClient(
  4369. ip_address="192.168.1.100",
  4370. serial_number="TEST123",
  4371. access_code="12345678",
  4372. )
  4373. client.state.connected = True
  4374. mock_paho = MagicMock()
  4375. mock_paho.socket.return_value = MagicMock()
  4376. client._client = mock_paho
  4377. client._connect_time = time.monotonic() - 10.0
  4378. # Set developer_mode so the dev-mode probe branch doesn't interfere
  4379. client.state.developer_mode = True
  4380. return client
  4381. def test_initial_state_is_clean(self, mqtt_client):
  4382. """Tracking fields start at zero / no pending command."""
  4383. assert mqtt_client._last_ams_cmd_time == 0.0
  4384. assert mqtt_client._ams_cmd_unanswered == 0
  4385. def test_publish_sets_pending_time(self, mqtt_client):
  4386. """set_ams_filament_setting records the publish timestamp."""
  4387. import time
  4388. before = time.monotonic()
  4389. mqtt_client.ams_set_filament_setting(
  4390. ams_id=0,
  4391. tray_id=0,
  4392. tray_info_idx="GFL99",
  4393. tray_type="PLA",
  4394. tray_sub_brands="",
  4395. tray_color="FF0000FF",
  4396. nozzle_temp_min=190,
  4397. nozzle_temp_max=230,
  4398. )
  4399. assert mqtt_client._last_ams_cmd_time >= before
  4400. def test_reset_slot_sets_pending_time(self, mqtt_client):
  4401. """reset_ams_slot also records the publish timestamp."""
  4402. import time
  4403. before = time.monotonic()
  4404. mqtt_client.reset_ams_slot(ams_id=0, tray_id=0)
  4405. assert mqtt_client._last_ams_cmd_time >= before
  4406. def test_response_clears_pending(self, mqtt_client):
  4407. """An ams_filament_setting response clears the pending state."""
  4408. import time
  4409. mqtt_client._last_ams_cmd_time = time.monotonic()
  4410. mqtt_client._ams_cmd_unanswered = 1
  4411. # Simulate receiving a user-command response (sequence_id "0")
  4412. print_data = {
  4413. "command": "ams_filament_setting",
  4414. "sequence_id": "0",
  4415. "result": "success",
  4416. }
  4417. # Walk the same path as _on_message: command response check then _update_state
  4418. cmd = print_data.get("command")
  4419. if cmd == "ams_filament_setting" and mqtt_client._last_ams_cmd_time > 0:
  4420. mqtt_client._last_ams_cmd_time = 0.0
  4421. mqtt_client._ams_cmd_unanswered = 0
  4422. assert mqtt_client._last_ams_cmd_time == 0.0
  4423. assert mqtt_client._ams_cmd_unanswered == 0
  4424. def test_single_timeout_increments_counter(self, mqtt_client):
  4425. """One unanswered command increments the counter but does not reconnect."""
  4426. import time
  4427. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  4428. mqtt_client._update_state({"gcode_state": "IDLE"})
  4429. assert mqtt_client._ams_cmd_unanswered == 1
  4430. assert mqtt_client._last_ams_cmd_time == 0.0
  4431. # Should NOT force-reconnect after just one
  4432. assert mqtt_client.state.connected is True
  4433. def test_two_timeouts_force_reconnect(self, mqtt_client):
  4434. """Two consecutive unanswered commands trigger force_reconnect.
  4435. Zombie detection runs from paho's network thread (no asyncio loop), so
  4436. the routing in force_reconnect_stale_session falls back to socket-close
  4437. — which is the safe option since loop_stop() from inside the loop
  4438. thread would deadlock. Hard-reset is reserved for async-context callers
  4439. on the queue dispatch path."""
  4440. import time
  4441. state_change_called = []
  4442. mqtt_client.on_state_change = lambda s: state_change_called.append(True)
  4443. # First unanswered command
  4444. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  4445. mqtt_client._update_state({"gcode_state": "IDLE"})
  4446. assert mqtt_client._ams_cmd_unanswered == 1
  4447. assert mqtt_client.state.connected is True
  4448. # Second unanswered command
  4449. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  4450. mqtt_client._update_state({"gcode_state": "IDLE"})
  4451. assert mqtt_client._ams_cmd_unanswered == 0 # reset after reconnect
  4452. assert mqtt_client.state.connected is False
  4453. assert mqtt_client._stale_reconnecting is True
  4454. # Sync test → no running loop → socket-close fallback path
  4455. mqtt_client._client.socket().close.assert_called()
  4456. assert len(state_change_called) > 0
  4457. def test_response_between_timeouts_resets_counter(self, mqtt_client):
  4458. """A successful response after one timeout resets the counter."""
  4459. import time
  4460. # First unanswered command
  4461. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  4462. mqtt_client._update_state({"gcode_state": "IDLE"})
  4463. assert mqtt_client._ams_cmd_unanswered == 1
  4464. # Now a response arrives — clear pending
  4465. mqtt_client._last_ams_cmd_time = time.monotonic()
  4466. mqtt_client._last_ams_cmd_time = 0.0
  4467. mqtt_client._ams_cmd_unanswered = 0
  4468. # Next unanswered command should be count=1, not count=2
  4469. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  4470. mqtt_client._update_state({"gcode_state": "IDLE"})
  4471. assert mqtt_client._ams_cmd_unanswered == 1
  4472. assert mqtt_client.state.connected is True # no reconnect
  4473. def test_late_response_after_watchdog_clears_counter_issue_1164(self, mqtt_client):
  4474. """Regression for #1164: a late ams_filament_setting response — one
  4475. that arrives AFTER the watchdog has already zeroed
  4476. `_last_ams_cmd_time` and incremented the unanswered counter — must
  4477. still reset the counter. Without this, a single sluggish response
  4478. leaves the counter armed at 1 indefinitely; the next slow response
  4479. on a totally unrelated command (possibly minutes or hours later)
  4480. takes it to 2 and force-reconnects, surfacing as 'AMS slot config
  4481. doesn't reach the printer ~6 changes in'."""
  4482. import time
  4483. # First command publishes, then doesn't get a response for >10s.
  4484. # Watchdog fires: counter=1, _last_ams_cmd_time zeroed.
  4485. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  4486. mqtt_client._update_state({"gcode_state": "IDLE"})
  4487. assert mqtt_client._ams_cmd_unanswered == 1
  4488. assert mqtt_client._last_ams_cmd_time == 0.0 # watchdog cleared it
  4489. # Late response arrives — the `_process_message` path used to require
  4490. # `_last_ams_cmd_time > 0` before resetting the counter, so this would
  4491. # have silently been ignored.
  4492. mqtt_client._process_message(
  4493. {
  4494. "print": {
  4495. "command": "ams_filament_setting",
  4496. "sequence_id": "0",
  4497. "result": "success",
  4498. "reason": "success",
  4499. }
  4500. }
  4501. )
  4502. # Counter MUST be reset — the response proves the channel is alive.
  4503. assert mqtt_client._ams_cmd_unanswered == 0, (
  4504. "Late ams_filament_setting response must reset the unanswered "
  4505. "counter even when the watchdog already zeroed _last_ams_cmd_time. "
  4506. "If this assertion fails the #1164 regression is back: a single "
  4507. "sluggish response will leave the counter armed and cause a "
  4508. "spurious force_reconnect on the next slow response."
  4509. )
  4510. # Now even if a future command times out, the counter starts fresh
  4511. # and a single timeout doesn't trip the 2x reconnect threshold.
  4512. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  4513. mqtt_client._update_state({"gcode_state": "IDLE"})
  4514. assert mqtt_client._ams_cmd_unanswered == 1
  4515. assert mqtt_client.state.connected is True # no force reconnect
  4516. def test_on_connect_resets_tracking(self, mqtt_client):
  4517. """_on_connect resets zombie tracking fields."""
  4518. import time
  4519. mqtt_client._last_ams_cmd_time = time.monotonic()
  4520. mqtt_client._ams_cmd_unanswered = 5
  4521. # subscribe() must return (result, mid) tuple
  4522. mqtt_client._client.subscribe.return_value = (0, 1)
  4523. mqtt_client._on_connect(mqtt_client._client, None, None, 0)
  4524. assert mqtt_client._last_ams_cmd_time == 0.0
  4525. assert mqtt_client._ams_cmd_unanswered == 0
  4526. def test_no_check_when_no_command_pending(self, mqtt_client):
  4527. """If no command was published, push_status does not trigger detection."""
  4528. assert mqtt_client._last_ams_cmd_time == 0.0
  4529. mqtt_client._update_state({"gcode_state": "IDLE"})
  4530. assert mqtt_client._ams_cmd_unanswered == 0
  4531. def test_no_timeout_within_window(self, mqtt_client):
  4532. """A command published <10s ago should not trigger a timeout."""
  4533. import time
  4534. mqtt_client._last_ams_cmd_time = time.monotonic() - 5.0
  4535. mqtt_client._update_state({"gcode_state": "IDLE"})
  4536. assert mqtt_client._ams_cmd_unanswered == 0
  4537. assert mqtt_client._last_ams_cmd_time > 0 # still pending
  4538. class TestHMSUserActionFiltering:
  4539. """HMS short codes the printer firmware emits during user-cancel sequences
  4540. must not appear in state.hms_errors — they're status echoes, not faults,
  4541. and shouldn't drive the printer card's "X problem" badge or red pip."""
  4542. @pytest.fixture
  4543. def mqtt_client(self):
  4544. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4545. return BambuMQTTClient(
  4546. ip_address="192.168.1.100",
  4547. serial_number="TEST_HMS",
  4548. access_code="12345678",
  4549. )
  4550. def test_task_cancelled_echo_0300_400c_filtered(self, mqtt_client):
  4551. """0300_400C ("The task was canceled.") is the user-cancel echo that was
  4552. leaving the printer card stuck on "1 problem" after every stop."""
  4553. mqtt_client._update_state({"hms": [{"attr": 0x03000300, "code": 0x400C}]})
  4554. assert mqtt_client.state.hms_errors == []
  4555. def test_printing_cancelled_echo_0500_400e_filtered(self, mqtt_client):
  4556. """0500_400E ("Printing was cancelled.") — the corresponding nozzle-module
  4557. echo that the backend notification path was already suppressing for the
  4558. same reason."""
  4559. mqtt_client._update_state({"hms": [{"attr": 0x05000300, "code": 0x400E}]})
  4560. assert mqtt_client.state.hms_errors == []
  4561. def test_real_layer_shift_still_passes_through(self, mqtt_client):
  4562. """0300_4057 (Z-axis step loss) is a real fault and must NOT be filtered."""
  4563. mqtt_client._update_state({"hms": [{"attr": 0x03000100, "code": 0x4057}]})
  4564. assert len(mqtt_client.state.hms_errors) == 1
  4565. assert mqtt_client.state.hms_errors[0].code == "0x4057"
  4566. def test_filter_only_drops_user_action_codes_keeps_concurrent_real_faults(self, mqtt_client):
  4567. """When the user cancels mid-fault, the firmware sends the real fault HMS
  4568. alongside the cancel echo. Drop only the echo, keep the real fault."""
  4569. mqtt_client._update_state(
  4570. {
  4571. "hms": [
  4572. {"attr": 0x03000300, "code": 0x400C}, # cancel echo — drop
  4573. {"attr": 0x07FF0200, "code": 0x8011}, # filament runout — keep
  4574. ]
  4575. }
  4576. )
  4577. codes = [e.code for e in mqtt_client.state.hms_errors]
  4578. assert "0x8011" in codes
  4579. assert "0x400c" not in codes
  4580. assert len(mqtt_client.state.hms_errors) == 1
  4581. def test_print_error_path_also_filters_cancel_echo(self, mqtt_client):
  4582. """`print_error` is a second route that appends into state.hms_errors. The
  4583. same user-action codes (e.g. 0500_400E "Printing was cancelled") must be
  4584. filtered there too — otherwise the printer card stays on "1 problem"
  4585. when the firmware reports the cancel via print_error rather than hms[]."""
  4586. mqtt_client._update_state({"print_error": 0x0500_400E})
  4587. assert mqtt_client.state.hms_errors == []
  4588. def test_print_error_path_passes_real_errors_through(self, mqtt_client):
  4589. """Real print_error codes still reach state.hms_errors."""
  4590. mqtt_client._update_state({"print_error": 0x0500_8061})
  4591. assert len(mqtt_client.state.hms_errors) == 1
  4592. assert mqtt_client.state.hms_errors[0].code == "0x8061"
  4593. class TestHMSFullCode:
  4594. """full_code is the firmware-matching key for HMS-related commands.
  4595. Truncating it to the 8-char short code is what caused #1830's silent
  4596. rejection on H2C, and the H2D wrong-plate path needs the print_error
  4597. 32-bit form. Both branches must populate full_code consistently."""
  4598. @pytest.fixture
  4599. def mqtt_client(self):
  4600. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4601. return BambuMQTTClient(
  4602. ip_address="192.168.1.100",
  4603. serial_number="TEST_FULLCODE",
  4604. access_code="12345678",
  4605. )
  4606. def test_hms_array_path_populates_16_char_full_code(self, mqtt_client):
  4607. """hms[] entries carry a 64-bit identifier (attr + code, 32 bits each).
  4608. The full 16-char hex is what BambuStudio uses to match err on
  4609. idle_ignore — the truncated short_code drops 32 bits and the firmware
  4610. silently rejects (#1830). Verifies the parser preserves the full
  4611. identifier on HMSError.full_code."""
  4612. # 0x07FF0200 / 0x8011 → displayed as 07FF_0200_0000_8011 in the wiki
  4613. mqtt_client._update_state({"hms": [{"attr": 0x07FF0200, "code": 0x8011}]})
  4614. assert len(mqtt_client.state.hms_errors) == 1
  4615. assert mqtt_client.state.hms_errors[0].full_code == "07FF02000000" + "8011"
  4616. def test_print_error_path_populates_8_char_full_code(self, mqtt_client):
  4617. """print_error is already 32 bits — no truncation. full_code is the
  4618. 8-char hex form, which is exactly what the firmware matches against."""
  4619. mqtt_client._update_state({"print_error": 0x05008051})
  4620. assert len(mqtt_client.state.hms_errors) == 1
  4621. assert mqtt_client.state.hms_errors[0].full_code == "05008051"
  4622. def test_print_error_path_carries_the_catalogue_description(self, mqtt_client):
  4623. """The sentence is resolved once, at parse time, so every surface that
  4624. reports the fault quotes the same text (#2926)."""
  4625. mqtt_client._update_state({"print_error": 0x03008004})
  4626. assert len(mqtt_client.state.hms_errors) == 1
  4627. assert mqtt_client.state.hms_errors[0].description == "Filament ran out. Please load new filament."
  4628. def test_print_error_path_leaves_description_none_for_an_uncatalogued_code(self, mqtt_client):
  4629. """An undocumented code gets no invented text — the field is the
  4630. catalogue's answer, not a placeholder."""
  4631. mqtt_client._update_state({"print_error": 0x03009999})
  4632. assert len(mqtt_client.state.hms_errors) == 1
  4633. assert mqtt_client.state.hms_errors[0].description is None
  4634. def test_hms_array_path_resolves_by_the_full_code(self, mqtt_client):
  4635. """A real P2S fault from #2728, verbatim from the report topic. It resolves
  4636. through its full 16-char code, which is how Bambu Studio keys its HMS
  4637. texts, and says what the reporter found: a module/firmware mismatch."""
  4638. mqtt_client._update_state({"hms": [{"attr": 83886848, "code": 131086}]})
  4639. assert len(mqtt_client.state.hms_errors) == 1
  4640. error = mqtt_client.state.hms_errors[0]
  4641. assert error.full_code == "050003000002000E"
  4642. assert error.description.startswith("Some modules are incompatible with the printer's firmware version")
  4643. def test_hms_array_leaves_description_none_when_bambu_publishes_no_text(self, mqtt_client):
  4644. """A real P2S fault (#2728) that Bambu lists with empty text. The fault is
  4645. still reported; only the text is absent."""
  4646. mqtt_client._update_state({"hms": [{"attr": 0x05000200, "code": 0x0003000A}]})
  4647. assert len(mqtt_client.state.hms_errors) == 1
  4648. assert mqtt_client.state.hms_errors[0].full_code == "050002000003000A"
  4649. assert mqtt_client.state.hms_errors[0].description is None
  4650. def test_hms_array_catalog_lookup_tries_16_char_first(self, mqtt_client, monkeypatch):
  4651. """When the catalog has both an 8-char and a 16-char entry for the
  4652. same fault family, the 16-char (specific variant) wins. The 8-char
  4653. is the fallback for codes that aren't in the long-form catalog."""
  4654. from backend.app.services import bambu_mqtt as mod
  4655. calls = []
  4656. def fake_lookup(device, code):
  4657. calls.append((device, code))
  4658. if len(code) == 16:
  4659. return ["RESUME_PRINTING"]
  4660. return []
  4661. monkeypatch.setattr(mod, "get_actions_for_error_code", fake_lookup)
  4662. # SN prefix "TES" — irrelevant for the test, we mocked the lookup.
  4663. mqtt_client._update_state({"hms": [{"attr": 0x07FF0200, "code": 0x8011}]})
  4664. # 16-char lookup attempted first, then 8-char only if 16-char missed.
  4665. assert calls[0][1] == "07FF020000008011"
  4666. assert mqtt_client.state.hms_errors[0].actions == ["RESUME_PRINTING"]
  4667. def test_hms_array_catalog_falls_back_to_8_char(self, mqtt_client, monkeypatch):
  4668. """If the catalog has no 16-char entry, fall back to the 8-char short
  4669. code — that's where base-class HMS codes live."""
  4670. from backend.app.services import bambu_mqtt as mod
  4671. calls = []
  4672. def fake_lookup(device, code):
  4673. calls.append((device, code))
  4674. if len(code) == 16:
  4675. return [] # no specific variant
  4676. return ["CHECK_ASSISTANT"] # base class hit
  4677. monkeypatch.setattr(mod, "get_actions_for_error_code", fake_lookup)
  4678. mqtt_client._update_state({"hms": [{"attr": 0x07FF0200, "code": 0x8011}]})
  4679. # Two lookups: 16-char miss, then 8-char hit.
  4680. assert len(calls) == 2
  4681. assert calls[0][1] == "07FF020000008011"
  4682. assert calls[1][1] == "07FF8011"
  4683. assert mqtt_client.state.hms_errors[0].actions == ["CHECK_ASSISTANT"]
  4684. class TestForceReconnectRouting:
  4685. """#1136 — force_reconnect_stale_session routes between hard-reset (full
  4686. paho-client teardown, wipes the QoS 1 queue) and socket-close (the legacy
  4687. behaviour, safe to call from paho's own network thread). The routing
  4688. decision is based on whether an asyncio loop is running: hard-reset
  4689. requires loop_stop() which would deadlock if called from inside the
  4690. network thread itself."""
  4691. @pytest.fixture
  4692. def mqtt_client(self):
  4693. from unittest.mock import MagicMock
  4694. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4695. client = BambuMQTTClient(
  4696. ip_address="192.168.1.100",
  4697. serial_number="TEST_HARD_RESET",
  4698. access_code="12345678",
  4699. )
  4700. client.state.connected = True
  4701. client._client = MagicMock()
  4702. return client
  4703. def test_routing_falls_back_to_socket_close_without_running_loop(self, mqtt_client):
  4704. """Sync caller → no asyncio loop → socket-close path (legacy behaviour
  4705. preserved for paho-thread callers like zombie detection)."""
  4706. mqtt_client.force_reconnect_stale_session("test")
  4707. mqtt_client._client.socket().close.assert_called()
  4708. # Old client is NOT torn down on this path; same-instance reconnect
  4709. # via paho's auto-reconnect handles it.
  4710. assert mqtt_client._client is not None
  4711. def test_routing_uses_hard_reset_when_loop_is_running(self, mqtt_client):
  4712. """Async caller → loop available → hard-reset path wipes the queue."""
  4713. import asyncio
  4714. original = mqtt_client._client
  4715. # Stub connect() so the rebuild doesn't open a real socket.
  4716. mqtt_client.connect = lambda loop=None: None
  4717. async def _trigger():
  4718. mqtt_client.force_reconnect_stale_session("test")
  4719. asyncio.run(_trigger())
  4720. original.disconnect.assert_called()
  4721. original.loop_stop.assert_called()
  4722. # connect() stub didn't repopulate _client, so it's None — the contract
  4723. # in production is that connect() builds a fresh mqtt.Client here.
  4724. assert mqtt_client._client is None
  4725. def test_marks_state_disconnected_and_broadcasts(self, mqtt_client):
  4726. """Both routing paths must broadcast the disconnected state once."""
  4727. broadcasts: list[bool] = []
  4728. mqtt_client.on_state_change = lambda s: broadcasts.append(s.connected)
  4729. mqtt_client.force_reconnect_stale_session("test")
  4730. assert mqtt_client.state.connected is False
  4731. assert mqtt_client._stale_reconnecting is True
  4732. assert broadcasts == [False]
  4733. class TestHardResetClientDirect:
  4734. """Lower-level coverage of `_hard_reset_client` itself — the helper called
  4735. by the routing layer when a full paho-client teardown is safe. These tests
  4736. drive the helper directly so they don't depend on the routing decision."""
  4737. @pytest.fixture
  4738. def mqtt_client(self):
  4739. from unittest.mock import MagicMock
  4740. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4741. client = BambuMQTTClient(
  4742. ip_address="192.168.1.100",
  4743. serial_number="TEST_HARD_DIRECT",
  4744. access_code="12345678",
  4745. )
  4746. client.state.connected = True
  4747. client._client = MagicMock()
  4748. # Stub connect() so the rebuild doesn't open a real socket.
  4749. client.connect = lambda loop=None: None
  4750. return client
  4751. def test_disconnects_and_stops_old_client(self, mqtt_client):
  4752. """Old paho client must receive DISCONNECT (broker drops session) +
  4753. loop_stop (network thread exits, taking its QoS 1 queue with it)."""
  4754. original = mqtt_client._client
  4755. mqtt_client._hard_reset_client()
  4756. original.disconnect.assert_called()
  4757. original.loop_stop.assert_called()
  4758. def test_clears_client_reference(self, mqtt_client):
  4759. """Old reference must go to None so subsequent code can't accidentally
  4760. publish through the dying client."""
  4761. mqtt_client._hard_reset_client()
  4762. assert mqtt_client._client is None
  4763. def test_swallows_disconnect_exception(self, mqtt_client):
  4764. """A failing disconnect() (e.g. paho already in error state) must not
  4765. propagate through async dispatch callers, and a single broken client
  4766. could brick every future dispatch."""
  4767. original = mqtt_client._client
  4768. original.disconnect.side_effect = RuntimeError("boom")
  4769. # No exception escapes the call (test would fail if it did).
  4770. mqtt_client._hard_reset_client()
  4771. # loop_stop is still attempted after the disconnect failure.
  4772. original.loop_stop.assert_called()
  4773. assert mqtt_client._client is None
  4774. class TestStartPrintRecordsDispatchedPlate:
  4775. """Tests for the dispatched-plate record set by start_print() — used by the
  4776. /cover route to pick the right thumbnail when the printer's gcode_file
  4777. echo doesn't include the plate path (#1166).
  4778. Some firmware versions (P1S 01.10.00.00) only put the .3mf filename in
  4779. print.gcode_file, so the regex falls back to plate 1 and the printer card
  4780. shows the wrong plate's thumbnail. Recording what we dispatched at the
  4781. publish site lets resolve_plate_id() return the right plate without
  4782. needing to introspect the 3MF.
  4783. """
  4784. @pytest.fixture
  4785. def mqtt_client(self):
  4786. from unittest.mock import MagicMock
  4787. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4788. client = BambuMQTTClient(
  4789. ip_address="192.168.1.100",
  4790. serial_number="TEST123",
  4791. access_code="12345678",
  4792. )
  4793. client._client = MagicMock()
  4794. client.state.connected = True
  4795. return client
  4796. def test_dispatched_plate_recorded_after_start_print(self, mqtt_client):
  4797. # Default state has no dispatched plate.
  4798. assert mqtt_client.state.dispatched_plate_id is None
  4799. assert mqtt_client.state.dispatched_subtask is None
  4800. mqtt_client.start_print("Luigi.3mf", plate_id=2)
  4801. # The subtask_name we record matches the one we send (and the printer
  4802. # reflects back via MQTT), so resolve_plate_id() can validate the
  4803. # match downstream.
  4804. assert mqtt_client.state.dispatched_plate_id == 2
  4805. assert mqtt_client.state.dispatched_subtask == "Luigi"
  4806. def test_dispatched_plate_default_is_one(self, mqtt_client):
  4807. # When start_print is called without plate_id (legacy/single-plate
  4808. # flow), we still record plate=1 — the contract is that dispatched_*
  4809. # describes the active dispatch.
  4810. mqtt_client.start_print("Single.3mf")
  4811. assert mqtt_client.state.dispatched_plate_id == 1
  4812. assert mqtt_client.state.dispatched_subtask == "Single"
  4813. def test_dispatched_plate_overwritten_by_subsequent_dispatch(self, mqtt_client):
  4814. # Each dispatch replaces the prior record so we can never serve a
  4815. # stale plate from an older print.
  4816. mqtt_client.start_print("First.3mf", plate_id=4)
  4817. mqtt_client.start_print("Second.3mf", plate_id=2)
  4818. assert mqtt_client.state.dispatched_plate_id == 2
  4819. assert mqtt_client.state.dispatched_subtask == "Second"
  4820. def test_dispatched_plate_not_recorded_when_publish_skipped(self, mqtt_client):
  4821. # If start_print early-returns because we're not connected, no record
  4822. # should land — otherwise the next print's /cover call would believe
  4823. # a phantom dispatch happened.
  4824. mqtt_client.state.connected = False
  4825. result = mqtt_client.start_print("Phantom.3mf", plate_id=3)
  4826. assert result is False
  4827. assert mqtt_client.state.dispatched_plate_id is None
  4828. assert mqtt_client.state.dispatched_subtask is None
  4829. class TestStartPrintNozzleMappingDispatch:
  4830. """H2C dual-nozzle-rack (#1780) — nozzle_mapping on dispatch.
  4831. BambuStudio's project_file MQTT command for O1C2 carries a per-filament
  4832. physical nozzle position ID array (`nozzle_mapping`). Without forwarding
  4833. it, the H2C firmware falls back to "last matching nozzle type" auto-pick
  4834. and ignores the user's slicer choice. Tests pin the gate, the parse, the
  4835. no-op cases, and the malformed-JSON safety net.
  4836. The original #1780 attempt also captured `nozzles_info` but a wire capture
  4837. on H2C confirmed BambuStudio never sends that field — the capture/dispatch
  4838. paths for it were dropped in the same release.
  4839. """
  4840. @pytest.fixture
  4841. def mqtt_client(self):
  4842. from unittest.mock import MagicMock
  4843. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4844. client = BambuMQTTClient(
  4845. ip_address="192.168.1.100",
  4846. serial_number="TEST_O1C2",
  4847. access_code="12345678",
  4848. )
  4849. client._client = MagicMock()
  4850. client.state.connected = True
  4851. return client
  4852. def _published_print_cmd(self, mqtt_client):
  4853. call_args = mqtt_client._client.publish.call_args
  4854. return json.loads(call_args[0][1])["print"]
  4855. def test_dual_nozzle_includes_nozzle_mapping(self, mqtt_client):
  4856. """Dual-nozzle + nozzle_mapping present → parsed JSON array injected
  4857. verbatim onto the dispatched project_file command."""
  4858. mqtt_client._is_dual_nozzle = True
  4859. mqtt_client.start_print(
  4860. "test.3mf",
  4861. nozzle_mapping=json.dumps([16, -1, -1, 1, -1, -1, -1, -1]),
  4862. )
  4863. cmd = self._published_print_cmd(mqtt_client)
  4864. # List, not string — the wire shape must match BambuStudio's.
  4865. assert cmd["nozzle_mapping"] == [16, -1, -1, 1, -1, -1, -1, -1]
  4866. def test_single_nozzle_omits_nozzle_mapping_even_if_set(self, mqtt_client):
  4867. """A single-nozzle printer must NOT emit the rack field even if the
  4868. caller passes it (defense-in-depth — the queue item could legitimately
  4869. carry a stale capture from before a model change)."""
  4870. mqtt_client._is_dual_nozzle = False
  4871. mqtt_client.model = "P1S" # single-nozzle
  4872. mqtt_client.start_print(
  4873. "test.3mf",
  4874. nozzle_mapping=json.dumps([16, 0, 19]),
  4875. )
  4876. cmd = self._published_print_cmd(mqtt_client)
  4877. assert "nozzle_mapping" not in cmd
  4878. def test_dual_nozzle_no_field_no_injection(self, mqtt_client):
  4879. """Dual-nozzle printer + no slicer pick (NULL on queue item) → command
  4880. carries no nozzle_mapping. The firmware then runs its normal
  4881. auto-pick, which is the pre-fix behaviour for any non-O1C2 dual-
  4882. nozzle model that has no rack to disambiguate against anyway."""
  4883. mqtt_client._is_dual_nozzle = True
  4884. mqtt_client.start_print("test.3mf", nozzle_mapping=None)
  4885. cmd = self._published_print_cmd(mqtt_client)
  4886. assert "nozzle_mapping" not in cmd
  4887. def test_malformed_nozzle_mapping_is_logged_and_omitted(self, mqtt_client, caplog):
  4888. """Invalid JSON on the queue item must NOT block the dispatch. Log a
  4889. warning and let the firmware auto-pick — the failure mode is just
  4890. the pre-fix behaviour, not a worse one. Fail-open is correct here
  4891. because the alternative would silently brick every dispatch on a
  4892. single bad row."""
  4893. mqtt_client._is_dual_nozzle = True
  4894. with caplog.at_level("WARNING"):
  4895. result = mqtt_client.start_print(
  4896. "test.3mf",
  4897. nozzle_mapping="not valid json {",
  4898. )
  4899. assert result is True # dispatch still proceeded
  4900. cmd = self._published_print_cmd(mqtt_client)
  4901. assert "nozzle_mapping" not in cmd
  4902. assert any("Invalid nozzle_mapping" in rec.message for rec in caplog.records)
  4903. def test_empty_string_field_is_treated_as_absent(self, mqtt_client):
  4904. """An empty-string column value (legacy data, or a NOT NULL DB
  4905. recovery shim) must behave the same as NULL — no injection, no
  4906. parse error log."""
  4907. mqtt_client._is_dual_nozzle = True
  4908. mqtt_client.start_print("test.3mf", nozzle_mapping="")
  4909. cmd = self._published_print_cmd(mqtt_client)
  4910. assert "nozzle_mapping" not in cmd
  4911. class TestFilamentTrackSwitchDetection:
  4912. """Tests for Filament Track Switch (FTS) accessory detection (#1162).
  4913. The FTS is an accessory that sits between an AMS and the printer's
  4914. extruders, dynamically routing any slot to either nozzle. When installed,
  4915. each AMS unit reports info bits 8-11 = 0xE (uninitialized) since slots are
  4916. no longer tied to a specific extruder. Detection comes from the presence of
  4917. the print.device.fila_switch object in MQTT push_status.
  4918. """
  4919. @pytest.fixture
  4920. def mqtt_client(self):
  4921. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4922. return BambuMQTTClient(
  4923. ip_address="192.168.1.100",
  4924. serial_number="TEST123",
  4925. access_code="12345678",
  4926. )
  4927. def test_fts_default_not_installed(self, mqtt_client):
  4928. """Without any MQTT data, fila_switch.installed must be False so the
  4929. frontend keeps applying the per-extruder filter on regular dual-nozzle
  4930. printers."""
  4931. assert mqtt_client.state.fila_switch.installed is False
  4932. assert mqtt_client.state.fila_switch.in_slots == []
  4933. assert mqtt_client.state.fila_switch.out_extruders == []
  4934. def test_fts_detected_from_device_fila_switch(self, mqtt_client):
  4935. """A push_status with print.device.fila_switch present must mark FTS
  4936. installed and capture its routing arrays. Mirrors the user's MQTT
  4937. bundle in #1162."""
  4938. data = {
  4939. "gcode_state": "RUNNING",
  4940. "device": {
  4941. "fila_switch": {
  4942. "in": [-1, 2],
  4943. "info": 2,
  4944. "out": [0, 1],
  4945. "stat": 0,
  4946. }
  4947. },
  4948. }
  4949. mqtt_client._update_state(data)
  4950. fs = mqtt_client.state.fila_switch
  4951. assert fs.installed is True
  4952. assert fs.in_slots == [-1, 2]
  4953. assert fs.out_extruders == [0, 1]
  4954. assert fs.stat == 0
  4955. assert fs.info == 2
  4956. def test_fts_absent_when_no_fila_switch_field(self, mqtt_client):
  4957. """A push_status that has device.* but no fila_switch must leave
  4958. fila_switch.installed = False — only that specific field flips it on."""
  4959. data = {
  4960. "gcode_state": "IDLE",
  4961. "device": {"extruder": {"state": 0}},
  4962. }
  4963. mqtt_client._update_state(data)
  4964. assert mqtt_client.state.fila_switch.installed is False
  4965. def test_fts_handles_missing_in_out_arrays(self, mqtt_client):
  4966. """If the firmware sends fila_switch with missing or non-list in/out,
  4967. we must still mark it installed (presence is the signal) and default
  4968. the arrays to empty lists rather than crashing."""
  4969. data = {
  4970. "gcode_state": "IDLE",
  4971. "device": {"fila_switch": {"stat": 0, "info": 0}},
  4972. }
  4973. mqtt_client._update_state(data)
  4974. fs = mqtt_client.state.fila_switch
  4975. assert fs.installed is True
  4976. assert fs.in_slots == []
  4977. assert fs.out_extruders == []
  4978. class TestFilamentTrackSwitchInletBinding:
  4979. """Which FTS inlet each AMS is plumbed into, from AMS ``info`` bits 24-27.
  4980. With a switch installed an AMS is no longer bound to one extruder — it is
  4981. bound to one of the switch's two *inlets*, and reaches both nozzles through
  4982. it. That binding is what the printer's "Manual AMS Setup" screen sets, and
  4983. it is the only per-AMS side information there is: ``ams_extruder_map`` is
  4984. empty on these machines because bits 8-11 read 0xE for every unit.
  4985. Bit layout and the 0=In-B / 1=In-A ordering are BambuStudio's
  4986. (``DevFilaSystem.cpp``, ``DevFilaSwitch::SwitchPos``), not inferred.
  4987. """
  4988. @pytest.fixture
  4989. def mqtt_client(self):
  4990. from backend.app.services.bambu_mqtt import BambuMQTTClient
  4991. return BambuMQTTClient(
  4992. ip_address="192.168.1.100",
  4993. serial_number="TEST123",
  4994. access_code="12345678",
  4995. )
  4996. @staticmethod
  4997. def _info(*, inlet_bits: int, extruder: int = 0xE, ams_type: int = 1) -> str:
  4998. """Build an AMS ``info`` hex string with the fields we read out of it."""
  4999. return f"{(inlet_bits << 24) | (extruder << 8) | ams_type:08X}"
  5000. @staticmethod
  5001. def _frame(client, ams_units: list[dict], *, fts: bool = True) -> None:
  5002. """Drive one push_status through the real entry point.
  5003. Deliberately goes through ``_process_message`` rather than calling the
  5004. AMS handler directly: the ordering between the switch block and the AMS
  5005. block is the thing most likely to break, and only this path exercises it.
  5006. """
  5007. print_data = {"gcode_state": "IDLE", "ams": {"ams": ams_units}}
  5008. if fts:
  5009. print_data["device"] = {"fila_switch": {"in": [-1, -1], "out": [1, 0], "stat": 0, "info": 0}}
  5010. client._process_message({"print": print_data})
  5011. def test_inlet_a_from_bits_24_27(self, mqtt_client):
  5012. self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}])
  5013. assert mqtt_client.state.ams_switch_inlet == {"0": "A"}
  5014. def test_inlet_b_from_bits_24_27(self, mqtt_client):
  5015. """0 is In-B, not "unset" — the enum is ordered B first."""
  5016. self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=0), "tray": []}])
  5017. assert mqtt_client.state.ams_switch_inlet == {"0": "B"}
  5018. def test_the_maintainers_layout(self, mqtt_client):
  5019. """Four units split across both inlets, as on the H2C in #1162's
  5020. follow-up: AMS-A and an AMS-HT on In-A, AMS-B and AMS-C on In-B."""
  5021. self._frame(
  5022. mqtt_client,
  5023. [
  5024. {"id": "0", "info": self._info(inlet_bits=1), "tray": []},
  5025. {"id": "1", "info": self._info(inlet_bits=0), "tray": []},
  5026. {"id": "2", "info": self._info(inlet_bits=0), "tray": []},
  5027. {"id": "128", "info": self._info(inlet_bits=1, ams_type=4), "tray": []},
  5028. ],
  5029. )
  5030. assert mqtt_client.state.ams_switch_inlet == {"0": "A", "1": "B", "2": "B", "128": "A"}
  5031. # Every unit reads 0xE, so the extruder map stays empty — which is
  5032. # exactly why a left/right label cannot be derived on these machines.
  5033. assert mqtt_client.state.ams_extruder_map == {}
  5034. def test_no_inlet_read_without_a_switch(self, mqtt_client):
  5035. """Without an FTS, 0xE means an uninitialised unit and bits 24-27 carry
  5036. nothing. Reading them anyway would invent inlet "B" for every AMS."""
  5037. self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=0), "tray": []}], fts=False)
  5038. assert mqtt_client.state.ams_switch_inlet == {}
  5039. def test_a_normally_bound_ams_is_untouched(self, mqtt_client):
  5040. """An AMS that still reports a real extruder id keeps using it, switch
  5041. or no switch — only 0xE units are inlet-bound."""
  5042. self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1, extruder=1), "tray": []}])
  5043. assert mqtt_client.state.ams_extruder_map == {"0": 1}
  5044. assert mqtt_client.state.ams_switch_inlet == {}
  5045. def test_binding_survives_a_frame_without_the_switch_block(self, mqtt_client):
  5046. """Partial push_status frames carry the AMS block without device.*.
  5047. The switch stays installed (sticky) so the binding must not be dropped."""
  5048. self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}])
  5049. mqtt_client._process_message(
  5050. {
  5051. "print": {
  5052. "gcode_state": "IDLE",
  5053. "ams": {"ams": [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}]},
  5054. }
  5055. }
  5056. )
  5057. assert mqtt_client.state.ams_switch_inlet == {"0": "A"}
  5058. def test_a_rebind_is_picked_up(self, mqtt_client):
  5059. """ "Join IN-B" on the printer screen flips the bits; we must follow it
  5060. rather than keeping the first value we ever saw."""
  5061. self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=1), "tray": []}])
  5062. self._frame(mqtt_client, [{"id": "0", "info": self._info(inlet_bits=0), "tray": []}])
  5063. assert mqtt_client.state.ams_switch_inlet == {"0": "B"}
  5064. def test_unparseable_info_is_skipped(self, mqtt_client):
  5065. self._frame(mqtt_client, [{"id": "0", "info": "not-hex", "tray": []}])
  5066. assert mqtt_client.state.ams_switch_inlet == {}
  5067. class TestFilamentTrackSwitchInletSlotDecode:
  5068. """``fila_switch.in`` decoding — snow-encoded, and ordered B before A."""
  5069. def test_decodes_ams_and_slot(self):
  5070. from backend.app.services.bambu_mqtt import FilaSwitchState
  5071. # 0x0102 = AMS 1 slot 2 on In-A; 0x0003 = AMS 0 slot 3 on In-B.
  5072. fs = FilaSwitchState(installed=True, in_slots=[0x0003, 0x0102])
  5073. assert fs.inlet_slot("A") == (1, 2)
  5074. assert fs.inlet_slot("B") == (0, 3)
  5075. def test_empty_inlet_is_none(self):
  5076. from backend.app.services.bambu_mqtt import FilaSwitchState
  5077. fs = FilaSwitchState(installed=True, in_slots=[-1, 0x0102])
  5078. assert fs.inlet_slot("B") is None
  5079. assert fs.inlet_slot("A") == (1, 2)
  5080. def test_missing_or_unknown_inlet_is_none(self):
  5081. from backend.app.services.bambu_mqtt import FilaSwitchState
  5082. assert FilaSwitchState(installed=True).inlet_slot("A") is None
  5083. assert FilaSwitchState(installed=True, in_slots=[0x0003, 0x0102]).inlet_slot("C") is None
  5084. class TestAmsLoadFilamentEncoding:
  5085. """Per-target ams_change_filament command encoding (#891)."""
  5086. @pytest.fixture
  5087. def mqtt_client(self):
  5088. from unittest.mock import MagicMock
  5089. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5090. client = BambuMQTTClient(
  5091. ip_address="192.168.1.100",
  5092. serial_number="TEST123",
  5093. access_code="12345678",
  5094. )
  5095. # Pretend the MQTT layer is connected so the publish path is reached.
  5096. client._client = MagicMock()
  5097. client.state.connected = True
  5098. return client
  5099. @staticmethod
  5100. def _published(client) -> dict:
  5101. """Return the JSON of the most recent publish() call."""
  5102. last_call = client._client.publish.call_args_list[-1]
  5103. topic, payload, *_ = last_call.args
  5104. return json.loads(payload)
  5105. def test_ams_slot_uses_local_index_and_minus_one_temps(self, mqtt_client):
  5106. """tray_id=5 → ams_id=1, slot_id=1, target=5, curr/tar=-1."""
  5107. assert mqtt_client.ams_load_filament(5) is True
  5108. cmd = self._published(mqtt_client)["print"]
  5109. assert cmd["command"] == "ams_change_filament"
  5110. assert cmd["ams_id"] == 1
  5111. assert cmd["slot_id"] == 1
  5112. assert cmd["target"] == 5
  5113. assert cmd["curr_temp"] == -1
  5114. assert cmd["tar_temp"] == -1
  5115. def test_external_left_keeps_legacy_encoding(self, mqtt_client):
  5116. """tray_id=254 → ams_id=255, slot_id=254, target=254, curr/tar=-1.
  5117. This is the original capture from a single-extruder printer; preserved
  5118. verbatim so existing single-external setups don't regress.
  5119. """
  5120. assert mqtt_client.ams_load_filament(254) is True
  5121. cmd = self._published(mqtt_client)["print"]
  5122. assert cmd["ams_id"] == 255
  5123. assert cmd["slot_id"] == 254
  5124. assert cmd["target"] == 254
  5125. assert cmd["curr_temp"] == -1
  5126. assert cmd["tar_temp"] == -1
  5127. def test_external_right_uses_extruder_index_and_actual_temp(self, mqtt_client):
  5128. """tray_id=255 → captured BambuStudio shape on dual-nozzle H2D:
  5129. ams_id=255, slot_id=0 (right extruder), target=255, curr/tar = right
  5130. nozzle temp.
  5131. """
  5132. # Simulate a heated right nozzle.
  5133. mqtt_client.state.temperatures["nozzle_2"] = 215.0
  5134. assert mqtt_client.ams_load_filament(255) is True
  5135. cmd = self._published(mqtt_client)["print"]
  5136. assert cmd["ams_id"] == 255
  5137. assert cmd["slot_id"] == 0
  5138. assert cmd["target"] == 255
  5139. assert cmd["curr_temp"] == 215
  5140. assert cmd["tar_temp"] == 215
  5141. def test_external_right_falls_back_when_nozzle_cold(self, mqtt_client):
  5142. """If the right nozzle reports < 180 °C, fall back to a sane default
  5143. so the printer accepts the command rather than rejecting it on a
  5144. nonsensical temperature.
  5145. """
  5146. mqtt_client.state.temperatures["nozzle_2"] = 25.0
  5147. assert mqtt_client.ams_load_filament(255) is True
  5148. cmd = self._published(mqtt_client)["print"]
  5149. assert cmd["curr_temp"] == 215
  5150. assert cmd["tar_temp"] == 215
  5151. def test_returns_false_when_disconnected(self, mqtt_client):
  5152. """Disconnected client must not publish anything."""
  5153. mqtt_client.state.connected = False
  5154. assert mqtt_client.ams_load_filament(0) is False
  5155. mqtt_client._client.publish.assert_not_called()
  5156. def test_extruder_id_is_absent_unless_asked_for(self, mqtt_client):
  5157. """The field must not appear on a printer that did not ask for it.
  5158. BambuStudio only sets it when a Filament Track Switch is installed
  5159. (``command_ams_change_filament`` takes it as std::optional). Everywhere
  5160. else the firmware derives the hotend from the AMS binding, and sending a
  5161. value would be us guessing at something it already knows.
  5162. """
  5163. assert mqtt_client.ams_load_filament(5) is True
  5164. assert "extruder_id" not in self._published(mqtt_client)["print"]
  5165. @pytest.mark.parametrize("extruder_id", [0, 1])
  5166. def test_extruder_id_rides_along_when_given(self, mqtt_client, extruder_id):
  5167. """With a switch fitted the hotend has to be named, or nothing happens."""
  5168. assert mqtt_client.ams_load_filament(5, extruder_id=extruder_id) is True
  5169. assert self._published(mqtt_client)["print"]["extruder_id"] == extruder_id
  5170. class TestAmsUnloadFilamentTargeting:
  5171. """Which hotend an unload acts on, for dual-nozzle printers.
  5172. ``tray_now`` is a single printer-wide value, so with both hotends loaded it
  5173. names only one of them. BambuStudio addresses the unload by walking its
  5174. extruders for the one fed from the clicked slot
  5175. (``StatusPanel::on_ams_unload``) and publishes nothing when none matches.
  5176. """
  5177. @pytest.fixture
  5178. def mqtt_client(self):
  5179. from unittest.mock import MagicMock
  5180. from backend.app.services.bambu_mqtt import BambuMQTTClient, ExtruderSlot
  5181. client = BambuMQTTClient(
  5182. ip_address="192.168.1.100",
  5183. serial_number="TEST123",
  5184. access_code="12345678",
  5185. )
  5186. client._client = MagicMock()
  5187. client.state.connected = True
  5188. # The check only runs once the printer has reported two extruders; a
  5189. # single-nozzle machine has nothing to disambiguate.
  5190. client._is_dual_nozzle = True
  5191. # Right hotend fed from AMS 1 slot 1 (global tray 5), left from AMS 0
  5192. # slot 2 (global tray 2). tray_now names only the first of the two.
  5193. client.state.tray_now = 5
  5194. client.state.extruder_slots = {
  5195. 0: ExtruderSlot(ams_id=1, slot_id=1, has_filament=True),
  5196. 1: ExtruderSlot(ams_id=0, slot_id=2, has_filament=True),
  5197. }
  5198. return client
  5199. @staticmethod
  5200. def _published(client) -> dict:
  5201. last_call = client._client.publish.call_args_list[-1]
  5202. _topic, payload, *_ = last_call.args
  5203. return json.loads(payload)
  5204. def test_an_addressed_unload_targets_that_slots_ams(self, mqtt_client):
  5205. """Tray 2 is on the left hotend, which tray_now does not name."""
  5206. assert mqtt_client.ams_unload_filament(2) is True
  5207. cmd = self._published(mqtt_client)["print"]
  5208. assert cmd["ams_id"] == 0
  5209. assert cmd["slot_id"] == 255
  5210. assert cmd["target"] == 255
  5211. def test_an_unaddressed_unload_still_follows_tray_now(self, mqtt_client):
  5212. """Single-nozzle printers have no slot to pass; that path is unchanged."""
  5213. assert mqtt_client.ams_unload_filament() is True
  5214. assert self._published(mqtt_client)["print"]["ams_id"] == 1
  5215. def test_a_slot_no_hotend_holds_publishes_nothing(self, mqtt_client):
  5216. """Clicking Unload on an idle slot is a no-op, not a command."""
  5217. assert mqtt_client.ams_unload_filament(7) is False
  5218. mqtt_client._client.publish.assert_not_called()
  5219. def test_the_check_is_skipped_when_the_printer_reports_no_extruder_slots(self, mqtt_client):
  5220. """An empty map means "did not say", never "no hotend holds it".
  5221. Every printer outside the H2/X2 series omits ``device.extruder.info``,
  5222. and reading that silence as a negative would break unload on all of them.
  5223. """
  5224. mqtt_client.state.extruder_slots = {}
  5225. assert mqtt_client.ams_unload_filament(7) is True
  5226. assert self._published(mqtt_client)["print"]["ams_id"] == 1
  5227. def test_a_single_nozzle_printer_is_never_gated_on_snow(self, mqtt_client):
  5228. """One hotend means tray_now is already unambiguous.
  5229. Single-nozzle machines do report ``device.extruder.info`` — BambuStudio
  5230. has a branch for it and an X1C sends the block — but nobody has read a
  5231. single-nozzle ``snow`` off the wire. Running the match there would stake
  5232. every X1C/P1S/A1 unload on an unverified encoding, so it is not run.
  5233. """
  5234. mqtt_client._is_dual_nozzle = False
  5235. assert mqtt_client.ams_unload_filament(7) is True
  5236. assert self._published(mqtt_client)["print"]["ams_id"] == 1
  5237. def test_the_external_spool_is_not_matched_against_ams_slots(self, mqtt_client):
  5238. """254/255 are not ams*4+slot, so the slot arithmetic cannot describe them."""
  5239. assert mqtt_client.ams_unload_filament(254) is True
  5240. assert self._published(mqtt_client)["print"]["ams_id"] == 255
  5241. class TestParseExtruderSlots:
  5242. """Decoding ``device.extruder.info`` into per-hotend loaded slots."""
  5243. @pytest.fixture
  5244. def mqtt_client(self):
  5245. from unittest.mock import MagicMock
  5246. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5247. client = BambuMQTTClient(
  5248. ip_address="192.168.1.100",
  5249. serial_number="TEST123",
  5250. access_code="12345678",
  5251. )
  5252. client._client = MagicMock()
  5253. return client
  5254. def test_snow_decodes_to_ams_and_slot(self, mqtt_client):
  5255. """snow is bits 8-15 = AMS id, bits 0-7 = slot — same shape as fila_switch.in."""
  5256. mqtt_client._parse_extruder_slots({"device": {"extruder": {"info": [{"id": 0, "snow": 0x0102, "info": 0b10}]}}})
  5257. slot = mqtt_client.state.extruder_slots[0]
  5258. assert (slot.ams_id, slot.slot_id) == (1, 2)
  5259. assert slot.has_filament is True
  5260. def test_the_empty_sentinel_is_not_read_as_a_slot(self, mqtt_client):
  5261. """0xFFFF would otherwise decode to the nonexistent AMS 255 slot 255."""
  5262. mqtt_client._parse_extruder_slots({"device": {"extruder": {"info": [{"id": 1, "snow": 0xFFFF, "info": 0}]}}})
  5263. slot = mqtt_client.state.extruder_slots[1]
  5264. assert slot.ams_id is None and slot.slot_id is None
  5265. assert slot.has_filament is False
  5266. def test_a_payload_without_the_block_keeps_the_last_answer(self, mqtt_client):
  5267. """A temperatures-only frame must not read as "both hotends are empty"."""
  5268. mqtt_client._parse_extruder_slots({"device": {"extruder": {"info": [{"id": 0, "snow": 0x0003, "info": 0b10}]}}})
  5269. mqtt_client._parse_extruder_slots({"bed_temper": 60})
  5270. assert mqtt_client.state.extruder_slots[0].ams_id == 0
  5271. def test_holds_answers_for_one_slot_only(self, mqtt_client):
  5272. from backend.app.services.bambu_mqtt import ExtruderSlot
  5273. assert ExtruderSlot(ams_id=1, slot_id=2).holds(1, 2) is True
  5274. assert ExtruderSlot(ams_id=1, slot_id=2).holds(1, 3) is False
  5275. assert ExtruderSlot().holds(0, 0) is False
  5276. class TestAmsFilamentSettingExternalSpoolEncoding:
  5277. """Encoding of `ams_filament_setting` / `reset_ams_slot` for the external spool.
  5278. Regression coverage for #1279. The encoding is verified against a captured
  5279. BambuStudio → X1C exchange (May 2026):
  5280. REQ {"command":"ams_filament_setting","ams_id":255,"tray_id":254,"slot_id":0,...}
  5281. REP {"result":"success",...}
  5282. The previous code sent `tray_id: 0` for the single-external case, which the
  5283. P1S in #1279 rejected with `result: "fail"`.
  5284. """
  5285. @pytest.fixture
  5286. def mqtt_client(self):
  5287. from unittest.mock import MagicMock
  5288. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5289. client = BambuMQTTClient(
  5290. ip_address="192.168.1.100",
  5291. serial_number="TEST123",
  5292. access_code="12345678",
  5293. )
  5294. client._client = MagicMock()
  5295. client.state.connected = True
  5296. return client
  5297. def _published(self, mqtt_client):
  5298. call_args = mqtt_client._client.publish.call_args
  5299. return json.loads(call_args[0][1])["print"]
  5300. def test_single_external_uses_tray_id_254(self, mqtt_client):
  5301. """X1C/P1S/A1 (single external slot): ams_id=255, tray_id=254, slot_id=0."""
  5302. # Simulate a single-external printer: vt_tray is a single-element list.
  5303. mqtt_client.state.raw_data = {"vt_tray": [{"id": "255"}]}
  5304. assert mqtt_client.ams_set_filament_setting(
  5305. ams_id=255,
  5306. tray_id=0,
  5307. tray_info_idx="GFL99",
  5308. tray_type="PLA",
  5309. tray_sub_brands="Generic PLA",
  5310. tray_color="000000FF",
  5311. nozzle_temp_min=190,
  5312. nozzle_temp_max=230,
  5313. )
  5314. cmd = self._published(mqtt_client)
  5315. assert cmd["command"] == "ams_filament_setting"
  5316. assert cmd["ams_id"] == 255
  5317. assert cmd["tray_id"] == 254, (
  5318. "Single-external `ams_filament_setting` must send tray_id=254 "
  5319. "(verified via BambuStudio→X1C capture). Sending tray_id=0 "
  5320. "is what the P1S in #1279 rejects."
  5321. )
  5322. assert cmd["slot_id"] == 0
  5323. def test_single_external_reset_uses_tray_id_254(self, mqtt_client):
  5324. """reset_ams_slot shares the convention — same encoding."""
  5325. mqtt_client.state.raw_data = {"vt_tray": [{"id": "255"}]}
  5326. assert mqtt_client.reset_ams_slot(ams_id=255, tray_id=0)
  5327. cmd = self._published(mqtt_client)
  5328. assert cmd["command"] == "ams_filament_setting"
  5329. assert cmd["ams_id"] == 255
  5330. assert cmd["tray_id"] == 254
  5331. assert cmd["slot_id"] == 0
  5332. # Reset clears the filament identity
  5333. assert cmd["tray_info_idx"] == ""
  5334. assert cmd["tray_type"] == ""
  5335. def test_regular_ams_tray_unchanged(self, mqtt_client):
  5336. """Regular AMS slots (ams_id <= 3) keep their existing encoding."""
  5337. mqtt_client.state.raw_data = {"vt_tray": []}
  5338. assert mqtt_client.ams_set_filament_setting(
  5339. ams_id=0,
  5340. tray_id=2,
  5341. tray_info_idx="GFA01",
  5342. tray_type="PLA",
  5343. tray_sub_brands="PLA Matte",
  5344. tray_color="FFFFFFFF",
  5345. nozzle_temp_min=190,
  5346. nozzle_temp_max=230,
  5347. )
  5348. cmd = self._published(mqtt_client)
  5349. assert cmd["ams_id"] == 0
  5350. assert cmd["tray_id"] == 2
  5351. assert cmd["slot_id"] == 2
  5352. def test_ams_ht_unchanged(self, mqtt_client):
  5353. """AMS-HT (ams_id >= 128) keeps its single-tray-per-unit encoding."""
  5354. mqtt_client.state.raw_data = {"vt_tray": []}
  5355. assert mqtt_client.ams_set_filament_setting(
  5356. ams_id=128,
  5357. tray_id=0,
  5358. tray_info_idx="GFA01",
  5359. tray_type="PLA",
  5360. tray_sub_brands="PLA Matte",
  5361. tray_color="FFFFFFFF",
  5362. nozzle_temp_min=190,
  5363. nozzle_temp_max=230,
  5364. )
  5365. cmd = self._published(mqtt_client)
  5366. assert cmd["ams_id"] == 128
  5367. assert cmd["tray_id"] == 0
  5368. assert cmd["slot_id"] == 0
  5369. def test_dual_external_left_keeps_legacy_encoding(self, mqtt_client):
  5370. """H2D dual-external (`vt_tray` length > 1): not in the X1C capture, so
  5371. left at the legacy `mqtt_tray_id = 0` until verified separately."""
  5372. mqtt_client.state.raw_data = {"vt_tray": [{"id": "254"}, {"id": "255"}]}
  5373. assert mqtt_client.ams_set_filament_setting(
  5374. ams_id=255,
  5375. tray_id=0, # Ext-L
  5376. tray_info_idx="GFA01",
  5377. tray_type="PLA",
  5378. tray_sub_brands="PLA Matte",
  5379. tray_color="FFFFFFFF",
  5380. nozzle_temp_min=190,
  5381. nozzle_temp_max=230,
  5382. )
  5383. cmd = self._published(mqtt_client)
  5384. # Ext-L → mqtt_ams_id = 254
  5385. assert cmd["ams_id"] == 254
  5386. # tray_id stays at 0 for dual external; this pins current behavior so
  5387. # a future capture-driven change shows up in the diff.
  5388. assert cmd["tray_id"] == 0
  5389. assert cmd["slot_id"] == 0
  5390. class TestDryCountdownStall:
  5391. """A ``dry_time`` the firmware set but whose countdown never ticks is a
  5392. parked command, not a running cycle — ``dry_countdown_stalled`` is how the
  5393. UI stops showing an active-drying badge for it. Live countdowns decrement
  5394. once a minute; the stall threshold (150 s) is two missed ticks plus jitter.
  5395. Found on an H2D mid-print: two AMS-HT cycles running, the third unit's
  5396. timer frozen at its full 720 while the AMS never heated."""
  5397. @pytest.fixture
  5398. def mqtt_client(self, monkeypatch):
  5399. from backend.app.services import bambu_mqtt as mod
  5400. clock = {"now": 1000.0}
  5401. monkeypatch.setattr(mod.time, "monotonic", lambda: clock["now"])
  5402. client = mod.BambuMQTTClient(
  5403. ip_address="192.168.1.100",
  5404. serial_number="TEST-STALL",
  5405. access_code="12345678",
  5406. )
  5407. client._test_clock = clock # Expose for tests to advance
  5408. return client
  5409. def _unit(self, mqtt_client, ams_id=0):
  5410. for u in mqtt_client.state.raw_data["ams"]:
  5411. if int(u["id"]) == ams_id:
  5412. return u
  5413. raise AssertionError(f"AMS {ams_id} not in raw_data")
  5414. def test_frozen_countdown_flags_stalled(self, mqtt_client):
  5415. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
  5416. assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
  5417. mqtt_client._test_clock["now"] += 151
  5418. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
  5419. assert self._unit(mqtt_client)["dry_countdown_stalled"] is True
  5420. def test_ticking_countdown_never_flags(self, mqtt_client):
  5421. """A live cycle decrements every minute; repeats of the same value
  5422. inside the minute must not flag either."""
  5423. for dt, value in ((0, 720), (60, 719), (100, 719), (120, 718)):
  5424. mqtt_client._test_clock["now"] = 1000.0 + dt
  5425. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": value, "tray": []}]})
  5426. assert self._unit(mqtt_client)["dry_countdown_stalled"] is False, dt
  5427. def test_active_dry_status_vouches_for_frozen_countdown(self, mqtt_client):
  5428. """When the firmware's own info phase says Drying, a frozen countdown
  5429. is trusted as live — the phase field outranks the tick heuristic."""
  5430. # info bits 4-7 = 2 (Drying) -> 0x20
  5431. frame = {"ams": [{"id": "0", "dry_time": 720, "info": "20", "tray": []}]}
  5432. mqtt_client._handle_ams_data(frame)
  5433. mqtt_client._test_clock["now"] += 300
  5434. mqtt_client._handle_ams_data(frame)
  5435. assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
  5436. def test_active_phase_clears_stall_on_transient_zero(self, mqtt_client):
  5437. """A Checking/Drying phase is authoritative even when its transient
  5438. dry_time=0 frame is ignored for completion-edge tracking (#2759)."""
  5439. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
  5440. mqtt_client._test_clock["now"] += 151
  5441. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
  5442. assert self._unit(mqtt_client)["dry_countdown_stalled"] is True
  5443. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "20", "tray": []}]})
  5444. assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
  5445. assert mqtt_client._previous_dry_times[0] == 720
  5446. def test_fresh_start_gets_grace_before_flagging(self, mqtt_client):
  5447. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5448. mqtt_client._test_clock["now"] += 30
  5449. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
  5450. mqtt_client._test_clock["now"] += 30
  5451. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
  5452. assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
  5453. def test_finished_cycle_never_reads_stalled(self, mqtt_client):
  5454. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 1, "tray": []}]})
  5455. mqtt_client._test_clock["now"] += 200
  5456. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5457. assert self._unit(mqtt_client)["dry_countdown_stalled"] is False
  5458. class TestDryingCompleteCallback:
  5459. """#1349 — fires ``on_drying_complete(ams_id)`` on a dry_time falling edge."""
  5460. @pytest.fixture
  5461. def mqtt_client(self):
  5462. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5463. events: list[int] = []
  5464. client = BambuMQTTClient(
  5465. ip_address="192.168.1.100",
  5466. serial_number="TEST-DRYING",
  5467. access_code="12345678",
  5468. on_drying_complete=events.append,
  5469. )
  5470. client._drying_events = events # Expose for assertions
  5471. return client
  5472. def test_falling_edge_fires_callback(self, mqtt_client):
  5473. """First push reports drying active, second reports drying done."""
  5474. # Push 1: AMS 0 drying with 60 minutes remaining.
  5475. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 60, "tray": []}]})
  5476. assert mqtt_client._drying_events == []
  5477. # Push 2: dry_time hits 0 → callback fires with the AMS id.
  5478. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5479. assert mqtt_client._drying_events == [0]
  5480. def test_no_fire_when_dry_time_never_started(self, mqtt_client):
  5481. """dry_time = 0 across consecutive pushes does NOT fire — there was
  5482. no drying cycle to finish. Guards against the seed-from-zero false
  5483. positive on startup."""
  5484. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5485. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5486. assert mqtt_client._drying_events == []
  5487. def test_falling_edge_fires_once(self, mqtt_client):
  5488. """Subsequent zero-pushes after the edge don't refire the callback."""
  5489. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 30, "tray": []}]})
  5490. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5491. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5492. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5493. assert mqtt_client._drying_events == [0]
  5494. def test_per_ams_tracking(self, mqtt_client):
  5495. """Two AMS units finishing drying at different times each fire once
  5496. — the falling-edge state is keyed per AMS id."""
  5497. # Both start drying.
  5498. mqtt_client._handle_ams_data(
  5499. {"ams": [{"id": "0", "dry_time": 30, "tray": []}, {"id": "1", "dry_time": 30, "tray": []}]}
  5500. )
  5501. # AMS 0 finishes, AMS 1 still drying.
  5502. mqtt_client._handle_ams_data(
  5503. {"ams": [{"id": "0", "dry_time": 0, "tray": []}, {"id": "1", "dry_time": 15, "tray": []}]}
  5504. )
  5505. assert mqtt_client._drying_events == [0]
  5506. # AMS 1 finishes.
  5507. mqtt_client._handle_ams_data(
  5508. {"ams": [{"id": "0", "dry_time": 0, "tray": []}, {"id": "1", "dry_time": 0, "tray": []}]}
  5509. )
  5510. assert mqtt_client._drying_events == [0, 1]
  5511. def test_restart_drying_after_completion_refires_callback(self, mqtt_client):
  5512. """A new drying cycle after the previous one finished fires the
  5513. callback again on its own falling edge — covers the user manually
  5514. starting a second dry from the UI."""
  5515. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 30, "tray": []}]})
  5516. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5517. # New cycle starts.
  5518. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 45, "tray": []}]})
  5519. # And finishes.
  5520. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5521. assert mqtt_client._drying_events == [0, 0]
  5522. def test_tray_only_partial_does_not_fake_completion(self, mqtt_client):
  5523. """#1462 — a tray-bearing partial update that omits dry_time must not
  5524. be read as dry_time=0. The pre-fix merge dropped dry_time on such
  5525. partials, so the falling-edge detector saw a 60→0 edge and fired a
  5526. false 'drying complete' seconds after drying started — which armed
  5527. smart-plug auto-off and killed the printer mid-cycle."""
  5528. # Drying active, 60 minutes remaining.
  5529. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 60, "tray": []}]})
  5530. assert mqtt_client._drying_events == []
  5531. # Printer sends a tray-bearing partial carrying NO dry_time field.
  5532. mqtt_client._handle_ams_data({"ams": [{"id": "0", "tray": []}]})
  5533. assert mqtt_client._drying_events == []
  5534. # dry_time survived the partial in the merged AMS state.
  5535. assert mqtt_client.state.raw_data["ams"][0]["dry_time"] == 60
  5536. # Drying genuinely finishes → the real edge still fires exactly once.
  5537. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5538. assert mqtt_client._drying_events == [0]
  5539. def test_transient_zero_while_checking_is_not_completion(self, mqtt_client):
  5540. """#2759 — between the command ack and the countdown settling, firmware
  5541. publishes a dry_time of 0 while the AMS is still in its Checking phase.
  5542. The reporter's log caught 720 → 0 → 719 one minute into a 12-hour
  5543. cycle: it dropped the cached target (so the badge guessed the filament
  5544. from tray 1 and read "PETG @ 65°C" for a PLA dry) and armed smart-plug
  5545. auto-off."""
  5546. mqtt_client._drying_targets[0] = {"filament": "PLA", "temp": 45}
  5547. # Cycle starts: 12 hours, unit reports dry_status 1 (Checking).
  5548. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "info": "11402113", "tray": []}]})
  5549. assert mqtt_client._drying_events == []
  5550. # The blip: dry_time 0, still Checking.
  5551. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "11402113", "tray": []}]})
  5552. assert mqtt_client._drying_events == []
  5553. # And the user's chosen target survived it.
  5554. assert mqtt_client._drying_targets[0] == {"filament": "PLA", "temp": 45}
  5555. # Countdown settles and the unit moves to dry_status 2 (Drying).
  5556. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 719, "info": "11402123", "tray": []}]})
  5557. assert mqtt_client._drying_events == []
  5558. # Twelve hours later it really finishes, back to dry_status 0 (Off).
  5559. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "11402103", "tray": []}]})
  5560. assert mqtt_client._drying_events == [0]
  5561. assert 0 not in mqtt_client._drying_targets
  5562. def test_zero_while_stopping_completes(self, mqtt_client):
  5563. """dry_status 4 (Stopping) means the cycle is ending, not running — the
  5564. edge must still fire so smart-plug auto-off runs when a user stops a
  5565. dry early."""
  5566. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "info": "11402123", "tray": []}]})
  5567. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "11402143", "tray": []}]})
  5568. assert mqtt_client._drying_events == [0]
  5569. def test_absent_dry_status_still_completes(self, mqtt_client):
  5570. """The phase gate is a suppression, not a requirement: firmware that
  5571. never reports an info hex must still be able to end a cycle."""
  5572. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
  5573. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5574. assert mqtt_client._drying_events == [0]
  5575. def test_early_end_logs_firmware_reason_codes(self, mqtt_client, caplog):
  5576. """#2770 — a 12-hour cycle the firmware abandoned 20 minutes in logged
  5577. only 'drying complete', so the report carried no evidence of why. An
  5578. early end now names the shortfall and the reason fields we already
  5579. parse: phase, sub-phase, cannot-dry codes and live HMS."""
  5580. from backend.app.services.bambu_mqtt import HMSError
  5581. mqtt_client.state.hms_errors = [
  5582. HMSError(code="0x2000003", attr=0x07008000, module=7, severity=2, full_code="0700800002000003")
  5583. ]
  5584. mqtt_client._client = MagicMock()
  5585. mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
  5586. mqtt_client._handle_ams_data(
  5587. {"ams": [{"id": "0", "dry_time": 700, "info": "10002123", "dry_sf_reason": [1], "tray": []}]}
  5588. )
  5589. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
  5590. mqtt_client._handle_ams_data(
  5591. {"ams": [{"id": "0", "dry_time": 0, "info": "10002103", "dry_sf_reason": [1], "tray": []}]}
  5592. )
  5593. assert mqtt_client._drying_events == [0]
  5594. message = "\n".join(r.getMessage() for r in caplog.records)
  5595. assert "drying ended early" in message
  5596. # The shortfall, against the duration we asked the firmware for.
  5597. assert "700 of 720 minutes" in message
  5598. # dry_status 0 (Off) and dry_sub_status 0 from info hex 10002103.
  5599. assert "dry_status=0" in message
  5600. assert "dry_sub_status=0" in message
  5601. # InsufficientPower, and the AMS heater-fan HMS that goes with it.
  5602. assert "dry_sf_reason=[1]" in message
  5603. assert "0700800002000003" in message
  5604. def test_early_end_without_a_cached_target_still_logs(self, mqtt_client, caplog):
  5605. """A cycle Bambuddy did not start — from the printer's screen, from
  5606. Studio, or from before a restart — has no cached duration to compare
  5607. against. The remaining time alone still proves it was cut short, so the
  5608. reason codes must be logged rather than withheld for lack of a target."""
  5609. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 480, "tray": []}]})
  5610. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
  5611. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5612. message = "\n".join(r.getMessage() for r in caplog.records)
  5613. assert "drying ended early" in message
  5614. assert "480 of ? minutes" in message
  5615. assert "hms=none" in message
  5616. def test_stop_we_sent_is_not_blamed_on_the_firmware(self, mqtt_client, caplog):
  5617. """A stop Bambuddy sends — print takes priority, or the user's Stop
  5618. button — also ends the cycle far short of its duration, which on the
  5619. telemetry alone looks exactly like the firmware abandoning it. It must
  5620. be named as ours rather than reported as an unexplained early end."""
  5621. mqtt_client._client = MagicMock()
  5622. mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
  5623. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
  5624. mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
  5625. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
  5626. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5627. message = "\n".join(r.getMessage() for r in caplog.records)
  5628. assert "drying stopped by Bambuddy" in message
  5629. assert "ended early" not in message
  5630. # And the attribution is consumed, so a later firmware-ended cycle on
  5631. # the same unit is not credited to a stop we sent hours earlier.
  5632. mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
  5633. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
  5634. caplog.clear()
  5635. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
  5636. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5637. assert "drying ended early" in "\n".join(r.getMessage() for r in caplog.records)
  5638. def test_cycle_that_runs_to_term_keeps_the_plain_completion_log(self, mqtt_client, caplog):
  5639. """The countdown of a cycle that finishes normally is all but exhausted
  5640. when it drops to 0. Nothing needs explaining, so it keeps the one-line
  5641. message it has always had — the early-end diagnostics must not become
  5642. noise on every completed dry."""
  5643. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 1, "tray": []}]})
  5644. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
  5645. mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
  5646. message = "\n".join(r.getMessage() for r in caplog.records)
  5647. assert "drying complete (dry_time 1 → 0" in message
  5648. assert "ended early" not in message
  5649. def test_every_cycle_end_records_the_box_conditions(self, mqtt_client, caplog):
  5650. """Whether auto-drying re-arms is decided by the unit's temperature and
  5651. humidity at the moment the cycle ends, so both belong on the line that
  5652. reports the end — normal or early. Reconstructing them for #2770 meant
  5653. cross-referencing hourly alarm lines against scheduler debug that was
  5654. switched off at the time."""
  5655. mqtt_client._handle_ams_data(
  5656. {"ams": [{"id": "0", "dry_time": 1, "temp": "63.0", "humidity_raw": "16", "tray": []}]}
  5657. )
  5658. with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
  5659. mqtt_client._handle_ams_data(
  5660. {"ams": [{"id": "0", "dry_time": 0, "temp": "63.0", "humidity_raw": "16", "tray": []}]}
  5661. )
  5662. message = "\n".join(r.getMessage() for r in caplog.records)
  5663. assert "temp=63.0" in message
  5664. assert "humidity=16" in message
  5665. class TestPrintRunningObservedCallback:
  5666. """#1485 follow-up: on_print_running_observed fires the FIRST time we
  5667. see ``state == RUNNING`` for a printer whose print started before
  5668. Bambuddy came up. It lets main.py capture a timelapse baseline at
  5669. restart-recovery time — when on_print_start was suppressed by the
  5670. #1304 first-push guard. Must NOT fire when on_print_start handles the
  5671. transition (avoids double-capture), and must NOT fire again after
  5672. the first observation in the same session.
  5673. """
  5674. @pytest.fixture
  5675. def mqtt_client(self):
  5676. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5677. return BambuMQTTClient(
  5678. ip_address="192.168.1.100",
  5679. serial_number="TEST123",
  5680. access_code="12345678",
  5681. )
  5682. def test_fires_on_first_running_push_after_startup(self, mqtt_client):
  5683. """First push the client sees has _previous_gcode_state=None, so the
  5684. #1304 guard suppresses on_print_start. on_print_running_observed
  5685. must fire instead so the consumer can recover."""
  5686. start_calls: list[dict] = []
  5687. running_observed_calls: list[dict] = []
  5688. mqtt_client.on_print_start = lambda data: start_calls.append(data)
  5689. mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
  5690. # Pristine state — exactly what we have right after BambuMQTTClient
  5691. # construction following a Bambuddy restart.
  5692. mqtt_client._was_running = False
  5693. mqtt_client._previous_gcode_state = None
  5694. mqtt_client._process_message(
  5695. {
  5696. "print": {
  5697. "gcode_state": "RUNNING",
  5698. "gcode_file": "/data/Metadata/test_print.gcode",
  5699. "subtask_name": "Test_Print",
  5700. }
  5701. }
  5702. )
  5703. assert start_calls == [], "on_print_start must be suppressed by the #1304 guard"
  5704. assert len(running_observed_calls) == 1
  5705. assert running_observed_calls[0]["filename"] == "/data/Metadata/test_print.gcode"
  5706. assert running_observed_calls[0]["subtask_name"] == "Test_Print"
  5707. def test_does_not_fire_when_print_start_fires(self, mqtt_client):
  5708. """Normal print start (a real state transition from non-RUNNING to
  5709. RUNNING) goes through on_print_start; on_print_running_observed
  5710. must stay quiet so the consumer doesn't capture the baseline twice."""
  5711. start_calls: list[dict] = []
  5712. running_observed_calls: list[dict] = []
  5713. mqtt_client.on_print_start = lambda data: start_calls.append(data)
  5714. mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
  5715. mqtt_client._was_running = False
  5716. mqtt_client._previous_gcode_state = "IDLE" # Not None — past the #1304 guard
  5717. mqtt_client._process_message(
  5718. {
  5719. "print": {
  5720. "gcode_state": "RUNNING",
  5721. "gcode_file": "/data/Metadata/test_print.gcode",
  5722. "subtask_name": "Test_Print",
  5723. }
  5724. }
  5725. )
  5726. assert len(start_calls) == 1, "on_print_start should fire on a real start transition"
  5727. assert running_observed_calls == [], "on_print_running_observed must not double up with on_print_start"
  5728. def test_fires_only_once_per_session(self, mqtt_client):
  5729. """Subsequent RUNNING pushes in the same session must not re-fire the
  5730. callback — the baseline only needs to be captured once, the consumer
  5731. treats repeat calls as a hint to skip via the in-memory dict guard."""
  5732. running_observed_calls: list[dict] = []
  5733. mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
  5734. mqtt_client._was_running = False
  5735. mqtt_client._previous_gcode_state = None
  5736. msg = {
  5737. "print": {
  5738. "gcode_state": "RUNNING",
  5739. "gcode_file": "/data/Metadata/test_print.gcode",
  5740. "subtask_name": "Test_Print",
  5741. }
  5742. }
  5743. mqtt_client._process_message(msg)
  5744. mqtt_client._process_message(msg)
  5745. mqtt_client._process_message(msg)
  5746. assert len(running_observed_calls) == 1
  5747. def test_does_not_fire_when_not_running(self, mqtt_client):
  5748. """An IDLE / PREPARE / FINISH first-push must not trigger the
  5749. restart-recovery path — there's no print to baseline."""
  5750. running_observed_calls: list[dict] = []
  5751. mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
  5752. mqtt_client._was_running = False
  5753. mqtt_client._previous_gcode_state = None
  5754. mqtt_client._process_message(
  5755. {
  5756. "print": {
  5757. "gcode_state": "IDLE",
  5758. "gcode_file": "",
  5759. "subtask_name": "",
  5760. }
  5761. }
  5762. )
  5763. assert running_observed_calls == []
  5764. def test_does_not_fire_without_current_file(self, mqtt_client):
  5765. """RUNNING with no file is ill-formed (firmware glitch / transient).
  5766. We need ``current_file`` to find the right archive, so skip the
  5767. callback rather than fire it with a meaningless payload."""
  5768. running_observed_calls: list[dict] = []
  5769. mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
  5770. mqtt_client._was_running = False
  5771. mqtt_client._previous_gcode_state = None
  5772. mqtt_client._process_message(
  5773. {
  5774. "print": {
  5775. "gcode_state": "RUNNING",
  5776. "gcode_file": "",
  5777. "subtask_name": "",
  5778. }
  5779. }
  5780. )
  5781. assert running_observed_calls == []
  5782. def test_safe_when_callback_not_set(self, mqtt_client):
  5783. """No callback configured → silently skip; no AttributeError on the
  5784. firing branch."""
  5785. mqtt_client.on_print_running_observed = None
  5786. mqtt_client._was_running = False
  5787. mqtt_client._previous_gcode_state = None
  5788. # Should not raise.
  5789. mqtt_client._process_message(
  5790. {
  5791. "print": {
  5792. "gcode_state": "RUNNING",
  5793. "gcode_file": "/data/Metadata/test_print.gcode",
  5794. "subtask_name": "Test_Print",
  5795. }
  5796. }
  5797. )
  5798. assert mqtt_client._was_running is True
  5799. def test_payload_shape_matches_print_start(self, mqtt_client):
  5800. """The payload shape must mirror on_print_start so main.py's
  5801. consumer can reuse the same dict fields (filename / subtask_name /
  5802. remaining_time / raw_data / ams_mapping). Test pins the keys."""
  5803. running_observed_calls: list[dict] = []
  5804. mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
  5805. mqtt_client._was_running = False
  5806. mqtt_client._previous_gcode_state = None
  5807. mqtt_client._process_message(
  5808. {
  5809. "print": {
  5810. "gcode_state": "RUNNING",
  5811. "gcode_file": "/data/Metadata/test_print.gcode",
  5812. "subtask_name": "Test_Print",
  5813. "mc_remaining_time": 42,
  5814. }
  5815. }
  5816. )
  5817. assert len(running_observed_calls) == 1
  5818. payload = running_observed_calls[0]
  5819. assert set(payload.keys()) == {
  5820. "filename",
  5821. "subtask_name",
  5822. "remaining_time",
  5823. "raw_data",
  5824. "ams_mapping",
  5825. }
  5826. class TestTotalLayersPreservation:
  5827. """#1771: P1S firmware resets `total_layer_num` to 0 at print end. Without
  5828. this guard, the usage tracker's split path saw `state.total_layers = 0` at
  5829. completion and dumped the whole print onto the last spool.
  5830. These tests pin the preservation pattern (mirror of `_last_valid_layer_num`)
  5831. and the explicit reset on new print start so the previous print's total
  5832. can't bleed into the next.
  5833. """
  5834. @pytest.fixture
  5835. def mqtt_client(self):
  5836. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5837. client = BambuMQTTClient(
  5838. ip_address="192.168.1.100",
  5839. serial_number="TEST123",
  5840. access_code="12345678",
  5841. )
  5842. return client
  5843. def test_nonzero_total_layer_num_sets_state(self, mqtt_client):
  5844. # Baseline: a fresh push with the slicer's total updates state.total_layers.
  5845. mqtt_client._process_message({"print": {"total_layer_num": 260}})
  5846. assert mqtt_client.state.total_layers == 260
  5847. def test_zero_total_layer_num_does_not_clobber_cached_value(self, mqtt_client):
  5848. # Firmware-reset frame: total_layer_num=0 arrives mid- or end-of-print.
  5849. # The guard must NOT overwrite the previously-captured 260.
  5850. mqtt_client._process_message({"print": {"total_layer_num": 260}})
  5851. mqtt_client._process_message({"print": {"total_layer_num": 0}})
  5852. assert mqtt_client.state.total_layers == 260
  5853. def test_print_start_explicitly_resets_total_layers(self, mqtt_client):
  5854. # Without the explicit reset on print start, the previous print's total
  5855. # would persist into the new print until its first total_layer_num push
  5856. # arrived — which is exactly the kind of cross-print bleed the
  5857. # preservation guard above otherwise opens up.
  5858. mqtt_client._process_message({"print": {"total_layer_num": 260}})
  5859. assert mqtt_client.state.total_layers == 260
  5860. # Simulate the new-print-start trigger shape (is_new_print path):
  5861. # state was previously RUNNING on an old file; now we observe a
  5862. # different file going RUNNING.
  5863. mqtt_client._previous_gcode_state = "RUNNING"
  5864. mqtt_client._previous_gcode_file = "/data/Metadata/old_print.gcode"
  5865. mqtt_client._was_running = True
  5866. mqtt_client._process_message(
  5867. {
  5868. "print": {
  5869. "gcode_state": "RUNNING",
  5870. "gcode_file": "/data/Metadata/new_print.gcode",
  5871. "subtask_name": "new_print",
  5872. }
  5873. }
  5874. )
  5875. assert mqtt_client.state.total_layers == 0
  5876. class TestAmsFilamentBackupHoldTimer:
  5877. """Regression: stale push_status arriving within the hold window after a
  5878. toggle command MUST NOT flip ams_filament_backup back to the printer's
  5879. old cfg. Same race-guard pattern xcam uses for spaghetti / first-layer
  5880. detector settings.
  5881. """
  5882. @pytest.fixture
  5883. def mqtt_client(self):
  5884. from unittest.mock import MagicMock
  5885. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5886. client = BambuMQTTClient(
  5887. ip_address="192.168.1.100",
  5888. serial_number="TEST123",
  5889. access_code="12345678",
  5890. )
  5891. # Pretend we're connected so _set_print_option actually publishes.
  5892. client.state.connected = True
  5893. client._client = MagicMock()
  5894. return client
  5895. def test_cfg_push_with_old_value_is_ignored_during_hold(self, mqtt_client):
  5896. # User toggled ON via badge → command sent → state optimistically set.
  5897. mqtt_client.set_ams_filament_backup(True)
  5898. assert mqtt_client.state.ams_filament_backup is True
  5899. # Within the 3 s hold window, a stale push_status arrives still showing
  5900. # the printer's old cfg (bit 18 cleared). The parser must NOT flip our
  5901. # optimistic state back to OFF — otherwise the badge flickers ON→OFF→ON.
  5902. mqtt_client._process_message({"print": {"cfg": "C0340BC219"}}) # bit18=0
  5903. assert mqtt_client.state.ams_filament_backup is True
  5904. def test_cfg_push_after_hold_expires_overrides_state(self, mqtt_client):
  5905. # After the hold window, the printer's real cfg becomes authoritative
  5906. # so a genuine slicer-side or display toggle that we did NOT initiate
  5907. # propagates correctly.
  5908. mqtt_client.set_ams_filament_backup(True)
  5909. mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] = time.time() - 10.0
  5910. mqtt_client._process_message({"print": {"cfg": "C0340BC219"}}) # bit18=0
  5911. assert mqtt_client.state.ams_filament_backup is False
  5912. def test_cfg_push_with_matching_value_during_hold_is_a_noop(self, mqtt_client):
  5913. # Same-value push during hold doesn't trigger the change branch at all
  5914. # (no state mutation, no log spam, hold timer stays armed).
  5915. mqtt_client.set_ams_filament_backup(True)
  5916. before_hold = mqtt_client._xcam_hold_start["print_option_auto_switch_filament"]
  5917. mqtt_client._process_message({"print": {"cfg": "C0340FC219"}}) # bit18=1
  5918. assert mqtt_client.state.ams_filament_backup is True
  5919. # Hold timer still armed — sub-second push didn't reset it.
  5920. assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold
  5921. class TestCommandAckIsNotTelemetry:
  5922. """Regression (#3040): a printer's command acknowledgement echoes the
  5923. fields Bambuddy sent, so ingesting one as status reads our own request
  5924. back as the printer's state.
  5925. Bambuddy used to put ``"cfg": "0"`` in every project_file. The ack came
  5926. back carrying it, bit 18 read as "AMS Filament Backup OFF", and on the
  5927. families that don't repeat ``cfg`` in their periodic frames (P1S, A1,
  5928. A1 Mini, A2L) the wrong value stuck until the user toggled it — which
  5929. silently disabled the prefer-lowest-remaining gate for the rest of the day.
  5930. """
  5931. @pytest.fixture
  5932. def mqtt_client(self):
  5933. from unittest.mock import MagicMock
  5934. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5935. client = BambuMQTTClient(
  5936. ip_address="192.168.1.100",
  5937. serial_number="TEST123",
  5938. access_code="12345678",
  5939. )
  5940. client.state.connected = True
  5941. client._client = MagicMock()
  5942. return client
  5943. def test_project_file_ack_does_not_clear_backup_state(self, mqtt_client):
  5944. mqtt_client.state.ams_filament_backup = True
  5945. mqtt_client._process_message(
  5946. {"print": {"command": "project_file", "sequence_id": "20000", "cfg": "0", "result": "success"}}
  5947. )
  5948. assert mqtt_client.state.ams_filament_backup is True
  5949. def test_project_file_ack_leaves_unknown_backup_unknown(self, mqtt_client):
  5950. """A1 / A1 Mini never report cfg, so the state must stay None ("unknown")
  5951. — the value the prefer-lowest gate reads as "preserve old behaviour"."""
  5952. assert mqtt_client.state.ams_filament_backup is None
  5953. mqtt_client._process_message({"print": {"command": "project_file", "cfg": "0"}})
  5954. assert mqtt_client.state.ams_filament_backup is None
  5955. def test_push_status_still_updates_backup_state(self, mqtt_client):
  5956. mqtt_client.state.ams_filament_backup = True
  5957. mqtt_client._process_message({"print": {"command": "push_status", "cfg": "C0340BC219"}}) # bit18=0
  5958. assert mqtt_client.state.ams_filament_backup is False
  5959. def test_status_frame_without_command_still_updates_backup_state(self, mqtt_client):
  5960. """Some firmwares omit `command` on a status frame; those stay trusted."""
  5961. mqtt_client.state.ams_filament_backup = False
  5962. mqtt_client._process_message({"print": {"cfg": "C0340FC219"}}) # bit18=1
  5963. assert mqtt_client.state.ams_filament_backup is True
  5964. def test_project_file_ack_does_not_clear_timelapse_state(self, mqtt_client):
  5965. """The ack echoes the per-job timelapse request, not the recorder."""
  5966. mqtt_client.state.timelapse = True
  5967. mqtt_client._process_message({"print": {"command": "project_file", "timelapse": False}})
  5968. assert mqtt_client.state.timelapse is True
  5969. def test_push_status_still_updates_timelapse_state(self, mqtt_client):
  5970. mqtt_client.state.timelapse = True
  5971. mqtt_client._process_message({"print": {"command": "push_status", "timelapse": False}})
  5972. assert mqtt_client.state.timelapse is False
  5973. # ---------------------------------------------------------------------------
  5974. # 2c. Single-nozzle H2S — external-spool tray_now override (#1822)
  5975. # ---------------------------------------------------------------------------
  5976. class TestTrayNowH2SExternalSpoolOverride:
  5977. """H2S firmware reports tray_now as the AMS's idle slot (typically 0)
  5978. instead of 254 when the active feed is the external spool.
  5979. Bambuddy detects the all-external case via the slicer-captured
  5980. ams_mapping (every entry 254, or -1 when the command carried no
  5981. ams_mapping2 to resolve it from, #3166) and promotes tray_now to 254 so
  5982. the UI active-tray highlight matches the real feed.
  5983. The override is intentionally narrow:
  5984. * only fires when ams_mapping is captured AND every entry is external
  5985. * does not touch mixed prints ([5, -1]) or AMS-only prints ([5])
  5986. * does not fire when no ams_mapping is captured (printer-screen start)
  5987. """
  5988. @pytest.fixture
  5989. def mqtt_client(self):
  5990. from backend.app.services.bambu_mqtt import BambuMQTTClient
  5991. return BambuMQTTClient(
  5992. ip_address="192.168.1.100",
  5993. serial_number="TEST_H2S",
  5994. access_code="12345678",
  5995. )
  5996. def test_all_external_mapping_promotes_tray_now_to_254(self, mqtt_client):
  5997. """Reporter's scenario: H2S, single nozzle, captured ams_mapping=[-1],
  5998. firmware sends tray_now=0 -> Bambuddy promotes to 254."""
  5999. mqtt_client._captured_ams_mapping = [-1]
  6000. mqtt_client._process_message(_ams_payload(0))
  6001. assert mqtt_client.state.tray_now == 254
  6002. def test_multi_external_mapping_also_promotes(self, mqtt_client):
  6003. """Multi-filament print, every filament mapped to external. Still
  6004. all-external -> still promotes."""
  6005. mqtt_client._captured_ams_mapping = [-1, -1, -1]
  6006. mqtt_client._process_message(_ams_payload(0))
  6007. assert mqtt_client.state.tray_now == 254
  6008. def test_resolved_external_mapping_promotes(self, mqtt_client):
  6009. """Since #3166 the capture resolves the external spool from
  6010. ams_mapping2, so a single-nozzle all-external print arrives as [254]
  6011. rather than [-1]. The override must still fire."""
  6012. mqtt_client._captured_ams_mapping = [254]
  6013. mqtt_client._process_message(_ams_payload(0))
  6014. assert mqtt_client.state.tray_now == 254
  6015. def test_ams_only_mapping_does_not_override(self, mqtt_client):
  6016. """ams_mapping=[5] (AMS slot 5 only) -> firmware value trusted as-is.
  6017. Without the all-external guard, this would falsely override real
  6018. AMS-slot prints."""
  6019. mqtt_client._captured_ams_mapping = [5]
  6020. mqtt_client._process_message(_ams_payload(0))
  6021. assert mqtt_client.state.tray_now == 0
  6022. def test_mixed_mapping_does_not_override(self, mqtt_client):
  6023. """Mixed mapping (AMS slot 5 + external): we have no evidence the
  6024. firmware misreports mid-print swaps, so leave tray_now alone."""
  6025. mqtt_client._captured_ams_mapping = [5, -1]
  6026. mqtt_client._process_message(_ams_payload(0))
  6027. assert mqtt_client.state.tray_now == 0
  6028. def test_no_captured_mapping_does_not_override(self, mqtt_client):
  6029. """Prints started from the printer screen (or before Bambuddy
  6030. connected) have no captured ams_mapping. Behaviour unchanged from
  6031. pre-#1822 — we accept the wrong value rather than guess."""
  6032. mqtt_client._captured_ams_mapping = None
  6033. mqtt_client._process_message(_ams_payload(0))
  6034. assert mqtt_client.state.tray_now == 0
  6035. def test_empty_captured_mapping_does_not_override(self, mqtt_client):
  6036. """Empty list (defensive — should not happen in practice but
  6037. all([]) returns True). Treat as no signal, not as all-external."""
  6038. mqtt_client._captured_ams_mapping = []
  6039. mqtt_client._process_message(_ams_payload(0))
  6040. assert mqtt_client.state.tray_now == 0
  6041. def test_unloaded_after_print_still_resolves_correctly(self, mqtt_client):
  6042. """When the firmware unloads (tray_now=255), the override is skipped
  6043. because the branch only fires for tray_now in 0-3."""
  6044. mqtt_client._captured_ams_mapping = [-1]
  6045. mqtt_client._process_message(_ams_payload(0))
  6046. assert mqtt_client.state.tray_now == 254
  6047. mqtt_client._process_message(_ams_payload(255))
  6048. assert mqtt_client.state.tray_now == 255
  6049. class TestNoLastLayerFinishPhotoTrigger:
  6050. """#2547: the layer_num→total_layer_num edge must NOT trigger a photo.
  6051. That edge is the moment the printer *starts* the final layer. On the H2C
  6052. capture that closed #2547 it arrived at 92% with `mc_remaining_time=2`,
  6053. three minutes and one filament change before the print actually ended, so
  6054. the photo showed the toolhead mid-print over the part. It also latched
  6055. `_finish_photo_captured`, which locked out the two triggers that fire at a
  6056. real end-of-print — so these tests pin both halves: the edge is silent, and
  6057. the later triggers still work after it has passed.
  6058. #1867 (End G-code ejecting the plate before FINISH) is handled in
  6059. `on_finish_photo_moment` via `print_dispatch_context`, not here.
  6060. """
  6061. @pytest.fixture
  6062. def mqtt_client(self):
  6063. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6064. client = BambuMQTTClient(
  6065. ip_address="192.168.1.100",
  6066. serial_number="TEST123",
  6067. access_code="12345678",
  6068. )
  6069. client._was_running = True
  6070. client.state.total_layers = 100
  6071. client.state.layer_num = 99
  6072. return client
  6073. def test_reaching_the_last_layer_fires_nothing(self, mqtt_client):
  6074. events = []
  6075. mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
  6076. mqtt_client._process_message({"print": {"layer_num": 100}})
  6077. assert events == []
  6078. assert mqtt_client._finish_photo_captured is False
  6079. def test_stage_22_still_fires_after_the_last_layer_started(self, mqtt_client):
  6080. """The regression the removed trigger caused: stage 22 is the good
  6081. moment on firmware that emits it, and it arrives *after* the last-layer
  6082. edge. The old latch swallowed it."""
  6083. events = []
  6084. mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
  6085. mqtt_client._process_message({"print": {"layer_num": 100}})
  6086. mqtt_client.state.progress = 100
  6087. mqtt_client._process_message({"print": {"stg_cur": 22}})
  6088. assert [e["trigger"] for e in events] == ["stage_22"]
  6089. def test_finish_state_still_fires_after_the_last_layer_started(self, mqtt_client):
  6090. """H2C/A1 Mini never emit stage 22, so FINISH is their only moment —
  6091. and it is now reachable, where the latch used to block it."""
  6092. events = []
  6093. completion_events = []
  6094. mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
  6095. mqtt_client.on_print_complete = lambda data: completion_events.append(data)
  6096. mqtt_client._previous_gcode_state = "RUNNING"
  6097. mqtt_client._process_message({"print": {"layer_num": 100}})
  6098. mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
  6099. assert [e["trigger"] for e in events] == ["finish_state"]
  6100. assert len(completion_events) == 1
  6101. def test_no_photo_trigger_fires_repeatedly_across_the_last_layer(self, mqtt_client):
  6102. """A three-minute last layer publishes many frames at layer_num ==
  6103. total. None of them may produce a moment."""
  6104. events = []
  6105. mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
  6106. for percent in (92, 93, 94, 95, 97, 98, 99):
  6107. mqtt_client._process_message({"print": {"layer_num": 100, "mc_percent": percent}})
  6108. assert events == []
  6109. class TestBillingProgressTracking:
  6110. """The latest positive MQTT progress is the fallback for partial billing."""
  6111. @pytest.fixture
  6112. def mqtt_client(self):
  6113. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6114. return BambuMQTTClient(
  6115. ip_address="192.168.1.100",
  6116. serial_number="TEST123",
  6117. access_code="12345678",
  6118. )
  6119. def test_current_frame_is_retained_and_zero_reset_does_not_overwrite_it(self, mqtt_client):
  6120. """Even the first positive frame must survive an immediate printer abort."""
  6121. mqtt_client._process_message({"print": {"mc_percent": 25}})
  6122. assert mqtt_client._last_valid_progress == 25
  6123. mqtt_client._process_message({"print": {"mc_percent": 0}})
  6124. assert mqtt_client._last_valid_progress == 25
  6125. class TestPrintProgressCallback:
  6126. """#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
  6127. Layer changes stop firing the instant the final layer begins, so the bank
  6128. would otherwise stay stale for the whole length of that layer. Progress is
  6129. the field that keeps advancing there — and it freezes before the End G-code
  6130. runs, which is what keeps a swapped plate out of the bank (#1867).
  6131. """
  6132. @pytest.fixture
  6133. def mqtt_client(self):
  6134. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6135. client = BambuMQTTClient(
  6136. ip_address="192.168.1.100",
  6137. serial_number="TEST123",
  6138. access_code="12345678",
  6139. )
  6140. client._was_running = True
  6141. return client
  6142. def test_fires_on_each_advance(self, mqtt_client):
  6143. seen = []
  6144. mqtt_client.on_print_progress = seen.append
  6145. for percent in (92, 93, 94):
  6146. mqtt_client._process_message({"print": {"mc_percent": percent}})
  6147. assert seen == [92, 93, 94]
  6148. def test_does_not_fire_when_progress_is_unchanged(self, mqtt_client):
  6149. """Most frames repeat the same percent; each one would otherwise cost a
  6150. camera grab that contends with the live view."""
  6151. seen = []
  6152. mqtt_client.on_print_progress = seen.append
  6153. mqtt_client._process_message({"print": {"mc_percent": 92}})
  6154. mqtt_client._process_message({"print": {"mc_percent": 92}})
  6155. mqtt_client._process_message({"print": {"mc_percent": 92}})
  6156. assert seen == [92]
  6157. def test_does_not_fire_when_progress_goes_backwards(self, mqtt_client):
  6158. """Firmware resets progress to 0 on cancel — that is not the print
  6159. advancing, and banking a frame there would be banking a cancelled bed."""
  6160. seen = []
  6161. mqtt_client.on_print_progress = seen.append
  6162. mqtt_client._process_message({"print": {"mc_percent": 92}})
  6163. mqtt_client._process_message({"print": {"mc_percent": 0}})
  6164. assert seen == [92]
  6165. def test_does_not_fire_when_no_print_is_running(self, mqtt_client):
  6166. mqtt_client._was_running = False
  6167. seen = []
  6168. mqtt_client.on_print_progress = seen.append
  6169. mqtt_client._process_message({"print": {"mc_percent": 92}})
  6170. assert seen == []
  6171. def test_absent_callback_is_not_an_error(self, mqtt_client):
  6172. mqtt_client.on_print_progress = None
  6173. mqtt_client._process_message({"print": {"mc_percent": 92}})
  6174. assert mqtt_client.state.progress == 92
  6175. class TestPresumedPowerOffRecovery:
  6176. """#2629: a smart-plug turn-off marks the printer offline optimistically.
  6177. When the plug does not actually feed the printer, the printer keeps
  6178. publishing — and the forced 'unknown' state must be undone, or it sticks
  6179. until the next full pushall and the queue scheduler stalls forever.
  6180. """
  6181. @pytest.fixture
  6182. def mqtt_client(self):
  6183. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6184. client = BambuMQTTClient(
  6185. ip_address="192.168.1.100",
  6186. serial_number="TEST123",
  6187. access_code="12345678",
  6188. )
  6189. client.state.connected = True
  6190. client.state.state = "FINISH"
  6191. return client
  6192. @staticmethod
  6193. def _report(client, payload):
  6194. """Feed a report-topic message through the real _on_message path."""
  6195. class _Msg:
  6196. def __init__(self, topic, data):
  6197. self.topic = topic
  6198. self.payload = json.dumps(data).encode()
  6199. client._on_message(None, None, _Msg(client.topic_subscribe, payload))
  6200. def test_mark_power_off_blanks_state_and_remembers_it(self, mqtt_client):
  6201. assert mqtt_client.mark_power_off() is True
  6202. assert mqtt_client.state.connected is False
  6203. assert mqtt_client.state.state == "unknown"
  6204. assert mqtt_client._state_before_power_off == "FINISH"
  6205. def test_mark_power_off_noop_when_already_disconnected(self, mqtt_client):
  6206. mqtt_client.state.connected = False
  6207. assert mqtt_client.mark_power_off() is False
  6208. assert mqtt_client._state_before_power_off is None
  6209. def test_second_mark_does_not_overwrite_saved_state(self, mqtt_client):
  6210. mqtt_client.mark_power_off()
  6211. # Something flips connected back (a partial message) before the second mark
  6212. mqtt_client.state.connected = True
  6213. mqtt_client.mark_power_off()
  6214. assert mqtt_client._state_before_power_off == "FINISH"
  6215. def test_partial_report_restores_state(self, mqtt_client):
  6216. """The steady-state push_status carries no gcode_state — the pre-off
  6217. state must come back anyway, otherwise 'unknown' is permanent."""
  6218. mqtt_client.mark_power_off()
  6219. self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
  6220. assert mqtt_client.state.connected is True
  6221. assert mqtt_client.state.state == "FINISH"
  6222. assert mqtt_client._state_before_power_off is None
  6223. def test_restore_broadcasts_state_change(self, mqtt_client):
  6224. broadcasts = []
  6225. mqtt_client.on_state_change = lambda state: broadcasts.append(state.state)
  6226. mqtt_client.mark_power_off()
  6227. self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
  6228. assert "FINISH" in broadcasts
  6229. def test_fresh_gcode_state_wins_over_restored_state(self, mqtt_client):
  6230. """A report that does carry gcode_state is authoritative."""
  6231. mqtt_client.mark_power_off()
  6232. self._report(mqtt_client, {"print": {"gcode_state": "IDLE"}})
  6233. assert mqtt_client.state.state == "IDLE"
  6234. def test_restore_happens_only_once(self, mqtt_client):
  6235. """After recovery a later genuine blank must not be undone by a stale
  6236. saved state."""
  6237. mqtt_client.mark_power_off()
  6238. self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
  6239. # Printer really loses power now: state blanked, nothing to restore from
  6240. mqtt_client.state.state = "unknown"
  6241. assert mqtt_client._restore_state_after_false_power_off() is False
  6242. assert mqtt_client.state.state == "unknown"
  6243. def test_request_topic_traffic_does_not_restore(self, mqtt_client):
  6244. """Only the printer's own report topic proves it is alive; the request
  6245. topic also carries slicer/Bambuddy commands."""
  6246. mqtt_client.mark_power_off()
  6247. class _Msg:
  6248. topic = mqtt_client.topic_publish
  6249. payload = json.dumps({"print": {"command": "project_file"}}).encode()
  6250. mqtt_client._on_message(None, None, _Msg())
  6251. assert mqtt_client.state.state == "unknown"
  6252. assert mqtt_client._state_before_power_off == "FINISH"
  6253. def test_reconnect_discards_saved_state(self, mqtt_client):
  6254. """A real power cut drops the MQTT session; on reconnect the saved state
  6255. is stale and must not be broadcast ahead of the printer's first report."""
  6256. from unittest.mock import MagicMock
  6257. mqtt_client.mark_power_off()
  6258. paho = MagicMock()
  6259. paho.subscribe.return_value = (0, 1) # (MQTT_ERR_SUCCESS, mid)
  6260. mqtt_client._on_connect(paho, None, {}, 0)
  6261. assert mqtt_client._state_before_power_off is None
  6262. self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
  6263. assert mqtt_client.state.state == "unknown"
  6264. def test_already_unknown_state_is_not_saved(self, mqtt_client):
  6265. """A printer that never reported has nothing to restore — saving
  6266. 'unknown' would make the recovery broadcast a no-op state change."""
  6267. mqtt_client.state.state = "unknown"
  6268. assert mqtt_client.mark_power_off() is True
  6269. assert mqtt_client._state_before_power_off is None
  6270. def test_message_interleaved_with_mark_does_not_strand_unknown(self, mqtt_client):
  6271. """mark_power_off runs on the event loop, _on_message on the paho
  6272. thread. A message landing mid-mark must not consume the saved state and
  6273. leave the printer stuck on 'unknown' — the next message must recover."""
  6274. # Simulate the worst interleaving: a report is processed after the state
  6275. # was blanked but before the previous state was recorded.
  6276. mqtt_client.state.connected = False
  6277. mqtt_client.state.state = "unknown"
  6278. self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
  6279. # ...now the rest of the mark completes.
  6280. mqtt_client._state_before_power_off = "FINISH"
  6281. self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
  6282. assert mqtt_client.state.state == "FINISH"
  6283. class TestKProfileResponseDoesNotClobberNozzle:
  6284. """#2663: the K-profile fetch (get_kprofiles) probes every nozzle size
  6285. 0.2/0.4/0.6/0.8 with an ``extrusion_cali_get`` request. Each response
  6286. echoes the *requested* nozzle_diameter at the top level, which is NOT the
  6287. installed hardware. _process_message must not feed those responses to
  6288. _update_state, or the real nozzle size gets overwritten (typically to 0.8,
  6289. the last size probed) and the #1899 dispatch guard then blocks prints.
  6290. """
  6291. @pytest.fixture
  6292. def mqtt_client(self):
  6293. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6294. return BambuMQTTClient(
  6295. ip_address="192.168.1.100",
  6296. serial_number="A1TEST",
  6297. access_code="12345678",
  6298. )
  6299. def test_kprofile_response_does_not_overwrite_nozzle_diameter(self, mqtt_client):
  6300. # A genuine pushall reports the real 0.4mm nozzle.
  6301. mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
  6302. assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
  6303. # The K-profile probe's 0.8mm response arrives (as it did on the
  6304. # reporter's A1s). It must NOT clobber the hardware nozzle.
  6305. mqtt_client._process_message(
  6306. {
  6307. "print": {
  6308. "command": "extrusion_cali_get",
  6309. "nozzle_diameter": "0.8",
  6310. "filaments": [],
  6311. "sequence_id": "1501",
  6312. }
  6313. }
  6314. )
  6315. assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
  6316. def test_kprofile_response_is_still_parsed(self, mqtt_client):
  6317. # Skipping _update_state must not skip the K-profile handler: the
  6318. # response's profiles still populate state.kprofiles.
  6319. mqtt_client._process_message(
  6320. {
  6321. "print": {
  6322. "command": "extrusion_cali_get",
  6323. "nozzle_diameter": "0.4",
  6324. "filaments": [
  6325. {
  6326. "cali_idx": 0,
  6327. "nozzle_diameter": "0.4",
  6328. "filament_id": "GFA00",
  6329. "name": "PLA",
  6330. "k_value": "0.020000",
  6331. }
  6332. ],
  6333. }
  6334. }
  6335. )
  6336. assert len(mqtt_client.state.kprofiles) == 1
  6337. assert mqtt_client.state.kprofiles[0].filament_id == "GFA00"
  6338. def test_genuine_pushall_still_updates_nozzle(self, mqtt_client):
  6339. # The one legitimate source of the hardware nozzle still works, and a
  6340. # later pushall corrects a value an old build left wrong.
  6341. mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
  6342. mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
  6343. assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
  6344. class TestKProfileNozzleDiameterFromEnvelope:
  6345. """#1748: every K-profile came back as 0.4mm on single-nozzle printers.
  6346. ``extrusion_cali_get`` carries ``nozzle_diameter`` only on the response
  6347. envelope — the per-filament entries hold just setting_id, filament_id,
  6348. name, k_value, n_coef and cali_idx. The parser read the field per entry
  6349. with a "0.4" default, so a 0.6/0.8 nozzle's profiles were all stamped 0.4.
  6350. Beyond the K-Profiles display that broke the cali_idx cascade in the
  6351. inventory and Spoolman assign paths, which match on nozzle_diameter.
  6352. """
  6353. @pytest.fixture
  6354. def mqtt_client(self):
  6355. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6356. return BambuMQTTClient(
  6357. ip_address="192.168.1.100",
  6358. serial_number="X1ETEST",
  6359. access_code="12345678",
  6360. )
  6361. @staticmethod
  6362. def _response(nozzle="0.8", entries=None, seq="48"):
  6363. """A verbatim-shaped extrusion_cali_get payload from the #1748 report."""
  6364. if entries is None:
  6365. entries = [
  6366. {
  6367. "setting_id": "GFSNLS02_07",
  6368. "filament_id": "GFSNL02",
  6369. "name": "SUNLU PLA Matte WHITE 0.8",
  6370. "k_value": "0.01750",
  6371. "n_coef": "1.000",
  6372. "cali_idx": 265,
  6373. "is_history_setting": True,
  6374. }
  6375. ]
  6376. print_data = {"command": "extrusion_cali_get", "filament_id": "", "filaments": entries}
  6377. if nozzle is not None:
  6378. print_data["nozzle_diameter"] = nozzle
  6379. if seq is not None:
  6380. print_data["sequence_id"] = seq
  6381. return {"print": print_data}
  6382. def test_broadcast_uses_envelope_diameter(self, mqtt_client):
  6383. # No request in flight: the unsolicited broadcast still has to record
  6384. # the right diameter, because state.kprofiles is what the assign paths
  6385. # read when nobody has just fetched.
  6386. mqtt_client._process_message(self._response(nozzle="0.8"))
  6387. assert [p.nozzle_diameter for p in mqtt_client.state.kprofiles] == ["0.8"]
  6388. @pytest.mark.asyncio
  6389. async def test_awaited_response_uses_envelope_diameter(self, mqtt_client):
  6390. profiles = await self._fetch(mqtt_client, "0.6", self._response(nozzle="0.6", seq="7"))
  6391. assert [p.nozzle_diameter for p in profiles] == ["0.6"]
  6392. def test_entry_value_still_wins(self, mqtt_client):
  6393. # Dual-nozzle firmware does put the field on each entry; that stays
  6394. # authoritative, since a batch can legitimately span nozzles.
  6395. entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": "0.4"}]
  6396. mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
  6397. assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
  6398. def test_empty_entry_value_falls_back_to_envelope(self, mqtt_client):
  6399. entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": ""}]
  6400. mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
  6401. assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.8"
  6402. def test_no_envelope_value_falls_back_to_default(self, mqtt_client):
  6403. # Neither source available: keep the old default rather than let
  6404. # str(None) write the literal string "None" into the profile.
  6405. mqtt_client._process_message(self._response(nozzle=None))
  6406. assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
  6407. @staticmethod
  6408. async def _fetch(client, nozzle, response):
  6409. """Run get_kprofiles, feeding `response` in as the printer's answer."""
  6410. client.state.connected = True
  6411. client._client = MagicMock()
  6412. client._client.publish.side_effect = lambda *a, **kw: client._process_message(response)
  6413. return await client.get_kprofiles(nozzle_diameter=nozzle, timeout=2.0)
  6414. class TestKProfileWriteAcks:
  6415. """#2718: K-profile writes were fire-and-forget.
  6416. ``set_kprofiles_batch`` published and returned True immediately, and the
  6417. printer's ``extrusion_cali_set`` answer was logged at DEBUG and dropped, so
  6418. a rejected write was reported to the user as saved. Two facts measured on
  6419. real hardware shape the fix: the printer echoes our ``sequence_id`` back
  6420. (so the ack can be correlated), and it answers ``result: "fail",
  6421. reason: "invalid tray_id"`` to ``tray_id: -1`` on single-nozzle firmware
  6422. while applying the write anyway — flipping that field to 0 is what makes
  6423. ``result`` trustworthy.
  6424. """
  6425. @pytest.fixture
  6426. def mqtt_client(self):
  6427. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6428. client = BambuMQTTClient(
  6429. ip_address="192.168.1.100",
  6430. serial_number="X1CTEST",
  6431. access_code="12345678",
  6432. )
  6433. client.state.connected = True
  6434. client._client = MagicMock()
  6435. return client
  6436. @staticmethod
  6437. def _sent(client):
  6438. return json.loads(client._client.publish.call_args[0][1])["print"]
  6439. def test_set_sends_tray_id_zero(self, mqtt_client):
  6440. mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
  6441. assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
  6442. def test_batch_sends_tray_id_zero(self, mqtt_client):
  6443. mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99", "name": "t", "k_value": "0.020000"}])
  6444. assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
  6445. def test_writers_return_their_sequence_id(self, mqtt_client):
  6446. seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
  6447. assert seq == self._sent(mqtt_client)["sequence_id"]
  6448. assert seq in mqtt_client._pending_cali_acks
  6449. def test_writers_return_none_when_disconnected(self, mqtt_client):
  6450. mqtt_client.state.connected = False
  6451. assert mqtt_client.set_kprofile(filament_id="GFL99", name="t", k_value="0.02") is None
  6452. assert mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99"}]) is None
  6453. assert mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4") is None
  6454. def test_per_tray_extrusion_cali_set_advances_the_sequence_id(self, mqtt_client):
  6455. # It used to reuse the previous command's id, which would silently
  6456. # defeat the correlation the write path now depends on.
  6457. before = mqtt_client._sequence_id
  6458. mqtt_client.extrusion_cali_set(tray_id=0, k_value=0.02)
  6459. assert mqtt_client._sequence_id > before
  6460. assert self._sent(mqtt_client)["sequence_id"] == str(mqtt_client._sequence_id)
  6461. @pytest.mark.asyncio
  6462. async def test_failure_ack_is_reported_as_failure(self, mqtt_client):
  6463. seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
  6464. mqtt_client._process_message(
  6465. {
  6466. "print": {
  6467. "command": "extrusion_cali_set",
  6468. "result": "fail",
  6469. "reason": "invalid tray_id",
  6470. "sequence_id": seq,
  6471. }
  6472. }
  6473. )
  6474. ok, detail = await mqtt_client.await_cali_ack(seq, timeout=2.0)
  6475. assert ok is False
  6476. assert detail == "invalid tray_id"
  6477. @pytest.mark.asyncio
  6478. async def test_success_ack_passes(self, mqtt_client):
  6479. seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
  6480. mqtt_client._process_message(
  6481. {"print": {"command": "extrusion_cali_set", "result": "success", "reason": "", "sequence_id": seq}}
  6482. )
  6483. ok, _ = await mqtt_client.await_cali_ack(seq, timeout=2.0)
  6484. assert ok is True
  6485. @pytest.mark.asyncio
  6486. async def test_ack_for_another_write_does_not_resolve_this_one(self, mqtt_client):
  6487. seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
  6488. mqtt_client._process_message(
  6489. {
  6490. "print": {
  6491. "command": "extrusion_cali_set",
  6492. "result": "fail",
  6493. "reason": "invalid tray_id",
  6494. "sequence_id": "999999",
  6495. }
  6496. }
  6497. )
  6498. # Unrelated sequence_id: this write is still unanswered, so it times
  6499. # out rather than inheriting someone else's failure.
  6500. ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
  6501. assert ok is True
  6502. assert "no acknowledgement" in detail
  6503. @pytest.mark.asyncio
  6504. async def test_silence_is_not_treated_as_rejection(self, mqtt_client):
  6505. # Firmware that never answers must not turn every save into an error.
  6506. seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
  6507. ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
  6508. assert ok is True
  6509. assert "no acknowledgement" in detail
  6510. @pytest.mark.asyncio
  6511. async def test_pending_slot_is_released(self, mqtt_client):
  6512. seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
  6513. await mqtt_client.await_cali_ack(seq, timeout=0.3)
  6514. assert mqtt_client._pending_cali_acks == {}
  6515. def test_delete_ack_is_matched_too(self, mqtt_client):
  6516. seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
  6517. mqtt_client._process_message(
  6518. {"print": {"command": "extrusion_cali_del", "result": "success", "sequence_id": seq}}
  6519. )
  6520. assert mqtt_client._pending_cali_acks[seq]["result"] == "success"
  6521. class TestKProfileRequestCorrelation:
  6522. """#1748: K-profile requests timed out whenever two were in flight.
  6523. Responses were matched to requests by nozzle diameter alone, held in one
  6524. shared ``_expected_kprofile_nozzle`` slot. A second request overwrote the
  6525. first's expectation, so the first's valid answer was discarded as a
  6526. mismatch and that request timed out even though the printer had replied.
  6527. Correlation now runs off the sequence_id we send, with the nozzle match
  6528. kept as a fallback for firmware that doesn't echo it.
  6529. """
  6530. @pytest.fixture
  6531. def mqtt_client(self):
  6532. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6533. client = BambuMQTTClient(
  6534. ip_address="192.168.1.100",
  6535. serial_number="X1ETEST",
  6536. access_code="12345678",
  6537. )
  6538. client.state.connected = True
  6539. client._client = MagicMock()
  6540. return client
  6541. @staticmethod
  6542. def _response(nozzle, seq, name):
  6543. return {
  6544. "print": {
  6545. "command": "extrusion_cali_get",
  6546. "nozzle_diameter": nozzle,
  6547. "sequence_id": seq,
  6548. "filaments": [{"cali_idx": 1, "filament_id": "GFA00", "name": name, "k_value": "0.020000"}],
  6549. }
  6550. }
  6551. @pytest.mark.asyncio
  6552. async def test_concurrent_requests_each_get_their_own_response(self, mqtt_client):
  6553. # The failing sequence from the report: 0.8 is requested, then 0.4,
  6554. # then the 0.8 answer lands. Under nozzle-only matching the expected
  6555. # slot already said 0.4, so the 0.8 answer was dropped on the floor.
  6556. seen: list[str] = []
  6557. def publish(_topic, payload, **_kw):
  6558. seen.append(json.loads(payload)["print"]["sequence_id"])
  6559. mqtt_client._client.publish.side_effect = publish
  6560. big = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=5.0))
  6561. small = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.4", timeout=5.0))
  6562. await asyncio.sleep(0) # let both publish before either answer arrives
  6563. assert len(seen) == 2
  6564. mqtt_client._process_message(self._response("0.8", seen[0], "wide"))
  6565. mqtt_client._process_message(self._response("0.4", seen[1], "narrow"))
  6566. assert [p.name for p in await big] == ["wide"]
  6567. assert [p.name for p in await small] == ["narrow"]
  6568. @pytest.mark.asyncio
  6569. async def test_falls_back_to_nozzle_match_when_sequence_id_is_not_echoed(self, mqtt_client):
  6570. # Firmware that answers with its own sequence_id must keep working.
  6571. mqtt_client._client.publish.side_effect = lambda *a, **kw: mqtt_client._process_message(
  6572. self._response("0.6", "9999", "echoed-nothing")
  6573. )
  6574. profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.6", timeout=2.0)
  6575. assert [p.name for p in profiles] == ["echoed-nothing"]
  6576. @pytest.mark.asyncio
  6577. async def test_unrelated_broadcast_does_not_clobber_a_pending_fetch(self, mqtt_client):
  6578. # The printer broadcasts 0.4 profiles unsolicited. One arriving while a
  6579. # 0.8 fetch is open must neither satisfy nor overwrite it.
  6580. def publish(_topic, payload, **_kw):
  6581. seq = json.loads(payload)["print"]["sequence_id"]
  6582. mqtt_client._process_message(self._response("0.4", "9999", "broadcast"))
  6583. mqtt_client._process_message(self._response("0.8", seq, "wanted"))
  6584. mqtt_client._client.publish.side_effect = publish
  6585. profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=2.0)
  6586. assert [p.name for p in profiles] == ["wanted"]
  6587. assert [p.name for p in mqtt_client.state.kprofiles] == ["wanted"]
  6588. @pytest.mark.asyncio
  6589. async def test_pending_entry_is_released_on_timeout(self, mqtt_client):
  6590. # A timed-out attempt must not leave its entry behind, or a later
  6591. # broadcast would be matched to a request nobody is waiting on.
  6592. profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=0.01, max_retries=1)
  6593. assert profiles == []
  6594. assert mqtt_client._pending_kprofile_requests == {}
  6595. class TestConnectRefusalReporting:
  6596. """#2698: a refused CONNACK must leave a trace.
  6597. ``_on_connect``'s failure branch used to be a bare ``connected = False``.
  6598. A printer refusing our access code then looked exactly like one that was
  6599. powered off: paho reports the follow-up drop as the generic "Unspecified
  6600. error", so the support bundle from a 30-second reconnect loop carried no
  6601. hint of the real cause. Bambu speaks MQTT 3.1.1, whose CONNACK return codes
  6602. 4 and 5 paho maps to reason codes 134 / 135.
  6603. """
  6604. @pytest.fixture
  6605. def mqtt_client(self):
  6606. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6607. return BambuMQTTClient(
  6608. ip_address="192.168.1.100",
  6609. serial_number="TEST123",
  6610. access_code="12345678",
  6611. )
  6612. @staticmethod
  6613. def _connack(v3_return_code):
  6614. from paho.mqtt.client import convert_connack_rc_to_reason_code
  6615. return convert_connack_rc_to_reason_code(v3_return_code)
  6616. def test_no_error_recorded_before_any_attempt(self, mqtt_client):
  6617. assert mqtt_client.last_connect_error is None
  6618. assert mqtt_client.last_connect_error_name is None
  6619. @pytest.mark.parametrize("v3_rc", [4, 5])
  6620. def test_credential_refusal_recorded(self, mqtt_client, v3_rc, caplog):
  6621. with caplog.at_level(logging.WARNING):
  6622. mqtt_client._on_connect(None, None, None, self._connack(v3_rc))
  6623. assert mqtt_client.state.connected is False
  6624. assert mqtt_client.last_connect_error == "auth_rejected"
  6625. assert "refused" in caplog.text.lower()
  6626. # The remedy has to be in the log — that line is what a maintainer
  6627. # reads out of a support bundle.
  6628. assert "access code" in caplog.text.lower()
  6629. # Never leak the credential itself into a bundle.
  6630. assert "12345678" not in caplog.text
  6631. def test_non_credential_refusal_recorded_separately(self, mqtt_client, caplog):
  6632. # CONNACK 3 = server unavailable: a real refusal, but not about creds.
  6633. with caplog.at_level(logging.WARNING):
  6634. mqtt_client._on_connect(None, None, None, self._connack(3))
  6635. assert mqtt_client.last_connect_error == "refused"
  6636. assert "access code" not in caplog.text.lower()
  6637. def test_successful_connect_clears_previous_error(self, mqtt_client):
  6638. mqtt_client._on_connect(None, None, None, self._connack(5))
  6639. assert mqtt_client.last_connect_error == "auth_rejected"
  6640. mock_client = type("MockClient", (), {"subscribe": lambda self, topic: (0, 1)})()
  6641. mqtt_client._on_connect(mock_client, None, None, 0)
  6642. assert mqtt_client.state.connected is True
  6643. assert mqtt_client.last_connect_error is None
  6644. assert mqtt_client.last_connect_error_name is None
  6645. def test_disconnect_line_carries_the_refusal(self, mqtt_client, caplog):
  6646. """The reconnect loop is what fills the log, so it must say why."""
  6647. mqtt_client._on_connect(None, None, None, self._connack(5))
  6648. caplog.clear()
  6649. with caplog.at_level(logging.WARNING):
  6650. mqtt_client._on_disconnect(None, None)
  6651. assert "MQTT disconnected" in caplog.text
  6652. assert "refused" in caplog.text
  6653. assert "Not authorized" in caplog.text
  6654. def test_disconnect_line_unchanged_without_a_refusal(self, mqtt_client, caplog):
  6655. with caplog.at_level(logging.WARNING):
  6656. mqtt_client._on_disconnect(None, None)
  6657. assert "MQTT disconnected" in caplog.text
  6658. assert "refused" not in caplog.text
  6659. class TestEndOfPrintProbe:
  6660. """Tests for #2547: the end-of-print telemetry probe.
  6661. The probe exists to answer a question no existing support bundle can:
  6662. what do the stage/action fields do between the last object layer and
  6663. gcode_state=FINISH? stg_cur=22 was supposed to mark "toolhead parked,
  6664. before filament unload" (#1721) and fires on no model in the field, and
  6665. Bambuddy drops every other stage field unread. These tests pin the
  6666. window's boundaries and the guarantee that instrumentation stays
  6667. instrumentation — it must never raise into the ingest path.
  6668. """
  6669. LOGGER = "backend.app.services.bambu_mqtt"
  6670. @pytest.fixture
  6671. def mqtt_client(self):
  6672. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6673. client = BambuMQTTClient(
  6674. ip_address="192.168.1.100",
  6675. serial_number="TEST123",
  6676. access_code="12345678",
  6677. )
  6678. client._was_running = True
  6679. client.state.state = "RUNNING"
  6680. client.state.total_layers = 100
  6681. client.state.layer_num = 98
  6682. client.state.progress = 90.0
  6683. client.state.remaining_time = 12
  6684. return client
  6685. def test_silent_when_debug_logging_is_off(self, mqtt_client, caplog):
  6686. """The probe is a debug tool; at INFO it must cost nothing and say
  6687. nothing, including for a frame that would otherwise open the window."""
  6688. with caplog.at_level(logging.INFO, logger=self.LOGGER):
  6689. mqtt_client._process_message({"print": {"layer_num": 100}})
  6690. assert "EOP-PROBE" not in caplog.text
  6691. assert mqtt_client._eop_probe_open is False
  6692. def test_does_not_open_mid_print(self, mqtt_client, caplog):
  6693. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6694. mqtt_client._process_message({"print": {"layer_num": 99, "mc_percent": 91}})
  6695. assert "EOP-PROBE" not in caplog.text
  6696. assert mqtt_client._eop_probe_open is False
  6697. def test_opens_on_the_last_layer_frame_itself(self, mqtt_client, caplog):
  6698. """The frame carrying the signal must be captured, not just the ones
  6699. after it — so the probe has to read the raw frame rather than state,
  6700. which _update_state only updates further down the same call."""
  6701. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6702. mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
  6703. assert "EOP-PROBE open" in caplog.text
  6704. assert "'layer_num': 100" in caplog.text
  6705. assert mqtt_client._eop_probe_open is True
  6706. def test_opens_on_progress_when_the_last_layer_packet_is_missed(self, mqtt_client, caplog):
  6707. """The layer_num edge is a single transient packet and is dropped
  6708. intermittently (the reason #1867 needed a second mechanism). Progress
  6709. has to be able to open the window on its own."""
  6710. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6711. mqtt_client._process_message({"print": {"mc_percent": 99}})
  6712. assert "EOP-PROBE open" in caplog.text
  6713. assert mqtt_client._eop_probe_open is True
  6714. def test_zero_remaining_does_not_open_before_the_print_progresses(self, mqtt_client, caplog):
  6715. """mc_remaining_time reads 0 during pre-print calibration too, so it
  6716. only counts once progress is non-zero."""
  6717. mqtt_client.state.progress = 0.0
  6718. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6719. mqtt_client._process_message({"print": {"mc_remaining_time": 0, "mc_percent": 0}})
  6720. assert "EOP-PROBE" not in caplog.text
  6721. def test_does_not_open_when_the_print_never_ran(self, mqtt_client, caplog):
  6722. """Bambuddy restarted mid-print, or firmware replayed a stale frame."""
  6723. mqtt_client._was_running = False
  6724. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6725. mqtt_client._process_message({"print": {"layer_num": 100}})
  6726. assert "EOP-PROBE" not in caplog.text
  6727. def test_logs_only_changed_fields_after_opening(self, mqtt_client, caplog):
  6728. """Most probed fields are static across the window; logging all of
  6729. them every frame would bury the transitions we're looking for."""
  6730. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6731. mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
  6732. caplog.clear()
  6733. # Identical frame — nothing moved, so nothing to say.
  6734. mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
  6735. assert "EOP-PROBE" not in caplog.text
  6736. mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 22}})
  6737. assert "'stg_cur': 22" in caplog.text
  6738. assert "layer_num" not in caplog.text.split("EOP-PROBE")[-1]
  6739. def test_captures_the_fields_bambuddy_does_not_parse(self, mqtt_client, caplog):
  6740. """The whole point: mc_stage / mc_action / print_real_action are read
  6741. by nothing else in the codebase, so only the probe can show them."""
  6742. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6743. mqtt_client._process_message({"print": {"layer_num": 100}})
  6744. mqtt_client._process_message(
  6745. {
  6746. "print": {
  6747. "mc_stage": 3,
  6748. "mc_action": 8,
  6749. "print_real_action": 2,
  6750. "print_gcode_action": 5,
  6751. "stg_cd": 1,
  6752. "home_flag": 2231371,
  6753. "spd_lvl": 0,
  6754. }
  6755. }
  6756. )
  6757. for field in ("mc_stage", "mc_action", "print_real_action", "print_gcode_action", "stg_cd", "spd_lvl"):
  6758. assert field in caplog.text
  6759. def test_closes_on_finish_and_does_not_reopen(self, mqtt_client, caplog):
  6760. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6761. mqtt_client._process_message({"print": {"layer_num": 100}})
  6762. mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
  6763. assert "EOP-PROBE 2 CLOSE" in caplog.text
  6764. assert mqtt_client._eop_probe_open is False
  6765. assert mqtt_client._eop_probe_armed is False
  6766. caplog.clear()
  6767. # Firmware re-sending FINISH, or a stale replay, must not restart it.
  6768. mqtt_client._process_message({"print": {"layer_num": 100, "mc_percent": 100}})
  6769. assert "EOP-PROBE" not in caplog.text
  6770. def test_closing_frame_is_self_contained_when_nothing_changed(self, mqtt_client, caplog):
  6771. """A FINISH frame that repeats values already seen still has to log
  6772. something — otherwise the window has no visible end."""
  6773. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6774. mqtt_client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 100}})
  6775. caplog.clear()
  6776. mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
  6777. # Same value the probe already recorded on the opening frame.
  6778. mqtt_client._eop_probe_open = True
  6779. mqtt_client._eop_probe_armed = True
  6780. mqtt_client._eop_probe_last = {"gcode_state": "FINISH"}
  6781. mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
  6782. assert "CLOSE" in caplog.text
  6783. assert "'gcode_state': 'FINISH'" in caplog.text
  6784. def test_rearms_for_the_next_print(self, mqtt_client, caplog):
  6785. # A completion callback is what lets _update_state finish the print
  6786. # (and clear _was_running), which the new-print detection depends on.
  6787. mqtt_client.on_print_complete = lambda data: None
  6788. mqtt_client.state.gcode_file = "current.3mf"
  6789. mqtt_client._previous_gcode_state = "RUNNING"
  6790. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6791. mqtt_client._process_message({"print": {"layer_num": 100}})
  6792. mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
  6793. assert mqtt_client._eop_probe_armed is False
  6794. # New print: RUNNING again with a file, after the previous print
  6795. # completed. _update_state rearms the probe alongside the
  6796. # finish-photo one-shot.
  6797. mqtt_client._process_message(
  6798. {"print": {"gcode_state": "RUNNING", "gcode_file": "next.3mf", "subtask_name": "next"}}
  6799. )
  6800. assert mqtt_client._eop_probe_armed is True
  6801. caplog.clear()
  6802. mqtt_client.state.total_layers = 50
  6803. mqtt_client._process_message({"print": {"layer_num": 50}})
  6804. assert "EOP-PROBE open" in caplog.text
  6805. def test_frame_budget_caps_output_but_still_logs_the_close(self, mqtt_client, caplog):
  6806. """A long final layer holds the window open at ~1 frame/second; the
  6807. user still has to be able to upload the resulting log."""
  6808. from backend.app.services.bambu_mqtt import _END_OF_PRINT_PROBE_MAX_FRAMES
  6809. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6810. mqtt_client._process_message({"print": {"layer_num": 100}})
  6811. for i in range(_END_OF_PRINT_PROBE_MAX_FRAMES + 50):
  6812. mqtt_client._process_message({"print": {"mc_remaining_time": i}})
  6813. assert "frame budget" in caplog.text
  6814. caplog.clear()
  6815. mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
  6816. assert "CLOSE" in caplog.text
  6817. def test_opens_on_numeric_strings(self, mqtt_client, caplog):
  6818. """Firmware sends these as ints or as numeric strings depending on
  6819. model and field, so the window checks must coerce rather than compare
  6820. a str against an int and silently never open."""
  6821. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6822. mqtt_client._process_message({"print": {"layer_num": "100", "mc_percent": "99"}})
  6823. assert "EOP-PROBE open" in caplog.text
  6824. assert mqtt_client._eop_probe_open is True
  6825. def test_coercion_helper_falls_back_on_junk(self, mqtt_client):
  6826. """Unit-level, because feeding junk through _process_message would trip
  6827. the pre-existing parsers before ever reaching the probe. The guarantee
  6828. under test is only that the probe's own reads can't raise."""
  6829. assert mqtt_client._probe_number("100") == 100.0
  6830. assert mqtt_client._probe_number("not-a-number", 7) == 7
  6831. assert mqtt_client._probe_number(None) is None
  6832. assert mqtt_client._probe_number({"unexpected": "shape"}, 0) == 0
  6833. def test_probe_failure_cannot_break_ingest(self, mqtt_client, caplog, monkeypatch):
  6834. """Instrumentation must stay instrumentation: if the probe ever throws,
  6835. state parsing still has to complete."""
  6836. def boom(_data):
  6837. raise RuntimeError("probe exploded")
  6838. monkeypatch.setattr(mqtt_client, "_probe_end_of_print", boom)
  6839. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6840. mqtt_client._process_message({"print": {"gcode_state": "RUNNING", "layer_num": 100}})
  6841. assert mqtt_client.state.layer_num == 100
  6842. assert "EOP-PROBE failed" in caplog.text
  6843. def test_never_logs_the_access_code(self, mqtt_client, caplog):
  6844. with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
  6845. mqtt_client._process_message({"print": {"layer_num": 100}})
  6846. mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
  6847. probe_lines = [line for line in caplog.text.splitlines() if "EOP-PROBE" in line]
  6848. assert probe_lines
  6849. assert not any("12345678" in line for line in probe_lines)
  6850. class TestAmsFilamentSettingRefusalLogging:
  6851. """A refused `ams_filament_setting` reaches the log at INFO (#2756).
  6852. The reporter configured a slot on an X1C six times. Every request returned
  6853. HTTP 200, every publish carried the complete `GFG99`/`GFSG99` pair, and
  6854. every #2582 read-back showed the previous profile still in place — with no
  6855. record anywhere of what the printer answered, because the response sat at
  6856. DEBUG and support bundles are collected at INFO.
  6857. Only a non-success is promoted. This command is not rare — every spool
  6858. assignment and every K-profile re-apply sends one — so logging each ack
  6859. would bury the one line worth reading.
  6860. """
  6861. @pytest.fixture
  6862. def mqtt_client(self):
  6863. from backend.app.services.bambu_mqtt import BambuMQTTClient
  6864. return BambuMQTTClient(
  6865. ip_address="192.168.1.100",
  6866. serial_number="TEST123",
  6867. access_code="12345678",
  6868. )
  6869. def _refusals(self, caplog):
  6870. return [line for line in caplog.text.splitlines() if "ams_filament_setting refused" in line]
  6871. def test_refusal_is_logged_at_info_with_result_and_reason(self, mqtt_client, caplog):
  6872. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  6873. mqtt_client._process_message(
  6874. {
  6875. "print": {
  6876. "command": "ams_filament_setting",
  6877. "result": "fail",
  6878. "reason": "invalid tray_id",
  6879. "ams_id": 0,
  6880. "tray_id": 1,
  6881. "sequence_id": "0",
  6882. }
  6883. }
  6884. )
  6885. refusals = self._refusals(caplog)
  6886. assert len(refusals) == 1
  6887. # The reason is the whole point of the promotion — a bare "fail" would
  6888. # not have told the reporter anything the read-back hadn't already.
  6889. assert "result=fail" in refusals[0]
  6890. assert "invalid tray_id" in refusals[0]
  6891. assert "ams_id=0" in refusals[0]
  6892. assert "tray_id=1" in refusals[0]
  6893. def test_success_stays_quiet(self, mqtt_client, caplog):
  6894. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  6895. mqtt_client._process_message(
  6896. {"print": {"command": "ams_filament_setting", "result": "success", "sequence_id": "0"}}
  6897. )
  6898. assert self._refusals(caplog) == []
  6899. def test_response_without_a_result_field_stays_quiet(self, mqtt_client, caplog):
  6900. """Firmware that omits `result` tells us nothing — don't invent a refusal."""
  6901. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  6902. mqtt_client._process_message({"print": {"command": "ams_filament_setting", "sequence_id": "0"}})
  6903. assert self._refusals(caplog) == []
  6904. def test_developer_mode_probe_failure_is_not_reported_as_a_refusal(self, mqtt_client, caplog):
  6905. """The probe sends this command to the external slot *expecting* a
  6906. refusal on P1 firmware — that is a reading, not a fault, and promoting
  6907. it would put an alarming line in every P1 bundle on every reconnect."""
  6908. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  6909. mqtt_client._dev_mode_probe_seq = "7"
  6910. mqtt_client._process_message(
  6911. {
  6912. "print": {
  6913. "command": "ams_filament_setting",
  6914. "result": "failed",
  6915. "reason": "mqtt message verify failed",
  6916. "sequence_id": "7",
  6917. }
  6918. }
  6919. )
  6920. assert self._refusals(caplog) == []
  6921. def test_user_command_is_not_mistaken_for_the_probe(self, mqtt_client, caplog):
  6922. """User-initiated publishes hardcode sequence_id "0", so a refusal is
  6923. still reported while a probe is outstanding under a different seq."""
  6924. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  6925. mqtt_client._dev_mode_probe_seq = "7"
  6926. mqtt_client._process_message(
  6927. {
  6928. "print": {
  6929. "command": "ams_filament_setting",
  6930. "result": "fail",
  6931. "reason": "",
  6932. "sequence_id": "0",
  6933. }
  6934. }
  6935. )
  6936. assert len(self._refusals(caplog)) == 1
  6937. def test_extrusion_cali_sel_is_untouched(self, mqtt_client, caplog):
  6938. """The sibling in the same branch keeps its DEBUG-only handling; this
  6939. change is scoped to the write #2756 is about."""
  6940. caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
  6941. mqtt_client._process_message({"print": {"command": "extrusion_cali_sel", "result": "fail", "sequence_id": "0"}})
  6942. assert self._refusals(caplog) == []
  6943. assert "extrusion_cali_sel" not in caplog.text