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

fix(printers): stop H2D SD badge from flipping red on heartbeat bursts

  Third follow-up on the H2D SD card badge. The prior 3-strike downgrade still
  lost the race: on idle printers, a nearby printer coming online (e.g. an A1
  reconnecting) triggered an MQTT activity burst that let idle H2Ds accumulate
  ≥3 heartbeat home_flag pushes before the next full push_status, flipping every
  H2D badge to red at once.

  Reworked the derivation:
    - the top-level `sdcard` field is authoritative when present (truthy check
      handles bool / int / "HAS_SDCARD_NORMAL" string variants)
    - home_flag bits 8-9 are only consulted on full push_status payloads
      (detected via ≥2 of gcode_state, mc_percent, nozzle_temper, print_type,
      stg_cur, ams)
    - bare heartbeat pushes carrying home_flag alone no longer affect SD state

  Removed the now-dead `_home_flag_seen` latch and 3-strike counter. Tests in
  TestSdCardParsing rewritten to cover the new semantics.
maziggy 1 месяц назад
Родитель
Сommit
0d7c0d4054
3 измененных файлов с 49 добавлено и 52 удалено
  1. 0 1
      CHANGELOG.md
  2. 21 33
      backend/app/services/bambu_mqtt.py
  3. 28 18
      backend/tests/unit/services/test_bambu_mqtt.py

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
CHANGELOG.md


+ 21 - 33
backend/app/services/bambu_mqtt.py

@@ -360,11 +360,6 @@ class BambuMQTTClient:
         self._dev_mode_probe_failures: int = 0  # consecutive unanswered probes
         self._connect_time: float = 0.0  # monotonic timestamp of last _on_connect
 
-        # Once we've seen home_flag from this printer, it's the canonical SD-card
-        # source. The legacy top-level `sdcard` field arrives in partial pushes
-        # with inconsistent typing and caused badge flap (#936 follow-up).
-        self._home_flag_seen: bool = False
-
         # Set when check_staleness() force-closes the socket to trigger reconnect.
         # Prevents _on_disconnect from redundantly broadcasting state (already done).
         self._stale_reconnecting: bool = False
@@ -461,7 +456,6 @@ class BambuMQTTClient:
             self._dev_mode_probe_seq = None
             self._dev_mode_probe_time = 0.0
             self._dev_mode_probe_failures = 0
-            self._home_flag_seen = False
             self._connect_time = time.monotonic()
             client.subscribe(self.topic_subscribe)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
@@ -2269,33 +2263,27 @@ class BambuMQTTClient:
             if home_flag < 0:
                 home_flag = home_flag & 0xFFFFFFFF
 
-        # Parse SD card status. Canonical source on real firmware is home_flag bits 8-9;
-        # the top-level `sdcard` field is firmware-dependent (bool on some models, string
-        # enum like "HAS_SDCARD_NORMAL" on others), so strict identity checks caused the
-        # badge to flap on H2D. Prefer home_flag when available, fall back to a truthy
-        # check on the `sdcard` field for firmwares that only send that.
-        if home_flag is not None:
-            sd_bits_set = ((home_flag >> 8) & 0x3) != 0
-            # H2D sometimes sends heartbeat-style home_flag pushes where bits 8-9
-            # are clear even when an SD card is inserted. Only downgrade true->false
-            # after several consecutive clear reads; upgrade false->true immediately.
-            if sd_bits_set:
-                self.state.sdcard = True
-                self._sdcard_clear_streak = 0
-            else:
-                self._sdcard_clear_streak = getattr(self, "_sdcard_clear_streak", 0) + 1
-                if self._sdcard_clear_streak >= 3 or not self.state.sdcard:
-                    self.state.sdcard = False
-            self._home_flag_seen = True
-        elif "sdcard" in data and not self._home_flag_seen:
-            # Only trust the legacy top-level field on firmwares that never send
-            # home_flag. Once we've seen home_flag on this session, partial
-            # pushes carrying only `sdcard` must not flip the badge.
-            raw_sdcard = data["sdcard"]
-            if isinstance(raw_sdcard, str):
-                self.state.sdcard = "HAS_SDCARD" in raw_sdcard.upper() or raw_sdcard.lower() in ("true", "normal", "1")
-            else:
-                self.state.sdcard = bool(raw_sdcard)
+        # A "full" push_status report carries many state fields; heartbeat pushes
+        # are sparse (often just home_flag + a handful of counters). We use this
+        # to decide whether home_flag's SD bits can be trusted as ground truth.
+        _full_push_markers = ("gcode_state", "mc_percent", "nozzle_temper", "print_type", "stg_cur", "ams")
+        _is_full_push = sum(1 for k in _full_push_markers if k in data) >= 2
+
+        # Parse SD card status. H2D (and likely others) emit heartbeat-style pushes
+        # that carry `home_flag` with bits 8-9 cleared even when a card is inserted,
+        # so `home_flag` alone is not reliable. Prefer the legacy top-level `sdcard`
+        # field when present (handling bool/int/string variants), and only consult
+        # `home_flag` bits 8-9 when the push looks like a full status report and
+        # `sdcard` is absent.
+        def _truthy_sdcard(v: object) -> bool:
+            if isinstance(v, str):
+                return "HAS_SDCARD" in v.upper() or v.lower() in ("true", "normal", "1")
+            return bool(v)
+
+        if "sdcard" in data:
+            self.state.sdcard = _truthy_sdcard(data["sdcard"])
+        elif home_flag is not None and _is_full_push:
+            self.state.sdcard = ((home_flag >> 8) & 0x3) != 0
 
         if home_flag is not None:
             store_to_sdcard = bool((home_flag >> 11) & 1)

+ 28 - 18
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3577,8 +3577,13 @@ class TestDoorOpenParsing:
 
 
 class TestSdCardParsing:
-    """SD-card state is derived from home_flag bits 8-9 when present, else from
-    the top-level `sdcard` field (which firmware may send as bool, int, or string)."""
+    """SD-card state is derived from the top-level `sdcard` field when present
+    (firmware may send bool/int/string). `home_flag` bits 8-9 are only consulted
+    on full push_status reports when `sdcard` is absent, because H2D heartbeat
+    pushes carry `home_flag` with those bits cleared regardless of card state."""
+
+    # Markers used by bambu_mqtt to recognize a full push_status payload.
+    FULL_PUSH = {"gcode_state": "IDLE", "mc_percent": 0}
 
     def _make_client(self, model: str = "H2D"):
         from backend.app.services.bambu_mqtt import BambuMQTTClient
@@ -3590,35 +3595,40 @@ class TestSdCardParsing:
             model=model,
         )
 
-    def test_home_flag_bit8_sets_sdcard_true(self):
+    def test_home_flag_bit8_sets_sdcard_true_on_full_push(self):
         client = self._make_client()
-        client._update_state({"home_flag": 0x00000100})  # bit 8
+        client._update_state({"home_flag": 0x00000100, **self.FULL_PUSH})
         assert client.state.sdcard is True
 
-    def test_home_flag_bit9_sets_sdcard_true(self):
-        # Abnormal-but-present still counts as inserted for the badge
+    def test_home_flag_bit9_sets_sdcard_true_on_full_push(self):
         client = self._make_client()
-        client._update_state({"home_flag": 0x00000200})  # bit 9
+        client._update_state({"home_flag": 0x00000200, **self.FULL_PUSH})
         assert client.state.sdcard is True
 
-    def test_home_flag_no_sdcard_bits(self):
+    def test_heartbeat_home_flag_does_not_flip_badge(self):
+        # The actual bug: repeated heartbeat pushes (home_flag only, bits clear)
+        # must NOT downgrade an inserted card. Only full pushes or an explicit
+        # `sdcard` field can change state.
         client = self._make_client()
         client.state.sdcard = True
-        # Downgrade requires 3 consecutive clear reads (H2D heartbeat workaround).
-        client._update_state({"home_flag": 0x00000000})
+        for _ in range(10):
+            client._update_state({"home_flag": 0x00000000})
         assert client.state.sdcard is True
-        client._update_state({"home_flag": 0x00000000})
+
+    def test_sdcard_field_wins_over_home_flag(self):
+        # The legacy top-level `sdcard` field is reliable; home_flag bits 8-9
+        # are cleared on heartbeats even when a card is inserted.
+        client = self._make_client()
+        client._update_state({"home_flag": 0x00000000, "sdcard": "HAS_SDCARD_NORMAL"})
         assert client.state.sdcard is True
-        client._update_state({"home_flag": 0x00000000})
+        client._update_state({"home_flag": 0x00000100, "sdcard": False})
         assert client.state.sdcard is False
 
-    def test_home_flag_wins_over_sdcard_field(self):
-        # Real firmware can send `sdcard` as a non-bool; home_flag must still win.
+    def test_full_push_downgrade_when_no_sdcard_field(self):
+        # On a genuine full push with bits clear and no `sdcard` field, we do downgrade.
         client = self._make_client()
-        client._update_state({"home_flag": 0x00000100, "sdcard": "HAS_SDCARD_NORMAL"})
-        assert client.state.sdcard is True
-        for _ in range(3):
-            client._update_state({"home_flag": 0x00000000, "sdcard": 1})
+        client.state.sdcard = True
+        client._update_state({"home_flag": 0x00000000, **self.FULL_PUSH})
         assert client.state.sdcard is False
 
     def test_sdcard_string_fallback_when_no_home_flag(self):

Некоторые файлы не были показаны из-за большого количества измененных файлов