Sfoglia il codice sorgente

fix(ams): stop reading the printer's command acks as status (issue #3040)

    Every project_file carried "cfg": "0" — the device-config bitmask, which
    Bambu Studio has never sent and the firmware ignores. The printer echoes a
    command's fields back in its ack, and the ack was ingested as telemetry, so
    bit 18 read as "AMS Filament Backup off" 25 ms after every dispatch.

    Families that repeat cfg in their periodic status (P2S, H2C, X2D) corrected
    themselves a second later; the P1S, A1, A1 Mini and A2L send it only in a
    full status dump, so the wrong value stuck and silently disabled the
    prefer-lowest-remaining gate. The A1 family, which reports no cfg at all and
    is meant to stay "unknown", was pinned to a definite "off".

    Acks are no longer read as status, for the backup bit or the per-job
    timelapse flag they also echo, and cfg is gone from the print command.
maziggy 4 giorni fa
parent
commit
2f7ec240fd

+ 32 - 4
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

+ 79 - 1
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)
 # ---------------------------------------------------------------------------