test_project_url_capture_2780.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """Capturing where the printer put the sliced file (#2780).
  2. The ``project_file`` command has always carried a ``url`` saying which storage
  3. the file landed on -- ``ftp://<name>`` for the card, ``brtc://emmc/<name>`` for
  4. the printer's internal storage. Bambuddy discarded it and swept FTPS regardless,
  5. which on an H2C or P2S is ~110 connections that cannot succeed followed by a
  6. blank archive card with no stated reason.
  7. These tests pin the capture itself. The decision made from it lives in
  8. :mod:`backend.app.services.print_storage` and is tested separately.
  9. """
  10. import pytest
  11. pytestmark = pytest.mark.unit
  12. @pytest.fixture
  13. def mqtt_client():
  14. from backend.app.services.bambu_mqtt import BambuMQTTClient
  15. return BambuMQTTClient(ip_address="192.168.1.210", serial_number="TEST2780", access_code="12345678")
  16. class TestProjectUrlCapture:
  17. def test_nothing_is_claimed_before_a_dispatch_is_seen(self, mqtt_client):
  18. """None is the "we don't know" answer, and consumers depend on it
  19. being distinguishable from a known-bad value."""
  20. assert mqtt_client.state.current_project_url is None
  21. def test_an_internal_storage_dispatch_is_recorded(self, mqtt_client):
  22. """The shape every H2C and P2S dispatch had in #2780's bundle."""
  23. mqtt_client._handle_request_message(
  24. {
  25. "print": {
  26. "command": "project_file",
  27. "url": "brtc://emmc/169356_204314.STEP.gcode.3mf",
  28. "sequence_id": "20002",
  29. }
  30. }
  31. )
  32. assert mqtt_client.state.current_project_url == "brtc://emmc/169356_204314.STEP.gcode.3mf"
  33. def test_our_own_dispatch_is_recorded_too(self, mqtt_client):
  34. """We publish to the request topic and subscribe to it, so our own
  35. commands come back. Capturing them is wanted, not incidental: after a
  36. Bambuddy-launched print the file really is on external storage, and a
  37. stale internal-storage URL from the slicer's last job would otherwise
  38. suppress a sweep that would have worked.
  39. ``sequence_id`` 20000 is ours; the external-payload log line keys off
  40. that, and this capture must not.
  41. """
  42. mqtt_client._handle_request_message(
  43. {"print": {"command": "project_file", "url": "ftp://Benchy.gcode.3mf", "sequence_id": "20000"}}
  44. )
  45. assert mqtt_client.state.current_project_url == "ftp://Benchy.gcode.3mf"
  46. def test_the_latest_dispatch_wins(self, mqtt_client):
  47. for url in ("brtc://emmc/first.3mf", "ftp://second.3mf"):
  48. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": url}})
  49. assert mqtt_client.state.current_project_url == "ftp://second.3mf"
  50. def test_a_dispatch_without_a_url_leaves_the_last_one_alone(self, mqtt_client):
  51. """Better a slightly stale answer than a wrongly-cleared one: clearing
  52. would drop us to "unknown" and re-run the sweep we just learned was
  53. pointless."""
  54. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
  55. mqtt_client._handle_request_message({"print": {"command": "project_file", "ams_mapping": [0]}})
  56. assert mqtt_client.state.current_project_url == "brtc://emmc/x.3mf"
  57. @pytest.mark.parametrize("url", [None, "", 12345, [], {}])
  58. def test_a_non_string_url_is_ignored(self, mqtt_client, url):
  59. """Straight off the wire, so it is whatever the sender sent."""
  60. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": url}})
  61. assert mqtt_client.state.current_project_url is None
  62. def test_other_commands_do_not_touch_it(self, mqtt_client):
  63. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "ftp://kept.3mf"}})
  64. for command in ("pause", "resume", "stop", "gcode_line"):
  65. mqtt_client._handle_request_message({"print": {"command": command, "url": "ftp://ignored.3mf"}})
  66. assert mqtt_client.state.current_project_url == "ftp://kept.3mf"
  67. class TestTheTwoFieldsHaveDifferentLifetimes:
  68. """``current_project_url`` describes the print now running and is cleared
  69. when it ends; ``last_project_url`` is sticky, for reporting after the fact.
  70. The clearing is the load-bearing half. In #2780's support bundle 14 of 79
  71. print starts arrived with no ``project_file`` on the request topic at all
  72. -- touchscreen reprints and restart recovery. If those inherited the
  73. previous job's destination, one Studio print to internal storage would
  74. suppress the FTPS sweep for every screen-started print after it.
  75. """
  76. def test_a_dispatch_sets_both(self, mqtt_client):
  77. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
  78. assert mqtt_client.state.current_project_url == "brtc://emmc/x.3mf"
  79. assert mqtt_client.state.last_project_url == "brtc://emmc/x.3mf"
  80. def test_finishing_a_print_clears_only_the_per_print_one(self, mqtt_client):
  81. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
  82. # Drive a full RUNNING -> FINISH transition so the real completion
  83. # path runs, rather than reaching in and clearing the field by hand.
  84. mqtt_client.on_print_complete = lambda data: None
  85. mqtt_client._update_state({"gcode_state": "RUNNING"})
  86. mqtt_client._update_state({"gcode_state": "FINISH"})
  87. assert mqtt_client.state.current_project_url is None, (
  88. "a print Bambuddy saw no dispatch for must read as unknown, not inherit this one"
  89. )
  90. assert mqtt_client.state.last_project_url == "brtc://emmc/x.3mf", (
  91. "the diagnostic runs after the print that prompted it and still needs the answer"
  92. )
  93. def test_an_aborted_print_clears_it_too(self, mqtt_client):
  94. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
  95. mqtt_client.on_print_complete = lambda data: None
  96. mqtt_client._update_state({"gcode_state": "RUNNING"})
  97. mqtt_client._update_state({"gcode_state": "FAILED"})
  98. assert mqtt_client.state.current_project_url is None
  99. def test_the_regression_sequence_end_to_end(self, mqtt_client):
  100. """Studio print to internal storage, then a print started from the
  101. printer's own screen.
  102. The capture and the gate are pinned separately; this is the sequence
  103. that made the split necessary, walked through both at once. The second
  104. print's file may well be on the card -- it has to be swept for, not
  105. written off on the strength of the first print's destination.
  106. """
  107. from backend.app.services.print_storage import print_file_reachable_over_ftp
  108. mqtt_client._update_state({"sdcard": True})
  109. mqtt_client.on_print_complete = lambda data: None
  110. # 1. Studio dispatches to internal storage and the print runs.
  111. mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/studio.3mf"}})
  112. mqtt_client._update_state({"gcode_state": "RUNNING"})
  113. assert print_file_reachable_over_ftp(mqtt_client.state).reachable is False
  114. # 2. It finishes.
  115. mqtt_client._update_state({"gcode_state": "FINISH"})
  116. # 3. The operator reprints from the touchscreen. No project_file
  117. # reaches the request topic at all -- 14 of 79 print starts in the
  118. # reporter's bundle looked exactly like this.
  119. mqtt_client._update_state({"gcode_state": "RUNNING"})
  120. assert print_file_reachable_over_ftp(mqtt_client.state).reachable, (
  121. "the screen-started print inherited the Studio print's destination and lost its archive"
  122. )
  123. class TestSdcardReported:
  124. """`sdcard` defaults to False, so "no card" and "never said" look alike.
  125. Every consumer that treats False as evidence needs the difference, and
  126. getting it wrong skips FTP sweeps for printers whose archives work fine.
  127. """
  128. def test_nothing_is_claimed_before_a_status_frame(self, mqtt_client):
  129. assert mqtt_client.state.sdcard_reported is False
  130. assert mqtt_client.state.sdcard is False
  131. def test_a_frame_carrying_the_field_marks_it_reported(self, mqtt_client):
  132. mqtt_client._update_state({"sdcard": False})
  133. assert mqtt_client.state.sdcard_reported is True
  134. assert mqtt_client.state.sdcard is False
  135. def test_a_frame_without_the_field_does_not(self, mqtt_client):
  136. mqtt_client._update_state({"gcode_state": "RUNNING"})
  137. assert mqtt_client.state.sdcard_reported is False
  138. @pytest.mark.parametrize(
  139. "raw,expected",
  140. [
  141. (True, True),
  142. (1, True),
  143. ("HAS_SDCARD_NORMAL", True),
  144. ("normal", True),
  145. ("HAS_SDCARD_ABNORMAL", True),
  146. (False, False),
  147. (0, False),
  148. ("", False),
  149. ],
  150. )
  151. def test_the_reported_flag_is_set_for_every_accepted_shape(self, mqtt_client, raw, expected):
  152. """Whatever the field means, having seen it is what is recorded."""
  153. mqtt_client._update_state({"sdcard": raw})
  154. assert mqtt_client.state.sdcard_reported is True
  155. assert mqtt_client.state.sdcard is expected