From 68d2230cab30e8fb0a92b171411d036202b8f3a4 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 10 Mar 2026 08:18:23 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + backend/app/services/bambu_mqtt.py | 34 ++++--- .../tests/unit/services/test_bambu_mqtt.py | 89 +++++++++++++++++-- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb21bdad..6e6a8f6c1 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index f116e5901..1e9c325b2 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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) + # 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 = {} - for ams_unit in ams_list: + 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 diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 3b63cdfa2..242fc3f26 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/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",