test_bambu_mqtt.py 93 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451
  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. class TestNozzleRackData:
  509. """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
  510. @pytest.fixture
  511. def mqtt_client(self):
  512. """Create a BambuMQTTClient instance for testing."""
  513. from backend.app.services.bambu_mqtt import BambuMQTTClient
  514. client = BambuMQTTClient(
  515. ip_address="192.168.1.100",
  516. serial_number="TEST123",
  517. access_code="12345678",
  518. )
  519. return client
  520. def test_h2c_nozzle_rack_populated_with_8_entries(self, mqtt_client):
  521. """H2C provides 8 nozzle entries: IDs 0,1 (L/R hotend) + 16-21 (rack)."""
  522. payload = {
  523. "print": {
  524. "device": {
  525. "nozzle": {
  526. "info": [
  527. {
  528. "id": 0,
  529. "type": "HS",
  530. "diameter": "0.4",
  531. "wear": 5,
  532. "stat": 1,
  533. "max_temp": 300,
  534. "serial_number": "SN-L",
  535. },
  536. {
  537. "id": 1,
  538. "type": "HS",
  539. "diameter": "0.4",
  540. "wear": 3,
  541. "stat": 0,
  542. "max_temp": 300,
  543. "serial_number": "SN-R",
  544. },
  545. {
  546. "id": 16,
  547. "type": "HS",
  548. "diameter": "0.4",
  549. "wear": 10,
  550. "stat": 0,
  551. "max_temp": 300,
  552. "serial_number": "SN-16",
  553. },
  554. {
  555. "id": 17,
  556. "type": "HH01",
  557. "diameter": "0.6",
  558. "wear": 0,
  559. "stat": 0,
  560. "max_temp": 300,
  561. "serial_number": "SN-17",
  562. },
  563. {
  564. "id": 18,
  565. "type": "HS",
  566. "diameter": "0.4",
  567. "wear": 2,
  568. "stat": 0,
  569. "max_temp": 300,
  570. "serial_number": "SN-18",
  571. },
  572. {
  573. "id": 19,
  574. "type": "",
  575. "diameter": "",
  576. "wear": None,
  577. "stat": None,
  578. "max_temp": 0,
  579. "serial_number": "",
  580. },
  581. {
  582. "id": 20,
  583. "type": "",
  584. "diameter": "",
  585. "wear": None,
  586. "stat": None,
  587. "max_temp": 0,
  588. "serial_number": "",
  589. },
  590. {
  591. "id": 21,
  592. "type": "",
  593. "diameter": "",
  594. "wear": None,
  595. "stat": None,
  596. "max_temp": 0,
  597. "serial_number": "",
  598. },
  599. ]
  600. }
  601. }
  602. }
  603. }
  604. mqtt_client._process_message(payload)
  605. assert len(mqtt_client.state.nozzle_rack) == 8
  606. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  607. assert ids == [0, 1, 16, 17, 18, 19, 20, 21]
  608. def test_h2d_nozzle_rack_populated_with_2_entries(self, mqtt_client):
  609. """H2D provides 2 nozzle entries: IDs 0,1 (L/R hotend) — no rack slots."""
  610. payload = {
  611. "print": {
  612. "device": {
  613. "nozzle": {
  614. "info": [
  615. {
  616. "id": 0,
  617. "type": "HS",
  618. "diameter": "0.4",
  619. "wear": 5,
  620. "stat": 1,
  621. "max_temp": 300,
  622. "serial_number": "SN-L",
  623. },
  624. {
  625. "id": 1,
  626. "type": "HS",
  627. "diameter": "0.4",
  628. "wear": 3,
  629. "stat": 1,
  630. "max_temp": 300,
  631. "serial_number": "SN-R",
  632. },
  633. ]
  634. }
  635. }
  636. }
  637. }
  638. mqtt_client._process_message(payload)
  639. assert len(mqtt_client.state.nozzle_rack) == 2
  640. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  641. assert ids == [0, 1]
  642. def test_single_nozzle_h2s_populated(self, mqtt_client):
  643. """H2S provides 1 nozzle entry: ID 0 only — single nozzle printer."""
  644. payload = {
  645. "print": {
  646. "device": {
  647. "nozzle": {
  648. "info": [
  649. {
  650. "id": 0,
  651. "type": "HS",
  652. "diameter": "0.4",
  653. "wear": 2,
  654. "stat": 1,
  655. "max_temp": 300,
  656. "serial_number": "SN-0",
  657. },
  658. ]
  659. }
  660. }
  661. }
  662. }
  663. mqtt_client._process_message(payload)
  664. assert len(mqtt_client.state.nozzle_rack) == 1
  665. assert mqtt_client.state.nozzle_rack[0]["id"] == 0
  666. def test_empty_nozzle_info_does_not_populate_rack(self, mqtt_client):
  667. """Empty nozzle info list should not populate nozzle_rack."""
  668. payload = {"print": {"device": {"nozzle": {"info": []}}}}
  669. mqtt_client._process_message(payload)
  670. assert mqtt_client.state.nozzle_rack == []
  671. def test_nozzle_rack_sorted_by_id(self, mqtt_client):
  672. """Nozzle rack entries should be sorted by ID regardless of input order."""
  673. payload = {
  674. "print": {
  675. "device": {
  676. "nozzle": {
  677. "info": [
  678. {"id": 17, "type": "HS", "diameter": "0.6"},
  679. {"id": 0, "type": "HS", "diameter": "0.4"},
  680. {"id": 16, "type": "HS", "diameter": "0.4"},
  681. {"id": 1, "type": "HS", "diameter": "0.4"},
  682. ]
  683. }
  684. }
  685. }
  686. }
  687. mqtt_client._process_message(payload)
  688. ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
  689. assert ids == [0, 1, 16, 17]
  690. def test_nozzle_rack_field_mapping(self, mqtt_client):
  691. """Verify field mapping from MQTT nozzle_info to nozzle_rack dict keys."""
  692. payload = {
  693. "print": {
  694. "device": {
  695. "nozzle": {
  696. "info": [
  697. {
  698. "id": 16,
  699. "type": "HH01",
  700. "diameter": "0.6",
  701. "wear": 15,
  702. "stat": 0,
  703. "max_temp": 320,
  704. "serial_number": "SN-ABC123",
  705. "filament_colour": "FF8800",
  706. "filament_id": "F42",
  707. "tray_type": "ABS",
  708. }
  709. ]
  710. }
  711. }
  712. }
  713. }
  714. mqtt_client._process_message(payload)
  715. slot = mqtt_client.state.nozzle_rack[0]
  716. assert slot["id"] == 16
  717. assert slot["type"] == "HH01"
  718. assert slot["diameter"] == "0.6"
  719. assert slot["wear"] == 15
  720. assert slot["stat"] == 0
  721. assert slot["max_temp"] == 320
  722. assert slot["serial_number"] == "SN-ABC123"
  723. assert slot["filament_color"] == "FF8800"
  724. assert slot["filament_id"] == "F42"
  725. assert slot["filament_type"] == "ABS"
  726. def test_nozzle_info_updates_nozzle_state(self, mqtt_client):
  727. """Nozzle info for IDs 0,1 should also update nozzle state (type/diameter)."""
  728. payload = {
  729. "print": {
  730. "device": {
  731. "nozzle": {
  732. "info": [
  733. {"id": 0, "type": "HS", "diameter": "0.4"},
  734. {"id": 1, "type": "HH01", "diameter": "0.6"},
  735. ]
  736. }
  737. }
  738. }
  739. }
  740. mqtt_client._process_message(payload)
  741. assert mqtt_client.state.nozzles[0].nozzle_type == "HS"
  742. assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
  743. assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
  744. assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
  745. class TestRequestTopicFailSafe:
  746. """Tests for graceful degradation when broker rejects request topic subscription."""
  747. @pytest.fixture
  748. def mqtt_client(self):
  749. from backend.app.services.bambu_mqtt import BambuMQTTClient
  750. client = BambuMQTTClient(
  751. ip_address="192.168.1.100",
  752. serial_number="TEST123",
  753. access_code="12345678",
  754. )
  755. return client
  756. def test_request_topic_supported_by_default(self, mqtt_client):
  757. """Request topic subscription is attempted by default."""
  758. assert mqtt_client._request_topic_supported is True
  759. assert mqtt_client._request_topic_confirmed is False
  760. def test_on_subscribe_confirms_success(self, mqtt_client):
  761. """Successful SUBACK marks request topic as confirmed."""
  762. from paho.mqtt.reasoncodes import ReasonCode
  763. mqtt_client._request_topic_sub_mid = 42
  764. rc = ReasonCode(9, identifier=0) # SUBACK packetType=9, QoS 0 = success
  765. mqtt_client._on_subscribe(None, None, 42, [rc], None)
  766. assert mqtt_client._request_topic_confirmed is True
  767. assert mqtt_client._request_topic_supported is True
  768. assert mqtt_client._request_topic_sub_mid is None
  769. assert mqtt_client._request_topic_sub_time == 0.0
  770. def test_on_subscribe_detects_rejection(self, mqtt_client):
  771. """SUBACK with failure code disables request topic."""
  772. from paho.mqtt.reasoncodes import ReasonCode
  773. mqtt_client._request_topic_sub_mid = 42
  774. rc = ReasonCode(9, identifier=0x80) # SUBACK packetType=9, 0x80 = failure
  775. mqtt_client._on_subscribe(None, None, 42, [rc], None)
  776. assert mqtt_client._request_topic_supported is False
  777. assert mqtt_client._request_topic_confirmed is False
  778. def test_on_subscribe_ignores_other_mids(self, mqtt_client):
  779. """SUBACK for other subscriptions (e.g. report topic) is ignored."""
  780. from paho.mqtt.reasoncodes import ReasonCode
  781. mqtt_client._request_topic_sub_mid = 42
  782. rc = ReasonCode(9, identifier=0x80)
  783. mqtt_client._on_subscribe(None, None, 99, [rc], None)
  784. # Not affected — mid doesn't match
  785. assert mqtt_client._request_topic_supported is True
  786. def test_disconnect_after_subscription_disables_topic(self, mqtt_client):
  787. """Disconnect within 10s of subscription attempt disables request topic."""
  788. import time
  789. mqtt_client._request_topic_sub_time = time.time()
  790. mqtt_client._request_topic_confirmed = False
  791. mqtt_client._last_message_time = 0.0
  792. mqtt_client._on_disconnect(None, None)
  793. assert mqtt_client._request_topic_supported is False
  794. assert mqtt_client._request_topic_sub_time == 0.0
  795. def test_disconnect_after_confirmation_does_not_disable(self, mqtt_client):
  796. """Disconnect after SUBACK confirmation keeps request topic enabled."""
  797. import time
  798. mqtt_client._request_topic_sub_time = time.time()
  799. mqtt_client._request_topic_confirmed = True
  800. mqtt_client._last_message_time = 0.0
  801. mqtt_client._on_disconnect(None, None)
  802. assert mqtt_client._request_topic_supported is True
  803. def test_late_disconnect_does_not_disable(self, mqtt_client):
  804. """Disconnect long after subscription (>10s) doesn't blame request topic."""
  805. import time
  806. mqtt_client._request_topic_sub_time = time.time() - 30.0
  807. mqtt_client._request_topic_confirmed = False
  808. mqtt_client._last_message_time = 0.0
  809. mqtt_client._on_disconnect(None, None)
  810. assert mqtt_client._request_topic_supported is True
  811. def test_on_connect_skips_request_topic_when_unsupported(self, mqtt_client):
  812. """After marking unsupported, reconnect skips request topic subscription."""
  813. mqtt_client._request_topic_supported = False
  814. subscribe_calls = []
  815. mock_client = type(
  816. "MockClient",
  817. (),
  818. {
  819. "subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
  820. },
  821. )()
  822. mqtt_client._on_connect(mock_client, None, None, 0)
  823. # Only report topic subscribed, not request topic
  824. assert len(subscribe_calls) == 1
  825. assert subscribe_calls[0] == mqtt_client.topic_subscribe
  826. class TestRequestTopicAmsMapping:
  827. """Tests for capturing ams_mapping from the MQTT request topic."""
  828. @pytest.fixture
  829. def mqtt_client(self):
  830. """Create a BambuMQTTClient instance for testing."""
  831. from backend.app.services.bambu_mqtt import BambuMQTTClient
  832. client = BambuMQTTClient(
  833. ip_address="192.168.1.100",
  834. serial_number="TEST123",
  835. access_code="12345678",
  836. )
  837. return client
  838. def test_captured_ams_mapping_initializes_to_none(self, mqtt_client):
  839. """Verify _captured_ams_mapping starts as None."""
  840. assert mqtt_client._captured_ams_mapping is None
  841. def test_handle_request_message_captures_ams_mapping(self, mqtt_client):
  842. """project_file command with ams_mapping stores the mapping."""
  843. data = {
  844. "print": {
  845. "command": "project_file",
  846. "ams_mapping": [0, 4, -1, -1],
  847. "url": "ftp://192.168.1.100/test.3mf",
  848. }
  849. }
  850. mqtt_client._handle_request_message(data)
  851. assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
  852. def test_handle_request_message_ignores_non_print_commands(self, mqtt_client):
  853. """Non-project_file commands don't store ams_mapping."""
  854. data = {
  855. "print": {
  856. "command": "pause",
  857. }
  858. }
  859. mqtt_client._handle_request_message(data)
  860. assert mqtt_client._captured_ams_mapping is None
  861. def test_handle_request_message_ignores_missing_ams_mapping(self, mqtt_client):
  862. """project_file command without ams_mapping doesn't store anything."""
  863. data = {
  864. "print": {
  865. "command": "project_file",
  866. "url": "ftp://192.168.1.100/test.3mf",
  867. }
  868. }
  869. mqtt_client._handle_request_message(data)
  870. assert mqtt_client._captured_ams_mapping is None
  871. def test_handle_request_message_ignores_non_dict_print(self, mqtt_client):
  872. """Non-dict print value is safely ignored."""
  873. data = {"print": "not_a_dict"}
  874. mqtt_client._handle_request_message(data)
  875. assert mqtt_client._captured_ams_mapping is None
  876. def test_handle_request_message_ignores_missing_print(self, mqtt_client):
  877. """Message without print key is safely ignored."""
  878. data = {"pushing": {"command": "pushall"}}
  879. mqtt_client._handle_request_message(data)
  880. assert mqtt_client._captured_ams_mapping is None
  881. def test_captured_mapping_overwrites_previous(self, mqtt_client):
  882. """A new print command overwrites a previously captured mapping."""
  883. mqtt_client._captured_ams_mapping = [0, -1, -1, -1]
  884. data = {
  885. "print": {
  886. "command": "project_file",
  887. "ams_mapping": [4, 8, -1, -1],
  888. }
  889. }
  890. mqtt_client._handle_request_message(data)
  891. assert mqtt_client._captured_ams_mapping == [4, 8, -1, -1]
  892. def test_print_start_callback_includes_ams_mapping(self, mqtt_client):
  893. """on_print_start callback data includes captured ams_mapping."""
  894. start_data = {}
  895. def on_start(data):
  896. start_data.update(data)
  897. mqtt_client.on_print_start = on_start
  898. mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
  899. # Trigger print start
  900. mqtt_client._process_message(
  901. {
  902. "print": {
  903. "gcode_state": "RUNNING",
  904. "gcode_file": "/data/Metadata/test.gcode",
  905. "subtask_name": "Test",
  906. }
  907. }
  908. )
  909. assert start_data.get("ams_mapping") == [0, 4, -1, -1]
  910. def test_print_start_callback_ams_mapping_none_when_not_captured(self, mqtt_client):
  911. """on_print_start callback has ams_mapping=None when no mapping captured."""
  912. start_data = {}
  913. def on_start(data):
  914. start_data.update(data)
  915. mqtt_client.on_print_start = on_start
  916. mqtt_client._process_message(
  917. {
  918. "print": {
  919. "gcode_state": "RUNNING",
  920. "gcode_file": "/data/Metadata/test.gcode",
  921. "subtask_name": "Test",
  922. }
  923. }
  924. )
  925. assert "ams_mapping" in start_data
  926. assert start_data["ams_mapping"] is None
  927. def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):
  928. """on_print_complete callback data includes captured ams_mapping."""
  929. complete_data = {}
  930. def on_complete(data):
  931. complete_data.update(data)
  932. mqtt_client.on_print_start = lambda d: None
  933. mqtt_client.on_print_complete = on_complete
  934. mqtt_client._captured_ams_mapping = [0, 9, -1, -1]
  935. # Start print
  936. mqtt_client._process_message(
  937. {
  938. "print": {
  939. "gcode_state": "RUNNING",
  940. "gcode_file": "/data/Metadata/test.gcode",
  941. "subtask_name": "Test",
  942. }
  943. }
  944. )
  945. # Complete print
  946. mqtt_client._process_message(
  947. {
  948. "print": {
  949. "gcode_state": "FINISH",
  950. "gcode_file": "/data/Metadata/test.gcode",
  951. "subtask_name": "Test",
  952. }
  953. }
  954. )
  955. assert complete_data.get("ams_mapping") == [0, 9, -1, -1]
  956. def test_captured_mapping_cleared_after_print_complete(self, mqtt_client):
  957. """_captured_ams_mapping is reset to None after print completion."""
  958. mqtt_client.on_print_start = lambda d: None
  959. mqtt_client.on_print_complete = lambda d: None
  960. mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
  961. # Start print
  962. mqtt_client._process_message(
  963. {
  964. "print": {
  965. "gcode_state": "RUNNING",
  966. "gcode_file": "/data/Metadata/test.gcode",
  967. "subtask_name": "Test",
  968. }
  969. }
  970. )
  971. # Complete print
  972. mqtt_client._process_message(
  973. {
  974. "print": {
  975. "gcode_state": "FINISH",
  976. "gcode_file": "/data/Metadata/test.gcode",
  977. "subtask_name": "Test",
  978. }
  979. }
  980. )
  981. assert mqtt_client._captured_ams_mapping is None
  982. def test_full_flow_capture_and_deliver(self, mqtt_client):
  983. """Full flow: slicer sends print command → MQTT captures mapping → completion delivers it."""
  984. complete_data = {}
  985. def on_complete(data):
  986. complete_data.update(data)
  987. mqtt_client.on_print_start = lambda d: None
  988. mqtt_client.on_print_complete = on_complete
  989. # 1. Slicer sends print command (captured from request topic)
  990. mqtt_client._handle_request_message(
  991. {
  992. "print": {
  993. "command": "project_file",
  994. "ams_mapping": [4, 9, -1, -1],
  995. "url": "ftp://192.168.1.100/model.3mf",
  996. }
  997. }
  998. )
  999. assert mqtt_client._captured_ams_mapping == [4, 9, -1, -1]
  1000. # 2. Printer reports RUNNING
  1001. mqtt_client._process_message(
  1002. {
  1003. "print": {
  1004. "gcode_state": "RUNNING",
  1005. "gcode_file": "/data/Metadata/model.gcode",
  1006. "subtask_name": "Model",
  1007. }
  1008. }
  1009. )
  1010. # 3. Printer reports FINISH
  1011. mqtt_client._process_message(
  1012. {
  1013. "print": {
  1014. "gcode_state": "FINISH",
  1015. "gcode_file": "/data/Metadata/model.gcode",
  1016. "subtask_name": "Model",
  1017. }
  1018. }
  1019. )
  1020. assert complete_data["ams_mapping"] == [4, 9, -1, -1]
  1021. assert complete_data["status"] == "completed"
  1022. # Mapping cleared after completion
  1023. assert mqtt_client._captured_ams_mapping is None
  1024. # ---------------------------------------------------------------------------
  1025. # tray_now disambiguation helpers
  1026. # ---------------------------------------------------------------------------
  1027. def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None, ams_exist_bits=None):
  1028. """Build minimal print.ams payload for tray_now disambiguation tests."""
  1029. ams = {"tray_now": str(tray_now)}
  1030. if ams_units is not None:
  1031. ams["ams"] = ams_units
  1032. if tray_exist_bits is not None:
  1033. ams["tray_exist_bits"] = tray_exist_bits
  1034. if ams_exist_bits is not None:
  1035. ams["ams_exist_bits"] = ams_exist_bits
  1036. return {"print": {"ams": ams}}
  1037. def _extruder_info_payload(extruders):
  1038. """Build device.extruder.info payload (dual-nozzle detection + snow).
  1039. Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
  1040. """
  1041. return {
  1042. "print": {
  1043. "device": {
  1044. "extruder": {
  1045. "info": extruders,
  1046. }
  1047. }
  1048. }
  1049. }
  1050. def _extruder_state_payload(state_val):
  1051. """Build device.extruder.state payload (active extruder via bit 8)."""
  1052. return {
  1053. "print": {
  1054. "device": {
  1055. "extruder": {
  1056. "state": state_val,
  1057. }
  1058. }
  1059. }
  1060. }
  1061. # ---------------------------------------------------------------------------
  1062. # 1. Single-nozzle X1E — direct passthrough
  1063. # ---------------------------------------------------------------------------
  1064. class TestTrayNowSingleNozzleX1E:
  1065. """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
  1066. @pytest.fixture
  1067. def mqtt_client(self):
  1068. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1069. return BambuMQTTClient(
  1070. ip_address="192.168.1.100",
  1071. serial_number="TEST_X1E",
  1072. access_code="12345678",
  1073. )
  1074. def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
  1075. """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
  1076. for slot in range(4):
  1077. mqtt_client._process_message(_ams_payload(slot))
  1078. assert mqtt_client.state.tray_now == slot
  1079. def test_tray_now_255_means_unloaded(self, mqtt_client):
  1080. """tray_now=255 means no filament loaded."""
  1081. mqtt_client._process_message(_ams_payload(255))
  1082. assert mqtt_client.state.tray_now == 255
  1083. def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
  1084. """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
  1085. mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
  1086. assert mqtt_client._is_dual_nozzle is False
  1087. def test_last_loaded_tray_survives_unload(self, mqtt_client):
  1088. """Load tray 2, unload → last_loaded_tray stays 2."""
  1089. mqtt_client._process_message(_ams_payload(2))
  1090. assert mqtt_client.state.last_loaded_tray == 2
  1091. mqtt_client._process_message(_ams_payload(255))
  1092. assert mqtt_client.state.tray_now == 255
  1093. assert mqtt_client.state.last_loaded_tray == 2
  1094. # ---------------------------------------------------------------------------
  1095. # 2. Single-nozzle P2S — multiple AMS, global IDs pass through
  1096. # ---------------------------------------------------------------------------
  1097. class TestTrayNowSingleNozzleP2S:
  1098. """Single-nozzle, 2 AMS — tray_now > 3 passes through as global ID."""
  1099. @pytest.fixture
  1100. def mqtt_client(self):
  1101. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1102. return BambuMQTTClient(
  1103. ip_address="192.168.1.100",
  1104. serial_number="TEST_P2S",
  1105. access_code="12345678",
  1106. )
  1107. def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
  1108. """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
  1109. for global_id in range(4, 8):
  1110. mqtt_client._process_message(_ams_payload(global_id))
  1111. assert mqtt_client.state.tray_now == global_id
  1112. def test_tray_change_across_ams_units(self, mqtt_client):
  1113. """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
  1114. mqtt_client._process_message(_ams_payload(1))
  1115. assert mqtt_client.state.tray_now == 1
  1116. mqtt_client._process_message(_ams_payload(6))
  1117. assert mqtt_client.state.tray_now == 6
  1118. # ---------------------------------------------------------------------------
  1119. # 2b. Single-nozzle P2S — multi-AMS local slot disambiguation (#420)
  1120. # ---------------------------------------------------------------------------
  1121. class TestTrayNowP2SMultiAmsDisambiguation:
  1122. """P2S firmware sends local slot IDs (0-3) in tray_now even with dual AMS.
  1123. When ams_exist_bits indicates >1 AMS unit and tray_now is 0-3, the backend
  1124. should use the MQTT mapping field (snow-encoded) to resolve the correct
  1125. global tray ID.
  1126. """
  1127. @pytest.fixture
  1128. def mqtt_client(self):
  1129. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1130. client = BambuMQTTClient(
  1131. ip_address="192.168.1.100",
  1132. serial_number="TEST_P2S_DUAL",
  1133. access_code="12345678",
  1134. )
  1135. return client
  1136. def test_resolves_ams1_slot1_from_mapping(self, mqtt_client):
  1137. """tray_now=1 with mapping=[257] → global ID 5 (AMS1-T1).
  1138. 257 snow-decoded: ams_hw_id=1, slot=1 → global 1*4+1=5.
  1139. """
  1140. # Set mapping field in raw_data (as the MQTT handler would)
  1141. mqtt_client.state.raw_data["mapping"] = [257]
  1142. mqtt_client._process_message(
  1143. _ams_payload(1, ams_exist_bits="3") # '3' = 0b11 → AMS 0 and 1
  1144. )
  1145. assert mqtt_client.state.tray_now == 5
  1146. def test_resolves_ams1_slot0_from_mapping(self, mqtt_client):
  1147. """tray_now=0 with mapping=[256] → global ID 4 (AMS1-T0).
  1148. 256 snow-decoded: ams_hw_id=1, slot=0 → global 1*4+0=4.
  1149. """
  1150. mqtt_client.state.raw_data["mapping"] = [256]
  1151. mqtt_client._process_message(_ams_payload(0, ams_exist_bits="3"))
  1152. assert mqtt_client.state.tray_now == 4
  1153. def test_resolves_ams1_slot3_from_mapping(self, mqtt_client):
  1154. """tray_now=3 with mapping=[259] → global ID 7 (AMS1-T3).
  1155. 259 snow-decoded: ams_hw_id=1, slot=3 → global 1*4+3=7.
  1156. """
  1157. mqtt_client.state.raw_data["mapping"] = [259]
  1158. mqtt_client._process_message(_ams_payload(3, ams_exist_bits="3"))
  1159. assert mqtt_client.state.tray_now == 7
  1160. def test_ams0_slot_unchanged_when_mapping_confirms_ams0(self, mqtt_client):
  1161. """tray_now=1 with mapping=[1] → stays 1 (AMS0-T1).
  1162. 1 snow-decoded: ams_hw_id=0, slot=1 → global 0*4+1=1.
  1163. """
  1164. mqtt_client.state.raw_data["mapping"] = [1]
  1165. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1166. assert mqtt_client.state.tray_now == 1
  1167. def test_multicolor_resolves_ams1_from_multi_entry_mapping(self, mqtt_client):
  1168. """Multi-color print: mapping=[0, 257] → tray_now=1 resolves to AMS1-T1 (5).
  1169. Entry 0: ams_hw_id=0, slot=0 (local 0) — doesn't match tray_now=1.
  1170. Entry 257: ams_hw_id=1, slot=1 (local 1) — matches tray_now=1 → global 5.
  1171. """
  1172. mqtt_client.state.raw_data["mapping"] = [0, 257]
  1173. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1174. assert mqtt_client.state.tray_now == 5
  1175. def test_multicolor_four_slot_mapping(self, mqtt_client):
  1176. """mapping=[65535, 65535, 65535, 257] → tray_now=1 resolves to global 5.
  1177. Only entry 257 has local slot=1, other entries are unmapped (65535).
  1178. Reproduces exact data from issue #420 support package.
  1179. """
  1180. mqtt_client.state.raw_data["mapping"] = [65535, 65535, 65535, 257]
  1181. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1182. assert mqtt_client.state.tray_now == 5
  1183. def test_ambiguous_mapping_falls_back_to_local_slot(self, mqtt_client):
  1184. """Two AMS units with same local slot in mapping → ambiguous, keep local slot.
  1185. mapping=[1, 257]: both have local slot 1 (AMS0-T1 and AMS1-T1).
  1186. Cannot disambiguate → fall back to tray_now=1.
  1187. """
  1188. mqtt_client.state.raw_data["mapping"] = [1, 257]
  1189. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1190. assert mqtt_client.state.tray_now == 1
  1191. def test_no_mapping_falls_back_to_local_slot(self, mqtt_client):
  1192. """No mapping field available → fall back to raw tray_now."""
  1193. # No mapping in raw_data (e.g. manual filament load, not during print)
  1194. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1195. assert mqtt_client.state.tray_now == 1
  1196. def test_empty_mapping_falls_back_to_local_slot(self, mqtt_client):
  1197. """Empty mapping list → fall back to raw tray_now."""
  1198. mqtt_client.state.raw_data["mapping"] = []
  1199. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1200. assert mqtt_client.state.tray_now == 1
  1201. def test_single_ams_passthrough(self, mqtt_client):
  1202. """Single AMS (ams_exist_bits='1') → tray_now 0-3 is direct global ID."""
  1203. mqtt_client._process_message(_ams_payload(2, ams_exist_bits="1"))
  1204. assert mqtt_client.state.tray_now == 2
  1205. def test_no_ams_exist_bits_passthrough(self, mqtt_client):
  1206. """No ams_exist_bits in payload → fall back to raw tray_now."""
  1207. mqtt_client._process_message(_ams_payload(1))
  1208. assert mqtt_client.state.tray_now == 1
  1209. def test_tray_now_255_unaffected_by_multi_ams(self, mqtt_client):
  1210. """tray_now=255 (unloaded) passes through regardless of AMS count."""
  1211. mqtt_client.state.raw_data["mapping"] = [257]
  1212. mqtt_client._process_message(_ams_payload(255, ams_exist_bits="3"))
  1213. assert mqtt_client.state.tray_now == 255
  1214. def test_tray_now_above_3_unaffected(self, mqtt_client):
  1215. """tray_now > 3 is already a global ID and passes through directly."""
  1216. mqtt_client._process_message(_ams_payload(6, ams_exist_bits="3"))
  1217. assert mqtt_client.state.tray_now == 6
  1218. def test_last_loaded_tray_uses_resolved_global_id(self, mqtt_client):
  1219. """last_loaded_tray should reflect the resolved global ID, not local slot."""
  1220. mqtt_client.state.raw_data["mapping"] = [257]
  1221. mqtt_client.state.state = "RUNNING"
  1222. mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
  1223. assert mqtt_client.state.tray_now == 5
  1224. assert mqtt_client.state.last_loaded_tray == 5
  1225. class TestResolveLocalSlotFromMapping:
  1226. """Unit tests for _resolve_local_slot_from_mapping static method."""
  1227. def test_single_match_ams0(self):
  1228. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1229. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1]) == 1
  1230. def test_single_match_ams1(self):
  1231. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1232. # 257 = 1*256 + 1 → AMS1 slot1 → global 5
  1233. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [257]) == 5
  1234. def test_single_match_ams2(self):
  1235. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1236. # 514 = 2*256 + 2 → AMS2 slot2 → global 10
  1237. assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514]) == 10
  1238. def test_unmapped_entries_skipped(self):
  1239. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1240. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [65535, 65535, 65535, 257]) == 5
  1241. def test_no_match_returns_none(self):
  1242. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1243. # mapping has slot 0 only, looking for slot 2
  1244. assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [0]) is None
  1245. def test_ambiguous_returns_none(self):
  1246. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1247. # Both AMS0 slot1 (1) and AMS1 slot1 (257) → ambiguous
  1248. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 257]) is None
  1249. def test_none_mapping_returns_none(self):
  1250. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1251. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, None) is None
  1252. def test_empty_mapping_returns_none(self):
  1253. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1254. assert BambuMQTTClient._resolve_local_slot_from_mapping(1, []) is None
  1255. def test_ams_ht_slot0_match(self):
  1256. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1257. # AMS-HT id=128: snow = 128*256 + 0 = 32768
  1258. assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128
  1259. # ---------------------------------------------------------------------------
  1260. # 3. H2D Pro — initial state detection
  1261. # ---------------------------------------------------------------------------
  1262. class TestTrayNowDualNozzleH2DSetup:
  1263. """H2D Pro initial state detection."""
  1264. @pytest.fixture
  1265. def mqtt_client(self):
  1266. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1267. return BambuMQTTClient(
  1268. ip_address="192.168.1.100",
  1269. serial_number="TEST_H2D",
  1270. access_code="12345678",
  1271. )
  1272. def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
  1273. """2 entries in device.extruder.info → _is_dual_nozzle=True."""
  1274. mqtt_client._process_message(
  1275. _extruder_info_payload(
  1276. [
  1277. {"id": 0, "snow": 0xFF00FF},
  1278. {"id": 1, "snow": 0xFF00FF},
  1279. ]
  1280. )
  1281. )
  1282. assert mqtt_client._is_dual_nozzle is True
  1283. def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
  1284. """AMS info field is hex: 0x2003 → ext 0 (right), 0x2104 → ext 1 (left)."""
  1285. # MQTT sends info as string; BambuStudio parses as hex via stoull(str, 16)
  1286. ams_units = [
  1287. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1288. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  1289. ]
  1290. payload = {
  1291. "print": {
  1292. "ams": {
  1293. "ams": ams_units,
  1294. "tray_now": "255",
  1295. "tray_exist_bits": "1000f",
  1296. },
  1297. }
  1298. }
  1299. mqtt_client._process_message(payload)
  1300. # 0x2003: bits 8-11 = (0x2003 >> 8) & 0xF = 0x20 & 0xF = 0 → extruder 0 (right)
  1301. # 0x2104: bits 8-11 = (0x2104 >> 8) & 0xF = 0x21 & 0xF = 1 → extruder 1 (left)
  1302. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1303. def test_ams_extruder_map_real_h2d_values(self, mqtt_client):
  1304. """Real H2D MQTT values: AMS2 Pro on right, AMS-HT on left."""
  1305. ams_units = [
  1306. {"id": 0, "info": "10001003", "tray": [{"id": i} for i in range(4)]},
  1307. {"id": 128, "info": "10002104", "tray": [{"id": 0}]},
  1308. ]
  1309. payload = {
  1310. "print": {
  1311. "ams": {
  1312. "ams": ams_units,
  1313. "tray_now": "255",
  1314. "tray_exist_bits": "1000a",
  1315. },
  1316. }
  1317. }
  1318. mqtt_client._process_message(payload)
  1319. # 0x10001003: bits 8-11 = (0x10001003 >> 8) & 0xF = 0x10 & 0xF = 0 → right
  1320. # 0x10002104: bits 8-11 = (0x10002104 >> 8) & 0xF = 0x21 & 0xF = 1 → left
  1321. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1322. def test_ams_extruder_map_skips_uninitialized(self, mqtt_client):
  1323. """extruder_id 0xE means uninitialized AMS — should be skipped."""
  1324. ams_units = [
  1325. {"id": 0, "info": "e03", "tray": [{"id": i} for i in range(4)]},
  1326. ]
  1327. payload = {
  1328. "print": {
  1329. "ams": {
  1330. "ams": ams_units,
  1331. "tray_now": "255",
  1332. "tray_exist_bits": "f",
  1333. },
  1334. }
  1335. }
  1336. mqtt_client._process_message(payload)
  1337. assert mqtt_client.state.ams_extruder_map == {}
  1338. def test_ams_extruder_map_partial_update_preserves_entries(self, mqtt_client):
  1339. """Partial MQTT update with one AMS should not overwrite other entries."""
  1340. # First: full update with both AMS units
  1341. full_payload = {
  1342. "print": {
  1343. "ams": {
  1344. "ams": [
  1345. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1346. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  1347. ],
  1348. "tray_now": "255",
  1349. "tray_exist_bits": "1000f",
  1350. },
  1351. }
  1352. }
  1353. mqtt_client._process_message(full_payload)
  1354. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1355. # Then: partial update with only AMS 0 (no info field this time)
  1356. partial_payload = {
  1357. "print": {
  1358. "ams": {
  1359. "ams": [
  1360. {"id": 0, "tray": [{"id": 0, "remain": 50}]},
  1361. ],
  1362. "tray_now": "0",
  1363. "tray_exist_bits": "1000f",
  1364. },
  1365. }
  1366. }
  1367. mqtt_client._process_message(partial_payload)
  1368. # Both entries should still be present
  1369. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1370. def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
  1371. """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
  1372. If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
  1373. """
  1374. payload = {
  1375. "print": {
  1376. "device": {
  1377. "extruder": {
  1378. "info": [
  1379. {"id": 0, "snow": 0xFF00FF},
  1380. {"id": 1, "snow": 0xFF00FF},
  1381. ],
  1382. "state": 0x0001,
  1383. }
  1384. },
  1385. "ams": {
  1386. "ams": [
  1387. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1388. ],
  1389. "tray_now": "2",
  1390. "tray_exist_bits": "f",
  1391. },
  1392. }
  1393. }
  1394. mqtt_client._process_message(payload)
  1395. # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
  1396. # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
  1397. # Single AMS on extruder 0 → global_id = 0*4+2 = 2
  1398. assert mqtt_client._is_dual_nozzle is True
  1399. assert mqtt_client.state.tray_now == 2
  1400. # ---------------------------------------------------------------------------
  1401. # Shared H2D fixture for classes 4-8
  1402. # ---------------------------------------------------------------------------
  1403. class _H2DFixtureMixin:
  1404. """Mixin providing a pre-configured H2D Pro client."""
  1405. @pytest.fixture
  1406. def mqtt_client(self):
  1407. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1408. return BambuMQTTClient(
  1409. ip_address="192.168.1.100",
  1410. serial_number="TEST_H2D",
  1411. access_code="12345678",
  1412. )
  1413. @pytest.fixture
  1414. def h2d_client(self, mqtt_client):
  1415. """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
  1416. mqtt_client._process_message(
  1417. {
  1418. "print": {
  1419. "device": {
  1420. "extruder": {
  1421. "info": [
  1422. {"id": 0, "snow": 0xFF00FF},
  1423. {"id": 1, "snow": 0xFF00FF},
  1424. ],
  1425. "state": 0x0001, # right extruder active
  1426. }
  1427. },
  1428. "ams": {
  1429. "ams": [
  1430. {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
  1431. {"id": 128, "info": "2104", "tray": [{"id": 0}]},
  1432. ],
  1433. "tray_now": "255",
  1434. "tray_exist_bits": "1000f",
  1435. },
  1436. }
  1437. }
  1438. )
  1439. assert mqtt_client._is_dual_nozzle is True
  1440. assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
  1441. return mqtt_client
  1442. # ---------------------------------------------------------------------------
  1443. # 4. H2D Snow field disambiguation
  1444. # ---------------------------------------------------------------------------
  1445. class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
  1446. """Snow field disambiguation (primary path)."""
  1447. def test_snow_disambiguates_ams0_slot(self, h2d_client):
  1448. """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
  1449. # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
  1450. # so we need it in a prior message).
  1451. snow_val = 0 << 8 | 2 # AMS 0 slot 2 = raw 2
  1452. h2d_client._process_message(
  1453. _extruder_info_payload(
  1454. [
  1455. {"id": 0, "snow": snow_val},
  1456. {"id": 1, "snow": 0xFF00FF},
  1457. ]
  1458. )
  1459. )
  1460. assert h2d_client.state.h2d_extruder_snow.get(0) == 2
  1461. # Now send tray_now=2
  1462. h2d_client._process_message(_ams_payload(2))
  1463. assert h2d_client.state.tray_now == 2
  1464. def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
  1465. """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
  1466. # Snow: extruder 1 → AMS 128 slot 0
  1467. snow_val = 128 << 8 | 0 # = 32768
  1468. h2d_client._process_message(
  1469. _extruder_info_payload(
  1470. [
  1471. {"id": 0, "snow": 0xFF00FF},
  1472. {"id": 1, "snow": snow_val},
  1473. ]
  1474. )
  1475. )
  1476. assert h2d_client.state.h2d_extruder_snow.get(1) == 128
  1477. # Switch to left extruder
  1478. h2d_client._process_message(_extruder_state_payload(0x0100))
  1479. assert h2d_client.state.active_extruder == 1
  1480. # tray_now="0" with left extruder active, snow says AMS HT (128)
  1481. # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
  1482. h2d_client._process_message(_ams_payload(0))
  1483. assert h2d_client.state.tray_now == 128
  1484. def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
  1485. """Verify state.h2d_extruder_snow dict is populated correctly."""
  1486. snow_ext0 = 1 << 8 | 3 # AMS 1 slot 3 → global 7
  1487. snow_ext1 = 0 << 8 | 0 # AMS 0 slot 0 → global 0
  1488. h2d_client._process_message(
  1489. _extruder_info_payload(
  1490. [
  1491. {"id": 0, "snow": snow_ext0},
  1492. {"id": 1, "snow": snow_ext1},
  1493. ]
  1494. )
  1495. )
  1496. assert h2d_client.state.h2d_extruder_snow[0] == 7
  1497. assert h2d_client.state.h2d_extruder_snow[1] == 0
  1498. def test_snow_unloaded_value(self, h2d_client):
  1499. """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
  1500. h2d_client._process_message(
  1501. _extruder_info_payload(
  1502. [
  1503. {"id": 0, "snow": 0xFFFF},
  1504. {"id": 1, "snow": 0xFFFF},
  1505. ]
  1506. )
  1507. )
  1508. assert h2d_client.state.h2d_extruder_snow[0] == 255
  1509. assert h2d_client.state.h2d_extruder_snow[1] == 255
  1510. def test_snow_initial_sentinel_not_stored(self, h2d_client):
  1511. """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
  1512. # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
  1513. h2d_client._process_message(
  1514. _extruder_info_payload(
  1515. [
  1516. {"id": 0, "snow": 0xFF00FF},
  1517. {"id": 1, "snow": 0xFF00FF},
  1518. ]
  1519. )
  1520. )
  1521. # Snow dict should remain empty (no matching branch)
  1522. assert h2d_client.state.h2d_extruder_snow == {}
  1523. # ---------------------------------------------------------------------------
  1524. # 5. H2D Pending target disambiguation
  1525. # ---------------------------------------------------------------------------
  1526. class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
  1527. """Pending target disambiguation (when Bambuddy initiates load)."""
  1528. def test_pending_target_matches_slot(self, h2d_client):
  1529. """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
  1530. h2d_client.state.pending_tray_target = 5
  1531. h2d_client._process_message(_ams_payload(1))
  1532. assert h2d_client.state.tray_now == 5
  1533. assert h2d_client.state.pending_tray_target is None # cleared
  1534. def test_pending_target_slot_mismatch(self, h2d_client):
  1535. """pending=5, tray_now='2' → uses raw slot, clears pending."""
  1536. h2d_client.state.pending_tray_target = 5
  1537. h2d_client._process_message(_ams_payload(2))
  1538. # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
  1539. assert h2d_client.state.tray_now == 2
  1540. assert h2d_client.state.pending_tray_target is None
  1541. def test_pending_target_takes_priority_over_snow(self, h2d_client):
  1542. """When both pending and snow are set, pending wins."""
  1543. # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
  1544. snow_val = 0 << 8 | 1
  1545. h2d_client._process_message(
  1546. _extruder_info_payload(
  1547. [
  1548. {"id": 0, "snow": snow_val},
  1549. {"id": 1, "snow": 0xFF00FF},
  1550. ]
  1551. )
  1552. )
  1553. assert h2d_client.state.h2d_extruder_snow.get(0) == 1
  1554. # Set pending target to AMS 1 slot 1 (global 5)
  1555. h2d_client.state.pending_tray_target = 5
  1556. # tray_now="1" — matches pending (5%4=1), pending should win over snow
  1557. h2d_client._process_message(_ams_payload(1))
  1558. assert h2d_client.state.tray_now == 5
  1559. # ---------------------------------------------------------------------------
  1560. # 6. H2D ams_extruder_map fallback
  1561. # ---------------------------------------------------------------------------
  1562. class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
  1563. """ams_extruder_map fallback (no pending, no snow)."""
  1564. def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
  1565. """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
  1566. # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
  1567. h2d_client._process_message(_ams_payload(2))
  1568. # AMS 0 is the only AMS on extruder 0 (right, active by default)
  1569. # Fallback: single AMS → global = 0*4+2 = 2
  1570. assert h2d_client.state.tray_now == 2
  1571. def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
  1572. """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
  1573. # Set up: two AMS units on the same extruder (right, ext 0)
  1574. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
  1575. # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
  1576. h2d_client.state.tray_now = 5
  1577. # tray_now="1" → 5%4=1 matches → keep current=5
  1578. h2d_client._process_message(_ams_payload(1))
  1579. assert h2d_client.state.tray_now == 5
  1580. def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
  1581. """No AMS mapped to the active extruder → raw slot as global ID."""
  1582. # All AMS on left extruder, but right is active
  1583. h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
  1584. h2d_client._process_message(_ams_payload(2))
  1585. assert h2d_client.state.tray_now == 2
  1586. def test_single_ams_ht_on_extruder_returns_unit_id(self, h2d_client):
  1587. """AMS-HT 128 alone on left extruder, slot 0 → global ID 128 (not 512)."""
  1588. # Switch to left extruder (where AMS-HT 128 is mapped)
  1589. h2d_client._process_message(_extruder_state_payload(0x0100))
  1590. # Only AMS-HT 128 on left extruder; no snow available
  1591. h2d_client._process_message(_ams_payload(0))
  1592. assert h2d_client.state.tray_now == 128
  1593. def test_single_ams_ht_ignores_nonzero_slot(self, h2d_client):
  1594. """AMS-HT has single slot; even if printer reports slot 1, global ID = unit ID."""
  1595. h2d_client.state.ams_extruder_map = {"129": 0}
  1596. h2d_client._process_message(_ams_payload(1))
  1597. # AMS-HT 129: global ID = 129, not 129*4+1=517
  1598. assert h2d_client.state.tray_now == 129
  1599. def test_multiple_ams_keeps_current_ams_ht(self, h2d_client):
  1600. """Current tray is AMS-HT 128, slot 0 reported → keeps 128."""
  1601. h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
  1602. h2d_client.state.tray_now = 128
  1603. h2d_client._process_message(_ams_payload(0))
  1604. assert h2d_client.state.tray_now == 128
  1605. def test_multiple_ams_slot_nonzero_excludes_ams_ht(self, h2d_client):
  1606. """Slot > 0 eliminates AMS-HT candidates; single regular AMS left → resolves."""
  1607. # AMS 0 + AMS-HT 128 both on right extruder
  1608. h2d_client.state.ams_extruder_map = {"0": 0, "128": 0}
  1609. h2d_client.state.tray_now = 255 # no current match
  1610. # Slot 2 → can't be AMS-HT → only AMS 0 → global = 0*4+2 = 2
  1611. h2d_client._process_message(_ams_payload(2))
  1612. assert h2d_client.state.tray_now == 2
  1613. def test_multiple_ams_slot_nonzero_narrows_to_single_ht_excluded(self, h2d_client):
  1614. """Two regular AMS + one AMS-HT, slot > 0 → AMS-HT excluded but still ambiguous."""
  1615. h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "128": 0}
  1616. h2d_client.state.tray_now = 255
  1617. # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
  1618. h2d_client._process_message(_ams_payload(3))
  1619. assert h2d_client.state.tray_now == 3 # raw slot fallback
  1620. # ---------------------------------------------------------------------------
  1621. # 6b. H2D last_loaded_tray validation
  1622. # ---------------------------------------------------------------------------
  1623. class TestLastLoadedTrayValidation(_H2DFixtureMixin):
  1624. """last_loaded_tray only stores physically valid tray IDs."""
  1625. def test_regular_ams_tray_stored(self, h2d_client):
  1626. """Valid regular AMS tray (0-15) → stored in last_loaded_tray."""
  1627. h2d_client.state.tray_now = 7
  1628. # Trigger tray_now processing via AMS message
  1629. h2d_client._process_message(
  1630. _extruder_info_payload(
  1631. [
  1632. {"id": 0, "snow": 1 << 8 | 3}, # AMS 1 slot 3 → global 7
  1633. {"id": 1, "snow": 0xFF00FF},
  1634. ]
  1635. )
  1636. )
  1637. h2d_client._process_message(_ams_payload(3))
  1638. assert h2d_client.state.tray_now == 7
  1639. assert h2d_client.state.last_loaded_tray == 7
  1640. def test_ams_ht_tray_stored(self, h2d_client):
  1641. """Valid AMS-HT tray (128-135) → stored in last_loaded_tray."""
  1642. h2d_client._process_message(_extruder_state_payload(0x0100))
  1643. h2d_client._process_message(
  1644. _extruder_info_payload(
  1645. [
  1646. {"id": 0, "snow": 0xFF00FF},
  1647. {"id": 1, "snow": 128 << 8 | 0},
  1648. ]
  1649. )
  1650. )
  1651. h2d_client._process_message(_ams_payload(0))
  1652. assert h2d_client.state.tray_now == 128
  1653. assert h2d_client.state.last_loaded_tray == 128
  1654. def test_unloaded_not_stored(self, h2d_client):
  1655. """tray_now=255 (unloaded) → last_loaded_tray unchanged."""
  1656. h2d_client.state.last_loaded_tray = 5
  1657. h2d_client._process_message(_ams_payload(255))
  1658. assert h2d_client.state.tray_now == 255
  1659. assert h2d_client.state.last_loaded_tray == 5
  1660. # ---------------------------------------------------------------------------
  1661. # 7. H2D Active extruder switching
  1662. # ---------------------------------------------------------------------------
  1663. class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
  1664. """Active extruder switching via device.extruder.state bit 8."""
  1665. def test_active_extruder_right_by_default(self, h2d_client):
  1666. """Initial state.active_extruder == 0 (right)."""
  1667. assert h2d_client.state.active_extruder == 0
  1668. def test_extruder_state_bit8_switches_to_left(self, h2d_client):
  1669. """state=0x100 → active_extruder=1 (left)."""
  1670. h2d_client._process_message(_extruder_state_payload(0x0100))
  1671. assert h2d_client.state.active_extruder == 1
  1672. def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
  1673. """Cycle 0 → 1 → 0."""
  1674. h2d_client._process_message(_extruder_state_payload(0x0100))
  1675. assert h2d_client.state.active_extruder == 1
  1676. h2d_client._process_message(_extruder_state_payload(0x0001))
  1677. assert h2d_client.state.active_extruder == 0
  1678. def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
  1679. """Snow on both extruders; switching active changes which snow is used."""
  1680. # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
  1681. h2d_client._process_message(
  1682. _extruder_info_payload(
  1683. [
  1684. {"id": 0, "snow": 0 << 8 | 1}, # AMS 0 slot 1 → global 1
  1685. {"id": 1, "snow": 128 << 8 | 0}, # AMS HT → global 128
  1686. ]
  1687. )
  1688. )
  1689. # Right active (default) — tray_now="1" → snow ext[0] says global 1
  1690. h2d_client._process_message(_ams_payload(1))
  1691. assert h2d_client.state.tray_now == 1
  1692. # Switch to left
  1693. h2d_client._process_message(_extruder_state_payload(0x0100))
  1694. # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
  1695. h2d_client._process_message(_ams_payload(0))
  1696. assert h2d_client.state.tray_now == 128
  1697. # ---------------------------------------------------------------------------
  1698. # 8. H2D Full multi-message sequences
  1699. # ---------------------------------------------------------------------------
  1700. class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
  1701. """Multi-message sequences simulating real H2D Pro prints."""
  1702. def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
  1703. """Setup → load AMS 0 slot 1 → verify tray_now=1."""
  1704. # Snow update: extruder 0 loading AMS 0 slot 1
  1705. h2d_client._process_message(
  1706. _extruder_info_payload(
  1707. [
  1708. {"id": 0, "snow": 0 << 8 | 1},
  1709. {"id": 1, "snow": 0xFF00FF},
  1710. ]
  1711. )
  1712. )
  1713. # Printer reports tray_now="1"
  1714. h2d_client._process_message(_ams_payload(1))
  1715. assert h2d_client.state.tray_now == 1
  1716. assert h2d_client.state.last_loaded_tray == 1
  1717. def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
  1718. """Setup → switch left → load AMS HT → verify tray_now=128."""
  1719. # Switch to left extruder
  1720. h2d_client._process_message(_extruder_state_payload(0x0100))
  1721. # Snow: ext 1 → AMS HT slot 0
  1722. h2d_client._process_message(
  1723. _extruder_info_payload(
  1724. [
  1725. {"id": 0, "snow": 0xFF00FF},
  1726. {"id": 1, "snow": 128 << 8 | 0},
  1727. ]
  1728. )
  1729. )
  1730. # Printer reports tray_now="0" (AMS HT single slot)
  1731. h2d_client._process_message(_ams_payload(0))
  1732. assert h2d_client.state.tray_now == 128
  1733. assert h2d_client.state.last_loaded_tray == 128
  1734. def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
  1735. """Multi-color print alternating between right and left nozzles.
  1736. Sequence:
  1737. 1. Right loads AMS 0 slot 0 (tray=0)
  1738. 2. Switch left, load AMS HT (tray=128)
  1739. 3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
  1740. 4. Unload (255)
  1741. """
  1742. # Step 1: Right extruder loads AMS 0 slot 0
  1743. h2d_client._process_message(
  1744. _extruder_info_payload(
  1745. [
  1746. {"id": 0, "snow": 0 << 8 | 0},
  1747. {"id": 1, "snow": 0xFF00FF},
  1748. ]
  1749. )
  1750. )
  1751. h2d_client._process_message(_ams_payload(0))
  1752. assert h2d_client.state.tray_now == 0
  1753. # Step 2: Switch to left, load AMS HT
  1754. h2d_client._process_message(_extruder_state_payload(0x0100))
  1755. h2d_client._process_message(
  1756. _extruder_info_payload(
  1757. [
  1758. {"id": 0, "snow": 0 << 8 | 0},
  1759. {"id": 1, "snow": 128 << 8 | 0},
  1760. ]
  1761. )
  1762. )
  1763. h2d_client._process_message(_ams_payload(0))
  1764. assert h2d_client.state.tray_now == 128
  1765. # Step 3: Switch back to right, load AMS 0 slot 2
  1766. h2d_client._process_message(_extruder_state_payload(0x0001))
  1767. h2d_client._process_message(
  1768. _extruder_info_payload(
  1769. [
  1770. {"id": 0, "snow": 0 << 8 | 2},
  1771. {"id": 1, "snow": 128 << 8 | 0},
  1772. ]
  1773. )
  1774. )
  1775. h2d_client._process_message(_ams_payload(2))
  1776. assert h2d_client.state.tray_now == 2
  1777. # Step 4: Unload
  1778. h2d_client._process_message(_ams_payload(255))
  1779. assert h2d_client.state.tray_now == 255
  1780. assert h2d_client.state.last_loaded_tray == 2
  1781. class TestTrayChangeLog:
  1782. """Tests for tray_change_log tracking during prints (mid-print tray switch)."""
  1783. @pytest.fixture
  1784. def mqtt_client(self):
  1785. """Create a BambuMQTTClient instance for testing."""
  1786. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1787. client = BambuMQTTClient(
  1788. ip_address="192.168.1.100",
  1789. serial_number="TRAYLOG1",
  1790. access_code="12345678",
  1791. )
  1792. return client
  1793. def test_tray_change_log_defaults_empty(self, mqtt_client):
  1794. """tray_change_log starts as an empty list."""
  1795. assert mqtt_client.state.tray_change_log == []
  1796. def test_tray_change_log_seeded_on_print_start(self, mqtt_client):
  1797. """Print start clears log and seeds with initial tray at layer 0."""
  1798. mqtt_client.state.tray_now = 2
  1799. mqtt_client.state.last_loaded_tray = 2
  1800. mqtt_client._previous_gcode_state = "IDLE"
  1801. # Transition to RUNNING via _process_message
  1802. mqtt_client._process_message(
  1803. {
  1804. "print": {
  1805. "gcode_state": "RUNNING",
  1806. "gcode_file": "test.3mf",
  1807. }
  1808. }
  1809. )
  1810. assert mqtt_client.state.tray_change_log == [(2, 0)]
  1811. def test_tray_change_log_cleared_on_new_print(self, mqtt_client):
  1812. """Old log entries are cleared when a new print starts."""
  1813. mqtt_client.state.tray_change_log = [(5, 0), (3, 100)]
  1814. mqtt_client.state.tray_now = 1
  1815. mqtt_client.state.last_loaded_tray = 1
  1816. mqtt_client._previous_gcode_state = "IDLE"
  1817. mqtt_client._process_message(
  1818. {
  1819. "print": {
  1820. "gcode_state": "RUNNING",
  1821. "gcode_file": "new.3mf",
  1822. }
  1823. }
  1824. )
  1825. assert mqtt_client.state.tray_change_log == [(1, 0)]
  1826. def test_tray_change_recorded_during_running(self, mqtt_client):
  1827. """Tray change while RUNNING is appended to the log."""
  1828. mqtt_client.state.state = "RUNNING"
  1829. mqtt_client.state.layer_num = 50
  1830. mqtt_client.state.last_loaded_tray = 0
  1831. mqtt_client.state.tray_change_log = [(0, 0)]
  1832. # Simulate tray_now update via AMS data
  1833. mqtt_client.state.tray_now = 1
  1834. # Trigger the tracking code path
  1835. tn = mqtt_client.state.tray_now
  1836. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  1837. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  1838. mqtt_client.state.last_loaded_tray = tn
  1839. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]
  1840. def test_tray_change_not_recorded_when_idle(self, mqtt_client):
  1841. """Tray changes while IDLE are NOT logged."""
  1842. mqtt_client.state.state = "IDLE"
  1843. mqtt_client.state.layer_num = 0
  1844. mqtt_client.state.last_loaded_tray = 0
  1845. mqtt_client.state.tray_change_log = []
  1846. mqtt_client.state.tray_now = 3
  1847. tn = mqtt_client.state.tray_now
  1848. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  1849. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  1850. mqtt_client.state.last_loaded_tray = tn
  1851. assert mqtt_client.state.tray_change_log == []
  1852. def test_tray_change_recorded_during_pause(self, mqtt_client):
  1853. """Tray change while PAUSE is also logged (AMS can swap during pause)."""
  1854. mqtt_client.state.state = "PAUSE"
  1855. mqtt_client.state.layer_num = 75
  1856. mqtt_client.state.last_loaded_tray = 2
  1857. mqtt_client.state.tray_change_log = [(2, 0)]
  1858. mqtt_client.state.tray_now = 5
  1859. tn = mqtt_client.state.tray_now
  1860. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  1861. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  1862. mqtt_client.state.last_loaded_tray = tn
  1863. assert mqtt_client.state.tray_change_log == [(2, 0), (5, 75)]
  1864. def test_same_tray_not_logged_twice(self, mqtt_client):
  1865. """Same tray value doesn't create duplicate log entries."""
  1866. mqtt_client.state.state = "RUNNING"
  1867. mqtt_client.state.layer_num = 30
  1868. mqtt_client.state.last_loaded_tray = 2
  1869. mqtt_client.state.tray_change_log = [(2, 0)]
  1870. # Same tray again
  1871. mqtt_client.state.tray_now = 2
  1872. tn = mqtt_client.state.tray_now
  1873. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  1874. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  1875. mqtt_client.state.last_loaded_tray = tn
  1876. assert mqtt_client.state.tray_change_log == [(2, 0)]
  1877. def test_multiple_tray_changes(self, mqtt_client):
  1878. """Multiple tray changes create a full history."""
  1879. mqtt_client.state.state = "RUNNING"
  1880. mqtt_client.state.last_loaded_tray = 0
  1881. mqtt_client.state.tray_change_log = [(0, 0)]
  1882. changes = [(1, 50), (3, 120), (0, 200)]
  1883. for tray, layer in changes:
  1884. mqtt_client.state.tray_now = tray
  1885. mqtt_client.state.layer_num = layer
  1886. tn = mqtt_client.state.tray_now
  1887. if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in ("RUNNING", "PAUSE"):
  1888. mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))
  1889. mqtt_client.state.last_loaded_tray = tn
  1890. assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50), (3, 120), (0, 200)]
  1891. class TestDeveloperModeDetection:
  1892. """Tests for developer LAN mode detection from MQTT 'fun' field."""
  1893. @pytest.fixture
  1894. def mqtt_client(self):
  1895. """Create a BambuMQTTClient instance for testing."""
  1896. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1897. client = BambuMQTTClient(
  1898. ip_address="192.168.1.100",
  1899. serial_number="TEST123",
  1900. access_code="12345678",
  1901. )
  1902. return client
  1903. def test_developer_mode_initially_none(self, mqtt_client):
  1904. """Verify developer_mode starts as None (unknown)."""
  1905. assert mqtt_client.state.developer_mode is None
  1906. def test_developer_mode_on_when_bit_clear(self, mqtt_client):
  1907. """Verify developer_mode is True when bit 0x20000000 is clear."""
  1908. # Bit 29 clear in lower 32 bits = developer mode ON
  1909. payload = {
  1910. "print": {
  1911. "gcode_state": "IDLE",
  1912. "fun": "1C8187FF9CFF",
  1913. }
  1914. }
  1915. mqtt_client._process_message(payload)
  1916. assert mqtt_client.state.developer_mode is True
  1917. def test_developer_mode_off_when_bit_set(self, mqtt_client):
  1918. """Verify developer_mode is False when bit 0x20000000 is set."""
  1919. # Bit 29 set in lower 32 bits = developer mode OFF (encryption required)
  1920. payload = {
  1921. "print": {
  1922. "gcode_state": "IDLE",
  1923. "fun": "1C81A7FF9CFF",
  1924. }
  1925. }
  1926. mqtt_client._process_message(payload)
  1927. assert mqtt_client.state.developer_mode is False
  1928. def test_developer_mode_exact_bit_check(self, mqtt_client):
  1929. """Verify only bit 0x20000000 matters, not other bits."""
  1930. # 0x20000000 in hex = bit 29. Set ONLY that bit.
  1931. payload = {
  1932. "print": {
  1933. "gcode_state": "IDLE",
  1934. "fun": "000020000000",
  1935. }
  1936. }
  1937. mqtt_client._process_message(payload)
  1938. assert mqtt_client.state.developer_mode is False
  1939. # All zeros = all bits clear = developer mode ON
  1940. payload["print"]["fun"] = "000000000000"
  1941. mqtt_client._process_message(payload)
  1942. assert mqtt_client.state.developer_mode is True
  1943. def test_developer_mode_invalid_fun_ignored(self, mqtt_client):
  1944. """Verify invalid fun values don't crash or change state."""
  1945. mqtt_client.state.developer_mode = True
  1946. payload = {
  1947. "print": {
  1948. "gcode_state": "IDLE",
  1949. "fun": "not_a_hex_value",
  1950. }
  1951. }
  1952. mqtt_client._process_message(payload)
  1953. # Should remain unchanged
  1954. assert mqtt_client.state.developer_mode is True
  1955. def test_developer_mode_missing_fun_preserves_state(self, mqtt_client):
  1956. """Verify messages without fun field don't reset developer_mode."""
  1957. mqtt_client.state.developer_mode = False
  1958. payload = {
  1959. "print": {
  1960. "gcode_state": "RUNNING",
  1961. "mc_percent": 50,
  1962. }
  1963. }
  1964. mqtt_client._process_message(payload)
  1965. assert mqtt_client.state.developer_mode is False
  1966. def test_developer_mode_persists_across_messages(self, mqtt_client):
  1967. """Verify developer_mode set by fun persists across messages without fun."""
  1968. # First message sets developer_mode
  1969. mqtt_client._process_message(
  1970. {
  1971. "print": {
  1972. "gcode_state": "IDLE",
  1973. "fun": "3EC1AFFF9CFF",
  1974. }
  1975. }
  1976. )
  1977. assert mqtt_client.state.developer_mode is False
  1978. # Subsequent messages without fun don't change it
  1979. for _ in range(3):
  1980. mqtt_client._process_message(
  1981. {
  1982. "print": {
  1983. "gcode_state": "RUNNING",
  1984. "mc_percent": 50,
  1985. }
  1986. }
  1987. )
  1988. assert mqtt_client.state.developer_mode is False
  1989. class TestSendDryingCommand:
  1990. """Tests for send_drying_command MQTT payload construction."""
  1991. @pytest.fixture
  1992. def mqtt_client(self):
  1993. """Create a BambuMQTTClient with a mock MQTT client."""
  1994. from unittest.mock import MagicMock
  1995. from backend.app.services.bambu_mqtt import BambuMQTTClient
  1996. client = BambuMQTTClient(
  1997. ip_address="192.168.1.100",
  1998. serial_number="TEST123",
  1999. access_code="12345678",
  2000. )
  2001. client._client = MagicMock()
  2002. return client
  2003. def test_rotate_tray_false_by_default(self, mqtt_client):
  2004. """Verify rotate_tray defaults to False in the MQTT payload."""
  2005. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
  2006. call_args = mqtt_client._client.publish.call_args
  2007. payload = json.loads(call_args[0][1])
  2008. assert payload["print"]["rotate_tray"] is False
  2009. def test_rotate_tray_true_when_enabled(self, mqtt_client):
  2010. """Verify rotate_tray is True when explicitly enabled."""
  2011. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA", rotate_tray=True)
  2012. call_args = mqtt_client._client.publish.call_args
  2013. payload = json.loads(call_args[0][1])
  2014. assert payload["print"]["rotate_tray"] is True
  2015. def test_rotate_tray_false_on_stop(self, mqtt_client):
  2016. """Verify rotate_tray is False when stopping drying (mode=0)."""
  2017. mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
  2018. call_args = mqtt_client._client.publish.call_args
  2019. payload = json.loads(call_args[0][1])
  2020. assert payload["print"]["rotate_tray"] is False
  2021. def test_all_required_fields_present(self, mqtt_client):
  2022. """Verify all required MQTT fields are present in the drying command."""
  2023. mqtt_client.send_drying_command(ams_id=128, temp=75, duration=8, mode=1, filament="ABS", rotate_tray=True)
  2024. call_args = mqtt_client._client.publish.call_args
  2025. payload = json.loads(call_args[0][1])
  2026. cmd = payload["print"]
  2027. assert cmd["command"] == "ams_filament_drying"
  2028. assert cmd["ams_id"] == 128
  2029. assert cmd["temp"] == 75
  2030. assert cmd["duration"] == 8
  2031. assert cmd["mode"] == 1
  2032. assert cmd["rotate_tray"] is True
  2033. assert cmd["filament"] == "ABS"
  2034. assert cmd["cooling_temp"] == 20
  2035. assert cmd["humidity"] == 0
  2036. assert cmd["close_power_conflict"] is False
  2037. assert "sequence_id" in cmd
  2038. def test_publishes_with_qos_1(self, mqtt_client):
  2039. """Verify drying commands are published with QoS 1."""
  2040. mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4)
  2041. call_args = mqtt_client._client.publish.call_args
  2042. # qos may be positional arg [2] or keyword
  2043. qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
  2044. assert qos == 1