diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 53cadeb5a..f39a4892d 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -84,6 +84,20 @@ def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None: return None +def is_printer_status_frame(print_data: dict) -> bool: + """True when a ``print`` payload is the printer reporting its own state. + + Bambu firmware echoes a command's fields back in its acknowledgement, so a + `project_file` ack carries whatever Bambuddy put on the wire — including + the `cfg` bitmask and the per-job `timelapse` flag. Ingesting those as + telemetry means reading our own request back as the printer's state + (#3040). Only `push_status` (and the odd firmware that omits `command` + entirely on a status frame) describes the printer. + """ + command = print_data.get("command") + return command is None or command == "push_status" + + # ── A2L "AMS Lite" unit-id normalisation (issue capture 2026-07-20) ────────── # The A2L reports its 4-slot AMS Lite as physical unit **id 16**, but the # firmware is internally inconsistent about it: @@ -2200,7 +2214,15 @@ class BambuMQTTClient: # next 1-2 push_status frames may still carry the printer's OLD cfg # for ~3 s before the firmware reflects the change. Without this # gate the UI would flicker ON→OFF→ON. Same pattern xcam uses. - new_backup = parse_ams_filament_backup_from_cfg(print_data.get("cfg")) + # Only from a status frame: a project_file ack echoes our own + # `"cfg": "0"` back, which read as "printer says backup is OFF" and + # stuck on every family that doesn't repeat `cfg` in its periodic + # frames — P1S, A1, A1 Mini, A2L (#3040). + new_backup = ( + parse_ams_filament_backup_from_cfg(print_data.get("cfg")) + if is_printer_status_frame(print_data) + else None + ) if new_backup is not None and new_backup != self.state.ams_filament_backup: hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament") if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time: @@ -4947,8 +4969,10 @@ class BambuMQTTClient: except (ValueError, TypeError): logger.debug("[%s] could not parse stat field: %r", self.serial_number, data["stat"]) - # Parse timelapse status (recording active during print) - if "timelapse" in data: + # Parse timelapse status (recording active during print). Status frames + # only — the project_file ack echoes back the per-job timelapse flag we + # asked for, which is a request, not the recorder's state (#3040). + if "timelapse" in data and is_printer_status_frame(data): logger.debug("[%s] timelapse field: %s", self.serial_number, data["timelapse"]) self.state.timelapse = data["timelapse"] is True # Track if timelapse was ever active during this print @@ -6001,7 +6025,11 @@ class BambuMQTTClient: "vibration_cali": vibration_cali, "layer_inspect": layer_inspect, "use_ams": use_ams, - "cfg": "0", + # No "cfg": it is the printer's device-config bitmask + # (auto-refill, detect-on-insert, chamber light, ...), not a + # per-job field — BambuStudio's PrintParams has no such + # member. We used to send "0"; firmware ignores it, but it + # comes straight back in the project_file ack (#3040). # extrude_cali_flag gates flow-dynamics calibration: # 0 = never, 1 = force every print, 2 = auto (run only if the # filament wasn't calibrated recently). #1721 saw stage 8 diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index ee588f2e5..f573efa03 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -4699,8 +4699,10 @@ class TestStartPrintUniqueIdentityFields: assert cmd["url"] == "ftp://test.3mf" assert cmd["file"] == "test.3mf" assert cmd["profile_id"] == "0" - assert cmd["cfg"] == "0" assert cmd["subtask_name"] == "test" + # The device-config bitmask is not a per-job field and is no longer + # sent; the printer echoed it back and we read it as telemetry (#3040). + assert "cfg" not in cmd class TestDeleteKProfileDualNozzleDetection: @@ -6951,6 +6953,82 @@ class TestAmsFilamentBackupHoldTimer: assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold +class TestCommandAckIsNotTelemetry: + """Regression (#3040): a printer's command acknowledgement echoes the + fields Bambuddy sent, so ingesting one as status reads our own request + back as the printer's state. + + Bambuddy used to put ``"cfg": "0"`` in every project_file. The ack came + back carrying it, bit 18 read as "AMS Filament Backup OFF", and on the + families that don't repeat ``cfg`` in their periodic frames (P1S, A1, + A1 Mini, A2L) the wrong value stuck until the user toggled it — which + silently disabled the prefer-lowest-remaining gate for the rest of the day. + """ + + @pytest.fixture + def mqtt_client(self): + from unittest.mock import MagicMock + + from backend.app.services.bambu_mqtt import BambuMQTTClient + + client = BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST123", + access_code="12345678", + ) + client.state.connected = True + client._client = MagicMock() + return client + + def test_project_file_ack_does_not_clear_backup_state(self, mqtt_client): + mqtt_client.state.ams_filament_backup = True + + mqtt_client._process_message( + {"print": {"command": "project_file", "sequence_id": "20000", "cfg": "0", "result": "success"}} + ) + + assert mqtt_client.state.ams_filament_backup is True + + def test_project_file_ack_leaves_unknown_backup_unknown(self, mqtt_client): + """A1 / A1 Mini never report cfg, so the state must stay None ("unknown") + — the value the prefer-lowest gate reads as "preserve old behaviour".""" + assert mqtt_client.state.ams_filament_backup is None + + mqtt_client._process_message({"print": {"command": "project_file", "cfg": "0"}}) + + assert mqtt_client.state.ams_filament_backup is None + + def test_push_status_still_updates_backup_state(self, mqtt_client): + mqtt_client.state.ams_filament_backup = True + + mqtt_client._process_message({"print": {"command": "push_status", "cfg": "C0340BC219"}}) # bit18=0 + + assert mqtt_client.state.ams_filament_backup is False + + def test_status_frame_without_command_still_updates_backup_state(self, mqtt_client): + """Some firmwares omit `command` on a status frame; those stay trusted.""" + mqtt_client.state.ams_filament_backup = False + + mqtt_client._process_message({"print": {"cfg": "C0340FC219"}}) # bit18=1 + + assert mqtt_client.state.ams_filament_backup is True + + def test_project_file_ack_does_not_clear_timelapse_state(self, mqtt_client): + """The ack echoes the per-job timelapse request, not the recorder.""" + mqtt_client.state.timelapse = True + + mqtt_client._process_message({"print": {"command": "project_file", "timelapse": False}}) + + assert mqtt_client.state.timelapse is True + + def test_push_status_still_updates_timelapse_state(self, mqtt_client): + mqtt_client.state.timelapse = True + + mqtt_client._process_message({"print": {"command": "push_status", "timelapse": False}}) + + assert mqtt_client.state.timelapse is False + + # --------------------------------------------------------------------------- # 2c. Single-nozzle H2S — external-spool tray_now override (#1822) # ---------------------------------------------------------------------------