|
@@ -0,0 +1,162 @@
|
|
|
|
|
+"""Where the print file went, read off the report topic (#1820).
|
|
|
|
|
+
|
|
|
|
|
+Until this landed, ``current_project_url`` was assigned in exactly one place --
|
|
|
|
|
+``_handle_request_message`` -- and ``_on_message`` calls that only for the
|
|
|
|
|
+request topic. A print started from the printer's own touchscreen publishes
|
|
|
|
|
+nothing there, so the field stayed None for the one case the storage verdict in
|
|
|
|
|
+``print_storage`` was written for. The verdict then fell through to the
|
|
|
|
|
+``sdcard`` fallback, and #1820's H2S reports ``sdcard: true`` (its "card" is the
|
|
|
|
|
+internal eMMC), so every such print swept ~110 doomed FTPS connections and
|
|
|
|
|
+archived blank with no stated reason.
|
|
|
|
|
+
|
|
|
|
|
+The printer does announce it, as an unsolicited ``project_file`` *response* on
|
|
|
|
|
+the report topic ~2 s before ``gcode_state`` reaches PREPARE. The frames below
|
|
|
|
|
+are from that reporter's sanitised capture, taken on an H2S + AMS 2 Pro.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.services.bambu_mqtt import BambuMQTTClient
|
|
|
|
|
+from backend.app.services.print_storage import (
|
|
|
|
|
+ REASON_INTERNAL_HISTORY,
|
|
|
|
|
+ print_file_reachable_over_ftp,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+pytestmark = pytest.mark.unit
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def client():
|
|
|
|
|
+ return BambuMQTTClient(ip_address="10.0.0.7", serial_number="H2S1820", access_code="12345678", model="H2S")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def screen_start(**overrides) -> dict:
|
|
|
|
|
+ """The frame an H2S publishes for a print started from its own screen.
|
|
|
|
|
+
|
|
|
|
|
+ Trimmed of the fields nothing here reads (``ams``, ``vt_tray``, the
|
|
|
|
|
+ calibration flags); everything kept is verbatim from the capture, including
|
|
|
|
|
+ the empty ``subtask_name`` and the printer's own ``sequence_id`` counter --
|
|
|
|
|
+ the two things that distinguish it from a slicer's dispatch.
|
|
|
|
|
+ """
|
|
|
|
|
+ print_data = {
|
|
|
|
|
+ "command": "project_file",
|
|
|
|
|
+ "result": "SUCCESS",
|
|
|
|
|
+ "reason": "SUCCESS",
|
|
|
|
|
+ "err_code": 0,
|
|
|
|
|
+ "sequence_id": "3338",
|
|
|
|
|
+ "subtask_name": "",
|
|
|
|
|
+ "task_type": 1,
|
|
|
|
|
+ "param": "Metadata/plate_1.gcode",
|
|
|
|
|
+ "plate": 1,
|
|
|
|
|
+ "ams_mapping": [0],
|
|
|
|
|
+ "mapping": [1],
|
|
|
|
|
+ "url": "file:///userdata/model/history/JOB_A.gcode.3mf",
|
|
|
|
|
+ }
|
|
|
|
|
+ print_data.update(overrides)
|
|
|
|
|
+ return {"print": print_data}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestAScreenStartedPrintNamesItsFile:
|
|
|
|
|
+ def test_the_url_is_captured(self, client):
|
|
|
|
|
+ client._process_message(screen_start())
|
|
|
|
|
+
|
|
|
|
|
+ assert client.state.current_project_url == "file:///userdata/model/history/JOB_A.gcode.3mf"
|
|
|
|
|
+
|
|
|
|
|
+ def test_the_sticky_copy_is_captured_too(self, client):
|
|
|
|
|
+ """The connection diagnostic runs after the print, by which point the
|
|
|
|
|
+ per-print field has been cleared."""
|
|
|
|
|
+ client._process_message(screen_start())
|
|
|
|
|
+
|
|
|
|
|
+ assert client.state.last_project_url == "file:///userdata/model/history/JOB_A.gcode.3mf"
|
|
|
|
|
+
|
|
|
|
|
+ def test_the_verdict_now_skips_the_sweep(self, client):
|
|
|
|
|
+ """The whole point, in one assertion: with the URL in hand the H2S's
|
|
|
|
|
+ ``sdcard: true`` no longer decides the outcome."""
|
|
|
|
|
+ client.state.sdcard = True
|
|
|
|
|
+ client.state.sdcard_reported = True
|
|
|
|
|
+
|
|
|
|
|
+ client._process_message(screen_start())
|
|
|
|
|
+ verdict = print_file_reachable_over_ftp(client.state)
|
|
|
|
|
+
|
|
|
|
|
+ assert verdict.reachable is False
|
|
|
|
|
+ assert verdict.reason == REASON_INTERNAL_HISTORY
|
|
|
|
|
+ # Still probed, because this printer keeps screen-started jobs under
|
|
|
|
|
+ # /cache for a while and that copy archives in full when it is there.
|
|
|
|
|
+ assert verdict.probe_filename == "JOB_A.gcode.3mf"
|
|
|
|
|
+
|
|
|
|
|
+ def test_the_mapping_is_captured_when_no_slicer_sent_one(self, client):
|
|
|
|
|
+ """On a screen start this frame is the only place it appears."""
|
|
|
|
|
+ client._process_message(screen_start())
|
|
|
|
|
+
|
|
|
|
|
+ assert client._captured_ams_mapping == [0]
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_mapping_from_the_request_topic_wins(self, client):
|
|
|
|
|
+ """The slicer's own mapping describes the same print and arrived first;
|
|
|
|
|
+ the echo can carry a different shape or none at all."""
|
|
|
|
|
+ client._captured_ams_mapping = [0, -1, -1, -1]
|
|
|
|
|
+
|
|
|
|
|
+ client._process_message(screen_start(ams_mapping=[3]))
|
|
|
|
|
+
|
|
|
|
|
+ assert client._captured_ams_mapping == [0, -1, -1, -1]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestWhatMustNotBeCaptured:
|
|
|
|
|
+ def test_a_refused_dispatch_is_ignored(self, client):
|
|
|
|
|
+ """It names a file that was never written. Acting on it would pin the
|
|
|
|
|
+ next print's archive on a destination nothing went to."""
|
|
|
|
|
+ client._process_message(screen_start(result="FAIL", reason="STORAGE_FULL"))
|
|
|
|
|
+
|
|
|
|
|
+ assert client.state.current_project_url is None
|
|
|
|
|
+ assert client._captured_ams_mapping is None
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.parametrize("url", ["", None, 12345, [], {}])
|
|
|
|
|
+ def test_a_missing_or_non_string_url_is_ignored(self, client, url):
|
|
|
|
|
+ """The value is whatever the sender put on the wire."""
|
|
|
|
|
+ client._process_message(screen_start(url=url))
|
|
|
|
|
+
|
|
|
|
|
+ assert client.state.current_project_url is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_another_command_on_the_same_topic_is_ignored(self, client):
|
|
|
|
|
+ """push_status carries a `url` field of its own on some firmwares."""
|
|
|
|
|
+ client._process_message({"print": {"command": "push_status", "url": "file:///userdata/model/history/x.3mf"}})
|
|
|
|
|
+
|
|
|
|
|
+ assert client.state.current_project_url is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_our_own_dispatch_is_not_logged_as_someone_elses(self, client, caplog):
|
|
|
|
|
+ """Our publish is echoed on *both* topics. The request-topic echo lands
|
|
|
|
|
+ first and clears ``_own_project_file_key``, so a report-topic branch
|
|
|
|
|
+ that reused ``_handle_request_message``'s diagnostic would report every
|
|
|
|
|
+ Bambuddy-started print as an external one.
|
|
|
|
|
+ """
|
|
|
|
|
+ dispatch = {
|
|
|
|
|
+ "command": "project_file",
|
|
|
|
|
+ "sequence_id": "20002",
|
|
|
|
|
+ "file": "JOB_NORMAL.gcode.3mf",
|
|
|
|
|
+ "url": "brtc://emmc/JOB_NORMAL.gcode.3mf",
|
|
|
|
|
+ "subtask_name": "JOB_NORMAL",
|
|
|
|
|
+ }
|
|
|
|
|
+ client._own_project_file_key = client._project_file_key(dispatch)
|
|
|
|
|
+ client._handle_request_message({"print": dispatch})
|
|
|
|
|
+ assert client._own_project_file_key is None
|
|
|
|
|
+
|
|
|
|
|
+ caplog.clear()
|
|
|
|
|
+ client._process_message({"print": {**dispatch, "result": "SUCCESS", "is_from_mqtt": True}})
|
|
|
|
|
+
|
|
|
|
|
+ assert "External project_file payload" not in caplog.text
|
|
|
|
|
+ # ...and the capture still happened, which is what makes it worth having
|
|
|
|
|
+ # on this topic at all: some brokers refuse the request subscription.
|
|
|
|
|
+ assert client.state.current_project_url == "brtc://emmc/JOB_NORMAL.gcode.3mf"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestTheSlicerPathIsUnchanged:
|
|
|
|
|
+ def test_an_external_dispatch_still_sweeps(self, client):
|
|
|
|
|
+ """The regression to fear: routing more URLs into the matcher must not
|
|
|
|
|
+ turn an ordinary Studio print into a blank archive."""
|
|
|
|
|
+ client.state.sdcard = True
|
|
|
|
|
+ client.state.sdcard_reported = True
|
|
|
|
|
+
|
|
|
|
|
+ client._process_message(
|
|
|
|
|
+ {"print": {"command": "project_file", "result": "SUCCESS", "url": "ftp://JOB_NORMAL.gcode.3mf"}}
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert print_file_reachable_over_ftp(client.state).reachable is True
|