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
This commit is contained in:
maziggy
2026-01-25 07:07:36 +01:00
parent 6bc1ce8693
commit 4266049e0c
3 changed files with 245 additions and 3 deletions
+3
View File
@@ -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
+61 -3
View File
@@ -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")
@@ -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"