Browse Source

[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.
maziggy 2 months ago
parent
commit
488f66310f
3 changed files with 132 additions and 1 deletions
  1. 1 0
      CHANGELOG.md
  2. 4 1
      backend/app/services/bambu_mqtt.py
  3. 127 0
      backend/tests/unit/services/test_bambu_mqtt.py

+ 1 - 0
CHANGELOG.md

@@ -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
backend/app/services/bambu_mqtt.py

@@ -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:

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

@@ -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."""