revert(printers): remove SD card badge entirely

Four attempts at making the printer-card SD badge stable on H2D all failed:
  the final straw was powering on an A1 causing every connected H2D to flip to
  red simultaneously. Bambu firmware SD signaling is not reliably derivable
  from MQTT — the legacy `sdcard` field is sporadic and inconsistently typed,
  and home_flag bits 8-9 are cleared on heartbeat pushes regardless of card
  state with no clean way to distinguish heartbeats from full status reports.

  Remove the badge from the Printers page card and the Printer Info modal,
  drop `sdcard` from the frontend PrinterStatus type, and strip all home_flag
  derivation and heartbeat-handling code from the MQTT parser.

  `state.sdcard` is retained on the backend and populated only from a plain
  truthy read of the `sdcard` field, because firmware_update.py uses it as a
  precondition before starting firmware installs.
This commit is contained in:
maziggy
2026-04-14 18:41:46 +02:00
parent 0d7c0d4054
commit 899c2c6480
8 changed files with 89 additions and 161 deletions
+2 -3
View File
@@ -7,8 +7,7 @@ All notable changes to Bambuddy will be documented in this file.
### New Features
- **Move Build Plate from Printer Card** ([#791](https://github.com/maziggy/bambuddy/issues/791)) — The printer card controls row now has a Z-jog badge between the speed control and the stop/pause buttons. Click the up/down arrows to move the build plate; click the middle label to switch the step size (1 / 10 / 50 mm). When the printer is not homed (typical right after a print finishes), the first jog opens a Bambu Studio-style warning modal with **Home Z**, **Move anyway** (bypasses soft endstops for this move), or **Cancel**. After the first "Move anyway" in a session, subsequent jogs skip the dialog. Disabled while a print is running. Backed by new `POST /printers/{id}/bed-jog` and `POST /printers/{id}/home-axes` endpoints, both gated behind `printers:control`. Thanks to @cadtoolbox for the request.
- **Printer Card Status Badges & Quick Controls** — The Printers page printer card now exposes four new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:
- **SD Card badge** in the top status row (HardDrive icon, green when card present, red when missing).
- **Printer Card Status Badges & Quick Controls** — The Printers page printer card now exposes new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:
- **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).
- **Airduct Mode badge** beside the print speed control (Snowflake/Flame icons, sky for Cooling and orange for Heating). One-click dropdown switches the printer between cooling and heating via the existing `set_airduct` MQTT command. Gated to P2S/H2D/H2C/H2S.
- **Force Refresh** menu entry in the printer card kebab menu (RotateCw icon) that re-requests a full `pushall` MQTT status report from the printer without forcing a reconnect.
@@ -34,7 +33,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. 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.
- **SD Card Badge Removed** — After four rounds of fixes the printer-card SD status badge still flipped red on H2D when unrelated activity happened on the network (e.g. powering on an A1 caused every H2D to go red simultaneously). The underlying problem is that Bambu firmware SD-state signaling is not reliably derivable from MQTT: the legacy top-level `sdcard` field is only sent on some pushes with inconsistent typing, and `home_flag` bits 8-9 are cleared on heartbeat pushes even when a card is inserted, with no reliable way to distinguish heartbeats from full status reports. The badge has been removed entirely from the Printers page card and the Printer Info modal. Underlying `state.sdcard` parsing is retained (simplified to a plain truthy read of the `sdcard` field only, no more `home_flag` derivation, no heartbeat latches) because the firmware-update precondition check still needs to know whether a card is inserted before starting an update. Thanks to @MartinNYHC for the extensive reporting across all four rounds. Previously, this entry described the H2D badge flap and its three attempted fixes — kept here for history: The original bug 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.
+12 -20
View File
@@ -2263,27 +2263,19 @@ class BambuMQTTClient:
if home_flag < 0:
home_flag = home_flag & 0xFFFFFFFF
# 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)
# SD card presence: the only remaining consumer is the firmware-update
# precondition check (firmware_update.py). Use the top-level `sdcard`
# field when present with a permissive truthy check covering the
# bool/int/"HAS_SDCARD_NORMAL" variants real firmware emits. We do NOT
# derive this from home_flag — heartbeat pushes clear bits 8-9 even
# when a card is inserted, which caused the badge to flap before the
# badge was removed entirely.
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
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)
+7 -39
View File
@@ -3577,13 +3577,10 @@ class TestDoorOpenParsing:
class TestSdCardParsing:
"""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}
"""SD-card state is only set from the top-level `sdcard` field (bool/int/
string variants). home_flag is NOT consulted — heartbeat pushes clear those
bits even when a card is inserted, and the prior badge feature was removed
entirely because no reliable heartbeat-vs-full-push heuristic existed."""
def _make_client(self, model: str = "H2D"):
from backend.app.services.bambu_mqtt import BambuMQTTClient
@@ -3595,42 +3592,13 @@ class TestSdCardParsing:
model=model,
)
def test_home_flag_bit8_sets_sdcard_true_on_full_push(self):
client = self._make_client()
client._update_state({"home_flag": 0x00000100, **self.FULL_PUSH})
assert client.state.sdcard is True
def test_home_flag_bit9_sets_sdcard_true_on_full_push(self):
client = self._make_client()
client._update_state({"home_flag": 0x00000200, **self.FULL_PUSH})
assert client.state.sdcard is True
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.
def test_home_flag_alone_does_not_touch_sdcard(self):
client = self._make_client()
client.state.sdcard = True
for _ in range(10):
client._update_state({"home_flag": 0x00000000})
for home_flag in (0x00000000, 0x00000100, 0x00000200):
client._update_state({"home_flag": home_flag})
assert client.state.sdcard is True
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": 0x00000100, "sdcard": False})
assert client.state.sdcard is False
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.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):
client = self._make_client()
client._update_state({"sdcard": "HAS_SDCARD_NORMAL"})
-1
View File
@@ -244,7 +244,6 @@ export interface PrinterStatus {
ams: AMSUnit[];
ams_exists: boolean;
vt_tray: AMSTray[]; // Virtual tray / external spool(s)
sdcard: boolean; // SD card inserted
store_to_sdcard: boolean; // Store sent files on SD card
timelapse: boolean; // Timelapse recording active
ipcam: boolean; // Live view enabled
@@ -162,22 +162,6 @@ export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }:
value: printer.nozzle_count,
});
// SD Card
if (status?.sdcard != null) {
rows.push({
label: t('printers.sdCard'),
value: (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
status.sdcard
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}>
{status.sdcard ? t('printers.inserted') : t('printers.notInserted')}
</span>
),
});
}
// Auto-Archive
rows.push({
label: t('printers.autoArchive'),
-14
View File
@@ -2480,20 +2480,6 @@ function PrinterCard({
</span>
) : null}
{/* SD Card Badge */}
{status?.connected && (
<span
className={`flex items-center px-2 py-1 rounded-full text-xs ${
status.sdcard
? 'bg-status-ok/20 text-status-ok'
: 'bg-red-500/20 text-red-400'
}`}
title={`${t('printers.sdCard')}: ${status.sdcard ? t('printers.inserted') : t('printers.notInserted')}`}
>
<HardDrive className="w-3 h-3" />
</span>
)}
{/* Enclosure Door Badge (X1/P1S/P2S/H2*) */}
{status?.connected && ['X1C', 'X1', 'X1E', 'P1S', 'P1P', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && (
<span
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-Dvdlpt_K.js"></script>
<script type="module" crossorigin src="/assets/index-Bcx_I7VB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
</head>
<body>