diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7151ff0..85e2405bc 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index ab6bf0470..dd266d8a0 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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: diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 99750b0bf..0fe065a15 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/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."""