diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f56108e5..56dcdcc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ All notable changes to Bambuddy will be documented in this file. - Bulk delete for multiple files at once ### 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 - **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 diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 49728df7c..f4063afbe 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -934,9 +934,25 @@ class BambuMQTTClient: # Merge: start with existing, update with new non-empty values merged_tray = existing_trays[tray_id].copy() 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, "", "0000000000000000", @@ -952,6 +968,48 @@ class BambuMQTTClient: # Convert back to list, sorted by ID for consistent ordering 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 # Update timestamp for RFID refresh detection (frontend can detect "new data arrived") diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index e529086f5..50e41bf44 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/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["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"