Просмотр исходного кода

Read AMS Filament Backup from home_flag on printers without cfg (#3259)

maziggy 2 недель назад
Родитель
Сommit
2804bc621c

+ 35 - 5
backend/app/services/bambu_mqtt.py

@@ -86,6 +86,22 @@ def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
         return None
 
 
+def parse_ams_filament_backup_from_home_flag(home_flag: object) -> bool | None:
+    """Extract AMS Filament Backup state from a push_status ``print.home_flag`` value.
+
+    Bambu Studio reads bit 10 for every family (DeviceManager.cpp
+    ``parse_home_flag``: ``SetAutoRefillEnabled((flag >> 10) & 0x1)``). It's
+    the only source on the P1S, P1P, A1 and A1 Mini, which never send ``cfg``
+    (#3259). On every printer that sends both, the two bits agree in all
+    captured snapshots, ON and OFF alike. Returns ``None`` for anything that
+    isn't an integer.
+    """
+    if isinstance(home_flag, bool) or not isinstance(home_flag, int):
+        return None
+    # Negative values are the 32-bit flag read as signed; bit 10 is the same.
+    return bool((home_flag >> 10) & 1)
+
+
 def is_printer_status_frame(print_data: dict) -> bool:
     """True when a ``print`` payload is the printer reporting its own state.
 
@@ -1446,6 +1462,10 @@ class BambuMQTTClient:
         self._xcam_hold_start: dict[str, float] = {}
         self._xcam_hold_time: float = 3.0  # Ignore incoming data for 3 seconds after command
 
+        # True once the printer has sent `cfg`; from then on AMS Filament Backup
+        # is read from cfg only, never from home_flag (#3259).
+        self._backup_cfg_seen: bool = False
+
         # Track last requested tray ID for H2D dual-nozzle printers
         # H2D only reports slot number (0-3) in tray_now, not global tray ID
         # We use our tracked value to resolve the correct global ID
@@ -2267,6 +2287,14 @@ class BambuMQTTClient:
             # DeviceManager.cpp:4961 SetAutoRefillEnabled(get_flag_bits(cfg, 18))
             # and live H2D ON/OFF capture 2026-06-20.
             #
+            # Families without cfg (P1S, P1P, A1, A1 Mini) carry it in home_flag
+            # bit 10 (#3259). That's read only from a full status report (the
+            # same >30-key test as the developer-mode probe) and only while the
+            # printer has never sent cfg: H2D firmware also sends small
+            # heartbeat frames with a partial home_flag (bits 8-9 clear with a
+            # card inserted, which is why the SD-card badge was removed), and
+            # printers that send cfg must keep reading it alone.
+            #
             # Hold-timer guard: when the user just toggled via the badge, the
             # 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
@@ -2275,11 +2303,13 @@ class BambuMQTTClient:
             # `"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
-            )
+            new_backup = None
+            if is_printer_status_frame(print_data):
+                if "cfg" in print_data:
+                    self._backup_cfg_seen = True
+                    new_backup = parse_ams_filament_backup_from_cfg(print_data["cfg"])
+                elif not self._backup_cfg_seen and len(print_data) > 30:
+                    new_backup = parse_ams_filament_backup_from_home_flag(print_data.get("home_flag"))
             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:

+ 2 - 2
backend/app/services/print_scheduler.py

@@ -4001,8 +4001,8 @@ class PrintScheduler:
         # Gate prefer_lowest on the printer's AMS Filament Backup state (#1766).
         # Without backup, the printer will not switch to a second spool when the
         # picked one runs out — so sorting toward the lowest leaves the print
-        # at risk of running dry mid-job. None (unknown / A1 family) preserves
-        # today's behaviour intentionally.
+        # at risk of running dry mid-job. None (unknown: no status report with
+        # cfg or home_flag yet) preserves today's behaviour intentionally.
         if prefer_lowest and status.ams_filament_backup is False:
             logger.info("[prefer-lowest] skipped (AMS Backup OFF on printer %s)", printer_id)
             prefer_lowest = False

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

@@ -7212,6 +7212,63 @@ class TestCommandAckIsNotTelemetry:
 
         assert mqtt_client.state.ams_filament_backup is True
 
+    @staticmethod
+    def _full_status(**fields):
+        """A full status report: home_flag is only read from one (>30 keys)."""
+        frame = {"command": "push_status", **{f"filler_{i}": 0 for i in range(30)}}
+        frame.update(fields)
+        return {"print": frame}
+
+    def test_full_status_home_flag_sets_backup_state_without_cfg(self, mqtt_client):
+        """P1S / P1P / A1 never send cfg; home_flag bit 10 carries the state (#3259)."""
+        assert mqtt_client.state.ams_filament_backup is None
+
+        mqtt_client._process_message(self._full_status(home_flag=7554719))  # bit10=1
+
+        assert mqtt_client.state.ams_filament_backup is True
+
+        mqtt_client._process_message(self._full_status(home_flag=7554719 & ~(1 << 10)))
+
+        assert mqtt_client.state.ams_filament_backup is False
+
+    def test_small_frame_home_flag_is_ignored(self, mqtt_client):
+        """Heartbeat-style frames carry a partial home_flag (H2D, SD-card badge)."""
+        mqtt_client.state.ams_filament_backup = True
+
+        mqtt_client._process_message({"print": {"command": "push_status", "home_flag": 0}})
+
+        assert mqtt_client.state.ams_filament_backup is True
+
+    def test_home_flag_ignored_once_printer_sent_cfg(self, mqtt_client):
+        """Printers that send cfg keep reading it alone, even in full reports without it."""
+        mqtt_client._process_message(self._full_status(cfg="C0340FC219", home_flag=1 << 10))  # bit18=1
+        assert mqtt_client.state.ams_filament_backup is True
+
+        mqtt_client._process_message(self._full_status(home_flag=0))
+
+        assert mqtt_client.state.ams_filament_backup is True
+
+    def test_cfg_wins_over_home_flag_in_same_frame(self, mqtt_client):
+        mqtt_client._process_message(self._full_status(cfg="C0340BC219", home_flag=1 << 10))  # bit18=0
+
+        assert mqtt_client.state.ams_filament_backup is False
+
+    def test_ack_home_flag_is_not_read_as_backup_state(self, mqtt_client):
+        frame = self._full_status(home_flag=7554719)
+        frame["print"]["command"] = "project_file"
+
+        mqtt_client._process_message(frame)
+
+        assert mqtt_client.state.ams_filament_backup is None
+
+    def test_home_flag_respects_toggle_hold(self, mqtt_client):
+        """A stale home_flag right after a toggle must not flip the badge back."""
+        mqtt_client.set_ams_filament_backup(True)
+
+        mqtt_client._process_message(self._full_status(home_flag=7554719 & ~(1 << 10)))
+
+        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

+ 27 - 1
backend/tests/unit/test_bambu_mqtt_cfg_parse.py

@@ -8,7 +8,10 @@ printers and pre-init pushes produce.
 
 import pytest
 
-from backend.app.services.bambu_mqtt import parse_ams_filament_backup_from_cfg
+from backend.app.services.bambu_mqtt import (
+    parse_ams_filament_backup_from_cfg,
+    parse_ams_filament_backup_from_home_flag,
+)
 
 
 class TestParseAmsFilamentBackupFromCfg:
@@ -56,3 +59,26 @@ class TestParseAmsFilamentBackupFromCfg:
         # None preserves today's behaviour for callers gating on backup state —
         # NOT False. Treating absent as OFF would regress A1-family scheduling.
         assert parse_ams_filament_backup_from_cfg(value) is None
+
+
+class TestParseAmsFilamentBackupFromHomeFlag:
+    """``home_flag`` bit 10, the only source on families without ``cfg``
+    (#3259). Values are from support-bundle push_status snapshots."""
+
+    def test_p1s_on(self):
+        # P1S fw 01.10.00.00 full status: no cfg, home_flag bit 10 set.
+        assert parse_ams_filament_backup_from_home_flag(7554719) is True
+
+    def test_a1_off(self):
+        # A1 fw 01.08.01.00: home_flag bit 10 clear.
+        assert parse_ams_filament_backup_from_home_flag(846152095) is False
+
+    def test_negative_value(self):
+        # X1-class firmware sends the 32-bit flag as a signed int; its bit 10
+        # matches cfg bit 18 in the same frames (ON and OFF).
+        assert parse_ams_filament_backup_from_home_flag(-1067070056) is True
+        assert parse_ams_filament_backup_from_home_flag(-1067069137) is False
+
+    @pytest.mark.parametrize("value", [None, "1024", True, 10.0, [1024]])
+    def test_invalid_returns_none(self, value):
+        assert parse_ams_filament_backup_from_home_flag(value) is None

+ 1 - 1
frontend/src/__tests__/components/AmsBackupModal.test.tsx

@@ -140,7 +140,7 @@ describe('AmsBackupModal', () => {
     expect(onToggle).toHaveBeenCalledWith(false);
   });
 
-  it('toggle is disabled when state is unknown (A1 family)', async () => {
+  it('toggle is disabled when state is unknown', async () => {
     render(
       <AmsBackupModal
         isOpen

+ 1 - 1
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -1218,7 +1218,7 @@ describe('effectivePreferLowest gate (#1766)', () => {
     expect(effectivePreferLowest(true, true)).toBe(true);
   });
 
-  it('passes through when backup is unknown (null/undefined — A1 family)', () => {
+  it('passes through when backup is unknown (null/undefined)', () => {
     expect(effectivePreferLowest(true, null)).toBe(true);
     expect(effectivePreferLowest(true, undefined)).toBe(true);
   });