fix(printers): stop SD card badge flapping on H2D

Parse `sdcard` from `home_flag` bits 8-9 (HAS_SDCARD_NORMAL /
  HAS_SDCARD_ABNORMAL) when available and fall back to a type-tolerant
  truthy check on the top-level `sdcard` field. Firmware ships that
  field inconsistently (bool, int `1`, or string `"HAS_SDCARD_NORMAL"`),
  so the previous `is True` identity check flipped the badge to red on
  every report that carried a non-bool value.
This commit is contained in:
maziggy
2026-04-14 08:17:34 +02:00
parent 53a70e37d9
commit 9cc7efdf5f
3 changed files with 80 additions and 8 deletions
+1
View File
@@ -19,6 +19,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.
- **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 -8
View File
@@ -2237,17 +2237,30 @@ class BambuMQTTClient:
)
)
# Parse SD card status
if "sdcard" in data:
self.state.sdcard = data["sdcard"] is True
# Parse home_flag for "Store Sent Files on External Storage" setting (bit 11)
# Parse home_flag first so SD-card detection below can prefer it.
# Bit 8 = HAS_SDCARD_NORMAL, bit 9 = HAS_SDCARD_ABNORMAL, bit 11 = store-to-SD,
# bit 23 = door-open (X1 family only).
home_flag = None
if "home_flag" in data:
home_flag = data["home_flag"]
# Bit 11 controls "Store Sent Files on External Storage"
# Convert to unsigned 32-bit if negative
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:
self.state.sdcard = ((home_flag >> 8) & 0x3) != 0
elif "sdcard" in data:
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)
if home_flag is not None:
store_to_sdcard = bool((home_flag >> 11) & 1)
if store_to_sdcard != self.state.store_to_sdcard:
logger.debug(
@@ -2261,7 +2274,7 @@ class BambuMQTTClient:
# Both share the same bitmask (0x00800000) but live in different fields.
model_upper = (self.model or "").upper().strip()
is_x1_family = model_upper in ("X1", "X1C", "X1E")
if is_x1_family and "home_flag" in data:
if is_x1_family and home_flag is not None:
door_open = (home_flag & 0x00800000) != 0
if door_open != self.state.door_open:
logger.debug(
@@ -3574,3 +3574,61 @@ class TestDoorOpenParsing:
client = self._make_client("H2D")
client._update_state({"stat": "not-hex"})
assert client.state.door_open is False
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)."""
def _make_client(self, model: str = "H2D"):
from backend.app.services.bambu_mqtt import BambuMQTTClient
return BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST",
access_code="12345678",
model=model,
)
def test_home_flag_bit8_sets_sdcard_true(self):
client = self._make_client()
client._update_state({"home_flag": 0x00000100}) # bit 8
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
client = self._make_client()
client._update_state({"home_flag": 0x00000200}) # bit 9
assert client.state.sdcard is True
def test_home_flag_no_sdcard_bits(self):
client = self._make_client()
client.state.sdcard = True
client._update_state({"home_flag": 0x00000000})
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.
client = self._make_client()
client._update_state({"home_flag": 0x00000100, "sdcard": "HAS_SDCARD_NORMAL"})
assert client.state.sdcard is True
client._update_state({"home_flag": 0x00000000, "sdcard": 1})
assert client.state.sdcard is False
def test_sdcard_string_fallback_when_no_home_flag(self):
client = self._make_client()
client._update_state({"sdcard": "HAS_SDCARD_NORMAL"})
assert client.state.sdcard is True
def test_sdcard_int_fallback_when_no_home_flag(self):
# `1 is True` is False — the old strict check flapped here.
client = self._make_client()
client._update_state({"sdcard": 1})
assert client.state.sdcard is True
def test_sdcard_bool_fallback_when_no_home_flag(self):
client = self._make_client()
client._update_state({"sdcard": True})
assert client.state.sdcard is True
client._update_state({"sdcard": False})
assert client.state.sdcard is False