test_bambu_mqtt.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. """
  2. Tests for the BambuMQTTClient service.
  3. These tests focus on timelapse tracking during prints.
  4. """
  5. from unittest.mock import MagicMock, patch
  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"