فهرست منبع

fix(dispatch): honor a resolved AMS mapping over a stale use_ams=false (#2595)

A print sliced against a Virtual Printer carries use_ams=false — a VP
advertises no AMS, so the slicer sends it and VP intake stamps it on the
queue item. But an "Any [model]" item is colour-matched to a real printer
at dispatch, resolving a real AMS slot in ams_mapping. The command builder
only ever forced use_ams off (all-external) and never back on, so the stale
false shipped with a real-tray mapping and the printer aborted at layer 0 on
the empty external spool.

For single-nozzle printers the mapping is now authoritative: a real tray
(0-253) forces use_ams=true, explicit external (254/255) forces it false,
and an unresolved -1 does neither (preserving the #2589 contract).
Dual-nozzle is untouched — use_ams is nozzle routing there. The correction
sits at the single command-builder choke point, covering the VP, queue, and
manual paths.
maziggy 1 ماه پیش
والد
کامیت
88ae595268
3فایلهای تغییر یافته به همراه127 افزوده شده و 14 حذف شده
  1. 0 0
      CHANGELOG.md
  2. 37 14
      backend/app/services/bambu_mqtt.py
  3. 90 0
      backend/tests/unit/test_use_ams_reconcile_2595.py

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 37 - 14
backend/app/services/bambu_mqtt.py

@@ -3916,23 +3916,46 @@ class BambuMQTTClient:
                         flat_ams_mapping.append(tray_id)
                         flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
 
 
-            # If all mapped slots are external spool (no real AMS trays), force use_ams=False.
-            # P1S/P1P with no AMS rejects use_ams=True with "Failed to get AMS mapping table".
-            # Skip for dual-nozzle printers — use_ams controls nozzle routing there.
-            # H2S falls through this gate now (#1386): it is single-nozzle and was
+            # Reconcile use_ams against the resolved ams_mapping for single-nozzle
+            # printers — the mapping is authoritative about whether this print
+            # actually feeds from the AMS. Skip for dual-nozzle printers, where
+            # use_ams encodes nozzle routing rather than an AMS on/off flag.
+            # H2S falls through here now (#1386): it is single-nozzle and was
             # hitting the dual-nozzle bypass, which caused 07FF_8012 when printing
             # hitting the dual-nozzle bypass, which caused 07FF_8012 when printing
             # without an AMS attached.
             # without an AMS attached.
             #
             #
-            # Only an *explicit* external/virtual spool (254/255) may downgrade to
-            # use_ams=False. An unresolved slot (-1) must NOT: it means the mapping
-            # was never resolved — e.g. a frontend status-load race that persisted
-            # [-1] (#2589) — and treating that as "external" silently started the
-            # print against an empty external feed, pausing with a runout. A genuine
-            # external selection is >=254; unresolved is -1. Keep them distinct so an
-            # unresolved mapping fails loudly (or is recomputed upstream) instead of
-            # silently going external.
-            if ams_mapping and use_ams and not is_dual_nozzle:
-                if all(t is None or int(t) >= 254 for t in ams_mapping):
+            # Two symmetric corrections:
+            #
+            # (a) A mapping that resolves a *real* AMS tray (0-253) forces
+            #     use_ams=True even if it arrived False. A print sent to a Virtual
+            #     Printer is sliced against the VP, which advertises no AMS, so the
+            #     slicer sends use_ams=false and that gets stamped on the queue item
+            #     — but at dispatch the scheduler colour-matches a real printer and
+            #     resolves a real AMS slot. Without this, the stale False reaches the
+            #     printer, which ignores the mapped slot and aborts at layer 0 on the
+            #     empty external spool ("not enough filament"). Diagnosed by
+            #     @Sawtaytoes (#2595, PR #2596).
+            #
+            # (b) Only an *explicit* external/virtual spool (254/255) may downgrade
+            #     to use_ams=False. P1S/P1P with no AMS rejects use_ams=True with
+            #     "Failed to get AMS mapping table". An unresolved slot (-1) does
+            #     NEITHER: it means the mapping was never resolved — e.g. a frontend
+            #     status-load race that persisted [-1] (#2589) — and treating it as
+            #     external silently started the print against an empty feed. A genuine
+            #     external selection is >=254; unresolved is -1; a loaded tray is
+            #     0-253. Keeping them distinct means an unresolved mapping fails loudly
+            #     (or is recomputed upstream) instead of silently going external, and
+            #     never gets force-enabled by (a) either.
+            if ams_mapping and not is_dual_nozzle:
+                has_real_tray = any(t is not None and 0 <= int(t) <= 253 for t in ams_mapping)
+                all_external = all(t is None or int(t) >= 254 for t in ams_mapping)
+                if has_real_tray and not use_ams:
+                    use_ams = True
+                    logger.info(
+                        "[%s] AMS mapping resolved a real slot — setting use_ams=True (#2595)",
+                        self.serial_number,
+                    )
+                elif use_ams and all_external:
                     use_ams = False
                     use_ams = False
                     logger.info(
                     logger.info(
                         "[%s] All filament slots use external spool — setting use_ams=False",
                         "[%s] All filament slots use external spool — setting use_ams=False",

+ 90 - 0
backend/tests/unit/test_use_ams_reconcile_2595.py

@@ -0,0 +1,90 @@
+"""use_ams must be reconciled against the resolved ams_mapping at dispatch (#2595).
+
+A print sent to a Virtual Printer is sliced against the VP, which advertises no
+AMS, so the slicer sends ``use_ams=false`` and that flag is stamped onto the
+queue item. But an "Any [model]" queue item is colour-matched to a real printer
+at dispatch, resolving a *real* AMS slot in ``ams_mapping``. The stale
+``use_ams=False`` used to survive to the print command, so the printer ignored
+the mapped slot and aborted at layer 0 on the empty external spool ("not enough
+filament"). Diagnosed by @Sawtaytoes.
+
+The command builder now treats the mapping as authoritative for single-nozzle
+printers: a real tray forces ``use_ams=True``; an explicit external selection
+forces it False; an unresolved ``-1`` mapping (#2589) does neither. Dual-nozzle
+is untouched (``use_ams`` is nozzle routing there).
+"""
+
+import json
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+class TestUseAmsReconcile:
+    @pytest.fixture
+    def mqtt_client(self):
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="01P00A452600691",
+            access_code="12345678",
+        )
+        # Single-nozzle X1C so the dual-nozzle bypass does not apply.
+        client.model = "X1C"
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _sent_command(self, mqtt_client) -> dict:
+        assert mqtt_client._client.publish.called, "start_print did not publish"
+        payload = mqtt_client._client.publish.call_args.args[1]
+        return json.loads(payload)["print"]
+
+    def test_vp_false_with_real_tray_forces_use_ams_true(self, mqtt_client):
+        """The reported bug: use_ams=False (VP-stamped) + a real AMS slot -> True."""
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[4], use_ams=False) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is True
+
+    def test_false_with_padded_real_tray_forces_true(self, mqtt_client):
+        """A padded mapping ([-1, -1, tray]) still has a real slot -> True."""
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[-1, -1, 5], use_ams=False) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is True
+
+    def test_false_all_external_stays_false(self, mqtt_client):
+        """An explicit external selection must NOT be force-enabled."""
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[254], use_ams=False) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is False
+
+    def test_false_mixed_external_stays_false(self, mqtt_client):
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[255, 254], use_ams=False) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is False
+
+    def test_false_unresolved_stays_false(self, mqtt_client):
+        """An unresolved [-1] is neither external nor a real tray — leave it alone
+        (preserves the #2589 contract; it should have been recomputed upstream)."""
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[-1], use_ams=False) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is False
+
+    def test_true_all_external_still_downgrades(self, mqtt_client):
+        """The original #2589 all-external downgrade still fires."""
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[254], use_ams=True) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is False
+
+    def test_true_real_tray_stays_true(self, mqtt_client):
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[5], use_ams=True) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is True
+
+    def test_dual_nozzle_use_ams_untouched(self, mqtt_client):
+        """Dual-nozzle: use_ams is nozzle routing, not an AMS flag — never coerced."""
+        mqtt_client._is_dual_nozzle = True
+        assert mqtt_client.start_print("shell.3mf", ams_mapping=[4], use_ams=False) is True
+        cmd = self._sent_command(mqtt_client)
+        assert cmd["use_ams"] is False

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است