test_report_topic_project_file_1820.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """Where the print file went, read off the report topic (#1820).
  2. Until this landed, ``current_project_url`` was assigned in exactly one place --
  3. ``_handle_request_message`` -- and ``_on_message`` calls that only for the
  4. request topic. A print started from the printer's own touchscreen publishes
  5. nothing there, so the field stayed None for the one case the storage verdict in
  6. ``print_storage`` was written for. The verdict then fell through to the
  7. ``sdcard`` fallback, and #1820's H2S reports ``sdcard: true`` (its "card" is the
  8. internal eMMC), so every such print swept ~110 doomed FTPS connections and
  9. archived blank with no stated reason.
  10. The printer does announce it, as an unsolicited ``project_file`` *response* on
  11. the report topic ~2 s before ``gcode_state`` reaches PREPARE. The frames below
  12. are from that reporter's sanitised capture, taken on an H2S + AMS 2 Pro.
  13. """
  14. import pytest
  15. from backend.app.services.bambu_mqtt import BambuMQTTClient
  16. from backend.app.services.print_storage import (
  17. REASON_INTERNAL_HISTORY,
  18. print_file_reachable_over_ftp,
  19. )
  20. pytestmark = pytest.mark.unit
  21. @pytest.fixture
  22. def client():
  23. return BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2S1820", access_code="12345678", model="H2S")
  24. def screen_start(**overrides) -> dict:
  25. """The frame an H2S publishes for a print started from its own screen.
  26. Trimmed of the fields nothing here reads (``ams``, ``vt_tray``, the
  27. calibration flags); everything kept is verbatim from the capture, including
  28. the empty ``subtask_name`` and the printer's own ``sequence_id`` counter --
  29. the two things that distinguish it from a slicer's dispatch.
  30. """
  31. print_data = {
  32. "command": "project_file",
  33. "result": "SUCCESS",
  34. "reason": "SUCCESS",
  35. "err_code": 0,
  36. "sequence_id": "3338",
  37. "subtask_name": "",
  38. "task_type": 1,
  39. "param": "Metadata/plate_1.gcode",
  40. "plate": 1,
  41. "ams_mapping": [0],
  42. "mapping": [1],
  43. "url": "file:///userdata/model/history/JOB_A.gcode.3mf",
  44. }
  45. print_data.update(overrides)
  46. return {"print": print_data}
  47. class TestAScreenStartedPrintNamesItsFile:
  48. def test_the_url_is_captured(self, client):
  49. client._process_message(screen_start())
  50. assert client.state.current_project_url == "file:///userdata/model/history/JOB_A.gcode.3mf"
  51. def test_the_sticky_copy_is_captured_too(self, client):
  52. """The connection diagnostic runs after the print, by which point the
  53. per-print field has been cleared."""
  54. client._process_message(screen_start())
  55. assert client.state.last_project_url == "file:///userdata/model/history/JOB_A.gcode.3mf"
  56. def test_the_verdict_now_skips_the_sweep(self, client):
  57. """The whole point, in one assertion: with the URL in hand the H2S's
  58. ``sdcard: true`` no longer decides the outcome."""
  59. client.state.sdcard = True
  60. client.state.sdcard_reported = True
  61. client._process_message(screen_start())
  62. verdict = print_file_reachable_over_ftp(client.state)
  63. assert verdict.reachable is False
  64. assert verdict.reason == REASON_INTERNAL_HISTORY
  65. # Still probed, because this printer keeps screen-started jobs under
  66. # /cache for a while and that copy archives in full when it is there.
  67. assert verdict.probe_filename == "JOB_A.gcode.3mf"
  68. def test_the_mapping_is_captured_when_no_slicer_sent_one(self, client):
  69. """On a screen start this frame is the only place it appears."""
  70. client._process_message(screen_start())
  71. assert client._captured_ams_mapping == [0]
  72. def test_a_mapping_from_the_request_topic_wins(self, client):
  73. """The slicer's own mapping describes the same print and arrived first;
  74. the echo can carry a different shape or none at all."""
  75. client._captured_ams_mapping = [0, -1, -1, -1]
  76. client._process_message(screen_start(ams_mapping=[3]))
  77. assert client._captured_ams_mapping == [0, -1, -1, -1]
  78. class TestWhatMustNotBeCaptured:
  79. def test_a_refused_dispatch_is_ignored(self, client):
  80. """It names a file that was never written. Acting on it would pin the
  81. next print's archive on a destination nothing went to."""
  82. client._process_message(screen_start(result="FAIL", reason="STORAGE_FULL"))
  83. assert client.state.current_project_url is None
  84. assert client._captured_ams_mapping is None
  85. @pytest.mark.parametrize("url", ["", None, 12345, [], {}])
  86. def test_a_missing_or_non_string_url_is_ignored(self, client, url):
  87. """The value is whatever the sender put on the wire."""
  88. client._process_message(screen_start(url=url))
  89. assert client.state.current_project_url is None
  90. def test_another_command_on_the_same_topic_is_ignored(self, client):
  91. """push_status carries a `url` field of its own on some firmwares."""
  92. client._process_message({"print": {"command": "push_status", "url": "file:///userdata/model/history/x.3mf"}})
  93. assert client.state.current_project_url is None
  94. def test_our_own_dispatch_is_not_logged_as_someone_elses(self, client, caplog):
  95. """Our publish is echoed on *both* topics. The request-topic echo lands
  96. first and clears ``_own_project_file_key``, so a report-topic branch
  97. that reused ``_handle_request_message``'s diagnostic would report every
  98. Bambuddy-started print as an external one.
  99. """
  100. dispatch = {
  101. "command": "project_file",
  102. "sequence_id": "20002",
  103. "file": "JOB_NORMAL.gcode.3mf",
  104. "url": "brtc://emmc/JOB_NORMAL.gcode.3mf",
  105. "subtask_name": "JOB_NORMAL",
  106. }
  107. client._own_project_file_key = client._project_file_key(dispatch)
  108. client._handle_request_message({"print": dispatch})
  109. assert client._own_project_file_key is None
  110. caplog.clear()
  111. client._process_message({"print": {**dispatch, "result": "SUCCESS", "is_from_mqtt": True}})
  112. assert "External project_file payload" not in caplog.text
  113. # ...and the capture still happened, which is what makes it worth having
  114. # on this topic at all: some brokers refuse the request subscription.
  115. assert client.state.current_project_url == "brtc://emmc/JOB_NORMAL.gcode.3mf"
  116. class TestTheSlicerPathIsUnchanged:
  117. def test_an_external_dispatch_still_sweeps(self, client):
  118. """The regression to fear: routing more URLs into the matcher must not
  119. turn an ordinary Studio print into a blank archive."""
  120. client.state.sdcard = True
  121. client.state.sdcard_reported = True
  122. client._process_message(
  123. {"print": {"command": "project_file", "result": "SUCCESS", "url": "ftp://JOB_NORMAL.gcode.3mf"}}
  124. )
  125. assert print_file_reachable_over_ftp(client.state).reachable is True