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

Keep the loaded tray while an H2 slot is ambiguous (#3242)

On a dual-nozzle printer with several AMS on one extruder, tray_now is a
local slot. On every filament change it arrives about a second before
the extruder's snow, and the fallback filled the gap with the bare slot,
i.e. AMS 0. Support packages show 2,416 of these across H2C, H2D and
H2D Pro, 2,291 of them landing in the tray change log.

Resolve the slot from the printer's mapping field first, narrowed to the
active extruder's units when two trays share it. When that doesn't
decide, keep the current tray until snow names the new one.
maziggy 2 недель назад
Родитель
Сommit
2a52785ce3
2 измененных файлов с 151 добавлено и 22 удалено
  1. 45 15
      backend/app/services/bambu_mqtt.py
  2. 106 7
      backend/tests/unit/services/test_bambu_mqtt.py

+ 45 - 15
backend/app/services/bambu_mqtt.py

@@ -2893,31 +2893,37 @@ class BambuMQTTClient:
             self.state.print_options.filament_tangle_detect = bool(xcam_data.get("filament_tangle_detect"))
 
     @staticmethod
-    def _resolve_local_slot_from_mapping(local_slot: int, mapping_raw: list | None) -> int | None:
+    def _resolve_local_slot_from_mapping(
+        local_slot: int, mapping_raw: list | None, units: list[int] | None = None
+    ) -> int | None:
         """Resolve a local AMS slot ID to a global tray ID using the MQTT mapping field.
 
         The MQTT mapping field is an array of snow-encoded values:
         each entry = ams_hw_id * 256 + slot_id (65535 = unmapped).
 
         Finds entries where the local slot matches, then computes the global tray ID.
+        When several match and ``units`` is given (the AMS units on the active
+        extruder of a dual-nozzle printer), only those units' trays count.
         Returns the global ID if exactly one AMS matches, or None if ambiguous/unavailable.
         """
         if not isinstance(mapping_raw, list) or not mapping_raw:
             return None
 
-        candidates: set[int] = set()
+        candidates: dict[int, int] = {}  # global tray ID -> AMS unit
         for value in mapping_raw:
             if not isinstance(value, int) or value >= 65535:
                 continue
             ams_hw_id = value >> 8
             slot = value & 0xFF
             if 0 <= ams_hw_id <= 3 and (slot & 0x03) == local_slot:
-                candidates.add(ams_hw_id * 4 + local_slot)
+                candidates[ams_hw_id * 4 + local_slot] = ams_hw_id
             elif 128 <= ams_hw_id <= 135 and local_slot == 0:
-                candidates.add(ams_hw_id)
+                candidates[ams_hw_id] = ams_hw_id
 
+        if len(candidates) > 1 and units:
+            candidates = {tray: unit for tray, unit in candidates.items() if unit in units}
         if len(candidates) == 1:
-            return candidates.pop()
+            return next(iter(candidates))
         return None
 
     def _maybe_trigger_external_spool_change(self):
@@ -3197,7 +3203,26 @@ class BambuMQTTClient:
                                     except ValueError:
                                         pass  # Skip AMS IDs that aren't valid integers
 
-                            if len(ams_on_extruder) == 1:
+                            # Several AMS (or none the map knows of) leave the
+                            # slot ambiguous. The printer's own mapping names the
+                            # trays the running print uses: one of them at this
+                            # slot is the one feeding (#3242). Only while a print
+                            # runs: an idle H2 keeps reporting the previous
+                            # print's mapping.
+                            mapped_tray = None
+                            if len(ams_on_extruder) != 1 and self._was_running and not self._completion_triggered:
+                                mapped_tray = self._resolve_local_slot_from_mapping(
+                                    parsed_tray_now, self.state.raw_data.get("mapping"), ams_on_extruder
+                                )
+
+                            if mapped_tray is not None:
+                                if self.state.tray_now != mapped_tray:
+                                    logger.debug(
+                                        f"[{self.serial_number}] H2D tray_now: AMS {ams_on_extruder} on extruder "
+                                        f"{active_ext}, slot {parsed_tray_now} -> global ID {mapped_tray} (from mapping)"
+                                    )
+                                self.state.tray_now = mapped_tray
+                            elif len(ams_on_extruder) == 1:
                                 # Single AMS on this extruder - unambiguous
                                 active_ams_id = ams_on_extruder[0]
                                 if 128 <= active_ams_id <= 135:
@@ -3244,19 +3269,24 @@ class BambuMQTTClient:
                                         )
                                         self.state.tray_now = resolved
                                     else:
-                                        # Genuinely ambiguous - use slot as-is (will be wrong for non-first AMS)
-                                        logger.warning(
-                                            f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on extruder {active_ext}, "
-                                            f"no snow field, using slot {parsed_tray_now} (may be incorrect)"
+                                        # Genuinely ambiguous. This is the second between a
+                                        # filament change reaching the AMS report and the
+                                        # extruder's snow, which then names the tray. Using
+                                        # the bare slot would point at AMS 0 and put that
+                                        # tray in the usage change log (#3242), so keep the
+                                        # current tray until snow arrives.
+                                        logger.debug(
+                                            f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on "
+                                            f"extruder {active_ext}, slot {parsed_tray_now} is ambiguous without snow, "
+                                            f"keeping {current_tray}"
                                         )
-                                        self.state.tray_now = parsed_tray_now
                             else:
-                                # No AMS on this extruder - use slot as-is
-                                logger.warning(
+                                # No AMS on this extruder that the map knows of: the
+                                # slot can't be placed, keep the current tray (#3242)
+                                logger.debug(
                                     f"[{self.serial_number}] H2D tray_now: no AMS on extruder {active_ext}, "
-                                    f"using slot {parsed_tray_now}"
+                                    f"slot {parsed_tray_now} can't be placed without snow, keeping {self.state.tray_now}"
                                 )
-                                self.state.tray_now = parsed_tray_now
                 elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # Single-nozzle printer with tray_now in 0-3 range.
                     # #1822: H2S firmware reports tray_now as the AMS's idle

+ 106 - 7
backend/tests/unit/services/test_bambu_mqtt.py

@@ -2691,6 +2691,23 @@ class TestResolveLocalSlotFromMapping:
         # AMS-HT id=128: snow = 128*256 + 0 = 32768
         assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128
 
+    def test_units_narrow_ambiguous_match(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        # AMS0 slot1 and AMS2 slot1 both mapped; only AMS 2 is on this extruder
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 513], [1, 2]) == 9
+
+    def test_units_do_not_drop_a_single_match(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        # One candidate stands even when the extruder map doesn't list its unit
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514], [0]) == 10
+
+    def test_units_still_ambiguous_returns_none(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        assert BambuMQTTClient._resolve_local_slot_from_mapping(3, [3, 259], [0, 1]) is None
+
 
 # ---------------------------------------------------------------------------
 # 3. H2D Pro — initial state detection
@@ -2843,10 +2860,16 @@ class TestTrayNowDualNozzleH2DSetup:
         }
         mqtt_client._process_message(payload)
 
-        # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
-        # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
-        # Single AMS on extruder 0 → global_id = 0*4+2 = 2
+        # Dual-nozzle was detected; snow is 0xFF00FF (unloaded), so the
+        # ams_extruder_map fallback runs. The map is built from this same
+        # report only after tray_now, so the slot can't be placed yet and
+        # nothing is guessed (#3242).
         assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.ams_extruder_map == {"0": 0}
+        assert mqtt_client.state.tray_now == 255
+
+        # The next report finds AMS 0 alone on the active extruder → 0*4+2 = 2
+        mqtt_client._process_message(_ams_payload(2))
         assert mqtt_client.state.tray_now == 2
 
 
@@ -3062,12 +3085,21 @@ class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
         h2d_client._process_message(_ams_payload(1))
         assert h2d_client.state.tray_now == 5
 
-    def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
-        """No AMS mapped to the active extruder → raw slot as global ID."""
+    def test_no_ams_on_extruder_keeps_current(self, h2d_client):
+        """No AMS mapped to the active extruder → the slot can't be placed, keep current (#3242)."""
         # All AMS on left extruder, but right is active
         h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
+        h2d_client.state.tray_now = 255
         h2d_client._process_message(_ams_payload(2))
-        assert h2d_client.state.tray_now == 2
+        assert h2d_client.state.tray_now == 255
+
+    def test_no_ams_on_extruder_resolved_from_mapping(self, h2d_client):
+        """No AMS the map knows of, but the print's mapping names one tray at the slot (#3242)."""
+        h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
+        h2d_client.state.raw_data["mapping"] = [514]  # AMS 2 slot 2
+        h2d_client._was_running = True
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 10
 
     def test_single_ams_ht_on_extruder_returns_unit_id(self, h2d_client):
         """AMS-HT 128 alone on left extruder, slot 0 → global ID 128 (not 512)."""
@@ -3106,7 +3138,74 @@ class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
         h2d_client.state.tray_now = 255
         # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
         h2d_client._process_message(_ams_payload(3))
-        assert h2d_client.state.tray_now == 3  # raw slot fallback
+        assert h2d_client.state.tray_now == 255  # kept, never the bare slot (#3242)
+
+    def test_multiple_ams_resolved_from_mapping(self, h2d_client):
+        """#3242: three AMS on one extruder, the print feeds from AMS 2 slot 1.
+        The printer's mapping names that tray, so AMS 0 slot 1 is never marked."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "2": 0}
+        h2d_client.state.raw_data["mapping"] = [65535, 513]  # AMS 2 slot 1
+        h2d_client._was_running = True
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 9
+
+    def test_mapping_ignored_while_idle(self, h2d_client):
+        """An idle H2 keeps reporting the previous print's mapping. A spool
+        swap at AMS 0 slot 1 must not be placed on that print's AMS 2 tray."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "2": 0}
+        h2d_client.state.raw_data["mapping"] = [513]  # left over: AMS 2 slot 1
+        h2d_client.state.tray_now = 255
+        assert h2d_client._was_running is False
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 255
+
+    def test_mapping_ignored_after_print_completed(self, h2d_client):
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0, "2": 0}
+        h2d_client.state.raw_data["mapping"] = [513]
+        h2d_client.state.tray_now = 255
+        h2d_client._was_running = True
+        h2d_client._completion_triggered = True
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 255
+
+    def test_mapping_narrowed_to_active_extruder(self, h2d_client):
+        """Two mapped trays share the slot; only one is on the active extruder."""
+        h2d_client.state.ams_extruder_map = {"0": 1, "1": 0, "2": 0}
+        h2d_client.state.raw_data["mapping"] = [1, 513]  # AMS 0 slot 1 (left), AMS 2 slot 1 (right)
+        h2d_client._was_running = True
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 9
+
+    def test_ambiguous_mapping_keeps_current(self, h2d_client):
+        """Multi-colour print: the mapping has the slot on two units of this extruder.
+        Keep the tray that was loaded until snow names the new one."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
+        h2d_client.state.raw_data["mapping"] = [3, 259, 256]  # AMS 0 slot 3, AMS 1 slot 3, AMS 1 slot 0
+        h2d_client.state.tray_now = 4
+        h2d_client._was_running = True
+        h2d_client._process_message(_ams_payload(3))
+        assert h2d_client.state.tray_now == 4
+
+    def test_ambiguous_slot_stays_out_of_tray_change_log(self, h2d_client):
+        """#3242: a mid-print filament change reports the slot about a second
+        before snow. The guess must not land in the usage change log."""
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
+        h2d_client.state.tray_now = 5
+        h2d_client.state.last_loaded_tray = 5
+        h2d_client.state.tray_change_log = [(5, 0)]
+        h2d_client._was_running = True
+        h2d_client._completion_triggered = False
+        h2d_client.state.layer_num = 120
+
+        h2d_client._process_message(_ams_payload(3))
+        assert h2d_client.state.tray_now == 5
+        assert h2d_client.state.tray_change_log == [(5, 0)]
+
+        # Snow arrives and names AMS 1 slot 3
+        h2d_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0x0103}, {"id": 1, "snow": 0xFFFF}]))
+        h2d_client._process_message(_ams_payload(3))
+        assert h2d_client.state.tray_now == 7
+        assert h2d_client.state.tray_change_log == [(5, 0), (7, 120)]
 
 
 # ---------------------------------------------------------------------------