Browse Source

fix(mqtt): unique per-submission IDs for archive reprints (#1011)

  Archive reprints and library-file prints built the MQTT project_file
  command with hardcoded project_id="0", subtask_id="0", task_id="0".
  Printers key per-job state (including gcode_start_time) on those IDs,
  so reprints looked like continuations of the same job and third-party
  MQTT observers (OctoEverywhere) reported compounding durations across
  repeat replays — a 40 min job reprinted from archive showed ~1h40m,
  and a second reprint of the same file showed ~4h. BambuStudio mints
  fresh IDs per submission; bambu_mqtt.start_print() now does the same
  using an epoch-millisecond timestamp for all three fields. md5 is
  deliberately left empty to avoid activating firmware md5-validation
  against a digest we can't compute without re-reading the upload.

  Added 6 regression tests in TestStartPrintUniqueIdentityFields
  covering non-zero IDs, md5 stays empty, uniqueness across successive
  submissions, numeric-string format, and blast-radius guard on
  unrelated payload fields. Updated CHANGELOG.
maziggy 1 month ago
parent
commit
115d6fe627
3 changed files with 113 additions and 3 deletions
  1. 0 0
      CHANGELOG.md
  2. 16 3
      backend/app/services/bambu_mqtt.py
  3. 97 0
      backend/tests/unit/services/test_bambu_mqtt.py

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 16 - 3
backend/app/services/bambu_mqtt.py

@@ -3009,6 +3009,19 @@ class BambuMQTTClient:
                         self.serial_number,
                         self.serial_number,
                     )
                     )
 
 
+            # Unique per-submission identity fields. Hardcoded "0" values caused
+            # third-party MQTT observers (OctoEverywhere, etc.) to see reprints as
+            # continuations of the same job: the printer reuses gcode_start_time
+            # from the prior print with task_id=0, so observers latch onto a stale
+            # timestamp and report compounding durations on repeat replays (#1011).
+            # BambuStudio mints fresh IDs per submission; matching that behavior
+            # makes the printer emit a clean state-transition for each job.
+            # md5 is left empty — firmware historically accepts "" as "skip
+            # validation" (unlike Studio, we don't have the file's real md5 here
+            # without re-reading the upload, and sending a synthetic wrong digest
+            # risks activation of md5 verification on some firmwares).
+            submission_id = str(int(time.time() * 1000))
+
             command = {
             command = {
                 "print": {
                 "print": {
                     "sequence_id": "20000",
                     "sequence_id": "20000",
@@ -3031,9 +3044,9 @@ class BambuMQTTClient:
                     "nozzle_offset_cali": 2,
                     "nozzle_offset_cali": 2,
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "profile_id": "0",
                     "profile_id": "0",
-                    "project_id": "0",
-                    "subtask_id": "0",
-                    "task_id": "0",
+                    "project_id": submission_id,
+                    "subtask_id": submission_id,
+                    "task_id": submission_id,
                 }
                 }
             }
             }
 
 

+ 97 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5,6 +5,7 @@ These tests focus on timelapse tracking during prints.
 """
 """
 
 
 import json
 import json
+import time
 
 
 import pytest
 import pytest
 
 
@@ -3410,6 +3411,102 @@ class TestStartPrintAmsMapping:
         assert cmd["flow_cali"] is False
         assert cmd["flow_cali"] is False
 
 
 
 
+class TestStartPrintUniqueIdentityFields:
+    """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
+
+    Hardcoded "0" values caused third-party MQTT observers (e.g. OctoEverywhere)
+    to treat archive reprints as continuations of the same job and report
+    compounding durations on repeat replays. Each start_print call must produce
+    a distinct, non-zero identity triplet so the printer emits a fresh state
+    transition. md5 is deliberately left empty — historically firmware treats
+    "" as "skip validation" and we don't have the file's real digest here.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _get_published_command(self, mqtt_client):
+        call_args = mqtt_client._client.publish.call_args
+        return json.loads(call_args[0][1])["print"]
+
+    def test_identity_fields_are_non_zero(self, mqtt_client):
+        mqtt_client.start_print("test.3mf")
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["project_id"] != "0"
+        assert cmd["subtask_id"] != "0"
+        assert cmd["task_id"] != "0"
+
+    def test_identity_fields_are_all_equal_per_submission(self, mqtt_client):
+        """All three IDs come from the same submission timestamp — Studio also
+        uses a single identity per submission across the three fields."""
+        mqtt_client.start_print("test.3mf")
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["project_id"] == cmd["subtask_id"] == cmd["task_id"]
+
+    def test_md5_stays_empty(self, mqtt_client):
+        """Deliberate: synthetic md5 risks activating firmware validation."""
+        mqtt_client.start_print("test.3mf")
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["md5"] == ""
+
+    def test_identity_fields_change_between_submissions(self, mqtt_client):
+        """Two successive start_print calls must produce different IDs.
+
+        Without this, the printer can't tell replays apart and reuses
+        gcode_start_time from the prior job.
+        """
+        mqtt_client.start_print("test.3mf")
+        first = self._get_published_command(mqtt_client)
+
+        time.sleep(0.002)
+
+        mqtt_client.start_print("test.3mf")
+        second = self._get_published_command(mqtt_client)
+
+        assert first["task_id"] != second["task_id"]
+        assert first["subtask_id"] != second["subtask_id"]
+        assert first["project_id"] != second["project_id"]
+
+    def test_submission_id_is_numeric_string(self, mqtt_client):
+        """ID format: digits-only string (epoch millis). Studio uses cloud
+        task IDs that are also numeric-looking strings; the DB column is
+        VARCHAR(64) and Bambuddy's own subtask_id parser treats '0'/'' as
+        absent — any valid digit string that isn't '0' is fine."""
+        mqtt_client.start_print("test.3mf")
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["task_id"].isdigit()
+        assert int(cmd["task_id"]) > 0
+        # Must fit in VARCHAR(64); epoch-ms is 13 digits
+        assert len(cmd["task_id"]) <= 64
+
+    def test_unrelated_payload_fields_untouched(self, mqtt_client):
+        """Regression guard: fix only touches identity fields; everything else
+        (sequence_id, command verb, calibration defaults, profile_id) must be
+        unchanged to avoid silently breaking printer behavior."""
+        mqtt_client.start_print("test.3mf")
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["sequence_id"] == "20000"
+        assert cmd["command"] == "project_file"
+        assert cmd["param"] == "Metadata/plate_1.gcode"
+        assert cmd["url"] == "ftp://test.3mf"
+        assert cmd["file"] == "test.3mf"
+        assert cmd["profile_id"] == "0"
+        assert cmd["cfg"] == "0"
+        assert cmd["subtask_name"] == "test"
+
+
 class TestDeleteKProfileDualNozzleDetection:
 class TestDeleteKProfileDualNozzleDetection:
     """Regression guard: dual-nozzle detection by serial prefix (#988).
     """Regression guard: dual-nozzle detection by serial prefix (#988).
 
 

Some files were not shown because too many files changed in this diff