test_bambu_mqtt.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. """
  2. Tests for the BambuMQTTClient service.
  3. These tests focus on timelapse tracking during prints.
  4. """
  5. import pytest
  6. class TestTimelapseTracking:
  7. """Tests for timelapse state tracking during prints."""
  8. @pytest.fixture
  9. def mqtt_client(self):
  10. """Create a BambuMQTTClient instance for testing."""
  11. from backend.app.services.bambu_mqtt import BambuMQTTClient
  12. client = BambuMQTTClient(
  13. ip_address="192.168.1.100",
  14. serial_number="TEST123",
  15. access_code="12345678",
  16. )
  17. return client
  18. def test_timelapse_flag_initializes_to_false(self, mqtt_client):
  19. """Verify _timelapse_during_print starts as False."""
  20. assert mqtt_client._timelapse_during_print is False
  21. def test_timelapse_flag_set_when_timelapse_active_during_running(self, mqtt_client):
  22. """Verify timelapse flag is set when timelapse is active while printing."""
  23. # Simulate print running
  24. mqtt_client._was_running = True
  25. mqtt_client.state.timelapse = False
  26. # Simulate xcam data showing timelapse is enabled
  27. xcam_data = {"timelapse": "enable"}
  28. mqtt_client._parse_xcam_data(xcam_data)
  29. assert mqtt_client.state.timelapse is True
  30. assert mqtt_client._timelapse_during_print is True
  31. def test_timelapse_flag_not_set_when_not_running(self, mqtt_client):
  32. """Verify timelapse flag is NOT set when printer not running."""
  33. # Printer is idle (not running)
  34. mqtt_client._was_running = False
  35. mqtt_client.state.timelapse = False
  36. # Timelapse is enabled but we're not printing
  37. xcam_data = {"timelapse": "enable"}
  38. mqtt_client._parse_xcam_data(xcam_data)
  39. assert mqtt_client.state.timelapse is True
  40. # Flag should NOT be set since we're not printing
  41. assert mqtt_client._timelapse_during_print is False
  42. def test_timelapse_flag_persists_after_timelapse_stops(self, mqtt_client):
  43. """Verify timelapse flag stays True even after recording stops."""
  44. # Simulate print running with timelapse
  45. mqtt_client._was_running = True
  46. # Enable timelapse during print
  47. xcam_data = {"timelapse": "enable"}
  48. mqtt_client._parse_xcam_data(xcam_data)
  49. assert mqtt_client._timelapse_during_print is True
  50. # Disable timelapse (recording stops at end of print)
  51. xcam_data = {"timelapse": "disable"}
  52. mqtt_client._parse_xcam_data(xcam_data)
  53. # Flag should still be True (persists until reset)
  54. assert mqtt_client.state.timelapse is False
  55. assert mqtt_client._timelapse_during_print is True
  56. def test_timelapse_flag_from_print_data(self, mqtt_client):
  57. """Verify timelapse flag is set from print data (not just xcam)."""
  58. # Simulate print running
  59. mqtt_client._was_running = True
  60. mqtt_client.state.timelapse = False
  61. mqtt_client._timelapse_during_print = False
  62. # Manually test the timelapse parsing logic from _parse_print_data
  63. # This tests the "timelapse" field in the main print data
  64. data = {"timelapse": True}
  65. mqtt_client.state.timelapse = data["timelapse"] is True
  66. if mqtt_client.state.timelapse and mqtt_client._was_running:
  67. mqtt_client._timelapse_during_print = True
  68. assert mqtt_client._timelapse_during_print is True
  69. class TestPrintCompletionWithTimelapse:
  70. """Tests for print completion including timelapse flag."""
  71. @pytest.fixture
  72. def mqtt_client(self):
  73. """Create a BambuMQTTClient instance for testing."""
  74. from backend.app.services.bambu_mqtt import BambuMQTTClient
  75. client = BambuMQTTClient(
  76. ip_address="192.168.1.100",
  77. serial_number="TEST123",
  78. access_code="12345678",
  79. )
  80. return client
  81. def test_print_complete_includes_timelapse_flag(self, mqtt_client):
  82. """Verify print complete callback includes timelapse_was_active."""
  83. # Set up completion callback
  84. callback_data = {}
  85. def on_complete(data):
  86. callback_data.update(data)
  87. mqtt_client.on_print_complete = on_complete
  88. # Simulate a print that had timelapse active
  89. mqtt_client._was_running = True
  90. mqtt_client._completion_triggered = False
  91. mqtt_client._timelapse_during_print = True
  92. mqtt_client._previous_gcode_state = "RUNNING"
  93. mqtt_client._previous_gcode_file = "test.gcode"
  94. mqtt_client.state.subtask_name = "Test Print"
  95. # Simulate print finish
  96. mqtt_client.state.state = "FINISH"
  97. # Manually trigger the completion logic (simplified)
  98. # In real code this happens in _parse_print_data
  99. should_trigger = (
  100. mqtt_client.state.state in ("FINISH", "FAILED")
  101. and not mqtt_client._completion_triggered
  102. and mqtt_client.on_print_complete
  103. and mqtt_client._previous_gcode_state == "RUNNING"
  104. )
  105. if should_trigger:
  106. status = "completed" if mqtt_client.state.state == "FINISH" else "failed"
  107. timelapse_was_active = mqtt_client._timelapse_during_print
  108. mqtt_client._completion_triggered = True
  109. mqtt_client._was_running = False
  110. mqtt_client._timelapse_during_print = False
  111. mqtt_client.on_print_complete(
  112. {
  113. "status": status,
  114. "filename": mqtt_client._previous_gcode_file,
  115. "subtask_name": mqtt_client.state.subtask_name,
  116. "timelapse_was_active": timelapse_was_active,
  117. }
  118. )
  119. assert "timelapse_was_active" in callback_data
  120. assert callback_data["timelapse_was_active"] is True
  121. def test_print_complete_timelapse_flag_false_when_no_timelapse(self, mqtt_client):
  122. """Verify timelapse_was_active is False when no timelapse during print."""
  123. callback_data = {}
  124. def on_complete(data):
  125. callback_data.update(data)
  126. mqtt_client.on_print_complete = on_complete
  127. # Print without timelapse
  128. mqtt_client._was_running = True
  129. mqtt_client._completion_triggered = False
  130. mqtt_client._timelapse_during_print = False # No timelapse
  131. mqtt_client._previous_gcode_state = "RUNNING"
  132. mqtt_client._previous_gcode_file = "test.gcode"
  133. mqtt_client.state.subtask_name = "Test Print"
  134. mqtt_client.state.state = "FINISH"
  135. # Trigger completion
  136. timelapse_was_active = mqtt_client._timelapse_during_print
  137. mqtt_client.on_print_complete(
  138. {
  139. "status": "completed",
  140. "filename": mqtt_client._previous_gcode_file,
  141. "subtask_name": mqtt_client.state.subtask_name,
  142. "timelapse_was_active": timelapse_was_active,
  143. }
  144. )
  145. assert callback_data["timelapse_was_active"] is False
  146. def test_timelapse_flag_reset_after_completion(self, mqtt_client):
  147. """Verify _timelapse_during_print is reset after print completion."""
  148. mqtt_client._timelapse_during_print = True
  149. mqtt_client._was_running = True
  150. mqtt_client._completion_triggered = False
  151. # Simulate completion reset
  152. mqtt_client._completion_triggered = True
  153. mqtt_client._was_running = False
  154. mqtt_client._timelapse_during_print = False
  155. assert mqtt_client._timelapse_during_print is False
  156. class TestRealisticMessageFlow:
  157. """Tests that simulate realistic MQTT message sequences.
  158. These tests process messages through _process_message to test the full flow,
  159. including the order of xcam parsing vs state detection.
  160. """
  161. @pytest.fixture
  162. def mqtt_client(self):
  163. """Create a BambuMQTTClient instance for testing."""
  164. from backend.app.services.bambu_mqtt import BambuMQTTClient
  165. client = BambuMQTTClient(
  166. ip_address="192.168.1.100",
  167. serial_number="TEST123",
  168. access_code="12345678",
  169. )
  170. return client
  171. def test_timelapse_detected_at_print_start_in_same_message(self, mqtt_client):
  172. """Test that timelapse is detected when xcam and state come in same message.
  173. This is the critical race condition test - xcam data is parsed BEFORE
  174. state detection, so the timelapse flag must be set AFTER _was_running is True.
  175. """
  176. # Callbacks to track events
  177. start_callback_data = {}
  178. def on_start(data):
  179. start_callback_data.update(data)
  180. mqtt_client.on_print_start = on_start
  181. # Initial state - idle
  182. mqtt_client._was_running = False
  183. mqtt_client._timelapse_during_print = False
  184. mqtt_client._previous_gcode_state = None
  185. # Simulate first message when print starts - contains both xcam and gcode_state
  186. # This is the realistic scenario from the printer
  187. # NOTE: Real MQTT messages wrap print data inside a "print" key
  188. payload = {
  189. "print": {
  190. "gcode_state": "RUNNING",
  191. "gcode_file": "/data/Metadata/test_print.gcode",
  192. "subtask_name": "Test_Print",
  193. "xcam": {
  194. "timelapse": "enable", # Timelapse is enabled in this print
  195. "printing_monitor": True,
  196. },
  197. "mc_percent": 0,
  198. "mc_remaining_time": 3600,
  199. }
  200. }
  201. # Process the message (this is what happens in real MQTT flow)
  202. mqtt_client._process_message(payload)
  203. # Verify timelapse was detected even though xcam is parsed before state
  204. assert mqtt_client._was_running is True, "_was_running should be True after RUNNING state"
  205. assert mqtt_client.state.timelapse is True, "state.timelapse should be True"
  206. assert mqtt_client._timelapse_during_print is True, (
  207. "timelapse_during_print should be True when timelapse is in the same message as RUNNING state"
  208. )
  209. def test_timelapse_not_detected_when_disabled(self, mqtt_client):
  210. """Test that timelapse is NOT detected when disabled in xcam data."""
  211. mqtt_client.on_print_start = lambda data: None
  212. # Initial state - idle
  213. mqtt_client._was_running = False
  214. mqtt_client._timelapse_during_print = False
  215. mqtt_client._previous_gcode_state = None
  216. # Print starts without timelapse
  217. payload = {
  218. "print": {
  219. "gcode_state": "RUNNING",
  220. "gcode_file": "/data/Metadata/test_print.gcode",
  221. "subtask_name": "Test_Print",
  222. "xcam": {
  223. "timelapse": "disable", # Timelapse is disabled
  224. "printing_monitor": True,
  225. },
  226. }
  227. }
  228. mqtt_client._process_message(payload)
  229. assert mqtt_client._was_running is True
  230. assert mqtt_client.state.timelapse is False
  231. assert mqtt_client._timelapse_during_print is False
  232. def test_timelapse_detected_when_enabled_after_print_start(self, mqtt_client):
  233. """Test timelapse detected when enabled in a message after print starts."""
  234. mqtt_client.on_print_start = lambda data: None
  235. # First message - print starts without timelapse info
  236. payload_start = {
  237. "print": {
  238. "gcode_state": "RUNNING",
  239. "gcode_file": "/data/Metadata/test_print.gcode",
  240. "subtask_name": "Test_Print",
  241. }
  242. }
  243. mqtt_client._process_message(payload_start)
  244. assert mqtt_client._was_running is True
  245. assert mqtt_client._timelapse_during_print is False # Not detected yet
  246. # Second message - xcam data arrives with timelapse enabled
  247. payload_xcam = {
  248. "print": {
  249. "gcode_state": "RUNNING",
  250. "gcode_file": "/data/Metadata/test_print.gcode",
  251. "subtask_name": "Test_Print",
  252. "xcam": {
  253. "timelapse": "enable",
  254. },
  255. }
  256. }
  257. mqtt_client._process_message(payload_xcam)
  258. # Now timelapse should be detected because _was_running is already True
  259. assert mqtt_client._timelapse_during_print is True
  260. def test_print_complete_includes_timelapse_flag_full_flow(self, mqtt_client):
  261. """Test full print lifecycle with timelapse - from start to completion."""
  262. start_data = {}
  263. complete_data = {}
  264. def on_start(data):
  265. start_data.update(data)
  266. def on_complete(data):
  267. complete_data.update(data)
  268. mqtt_client.on_print_start = on_start
  269. mqtt_client.on_print_complete = on_complete
  270. # 1. Print starts with timelapse
  271. mqtt_client._process_message(
  272. {
  273. "print": {
  274. "gcode_state": "RUNNING",
  275. "gcode_file": "/data/Metadata/test.gcode",
  276. "subtask_name": "Test",
  277. "xcam": {"timelapse": "enable"},
  278. }
  279. }
  280. )
  281. assert mqtt_client._timelapse_during_print is True
  282. assert "subtask_name" in start_data
  283. # 2. Print continues (multiple messages)
  284. for _ in range(3):
  285. mqtt_client._process_message(
  286. {
  287. "print": {
  288. "gcode_state": "RUNNING",
  289. "gcode_file": "/data/Metadata/test.gcode",
  290. "subtask_name": "Test",
  291. "mc_percent": 50,
  292. }
  293. }
  294. )
  295. # Timelapse flag should still be True
  296. assert mqtt_client._timelapse_during_print is True
  297. # 3. Print completes
  298. mqtt_client._process_message(
  299. {
  300. "print": {
  301. "gcode_state": "FINISH",
  302. "gcode_file": "/data/Metadata/test.gcode",
  303. "subtask_name": "Test",
  304. }
  305. }
  306. )
  307. # Verify completion callback received timelapse flag
  308. assert "timelapse_was_active" in complete_data
  309. assert complete_data["timelapse_was_active"] is True
  310. assert complete_data["status"] == "completed"
  311. # Flags should be reset after completion
  312. assert mqtt_client._timelapse_during_print is False
  313. assert mqtt_client._was_running is False
  314. def test_print_failed_includes_timelapse_flag(self, mqtt_client):
  315. """Test that failed print also includes timelapse flag."""
  316. complete_data = {}
  317. def on_complete(data):
  318. complete_data.update(data)
  319. mqtt_client.on_print_start = lambda data: None
  320. mqtt_client.on_print_complete = on_complete
  321. # Start with timelapse
  322. mqtt_client._process_message(
  323. {
  324. "print": {
  325. "gcode_state": "RUNNING",
  326. "gcode_file": "/data/Metadata/test.gcode",
  327. "subtask_name": "Test",
  328. "xcam": {"timelapse": "enable"},
  329. }
  330. }
  331. )
  332. # Print fails
  333. mqtt_client._process_message(
  334. {
  335. "print": {
  336. "gcode_state": "FAILED",
  337. "gcode_file": "/data/Metadata/test.gcode",
  338. "subtask_name": "Test",
  339. }
  340. }
  341. )
  342. assert complete_data["timelapse_was_active"] is True
  343. assert complete_data["status"] == "failed"
  344. class TestAMSDataMerging:
  345. """Tests for AMS data merging, particularly handling empty slots."""
  346. @pytest.fixture
  347. def mqtt_client(self):
  348. """Create a BambuMQTTClient instance for testing."""
  349. from backend.app.services.bambu_mqtt import BambuMQTTClient
  350. client = BambuMQTTClient(
  351. ip_address="192.168.1.100",
  352. serial_number="TEST123",
  353. access_code="12345678",
  354. )
  355. return client
  356. def test_empty_slot_clears_tray_type(self, mqtt_client):
  357. """Test that empty slot update clears tray_type (Issue #147).
  358. When a spool is removed from an old AMS, the printer sends empty values.
  359. These must overwrite the previous values to show the slot as empty.
  360. """
  361. # Initial state: AMS unit with a loaded spool
  362. initial_ams = {
  363. "ams": [
  364. {
  365. "id": 0,
  366. "tray": [
  367. {
  368. "id": 0,
  369. "tray_type": "PLA",
  370. "tray_sub_brands": "Bambu PLA Basic",
  371. "tray_color": "FF0000",
  372. "tag_uid": "1234567890ABCDEF",
  373. "remain": 80,
  374. }
  375. ],
  376. }
  377. ]
  378. }
  379. mqtt_client._handle_ams_data(initial_ams)
  380. # Verify initial state
  381. ams_data = mqtt_client.state.raw_data.get("ams", [])
  382. assert len(ams_data) == 1
  383. tray = ams_data[0]["tray"][0]
  384. assert tray["tray_type"] == "PLA"
  385. assert tray["tray_color"] == "FF0000"
  386. # Now simulate spool removal - printer sends empty values
  387. empty_update = {
  388. "ams": [
  389. {
  390. "id": 0,
  391. "tray": [
  392. {
  393. "id": 0,
  394. "tray_type": "", # Empty = slot is empty
  395. "tray_sub_brands": "",
  396. "tray_color": "",
  397. "tag_uid": "0000000000000000", # Zero UID
  398. "remain": 0,
  399. }
  400. ],
  401. }
  402. ]
  403. }
  404. mqtt_client._handle_ams_data(empty_update)
  405. # Verify empty values were applied (not ignored by merge logic)
  406. ams_data = mqtt_client.state.raw_data.get("ams", [])
  407. tray = ams_data[0]["tray"][0]
  408. assert tray["tray_type"] == "", "tray_type should be cleared when slot is empty"
  409. assert tray["tray_color"] == "", "tray_color should be cleared when slot is empty"
  410. assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
  411. assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
  412. def test_partial_update_preserves_other_fields(self, mqtt_client):
  413. """Test that partial updates still preserve non-slot-status fields."""
  414. # Initial state with full data
  415. initial_ams = {
  416. "ams": [
  417. {
  418. "id": 0,
  419. "humidity": "3",
  420. "temp": "25.5",
  421. "tray": [
  422. {
  423. "id": 0,
  424. "tray_type": "PLA",
  425. "tray_color": "00FF00",
  426. "remain": 90,
  427. "k": 0.02,
  428. }
  429. ],
  430. }
  431. ]
  432. }
  433. mqtt_client._handle_ams_data(initial_ams)
  434. # Partial update - only remain changes
  435. partial_update = {
  436. "ams": [
  437. {
  438. "id": 0,
  439. "tray": [
  440. {
  441. "id": 0,
  442. "remain": 85, # Only this changed
  443. }
  444. ],
  445. }
  446. ]
  447. }
  448. mqtt_client._handle_ams_data(partial_update)
  449. # Verify remain was updated but other fields preserved
  450. ams_data = mqtt_client.state.raw_data.get("ams", [])
  451. tray = ams_data[0]["tray"][0]
  452. assert tray["remain"] == 85, "remain should be updated"
  453. assert tray["tray_type"] == "PLA", "tray_type should be preserved"
  454. assert tray["tray_color"] == "00FF00", "tray_color should be preserved"
  455. assert tray["k"] == 0.02, "k should be preserved"
  456. def test_tray_exist_bits_clears_empty_slots(self, mqtt_client):
  457. """Test that tray_exist_bits clears slots marked as empty (Issue #147).
  458. New AMS models (AMS 2 Pro) don't send empty tray data when a spool is removed.
  459. Instead, they update tray_exist_bits to indicate which slots have spools.
  460. """
  461. # Initial state: AMS 0 and AMS 1 with loaded spools
  462. initial_ams = {
  463. "ams": [
  464. {
  465. "id": 0,
  466. "tray": [
  467. {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
  468. {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
  469. {"id": 2, "tray_type": "ABS", "tray_color": "0000FF", "remain": 40},
  470. {"id": 3, "tray_type": "TPU", "tray_color": "FFFF00", "remain": 20},
  471. ],
  472. },
  473. {
  474. "id": 1,
  475. "tray": [
  476. {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFF", "remain": 90},
  477. {"id": 1, "tray_type": "PLA", "tray_color": "000000", "remain": 70},
  478. {"id": 2, "tray_type": "PLA", "tray_color": "FF00FF", "remain": 50},
  479. {"id": 3, "tray_type": "PLA", "tray_color": "00FFFF", "remain": 30},
  480. ],
  481. },
  482. ],
  483. "tray_exist_bits": "ff", # All 8 slots have spools (0xFF = 11111111)
  484. }
  485. mqtt_client._handle_ams_data(initial_ams)
  486. # Verify initial state
  487. ams_data = mqtt_client.state.raw_data.get("ams", [])
  488. assert ams_data[1]["tray"][3]["tray_type"] == "PLA" # AMS 1 slot 3 (B4) has spool
  489. # Now simulate spool removal from AMS 1 slot 3 (B4)
  490. # tray_exist_bits: 0x7f = 01111111 (bit 7 = 0 means AMS 1 slot 3 is empty)
  491. update_ams = {
  492. "ams": [
  493. {"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
  494. {"id": 1, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
  495. ],
  496. "tray_exist_bits": "7f", # Bit 7 = 0 -> AMS 1 slot 3 is empty
  497. }
  498. mqtt_client._handle_ams_data(update_ams)
  499. # Verify AMS 1 slot 3 was cleared
  500. ams_data = mqtt_client.state.raw_data.get("ams", [])
  501. b4_tray = ams_data[1]["tray"][3]
  502. assert b4_tray["tray_type"] == "", "tray_type should be cleared for empty slot"
  503. assert b4_tray["remain"] == 0, "remain should be 0 for empty slot"
  504. # Verify other slots are preserved
  505. assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
  506. assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"