Просмотр исходного кода

fix(mqtt): capture finish photo on last-layer edge, not FINISH state (#1867)

    A1 Mini firmware skips stg_cur=22 entirely, so the finish-photo fallback
    fires at gcode_state=FINISH — which runs AFTER Bambu Studio has already
    executed the user's End G-code. Users with SwapMod plate-swap injected
    into End G-code always got a photo of the swapped (empty) plate.

    Add a layer_num >= total_layer_num edge trigger in _parse_print_data so
    the pre-capture fires the moment the last object layer completes, on
    every printer variant. Guarded by the existing _finish_photo_captured
    one-shot so stage-22 and FINISH-state hooks become no-ops for the same
    print — no framing regression on AMS printers without custom end G-code.
maziggy 2 месяцев назад
Родитель
Сommit
8b49edf811
2 измененных файлов с 141 добавлено и 0 удалено
  1. 29 0
      backend/app/services/bambu_mqtt.py
  2. 112 0
      backend/tests/unit/services/test_bambu_mqtt.py

+ 29 - 0
backend/app/services/bambu_mqtt.py

@@ -2177,6 +2177,35 @@ class BambuMQTTClient:
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
                 self.on_layer_change(new_layer)
+            # #1867 last-layer finish-photo trigger. A1 Mini (and other
+            # firmware variants) skips `stg_cur=22`, so the fallback fires
+            # at gcode_state=FINISH — which runs AFTER user End G-code
+            # (e.g. SwapMod plate-swap) and captures the wrong plate.
+            # Firing on the layer_num→total_layer_num edge captures the
+            # last object layer before any end G-code executes.
+            total = self.state.total_layers or 0
+            if (
+                total > 0
+                and new_layer >= total
+                and old_layer < total
+                and self._was_running
+                and not self._finish_photo_captured
+                and self.on_finish_photo_moment
+            ):
+                self._finish_photo_captured = True
+                logger.info(
+                    f"[{self.serial_number}] FINISH PHOTO MOMENT (last-layer) — "
+                    f"layer={new_layer}/{total}, "
+                    f"timelapse_active={self._timelapse_during_print}"
+                )
+                self.on_finish_photo_moment(
+                    {
+                        "trigger": "last_layer",
+                        "filename": self._previous_gcode_file or self.state.gcode_file,
+                        "subtask_name": self.state.subtask_name,
+                        "timelapse_was_active": self._timelapse_during_print,
+                    }
+                )
         if "total_layer_num" in data:
             # Some firmware (P1S observed) resets `total_layer_num` to 0 at
             # print end — same shape as the `layer_num` reset guarded above.

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

@@ -6070,3 +6070,115 @@ class TestTrayNowH2SExternalSpoolOverride:
 
         mqtt_client._process_message(_ams_payload(255))
         assert mqtt_client.state.tray_now == 255
+
+
+class TestLastLayerFinishPhotoTrigger:
+    """Tests for #1867: layer_num→total_layer_num edge fires the finish-photo
+    moment before user End G-code (e.g. SwapMod) executes.
+
+    A1 Mini firmware skips stg_cur=22 entirely, so the FINISH-state fallback
+    fires after end G-code has already moved the plate. The last-layer edge
+    is the earliest reliable "print finished" signal available across all
+    Bambu printer variants.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._was_running = True
+        client.state.total_layers = 100
+        client.state.layer_num = 99
+        return client
+
+    def test_fires_when_layer_reaches_total(self, mqtt_client):
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert len(events) == 1
+        assert events[0]["trigger"] == "last_layer"
+        assert mqtt_client._finish_photo_captured is True
+
+    def test_does_not_fire_when_layer_still_below_total(self, mqtt_client):
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 99}})
+
+        assert events == []
+        assert mqtt_client._finish_photo_captured is False
+
+    def test_edge_only_no_double_fire(self, mqtt_client):
+        """Once fired, subsequent messages at layer_num == total must not
+        re-fire (the guard flips _finish_photo_captured to True)."""
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert len(events) == 1
+
+    def test_does_not_fire_when_not_running(self, mqtt_client):
+        """If the print never went through RUNNING (Bambuddy restart mid-print,
+        firmware replay), _was_running is False and no photo trigger fires."""
+        mqtt_client._was_running = False
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert events == []
+
+    def test_does_not_fire_when_total_layers_unknown(self, mqtt_client):
+        """total=0 (before slicer metadata arrives) must never satisfy the
+        `new_layer >= total` condition."""
+        mqtt_client.state.total_layers = 0
+        mqtt_client.state.layer_num = 0
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 0}})
+
+        assert events == []
+
+    def test_stage_22_skipped_after_last_layer_already_fired(self, mqtt_client):
+        """Once the last-layer trigger has set _finish_photo_captured, the
+        stage-22 hook that runs later on AMS printers must be a no-op."""
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        assert len(events) == 1
+
+        mqtt_client.state.progress = 100
+        mqtt_client._process_message({"print": {"stg_cur": 22}})
+
+        assert len(events) == 1
+
+    def test_finish_state_fallback_skipped_after_last_layer_fired(self, mqtt_client):
+        """The gcode_state=FINISH fallback (which fires after end G-code on
+        every printer) must be suppressed once the last-layer edge fired.
+        This is the #1867 regression check — SwapMod plate must be captured
+        by last_layer, NOT by the post-End-G-code FINISH fallback."""
+        events = []
+        completion_events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+        mqtt_client.on_print_complete = lambda data: completion_events.append(data)
+        mqtt_client._previous_gcode_state = "RUNNING"
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        assert events[0]["trigger"] == "last_layer"
+
+        mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+
+        assert len(events) == 1
+        assert len(completion_events) == 1