Browse Source

Fix empty AMS slot not recognized (Issue #147)

When spools were removed from AMS slots, Bambuddy continued showing them
as loaded. Fixed for both old and new AMS models:

- Old AMS: Allow empty values to overwrite slot data during merge
  (tray_type, tray_color, tag_uid, etc. were being skipped)
- New AMS (AMS 2 Pro): Parse tray_exist_bits bitmask to detect empty
  slots and clear their data accordingly

Added 3 unit tests for AMS data merging behavior.

Closes #147
maziggy 4 months ago
parent
commit
4266049e0c
3 changed files with 245 additions and 3 deletions
  1. 3 0
      CHANGELOG.md
  2. 61 3
      backend/app/services/bambu_mqtt.py
  3. 181 0
      backend/tests/unit/services/test_bambu_mqtt.py

+ 3 - 0
CHANGELOG.md

@@ -25,6 +25,9 @@ All notable changes to Bambuddy will be documented in this file.
   - Bulk delete for multiple files at once
   - Bulk delete for multiple files at once
 
 
 ### Fixes
 ### Fixes
+- **Empty AMS Slot Not Recognized** - Fixed bug where removed spools still appeared in Bambuddy (Issue #147):
+  - Old AMS: Now properly applies empty values from tray data updates
+  - New AMS (AMS 2 Pro): Now checks `tray_exist_bits` bitmask to detect and clear empty slots
 - **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
 - **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
 - **HA Energy Sensors Not Detected** - Home Assistant energy sensors with lowercase units (w, kwh) are now properly detected; unit matching is now case-insensitive (Issue #119)
 - **HA Energy Sensors Not Detected** - Home Assistant energy sensors with lowercase units (w, kwh) are now properly detected; unit matching is now case-insensitive (Issue #119)
 - **File Manager Upload** - Upload modal now accepts all file types, not just ZIP files
 - **File Manager Upload** - Upload modal now accepts all file types, not just ZIP files

+ 61 - 3
backend/app/services/bambu_mqtt.py

@@ -934,9 +934,25 @@ class BambuMQTTClient:
                             # Merge: start with existing, update with new non-empty values
                             # Merge: start with existing, update with new non-empty values
                             merged_tray = existing_trays[tray_id].copy()
                             merged_tray = existing_trays[tray_id].copy()
                             for key, value in new_tray.items():
                             for key, value in new_tray.items():
-                                # Only overwrite if new value is not empty/None
-                                # Exception: remain/k can be 0, which is valid
-                                if key in ("remain", "k", "id", "cali_idx") or value not in (
+                                # Fields that should always be updated (even with empty/zero values):
+                                # - remain, k, id, cali_idx: status indicators where 0 is valid
+                                # - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
+                                #   tray_color, tray_id_name: slot content indicators that must be
+                                #   cleared when a spool is removed (fixes #147 - old AMS empty slot)
+                                always_update_fields = (
+                                    "remain",
+                                    "k",
+                                    "id",
+                                    "cali_idx",
+                                    "tray_type",
+                                    "tray_sub_brands",
+                                    "tag_uid",
+                                    "tray_uuid",
+                                    "tray_info_idx",
+                                    "tray_color",
+                                    "tray_id_name",
+                                )
+                                if key in always_update_fields or value not in (
                                     None,
                                     None,
                                     "",
                                     "",
                                     "0000000000000000",
                                     "0000000000000000",
@@ -952,6 +968,48 @@ class BambuMQTTClient:
 
 
         # Convert back to list, sorted by ID for consistent ordering
         # Convert back to list, sorted by ID for consistent ordering
         merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get("id", 0))
         merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get("id", 0))
+
+        # Check tray_exist_bits to clear empty slots (Issue #147)
+        # New AMS models don't send empty tray data - they just update tray_exist_bits
+        # Each bit in tray_exist_bits represents a slot: bit=0 means empty, bit=1 means has spool
+        tray_exist_bits_str = ams_data.get("tray_exist_bits") if isinstance(ams_data, dict) else None
+        if tray_exist_bits_str:
+            try:
+                tray_exist_bits = int(tray_exist_bits_str, 16)
+                for ams_unit in merged_ams:
+                    ams_id_raw = ams_unit.get("id")
+                    if ams_id_raw is None:
+                        continue
+                    # Convert to int (may be string from JSON)
+                    ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
+                    if ams_id >= 128:  # Skip HT AMS (id >= 128)
+                        continue
+                    # Bits for this AMS unit: bits (ams_id*4) to (ams_id*4 + 3)
+                    for tray in ams_unit.get("tray", []):
+                        tray_id_raw = tray.get("id")
+                        if tray_id_raw is None:
+                            continue
+                        # Convert to int (may be string from JSON)
+                        tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
+                        global_bit = ams_id * 4 + tray_id
+                        slot_exists = (tray_exist_bits >> global_bit) & 1
+                        if not slot_exists and tray.get("tray_type"):
+                            # Slot is marked empty but has data - clear it
+                            logger.info(
+                                f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
+                                f"(tray_exist_bits bit {global_bit} = 0)"
+                            )
+                            tray["tray_type"] = ""
+                            tray["tray_sub_brands"] = ""
+                            tray["tray_color"] = ""
+                            tray["tray_id_name"] = ""
+                            tray["tag_uid"] = "0000000000000000"
+                            tray["tray_uuid"] = "00000000000000000000000000000000"
+                            tray["tray_info_idx"] = ""
+                            tray["remain"] = 0
+            except (ValueError, TypeError) as e:
+                logger.debug(f"[{self.serial_number}] Could not parse tray_exist_bits: {e}")
+
         self.state.raw_data["ams"] = merged_ams
         self.state.raw_data["ams"] = merged_ams
 
 
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")

+ 181 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -424,3 +424,184 @@ class TestRealisticMessageFlow:
 
 
         assert complete_data["timelapse_was_active"] is True
         assert complete_data["timelapse_was_active"] is True
         assert complete_data["status"] == "failed"
         assert complete_data["status"] == "failed"
+
+
+class TestAMSDataMerging:
+    """Tests for AMS data merging, particularly handling empty slots."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_empty_slot_clears_tray_type(self, mqtt_client):
+        """Test that empty slot update clears tray_type (Issue #147).
+
+        When a spool is removed from an old AMS, the printer sends empty values.
+        These must overwrite the previous values to show the slot as empty.
+        """
+        # Initial state: AMS unit with a loaded spool
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_sub_brands": "Bambu PLA Basic",
+                            "tray_color": "FF0000",
+                            "tag_uid": "1234567890ABCDEF",
+                            "remain": 80,
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Verify initial state
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        assert len(ams_data) == 1
+        tray = ams_data[0]["tray"][0]
+        assert tray["tray_type"] == "PLA"
+        assert tray["tray_color"] == "FF0000"
+
+        # Now simulate spool removal - printer sends empty values
+        empty_update = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "",  # Empty = slot is empty
+                            "tray_sub_brands": "",
+                            "tray_color": "",
+                            "tag_uid": "0000000000000000",  # Zero UID
+                            "remain": 0,
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(empty_update)
+
+        # Verify empty values were applied (not ignored by merge logic)
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        tray = ams_data[0]["tray"][0]
+        assert tray["tray_type"] == "", "tray_type should be cleared when slot is empty"
+        assert tray["tray_color"] == "", "tray_color should be cleared when slot is empty"
+        assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
+        assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
+
+    def test_partial_update_preserves_other_fields(self, mqtt_client):
+        """Test that partial updates still preserve non-slot-status fields."""
+        # Initial state with full data
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "humidity": "3",
+                    "temp": "25.5",
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "00FF00",
+                            "remain": 90,
+                            "k": 0.02,
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Partial update - only remain changes
+        partial_update = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "remain": 85,  # Only this changed
+                        }
+                    ],
+                }
+            ]
+        }
+        mqtt_client._handle_ams_data(partial_update)
+
+        # Verify remain was updated but other fields preserved
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        tray = ams_data[0]["tray"][0]
+        assert tray["remain"] == 85, "remain should be updated"
+        assert tray["tray_type"] == "PLA", "tray_type should be preserved"
+        assert tray["tray_color"] == "00FF00", "tray_color should be preserved"
+        assert tray["k"] == 0.02, "k should be preserved"
+
+    def test_tray_exist_bits_clears_empty_slots(self, mqtt_client):
+        """Test that tray_exist_bits clears slots marked as empty (Issue #147).
+
+        New AMS models (AMS 2 Pro) don't send empty tray data when a spool is removed.
+        Instead, they update tray_exist_bits to indicate which slots have spools.
+        """
+        # Initial state: AMS 0 and AMS 1 with loaded spools
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
+                        {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
+                        {"id": 2, "tray_type": "ABS", "tray_color": "0000FF", "remain": 40},
+                        {"id": 3, "tray_type": "TPU", "tray_color": "FFFF00", "remain": 20},
+                    ],
+                },
+                {
+                    "id": 1,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFF", "remain": 90},
+                        {"id": 1, "tray_type": "PLA", "tray_color": "000000", "remain": 70},
+                        {"id": 2, "tray_type": "PLA", "tray_color": "FF00FF", "remain": 50},
+                        {"id": 3, "tray_type": "PLA", "tray_color": "00FFFF", "remain": 30},
+                    ],
+                },
+            ],
+            "tray_exist_bits": "ff",  # All 8 slots have spools (0xFF = 11111111)
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Verify initial state
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        assert ams_data[1]["tray"][3]["tray_type"] == "PLA"  # AMS 1 slot 3 (B4) has spool
+
+        # Now simulate spool removal from AMS 1 slot 3 (B4)
+        # tray_exist_bits: 0x7f = 01111111 (bit 7 = 0 means AMS 1 slot 3 is empty)
+        update_ams = {
+            "ams": [
+                {"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
+                {"id": 1, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]},
+            ],
+            "tray_exist_bits": "7f",  # Bit 7 = 0 -> AMS 1 slot 3 is empty
+        }
+        mqtt_client._handle_ams_data(update_ams)
+
+        # Verify AMS 1 slot 3 was cleared
+        ams_data = mqtt_client.state.raw_data.get("ams", [])
+        b4_tray = ams_data[1]["tray"][3]
+        assert b4_tray["tray_type"] == "", "tray_type should be cleared for empty slot"
+        assert b4_tray["remain"] == 0, "remain should be 0 for empty slot"
+
+        # Verify other slots are preserved
+        assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
+        assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"