test_bambu_mqtt.py 154 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016
  1. """
  2. Tests for the BambuMQTTClient service.
  3. These tests focus on timelapse tracking during prints.
  4. """
  5. import json
  6. import time
  7. import pytest
  8. class TestTimelapseTracking:
  9. """Tests for timelapse state tracking during prints."""
  10. @pytest.fixture
  11. def mqtt_client(self):
  12. """Create a BambuMQTTClient instance for testing."""
  13. from backend.app.services.bambu_mqtt import BambuMQTTClient
  14. client = BambuMQTTClient(
  15. ip_address="192.168.1.100",
  16. serial_number="TEST123",
  17. access_code="12345678",
  18. )
  19. return client
  20. def test_timelapse_flag_initializes_to_false(self, mqtt_client):
  21. """Verify _timelapse_during_print starts as False."""
  22. assert mqtt_client._timelapse_during_print is False
  23. def test_timelapse_flag_set_when_timelapse_active_during_running(self, mqtt_client):
  24. """Verify timelapse flag is set when timelapse is active while printing."""
  25. # Simulate print running
  26. mqtt_client._was_running = True
  27. mqtt_client.state.timelapse = False
  28. # Simulate xcam data showing timelapse is enabled
  29. xcam_data = {"timelapse": "enable"}
  30. mqtt_client._parse_xcam_data(xcam_data)
  31. assert mqtt_client.state.timelapse is True
  32. assert mqtt_client._timelapse_during_print is True
  33. def test_timelapse_flag_not_set_when_not_running(self, mqtt_client):
  34. """Verify timelapse flag is NOT set when printer not running."""
  35. # Printer is idle (not running)
  36. mqtt_client._was_running = False
  37. mqtt_client.state.timelapse = False
  38. # Timelapse is enabled but we're not printing
  39. xcam_data = {"timelapse": "enable"}
  40. mqtt_client._parse_xcam_data(xcam_data)
  41. assert mqtt_client.state.timelapse is True
  42. # Flag should NOT be set since we're not printing
  43. assert mqtt_client._timelapse_during_print is False
  44. def test_timelapse_flag_persists_after_timelapse_stops(self, mqtt_client):
  45. """Verify timelapse flag stays True even after recording stops."""
  46. # Simulate print running with timelapse
  47. mqtt_client._was_running = True
  48. # Enable timelapse during print
  49. xcam_data = {"timelapse": "enable"}
  50. mqtt_client._parse_xcam_data(xcam_data)
  51. assert mqtt_client._timelapse_during_print is True
  52. # Disable timelapse (recording stops at end of print)
  53. xcam_data = {"timelapse": "disable"}
  54. mqtt_client._parse_xcam_data(xcam_data)
  55. # Flag should still be True (persists until reset)
  56. assert mqtt_client.state.timelapse is False
  57. assert mqtt_client._timelapse_during_print is True
  58. def test_timelapse_flag_from_print_data(self, mqtt_client):
  59. """Verify timelapse flag is set from print data (not just xcam)."""
  60. # Simulate print running
  61. mqtt_client._was_running = True
  62. mqtt_client.state.timelapse = False
  63. mqtt_client._timelapse_during_print = False
  64. # Manually test the timelapse parsing logic from _parse_print_data
  65. # This tests the "timelapse" field in the main print data
  66. data = {"timelapse": True}
  67. mqtt_client.state.timelapse = data["timelapse"] is True
  68. if mqtt_client.state.timelapse and mqtt_client._was_running:
  69. mqtt_client._timelapse_during_print = True
  70. assert mqtt_client._timelapse_during_print is True
  71. class TestPrintCompletionWithTimelapse:
  72. """Tests for print completion including timelapse flag."""
  73. @pytest.fixture
  74. def mqtt_client(self):
  75. """Create a BambuMQTTClient instance for testing."""
  76. from backend.app.services.bambu_mqtt import BambuMQTTClient
  77. client = BambuMQTTClient(
  78. ip_address="192.168.1.100",
  79. serial_number="TEST123",
  80. access_code="12345678",
  81. )
  82. return client
  83. def test_print_complete_includes_timelapse_flag(self, mqtt_client):
  84. """Verify print complete callback includes timelapse_was_active."""
  85. # Set up completion callback
  86. callback_data = {}
  87. def on_complete(data):
  88. callback_data.update(data)
  89. mqtt_client.on_print_complete = on_complete
  90. # Simulate a print that had timelapse active
  91. mqtt_client._was_running = True
  92. mqtt_client._completion_triggered = False
  93. mqtt_client._timelapse_during_print = True
  94. mqtt_client._previous_gcode_state = "RUNNING"
  95. mqtt_client._previous_gcode_file = "test.gcode"
  96. mqtt_client.state.subtask_name = "Test Print"
  97. # Simulate print finish
  98. mqtt_client.state.state = "FINISH"
  99. # Manually trigger the completion logic (simplified)
  100. # In real code this happens in _parse_print_data
  101. should_trigger = (
  102. mqtt_client.state.state in ("FINISH", "FAILED")
  103. and not mqtt_client._completion_triggered
  104. and mqtt_client.on_print_complete
  105. and mqtt_client._previous_gcode_state == "RUNNING"
  106. )
  107. if should_trigger:
  108. status = "completed" if mqtt_client.state.state == "FINISH" else "failed"
  109. timelapse_was_active = mqtt_client._timelapse_during_print
  110. mqtt_client._completion_triggered = True
  111. mqtt_client._was_running = False
  112. mqtt_client._timelapse_during_print = False
  113. mqtt_client.on_print_complete(
  114. {
  115. "status": status,
  116. "filename": mqtt_client._previous_gcode_file,
  117. "subtask_name": mqtt_client.state.subtask_name,
  118. "timelapse_was_active": timelapse_was_active,
  119. }
  120. )
  121. assert "timelapse_was_active" in callback_data
  122. assert callback_data["timelapse_was_active"] is True
  123. def test_print_complete_timelapse_flag_false_when_no_timelapse(self, mqtt_client):
  124. """Verify timelapse_was_active is False when no timelapse during print."""
  125. callback_data = {}
  126. def on_complete(data):
  127. callback_data.update(data)
  128. mqtt_client.on_print_complete = on_complete
  129. # Print without timelapse
  130. mqtt_client._was_running = True
  131. mqtt_client._completion_triggered = False
  132. mqtt_client._timelapse_during_print = False # No timelapse
  133. mqtt_client._previous_gcode_state = "RUNNING"
  134. mqtt_client._previous_gcode_file = "test.gcode"
  135. mqtt_client.state.subtask_name = "Test Print"
  136. mqtt_client.state.state = "FINISH"
  137. # Trigger completion
  138. timelapse_was_active = mqtt_client._timelapse_during_print
  139. mqtt_client.on_print_complete(
  140. {
  141. "status": "completed",
  142. "filename": mqtt_client._previous_gcode_file,
  143. "subtask_name": mqtt_client.state.subtask_name,
  144. "timelapse_was_active": timelapse_was_active,
  145. }
  146. )
  147. assert callback_data["timelapse_was_active"] is False
  148. def test_timelapse_flag_reset_after_completion(self, mqtt_client):
  149. """Verify _timelapse_during_print is reset after print completion."""
  150. mqtt_client._timelapse_during_print = True
  151. mqtt_client._was_running = True
  152. mqtt_client._completion_triggered = False
  153. # Simulate completion reset
  154. mqtt_client._completion_triggered = True
  155. mqtt_client._was_running = False
  156. mqtt_client._timelapse_during_print = False
  157. assert mqtt_client._timelapse_during_print is False
  158. class TestRealisticMessageFlow:
  159. """Tests that simulate realistic MQTT message sequences.
  160. These tests process messages through _process_message to test the full flow,
  161. including the order of xcam parsing vs state detection.
  162. """
  163. @pytest.fixture
  164. def mqtt_client(self):
  165. """Create a BambuMQTTClient instance for testing."""
  166. from backend.app.services.bambu_mqtt import BambuMQTTClient
  167. client = BambuMQTTClient(
  168. ip_address="192.168.1.100",
  169. serial_number="TEST123",
  170. access_code="12345678",
  171. )
  172. return client
  173. def test_timelapse_detected_at_print_start_in_same_message(self, mqtt_client):
  174. """Test that timelapse is detected when xcam and state come in same message.
  175. This is the critical race condition test - xcam data is parsed BEFORE
  176. state detection, so the timelapse flag must be set AFTER _was_running is True.
  177. """
  178. # Callbacks to track events
  179. start_callback_data = {}
  180. def on_start(data):
  181. start_callback_data.update(data)
  182. mqtt_client.on_print_start = on_start
  183. # Initial state - idle
  184. mqtt_client._was_running = False
  185. mqtt_client._timelapse_during_print = False
  186. mqtt_client._previous_gcode_state = None
  187. # Simulate first message when print starts - contains both xcam and gcode_state
  188. # This is the realistic scenario from the printer
  189. # NOTE: Real MQTT messages wrap print data inside a "print" key
  190. payload = {
  191. "print": {
  192. "gcode_state": "RUNNING",
  193. "gcode_file": "/data/Metadata/test_print.gcode",
  194. "subtask_name": "Test_Print",
  195. "xcam": {
  196. "timelapse": "enable", # Timelapse is enabled in this print
  197. "printing_monitor": True,
  198. },
  199. "mc_percent": 0,
  200. "mc_remaining_time": 3600,
  201. }
  202. }
  203. # Process the message (this is what happens in real MQTT flow)
  204. mqtt_client._process_message(payload)
  205. # Verify timelapse was detected even though xcam is parsed before state
  206. assert mqtt_client._was_running is True, "_was_running should be True after RUNNING state"
  207. assert mqtt_client.state.timelapse is True, "state.timelapse should be True"
  208. assert mqtt_client._timelapse_during_print is True, (
  209. "timelapse_during_print should be True when timelapse is in the same message as RUNNING state"
  210. )
  211. def test_timelapse_not_detected_when_disabled(self, mqtt_client):
  212. """Test that timelapse is NOT detected when disabled in xcam data."""
  213. mqtt_client.on_print_start = lambda data: None
  214. # Initial state - idle
  215. mqtt_client._was_running = False
  216. mqtt_client._timelapse_during_print = False
  217. mqtt_client._previous_gcode_state = None
  218. # Print starts without timelapse
  219. payload = {
  220. "print": {
  221. "gcode_state": "RUNNING",
  222. "gcode_file": "/data/Metadata/test_print.gcode",
  223. "subtask_name": "Test_Print",
  224. "xcam": {
  225. "timelapse": "disable", # Timelapse is disabled
  226. "printing_monitor": True,
  227. },
  228. }
  229. }
  230. mqtt_client._process_message(payload)
  231. assert mqtt_client._was_running is True
  232. assert mqtt_client.state.timelapse is False
  233. assert mqtt_client._timelapse_during_print is False
  234. def test_timelapse_detected_when_enabled_after_print_start(self, mqtt_client):
  235. """Test timelapse detected when enabled in a message after print starts."""
  236. mqtt_client.on_print_start = lambda data: None
  237. # First message - print starts without timelapse info
  238. payload_start = {
  239. "print": {
  240. "gcode_state": "RUNNING",
  241. "gcode_file": "/data/Metadata/test_print.gcode",
  242. "subtask_name": "Test_Print",
  243. }
  244. }
  245. mqtt_client._process_message(payload_start)
  246. assert mqtt_client._was_running is True
  247. assert mqtt_client._timelapse_during_print is False # Not detected yet
  248. # Second message - xcam data arrives with timelapse enabled
  249. payload_xcam = {
  250. "print": {
  251. "gcode_state": "RUNNING",
  252. "gcode_file": "/data/Metadata/test_print.gcode",
  253. "subtask_name": "Test_Print",
  254. "xcam": {
  255. "timelapse": "enable",
  256. },
  257. }
  258. }
  259. mqtt_client._process_message(payload_xcam)
  260. # Now timelapse should be detected because _was_running is already True
  261. assert mqtt_client._timelapse_during_print is True
  262. def test_print_complete_includes_timelapse_flag_full_flow(self, mqtt_client):
  263. """Test full print lifecycle with timelapse - from start to completion."""
  264. start_data = {}
  265. complete_data = {}
  266. def on_start(data):
  267. start_data.update(data)
  268. def on_complete(data):
  269. complete_data.update(data)
  270. mqtt_client.on_print_start = on_start
  271. mqtt_client.on_print_complete = on_complete
  272. # 1. Print starts with timelapse
  273. mqtt_client._process_message(
  274. {
  275. "print": {
  276. "gcode_state": "RUNNING",
  277. "gcode_file": "/data/Metadata/test.gcode",
  278. "subtask_name": "Test",
  279. "xcam": {"timelapse": "enable"},
  280. }
  281. }
  282. )
  283. assert mqtt_client._timelapse_during_print is True
  284. assert "subtask_name" in start_data
  285. # 2. Print continues (multiple messages)
  286. for _ in range(3):
  287. mqtt_client._process_message(
  288. {
  289. "print": {
  290. "gcode_state": "RUNNING",
  291. "gcode_file": "/data/Metadata/test.gcode",
  292. "subtask_name": "Test",
  293. "mc_percent": 50,
  294. }
  295. }
  296. )
  297. # Timelapse flag should still be True
  298. assert mqtt_client._timelapse_during_print is True
  299. # 3. Print completes
  300. mqtt_client._process_message(
  301. {
  302. "print": {
  303. "gcode_state": "FINISH",
  304. "gcode_file": "/data/Metadata/test.gcode",
  305. "subtask_name": "Test",
  306. }
  307. }
  308. )
  309. # Verify completion callback received timelapse flag
  310. assert "timelapse_was_active" in complete_data
  311. assert complete_data["timelapse_was_active"] is True
  312. assert complete_data["status"] == "completed"
  313. # Flags should be reset after completion
  314. assert mqtt_client._timelapse_during_print is False
  315. assert mqtt_client._was_running is False
  316. def test_print_failed_includes_timelapse_flag(self, mqtt_client):
  317. """Test that failed print also includes timelapse flag."""
  318. complete_data = {}
  319. def on_complete(data):
  320. complete_data.update(data)
  321. mqtt_client.on_print_start = lambda data: None
  322. mqtt_client.on_print_complete = on_complete
  323. # Start with timelapse
  324. mqtt_client._process_message(
  325. {
  326. "print": {
  327. "gcode_state": "RUNNING",
  328. "gcode_file": "/data/Metadata/test.gcode",
  329. "subtask_name": "Test",
  330. "xcam": {"timelapse": "enable"},
  331. }
  332. }
  333. )
  334. # Print fails
  335. mqtt_client._process_message(
  336. {
  337. "print": {
  338. "gcode_state": "FAILED",
  339. "gcode_file": "/data/Metadata/test.gcode",
  340. "subtask_name": "Test",
  341. }
  342. }
  343. )
  344. assert complete_data["timelapse_was_active"] is True
  345. assert complete_data["status"] == "failed"
  346. class TestAMSDataMerging:
  347. """Tests for AMS data merging, particularly handling empty slots."""
  348. @pytest.fixture
  349. def mqtt_client(self):
  350. """Create a BambuMQTTClient instance for testing."""
  351. from backend.app.services.bambu_mqtt import BambuMQTTClient
  352. client = BambuMQTTClient(
  353. ip_address="192.168.1.100",
  354. serial_number="TEST123",
  355. access_code="12345678",
  356. )
  357. return client
  358. def test_empty_slot_clears_tray_type(self, mqtt_client):
  359. """Test that empty slot update clears tray_type (Issue #147).
  360. When a spool is removed from an old AMS, the printer sends empty values.
  361. These must overwrite the previous values to show the slot as empty.
  362. """
  363. # Initial state: AMS unit with a loaded spool
  364. initial_ams = {
  365. "ams": [
  366. {
  367. "id": 0,
  368. "tray": [
  369. {
  370. "id": 0,
  371. "tray_type": "PLA",
  372. "tray_sub_brands": "Bambu PLA Basic",
  373. "tray_color": "FF0000",
  374. "tag_uid": "1234567890ABCDEF",
  375. "remain": 80,
  376. }
  377. ],
  378. }
  379. ]
  380. }
  381. mqtt_client._handle_ams_data(initial_ams)
  382. # Verify initial state
  383. ams_data = mqtt_client.state.raw_data.get("ams", [])
  384. assert len(ams_data) == 1
  385. tray = ams_data[0]["tray"][0]
  386. assert tray["tray_type"] == "PLA"
  387. assert tray["tray_color"] == "FF0000"
  388. # Now simulate spool removal - printer sends empty values
  389. empty_update = {
  390. "ams": [
  391. {
  392. "id": 0,
  393. "tray": [
  394. {
  395. "id": 0,
  396. "tray_type": "", # Empty = slot is empty
  397. "tray_sub_brands": "",
  398. "tray_color": "",
  399. "tag_uid": "0000000000000000", # Zero UID
  400. "remain": 0,
  401. }
  402. ],
  403. }
  404. ]
  405. }
  406. mqtt_client._handle_ams_data(empty_update)
  407. # Verify empty values were applied (not ignored by merge logic)
  408. ams_data = mqtt_client.state.raw_data.get("ams", [])
  409. tray = ams_data[0]["tray"][0]
  410. assert tray["tray_type"] == "", "tray_type should be cleared when slot is empty"
  411. assert tray["tray_color"] == "", "tray_color should be cleared when slot is empty"
  412. assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
  413. assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
  414. def test_partial_update_preserves_other_fields(self, mqtt_client):
  415. """Test that partial updates still preserve non-slot-status fields."""
  416. # Initial state with full data
  417. initial_ams = {
  418. "ams": [
  419. {
  420. "id": 0,
  421. "humidity": "3",
  422. "temp": "25.5",
  423. "tray": [
  424. {
  425. "id": 0,
  426. "tray_type": "PLA",
  427. "tray_color": "00FF00",
  428. "remain": 90,
  429. "k": 0.02,
  430. }
  431. ],
  432. }
  433. ]
  434. }
  435. mqtt_client._handle_ams_data(initial_ams)
  436. # Partial update - only remain changes
  437. partial_update = {
  438. "ams": [
  439. {
  440. "id": 0,
  441. "tray": [
  442. {
  443. "id": 0,
  444. "remain": 85, # Only this changed
  445. }
  446. ],
  447. }
  448. ]
  449. }
  450. mqtt_client._handle_ams_data(partial_update)
  451. # Verify remain was updated but other fields preserved
  452. ams_data = mqtt_client.state.raw_data.get("ams", [])
  453. tray = ams_data[0]["tray"][0]
  454. assert tray["remain"] == 85, "remain should be updated"
  455. assert tray["tray_type"] == "PLA", "tray_type should be preserved"
  456. assert tray["tray_color"] == "00FF00", "tray_color should be preserved"
  457. assert tray["k"] == 0.02, "k should be preserved"
  458. def test_tray_exist_bits_clears_empty_slots(self, mqtt_client):
  459. """Test that tray_exist_bits clears slots marked as empty (Issue #147).
  460. New AMS models (AMS 2 Pro) don't send empty tray data when a spool is removed.
  461. Instead, they update tray_exist_bits to indicate which slots have spools.
  462. """
  463. # Initial state: AMS 0 and AMS 1 with loaded spools
  464. initial_ams = {
  465. "ams": [
  466. {
  467. "id": 0,
  468. "tray": [
  469. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
  470. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
  471. {"id": 2, "tray_type": "ABS", "tray_color": "0000FF", "remain": 40},
  472. {"id": 3, "tray_type": "TPU", "tray_color": "FFFF00", "remain": 20},
  473. ],
  474. },
  475. {
  476. "id": 1,
  477. "tray": [
  478. {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFF", "remain": 90},
  479. {"id": 1, "tray_type": "PLA", "tray_color": "000000", "remain": 70},
  480. {"id": 2, "tray_type": "PLA", "tray_color": "FF00FF", "remain": 50},
  481. {"id": 3, "tray_type": "PLA", "tray_color": "00FFFF", "remain": 30},
  482. ],
  483. },
  484. ],
  485. "tray_exist_bits": "ff", # All 8 slots have spools (0xFF = 11111111)
  486. }
  487. mqtt_client._handle_ams_data(initial_ams)
  488. # Verify initial state
  489. ams_data = mqtt_client.state.raw_data.get("ams", [])
  490. assert ams_data[1]["tray"][3]["tray_type"] == "PLA" # AMS 1 slot 3 (B4) has spool
  491. # Now simulate spool removal from AMS 1 slot 3 (B4)
  492. # tray_exist_bits: 0x7f = 01111111 (bit 7 = 0 means AMS 1 slot 3 is empty)
  493. update_ams = {
  494. "ams": [
  495. {"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
  496. {"id": 1, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
  497. ],
  498. "tray_exist_bits": "7f", # Bit 7 = 0 -> AMS 1 slot 3 is empty
  499. }
  500. mqtt_client._handle_ams_data(update_ams)
  501. # Verify AMS 1 slot 3 was cleared
  502. ams_data = mqtt_client.state.raw_data.get("ams", [])
  503. b4_tray = ams_data[1]["tray"][3]
  504. assert b4_tray["tray_type"] == "", "tray_type should be cleared for empty slot"
  505. assert b4_tray["remain"] == 0, "remain should be 0 for empty slot"
  506. # Verify other slots are preserved
  507. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
  508. assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
  509. def test_shutdown_message_preserves_ams_data(self, mqtt_client):
  510. """Printer shutdown (power_on_flag=False) must not wipe AMS slot data (#765).
  511. When a printer shuts down it sends a final MQTT message with
  512. tray_exist_bits='0' and power_on_flag=False. This all-zero value
  513. previously caused every slot to be cleared, which then triggered
  514. auto-unlink of all spool assignments on reconnect.
  515. """
  516. # Initial state: two AMS units with loaded spools
  517. initial_ams = {
  518. "ams": [
  519. {
  520. "id": 0,
  521. "tray": [
  522. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "remain": 80},
  523. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF", "remain": 60},
  524. ],
  525. },
  526. {
  527. "id": 1,
  528. "tray": [
  529. {"id": 0, "tray_type": "PETG", "tray_color": "DBDDD9FF", "remain": 90},
  530. {"id": 1, "tray_type": "PETG", "tray_color": "67DB25FF", "remain": 70},
  531. ],
  532. },
  533. ],
  534. "tray_exist_bits": "33", # Slots 0,1 of each AMS (0b00110011)
  535. "power_on_flag": True,
  536. }
  537. mqtt_client._handle_ams_data(initial_ams)
  538. # Verify initial state
  539. ams_data = mqtt_client.state.raw_data["ams"]
  540. assert ams_data[0]["tray"][0]["tray_type"] == "PLA"
  541. assert ams_data[1]["tray"][0]["tray_type"] == "PETG"
  542. # Simulate printer shutdown — all-zero bits with power_on_flag=False
  543. shutdown_ams = {
  544. "ams_exist_bits": "0",
  545. "tray_exist_bits": "0",
  546. "power_on_flag": False,
  547. "insert_flag": False,
  548. "tray_now": "0",
  549. "version": 0,
  550. }
  551. mqtt_client._handle_ams_data(shutdown_ams)
  552. # AMS slot data MUST be preserved — shutdown should not clear it
  553. ams_data = mqtt_client.state.raw_data["ams"]
  554. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Shutdown must not clear AMS 0 slot 0"
  555. assert ams_data[0]["tray"][0]["tray_color"] == "FF0000FF", "Shutdown must not clear AMS 0 slot 0 color"
  556. assert ams_data[0]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 0 slot 1"
  557. assert ams_data[1]["tray"][0]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 0"
  558. assert ams_data[1]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 1"
  559. def test_genuine_removal_still_clears_with_power_on(self, mqtt_client):
  560. """Genuine spool removal (power_on_flag=True) must still clear slot data.
  561. Ensures the #765 fix doesn't break normal spool removal detection.
  562. """
  563. # Initial state: AMS with loaded spool
  564. initial_ams = {
  565. "ams": [
  566. {
  567. "id": 0,
  568. "tray": [
  569. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
  570. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
  571. ],
  572. },
  573. ],
  574. "tray_exist_bits": "3", # Both slots occupied (0b11)
  575. "power_on_flag": True,
  576. }
  577. mqtt_client._handle_ams_data(initial_ams)
  578. # Spool removed from slot 1 while printer is running
  579. removal_ams = {
  580. "ams": [
  581. {
  582. "id": 0,
  583. "tray": [{"id": 0}, {"id": 1}],
  584. },
  585. ],
  586. "tray_exist_bits": "1", # Only slot 0 occupied (0b01)
  587. "power_on_flag": True,
  588. }
  589. mqtt_client._handle_ams_data(removal_ams)
  590. # Slot 0 preserved, slot 1 cleared
  591. ams_data = mqtt_client.state.raw_data["ams"]
  592. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Slot 0 should be preserved"
  593. assert ams_data[0]["tray"][1]["tray_type"] == "", "Slot 1 should be cleared on removal"
  594. assert ams_data[0]["tray"][1]["tray_color"] == "", "Slot 1 color should be cleared"
  595. def test_power_on_flag_defaults_true_when_absent(self, mqtt_client):
  596. """When power_on_flag is not in the MQTT data, clearing must proceed normally.
  597. Ensures backwards compatibility with firmware that doesn't send power_on_flag.
  598. """
  599. # Initial state
  600. initial_ams = {
  601. "ams": [
  602. {
  603. "id": 0,
  604. "tray": [
  605. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
  606. ],
  607. },
  608. ],
  609. "tray_exist_bits": "1",
  610. }
  611. mqtt_client._handle_ams_data(initial_ams)
  612. # Update WITHOUT power_on_flag — should still clear when bit=0
  613. update_ams = {
  614. "ams": [{"id": 0, "tray": [{"id": 0}]}],
  615. "tray_exist_bits": "0",
  616. # No power_on_flag key at all
  617. }
  618. mqtt_client._handle_ams_data(update_ams)
  619. ams_data = mqtt_client.state.raw_data["ams"]
  620. assert ams_data[0]["tray"][0]["tray_type"] == "", (
  621. "Without power_on_flag, clearing should proceed (defaults to True)"
  622. )
  623. class TestAMSTrayStateClearning:
  624. """Tests for AMS tray state-based clearing (#784).
  625. Some printers (e.g. H2D) only send {id, state} in incremental MQTT
  626. updates when a tray is not fully loaded. state=11 means loaded;
  627. other values (9=empty, 10=spool present but filament not in feeder)
  628. should clear stale tray data that was set from an earlier pushall.
  629. """
  630. @pytest.fixture
  631. def mqtt_client(self):
  632. from backend.app.services.bambu_mqtt import BambuMQTTClient
  633. client = BambuMQTTClient(
  634. ip_address="192.168.1.100",
  635. serial_number="TEST_H2D",
  636. access_code="12345678",
  637. )
  638. return client
  639. def _seed_loaded_tray(self, mqtt_client):
  640. """Seed AMS 0 with a fully loaded tray (state=11) and an empty slot."""
  641. initial = {
  642. "ams": [
  643. {
  644. "id": 0,
  645. "tray": [
  646. {
  647. "id": 0,
  648. "tray_type": "PETG",
  649. "tray_sub_brands": "PETG HF",
  650. "tray_color": "00FF00FF",
  651. "tray_id_name": "A00-G1",
  652. "tray_info_idx": "GFG99",
  653. "tag_uid": "AABBCCDD11223344",
  654. "tray_uuid": "AABBCCDD11223344AABBCCDD11223344",
  655. "remain": 75,
  656. "k": 0.02,
  657. "cali_idx": 5,
  658. "state": 11,
  659. },
  660. {
  661. "id": 1,
  662. "tray_type": "PLA",
  663. "tray_color": "FF0000FF",
  664. "remain": 50,
  665. "state": 11,
  666. },
  667. ],
  668. }
  669. ],
  670. "power_on_flag": False, # H2D always sends False
  671. }
  672. mqtt_client._handle_ams_data(initial)
  673. ams = mqtt_client.state.raw_data["ams"]
  674. assert ams[0]["tray"][0]["tray_type"] == "PETG"
  675. assert ams[0]["tray"][1]["tray_type"] == "PLA"
  676. def test_state_10_clears_stale_tray_data(self, mqtt_client):
  677. """Incremental update with state=10 (spool present, not loaded) clears tray."""
  678. self._seed_loaded_tray(mqtt_client)
  679. # H2D sends only {id, state} when filament is retracted
  680. update = {
  681. "ams": [
  682. {
  683. "id": 0,
  684. "tray": [
  685. {"id": 0, "state": 10},
  686. {"id": 1, "state": 11}, # slot 1 still loaded
  687. ],
  688. }
  689. ],
  690. "power_on_flag": False,
  691. }
  692. mqtt_client._handle_ams_data(update)
  693. ams = mqtt_client.state.raw_data["ams"]
  694. tray0 = ams[0]["tray"][0]
  695. tray1 = ams[0]["tray"][1]
  696. # Tray 0 should be cleared
  697. assert tray0["tray_type"] == "", "tray_type must be cleared on state=10"
  698. assert tray0["tray_color"] == "", "tray_color must be cleared"
  699. assert tray0["tray_sub_brands"] == "", "tray_sub_brands must be cleared"
  700. assert tray0["tray_id_name"] == "", "tray_id_name must be cleared"
  701. assert tray0["tray_info_idx"] == "", "tray_info_idx must be cleared"
  702. assert tray0["tag_uid"] == "0000000000000000", "tag_uid must be cleared"
  703. assert tray0["tray_uuid"] == "00000000000000000000000000000000", "tray_uuid must be cleared"
  704. assert tray0["remain"] == 0, "remain must be 0"
  705. assert tray0["k"] is None, "k must be cleared"
  706. assert tray0["cali_idx"] is None, "cali_idx must be cleared"
  707. assert tray0["state"] == 10, "state should be preserved"
  708. # Tray 1 should be untouched
  709. assert tray1["tray_type"] == "PLA", "Loaded slot must be preserved"
  710. assert tray1["remain"] == 50
  711. def test_state_9_clears_stale_tray_data(self, mqtt_client):
  712. """Incremental update with state=9 (empty, no spool) clears tray."""
  713. self._seed_loaded_tray(mqtt_client)
  714. update = {
  715. "ams": [
  716. {
  717. "id": 0,
  718. "tray": [
  719. {"id": 0, "state": 9},
  720. {"id": 1, "state": 11},
  721. ],
  722. }
  723. ],
  724. "power_on_flag": False,
  725. }
  726. mqtt_client._handle_ams_data(update)
  727. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  728. assert tray0["tray_type"] == "", "state=9 must clear tray_type"
  729. assert tray0["remain"] == 0
  730. def test_state_11_preserves_tray_data(self, mqtt_client):
  731. """Incremental update with state=11 (loaded) must NOT clear tray."""
  732. self._seed_loaded_tray(mqtt_client)
  733. update = {
  734. "ams": [
  735. {
  736. "id": 0,
  737. "tray": [
  738. {"id": 0, "state": 11},
  739. {"id": 1, "state": 11},
  740. ],
  741. }
  742. ],
  743. "power_on_flag": False,
  744. }
  745. mqtt_client._handle_ams_data(update)
  746. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  747. assert tray0["tray_type"] == "PETG", "state=11 must preserve tray data"
  748. assert tray0["tray_color"] == "00FF00FF"
  749. assert tray0["remain"] == 75
  750. def test_no_clearing_when_tray_type_already_empty(self, mqtt_client):
  751. """Don't re-clear a tray that's already empty (avoids log spam)."""
  752. self._seed_loaded_tray(mqtt_client)
  753. # First unload clears
  754. update = {
  755. "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}],
  756. "power_on_flag": False,
  757. }
  758. mqtt_client._handle_ams_data(update)
  759. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
  760. # Second identical update should not trigger clearing again
  761. # (merged_tray.get("tray_type") is already empty/falsy)
  762. mqtt_client._handle_ams_data(update)
  763. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
  764. def test_reload_after_unload_restores_data(self, mqtt_client):
  765. """After clearing via state=10, a full update with state=11 restores data."""
  766. self._seed_loaded_tray(mqtt_client)
  767. # Unload
  768. mqtt_client._handle_ams_data(
  769. {
  770. "ams": [{"id": 0, "tray": [{"id": 0, "state": 10}, {"id": 1, "state": 11}]}],
  771. "power_on_flag": False,
  772. }
  773. )
  774. assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
  775. # Reload — full tray data arrives again
  776. mqtt_client._handle_ams_data(
  777. {
  778. "ams": [
  779. {
  780. "id": 0,
  781. "tray": [
  782. {
  783. "id": 0,
  784. "tray_type": "PETG",
  785. "tray_sub_brands": "PETG HF",
  786. "tray_color": "00FF00FF",
  787. "remain": 75,
  788. "state": 11,
  789. },
  790. {"id": 1, "state": 11},
  791. ],
  792. }
  793. ],
  794. "power_on_flag": False,
  795. }
  796. )
  797. tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
  798. assert tray0["tray_type"] == "PETG", "Reload must restore tray data"
  799. assert tray0["tray_color"] == "00FF00FF"
  800. assert tray0["remain"] == 75
  801. class TestNozzleRackData:
  802. """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
  803. @pytest.fixture
  804. def mqtt_client(self):
  805. """Create a BambuMQTTClient instance for testing."""
  806. from backend.app.services.bambu_mqtt import BambuMQTTClient
  807. client = BambuMQTTClient(
  808. ip_address="192.168.1.100",
  809. serial_number="TEST123",
  810. access_code="12345678",
  811. )
  812. return client
  813. def test_h2c_nozzle_rack_populated_with_8_entries(self, mqtt_client):
  814. """H2C provides 8 nozzle entries: IDs 0,1 (L/R hotend) + 16-21 (rack)."""
  815. payload = {
  816. "print": {
  817. "device": {
  818. "nozzle": {
  819. "info": [
  820. {
  821. "id": 0,
  822. "type": "HS",
  823. "diameter": "0.4",
  824. "wear": 5,
  825. "stat": 1,
  826. "max_temp": 300,
  827. "serial_number": "SN-L",
  828. },
  829. {
  830. "id": 1,
  831. "type": "HS",
  832. "diameter": "0.4",
  833. "wear": 3,
  834. "stat": 0,
  835. "max_temp": 300,
  836. "serial_number": "SN-R",
  837. },
  838. {
  839. "id": 16,
  840. "type": "HS",
  841. "diameter": "0.4",
  842. "wear": 10,
  843. "stat": 0,
  844. "max_temp": 300,
  845. "serial_number": "SN-16",
  846. },
  847. {
  848. "id": 17,
  849. "type": "HH01",
  850. "diameter": "0.6",
  851. "wear": 0,
  852. "stat": 0,
  853. "max_temp": 300,
  854. "serial_number": "SN-17",
  855. },
  856. {
  857. "id": 18,
  858. "type": "HS",
  859. "diameter": "0.4",
  860. "wear": 2,
  861. "stat": 0,
  862. "max_temp": 300,
  863. "serial_number": "SN-18",
  864. },
  865. {
  866. "id": 19,
  867. "type": "",
  868. "diameter": "",
  869. "wear": None,
  870. "stat": None,
  871. "max_temp": 0,
  872. "serial_number": "",
  873. },
  874. {
  875. "id": 20,
  876. "type": "",
  877. "diameter": "",
  878. "wear": None,
  879. "stat": None,
  880. "max_temp": 0,
  881. "serial_number": "",
  882. },
  883. {
  884. "id": 21,
  885. "type": "",
  886. "diameter": "",
  887. "wear": None,
  888. "stat": None,
  889. "max_temp": 0,
  890. "serial_number": "",
  891. },
  892. ]
  893. }
  894. }
  895. }
  896. }
  897. mqtt_client._process_message(payload)
  898. assert len(mqtt_client.state.nozzle_rack) == 8
  899. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  900. assert ids == [0, 1, 16, 17, 18, 19, 20, 21]
  901. def test_h2d_nozzle_rack_populated_with_2_entries(self, mqtt_client):
  902. """H2D provides 2 nozzle entries: IDs 0,1 (L/R hotend) — no rack slots."""
  903. payload = {
  904. "print": {
  905. "device": {
  906. "nozzle": {
  907. "info": [
  908. {
  909. "id": 0,
  910. "type": "HS",
  911. "diameter": "0.4",
  912. "wear": 5,
  913. "stat": 1,
  914. "max_temp": 300,
  915. "serial_number": "SN-L",
  916. },
  917. {
  918. "id": 1,
  919. "type": "HS",
  920. "diameter": "0.4",
  921. "wear": 3,
  922. "stat": 1,
  923. "max_temp": 300,
  924. "serial_number": "SN-R",
  925. },
  926. ]
  927. }
  928. }
  929. }
  930. }
  931. mqtt_client._process_message(payload)
  932. assert len(mqtt_client.state.nozzle_rack) == 2
  933. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  934. assert ids == [0, 1]
  935. def test_single_nozzle_h2s_populated(self, mqtt_client):
  936. """H2S provides 1 nozzle entry: ID 0 only — single nozzle printer."""
  937. payload = {
  938. "print": {
  939. "device": {
  940. "nozzle": {
  941. "info": [
  942. {
  943. "id": 0,
  944. "type": "HS",
  945. "diameter": "0.4",
  946. "wear": 2,
  947. "stat": 1,
  948. "max_temp": 300,
  949. "serial_number": "SN-0",
  950. },
  951. ]
  952. }
  953. }
  954. }
  955. }
  956. mqtt_client._process_message(payload)
  957. assert len(mqtt_client.state.nozzle_rack) == 1
  958. assert mqtt_client.state.nozzle_rack[0]["id"] == 0
  959. def test_empty_nozzle_info_does_not_populate_rack(self, mqtt_client):
  960. """Empty nozzle info list should not populate nozzle_rack."""
  961. payload = {"print": {"device": {"nozzle": {"info": []}}}}
  962. mqtt_client._process_message(payload)
  963. assert mqtt_client.state.nozzle_rack == []
  964. def test_nozzle_rack_sorted_by_id(self, mqtt_client):
  965. """Nozzle rack entries should be sorted by ID regardless of input order."""
  966. payload = {
  967. "print": {
  968. "device": {
  969. "nozzle": {
  970. "info": [
  971. {"id": 17, "type": "HS", "diameter": "0.6"},
  972. {"id": 0, "type": "HS", "diameter": "0.4"},
  973. {"id": 16, "type": "HS", "diameter": "0.4"},
  974. {"id": 1, "type": "HS", "diameter": "0.4"},
  975. ]
  976. }
  977. }
  978. }
  979. }
  980. mqtt_client._process_message(payload)
  981. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  982. assert ids == [0, 1, 16, 17]
  983. def test_nozzle_rack_field_mapping(self, mqtt_client):
  984. """Verify field mapping from MQTT nozzle_info to nozzle_rack dict keys."""
  985. payload = {
  986. "print": {
  987. "device": {
  988. "nozzle": {
  989. "info": [
  990. {
  991. "id": 16,
  992. "type": "HH01",
  993. "diameter": "0.6",
  994. "wear": 15,
  995. "stat": 0,
  996. "max_temp": 320,
  997. "serial_number": "SN-ABC123",
  998. "filament_colour": "FF8800",
  999. "filament_id": "F42",
  1000. "tray_type": "ABS",
  1001. }
  1002. ]
  1003. }
  1004. }
  1005. }
  1006. }
  1007. mqtt_client._process_message(payload)
  1008. slot = mqtt_client.state.nozzle_rack[0]
  1009. assert slot["id"] == 16
  1010. assert slot["type"] == "HH01"
  1011. assert slot["diameter"] == "0.6"
  1012. assert slot["wear"] == 15
  1013. assert slot["stat"] == 0
  1014. assert slot["max_temp"] == 320
  1015. assert slot["serial_number"] == "SN-ABC123"
  1016. assert slot["filament_color"] == "FF8800"
  1017. assert slot["filament_id"] == "F42"
  1018. assert slot["filament_type"] == "ABS"
  1019. def test_nozzle_info_updates_nozzle_state(self, mqtt_client):
  1020. """Nozzle info for IDs 0,1 should also update nozzle state (type/diameter)."""
  1021. payload = {
  1022. "print": {
  1023. "device": {
  1024. "nozzle": {
  1025. "info": [
  1026. {"id": 0, "type": "HS", "diameter": "0.4"},
  1027. {"id": 1, "type": "HH01", "diameter": "0.6"},
  1028. ]
  1029. }
  1030. }
  1031. }
  1032. }
  1033. mqtt_client._process_message(payload)
  1034. assert mqtt_client.state.nozzles[0].nozzle_type == "HS"
  1035. assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
  1036. assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
  1037. assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
  1038. class TestRequestTopicFailSafe:
  1039. """Tests for graceful degradation when broker rejects request topic subscription."""
  1040. @pytest.fixture(autouse=True)
  1041. def clear_request_topic_cache(self):
  1042. """Clear class-level cache before each test to avoid cross-test pollution."""
  1043. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1044. BambuMQTTClient._request_topic_cache.clear()
  1045. @pytest.fixture
  1046. def mqtt_client(self):
  1047. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1048. client = BambuMQTTClient(
  1049. ip_address="192.168.1.100",
  1050. serial_number="TEST123",
  1051. access_code="12345678",
  1052. )
  1053. return client
  1054. def test_request_topic_supported_by_default(self, mqtt_client):
  1055. """Request topic subscription is attempted by default."""
  1056. assert mqtt_client._request_topic_supported is True
  1057. assert mqtt_client._request_topic_confirmed is False
  1058. def test_on_subscribe_confirms_success(self, mqtt_client):
  1059. """Successful SUBACK marks request topic as confirmed."""
  1060. from paho.mqtt.reasoncodes import ReasonCode
  1061. mqtt_client._request_topic_sub_mid = 42
  1062. rc = ReasonCode(9, identifier=0) # SUBACK packetType=9, QoS 0 = success
  1063. mqtt_client._on_subscribe(None, None, 42, [rc], None)
  1064. assert mqtt_client._request_topic_confirmed is True
  1065. assert mqtt_client._request_topic_supported is True
  1066. assert mqtt_client._request_topic_sub_mid is None
  1067. assert mqtt_client._request_topic_sub_time == 0.0
  1068. def test_on_subscribe_detects_rejection(self, mqtt_client):
  1069. """SUBACK with failure code disables request topic."""
  1070. from paho.mqtt.reasoncodes import ReasonCode
  1071. mqtt_client._request_topic_sub_mid = 42
  1072. rc = ReasonCode(9, identifier=0x80) # SUBACK packetType=9, 0x80 = failure
  1073. mqtt_client._on_subscribe(None, None, 42, [rc], None)
  1074. assert mqtt_client._request_topic_supported is False
  1075. assert mqtt_client._request_topic_confirmed is False
  1076. def test_on_subscribe_ignores_other_mids(self, mqtt_client):
  1077. """SUBACK for other subscriptions (e.g. report topic) is ignored."""
  1078. from paho.mqtt.reasoncodes import ReasonCode
  1079. mqtt_client._request_topic_sub_mid = 42
  1080. rc = ReasonCode(9, identifier=0x80)
  1081. mqtt_client._on_subscribe(None, None, 99, [rc], None)
  1082. # Not affected — mid doesn't match
  1083. assert mqtt_client._request_topic_supported is True
  1084. def test_disconnect_after_subscription_disables_topic(self, mqtt_client):
  1085. """Disconnect within 10s of subscription attempt disables request topic."""
  1086. import time
  1087. mqtt_client._request_topic_sub_time = time.time()
  1088. mqtt_client._request_topic_confirmed = False
  1089. mqtt_client._last_message_time = 0.0
  1090. mqtt_client._on_disconnect(None, None)
  1091. assert mqtt_client._request_topic_supported is False
  1092. assert mqtt_client._request_topic_sub_time == 0.0
  1093. def test_disconnect_after_confirmation_does_not_disable(self, mqtt_client):
  1094. """Disconnect after SUBACK confirmation keeps request topic enabled."""
  1095. import time
  1096. mqtt_client._request_topic_sub_time = time.time()
  1097. mqtt_client._request_topic_confirmed = True
  1098. mqtt_client._last_message_time = 0.0
  1099. mqtt_client._on_disconnect(None, None)
  1100. assert mqtt_client._request_topic_supported is True
  1101. def test_late_disconnect_does_not_disable(self, mqtt_client):
  1102. """Disconnect long after subscription (>10s) doesn't blame request topic."""
  1103. import time
  1104. mqtt_client._request_topic_sub_time = time.time() - 30.0
  1105. mqtt_client._request_topic_confirmed = False
  1106. mqtt_client._last_message_time = 0.0
  1107. mqtt_client._on_disconnect(None, None)
  1108. assert mqtt_client._request_topic_supported is True
  1109. def test_on_connect_skips_request_topic_when_unsupported(self, mqtt_client):
  1110. """After marking unsupported, reconnect skips request topic subscription."""
  1111. mqtt_client._request_topic_supported = False
  1112. subscribe_calls = []
  1113. mock_client = type(
  1114. "MockClient",
  1115. (),
  1116. {
  1117. "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
  1118. },
  1119. )()
  1120. mqtt_client._on_connect(mock_client, None, None, 0)
  1121. # Only report topic subscribed, not request topic
  1122. assert len(subscribe_calls) == 1
  1123. assert subscribe_calls[0] == mqtt_client.topic_subscribe
  1124. def test_cache_persists_across_instances(self):
  1125. """New client instance inherits request topic unsupported state from cache."""
  1126. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1127. client1 = BambuMQTTClient(
  1128. ip_address="192.168.1.100",
  1129. serial_number="TEST_CACHE",
  1130. access_code="12345678",
  1131. )
  1132. assert client1._request_topic_supported is True
  1133. # Simulate disconnect-after-subscribe disabling the topic
  1134. client1._request_topic_sub_time = __import__("time").time()
  1135. client1._request_topic_confirmed = False
  1136. client1._last_message_time = 0.0
  1137. client1._on_disconnect(None, None)
  1138. assert client1._request_topic_supported is False
  1139. # New instance for same serial should inherit the cached state
  1140. client2 = BambuMQTTClient(
  1141. ip_address="192.168.1.100",
  1142. serial_number="TEST_CACHE",
  1143. access_code="12345678",
  1144. )
  1145. assert client2._request_topic_supported is False
  1146. def test_cache_does_not_affect_different_serial(self):
  1147. """Cache is per-serial — different printer is unaffected."""
  1148. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1149. BambuMQTTClient._request_topic_cache["SERIAL_A"] = False
  1150. client = BambuMQTTClient(
  1151. ip_address="192.168.1.100",
  1152. serial_number="SERIAL_B",
  1153. access_code="12345678",
  1154. )
  1155. assert client._request_topic_supported is True
  1156. def test_cache_updated_on_suback_success(self):
  1157. """Successful SUBACK caches positive confirmation."""
  1158. from paho.mqtt.reasoncodes import ReasonCode
  1159. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1160. client = BambuMQTTClient(
  1161. ip_address="192.168.1.100",
  1162. serial_number="TEST_SUBACK",
  1163. access_code="12345678",
  1164. )
  1165. client._request_topic_sub_mid = 42
  1166. rc = ReasonCode(9, identifier=0) # Success
  1167. client._on_subscribe(None, None, 42, [rc], None)
  1168. assert BambuMQTTClient._request_topic_cache["TEST_SUBACK"] is True
  1169. def test_cache_updated_on_suback_rejection(self):
  1170. """SUBACK rejection caches negative state."""
  1171. from paho.mqtt.reasoncodes import ReasonCode
  1172. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1173. client = BambuMQTTClient(
  1174. ip_address="192.168.1.100",
  1175. serial_number="TEST_REJECT",
  1176. access_code="12345678",
  1177. )
  1178. client._request_topic_sub_mid = 42
  1179. rc = ReasonCode(9, identifier=0x80) # Failure
  1180. client._on_subscribe(None, None, 42, [rc], None)
  1181. assert BambuMQTTClient._request_topic_cache["TEST_REJECT"] is False
  1182. class TestRequestTopicAmsMapping:
  1183. """Tests for capturing ams_mapping from the MQTT request topic."""
  1184. @pytest.fixture
  1185. def mqtt_client(self):
  1186. """Create a BambuMQTTClient instance for testing."""
  1187. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1188. client = BambuMQTTClient(
  1189. ip_address="192.168.1.100",
  1190. serial_number="TEST123",
  1191. access_code="12345678",
  1192. )
  1193. return client
  1194. def test_captured_ams_mapping_initializes_to_none(self, mqtt_client):
  1195. """Verify _captured_ams_mapping starts as None."""
  1196. assert mqtt_client._captured_ams_mapping is None
  1197. def test_handle_request_message_captures_ams_mapping(self, mqtt_client):
  1198. """project_file command with ams_mapping stores the mapping."""
  1199. data = {
  1200. "print": {
  1201. "command": "project_file",
  1202. "ams_mapping": [0, 4, -1, -1],
  1203. "url": "ftp://192.168.1.100/test.3mf",
  1204. }
  1205. }
  1206. mqtt_client._handle_request_message(data)
  1207. assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
  1208. def test_handle_request_message_ignores_non_print_commands(self, mqtt_client):
  1209. """Non-project_file commands don't store ams_mapping."""
  1210. data = {
  1211. "print": {
  1212. "command": "pause",
  1213. }
  1214. }
  1215. mqtt_client._handle_request_message(data)
  1216. assert mqtt_client._captured_ams_mapping is None
  1217. def test_handle_request_message_ignores_missing_ams_mapping(self, mqtt_client):
  1218. """project_file command without ams_mapping doesn't store anything."""
  1219. data = {
  1220. "print": {
  1221. "command": "project_file",
  1222. "url": "ftp://192.168.1.100/test.3mf",
  1223. }
  1224. }
  1225. mqtt_client._handle_request_message(data)
  1226. assert mqtt_client._captured_ams_mapping is None
  1227. def test_handle_request_message_ignores_non_dict_print(self, mqtt_client):
  1228. """Non-dict print value is safely ignored."""
  1229. data = {"print": "not_a_dict"}
  1230. mqtt_client._handle_request_message(data)
  1231. assert mqtt_client._captured_ams_mapping is None
  1232. def test_handle_request_message_ignores_missing_print(self, mqtt_client):
  1233. """Message without print key is safely ignored."""
  1234. data = {"pushing": {"command": "pushall"}}
  1235. mqtt_client._handle_request_message(data)
  1236. assert mqtt_client._captured_ams_mapping is None
  1237. def test_captured_mapping_overwrites_previous(self, mqtt_client):
  1238. """A new print command overwrites a previously captured mapping."""
  1239. mqtt_client._captured_ams_mapping = [0, -1, -1, -1]
  1240. data = {
  1241. "print": {
  1242. "command": "project_file",
  1243. "ams_mapping": [4, 8, -1, -1],
  1244. }
  1245. }
  1246. mqtt_client._handle_request_message(data)
  1247. assert mqtt_client._captured_ams_mapping == [4, 8, -1, -1]
  1248. def test_print_start_callback_includes_ams_mapping(self, mqtt_client):
  1249. """on_print_start callback data includes captured ams_mapping."""
  1250. start_data = {}
  1251. def on_start(data):
  1252. start_data.update(data)
  1253. mqtt_client.on_print_start = on_start
  1254. mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
  1255. # Trigger print start
  1256. mqtt_client._process_message(
  1257. {
  1258. "print": {
  1259. "gcode_state": "RUNNING",
  1260. "gcode_file": "/data/Metadata/test.gcode",
  1261. "subtask_name": "Test",
  1262. }
  1263. }
  1264. )
  1265. assert start_data.get("ams_mapping") == [0, 4, -1, -1]
  1266. def test_print_start_callback_ams_mapping_none_when_not_captured(self, mqtt_client):
  1267. """on_print_start callback has ams_mapping=None when no mapping captured."""
  1268. start_data = {}
  1269. def on_start(data):
  1270. start_data.update(data)
  1271. mqtt_client.on_print_start = on_start
  1272. mqtt_client._process_message(
  1273. {
  1274. "print": {
  1275. "gcode_state": "RUNNING",
  1276. "gcode_file": "/data/Metadata/test.gcode",
  1277. "subtask_name": "Test",
  1278. }
  1279. }
  1280. )
  1281. assert "ams_mapping" in start_data
  1282. assert start_data["ams_mapping"] is None
  1283. def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):
  1284. """on_print_complete callback data includes captured ams_mapping."""
  1285. complete_data = {}
  1286. def on_complete(data):
  1287. complete_data.update(data)
  1288. mqtt_client.on_print_start = lambda d: None
  1289. mqtt_client.on_print_complete = on_complete
  1290. mqtt_client._captured_ams_mapping = [0, 9, -1, -1]
  1291. # Start print
  1292. mqtt_client._process_message(
  1293. {
  1294. "print": {
  1295. "gcode_state": "RUNNING",
  1296. "gcode_file": "/data/Metadata/test.gcode",
  1297. "subtask_name": "Test",
  1298. }
  1299. }
  1300. )
  1301. # Complete print
  1302. mqtt_client._process_message(
  1303. {
  1304. "print": {
  1305. "gcode_state": "FINISH",
  1306. "gcode_file": "/data/Metadata/test.gcode",
  1307. "subtask_name": "Test",
  1308. }
  1309. }
  1310. )
  1311. assert complete_data.get("ams_mapping") == [0, 9, -1, -1]
  1312. def test_captured_mapping_cleared_after_print_complete(self, mqtt_client):
  1313. """_captured_ams_mapping is reset to None after print completion."""
  1314. mqtt_client.on_print_start = lambda d: None
  1315. mqtt_client.on_print_complete = lambda d: None
  1316. mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
  1317. # Start print
  1318. mqtt_client._process_message(
  1319. {
  1320. "print": {
  1321. "gcode_state": "RUNNING",
  1322. "gcode_file": "/data/Metadata/test.gcode",
  1323. "subtask_name": "Test",
  1324. }
  1325. }
  1326. )
  1327. # Complete print
  1328. mqtt_client._process_message(
  1329. {
  1330. "print": {
  1331. "gcode_state": "FINISH",
  1332. "gcode_file": "/data/Metadata/test.gcode",
  1333. "subtask_name": "Test",
  1334. }
  1335. }
  1336. )
  1337. assert mqtt_client._captured_ams_mapping is None
  1338. def test_full_flow_capture_and_deliver(self, mqtt_client):
  1339. """Full flow: slicer sends print command → MQTT captures mapping → completion delivers it."""
  1340. complete_data = {}
  1341. def on_complete(data):
  1342. complete_data.update(data)
  1343. mqtt_client.on_print_start = lambda d: None
  1344. mqtt_client.on_print_complete = on_complete
  1345. # 1. Slicer sends print command (captured from request topic)
  1346. mqtt_client._handle_request_message(
  1347. {
  1348. "print": {
  1349. "command": "project_file",
  1350. "ams_mapping": [4, 9, -1, -1],
  1351. "url": "ftp://192.168.1.100/model.3mf",
  1352. }
  1353. }
  1354. )
  1355. assert mqtt_client._captured_ams_mapping == [4, 9, -1, -1]
  1356. # 2. Printer reports RUNNING
  1357. mqtt_client._process_message(
  1358. {
  1359. "print": {
  1360. "gcode_state": "RUNNING",
  1361. "gcode_file": "/data/Metadata/model.gcode",
  1362. "subtask_name": "Model",
  1363. }
  1364. }
  1365. )
  1366. # 3. Printer reports FINISH
  1367. mqtt_client._process_message(
  1368. {
  1369. "print": {
  1370. "gcode_state": "FINISH",
  1371. "gcode_file": "/data/Metadata/model.gcode",
  1372. "subtask_name": "Model",
  1373. }
  1374. }
  1375. )
  1376. assert complete_data["ams_mapping"] == [4, 9, -1, -1]
  1377. assert complete_data["status"] == "completed"
  1378. # Mapping cleared after completion
  1379. assert mqtt_client._captured_ams_mapping is None
  1380. # ---------------------------------------------------------------------------
  1381. # tray_now disambiguation helpers
  1382. # ---------------------------------------------------------------------------
  1383. def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None, ams_exist_bits=None):
  1384. """Build minimal print.ams payload for tray_now disambiguation tests."""
  1385. ams = {"tray_now": str(tray_now)}
  1386. if ams_units is not None:
  1387. ams["ams"] = ams_units
  1388. if tray_exist_bits is not None:
  1389. ams["tray_exist_bits"] = tray_exist_bits
  1390. if ams_exist_bits is not None:
  1391. ams["ams_exist_bits"] = ams_exist_bits
  1392. return {"print": {"ams": ams}}
  1393. def _extruder_info_payload(extruders):
  1394. """Build device.extruder.info payload (dual-nozzle detection + snow).
  1395. Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
  1396. """
  1397. return {
  1398. "print": {
  1399. "device": {
  1400. "extruder": {
  1401. "info": extruders,
  1402. }
  1403. }
  1404. }
  1405. }
  1406. def _extruder_state_payload(state_val):
  1407. """Build device.extruder.state payload (active extruder via bit 8)."""
  1408. return {
  1409. "print": {
  1410. "device": {
  1411. "extruder": {
  1412. "state": state_val,
  1413. }
  1414. }
  1415. }
  1416. }
  1417. # ---------------------------------------------------------------------------
  1418. # 1. Single-nozzle X1E — direct passthrough
  1419. # ---------------------------------------------------------------------------
  1420. class TestTrayNowSingleNozzleX1E:
  1421. """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
  1422. @pytest.fixture
  1423. def mqtt_client(self):
  1424. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1425. return BambuMQTTClient(
  1426. ip_address="192.168.1.100",
  1427. serial_number="TEST_X1E",
  1428. access_code="12345678",
  1429. )
  1430. def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
  1431. """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
  1432. for slot in range(4):
  1433. mqtt_client._process_message(_ams_payload(slot))
  1434. assert mqtt_client.state.tray_now == slot
  1435. def test_tray_now_255_means_unloaded(self, mqtt_client):
  1436. """tray_now=255 means no filament loaded."""
  1437. mqtt_client._process_message(_ams_payload(255))
  1438. assert mqtt_client.state.tray_now == 255
  1439. def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
  1440. """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
  1441. mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
  1442. assert mqtt_client._is_dual_nozzle is False
  1443. def test_last_loaded_tray_survives_unload(self, mqtt_client):
  1444. """Load tray 2, unload → last_loaded_tray stays 2."""
  1445. mqtt_client._process_message(_ams_payload(2))
  1446. assert mqtt_client.state.last_loaded_tray == 2
  1447. mqtt_client._process_message(_ams_payload(255))
  1448. assert mqtt_client.state.tray_now == 255
  1449. assert mqtt_client.state.last_loaded_tray == 2
  1450. # ---------------------------------------------------------------------------
  1451. # 2. Single-nozzle P2S — multiple AMS, global IDs pass through
  1452. # ---------------------------------------------------------------------------
  1453. class TestTrayNowSingleNozzleP2S:
  1454. """Single-nozzle, 2 AMS — tray_now > 3 passes through as global ID."""
  1455. @pytest.fixture
  1456. def mqtt_client(self):
  1457. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1458. return BambuMQTTClient(
  1459. ip_address="192.168.1.100",
  1460. serial_number="TEST_P2S",
  1461. access_code="12345678",
  1462. )
  1463. def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
  1464. """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
  1465. for global_id in range(4, 8):
  1466. mqtt_client._process_message(_ams_payload(global_id))
  1467. assert mqtt_client.state.tray_now == global_id
  1468. def test_tray_change_across_ams_units(self, mqtt_client):
  1469. """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
  1470. mqtt_client._process_message(_ams_payload(1))
  1471. assert mqtt_client.state.tray_now == 1
  1472. mqtt_client._process_message(_ams_payload(6))
  1473. assert mqtt_client.state.tray_now == 6
  1474. # ---------------------------------------------------------------------------
  1475. # 2b. Single-nozzle P2S — multi-AMS local slot disambiguation (#420)
  1476. # ---------------------------------------------------------------------------
  1477. class TestTrayNowP2SMultiAmsDisambiguation:
  1478. """P2S firmware sends local slot IDs (0-3) in tray_now even with dual AMS.
  1479. When ams_exist_bits indicates >1 AMS unit and tray_now is 0-3, the backend
  1480. should use the MQTT mapping field (snow-encoded) to resolve the correct
  1481. global tray ID.
  1482. """
  1483. @pytest.fixture
  1484. def mqtt_client(self):
  1485. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1486. client = BambuMQTTClient(
  1487. ip_address="192.168.1.100",
  1488. serial_number="TEST_P2S_DUAL",
  1489. access_code="12345678",
  1490. )
  1491. return client
  1492. def test_resolves_ams1_slot1_from_mapping(self, mqtt_client):
  1493. """tray_now=1 with mapping=[257] → global ID 5 (AMS1-T1).
  1494. 257 snow-decoded: ams_hw_id=1, slot=1 → global 1*4+1=5.
  1495. """
  1496. # Set mapping field in raw_data (as the MQTT handler would)
  1497. mqtt_client.state.raw_data["mapping"] = [257]
  1498. mqtt_client._process_message(
  1499. _ams_payload(1, ams_exist_bits="3") # '3' = 0b11 → AMS 0 and 1
  1500. )
  1501. assert mqtt_client.state.tray_now == 5
  1502. def test_resolves_ams1_slot0_from_mapping(self, mqtt_client):
  1503. """tray_now=0 with mapping=[256] → global ID 4 (AMS1-T0).
  1504. 256 snow-decoded: ams_hw_id=1, slot=0 → global 1*4+0=4.
  1505. """
  1506. mqtt_client.state.raw_data["mapping"] = [256]
  1507. mqtt_client._process_message(_ams_payload(0, ams_exist_bits="3"))
  1508. assert mqtt_client.state.tray_now == 4
  1509. def test_resolves_ams1_slot3_from_mapping(self, mqtt_client):
  1510. """tray_now=3 with mapping=[259] → global ID 7 (AMS1-T3).
  1511. 259 snow-decoded: ams_hw_id=1, slot=3 → global 1*4+3=7.
  1512. """
  1513. mqtt_client.state.raw_data["mapping"] = [259]
  1514. mqtt_client._process_message(_ams_payload(3, ams_exist_bits="3"))
  1515. assert mqtt_client.state.tray_now == 7
  1516. def test_ams0_slot_unchanged_when_mapping_confirms_ams0(self, mqtt_client):
  1517. """tray_now=1 with mapping=[1] → stays 1 (AMS0-T1).
  1518. 1 snow-decoded: ams_hw_id=0, slot=1 → global 0*4+1=1.
  1519. """
  1520. mqtt_client.state.raw_data["mapping"] = [1]
  1521. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1522. assert mqtt_client.state.tray_now == 1
  1523. def test_multicolor_resolves_ams1_from_multi_entry_mapping(self, mqtt_client):
  1524. """Multi-color print: mapping=[0, 257] → tray_now=1 resolves to AMS1-T1 (5).
  1525. Entry 0: ams_hw_id=0, slot=0 (local 0) — doesn't match tray_now=1.
  1526. Entry 257: ams_hw_id=1, slot=1 (local 1) — matches tray_now=1 → global 5.
  1527. """
  1528. mqtt_client.state.raw_data["mapping"] = [0, 257]
  1529. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1530. assert mqtt_client.state.tray_now == 5
  1531. def test_multicolor_four_slot_mapping(self, mqtt_client):
  1532. """mapping=[65535, 65535, 65535, 257] → tray_now=1 resolves to global 5.
  1533. Only entry 257 has local slot=1, other entries are unmapped (65535).
  1534. Reproduces exact data from issue #420 support package.
  1535. """
  1536. mqtt_client.state.raw_data["mapping"] = [65535, 65535, 65535, 257]
  1537. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1538. assert mqtt_client.state.tray_now == 5
  1539. def test_ambiguous_mapping_falls_back_to_local_slot(self, mqtt_client):
  1540. """Two AMS units with same local slot in mapping → ambiguous, keep local slot.
  1541. mapping=[1, 257]: both have local slot 1 (AMS0-T1 and AMS1-T1).
  1542. Cannot disambiguate → fall back to tray_now=1.
  1543. """
  1544. mqtt_client.state.raw_data["mapping"] = [1, 257]
  1545. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1546. assert mqtt_client.state.tray_now == 1
  1547. def test_no_mapping_falls_back_to_local_slot(self, mqtt_client):
  1548. """No mapping field available → fall back to raw tray_now."""
  1549. # No mapping in raw_data (e.g. manual filament load, not during print)
  1550. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1551. assert mqtt_client.state.tray_now == 1
  1552. def test_empty_mapping_falls_back_to_local_slot(self, mqtt_client):
  1553. """Empty mapping list → fall back to raw tray_now."""
  1554. mqtt_client.state.raw_data["mapping"] = []
  1555. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1556. assert mqtt_client.state.tray_now == 1
  1557. def test_single_ams_passthrough(self, mqtt_client):
  1558. """Single AMS (ams_exist_bits='1') → tray_now 0-3 is direct global ID."""
  1559. mqtt_client._process_message(_ams_payload(2, ams_exist_bits="1"))
  1560. assert mqtt_client.state.tray_now == 2
  1561. def test_no_ams_exist_bits_passthrough(self, mqtt_client):
  1562. """No ams_exist_bits in payload → fall back to raw tray_now."""
  1563. mqtt_client._process_message(_ams_payload(1))
  1564. assert mqtt_client.state.tray_now == 1
  1565. def test_tray_now_255_unaffected_by_multi_ams(self, mqtt_client):
  1566. """tray_now=255 (unloaded) passes through regardless of AMS count."""
  1567. mqtt_client.state.raw_data["mapping"] = [257]
  1568. mqtt_client._process_message(_ams_payload(255, ams_exist_bits="3"))
  1569. assert mqtt_client.state.tray_now == 255
  1570. def test_tray_now_above_3_unaffected(self, mqtt_client):
  1571. """tray_now > 3 is already a global ID and passes through directly."""
  1572. mqtt_client._process_message(_ams_payload(6, ams_exist_bits="3"))
  1573. assert mqtt_client.state.tray_now == 6
  1574. def test_last_loaded_tray_uses_resolved_global_id(self, mqtt_client):
  1575. """last_loaded_tray should reflect the resolved global ID, not local slot."""
  1576. mqtt_client.state.raw_data["mapping"] = [257]
  1577. mqtt_client.state.state = "RUNNING"
  1578. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1579. assert mqtt_client.state.tray_now == 5
  1580. assert mqtt_client.state.last_loaded_tray == 5
  1581. class TestResolveLocalSlotFromMapping:
  1582. """Unit tests for _resolve_local_slot_from_mapping static method."""
  1583. def test_single_match_ams0(self):
  1584. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1585. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1]) == 1
  1586. def test_single_match_ams1(self):
  1587. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1588. # 257 = 1*256 + 1 → AMS1 slot1 → global 5
  1589. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [257]) == 5
  1590. def test_single_match_ams2(self):
  1591. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1592. # 514 = 2*256 + 2 → AMS2 slot2 → global 10
  1593. assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514]) == 10
  1594. def test_unmapped_entries_skipped(self):
  1595. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1596. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [65535, 65535, 65535, 257]) == 5
  1597. def test_no_match_returns_none(self):
  1598. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1599. # mapping has slot 0 only, looking for slot 2
  1600. assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [0]) is None
  1601. def test_ambiguous_returns_none(self):
  1602. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1603. # Both AMS0 slot1 (1) and AMS1 slot1 (257) → ambiguous
  1604. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 257]) is None
  1605. def test_none_mapping_returns_none(self):
  1606. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1607. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, None) is None
  1608. def test_empty_mapping_returns_none(self):
  1609. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1610. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, []) is None
  1611. def test_ams_ht_slot0_match(self):
  1612. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1613. # AMS-HT id=128: snow = 128*256 + 0 = 32768
  1614. assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128
  1615. # ---------------------------------------------------------------------------
  1616. # 3. H2D Pro — initial state detection
  1617. # ---------------------------------------------------------------------------
  1618. class TestTrayNowDualNozzleH2DSetup:
  1619. """H2D Pro initial state detection."""
  1620. @pytest.fixture
  1621. def mqtt_client(self):
  1622. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1623. return BambuMQTTClient(
  1624. ip_address="192.168.1.100",
  1625. serial_number="TEST_H2D",
  1626. access_code="12345678",
  1627. )
  1628. def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
  1629. """2 entries in device.extruder.info → _is_dual_nozzle=True."""
  1630. mqtt_client._process_message(
  1631. _extruder_info_payload(
  1632. [
  1633. {"id": 0, "snow": 0xFF00FF},
  1634. {"id": 1, "snow": 0xFF00FF},
  1635. ]
  1636. )
  1637. )
  1638. assert mqtt_client._is_dual_nozzle is True
  1639. def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
  1640. """AMS info field is hex: 0x2003 → ext 0 (right), 0x2104 → ext 1 (left)."""
  1641. # MQTT sends info as string; BambuStudio parses as hex via stoull(str, 16)
  1642. ams_units = [
  1643. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1644. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  1645. ]
  1646. payload = {
  1647. "print": {
  1648. "ams": {
  1649. "ams": ams_units,
  1650. "tray_now": "255",
  1651. "tray_exist_bits": "1000f",
  1652. },
  1653. }
  1654. }
  1655. mqtt_client._process_message(payload)
  1656. # 0x2003: bits 8-11 = (0x2003 >> 8) & 0xF = 0x20 & 0xF = 0 → extruder 0 (right)
  1657. # 0x2104: bits 8-11 = (0x2104 >> 8) & 0xF = 0x21 & 0xF = 1 → extruder 1 (left)
  1658. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1659. def test_ams_extruder_map_real_h2d_values(self, mqtt_client):
  1660. """Real H2D MQTT values: AMS2 Pro on right, AMS-HT on left."""
  1661. ams_units = [
  1662. {"id": 0, "info": "10001003", "tray": [{"id": i} for i in range(4)]},
  1663. {"id": 128, "info": "10002104", "tray": [{"id": 0}]},
  1664. ]
  1665. payload = {
  1666. "print": {
  1667. "ams": {
  1668. "ams": ams_units,
  1669. "tray_now": "255",
  1670. "tray_exist_bits": "1000a",
  1671. },
  1672. }
  1673. }
  1674. mqtt_client._process_message(payload)
  1675. # 0x10001003: bits 8-11 = (0x10001003 >> 8) & 0xF = 0x10 & 0xF = 0 → right
  1676. # 0x10002104: bits 8-11 = (0x10002104 >> 8) & 0xF = 0x21 & 0xF = 1 → left
  1677. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1678. def test_ams_extruder_map_skips_uninitialized(self, mqtt_client):
  1679. """extruder_id 0xE means uninitialized AMS — should be skipped."""
  1680. ams_units = [
  1681. {"id": 0, "info": "e03", "tray": [{"id": i} for i in range(4)]},
  1682. ]
  1683. payload = {
  1684. "print": {
  1685. "ams": {
  1686. "ams": ams_units,
  1687. "tray_now": "255",
  1688. "tray_exist_bits": "f",
  1689. },
  1690. }
  1691. }
  1692. mqtt_client._process_message(payload)
  1693. assert mqtt_client.state.ams_extruder_map == {}
  1694. def test_ams_extruder_map_partial_update_preserves_entries(self, mqtt_client):
  1695. """Partial MQTT update with one AMS should not overwrite other entries."""
  1696. # First: full update with both AMS units
  1697. full_payload = {
  1698. "print": {
  1699. "ams": {
  1700. "ams": [
  1701. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1702. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  1703. ],
  1704. "tray_now": "255",
  1705. "tray_exist_bits": "1000f",
  1706. },
  1707. }
  1708. }
  1709. mqtt_client._process_message(full_payload)
  1710. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1711. # Then: partial update with only AMS 0 (no info field this time)
  1712. partial_payload = {
  1713. "print": {
  1714. "ams": {
  1715. "ams": [
  1716. {"id": 0, "tray": [{"id": 0, "remain": 50}]},
  1717. ],
  1718. "tray_now": "0",
  1719. "tray_exist_bits": "1000f",
  1720. },
  1721. }
  1722. }
  1723. mqtt_client._process_message(partial_payload)
  1724. # Both entries should still be present
  1725. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1726. def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
  1727. """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
  1728. If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
  1729. """
  1730. payload = {
  1731. "print": {
  1732. "device": {
  1733. "extruder": {
  1734. "info": [
  1735. {"id": 0, "snow": 0xFF00FF},
  1736. {"id": 1, "snow": 0xFF00FF},
  1737. ],
  1738. "state": 0x0001,
  1739. }
  1740. },
  1741. "ams": {
  1742. "ams": [
  1743. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1744. ],
  1745. "tray_now": "2",
  1746. "tray_exist_bits": "f",
  1747. },
  1748. }
  1749. }
  1750. mqtt_client._process_message(payload)
  1751. # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
  1752. # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
  1753. # Single AMS on extruder 0 → global_id = 0*4+2 = 2
  1754. assert mqtt_client._is_dual_nozzle is True
  1755. assert mqtt_client.state.tray_now == 2
  1756. # ---------------------------------------------------------------------------
  1757. # Shared H2D fixture for classes 4-8
  1758. # ---------------------------------------------------------------------------
  1759. class _H2DFixtureMixin:
  1760. """Mixin providing a pre-configured H2D Pro client."""
  1761. @pytest.fixture
  1762. def mqtt_client(self):
  1763. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1764. return BambuMQTTClient(
  1765. ip_address="192.168.1.100",
  1766. serial_number="TEST_H2D",
  1767. access_code="12345678",
  1768. )
  1769. @pytest.fixture
  1770. def h2d_client(self, mqtt_client):
  1771. """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
  1772. mqtt_client._process_message(
  1773. {
  1774. "print": {
  1775. "device": {
  1776. "extruder": {
  1777. "info": [
  1778. {"id": 0, "snow": 0xFF00FF},
  1779. {"id": 1, "snow": 0xFF00FF},
  1780. ],
  1781. "state": 0x0001, # right extruder active
  1782. }
  1783. },
  1784. "ams": {
  1785. "ams": [
  1786. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1787. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  1788. ],
  1789. "tray_now": "255",
  1790. "tray_exist_bits": "1000f",
  1791. },
  1792. }
  1793. }
  1794. )
  1795. assert mqtt_client._is_dual_nozzle is True
  1796. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1797. return mqtt_client
  1798. # ---------------------------------------------------------------------------
  1799. # 4. H2D Snow field disambiguation
  1800. # ---------------------------------------------------------------------------
  1801. class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
  1802. """Snow field disambiguation (primary path)."""
  1803. def test_snow_disambiguates_ams0_slot(self, h2d_client):
  1804. """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
  1805. # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
  1806. # so we need it in a prior message).
  1807. snow_val = 0 << 8 | 2 # AMS 0 slot 2 = raw 2
  1808. h2d_client._process_message(
  1809. _extruder_info_payload(
  1810. [
  1811. {"id": 0, "snow": snow_val},
  1812. {"id": 1, "snow": 0xFF00FF},
  1813. ]
  1814. )
  1815. )
  1816. assert h2d_client.state.h2d_extruder_snow.get(0) == 2
  1817. # Now send tray_now=2
  1818. h2d_client._process_message(_ams_payload(2))
  1819. assert h2d_client.state.tray_now == 2
  1820. def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
  1821. """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
  1822. # Snow: extruder 1 → AMS 128 slot 0
  1823. snow_val = 128 << 8 | 0 # = 32768
  1824. h2d_client._process_message(
  1825. _extruder_info_payload(
  1826. [
  1827. {"id": 0, "snow": 0xFF00FF},
  1828. {"id": 1, "snow": snow_val},
  1829. ]
  1830. )
  1831. )
  1832. assert h2d_client.state.h2d_extruder_snow.get(1) == 128
  1833. # Switch to left extruder
  1834. h2d_client._process_message(_extruder_state_payload(0x0100))
  1835. assert h2d_client.state.active_extruder == 1
  1836. # tray_now="0" with left extruder active, snow says AMS HT (128)
  1837. # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
  1838. h2d_client._process_message(_ams_payload(0))
  1839. assert h2d_client.state.tray_now == 128
  1840. def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
  1841. """Verify state.h2d_extruder_snow dict is populated correctly."""
  1842. snow_ext0 = 1 << 8 | 3 # AMS 1 slot 3 → global 7
  1843. snow_ext1 = 0 << 8 | 0 # AMS 0 slot 0 → global 0
  1844. h2d_client._process_message(
  1845. _extruder_info_payload(
  1846. [
  1847. {"id": 0, "snow": snow_ext0},
  1848. {"id": 1, "snow": snow_ext1},
  1849. ]
  1850. )
  1851. )
  1852. assert h2d_client.state.h2d_extruder_snow[0] == 7
  1853. assert h2d_client.state.h2d_extruder_snow[1] == 0
  1854. def test_snow_unloaded_value(self, h2d_client):
  1855. """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
  1856. h2d_client._process_message(
  1857. _extruder_info_payload(
  1858. [
  1859. {"id": 0, "snow": 0xFFFF},
  1860. {"id": 1, "snow": 0xFFFF},
  1861. ]
  1862. )
  1863. )
  1864. assert h2d_client.state.h2d_extruder_snow[0] == 255
  1865. assert h2d_client.state.h2d_extruder_snow[1] == 255
  1866. def test_snow_initial_sentinel_not_stored(self, h2d_client):
  1867. """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
  1868. # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
  1869. h2d_client._process_message(
  1870. _extruder_info_payload(
  1871. [
  1872. {"id": 0, "snow": 0xFF00FF},
  1873. {"id": 1, "snow": 0xFF00FF},
  1874. ]
  1875. )
  1876. )
  1877. # Snow dict should remain empty (no matching branch)
  1878. assert h2d_client.state.h2d_extruder_snow == {}
  1879. # ---------------------------------------------------------------------------
  1880. # 5. H2D Pending target disambiguation
  1881. # ---------------------------------------------------------------------------
  1882. class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
  1883. """Pending target disambiguation (when Bambuddy initiates load)."""
  1884. def test_pending_target_matches_slot(self, h2d_client):
  1885. """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
  1886. h2d_client.state.pending_tray_target = 5
  1887. h2d_client._process_message(_ams_payload(1))
  1888. assert h2d_client.state.tray_now == 5
  1889. assert h2d_client.state.pending_tray_target is None # cleared
  1890. def test_pending_target_slot_mismatch(self, h2d_client):
  1891. """pending=5, tray_now='2' → uses raw slot, clears pending."""
  1892. h2d_client.state.pending_tray_target = 5
  1893. h2d_client._process_message(_ams_payload(2))
  1894. # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
  1895. assert h2d_client.state.tray_now == 2
  1896. assert h2d_client.state.pending_tray_target is None
  1897. def test_pending_target_takes_priority_over_snow(self, h2d_client):
  1898. """When both pending and snow are set, pending wins."""
  1899. # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
  1900. snow_val = 0 << 8 | 1
  1901. h2d_client._process_message(
  1902. _extruder_info_payload(
  1903. [
  1904. {"id": 0, "snow": snow_val},
  1905. {"id": 1, "snow": 0xFF00FF},
  1906. ]
  1907. )
  1908. )
  1909. assert h2d_client.state.h2d_extruder_snow.get(0) == 1
  1910. # Set pending target to AMS 1 slot 1 (global 5)
  1911. h2d_client.state.pending_tray_target = 5
  1912. # tray_now="1" — matches pending (5%4=1), pending should win over snow
  1913. h2d_client._process_message(_ams_payload(1))
  1914. assert h2d_client.state.tray_now == 5
  1915. # ---------------------------------------------------------------------------
  1916. # 6. H2D ams_extruder_map fallback
  1917. # ---------------------------------------------------------------------------
  1918. class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
  1919. """ams_extruder_map fallback (no pending, no snow)."""
  1920. def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
  1921. """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
  1922. # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
  1923. h2d_client._process_message(_ams_payload(2))
  1924. # AMS 0 is the only AMS on extruder 0 (right, active by default)
  1925. # Fallback: single AMS → global = 0*4+2 = 2
  1926. assert h2d_client.state.tray_now == 2
  1927. def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
  1928. """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
  1929. # Set up: two AMS units on the same extruder (right, ext 0)
  1930. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
  1931. # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
  1932. h2d_client.state.tray_now = 5
  1933. # tray_now="1" → 5%4=1 matches → keep current=5
  1934. h2d_client._process_message(_ams_payload(1))
  1935. assert h2d_client.state.tray_now == 5
  1936. def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
  1937. """No AMS mapped to the active extruder → raw slot as global ID."""
  1938. # All AMS on left extruder, but right is active
  1939. h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
  1940. h2d_client._process_message(_ams_payload(2))
  1941. assert h2d_client.state.tray_now == 2
  1942. def test_single_ams_ht_on_extruder_returns_unit_id(self, h2d_client):
  1943. """AMS-HT 128 alone on left extruder, slot 0 → global ID 128 (not 512)."""
  1944. # Switch to left extruder (where AMS-HT 128 is mapped)
  1945. h2d_client._process_message(_extruder_state_payload(0x0100))
  1946. # Only AMS-HT 128 on left extruder; no snow available
  1947. h2d_client._process_message(_ams_payload(0))
  1948. assert h2d_client.state.tray_now == 128
  1949. def test_single_ams_ht_ignores_nonzero_slot(self, h2d_client):
  1950. """AMS-HT has single slot; even if printer reports slot 1, global ID = unit ID."""
  1951. h2d_client.state.ams_extruder_map = {"129": 0}
  1952. h2d_client._process_message(_ams_payload(1))
  1953. # AMS-HT 129: global ID = 129, not 129*4+1=517
  1954. assert h2d_client.state.tray_now == 129
  1955. def test_multiple_ams_keeps_current_ams_ht(self, h2d_client):
  1956. """Current tray is AMS-HT 128, slot 0 reported → keeps 128."""
  1957. h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
  1958. h2d_client.state.tray_now = 128
  1959. h2d_client._process_message(_ams_payload(0))
  1960. assert h2d_client.state.tray_now == 128
  1961. def test_multiple_ams_slot_nonzero_excludes_ams_ht(self, h2d_client):
  1962. """Slot > 0 eliminates AMS-HT candidates; single regular AMS left → resolves."""
  1963. # AMS 0 + AMS-HT 128 both on right extruder
  1964. h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
  1965. h2d_client.state.tray_now = 255 # no current match
  1966. # Slot 2 → can't be AMS-HT → only AMS 0 → global = 0*4+2 = 2
  1967. h2d_client._process_message(_ams_payload(2))
  1968. assert h2d_client.state.tray_now == 2
  1969. def test_multiple_ams_slot_nonzero_narrows_to_single_ht_excluded(self, h2d_client):
  1970. """Two regular AMS + one AMS-HT, slot > 0 → AMS-HT excluded but still ambiguous."""
  1971. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "128": 0}
  1972. h2d_client.state.tray_now = 255
  1973. # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
  1974. h2d_client._process_message(_ams_payload(3))
  1975. assert h2d_client.state.tray_now == 3 # raw slot fallback
  1976. # ---------------------------------------------------------------------------
  1977. # 6b. H2D last_loaded_tray validation
  1978. # ---------------------------------------------------------------------------
  1979. class TestLastLoadedTrayValidation(_H2DFixtureMixin):
  1980. """last_loaded_tray only stores physically valid tray IDs."""
  1981. def test_regular_ams_tray_stored(self, h2d_client):
  1982. """Valid regular AMS tray (0-15) → stored in last_loaded_tray."""
  1983. h2d_client.state.tray_now = 7
  1984. # Trigger tray_now processing via AMS message
  1985. h2d_client._process_message(
  1986. _extruder_info_payload(
  1987. [
  1988. {"id": 0, "snow": 1 << 8 | 3}, # AMS 1 slot 3 → global 7
  1989. {"id": 1, "snow": 0xFF00FF},
  1990. ]
  1991. )
  1992. )
  1993. h2d_client._process_message(_ams_payload(3))
  1994. assert h2d_client.state.tray_now == 7
  1995. assert h2d_client.state.last_loaded_tray == 7
  1996. def test_ams_ht_tray_stored(self, h2d_client):
  1997. """Valid AMS-HT tray (128-135) → stored in last_loaded_tray."""
  1998. h2d_client._process_message(_extruder_state_payload(0x0100))
  1999. h2d_client._process_message(
  2000. _extruder_info_payload(
  2001. [
  2002. {"id": 0, "snow": 0xFF00FF},
  2003. {"id": 1, "snow": 128 << 8 | 0},
  2004. ]
  2005. )
  2006. )
  2007. h2d_client._process_message(_ams_payload(0))
  2008. assert h2d_client.state.tray_now == 128
  2009. assert h2d_client.state.last_loaded_tray == 128
  2010. def test_unloaded_not_stored(self, h2d_client):
  2011. """tray_now=255 (unloaded) → last_loaded_tray unchanged."""
  2012. h2d_client.state.last_loaded_tray = 5
  2013. h2d_client._process_message(_ams_payload(255))
  2014. assert h2d_client.state.tray_now == 255
  2015. assert h2d_client.state.last_loaded_tray == 5
  2016. # ---------------------------------------------------------------------------
  2017. # 7. H2D Active extruder switching
  2018. # ---------------------------------------------------------------------------
  2019. class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
  2020. """Active extruder switching via device.extruder.state bit 8."""
  2021. def test_active_extruder_right_by_default(self, h2d_client):
  2022. """Initial state.active_extruder == 0 (right)."""
  2023. assert h2d_client.state.active_extruder == 0
  2024. def test_extruder_state_bit8_switches_to_left(self, h2d_client):
  2025. """state=0x100 → active_extruder=1 (left)."""
  2026. h2d_client._process_message(_extruder_state_payload(0x0100))
  2027. assert h2d_client.state.active_extruder == 1
  2028. def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
  2029. """Cycle 0 → 1 → 0."""
  2030. h2d_client._process_message(_extruder_state_payload(0x0100))
  2031. assert h2d_client.state.active_extruder == 1
  2032. h2d_client._process_message(_extruder_state_payload(0x0001))
  2033. assert h2d_client.state.active_extruder == 0
  2034. def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
  2035. """Snow on both extruders; switching active changes which snow is used."""
  2036. # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
  2037. h2d_client._process_message(
  2038. _extruder_info_payload(
  2039. [
  2040. {"id": 0, "snow": 0 << 8 | 1}, # AMS 0 slot 1 → global 1
  2041. {"id": 1, "snow": 128 << 8 | 0}, # AMS HT → global 128
  2042. ]
  2043. )
  2044. )
  2045. # Right active (default) — tray_now="1" → snow ext[0] says global 1
  2046. h2d_client._process_message(_ams_payload(1))
  2047. assert h2d_client.state.tray_now == 1
  2048. # Switch to left
  2049. h2d_client._process_message(_extruder_state_payload(0x0100))
  2050. # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
  2051. h2d_client._process_message(_ams_payload(0))
  2052. assert h2d_client.state.tray_now == 128
  2053. # ---------------------------------------------------------------------------
  2054. # 8. H2D Full multi-message sequences
  2055. # ---------------------------------------------------------------------------
  2056. class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
  2057. """Multi-message sequences simulating real H2D Pro prints."""
  2058. def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
  2059. """Setup → load AMS 0 slot 1 → verify tray_now=1."""
  2060. # Snow update: extruder 0 loading AMS 0 slot 1
  2061. h2d_client._process_message(
  2062. _extruder_info_payload(
  2063. [
  2064. {"id": 0, "snow": 0 << 8 | 1},
  2065. {"id": 1, "snow": 0xFF00FF},
  2066. ]
  2067. )
  2068. )
  2069. # Printer reports tray_now="1"
  2070. h2d_client._process_message(_ams_payload(1))
  2071. assert h2d_client.state.tray_now == 1
  2072. assert h2d_client.state.last_loaded_tray == 1
  2073. def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
  2074. """Setup → switch left → load AMS HT → verify tray_now=128."""
  2075. # Switch to left extruder
  2076. h2d_client._process_message(_extruder_state_payload(0x0100))
  2077. # Snow: ext 1 → AMS HT slot 0
  2078. h2d_client._process_message(
  2079. _extruder_info_payload(
  2080. [
  2081. {"id": 0, "snow": 0xFF00FF},
  2082. {"id": 1, "snow": 128 << 8 | 0},
  2083. ]
  2084. )
  2085. )
  2086. # Printer reports tray_now="0" (AMS HT single slot)
  2087. h2d_client._process_message(_ams_payload(0))
  2088. assert h2d_client.state.tray_now == 128
  2089. assert h2d_client.state.last_loaded_tray == 128
  2090. def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
  2091. """Multi-color print alternating between right and left nozzles.
  2092. Sequence:
  2093. 1. Right loads AMS 0 slot 0 (tray=0)
  2094. 2. Switch left, load AMS HT (tray=128)
  2095. 3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
  2096. 4. Unload (255)
  2097. """
  2098. # Step 1: Right extruder loads AMS 0 slot 0
  2099. h2d_client._process_message(
  2100. _extruder_info_payload(
  2101. [
  2102. {"id": 0, "snow": 0 << 8 | 0},
  2103. {"id": 1, "snow": 0xFF00FF},
  2104. ]
  2105. )
  2106. )
  2107. h2d_client._process_message(_ams_payload(0))
  2108. assert h2d_client.state.tray_now == 0
  2109. # Step 2: Switch to left, load AMS HT
  2110. h2d_client._process_message(_extruder_state_payload(0x0100))
  2111. h2d_client._process_message(
  2112. _extruder_info_payload(
  2113. [
  2114. {"id": 0, "snow": 0 << 8 | 0},
  2115. {"id": 1, "snow": 128 << 8 | 0},
  2116. ]
  2117. )
  2118. )
  2119. h2d_client._process_message(_ams_payload(0))
  2120. assert h2d_client.state.tray_now == 128
  2121. # Step 3: Switch back to right, load AMS 0 slot 2
  2122. h2d_client._process_message(_extruder_state_payload(0x0001))
  2123. h2d_client._process_message(
  2124. _extruder_info_payload(
  2125. [
  2126. {"id": 0, "snow": 0 << 8 | 2},
  2127. {"id": 1, "snow": 128 << 8 | 0},
  2128. ]
  2129. )
  2130. )
  2131. h2d_client._process_message(_ams_payload(2))
  2132. assert h2d_client.state.tray_now == 2
  2133. # Step 4: Unload
  2134. h2d_client._process_message(_ams_payload(255))
  2135. assert h2d_client.state.tray_now == 255
  2136. assert h2d_client.state.last_loaded_tray == 2
  2137. class TestTrayChangeLog:
  2138. """Tests for tray_change_log tracking during prints (mid-print tray switch)."""
  2139. @pytest.fixture
  2140. def mqtt_client(self):
  2141. """Create a BambuMQTTClient instance for testing."""
  2142. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2143. client = BambuMQTTClient(
  2144. ip_address="192.168.1.100",
  2145. serial_number="TRAYLOG1",
  2146. access_code="12345678",
  2147. )
  2148. return client
  2149. def test_tray_change_log_defaults_empty(self, mqtt_client):
  2150. """tray_change_log starts as an empty list."""
  2151. assert mqtt_client.state.tray_change_log == []
  2152. def test_tray_change_log_seeded_on_print_start(self, mqtt_client):
  2153. """Print start clears log and seeds with initial tray at layer 0."""
  2154. mqtt_client.state.tray_now = 2
  2155. mqtt_client.state.last_loaded_tray = 2
  2156. mqtt_client._previous_gcode_state = "IDLE"
  2157. # Transition to RUNNING via _process_message
  2158. mqtt_client._process_message(
  2159. {
  2160. "print": {
  2161. "gcode_state": "RUNNING",
  2162. "gcode_file": "test.3mf",
  2163. }
  2164. }
  2165. )
  2166. assert mqtt_client.state.tray_change_log == [(2, 0)]
  2167. def test_tray_change_log_cleared_on_new_print(self, mqtt_client):
  2168. """Old log entries are cleared when a new print starts."""
  2169. mqtt_client.state.tray_change_log = [(5, 0), (3, 100)]
  2170. mqtt_client.state.tray_now = 1
  2171. mqtt_client.state.last_loaded_tray = 1
  2172. mqtt_client._previous_gcode_state = "IDLE"
  2173. mqtt_client._process_message(
  2174. {
  2175. "print": {
  2176. "gcode_state": "RUNNING",
  2177. "gcode_file": "new.3mf",
  2178. }
  2179. }
  2180. )
  2181. assert mqtt_client.state.tray_change_log == [(1, 0)]
  2182. def test_tray_change_recorded_during_running(self, mqtt_client):
  2183. """Tray change while RUNNING is appended to the log."""
  2184. mqtt_client.state.state = "RUNNING"
  2185. mqtt_client.state.layer_num = 50
  2186. mqtt_client.state.last_loaded_tray = 0
  2187. mqtt_client.state.tray_change_log = [(0, 0)]
  2188. # Simulate tray_now update via AMS data
  2189. mqtt_client.state.tray_now = 1
  2190. # Trigger the tracking code path
  2191. tn = mqtt_client.state.tray_now
  2192. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  2193. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  2194. mqtt_client.state.last_loaded_tray = tn
  2195. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]
  2196. def test_tray_change_not_recorded_when_idle(self, mqtt_client):
  2197. """Tray changes while IDLE are NOT logged."""
  2198. mqtt_client.state.state = "IDLE"
  2199. mqtt_client.state.layer_num = 0
  2200. mqtt_client.state.last_loaded_tray = 0
  2201. mqtt_client.state.tray_change_log = []
  2202. mqtt_client.state.tray_now = 3
  2203. tn = mqtt_client.state.tray_now
  2204. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  2205. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  2206. mqtt_client.state.last_loaded_tray = tn
  2207. assert mqtt_client.state.tray_change_log == []
  2208. def test_tray_change_recorded_during_pause(self, mqtt_client):
  2209. """Tray change while PAUSE is also logged (AMS can swap during pause)."""
  2210. mqtt_client.state.state = "PAUSE"
  2211. mqtt_client.state.layer_num = 75
  2212. mqtt_client.state.last_loaded_tray = 2
  2213. mqtt_client.state.tray_change_log = [(2, 0)]
  2214. mqtt_client.state.tray_now = 5
  2215. tn = mqtt_client.state.tray_now
  2216. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  2217. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  2218. mqtt_client.state.last_loaded_tray = tn
  2219. assert mqtt_client.state.tray_change_log == [(2, 0), (5, 75)]
  2220. def test_same_tray_not_logged_twice(self, mqtt_client):
  2221. """Same tray value doesn't create duplicate log entries."""
  2222. mqtt_client.state.state = "RUNNING"
  2223. mqtt_client.state.layer_num = 30
  2224. mqtt_client.state.last_loaded_tray = 2
  2225. mqtt_client.state.tray_change_log = [(2, 0)]
  2226. # Same tray again
  2227. mqtt_client.state.tray_now = 2
  2228. tn = mqtt_client.state.tray_now
  2229. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  2230. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  2231. mqtt_client.state.last_loaded_tray = tn
  2232. assert mqtt_client.state.tray_change_log == [(2, 0)]
  2233. def test_multiple_tray_changes(self, mqtt_client):
  2234. """Multiple tray changes create a full history."""
  2235. mqtt_client.state.state = "RUNNING"
  2236. mqtt_client.state.last_loaded_tray = 0
  2237. mqtt_client.state.tray_change_log = [(0, 0)]
  2238. changes = [(1, 50), (3, 120), (0, 200)]
  2239. for tray, layer in changes:
  2240. mqtt_client.state.tray_now = tray
  2241. mqtt_client.state.layer_num = layer
  2242. tn = mqtt_client.state.tray_now
  2243. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  2244. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  2245. mqtt_client.state.last_loaded_tray = tn
  2246. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50), (3, 120), (0, 200)]
  2247. class TestDeveloperModeDetection:
  2248. """Tests for developer LAN mode detection from MQTT 'fun' field."""
  2249. @pytest.fixture
  2250. def mqtt_client(self):
  2251. """Create a BambuMQTTClient instance for testing."""
  2252. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2253. client = BambuMQTTClient(
  2254. ip_address="192.168.1.100",
  2255. serial_number="TEST123",
  2256. access_code="12345678",
  2257. )
  2258. return client
  2259. def test_developer_mode_initially_none(self, mqtt_client):
  2260. """Verify developer_mode starts as None (unknown)."""
  2261. assert mqtt_client.state.developer_mode is None
  2262. def test_developer_mode_on_when_bit_clear(self, mqtt_client):
  2263. """Verify developer_mode is True when bit 0x20000000 is clear."""
  2264. # Bit 29 clear in lower 32 bits = developer mode ON
  2265. payload = {
  2266. "print": {
  2267. "gcode_state": "IDLE",
  2268. "fun": "1C8187FF9CFF",
  2269. }
  2270. }
  2271. mqtt_client._process_message(payload)
  2272. assert mqtt_client.state.developer_mode is True
  2273. def test_developer_mode_off_when_bit_set(self, mqtt_client):
  2274. """Verify developer_mode is False when bit 0x20000000 is set."""
  2275. # Bit 29 set in lower 32 bits = developer mode OFF (encryption required)
  2276. payload = {
  2277. "print": {
  2278. "gcode_state": "IDLE",
  2279. "fun": "1C81A7FF9CFF",
  2280. }
  2281. }
  2282. mqtt_client._process_message(payload)
  2283. assert mqtt_client.state.developer_mode is False
  2284. def test_developer_mode_exact_bit_check(self, mqtt_client):
  2285. """Verify only bit 0x20000000 matters, not other bits."""
  2286. # 0x20000000 in hex = bit 29. Set ONLY that bit.
  2287. payload = {
  2288. "print": {
  2289. "gcode_state": "IDLE",
  2290. "fun": "000020000000",
  2291. }
  2292. }
  2293. mqtt_client._process_message(payload)
  2294. assert mqtt_client.state.developer_mode is False
  2295. # All zeros = all bits clear = developer mode ON
  2296. payload["print"]["fun"] = "000000000000"
  2297. mqtt_client._process_message(payload)
  2298. assert mqtt_client.state.developer_mode is True
  2299. def test_developer_mode_invalid_fun_ignored(self, mqtt_client):
  2300. """Verify invalid fun values don't crash or change state."""
  2301. mqtt_client.state.developer_mode = True
  2302. payload = {
  2303. "print": {
  2304. "gcode_state": "IDLE",
  2305. "fun": "not_a_hex_value",
  2306. }
  2307. }
  2308. mqtt_client._process_message(payload)
  2309. # Should remain unchanged
  2310. assert mqtt_client.state.developer_mode is True
  2311. def test_developer_mode_missing_fun_preserves_state(self, mqtt_client):
  2312. """Verify messages without fun field don't reset developer_mode."""
  2313. mqtt_client.state.developer_mode = False
  2314. payload = {
  2315. "print": {
  2316. "gcode_state": "RUNNING",
  2317. "mc_percent": 50,
  2318. }
  2319. }
  2320. mqtt_client._process_message(payload)
  2321. assert mqtt_client.state.developer_mode is False
  2322. def test_developer_mode_persists_across_messages(self, mqtt_client):
  2323. """Verify developer_mode set by fun persists across messages without fun."""
  2324. # First message sets developer_mode
  2325. mqtt_client._process_message(
  2326. {
  2327. "print": {
  2328. "gcode_state": "IDLE",
  2329. "fun": "3EC1AFFF9CFF",
  2330. }
  2331. }
  2332. )
  2333. assert mqtt_client.state.developer_mode is False
  2334. # Subsequent messages without fun don't change it
  2335. for _ in range(3):
  2336. mqtt_client._process_message(
  2337. {
  2338. "print": {
  2339. "gcode_state": "RUNNING",
  2340. "mc_percent": 50,
  2341. }
  2342. }
  2343. )
  2344. assert mqtt_client.state.developer_mode is False
  2345. class TestDeveloperModeProbeTimeout:
  2346. """Tests for developer mode probe timeout, retry, and forced reconnect (#887).
  2347. When a printer's MQTT session is half-broken (sends status but ignores
  2348. commands), the developer mode probe gets no response. The timeout logic
  2349. retries once, then force-closes the socket on the second failure.
  2350. """
  2351. @pytest.fixture
  2352. def mqtt_client(self):
  2353. import time
  2354. from unittest.mock import MagicMock
  2355. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2356. client = BambuMQTTClient(
  2357. ip_address="192.168.1.100",
  2358. serial_number="TEST123",
  2359. access_code="12345678",
  2360. )
  2361. # Simulate connected state with a mock MQTT client
  2362. client.state.connected = True
  2363. mock_paho = MagicMock()
  2364. mock_paho.socket.return_value = MagicMock()
  2365. client._client = mock_paho
  2366. # Set connect time in the past so the 5s probe delay is satisfied
  2367. client._connect_time = time.monotonic() - 10.0
  2368. return client
  2369. def _make_pushall_data(self):
  2370. """Create a print data dict with >30 keys (triggers probe) and no 'fun' field."""
  2371. return {f"key_{i}": i for i in range(35)}
  2372. def test_first_timeout_allows_retry(self, mqtt_client):
  2373. """After first probe timeout, _dev_mode_probed resets to allow retry."""
  2374. import time
  2375. data = self._make_pushall_data()
  2376. # First pushall triggers the probe
  2377. mqtt_client._update_state(data)
  2378. assert mqtt_client._dev_mode_probed is True
  2379. assert mqtt_client._dev_mode_probe_seq is not None
  2380. assert mqtt_client.state.developer_mode is None
  2381. # Simulate 11 seconds passing
  2382. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  2383. # Next status message detects the timeout
  2384. mqtt_client._update_state(data)
  2385. assert mqtt_client._dev_mode_probe_failures == 1
  2386. assert mqtt_client._dev_mode_probe_seq is None
  2387. # Should allow retry on next full message
  2388. assert mqtt_client._dev_mode_probed is False
  2389. # Connection should NOT be force-closed after 1 failure
  2390. assert mqtt_client.state.connected is True
  2391. def test_second_timeout_forces_reconnect(self, mqtt_client):
  2392. """After two consecutive probe timeouts, force-close the socket."""
  2393. import time
  2394. data = self._make_pushall_data()
  2395. state_change_called = []
  2396. mqtt_client.on_state_change = lambda s: state_change_called.append(True)
  2397. # First probe + timeout
  2398. mqtt_client._update_state(data)
  2399. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  2400. mqtt_client._update_state(data)
  2401. assert mqtt_client._dev_mode_probe_failures == 1
  2402. # Second probe (retry) + timeout
  2403. mqtt_client._update_state(data) # triggers new probe
  2404. assert mqtt_client._dev_mode_probed is True
  2405. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  2406. mqtt_client._update_state(data) # detects second timeout
  2407. assert mqtt_client._dev_mode_probe_failures == 2
  2408. assert mqtt_client.state.connected is False
  2409. assert mqtt_client._stale_reconnecting is True
  2410. # Socket should have been closed
  2411. mqtt_client._client.socket().close.assert_called()
  2412. # on_state_change should have been called
  2413. assert len(state_change_called) > 0
  2414. def test_successful_probe_resets_failure_counter(self, mqtt_client):
  2415. """A probe response after a previous failure resets the counter."""
  2416. import time
  2417. data = self._make_pushall_data()
  2418. # First probe + timeout → failure=1
  2419. mqtt_client._update_state(data)
  2420. seq = mqtt_client._dev_mode_probe_seq
  2421. mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
  2422. mqtt_client._update_state(data)
  2423. assert mqtt_client._dev_mode_probe_failures == 1
  2424. # Retry probe
  2425. mqtt_client._update_state(data)
  2426. new_seq = mqtt_client._dev_mode_probe_seq
  2427. assert new_seq is not None
  2428. assert new_seq != seq
  2429. # Simulate successful response
  2430. mqtt_client._handle_dev_mode_probe_response(
  2431. {
  2432. "command": "ams_filament_setting",
  2433. "sequence_id": new_seq,
  2434. "result": "success",
  2435. }
  2436. )
  2437. assert mqtt_client._dev_mode_probe_failures == 0
  2438. assert mqtt_client.state.developer_mode is True
  2439. assert mqtt_client._dev_mode_probe_seq is None
  2440. def test_no_timeout_when_probe_not_sent(self, mqtt_client):
  2441. """The timeout branch is only entered when a probe is pending."""
  2442. # No probe sent — _dev_mode_probed is False, _dev_mode_probe_seq is None
  2443. data = {"gcode_state": "IDLE", "mc_percent": 0} # < 30 keys
  2444. mqtt_client._update_state(data)
  2445. assert mqtt_client._dev_mode_probe_failures == 0
  2446. def test_on_connect_resets_probe_state_but_preserves_developer_mode(self, mqtt_client):
  2447. """_on_connect resets probe tracking but preserves cached developer_mode."""
  2448. import time
  2449. mqtt_client._dev_mode_probed = True
  2450. mqtt_client._dev_mode_probe_seq = "42"
  2451. mqtt_client._dev_mode_probe_time = time.monotonic()
  2452. mqtt_client._dev_mode_probe_failures = 2
  2453. mqtt_client.state.developer_mode = True
  2454. # subscribe() must return (result, mid) tuple
  2455. mqtt_client._client.subscribe.return_value = (0, 1)
  2456. mqtt_client._on_connect(mqtt_client._client, None, None, 0)
  2457. # developer_mode is preserved across reconnects (#887)
  2458. assert mqtt_client.state.developer_mode is True
  2459. assert mqtt_client._dev_mode_probed is False
  2460. assert mqtt_client._dev_mode_probe_seq is None
  2461. assert mqtt_client._dev_mode_probe_time == 0.0
  2462. assert mqtt_client._dev_mode_probe_failures == 0
  2463. assert mqtt_client._connect_time > 0
  2464. def test_probe_deferred_when_connect_too_recent(self, mqtt_client):
  2465. """Probe is deferred if less than 5s have passed since _on_connect."""
  2466. import time
  2467. data = self._make_pushall_data()
  2468. # Set connect time to 1 second ago — too recent for probe
  2469. mqtt_client._connect_time = time.monotonic() - 1.0
  2470. mqtt_client._update_state(data)
  2471. # Pushall seen, so needs_probe is set, but probe NOT fired yet
  2472. assert mqtt_client._dev_mode_needs_probe is True
  2473. assert mqtt_client._dev_mode_probed is False
  2474. assert mqtt_client._dev_mode_probe_seq is None
  2475. def test_probe_fires_after_delay(self, mqtt_client):
  2476. """Probe fires once 5s have passed since _on_connect."""
  2477. import time
  2478. data = self._make_pushall_data()
  2479. # Set connect time to 6 seconds ago — delay satisfied
  2480. mqtt_client._connect_time = time.monotonic() - 6.0
  2481. mqtt_client._update_state(data)
  2482. # Probe should have fired
  2483. assert mqtt_client._dev_mode_needs_probe is True
  2484. assert mqtt_client._dev_mode_probed is True
  2485. assert mqtt_client._dev_mode_probe_seq is not None
  2486. def test_probe_fires_on_incremental_after_delay(self, mqtt_client):
  2487. """After seeing a pushall within 5s, probe fires on later incremental message."""
  2488. import time
  2489. pushall_data = self._make_pushall_data()
  2490. incremental_data = {"gcode_state": "IDLE", "mc_percent": 0} # < 30 keys
  2491. # Pushall arrives 1s after connect — too early for probe
  2492. mqtt_client._connect_time = time.monotonic() - 1.0
  2493. mqtt_client._update_state(pushall_data)
  2494. assert mqtt_client._dev_mode_needs_probe is True
  2495. assert mqtt_client._dev_mode_probed is False
  2496. # 5s later, an incremental update arrives — probe fires now
  2497. mqtt_client._connect_time = time.monotonic() - 6.0
  2498. mqtt_client._update_state(incremental_data)
  2499. assert mqtt_client._dev_mode_probed is True
  2500. assert mqtt_client._dev_mode_probe_seq is not None
  2501. def test_no_reprobe_when_developer_mode_cached(self, mqtt_client):
  2502. """Auto-reconnect preserves developer_mode, skipping reprobe."""
  2503. import time
  2504. data = self._make_pushall_data()
  2505. # Simulate known developer_mode from previous connection
  2506. mqtt_client.state.developer_mode = True
  2507. mqtt_client._connect_time = time.monotonic() - 10.0
  2508. mqtt_client._update_state(data)
  2509. # Should NOT probe — developer_mode is already known
  2510. assert mqtt_client._dev_mode_needs_probe is False
  2511. assert mqtt_client._dev_mode_probed is False
  2512. assert mqtt_client._dev_mode_probe_seq is None
  2513. assert mqtt_client.state.developer_mode is True
  2514. def test_on_connect_resets_needs_probe(self, mqtt_client):
  2515. """_on_connect resets _dev_mode_needs_probe for a clean start."""
  2516. mqtt_client._dev_mode_needs_probe = True
  2517. mqtt_client._client.subscribe.return_value = (0, 1)
  2518. mqtt_client._on_connect(mqtt_client._client, None, None, 0)
  2519. assert mqtt_client._dev_mode_needs_probe is False
  2520. class TestVtTrayNormalization:
  2521. """Tests for vt_tray dict→list normalization in _update_state.
  2522. MQTT sends vt_tray as a dict for single-slot printers, but all consumers
  2523. expect a list. _update_state must normalize it before any callback can
  2524. read raw_data, because the dev-mode probe may release the GIL and let
  2525. the event loop read the partially-updated state.
  2526. """
  2527. @pytest.fixture
  2528. def mqtt_client(self):
  2529. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2530. client = BambuMQTTClient(
  2531. ip_address="192.168.1.100",
  2532. serial_number="TEST123",
  2533. access_code="12345678",
  2534. )
  2535. return client
  2536. def test_vt_tray_dict_normalized_in_update_state(self, mqtt_client):
  2537. """Verify _update_state wraps a raw vt_tray dict into a list."""
  2538. vt_dict = {
  2539. "id": "254",
  2540. "tray_color": "FF0000",
  2541. "tray_type": "PLA",
  2542. "tag_uid": "0000000000000000",
  2543. "tray_uuid": "00000000000000000000000000000000",
  2544. }
  2545. data = {"gcode_state": "IDLE", "vt_tray": vt_dict}
  2546. mqtt_client._update_state(data)
  2547. stored = mqtt_client.state.raw_data.get("vt_tray")
  2548. assert isinstance(stored, list)
  2549. assert len(stored) == 1
  2550. assert stored[0]["tray_color"] == "FF0000"
  2551. def test_vt_tray_list_unchanged_in_update_state(self, mqtt_client):
  2552. """Verify _update_state keeps an already-list vt_tray unchanged."""
  2553. vt_list = [
  2554. {"id": "254", "tray_type": "PLA"},
  2555. {"id": "255", "tray_type": "PETG"},
  2556. ]
  2557. data = {"gcode_state": "IDLE", "vt_tray": vt_list}
  2558. mqtt_client._update_state(data)
  2559. stored = mqtt_client.state.raw_data.get("vt_tray")
  2560. assert isinstance(stored, list)
  2561. assert len(stored) == 2
  2562. def test_preserved_vt_tray_restored_before_probe(self, mqtt_client):
  2563. """Verify preserved vt_tray is restored before dev-mode probe runs.
  2564. On the first message, the incremental handler wraps vt_tray into a list
  2565. and stores it. _update_state then replaces raw_data with the full data
  2566. dict, but must restore preserved fields BEFORE the probe publishes
  2567. (which can release the GIL).
  2568. """
  2569. # Simulate: incremental handler already stored a wrapped list
  2570. mqtt_client.state.raw_data = {
  2571. "vt_tray": [{"id": "254", "tray_type": "PLA", "tray_color": "00FF00"}],
  2572. }
  2573. # Now _update_state runs with new data that has vt_tray as dict
  2574. new_data = {
  2575. "gcode_state": "IDLE",
  2576. "vt_tray": {"id": "254", "tray_type": "PETG", "tray_color": "FF0000"},
  2577. }
  2578. mqtt_client._update_state(new_data)
  2579. # The preserved list (PLA/green) should take priority over new data
  2580. stored = mqtt_client.state.raw_data["vt_tray"]
  2581. assert isinstance(stored, list)
  2582. assert stored[0]["tray_type"] == "PLA"
  2583. assert stored[0]["tray_color"] == "00FF00"
  2584. def test_first_message_vt_tray_dict_becomes_list(self, mqtt_client):
  2585. """Verify on the very first message, vt_tray dict is still a list.
  2586. When there's no previously preserved data, the normalized dict should
  2587. remain as a list in raw_data.
  2588. """
  2589. # raw_data starts empty — no preserved vt_tray
  2590. mqtt_client.state.raw_data = {}
  2591. data = {
  2592. "gcode_state": "IDLE",
  2593. "vt_tray": {"id": "254", "tray_type": "ABS"},
  2594. }
  2595. mqtt_client._update_state(data)
  2596. stored = mqtt_client.state.raw_data["vt_tray"]
  2597. assert isinstance(stored, list)
  2598. assert stored[0]["tray_type"] == "ABS"
  2599. class TestSendDryingCommand:
  2600. """Tests for send_drying_command MQTT payload construction."""
  2601. @pytest.fixture
  2602. def mqtt_client(self):
  2603. """Create a BambuMQTTClient with a mock MQTT client."""
  2604. from unittest.mock import MagicMock
  2605. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2606. client = BambuMQTTClient(
  2607. ip_address="192.168.1.100",
  2608. serial_number="TEST123",
  2609. access_code="12345678",
  2610. )
  2611. client._client = MagicMock()
  2612. return client
  2613. def test_rotate_tray_false_by_default(self, mqtt_client):
  2614. """Verify rotate_tray defaults to False in the MQTT payload."""
  2615. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
  2616. call_args = mqtt_client._client.publish.call_args
  2617. payload = json.loads(call_args[0][1])
  2618. assert payload["print"]["rotate_tray"] is False
  2619. def test_rotate_tray_true_when_enabled(self, mqtt_client):
  2620. """Verify rotate_tray is True when explicitly enabled."""
  2621. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA", rotate_tray=True)
  2622. call_args = mqtt_client._client.publish.call_args
  2623. payload = json.loads(call_args[0][1])
  2624. assert payload["print"]["rotate_tray"] is True
  2625. def test_rotate_tray_false_on_stop(self, mqtt_client):
  2626. """Verify rotate_tray is False when stopping drying (mode=0)."""
  2627. mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
  2628. call_args = mqtt_client._client.publish.call_args
  2629. payload = json.loads(call_args[0][1])
  2630. assert payload["print"]["rotate_tray"] is False
  2631. def test_all_required_fields_present(self, mqtt_client):
  2632. """Verify all required MQTT fields are present in the drying command."""
  2633. mqtt_client.send_drying_command(ams_id=128, temp=75, duration=8, mode=1, filament="ABS", rotate_tray=True)
  2634. call_args = mqtt_client._client.publish.call_args
  2635. payload = json.loads(call_args[0][1])
  2636. cmd = payload["print"]
  2637. assert cmd["command"] == "ams_filament_drying"
  2638. assert cmd["ams_id"] == 128
  2639. assert cmd["temp"] == 75
  2640. assert cmd["duration"] == 8
  2641. assert cmd["mode"] == 1
  2642. assert cmd["rotate_tray"] is True
  2643. assert cmd["filament"] == "ABS"
  2644. assert cmd["cooling_temp"] == 20
  2645. assert cmd["humidity"] == 0
  2646. assert cmd["close_power_conflict"] is False
  2647. assert "sequence_id" in cmd
  2648. def test_publishes_with_qos_1(self, mqtt_client):
  2649. """Verify drying commands are published with QoS 1."""
  2650. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4)
  2651. call_args = mqtt_client._client.publish.call_args
  2652. # qos may be positional arg [2] or keyword
  2653. qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
  2654. assert qos == 1
  2655. class TestStartPrintAmsMapping:
  2656. """Tests for ams_mapping/ams_mapping2 construction in start_print().
  2657. BambuStudio converts virtual tray IDs (254/255) to -1 in the flat
  2658. ams_mapping and puts the real external spool info only in ams_mapping2.
  2659. Passing raw 254/255 in the flat array causes H2D firmware to fail
  2660. with 0700_8012 "Failed to get AMS mapping table".
  2661. """
  2662. @pytest.fixture
  2663. def mqtt_client(self):
  2664. from unittest.mock import MagicMock
  2665. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2666. client = BambuMQTTClient(
  2667. ip_address="192.168.1.100",
  2668. serial_number="TEST123",
  2669. access_code="12345678",
  2670. )
  2671. client._client = MagicMock()
  2672. client.state.connected = True
  2673. return client
  2674. def _get_published_command(self, mqtt_client):
  2675. """Extract the parsed print command from the last publish call."""
  2676. call_args = mqtt_client._client.publish.call_args
  2677. return json.loads(call_args[0][1])["print"]
  2678. def test_regular_ams_trays_preserved_in_flat_mapping(self, mqtt_client):
  2679. """Regular AMS tray IDs pass through unchanged in flat ams_mapping."""
  2680. mqtt_client.start_print("test.3mf", ams_mapping=[0, 5, 11])
  2681. cmd = self._get_published_command(mqtt_client)
  2682. assert cmd["ams_mapping"] == [0, 5, 11]
  2683. assert cmd["ams_mapping2"] == [
  2684. {"ams_id": 0, "slot_id": 0},
  2685. {"ams_id": 1, "slot_id": 1},
  2686. {"ams_id": 2, "slot_id": 3},
  2687. ]
  2688. def test_unmapped_slots(self, mqtt_client):
  2689. """Unmapped slots (-1) produce -1 in flat and 0xFF/0xFF in mapping2."""
  2690. mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1])
  2691. cmd = self._get_published_command(mqtt_client)
  2692. assert cmd["ams_mapping"] == [-1, -1]
  2693. assert cmd["ams_mapping2"] == [
  2694. {"ams_id": 255, "slot_id": 255},
  2695. {"ams_id": 255, "slot_id": 255},
  2696. ]
  2697. def test_external_main_nozzle_becomes_minus_one_in_flat(self, mqtt_client):
  2698. """Virtual tray 255 (main nozzle) must be -1 in flat mapping."""
  2699. mqtt_client.start_print("test.3mf", ams_mapping=[255])
  2700. cmd = self._get_published_command(mqtt_client)
  2701. assert cmd["ams_mapping"] == [-1]
  2702. assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
  2703. def test_single_nozzle_external_spool_uses_main_id(self, mqtt_client):
  2704. """Single-nozzle external spool (254) maps to ams_id=255 (VIRTUAL_TRAY_MAIN_ID).
  2705. Firmware reports tray_now=254 for external spool, but the print command
  2706. must use ams_id=255 in ams_mapping2. Sending 254 causes the firmware to
  2707. target AMS tray 0 instead of external spool (07FF_8012 error).
  2708. """
  2709. mqtt_client.start_print("test.3mf", ams_mapping=[254])
  2710. cmd = self._get_published_command(mqtt_client)
  2711. assert cmd["ams_mapping"] == [-1]
  2712. assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
  2713. def test_h2d_external_spool_mixed_with_ams(self, mqtt_client):
  2714. """H2D scenario: AMS trays + unmapped + external deputy nozzle."""
  2715. # Reproduces the exact scenario from issue #797:
  2716. # 5-slot 3MF, only slot 5 assigned to external deputy nozzle (254)
  2717. mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1, -1, -1, 255])
  2718. cmd = self._get_published_command(mqtt_client)
  2719. # Flat mapping: all -1 (external converted, unmapped stay -1)
  2720. assert cmd["ams_mapping"] == [-1, -1, -1, -1, -1]
  2721. # Detailed mapping: unmapped slots use 0xFF, external uses real ams_id
  2722. assert cmd["ams_mapping2"] == [
  2723. {"ams_id": 255, "slot_id": 255},
  2724. {"ams_id": 255, "slot_id": 255},
  2725. {"ams_id": 255, "slot_id": 255},
  2726. {"ams_id": 255, "slot_id": 255},
  2727. {"ams_id": 255, "slot_id": 0},
  2728. ]
  2729. def test_ams_ht_trays_preserved_in_flat_mapping(self, mqtt_client):
  2730. """AMS-HT tray IDs (>=128) pass through in flat mapping."""
  2731. mqtt_client.start_print("test.3mf", ams_mapping=[128, 131])
  2732. cmd = self._get_published_command(mqtt_client)
  2733. assert cmd["ams_mapping"] == [128, 131]
  2734. assert cmd["ams_mapping2"] == [
  2735. {"ams_id": 128, "slot_id": 0},
  2736. {"ams_id": 131, "slot_id": 0},
  2737. ]
  2738. def test_non_h2d_both_external_maps_to_main_id(self, mqtt_client):
  2739. """Non-H2D: both 254 and 255 map to ams_id=255 (single nozzle)."""
  2740. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
  2741. cmd = self._get_published_command(mqtt_client)
  2742. assert cmd["ams_mapping"] == [-1, -1]
  2743. assert cmd["ams_mapping2"] == [
  2744. {"ams_id": 255, "slot_id": 0},
  2745. {"ams_id": 255, "slot_id": 0},
  2746. ]
  2747. def test_h2d_external_preserves_deputy_id(self, mqtt_client):
  2748. """H2D dual-nozzle: 254 (deputy) stays 254, 255 (main) stays 255."""
  2749. mqtt_client.model = "H2D"
  2750. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
  2751. cmd = self._get_published_command(mqtt_client)
  2752. assert cmd["ams_mapping"] == [-1, -1]
  2753. assert cmd["ams_mapping2"] == [
  2754. {"ams_id": 254, "slot_id": 0},
  2755. {"ams_id": 255, "slot_id": 0},
  2756. ]
  2757. def test_h2d_single_external_deputy(self, mqtt_client):
  2758. """H2D: single external spool on deputy nozzle (254) keeps ams_id=254."""
  2759. mqtt_client.model = "H2D Pro"
  2760. mqtt_client.start_print("test.3mf", ams_mapping=[254])
  2761. cmd = self._get_published_command(mqtt_client)
  2762. assert cmd["ams_mapping"] == [-1]
  2763. assert cmd["ams_mapping2"] == [{"ams_id": 254, "slot_id": 0}]
  2764. def test_external_spool_only_sets_use_ams_false(self, mqtt_client):
  2765. """Single external spool on non-H2D printer sets use_ams=False."""
  2766. mqtt_client.start_print("test.3mf", ams_mapping=[254], use_ams=True)
  2767. cmd = self._get_published_command(mqtt_client)
  2768. assert cmd["use_ams"] is False
  2769. def test_all_unmapped_sets_use_ams_false(self, mqtt_client):
  2770. """All unmapped slots on non-H2D printer sets use_ams=False."""
  2771. mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1], use_ams=True)
  2772. cmd = self._get_published_command(mqtt_client)
  2773. assert cmd["use_ams"] is False
  2774. def test_mixed_ams_and_external_keeps_use_ams_true(self, mqtt_client):
  2775. """AMS tray + external spool keeps use_ams=True."""
  2776. mqtt_client.start_print("test.3mf", ams_mapping=[0, 254], use_ams=True)
  2777. cmd = self._get_published_command(mqtt_client)
  2778. assert cmd["use_ams"] is True
  2779. def test_h2d_both_external_keeps_use_ams_true(self, mqtt_client):
  2780. """H2D with both external spools keeps use_ams=True (nozzle routing)."""
  2781. mqtt_client.model = "H2D"
  2782. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255], use_ams=True)
  2783. cmd = self._get_published_command(mqtt_client)
  2784. assert cmd["use_ams"] is True
  2785. def test_empty_ams_mapping_keeps_use_ams_true(self, mqtt_client):
  2786. """Empty ams_mapping list does not override use_ams."""
  2787. mqtt_client.start_print("test.3mf", ams_mapping=[], use_ams=True)
  2788. cmd = self._get_published_command(mqtt_client)
  2789. assert cmd["use_ams"] is True
  2790. def test_no_ams_mapping_omits_fields(self, mqtt_client):
  2791. """When ams_mapping is None, neither field is in the command."""
  2792. mqtt_client.start_print("test.3mf", ams_mapping=None)
  2793. cmd = self._get_published_command(mqtt_client)
  2794. assert "ams_mapping" not in cmd
  2795. assert "ams_mapping2" not in cmd
  2796. def test_x2d_external_preserves_deputy_id(self, mqtt_client):
  2797. """X2D dual-nozzle (#988): 254 (deputy) stays 254, like H2D family.
  2798. X2D launched April 2026 and shares the H2D-style dual-extruder
  2799. firmware convention — external spool on the deputy (left) nozzle
  2800. is addressed as ams_id=254, not coerced to 255.
  2801. """
  2802. mqtt_client.model = "X2D"
  2803. mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
  2804. cmd = self._get_published_command(mqtt_client)
  2805. assert cmd["ams_mapping"] == [-1, -1]
  2806. assert cmd["ams_mapping2"] == [
  2807. {"ams_id": 254, "slot_id": 0},
  2808. {"ams_id": 255, "slot_id": 0},
  2809. ]
  2810. def test_x2d_uses_integer_format_for_calibration_fields(self, mqtt_client):
  2811. """X2D must use H2D-style integer (0/1) format for calibration fields (#988).
  2812. The reporter's support bundle showed X2D running firmware in the same
  2813. family as H2D. Booleans in these fields are interpreted as nozzle
  2814. indexes by H2D firmware; X2D is treated identically until proven
  2815. otherwise.
  2816. """
  2817. mqtt_client.model = "X2D"
  2818. mqtt_client.start_print(
  2819. "test.3mf",
  2820. timelapse=True,
  2821. bed_levelling=False,
  2822. flow_cali=True,
  2823. vibration_cali=False,
  2824. layer_inspect=True,
  2825. )
  2826. cmd = self._get_published_command(mqtt_client)
  2827. assert cmd["timelapse"] == 1
  2828. assert cmd["bed_leveling"] == 0
  2829. assert cmd["flow_cali"] == 1
  2830. assert cmd["vibration_cali"] == 0
  2831. assert cmd["layer_inspect"] == 1
  2832. def test_p2s_still_uses_boolean_format(self, mqtt_client):
  2833. """Regression guard: P2S is NOT in the is_h2d gate — must still use booleans.
  2834. Adding X2D to the is_h2d set must not accidentally affect P2S, which
  2835. is single-nozzle and uses boolean format like X1C/A1/P1.
  2836. """
  2837. mqtt_client.model = "P2S"
  2838. mqtt_client.start_print("test.3mf", timelapse=True, flow_cali=False)
  2839. cmd = self._get_published_command(mqtt_client)
  2840. assert cmd["timelapse"] is True
  2841. assert cmd["flow_cali"] is False
  2842. class TestStartPrintUniqueIdentityFields:
  2843. """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
  2844. Hardcoded "0" values caused third-party MQTT observers (e.g. OctoEverywhere)
  2845. to treat archive reprints as continuations of the same job and report
  2846. compounding durations on repeat replays. Each start_print call must produce
  2847. a distinct, non-zero identity triplet so the printer emits a fresh state
  2848. transition. md5 is deliberately left empty — historically firmware treats
  2849. "" as "skip validation" and we don't have the file's real digest here.
  2850. """
  2851. @pytest.fixture
  2852. def mqtt_client(self):
  2853. from unittest.mock import MagicMock
  2854. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2855. client = BambuMQTTClient(
  2856. ip_address="192.168.1.100",
  2857. serial_number="TEST123",
  2858. access_code="12345678",
  2859. )
  2860. client._client = MagicMock()
  2861. client.state.connected = True
  2862. return client
  2863. def _get_published_command(self, mqtt_client):
  2864. call_args = mqtt_client._client.publish.call_args
  2865. return json.loads(call_args[0][1])["print"]
  2866. def test_identity_fields_are_non_zero(self, mqtt_client):
  2867. mqtt_client.start_print("test.3mf")
  2868. cmd = self._get_published_command(mqtt_client)
  2869. assert cmd["project_id"] != "0"
  2870. assert cmd["subtask_id"] != "0"
  2871. assert cmd["task_id"] != "0"
  2872. def test_identity_fields_are_all_equal_per_submission(self, mqtt_client):
  2873. """All three IDs come from the same submission timestamp — Studio also
  2874. uses a single identity per submission across the three fields."""
  2875. mqtt_client.start_print("test.3mf")
  2876. cmd = self._get_published_command(mqtt_client)
  2877. assert cmd["project_id"] == cmd["subtask_id"] == cmd["task_id"]
  2878. def test_md5_stays_empty(self, mqtt_client):
  2879. """Deliberate: synthetic md5 risks activating firmware validation."""
  2880. mqtt_client.start_print("test.3mf")
  2881. cmd = self._get_published_command(mqtt_client)
  2882. assert cmd["md5"] == ""
  2883. def test_identity_fields_change_between_submissions(self, mqtt_client):
  2884. """Two successive start_print calls must produce different IDs.
  2885. Without this, the printer can't tell replays apart and reuses
  2886. gcode_start_time from the prior job.
  2887. """
  2888. mqtt_client.start_print("test.3mf")
  2889. first = self._get_published_command(mqtt_client)
  2890. time.sleep(0.002)
  2891. mqtt_client.start_print("test.3mf")
  2892. second = self._get_published_command(mqtt_client)
  2893. assert first["task_id"] != second["task_id"]
  2894. assert first["subtask_id"] != second["subtask_id"]
  2895. assert first["project_id"] != second["project_id"]
  2896. def test_submission_id_is_numeric_string(self, mqtt_client):
  2897. """ID format: digits-only string. Studio uses cloud task IDs that are
  2898. also numeric-looking strings; the DB column is VARCHAR(64) and
  2899. Bambuddy's own subtask_id parser treats '0'/'' as absent — any valid
  2900. digit string that isn't '0' is fine."""
  2901. mqtt_client.start_print("test.3mf")
  2902. cmd = self._get_published_command(mqtt_client)
  2903. assert cmd["task_id"].isdigit()
  2904. assert int(cmd["task_id"]) > 0
  2905. assert len(cmd["task_id"]) <= 64
  2906. def test_submission_id_fits_signed_int32(self, mqtt_client):
  2907. """Regression for #1042: P1S firmware clamps oversized task identity
  2908. fields to signed int32 max (2**31-1 = 2147483647). If we send raw
  2909. epoch-ms (~1.7e12), the printer sees a saturated constant on every
  2910. submission and treats fresh dispatches as continuations of the last
  2911. FAILED job — never leaves IDLE. Keep below 2**31.
  2912. """
  2913. mqtt_client.start_print("test.3mf")
  2914. cmd = self._get_published_command(mqtt_client)
  2915. assert int(cmd["task_id"]) < 2**31
  2916. assert int(cmd["project_id"]) < 2**31
  2917. assert int(cmd["subtask_id"]) < 2**31
  2918. def test_unrelated_payload_fields_untouched(self, mqtt_client):
  2919. """Regression guard: fix only touches identity fields; everything else
  2920. (sequence_id, command verb, calibration defaults, profile_id) must be
  2921. unchanged to avoid silently breaking printer behavior."""
  2922. mqtt_client.start_print("test.3mf")
  2923. cmd = self._get_published_command(mqtt_client)
  2924. assert cmd["sequence_id"] == "20000"
  2925. assert cmd["command"] == "project_file"
  2926. assert cmd["param"] == "Metadata/plate_1.gcode"
  2927. assert cmd["url"] == "ftp://test.3mf"
  2928. assert cmd["file"] == "test.3mf"
  2929. assert cmd["profile_id"] == "0"
  2930. assert cmd["cfg"] == "0"
  2931. assert cmd["subtask_name"] == "test"
  2932. class TestDeleteKProfileDualNozzleDetection:
  2933. """Regression guard: dual-nozzle detection by serial prefix (#988).
  2934. delete_kprofile branches on serial-prefix-derived dual-nozzle status.
  2935. H2D serials start with "094"; X2D serials start with "20P9". Non-dual
  2936. families (X1C "00M", P1S "01P", P2S "22E", A1 "039", etc.) must take
  2937. the single-nozzle branch.
  2938. """
  2939. def _make_client(self, serial: str):
  2940. from unittest.mock import MagicMock
  2941. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2942. client = BambuMQTTClient(
  2943. ip_address="192.168.1.100",
  2944. serial_number=serial,
  2945. access_code="12345678",
  2946. )
  2947. client._client = MagicMock()
  2948. client.state.connected = True
  2949. return client
  2950. def _published(self, client):
  2951. return json.loads(client._client.publish.call_args[0][1])["print"]
  2952. def test_h2d_serial_uses_dual_nozzle_format(self):
  2953. client = self._make_client("09400A000000001")
  2954. client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
  2955. cmd = self._published(client)
  2956. # Dual-nozzle command omits setting_id.
  2957. assert "setting_id" not in cmd
  2958. assert cmd["extruder_id"] == 0
  2959. def test_x2d_serial_uses_dual_nozzle_format(self):
  2960. client = self._make_client("20P90A000000001")
  2961. client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
  2962. cmd = self._published(client)
  2963. assert "setting_id" not in cmd
  2964. assert cmd["extruder_id"] == 0
  2965. def test_p2s_serial_uses_single_nozzle_format(self):
  2966. """P2S is single-nozzle — must NOT take the dual-nozzle branch."""
  2967. client = self._make_client("22E00A000000001")
  2968. client.delete_kprofile(
  2969. cali_idx=1,
  2970. filament_id="GFA00",
  2971. nozzle_id="HH00-0.4",
  2972. setting_id="PFB123",
  2973. )
  2974. cmd = self._published(client)
  2975. # Single-nozzle command includes setting_id.
  2976. assert cmd["setting_id"] == "PFB123"
  2977. def test_x1c_serial_uses_single_nozzle_format(self):
  2978. client = self._make_client("00M00A000000001")
  2979. client.delete_kprofile(
  2980. cali_idx=1,
  2981. filament_id="GFA00",
  2982. nozzle_id="HH00-0.4",
  2983. setting_id="PFB123",
  2984. )
  2985. cmd = self._published(client)
  2986. assert cmd["setting_id"] == "PFB123"
  2987. class TestStaleReconnect:
  2988. """Tests for stale connection detection and reconnect without UI bouncing."""
  2989. @pytest.fixture
  2990. def mqtt_client(self):
  2991. from backend.app.services.bambu_mqtt import BambuMQTTClient
  2992. client = BambuMQTTClient(
  2993. ip_address="192.168.1.100",
  2994. serial_number="TEST_STALE",
  2995. access_code="12345678",
  2996. )
  2997. return client
  2998. def test_check_staleness_sets_flag_and_broadcasts_once(self, mqtt_client):
  2999. """check_staleness() should set connected=False, broadcast, and set _stale_reconnecting."""
  3000. import time
  3001. state_changes = []
  3002. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  3003. mqtt_client.state.connected = True
  3004. mqtt_client._last_message_time = time.time() - 120 # well past 60s threshold
  3005. result = mqtt_client.check_staleness()
  3006. assert result is False
  3007. assert mqtt_client.state.connected is False
  3008. assert mqtt_client._stale_reconnecting is True
  3009. assert state_changes == [False] # Exactly one broadcast
  3010. def test_check_staleness_noop_when_not_connected(self, mqtt_client):
  3011. """check_staleness() should not set flag when already disconnected."""
  3012. import time
  3013. mqtt_client.state.connected = False
  3014. mqtt_client._last_message_time = time.time() - 120
  3015. mqtt_client.check_staleness()
  3016. assert mqtt_client._stale_reconnecting is False
  3017. def test_check_staleness_noop_when_not_stale(self, mqtt_client):
  3018. """check_staleness() should not set flag when messages are recent."""
  3019. import time
  3020. mqtt_client.state.connected = True
  3021. mqtt_client._last_message_time = time.time() - 5 # 5s ago, well within 60s
  3022. result = mqtt_client.check_staleness()
  3023. assert result is True
  3024. assert mqtt_client.state.connected is True
  3025. assert mqtt_client._stale_reconnecting is False
  3026. def test_on_disconnect_skipped_during_stale_reconnect(self, mqtt_client):
  3027. """_on_disconnect should not broadcast state when _stale_reconnecting is set."""
  3028. state_changes = []
  3029. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  3030. mqtt_client._stale_reconnecting = True
  3031. mqtt_client.state.connected = False
  3032. mqtt_client._on_disconnect(None, None)
  3033. # No state change broadcast — check_staleness() already did it
  3034. assert state_changes == []
  3035. assert mqtt_client.state.connected is False
  3036. def test_on_disconnect_fires_event_during_stale_reconnect(self, mqtt_client):
  3037. """_on_disconnect must still fire _disconnection_event even during stale reconnect.
  3038. If disconnect() is called while _stale_reconnecting is True (e.g. user removes
  3039. the printer before paho reconnects), the event must fire so disconnect() doesn't hang.
  3040. """
  3041. import threading
  3042. mqtt_client._stale_reconnecting = True
  3043. mqtt_client._disconnection_event = threading.Event()
  3044. mqtt_client._on_disconnect(None, None)
  3045. assert mqtt_client._disconnection_event.is_set()
  3046. def test_on_connect_clears_stale_reconnecting_flag(self, mqtt_client):
  3047. """_on_connect should clear _stale_reconnecting and restore connected=True."""
  3048. mqtt_client._stale_reconnecting = True
  3049. mqtt_client.state.connected = False
  3050. subscribe_calls = []
  3051. mock_client = type(
  3052. "MockClient",
  3053. (),
  3054. {
  3055. "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
  3056. },
  3057. )()
  3058. mqtt_client._on_connect(mock_client, None, None, 0)
  3059. assert mqtt_client._stale_reconnecting is False
  3060. assert mqtt_client.state.connected is True
  3061. def test_full_stale_reconnect_cycle_no_bounce(self, mqtt_client):
  3062. """Full cycle: stale → disconnect callback → reconnect. UI should see exactly one disconnect."""
  3063. import time
  3064. state_changes = []
  3065. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  3066. mqtt_client.state.connected = True
  3067. mqtt_client._last_message_time = time.time() - 120
  3068. # Step 1: Stale detection triggers
  3069. mqtt_client.check_staleness()
  3070. assert state_changes == [False]
  3071. # Step 2: Paho fires disconnect callback (from socket close)
  3072. mqtt_client._on_disconnect(None, None)
  3073. # Should NOT add another state change
  3074. assert state_changes == [False]
  3075. # Step 3: Paho reconnects
  3076. subscribe_calls = []
  3077. mock_client = type(
  3078. "MockClient",
  3079. (),
  3080. {
  3081. "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
  3082. },
  3083. )()
  3084. mqtt_client._on_connect(mock_client, None, None, 0)
  3085. assert state_changes == [False, True] # Now connected again
  3086. assert mqtt_client._stale_reconnecting is False
  3087. def test_spurious_disconnect_suppressed_when_recent_messages(self, mqtt_client):
  3088. """Non-error disconnect with recent messages should be suppressed."""
  3089. import time
  3090. state_changes = []
  3091. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  3092. mqtt_client.state.connected = True
  3093. mqtt_client._last_message_time = time.time() - 3 # 3s ago
  3094. # Non-error disconnect (rc=None)
  3095. mqtt_client._on_disconnect(None, None)
  3096. assert state_changes == []
  3097. assert mqtt_client.state.connected is True
  3098. def test_error_disconnect_not_suppressed_despite_recent_messages(self, mqtt_client):
  3099. """Error disconnect should always be processed, even with recent messages."""
  3100. import time
  3101. import paho.mqtt.client as mqtt
  3102. from paho.mqtt.reasoncodes import ReasonCode
  3103. state_changes = []
  3104. mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)
  3105. mqtt_client.state.connected = True
  3106. mqtt_client._last_message_time = time.time() - 3 # 3s ago
  3107. # Error disconnect (rc.is_failure = True)
  3108. rc = ReasonCode(mqtt.CONNACK >> 4, identifier=0x80) # Failure code
  3109. mqtt_client._on_disconnect(None, None, rc=rc)
  3110. assert state_changes == [False]
  3111. assert mqtt_client.state.connected is False
  3112. class TestDoorOpenParsing:
  3113. """Tests for enclosure door state parsing (X1 home_flag bit 23 vs others stat bit 23)."""
  3114. def _make_client(self, model: str):
  3115. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3116. return BambuMQTTClient(
  3117. ip_address="192.168.1.100",
  3118. serial_number="TEST",
  3119. access_code="12345678",
  3120. model=model,
  3121. )
  3122. def test_x1c_door_open_from_home_flag(self):
  3123. client = self._make_client("X1C")
  3124. # bit 23 set
  3125. client._update_state({"home_flag": 0xC0E5CD98})
  3126. assert client.state.door_open is True
  3127. def test_x1c_door_closed_from_home_flag(self):
  3128. client = self._make_client("X1C")
  3129. client.state.door_open = True # start "open"
  3130. client._update_state({"home_flag": 0xC065CD98})
  3131. assert client.state.door_open is False
  3132. def test_x1c_ignores_stat_field(self):
  3133. # X1C must NOT use stat (bit 23 in stat is unrelated for X1)
  3134. client = self._make_client("X1C")
  3135. client._update_state({"home_flag": 0xC065CD98, "stat": "47A58000"})
  3136. assert client.state.door_open is False # home_flag wins
  3137. def test_h2d_door_open_from_stat(self):
  3138. client = self._make_client("H2D")
  3139. client._update_state({"stat": "640A58000"}) # bit 23 set
  3140. assert client.state.door_open is True
  3141. def test_h2d_door_closed_from_stat(self):
  3142. client = self._make_client("H2D")
  3143. client.state.door_open = True
  3144. client._update_state({"stat": "640258000"}) # bit 23 cleared
  3145. assert client.state.door_open is False
  3146. def test_h2d_ignores_home_flag(self):
  3147. # Non-X1 must NOT consume home_flag for door state
  3148. client = self._make_client("H2D")
  3149. client._update_state({"home_flag": 0xC0E5CD98, "stat": "640258000"})
  3150. assert client.state.door_open is False # stat wins
  3151. def test_invalid_stat_does_not_raise(self):
  3152. client = self._make_client("H2D")
  3153. client._update_state({"stat": "not-hex"})
  3154. assert client.state.door_open is False
  3155. class TestSdCardParsing:
  3156. """SD-card state is only set from the top-level `sdcard` field (bool/int/
  3157. string variants). home_flag is NOT consulted — heartbeat pushes clear those
  3158. bits even when a card is inserted, and the prior badge feature was removed
  3159. entirely because no reliable heartbeat-vs-full-push heuristic existed."""
  3160. def _make_client(self, model: str = "H2D"):
  3161. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3162. return BambuMQTTClient(
  3163. ip_address="192.168.1.100",
  3164. serial_number="TEST",
  3165. access_code="12345678",
  3166. model=model,
  3167. )
  3168. def test_home_flag_alone_does_not_touch_sdcard(self):
  3169. client = self._make_client()
  3170. client.state.sdcard = True
  3171. for home_flag in (0x00000000, 0x00000100, 0x00000200):
  3172. client._update_state({"home_flag": home_flag})
  3173. assert client.state.sdcard is True
  3174. def test_sdcard_string_fallback_when_no_home_flag(self):
  3175. client = self._make_client()
  3176. client._update_state({"sdcard": "HAS_SDCARD_NORMAL"})
  3177. assert client.state.sdcard is True
  3178. def test_sdcard_int_fallback_when_no_home_flag(self):
  3179. # `1 is True` is False — the old strict check flapped here.
  3180. client = self._make_client()
  3181. client._update_state({"sdcard": 1})
  3182. assert client.state.sdcard is True
  3183. def test_sdcard_bool_fallback_when_no_home_flag(self):
  3184. client = self._make_client()
  3185. client._update_state({"sdcard": True})
  3186. assert client.state.sdcard is True
  3187. client._update_state({"sdcard": False})
  3188. assert client.state.sdcard is False
  3189. class TestZombieSessionDetection:
  3190. """Tests for ams_filament_setting response tracking (#887).
  3191. When a printer's MQTT session degrades so that telemetry flows but
  3192. published commands never reach the printer, the zombie detector
  3193. counts consecutive unanswered ams_filament_setting commands and
  3194. force-reconnects after two.
  3195. """
  3196. @pytest.fixture
  3197. def mqtt_client(self):
  3198. import time
  3199. from unittest.mock import MagicMock
  3200. from backend.app.services.bambu_mqtt import BambuMQTTClient
  3201. client = BambuMQTTClient(
  3202. ip_address="192.168.1.100",
  3203. serial_number="TEST123",
  3204. access_code="12345678",
  3205. )
  3206. client.state.connected = True
  3207. mock_paho = MagicMock()
  3208. mock_paho.socket.return_value = MagicMock()
  3209. client._client = mock_paho
  3210. client._connect_time = time.monotonic() - 10.0
  3211. # Set developer_mode so the dev-mode probe branch doesn't interfere
  3212. client.state.developer_mode = True
  3213. return client
  3214. def test_initial_state_is_clean(self, mqtt_client):
  3215. """Tracking fields start at zero / no pending command."""
  3216. assert mqtt_client._last_ams_cmd_time == 0.0
  3217. assert mqtt_client._ams_cmd_unanswered == 0
  3218. def test_publish_sets_pending_time(self, mqtt_client):
  3219. """set_ams_filament_setting records the publish timestamp."""
  3220. import time
  3221. before = time.monotonic()
  3222. mqtt_client.ams_set_filament_setting(
  3223. ams_id=0,
  3224. tray_id=0,
  3225. tray_info_idx="GFL99",
  3226. tray_type="PLA",
  3227. tray_sub_brands="",
  3228. tray_color="FF0000FF",
  3229. nozzle_temp_min=190,
  3230. nozzle_temp_max=230,
  3231. )
  3232. assert mqtt_client._last_ams_cmd_time >= before
  3233. def test_reset_slot_sets_pending_time(self, mqtt_client):
  3234. """reset_ams_slot also records the publish timestamp."""
  3235. import time
  3236. before = time.monotonic()
  3237. mqtt_client.reset_ams_slot(ams_id=0, tray_id=0)
  3238. assert mqtt_client._last_ams_cmd_time >= before
  3239. def test_response_clears_pending(self, mqtt_client):
  3240. """An ams_filament_setting response clears the pending state."""
  3241. import time
  3242. mqtt_client._last_ams_cmd_time = time.monotonic()
  3243. mqtt_client._ams_cmd_unanswered = 1
  3244. # Simulate receiving a user-command response (sequence_id "0")
  3245. print_data = {
  3246. "command": "ams_filament_setting",
  3247. "sequence_id": "0",
  3248. "result": "success",
  3249. }
  3250. # Walk the same path as _on_message: command response check then _update_state
  3251. cmd = print_data.get("command")
  3252. if cmd == "ams_filament_setting" and mqtt_client._last_ams_cmd_time > 0:
  3253. mqtt_client._last_ams_cmd_time = 0.0
  3254. mqtt_client._ams_cmd_unanswered = 0
  3255. assert mqtt_client._last_ams_cmd_time == 0.0
  3256. assert mqtt_client._ams_cmd_unanswered == 0
  3257. def test_single_timeout_increments_counter(self, mqtt_client):
  3258. """One unanswered command increments the counter but does not reconnect."""
  3259. import time
  3260. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  3261. mqtt_client._update_state({"gcode_state": "IDLE"})
  3262. assert mqtt_client._ams_cmd_unanswered == 1
  3263. assert mqtt_client._last_ams_cmd_time == 0.0
  3264. # Should NOT force-reconnect after just one
  3265. assert mqtt_client.state.connected is True
  3266. def test_two_timeouts_force_reconnect(self, mqtt_client):
  3267. """Two consecutive unanswered commands trigger force_reconnect."""
  3268. import time
  3269. state_change_called = []
  3270. mqtt_client.on_state_change = lambda s: state_change_called.append(True)
  3271. # First unanswered command
  3272. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  3273. mqtt_client._update_state({"gcode_state": "IDLE"})
  3274. assert mqtt_client._ams_cmd_unanswered == 1
  3275. assert mqtt_client.state.connected is True
  3276. # Second unanswered command
  3277. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  3278. mqtt_client._update_state({"gcode_state": "IDLE"})
  3279. assert mqtt_client._ams_cmd_unanswered == 0 # reset after reconnect
  3280. assert mqtt_client.state.connected is False
  3281. assert mqtt_client._stale_reconnecting is True
  3282. mqtt_client._client.socket().close.assert_called()
  3283. assert len(state_change_called) > 0
  3284. def test_response_between_timeouts_resets_counter(self, mqtt_client):
  3285. """A successful response after one timeout resets the counter."""
  3286. import time
  3287. # First unanswered command
  3288. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  3289. mqtt_client._update_state({"gcode_state": "IDLE"})
  3290. assert mqtt_client._ams_cmd_unanswered == 1
  3291. # Now a response arrives — clear pending
  3292. mqtt_client._last_ams_cmd_time = time.monotonic()
  3293. mqtt_client._last_ams_cmd_time = 0.0
  3294. mqtt_client._ams_cmd_unanswered = 0
  3295. # Next unanswered command should be count=1, not count=2
  3296. mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0
  3297. mqtt_client._update_state({"gcode_state": "IDLE"})
  3298. assert mqtt_client._ams_cmd_unanswered == 1
  3299. assert mqtt_client.state.connected is True # no reconnect
  3300. def test_on_connect_resets_tracking(self, mqtt_client):
  3301. """_on_connect resets zombie tracking fields."""
  3302. import time
  3303. mqtt_client._last_ams_cmd_time = time.monotonic()
  3304. mqtt_client._ams_cmd_unanswered = 5
  3305. # subscribe() must return (result, mid) tuple
  3306. mqtt_client._client.subscribe.return_value = (0, 1)
  3307. mqtt_client._on_connect(mqtt_client._client, None, None, 0)
  3308. assert mqtt_client._last_ams_cmd_time == 0.0
  3309. assert mqtt_client._ams_cmd_unanswered == 0
  3310. def test_no_check_when_no_command_pending(self, mqtt_client):
  3311. """If no command was published, push_status does not trigger detection."""
  3312. assert mqtt_client._last_ams_cmd_time == 0.0
  3313. mqtt_client._update_state({"gcode_state": "IDLE"})
  3314. assert mqtt_client._ams_cmd_unanswered == 0
  3315. def test_no_timeout_within_window(self, mqtt_client):
  3316. """A command published <10s ago should not trigger a timeout."""
  3317. import time
  3318. mqtt_client._last_ams_cmd_time = time.monotonic() - 5.0
  3319. mqtt_client._update_state({"gcode_state": "IDLE"})
  3320. assert mqtt_client._ams_cmd_unanswered == 0
  3321. assert mqtt_client._last_ams_cmd_time > 0 # still pending