Forráskód Böngészése

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

maziggy 4 napja
szülő
commit
e7dd899963

+ 1 - 0
CHANGELOG.md

@@ -149,6 +149,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **AMS Filament Backup showed "?" on the P1S, P1P, A1 and A1 Mini (#3259, reported by @HopNotch)** — These printers never send the `cfg` field Bambuddy read the setting from; they carry it in `home_flag` bit 10, where Bambu Studio reads it for every printer. Bambuddy now falls back to that bit, so the badge shows the printer's real setting and the prefer-lowest-remaining gate follows it. Before 1.2.5.6 the badge seemed to work on these printers only because it read Bambuddy's own print command echoed back, which always said off (#3040).
 - **Slicing failed with a printer preset saved under a name of its own (#3250, reported by @bobobo1618)** — A printer preset copied from a stock one and renamed, such as "Bambu Lab H2D 0.4 nozzle - Apply Skew Calibration", failed every slice with "The selected printer is not compatible with the process preset in the 3mf", even with a stock process the stock printer slices fine.
   - **Why:** the slicer checks a process against the stock preset a printer preset was saved from. The slicer sidecar handed it the renamed preset as a stock preset in its own right, and no process lists that name. It now names the stock preset the printer was saved from, while still slicing with the renamed preset's own settings, start G-code included. A process or filament preset that lists the renamed printer itself keeps fitting it, as it does in the slicer. Needs the updated slicer sidecar image (OrcaSlicer and Bambu Studio).
   - **The Slice dialog** read the printer model out of the preset's name, which a name of your own may not contain, so it misjudged which process and filament presets fit. It now also checks against the stock preset the printer was saved from, for imported and OrcaSlicer Cloud printer presets.

+ 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.
 
@@ -1475,6 +1491,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
@@ -2296,6 +2316,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
@@ -2304,11 +2332,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

@@ -4110,8 +4110,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);
   });