test_bambu_mqtt.py 139 KB

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