mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user