Просмотр исходного кода

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.
maziggy 1 месяц назад
Родитель
Сommit
d0efb9db9e

+ 12 - 3
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.

+ 6 - 0
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")

+ 66 - 0
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())

+ 71 - 0
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