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

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
2d13e77f1b
3 измененных файлов с 144 добавлено и 0 удалено
  1. 3 0
      CHANGELOG.md
  2. 29 0
      backend/app/services/bambu_mqtt.py
  3. 112 0
      backend/tests/unit/services/test_bambu_mqtt.py

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.5b2] - Unreleased
 
+### Fixed
+- **Finish photo captures the wrong plate on A1 / A1 Mini when SwapMod plate-swap End G-code is injected (#1867, reporter @qoatzelcoat)** — The "Print Complete" snapshot arrived after the user's SwapMod plate-swap moves had already ejected the printed part, so the notification always carried an image of the *swapped* (empty) plate. **Root cause.** The stage-22 ("Filament unloading") edge that normally fires the finish-photo pre-capture never fires on A1 Mini firmware (confirmed in the reporter's log: `FINISH PHOTO MOMENT (FINISH fallback) — stage-22 never fired; capturing at FINISH-state transition`). The fallback path in `bambu_mqtt.py` waits for `gcode_state → FINISH`, and Bambu Studio runs the user-defined End G-code *before* that state transition — so by the time the fallback captures, SwapMod has already moved the plate. **Fix.** Added a new `layer_num → total_layer_num` edge trigger inside `_parse_print_data` that fires the finish-photo moment the instant the last object layer completes, before any end G-code runs. Guarded by the existing `_was_running` / `_finish_photo_captured` one-shot so subsequent stage-22 and FINISH-state ticks become no-ops for the same print. Works on every Bambu variant (A1, A1 Mini, P1P/S, X1C, H2S/H2D) without model detection; on AMS printers the last-layer edge fires slightly before stage-22 would have, which also fixes SwapMod-style setups on those printers rather than only on the A1 family. Test coverage: `TestLastLayerFinishPhotoTrigger` in `backend/tests/unit/services/test_bambu_mqtt.py` (7 cases — fires on last layer, edge-only, skipped on stage-22 replay, skipped on the FINISH fallback, ignores `total=0` bootstrap frames, ignores non-running catch-up messages).
+
 ### Added
 - **Indonesian Rupiah (IDR) currency support (#1869, reporter @qoatzelcoat)** — Added `IDR` with the `Rp` symbol to `frontend/src/utils/currency.ts`; appears in Settings → Cost Tracking and formats spool/print costs as `Rp <amount>`. Backend already accepts any 3-letter ISO code (`filament.currency` / `settings.currency` are freeform `String(3)`), so no schema change or migration was needed.
 

+ 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