[Fix] Preserve AMS spool assignments across printer restart (#765)

When a printer shuts down it sends a final MQTT message with
  tray_exist_bits=0 and power_on_flag=false. The tray_exist_bits
  clearing code processed this all-zero value, wiping every AMS
  slot's filament data. On reconnect, the auto-unlink check saw
  empty tray data (no color, no type) and deleted all spool
  assignments as "fingerprint mismatch".

  Fix: skip tray_exist_bits slot clearing when power_on_flag is
  false. Defaults to true when absent for backwards compatibility.

  Adds 3 regression tests covering shutdown preservation, genuine
  removal still working, and missing power_on_flag fallback.
This commit is contained in:
maziggy
2026-03-20 16:28:45 +01:00
parent dc4d77b93a
commit 488f66310f
3 changed files with 132 additions and 1 deletions
+1
View File
@@ -27,6 +27,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Virtual Printer Proxy A1 Diagnostics** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — Added diagnostic port probing (ports 21, 80, 443) on proxy VP bind IPs to detect if BambuStudio tries to connect on ports the proxy doesn't handle. Logs a warning when an unexpected connection is detected. Helps diagnose A1/A1 Mini proxy issues where the slicer may use a different connection flow.
- **File Rename Removes Extension** ([#751](https://github.com/maziggy/bambuddy/issues/751)) — Renaming a file in the File Manager included the file extension in the editable text, so users could accidentally remove it (e.g. renaming `bracket.gcode.3mf` to `bracket`), making the file unprintable. The rename modal now only lets users edit the base name, with the extension shown as a non-editable suffix. Reported by @fleishmaab, confirmed by @cadtoolbox.
- **Spurious "Job Waiting for Filament" Notification** ([#753](https://github.com/maziggy/bambuddy/issues/753)) — When all printers of a model were busy and a job was queued with ASAP timing, a "Job Waiting for Filament" notification fired immediately even though no filament issue existed. The job was simply waiting for a printer to finish. The scheduler now skips the waiting notification when all matching printers are just busy, since the job will auto-start when one finishes. Also renamed the default notification title from "Job Waiting for Filament" to "Queue Job Waiting" to accurately reflect all waiting reasons. Reported by @maziggy.
- **AMS Spools Removed After Printer Restart** ([#765](https://github.com/maziggy/bambuddy/issues/765)) — AMS spool assignments and slot configurations were lost after restarting the printer. When the printer shuts down, it sends a final MQTT message with `tray_exist_bits=0` and `power_on_flag=false`, which caused Bambuddy to clear all AMS slot data and auto-unlink every spool assignment. On reconnect, the assignments were gone. Fixed by skipping `tray_exist_bits` slot clearing when `power_on_flag` is `false` (shutdown message), preserving AMS data across printer restarts. Reported by @Woyteck1.
### Added
- **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.
+4 -1
View File
@@ -1399,8 +1399,11 @@ class BambuMQTTClient:
# 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
# Skip when power_on_flag=False: printer shutdown sends all-zero bits which would
# wipe all slot data and cause auto-unlink to remove spool assignments (#765)
tray_exist_bits_str = ams_data.get("tray_exist_bits") if isinstance(ams_data, dict) else None
if tray_exist_bits_str:
power_on = ams_data.get("power_on_flag", True) if isinstance(ams_data, dict) else True
if tray_exist_bits_str and power_on:
try:
tray_exist_bits = int(tray_exist_bits_str, 16)
for ams_unit in merged_ams:
@@ -606,6 +606,133 @@ class TestAMSDataMerging:
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"
def test_shutdown_message_preserves_ams_data(self, mqtt_client):
"""Printer shutdown (power_on_flag=False) must not wipe AMS slot data (#765).
When a printer shuts down it sends a final MQTT message with
tray_exist_bits='0' and power_on_flag=False. This all-zero value
previously caused every slot to be cleared, which then triggered
auto-unlink of all spool assignments on reconnect.
"""
# Initial state: two AMS units with loaded spools
initial_ams = {
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "remain": 80},
{"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF", "remain": 60},
],
},
{
"id": 1,
"tray": [
{"id": 0, "tray_type": "PETG", "tray_color": "DBDDD9FF", "remain": 90},
{"id": 1, "tray_type": "PETG", "tray_color": "67DB25FF", "remain": 70},
],
},
],
"tray_exist_bits": "33", # Slots 0,1 of each AMS (0b00110011)
"power_on_flag": True,
}
mqtt_client._handle_ams_data(initial_ams)
# Verify initial state
ams_data = mqtt_client.state.raw_data["ams"]
assert ams_data[0]["tray"][0]["tray_type"] == "PLA"
assert ams_data[1]["tray"][0]["tray_type"] == "PETG"
# Simulate printer shutdown — all-zero bits with power_on_flag=False
shutdown_ams = {
"ams_exist_bits": "0",
"tray_exist_bits": "0",
"power_on_flag": False,
"insert_flag": False,
"tray_now": "0",
"version": 0,
}
mqtt_client._handle_ams_data(shutdown_ams)
# AMS slot data MUST be preserved — shutdown should not clear it
ams_data = mqtt_client.state.raw_data["ams"]
assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Shutdown must not clear AMS 0 slot 0"
assert ams_data[0]["tray"][0]["tray_color"] == "FF0000FF", "Shutdown must not clear AMS 0 slot 0 color"
assert ams_data[0]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 0 slot 1"
assert ams_data[1]["tray"][0]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 0"
assert ams_data[1]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 1"
def test_genuine_removal_still_clears_with_power_on(self, mqtt_client):
"""Genuine spool removal (power_on_flag=True) must still clear slot data.
Ensures the #765 fix doesn't break normal spool removal detection.
"""
# Initial state: AMS with loaded spool
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},
],
},
],
"tray_exist_bits": "3", # Both slots occupied (0b11)
"power_on_flag": True,
}
mqtt_client._handle_ams_data(initial_ams)
# Spool removed from slot 1 while printer is running
removal_ams = {
"ams": [
{
"id": 0,
"tray": [{"id": 0}, {"id": 1}],
},
],
"tray_exist_bits": "1", # Only slot 0 occupied (0b01)
"power_on_flag": True,
}
mqtt_client._handle_ams_data(removal_ams)
# Slot 0 preserved, slot 1 cleared
ams_data = mqtt_client.state.raw_data["ams"]
assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Slot 0 should be preserved"
assert ams_data[0]["tray"][1]["tray_type"] == "", "Slot 1 should be cleared on removal"
assert ams_data[0]["tray"][1]["tray_color"] == "", "Slot 1 color should be cleared"
def test_power_on_flag_defaults_true_when_absent(self, mqtt_client):
"""When power_on_flag is not in the MQTT data, clearing must proceed normally.
Ensures backwards compatibility with firmware that doesn't send power_on_flag.
"""
# Initial state
initial_ams = {
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
],
},
],
"tray_exist_bits": "1",
}
mqtt_client._handle_ams_data(initial_ams)
# Update WITHOUT power_on_flag — should still clear when bit=0
update_ams = {
"ams": [{"id": 0, "tray": [{"id": 0}]}],
"tray_exist_bits": "0",
# No power_on_flag key at all
}
mqtt_client._handle_ams_data(update_ams)
ams_data = mqtt_client.state.raw_data["ams"]
assert ams_data[0]["tray"][0]["tray_type"] == "", (
"Without power_on_flag, clearing should proceed (defaults to True)"
)
class TestNozzleRackData:
"""Tests for nozzle rack data parsing from H2 series device.nozzle.info."""