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.
This commit is contained in:
maziggy
2026-04-14 18:25:36 +02:00
parent 665419dd22
commit 0d7c0d4054
3 changed files with 50 additions and 52 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.
- **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window.
- **Spoolman Iframe Blocked After 0.2.3b4 Security Headers** — The Spoolman page (Inventory → Spoolman iframe) failed to load when Spoolman was served from the same host as Bambuddy via a reverse proxy. The security-headers middleware added in 0.2.3b4 set `X-Frame-Options: DENY` on every response, which blocked even same-origin iframing. Relaxed to `SAMEORIGIN` so Spoolman (and any other same-origin tool behind the same reverse proxy) can be embedded again, while still preventing cross-origin clickjacking.
- **SD Card Badge Flapping on H2D** — The new SD card status badge on the Printers page card toggled between "inserted" (green) and "not inserted" (red) every few seconds on H2D. Root cause: the MQTT parser used a strict identity check (`data["sdcard"] is True`) on the top-level `sdcard` field, but real firmware ships that field inconsistently — bool on some models, int `1`, or a string enum like `"HAS_SDCARD_NORMAL"` on others — so any message carrying a non-bool value flipped the state to `False`. Fixed by deriving the badge from `home_flag` bits 8–9 (`HAS_SDCARD_NORMAL` / `HAS_SDCARD_ABNORMAL`) when present — the canonical firmware source, same as door and store-to-SD parsing — and falling back to a truthy check on the top-level field for firmwares that only send that. Follow-up: the badge was still flapping because Bambu firmwares send partial MQTT pushes that carry the legacy `sdcard` field alone (without `home_flag`), and the fallback was re-engaging on every such push. The parser now latches `home_flag` as the canonical source for the session once seen, so partial pushes carrying only `sdcard` can no longer flip the badge; the latch resets on reconnect so a firmware change still re-learns. Second follow-up: on H2D the badge still showed red on initial Printers-page navigation and flipped to green on reload, because H2D also sends heartbeat-style `home_flag` pushes where bits 8–9 are clear even when a card is inserted. Downgrades from true→false now require three consecutive clear reads (upgrades false→true still apply immediately), so a single heartbeat no longer turns the badge red.
- **SD Card Badge Flapping on H2D** — The new SD card status badge on the Printers page card toggled between "inserted" (green) and "not inserted" (red) every few seconds on H2D. Root cause: the MQTT parser used a strict identity check (`data["sdcard"] is True`) on the top-level `sdcard` field, but real firmware ships that field inconsistently — bool on some models, int `1`, or a string enum like `"HAS_SDCARD_NORMAL"` on others — so any message carrying a non-bool value flipped the state to `False`. Fixed by deriving the badge from `home_flag` bits 8–9 (`HAS_SDCARD_NORMAL` / `HAS_SDCARD_ABNORMAL`) when present — the canonical firmware source, same as door and store-to-SD parsing — and falling back to a truthy check on the top-level field for firmwares that only send that. Follow-up: the badge was still flapping because Bambu firmwares send partial MQTT pushes that carry the legacy `sdcard` field alone (without `home_flag`), and the fallback was re-engaging on every such push. The parser now latches `home_flag` as the canonical source for the session once seen, so partial pushes carrying only `sdcard` can no longer flip the badge; the latch resets on reconnect so a firmware change still re-learns. Second follow-up: on H2D the badge still showed red on initial Printers-page navigation and flipped to green on reload, because H2D also sends heartbeat-style `home_flag` pushes where bits 8–9 are clear even when a card is inserted. Downgrades from true→false now require three consecutive clear reads (upgrades false→true still apply immediately), so a single heartbeat no longer turns the badge red. Third follow-up: the three-strike counter still lost the race on idle printers — once an A1 or other printer connecting nearby triggered a burst of MQTT activity, idle H2Ds could accumulate ≥3 heartbeat pushes before the next full status report and all flip to red simultaneously. Reworked the derivation: the legacy top-level `sdcard` field is now authoritative when present (truthy check covers bool/int/string firmware variants), `home_flag` bits 8–9 are only consulted on full `push_status` reports (identified by the presence of multiple state markers like `gcode_state`, `mc_percent`, `nozzle_temper`, `print_type`, `stg_cur`, or `ams`), and bare heartbeat pushes carrying `home_flag` alone no longer affect SD state at all. Thanks to @MartinNYHC for reporting.
- **CSP Blocked Sidebar Iframes, Service-Worker Registration, and Google Fonts** — The strict `Content-Security-Policy` header added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered in `ExternalLinkPage` were blocked because no `frame-src` was declared and iframes fell back to `default-src 'self'`; (2) the inline service-worker registration `<script>` at the bottom of `index.html` was blocked by `script-src 'self'`, silently preventing the PWA service worker from installing; (3) the `@import` of Google Fonts' Inter from `index.css` was blocked by `style-src` and `font-src`. Fixed by adding `frame-src 'self' https:` for user-configured HTTPS iframe targets, moving the inline SW-registration script into `/sw-register.js` so `script-src 'self'` covers it without needing `'unsafe-inline'` or per-build hashes, and allowing `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in `font-src`. `frame-ancestors 'none'` is preserved so Bambuddy itself still cannot be framed cross-origin.
+21 -33
View File
@@ -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
View File
@@ -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):