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
This commit is contained in:
maziggy
2026-03-10 08:18:23 +01:00
parent c5269b5b0c
commit 68d2230cab
3 changed files with 102 additions and 22 deletions
+1
View File
@@ -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
+20 -14
View File
@@ -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
+81 -8
View File
@@ -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",