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