فهرست منبع

fix(mqtt): close #1822 — promote H2S tray_now to 254 on all-external prints

  H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
  external-spool prints instead of 254 like X1C/P1S/A1 do, so the
  single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
  0 — UI highlighted AMS SLOT 1 instead of the external spool.
  Usage credit was unaffected (#1276 covers that via ams_mapping).

  The single-nozzle branch now checks _captured_ams_mapping (slicer-
  captured per-filament mapping that the request-topic intercept
  already tracks) before the existing P2S multi-AMS resolver. When
  every entry is -1, the print uses ONLY the external spool, so
  state.tray_now is promoted to 254.

  Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
  overridden — we have no evidence H2S misreports mid-print swaps, and
  trusting the firmware preserves correctness for users with multi-
  filament setups. No-mapping prints (printer-screen start) fall
  through unchanged.
maziggy 2 ماه پیش
والد
کامیت
4b0150b0ec
3فایلهای تغییر یافته به همراه131 افزوده شده و 27 حذف شده
  1. 0 0
      CHANGELOG.md
  2. 47 27
      backend/app/services/bambu_mqtt.py
  3. 84 0
      backend/tests/unit/services/test_bambu_mqtt.py

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


+ 47 - 27
backend/app/services/bambu_mqtt.py

@@ -1766,35 +1766,55 @@ class BambuMQTTClient:
                                 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.
-                    # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
-                    # in tray_now, not global tray IDs (#420). Use the MQTT mapping field
-                    # (snow-encoded) to resolve the correct AMS unit.
-                    ams_exist_raw = ams_data.get("ams_exist_bits", "0")
-                    try:
-                        ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
-                    except (ValueError, TypeError):
-                        ams_exist = 0
-                    num_ams = bin(ams_exist).count("1")
-
-                    if num_ams > 1:
-                        # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
-                        # Cross-reference with MQTT mapping field to find the correct AMS unit.
-                        mapping_raw = self.state.raw_data.get("mapping")
-                        resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
-                        if resolved is not None:
-                            if resolved != parsed_tray_now:
-                                logger.debug(
-                                    f"[{self.serial_number}] Multi-AMS tray_now: "
-                                    f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
-                                )
-                            self.state.tray_now = resolved
+                    # #1822: H2S firmware reports tray_now as the AMS's idle
+                    # slot (typically 0) when the active feed is actually the
+                    # external spool. X1C / P1S / A1 correctly report 254 in
+                    # that case; H2S does not. When the slicer-captured
+                    # ams_mapping is all-external (every entry == -1), the
+                    # print can only be feeding from the external spool, so
+                    # promote tray_now to 254. Mixed (e.g. [5, -1]) and
+                    # AMS-only mappings are NOT overridden — there's no
+                    # evidence the firmware misreports in those cases. Prints
+                    # started without a captured mapping (printer-screen start,
+                    # or before Bambuddy connected) fall through unchanged.
+                    captured = self._captured_ams_mapping
+                    if captured and all(s == -1 for s in captured):
+                        if self.state.tray_now != 254:
+                            logger.debug(
+                                f"[{self.serial_number}] tray_now external-spool override (#1822): "
+                                f"slot {parsed_tray_now} -> 254 (ams_mapping={captured})"
+                            )
+                        self.state.tray_now = 254
+                    else:
+                        # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
+                        # in tray_now, not global tray IDs (#420). Use the MQTT mapping field
+                        # (snow-encoded) to resolve the correct AMS unit.
+                        ams_exist_raw = ams_data.get("ams_exist_bits", "0")
+                        try:
+                            ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
+                        except (ValueError, TypeError):
+                            ams_exist = 0
+                        num_ams = bin(ams_exist).count("1")
+
+                        if num_ams > 1:
+                            # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
+                            # Cross-reference with MQTT mapping field to find the correct AMS unit.
+                            mapping_raw = self.state.raw_data.get("mapping")
+                            resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
+                            if resolved is not None:
+                                if resolved != parsed_tray_now:
+                                    logger.debug(
+                                        f"[{self.serial_number}] Multi-AMS tray_now: "
+                                        f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
+                                    )
+                                self.state.tray_now = resolved
+                            else:
+                                # No mapping available (not printing, or ambiguous) — use as-is.
+                                # This matches the old behavior and is correct for AMS 0.
+                                self.state.tray_now = parsed_tray_now
                         else:
-                            # No mapping available (not printing, or ambiguous) — use as-is.
-                            # This matches the old behavior and is correct for AMS 0.
+                            # Single AMS — local slot 0-3 equals global ID
                             self.state.tray_now = parsed_tray_now
-                    else:
-                        # Single AMS — local slot 0-3 equals global ID
-                        self.state.tray_now = parsed_tray_now
                 else:
                     # tray_now > 3 means it's already a global ID, or 255 means unloaded
                     # Note: Do NOT clear pending_tray_target on tray_now=255 here.

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

@@ -5884,3 +5884,87 @@ class TestAmsFilamentBackupHoldTimer:
         assert mqtt_client.state.ams_filament_backup is True
         # Hold timer still armed — sub-second push didn't reset it.
         assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold
+
+
+# ---------------------------------------------------------------------------
+# 2c. Single-nozzle H2S — external-spool tray_now override (#1822)
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowH2SExternalSpoolOverride:
+    """H2S firmware reports tray_now as the AMS's idle slot (typically 0)
+    instead of 254 when the active feed is the external spool.
+
+    Bambuddy detects the all-external case via the slicer-captured
+    ams_mapping (every entry == -1) and promotes tray_now to 254 so the
+    UI active-tray highlight matches the real feed.
+
+    The override is intentionally narrow:
+      * only fires when ams_mapping is captured AND every entry is -1
+      * does not touch mixed prints ([5, -1]) or AMS-only prints ([5])
+      * does not fire when no ams_mapping is captured (printer-screen start)
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2S",
+            access_code="12345678",
+        )
+
+    def test_all_external_mapping_promotes_tray_now_to_254(self, mqtt_client):
+        """Reporter's scenario: H2S, single nozzle, captured ams_mapping=[-1],
+        firmware sends tray_now=0 -> Bambuddy promotes to 254."""
+        mqtt_client._captured_ams_mapping = [-1]
+        mqtt_client._process_message(_ams_payload(0))
+        assert mqtt_client.state.tray_now == 254
+
+    def test_multi_external_mapping_also_promotes(self, mqtt_client):
+        """Multi-filament print, every filament mapped to external. Still
+        all-external -> still promotes."""
+        mqtt_client._captured_ams_mapping = [-1, -1, -1]
+        mqtt_client._process_message(_ams_payload(0))
+        assert mqtt_client.state.tray_now == 254
+
+    def test_ams_only_mapping_does_not_override(self, mqtt_client):
+        """ams_mapping=[5] (AMS slot 5 only) -> firmware value trusted as-is.
+        Without the all-external guard, this would falsely override real
+        AMS-slot prints."""
+        mqtt_client._captured_ams_mapping = [5]
+        mqtt_client._process_message(_ams_payload(0))
+        assert mqtt_client.state.tray_now == 0
+
+    def test_mixed_mapping_does_not_override(self, mqtt_client):
+        """Mixed mapping (AMS slot 5 + external): we have no evidence the
+        firmware misreports mid-print swaps, so leave tray_now alone."""
+        mqtt_client._captured_ams_mapping = [5, -1]
+        mqtt_client._process_message(_ams_payload(0))
+        assert mqtt_client.state.tray_now == 0
+
+    def test_no_captured_mapping_does_not_override(self, mqtt_client):
+        """Prints started from the printer screen (or before Bambuddy
+        connected) have no captured ams_mapping. Behaviour unchanged from
+        pre-#1822 — we accept the wrong value rather than guess."""
+        mqtt_client._captured_ams_mapping = None
+        mqtt_client._process_message(_ams_payload(0))
+        assert mqtt_client.state.tray_now == 0
+
+    def test_empty_captured_mapping_does_not_override(self, mqtt_client):
+        """Empty list (defensive — should not happen in practice but
+        all([]) returns True). Treat as no signal, not as all-external."""
+        mqtt_client._captured_ams_mapping = []
+        mqtt_client._process_message(_ams_payload(0))
+        assert mqtt_client.state.tray_now == 0
+
+    def test_unloaded_after_print_still_resolves_correctly(self, mqtt_client):
+        """When the firmware unloads (tray_now=255), the override is skipped
+        because the branch only fires for tray_now in 0-3."""
+        mqtt_client._captured_ams_mapping = [-1]
+        mqtt_client._process_message(_ams_payload(0))
+        assert mqtt_client.state.tray_now == 254
+
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255

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