Sfoglia il codice sorgente

Fix H2D AMS units displayed on wrong nozzle (#659)

  Three interrelated bugs in AMS info field parsing caused AMS units
  to appear on the wrong nozzle on H2D dual-nozzle printers:

  1. Info field parsed as decimal instead of hex (BambuStudio uses
     std::stoull with base 16)
  2. Extruder ID extracted as 1-bit instead of 4-bit field (bits 8-11)
  3. Partial MQTT updates replaced the full extruder map instead of
     merging into it
maziggy 2 mesi fa
parent
commit
68d2230cab

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Ethernet Badge Always Shown on Printer Cards** — The printer card network badge always showed "Ethernet" instead of the WiFi signal indicator, even on printers without an ethernet port. The `home_flag` bit 18 was incorrectly interpreted as indicating a wired connection. Removed the faulty ethernet detection; the WiFi signal badge now displays correctly when the printer reports signal strength.
 - **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing "Bambu Cloud login required" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The "Cloud Profiles" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.
 - **GitHub Backup Log Timestamps Off by 1 Hour** — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local `formatDateTime` function didn't use `parseUTCDate`, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared `parseUTCDate` utility for correct UTC-to-local conversion.
+- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS `info` field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses `std::stoull(str, nullptr, 16)`), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the `info` field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (`0xE`), and merges partial updates into the existing map. Reported by @cadtoolbox.
 
 ## [0.2.2b3] - Unreleased
 

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

@@ -152,7 +152,7 @@ class PrinterState:
     mc_print_sub_stage: int = 0
     # AMS mapping for dual nozzle: which slot is active (from ams.ams_exist_bits/tray_exist_bits)
     ams_mapping: list = field(default_factory=list)
-    # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
+    # Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy
     ams_extruder_map: dict = field(default_factory=dict)
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
@@ -1431,29 +1431,35 @@ class BambuMQTTClient:
         logger.debug("[%s] Merged AMS data: %s new units, %s total", self.serial_number, len(ams_list), len(merged_ams))
 
         # Extract ams_extruder_map from each AMS unit's info field
-        # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
-
-        ams_extruder_map = {}
-        for ams_unit in ams_list:
+        # BambuStudio DevFilaSystem.cpp parses info as hex string:
+        #   type_id    = get_flag_bits(info, 0, 4)   // bits 0-3: AMS type
+        #   extruder_id = get_flag_bits(info, 8, 4)  // bits 8-11: extruder assignment
+        # where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.
+        # extruder_id: 0=right/main, 1=left/deputy, 0xE=uninitialized (skip)
+        #
+        # Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting
+        # the full map. Merge into existing map to preserve entries from prior updates.
+
+        ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
+        for ams_unit in merged_ams:
             ams_id = ams_unit.get("id")
             info = ams_unit.get("info")
             if ams_id is not None and info is not None:
                 try:
-                    info_val = int(info) if isinstance(info, str) else info
-                    # Extract bit 8 for extruder assignment
-                    # Bit 8 = 0 means LEFT extruder (id 1), bit 8 = 1 means RIGHT extruder (id 0)
-                    # So we invert: extruder_id = 1 - bit8
-                    bit8 = (info_val >> 8) & 0x1
-                    extruder_id = 1 - bit8  # 0=right, 1=left
+                    # info is a hex-encoded string in MQTT JSON (e.g. "10001003")
+                    info_val = int(str(info), 16)
+                    # Extract 4 bits starting at bit 8 for extruder assignment
+                    extruder_id = (info_val >> 8) & 0xF
+                    if extruder_id == 0xE:
+                        # 0xE = uninitialized AMS, skip
+                        continue
                     ams_extruder_map[str(ams_id)] = extruder_id
-                    logger.debug(
-                        f"[{self.serial_number}] AMS {ams_id} info={info_val} (bit8={bit8}) -> extruder {extruder_id}"
-                    )
+                    logger.debug(f"[{self.serial_number}] AMS {ams_id} info=0x{info} -> extruder {extruder_id}")
                 except (ValueError, TypeError):
                     pass  # Skip AMS units with unparseable info bitmask values
         if ams_extruder_map:
             self.state.raw_data["ams_extruder_map"] = ams_extruder_map
-            self.state.ams_extruder_map = ams_extruder_map  # Also set on state for inference logic
+            self.state.ams_extruder_map = ams_extruder_map
             logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
 
         # Create a hash of relevant AMS data to detect changes

+ 81 - 8
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1550,10 +1550,11 @@ class TestTrayNowDualNozzleH2DSetup:
         assert mqtt_client._is_dual_nozzle is True
 
     def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
-        """AMS 0 info=2003 → right (ext 0), AMS 128 info=2104 → left (ext 1)."""
+        """AMS info field is hex: 0x2003 → ext 0 (right), 0x2104 → ext 1 (left)."""
+        # MQTT sends info as string; BambuStudio parses as hex via stoull(str, 16)
         ams_units = [
-            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
-            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+            {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": "2104", "tray": [{"id": 0}]},
         ]
         payload = {
             "print": {
@@ -1566,8 +1567,80 @@ class TestTrayNowDualNozzleH2DSetup:
         }
         mqtt_client._process_message(payload)
 
-        # info=2003: bit8 = (2003>>8)&1 = 7&1 = 1 → extruder = 1-1 = 0 (right)
-        # info=2104: bit8 = (2104>>8)&1 = 8&1 = 0 → extruder = 1-0 = 1 (left)
+        # 0x2003: bits 8-11 = (0x2003 >> 8) & 0xF = 0x20 & 0xF = 0 → extruder 0 (right)
+        # 0x2104: bits 8-11 = (0x2104 >> 8) & 0xF = 0x21 & 0xF = 1 → extruder 1 (left)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_ams_extruder_map_real_h2d_values(self, mqtt_client):
+        """Real H2D MQTT values: AMS2 Pro on right, AMS-HT on left."""
+        ams_units = [
+            {"id": 0, "info": "10001003", "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": "10002104", "tray": [{"id": 0}]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000a",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # 0x10001003: bits 8-11 = (0x10001003 >> 8) & 0xF = 0x10 & 0xF = 0 → right
+        # 0x10002104: bits 8-11 = (0x10002104 >> 8) & 0xF = 0x21 & 0xF = 1 → left
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_ams_extruder_map_skips_uninitialized(self, mqtt_client):
+        """extruder_id 0xE means uninitialized AMS — should be skipped."""
+        ams_units = [
+            {"id": 0, "info": "e03", "tray": [{"id": i} for i in range(4)]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+        assert mqtt_client.state.ams_extruder_map == {}
+
+    def test_ams_extruder_map_partial_update_preserves_entries(self, mqtt_client):
+        """Partial MQTT update with one AMS should not overwrite other entries."""
+        # First: full update with both AMS units
+        full_payload = {
+            "print": {
+                "ams": {
+                    "ams": [
+                        {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
+                        {"id": 128, "info": "2104", "tray": [{"id": 0}]},
+                    ],
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(full_payload)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+        # Then: partial update with only AMS 0 (no info field this time)
+        partial_payload = {
+            "print": {
+                "ams": {
+                    "ams": [
+                        {"id": 0, "tray": [{"id": 0, "remain": 50}]},
+                    ],
+                    "tray_now": "0",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(partial_payload)
+        # Both entries should still be present
         assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
 
     def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
@@ -1588,7 +1661,7 @@ class TestTrayNowDualNozzleH2DSetup:
                 },
                 "ams": {
                     "ams": [
-                        {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                        {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
                     ],
                     "tray_now": "2",
                     "tray_exist_bits": "f",
@@ -1639,8 +1712,8 @@ class _H2DFixtureMixin:
                     },
                     "ams": {
                         "ams": [
-                            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
-                            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+                            {"id": 0, "info": "2003", "tray": [{"id": i} for i in range(4)]},
+                            {"id": 128, "info": "2104", "tray": [{"id": 0}]},
                         ],
                         "tray_now": "255",
                         "tray_exist_bits": "1000f",