From d0efb9db9ee1b184bb8eb57813034513c38e9bef Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 29 Jul 2026 08:40:42 +0200 Subject: [PATCH] fix(vp): relay A2L AMS filament to the slicer instead of blanking every slot Every slot of the A2L's AMS Lite rendered as "?" in Bambu Studio through the Virtual Printer while Bambuddy's own AMS card was correct, and a filament set by hand in Studio reverted about a second later. The A2L reports its AMS Lite as physical unit id 16 but packs the slot presence bits at base 24, so bambu_mqtt normalises the id to 6 at the ingest boundary and every internal reader gets the right bits. The VP bridge is not downstream of that: BambuMQTTClient._on_message fans raw payload bytes out to raw-message handlers before parsing, so mqtt_bridge._on_printer_raw does its own json.loads and still holds id 16. It then called the shared apply_tray_exist_bits, which computed 16*4 = bits 64-67 -- never set -- concluded all four slots were empty, and wiped tray_type / tray_color / tray_info_idx / tag_uid / tray_uuid / remain from the copy sent to the slicer. That runs on every push, which is why a manual pick could not survive the next 1 Hz cached-as-base report. apply_tray_exist_bits now folds the unit id through normalize_am_unit_id, so 16 and 6 land on the same bit base whichever id the caller holds. The bridge's cached ids stay physical on purpose -- Studio addresses the Lite as 16, sending ams_get_rfid {ams_id: 16} through the VP -- so normalising the cache instead would have broken the slicer's own command path. Confirmed from the reporter's debug log, which shows the cleanup clearing slots at bits 64-67 under the VP's log label. Before #2670 added the 0 <= ams_id <= 15 range guard this wiped the slots; after it, unit 16 fell out of the guard and the A2L got no empty-slot cleanup at all -- two different wrong answers, both fixed here. --- backend/app/services/bambu_mqtt.py | 15 +++- .../services/virtual_printer/mqtt_bridge.py | 6 ++ backend/tests/unit/test_a2l_ams_lite_2619.py | 66 +++++++++++++++++ backend/tests/unit/test_vp_mqtt_bridge.py | 71 +++++++++++++++++++ 4 files changed, 155 insertions(+), 3 deletions(-) diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 6b9862a47..02224b3a6 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -147,9 +147,11 @@ def apply_tray_exist_bits( the HT keeps echoing stale ``tray_type`` and its ``state`` is firmware-variant (#2670). Verified against OrcaSlicer ``DevFilaSystem.cpp`` (``is_exists = tray_exist_bits >> (16 + (ams_id-128))``) and a live H2D - capture (HT-A → bit 16). The A2L-Lite (normalised to id 6 upstream) lands at - bits 24-27 via the regular ``ams_id * 4`` formula, matching OrcaSlicer's - ``AMS_LITE_MIXED`` offset, so it needs no special case here. + capture (HT-A → bit 16). The A2L-Lite lands at bits 24-27 via the regular + ``ams_id * 4`` formula, matching OrcaSlicer's ``AMS_LITE_MIXED`` offset; the + unit id is folded through ``normalize_am_unit_id`` first so callers holding + the raw physical id 16 get the same bit base as callers holding the + normalised 6 (#2697). `tray_exist_bits_str` is expected as a hex string (firmware sends it that way). Ints are tolerated for defensive symmetry but typically not seen @@ -192,6 +194,13 @@ def apply_tray_exist_bits( continue if not isinstance(ams_id, int): continue + # The A2L AMS-Lite reaches this helper under either id: `_handle_ams_data` + # normalises 16 -> 6 before calling, but the VP bridge parses the raw + # printer payload itself (`mqtt_bridge._on_printer_raw`) and still holds + # the physical 16. Both mean bit base 24, so fold them together here + # rather than relying on every caller to normalise first — reading 16 as + # 16*4 = bit 64 finds nothing set and wipes every A2L slot (#2697). + ams_id = normalize_am_unit_id(ams_id) # AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128). # Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id. # Anything outside those ranges has no known bit layout — don't guess it. diff --git a/backend/app/services/virtual_printer/mqtt_bridge.py b/backend/app/services/virtual_printer/mqtt_bridge.py index 7a378ac97..bf000bf7a 100644 --- a/backend/app/services/virtual_printer/mqtt_bridge.py +++ b/backend/app/services/virtual_printer/mqtt_bridge.py @@ -659,6 +659,12 @@ class MQTTBridge: # paints those empty slots as phantom loaded filaments (#1726). # Runs whether or not a prev cache existed — fresh pushalls also # carry tray_exist_bits and benefit from the cleanup. + # These units carry the RAW firmware ids — this cache is what the + # slicer sees, and BambuStudio addresses the A2L's AMS-Lite as the + # physical id 16 (it sends `ams_get_rfid {ams_id: 16}` through the + # VP), so we must not normalise them to 6 the way Bambuddy's + # internal state does. `apply_tray_exist_bits` folds 16 onto the + # same bit base internally instead (#2697). merged_ams_dict = new_state.get("ams") if isinstance(merged_ams_dict, dict): units = merged_ams_dict.get("ams") diff --git a/backend/tests/unit/test_a2l_ams_lite_2619.py b/backend/tests/unit/test_a2l_ams_lite_2619.py index c4e5e16f6..7f4857da0 100644 --- a/backend/tests/unit/test_a2l_ams_lite_2619.py +++ b/backend/tests/unit/test_a2l_ams_lite_2619.py @@ -20,6 +20,7 @@ from backend.app.services.bambu_mqtt import ( A2L_LITE_PHYSICAL_AMS_ID, BambuMQTTClient, a2l_lite_wire_ids, + apply_tray_exist_bits, normalize_am_unit_id, ) @@ -142,6 +143,71 @@ class TestTrayNowGlobalisation: assert client.state.last_loaded_tray == 26 +class TestTrayExistBitsBitBase: + """#2697: ``apply_tray_exist_bits`` is reached with BOTH ids. + + ``_handle_ams_data`` normalises 16 -> 6 before calling it, but the VP + bridge parses the raw printer payload itself and still holds the physical + 16. Reading 16 as ``16 * 4`` lands on bits 64-67, where nothing is ever + set, so every A2L slot was wiped in the slicer-facing cache. Both ids must + resolve to bit base 24. + """ + + # Reporter's capture: bits 24, 25, 26 set -> slots 0/1/2 loaded, slot 3 empty. + BITS = "7000000" + + def _units(self, ams_id): + return [ + { + "id": ams_id, + "tray": [ + { + "id": str(i), + "state": 3, + "tray_type": "PLA", + "tray_color": "C12E1FFF", + "tray_info_idx": "GFA00", + "remain": 100, + } + for i in range(4) + ], + } + ] + + def test_physical_id_16_uses_bit_base_24(self): + units = self._units(A2L_LITE_PHYSICAL_AMS_ID) + cleared = apply_tray_exist_bits(units, self.BITS) + trays = units[0]["tray"] + # Slots 0-2 are loaded and must survive untouched. + for slot in range(3): + assert trays[slot]["tray_type"] == "PLA", f"slot {slot} wrongly cleared" + assert trays[slot]["state"] == 3 + # Only the genuinely empty slot 3 is cleared. + assert cleared == 1 + assert trays[3]["state"] == 9 + assert trays[3]["tray_type"] == "" + + def test_normalised_id_6_matches_physical_id_16(self): + physical = self._units(A2L_LITE_PHYSICAL_AMS_ID) + normalised = self._units(A2L_LITE_NORMALIZED_AMS_ID) + apply_tray_exist_bits(physical, self.BITS) + apply_tray_exist_bits(normalised, self.BITS) + assert physical[0]["tray"] == normalised[0]["tray"] + + def test_exists_annotation_matches_physical_slots(self): + units = self._units(A2L_LITE_PHYSICAL_AMS_ID) + apply_tray_exist_bits(units, self.BITS, annotate_exists=True) + assert [t["exists"] for t in units[0]["tray"]] == [True, True, True, False] + + def test_regular_ams_unchanged(self): + # id 0 still reads bits 0-3 — the fold must not touch any other unit. + units = self._units(0) + apply_tray_exist_bits(units, "e") # bits 1,2,3 + trays = units[0]["tray"] + assert trays[0]["state"] == 9 + assert [t["tray_type"] for t in trays] == ["", "PLA", "PLA", "PLA"] + + class TestOutboundTranslation: def test_set_filament_setting_uses_physical_16_local_slot(self): client = _wired(_client()) diff --git a/backend/tests/unit/test_vp_mqtt_bridge.py b/backend/tests/unit/test_vp_mqtt_bridge.py index 8d2ff633f..4849a524a 100644 --- a/backend/tests/unit/test_vp_mqtt_bridge.py +++ b/backend/tests/unit/test_vp_mqtt_bridge.py @@ -783,6 +783,77 @@ class TestPushStatusCache: await bridge.stop() + @pytest.mark.asyncio + async def test_a2l_ams_lite_slots_survive_in_slicer_cache(self): + """#2697 (reported by @qoatzelcoat): every A2L slot rendered as "?" in + BambuStudio through the VP, while Bambuddy's own AMS card was correct. + + The A2L reports its AMS Lite as physical unit id 16 but packs the + presence bits at base 24. Bambuddy's internal path normalises 16 -> 6 + before the cleanup runs, so it read the right bits; the bridge parses + the raw printer payload itself and still held 16, so the cleanup read + bits 64-67 — never set — and wiped all four slots in the cache the + slicer reads. A slicer-side filament pick reverted on the next 1 Hz + push for the same reason. + + The cached units must keep the physical id 16: BambuStudio addresses + the Lite as 16 (it sends `ams_get_rfid {ams_id: 16}` through the VP). + """ + server = _make_server() + bridge = _make_bridge(server) + await bridge.start() + + # Reporter's capture: tray_exist_bits 0x7000000 = bits 24/25/26 → + # slots 0, 1, 2 loaded, slot 3 empty. + bridge._on_printer_raw( + f"device/{H2D_SERIAL}/report", + json.dumps( + { + "print": { + "command": "push_status", + "ams": { + "ams": [ + { + "id": "16", + "tray": [ + { + "id": "0", + "state": 3, + "tray_type": "PLA", + "tray_sub_brands": "PLA Basic", + "tray_color": "C12E1FFF", + "tray_info_idx": "GFA00", + "remain": 100, + }, + {"id": "1", "state": 3, "tray_type": "PETG", "tray_info_idx": "GFG00"}, + {"id": "2", "state": 3, "tray_type": "ABS", "tray_info_idx": "GFB00"}, + {"id": "3", "state": 3, "tray_type": "TPU", "tray_info_idx": "GFU00"}, + ], + } + ], + "tray_exist_bits": "7000000", + }, + } + } + ).encode(), + ) + await asyncio.sleep(0.01) + + cached = bridge.get_latest_print_state() + unit = cached["ams"]["ams"][0] + # The slicer-facing cache keeps the PHYSICAL id — BambuStudio speaks 16. + assert unit["id"] == "16" + trays = unit["tray"] + assert trays[0]["tray_type"] == "PLA", "loaded slot wrongly cleared (bit base 64 regression)" + assert trays[1]["tray_type"] == "PETG" + assert trays[2]["tray_type"] == "ABS" + assert trays[0]["tray_info_idx"] == "GFA00" + # Slot 3 is genuinely empty and still gets the normal cleanup. + assert trays[3]["state"] == 9 + assert trays[3]["tray_type"] == "" + + await bridge.stop() + @pytest.mark.asyncio async def test_tray_exist_bits_shutdown_guard_preserves_cache(self): """#765 shutdown guard mirrored at the bridge: when the printer