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

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 1 день назад
Родитель
Сommit
3ddd324f87
3 измененных файлов с 155 добавлено и 22 удалено
  1. 4 0
      CHANGELOG.md
  2. 45 15
      backend/app/services/bambu_mqtt.py
  3. 106 7
      backend/tests/unit/services/test_bambu_mqtt.py

+ 4 - 0
CHANGELOG.md

@@ -138,6 +138,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **H2D, H2D Pro and H2C with several AMS on one extruder marked the wrong tray as loaded on every filament change (#3242, reported by @Thomansky)** — These printers report the loading tray as a slot number (0-3) without saying which AMS it is in, and the report that says which AMS is read a moment later. In that gap Bambuddy assumed AMS 0: during a print feeding from AMS 2 slot 1, AMS 0 slot 1 showed as loaded for about a second, and that tray went into the list of tray changes that splits a print's filament between spools. Support packages show this on every filament change.
+  - **Now:** while a print runs, the slot is matched against the trays the print uses, taken from the printer's own mapping. If that doesn't settle it, as in a multi-colour print with the same slot in two AMS, the tray already loaded stays marked until the printer says which one is feeding.
+  - **Usage:** no case was found where a spool was actually charged for the wrong tray, because the wrong tray was always replaced within the same layer. It no longer gets into the list at all.
+  - **Idle printers:** the printer keeps reporting the last print's mapping after it ends, so it is used only during a print.
 - **A job moved to a location or model could print from an empty external spool on a printer that has the filament in its AMS (#3239, reported by @hyyz17200)** — A job keeps the AMS mapping of the printer it was made for. When a job made for a printer without an AMS, which feeds from the external spool, was moved to a location and picked by a printer with an AMS, it still told that printer to use its external spool, and the print stopped at once with "External filament is missing". The check added for #2799 didn't catch it, because it read an external spool the printer reports as empty as "no information". Now, when the printer reports its external spool empty and its AMS holds the filament the job needs, in the same colour, the mapping is worked out again for that printer. A printer without an AMS keeps using its external spool as before, also when the spool's filament was never set, and with only another colour in the AMS the printer still asks for the spool rather than print in that colour.
 - **A job moved to a location or model could print from an empty external spool on a printer that has the filament in its AMS (#3239, reported by @hyyz17200)** — A job keeps the AMS mapping of the printer it was made for. When a job made for a printer without an AMS, which feeds from the external spool, was moved to a location and picked by a printer with an AMS, it still told that printer to use its external spool, and the print stopped at once with "External filament is missing". The check added for #2799 didn't catch it, because it read an external spool the printer reports as empty as "no information". Now, when the printer reports its external spool empty and its AMS holds the filament the job needs, in the same colour, the mapping is worked out again for that printer. A printer without an AMS keeps using its external spool as before, also when the spool's filament was never set, and with only another colour in the AMS the printer still asks for the spool rather than print in that colour.
 - **A job moved to a location or model could print in the wrong colour (#3239, reported by @hyyz17200)** — The fix above only covered the external spool. A job still kept the AMS trays of the printer it was made for, so a job for black PETG in tray 3 of one printer, moved to a location and picked by a printer with blue PETG in tray 3, printed in blue: the tray existed and held the right material, so nothing stopped it. A job that Bambuddy places on a printer by model or location now gets its trays matched on that printer, by material and colour, and is held for a spool when nothing fits. Moving a job from a printer to a model or location also clears its tray choice and any "Print Anyway" given for that printer. A job sent to one printer keeps the trays picked for it. A printer without an AMS whose external spool has no filament set now prints every filament from that spool; such a job used to go out without a mapping and stop with 0700_8012 "Failed to get AMS mapping table".
 - **A job moved to a location or model could print in the wrong colour (#3239, reported by @hyyz17200)** — The fix above only covered the external spool. A job still kept the AMS trays of the printer it was made for, so a job for black PETG in tray 3 of one printer, moved to a location and picked by a printer with blue PETG in tray 3, printed in blue: the tray existed and held the right material, so nothing stopped it. A job that Bambuddy places on a printer by model or location now gets its trays matched on that printer, by material and colour, and is held for a spool when nothing fits. Moving a job from a printer to a model or location also clears its tray choice and any "Print Anyway" given for that printer. A job sent to one printer keeps the trays picked for it. A printer without an AMS whose external spool has no filament set now prints every filament from that spool; such a job used to go out without a mapping and stop with 0700_8012 "Failed to get AMS mapping table".
 - **The SD-card cleanup could delete a file the printer was printing (#3009, reported by @bdwilson)** — If a print finished while Bambuddy was stopped and you then reprinted the same file from the printer's screen, Bambuddy closed the old print when it came back and deleted its file from the SD card, even though the printer was printing that file right then. The cleanup now leaves a file alone while the printer is printing it.
 - **The SD-card cleanup could delete a file the printer was printing (#3009, reported by @bdwilson)** — If a print finished while Bambuddy was stopped and you then reprinted the same file from the printer's screen, Bambuddy closed the old print when it came back and deleted its file from the SD card, even though the printer was printing that file right then. The cleanup now leaves a file alone while the printer is printing it.

+ 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"))
             self.state.print_options.filament_tangle_detect = bool(xcam_data.get("filament_tangle_detect"))
 
 
     @staticmethod
     @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.
         """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:
         The MQTT mapping field is an array of snow-encoded values:
         each entry = ams_hw_id * 256 + slot_id (65535 = unmapped).
         each entry = ams_hw_id * 256 + slot_id (65535 = unmapped).
 
 
         Finds entries where the local slot matches, then computes the global tray ID.
         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.
         Returns the global ID if exactly one AMS matches, or None if ambiguous/unavailable.
         """
         """
         if not isinstance(mapping_raw, list) or not mapping_raw:
         if not isinstance(mapping_raw, list) or not mapping_raw:
             return None
             return None
 
 
-        candidates: set[int] = set()
+        candidates: dict[int, int] = {}  # global tray ID -> AMS unit
         for value in mapping_raw:
         for value in mapping_raw:
             if not isinstance(value, int) or value >= 65535:
             if not isinstance(value, int) or value >= 65535:
                 continue
                 continue
             ams_hw_id = value >> 8
             ams_hw_id = value >> 8
             slot = value & 0xFF
             slot = value & 0xFF
             if 0 <= ams_hw_id <= 3 and (slot & 0x03) == local_slot:
             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:
             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:
         if len(candidates) == 1:
-            return candidates.pop()
+            return next(iter(candidates))
         return None
         return None
 
 
     def _maybe_trigger_external_spool_change(self):
     def _maybe_trigger_external_spool_change(self):
@@ -3197,7 +3203,26 @@ class BambuMQTTClient:
                                     except ValueError:
                                     except ValueError:
                                         pass  # Skip AMS IDs that aren't valid integers
                                         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
                                 # Single AMS on this extruder - unambiguous
                                 active_ams_id = ams_on_extruder[0]
                                 active_ams_id = ams_on_extruder[0]
                                 if 128 <= active_ams_id <= 135:
                                 if 128 <= active_ams_id <= 135:
@@ -3244,19 +3269,24 @@ class BambuMQTTClient:
                                         )
                                         )
                                         self.state.tray_now = resolved
                                         self.state.tray_now = resolved
                                     else:
                                     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:
                             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"[{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:
                 elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # Single-nozzle printer with tray_now in 0-3 range.
                     # Single-nozzle printer with tray_now in 0-3 range.
                     # #1822: H2S firmware reports tray_now as the AMS's idle
                     # #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
         # AMS-HT id=128: snow = 128*256 + 0 = 32768
         assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128
         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
 # 3. H2D Pro — initial state detection
@@ -2843,10 +2860,16 @@ class TestTrayNowDualNozzleH2DSetup:
         }
         }
         mqtt_client._process_message(payload)
         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._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
         assert mqtt_client.state.tray_now == 2
 
 
 
 
@@ -3062,12 +3085,21 @@ class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
         h2d_client._process_message(_ams_payload(1))
         h2d_client._process_message(_ams_payload(1))
         assert h2d_client.state.tray_now == 5
         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
         # All AMS on left extruder, but right is active
         h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
         h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
+        h2d_client.state.tray_now = 255
         h2d_client._process_message(_ams_payload(2))
         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):
     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)."""
         """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
         h2d_client.state.tray_now = 255
         # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
         # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous
         h2d_client._process_message(_ams_payload(3))
         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)]
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------