fix(spoolbuddy): tare banner now resolves to complete or timed-out (#1536)

The TARE button on Settings → Scale set a "Tare command sent. Waiting
  for device..." banner with no mechanism to clear it. The daemon writes
  back through /calibration/set-tare which stamps last_calibrated_at on
  the device row, but handleTare was set-and-forget — the banner stayed
  forever. The "Calibration complete!" success banner had the same shape.

  Snapshot last_calibrated_at when TARE is pressed, set an awaiting state,
  invalidate the device-list query every 1s while waiting (so detection
  responds within ~1s, not the 10s background poll), and when the snapshot
  advances flip the banner to "Tare complete!" with a 3s auto-dismiss. A
  15s timeout falls open to "Tare timed out — is the SpoolBuddy daemon
  running?" so a dead daemon doesn't trap the user on the spinner. The
  calibration-complete and calibration-failed banners now share the same
  auto-dismiss helper.
This commit is contained in:
maziggy
2026-05-26 11:34:46 +02:00
parent 4343bd60b1
commit d0ff6f7dc1
13 changed files with 194 additions and 107 deletions
+1
View File
@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Trivy DS-0026 (`Dockerfile.test` missing HEALTHCHECK): silenced via `HEALTHCHECK NONE`** — The test image runs `pytest` and exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise. `HEALTHCHECK NONE` is the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813.
### Fixed
- **SpoolBuddy: Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by @flom89)** — On the SpoolBuddy kiosk's Settings → Scale (Waage) tab, pressing TARE wrote the "Tare command sent. Waiting for device..." banner but had no mechanism to resolve it. The daemon writes back through `POST /spoolbuddy/devices/{id}/calibration/set-tare` (which stamps `tare_offset` + `last_calibrated_at` on the device row), the device list query already polls every 10 s, but `handleTare` in `frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx` was set-and-forget — the banner persisted indefinitely. The "Calibration complete!" banner on the full calibration flow had the same shape and stayed forever too. **Fix**: a completion watcher that snapshots `device.last_calibrated_at` when TARE is pressed, sets an `awaitingTareSince` state, invalidates the device-list query every 1 s while that state is active (so detection responds within ~1 s instead of waiting on the 10 s background poll), and when `last_calibrated_at` advances past the snapshot flips the banner to "Tare complete!" with a 3 s auto-dismiss timer. A 15 s timeout on the watcher fails open to "Tare timed out — is the SpoolBuddy daemon running?" so a dead daemon doesn't leave the user staring at the spinner. The Calibration-complete success banner and the calibration-failed error banner now share the same auto-dismiss helper (3 s success, 5 s error). All timers are owned by a `useRef` that cleans up on unmount; pressing TARE while a previous dismiss is queued cancels the old timer. **i18n**: two new keys (`spoolbuddy.settings.tareComplete`, `spoolbuddy.settings.tareTimedOut`) translated into all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]] — no English fallbacks. Parity script passes at 4997 keys × 9 locales. Frontend build clean.
- **ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by @apizz)** — Reporter pointed an ntfy server behind a Cloudflare Tunnel at Bambuddy and got `HTTP 403: <!DOCTYPE html>...Just a moment...` on every Test click. They reproduced the same response with a plain `curl -H "Authorization: Bearer <token>" -d "test" https://ntfy.example/<topic>` — confirming the 403 originates from Cloudflare's JS challenge intercept (Bot Fight Mode / "Under Attack" mode), not from Bambuddy or ntfy. Cloudflare returns its interstitial HTML to any non-browser client at the edge, so the request never reaches the user's ntfy backend at all. Bambuddy can't solve a JS challenge from a backend — the only real fix is on the user's Cloudflare side (a security-skip rule for the hostname/path, disabling Bot Fight Mode for that hostname, or fronting the server with Cloudflare Access using a service token). Two improvements shipped to make this footgun self-diagnosable for the next user who hits it. **(1) Honest User-Agent on the notification HTTP client.** `backend/app/services/notification_service.py` was the one outbound httpx client in the codebase that didn't set the project-standard `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` UA — it leaked `python-httpx/<version>` instead. Brings it in line with `bambu_cloud` / `makerworld` / `firmware_check` / `inventory` (all unified during the May 2026 compliance pass) and makes Bambuddy a more obvious citizen to upstream WAFs and proxy operators. Won't defeat Cloudflare's JS challenge (the user's curl test proves CF blocks regardless of UA) but it's a consistency / hygiene fix with no regression risk. **(2) Cloudflare-challenge detection on the ntfy error path.** New `_looks_like_cloudflare_challenge(response)` helper checks the response shape (`Server: cloudflare` or `cf-mitigated` header, or `<!DOCTYPE html>...Just a moment...` body). When a 403/non-success response matches, the error returned to the UI now reads: *"HTTP 403 — ntfy server is behind a Cloudflare challenge. Bambuddy was served the JS challenge page instead of reaching ntfy. Cloudflare cannot be solved from a backend; add a Cloudflare security-skip rule for this hostname, disable Bot Fight Mode, or front the server with Cloudflare Access using a service token. (#1534)"* — actionable, points at the real fix, removes the raw HTML dump. A regular 403 (e.g. ntfy auth failure with a plain `forbidden: invalid auth token` body) still surfaces the original body so genuine auth errors stay debuggable; the interceptor only fires on the Cloudflare shape. **Tests**: 3 new in `TestNtfyOutbound` in `test_notification_service.py` — (a) the lazy-constructed httpx client carries the honest UA header on first use; (b) a 403 with `Server: cloudflare` + `Just a moment...` body produces the actionable error and does not echo `<!DOCTYPE` to the user; (c) a 403 with a plain text auth-failure body keeps the original `HTTP 403: forbidden: invalid auth token` so we don't hide real errors. 110/110 in the notification suites green under `pytest -n 30`. Backend ruff clean.
- **Source-3MF upload on "fallback" archives no longer crashes with HTTP 500 (and stops orphaning files outside the data volume) (#1531, reported by @d3nn3s08)** — When MQTT reports a print start but Bambuddy never saw the source 3MF (cloud-initiated prints, Bambu Handy, prints already on the printer's SD card when Bambuddy connected), `main.py:2596` creates a "fallback" `PrintArchive` row with `file_path=""`. The two `Archives → Source 3MF Upload` routes computed the destination directory as `(settings.base_dir / archive.file_path).parent / "source"` — which on a fallback row collapsed to `Path('/app/data') / '' = Path('/app/data')`, whose `.parent` is `Path('/app')`, sending the upload to `/app/source/<filename>.3mf`. The file was physically written there (a path outside the user's mounted data volume — orphaned on container restart) and only the *final* `source_path.relative_to(settings.base_dir)` raised, so every retry left another orphan. Affected reporter is on a QNAP Docker host with the standard `/app/data` mount; both maintainer and triage initially diagnosed it as a Docker volume misconfiguration, but the traceback shows the bug is purely on Bambuddy's side — the user's setup was correct. **Fix**: new private helper `_resolve_source_3mf_path(archive, source_filename)` in `backend/app/api/routes/archives.py` centralises the destination computation. Normal archives still nest the source under `<archive_file_dir>/source/<filename>`. Fallback archives (empty `file_path`) now land under `<base_dir>/archive/no_source/<archive_id>/<filename>` instead — a deterministic, addressable location that stays inside the data volume, and the existing read sites (`download_source_3mf`, `download_source_3mf_by_filename`, the slicer-token routes, `delete_source_3mf`) all continue to work because they read back via `settings.base_dir / archive.source_3mf_path`. The helper also defensively asserts the resolved directory is inside `base_dir.resolve()` regardless of where it came from, so a row corrupted by an old import or a manual SQL edit fails with a clear 500 message ("Archive N resolves to a path outside the data directory; cannot attach source.") instead of silently writing outside the volume. Both upload sites (`upload_source_3mf` and `upload_source_3mf_by_name`, the slicer-post-processing endpoint) now route through the helper, so neither can independently drift back into the bug. **Tests**: 2 new in `TestUploadSourceThreeMF` in `backend/tests/integration/test_archives_api.py` — (a) `test_fallback_archive_source_upload_lands_under_base_dir` creates an archive with `file_path=""`, uploads a minimal valid 3MF, asserts 200 status, that the returned `source_3mf_path` is relative (not `/app/source/...`), that the file physically exists under the patched `base_dir`, and that the path is the deterministic fallback location keyed off `archive.id`; (b) `test_normal_archive_source_upload_unchanged` is the same flow against an archive with a populated `file_path`, asserting the existing `archives/test/source/<filename>.3mf` layout is preserved (regression guard against the helper accidentally changing the normal path). 57/57 in `test_archives_api.py` green under `pytest -n 30`. Backend ruff clean. **Note**: existing orphan files at `/app/source/<filename>.3mf` from prior failed retries inside an affected user's container can be safely deleted; they were never indexed in the DB, never reachable from the UI, and would have vanished on the next container restart anyway.
- **SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by @chesterakl)** — Reporter (Spoolman mode, H2C, internal "manually add then NFC-link" flow) saw the SpoolBuddy "Sync Weight" button flip to "Synced!" but the Spoolman-backed inventory listing never updated. Cause: `POST /spoolbuddy/scale/update-spool-weight` (`backend/app/api/routes/spoolbuddy.py`) ran the lookup local-DB-first and only fell through to Spoolman on local miss — but the upstream `nfc/tag-scanned` route is exclusive (always-Spoolman when `spoolman_enabled=true`, after the #1119 / nfc-routing fix). When the user's local DB still held a stale `Spool` row that happened to share a numeric id with the Spoolman spool the NFC tag mapped to, the sync endpoint absorbed the update into the stale local row, returned 200 with the local `weight_used`, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line logged `SpoolBuddy updated spool 2 weight: …g on scale, …g used` (the local-branch log format) and the `SpoolBuddy updated Spoolman spool …` line (which only fires in the Spoolman branch) never appeared. The bug couldn't be reproduced on developer setups because they don't carry a leftover local row with a colliding id. **Fix**: `update_spool_weight` now routes exactly like `nfc_tag_scanned` — `_get_spoolman_client_or_none(db)` first, and that result picks the branch exclusively. Spoolman mode goes straight to Spoolman with no local-DB read; local mode does the local update and returns 404 (not "fallback to Spoolman") on a local miss. Matches [[feedback_inventory_modes_parity]] — the two inventory modes must behave identically from the user's perspective, including which row gets written. The docstring now spells out the routing contract so the next reader doesn't reintroduce the local-first read. **Tests**: 1 new regression test in `TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman` — creates a local `Spool` with the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman's `update_spool` was called with the correct remaining weight, and (b) the local row's `weight_used` and `last_scale_weight` are unchanged after a `refresh()` against the live DB. The existing 8 tests in that class continue to assert the Spoolman branch math (filament/spool-level tare priority, 404 / 503 mappings, 250g fallback warning). 9/9 green; 126/126 across the spoolbuddy + spoolman-filament-patch integration suites green under `pytest -n 30`. **Cleanup hint for affected users**: anyone in Spoolman mode with leftover local Spool rows from before they switched should delete those rows — they're inert under the new routing, but they were eating sync attempts under the old. Backend ruff clean.
+2
View File
@@ -5430,6 +5430,8 @@ export default {
calibrateNow: 'Kalibrieren',
calibrated: 'Kalibriert',
tareSet: 'Tara-Befehl gesendet. Warte auf Gerät...',
tareComplete: 'Tara abgeschlossen!',
tareTimedOut: 'Tara-Zeitüberschreitung — läuft der SpoolBuddy-Daemon?',
tareFailed: 'Tara-Befehl fehlgeschlagen',
zeroSet: 'Nullpunkt gesetzt. Bekanntes Gewicht auf die Waage legen.',
calibrationDone: 'Kalibrierung abgeschlossen!',
+2
View File
@@ -5443,6 +5443,8 @@ export default {
calibrateNow: 'Calibrate',
calibrated: 'Calibrated',
tareSet: 'Tare command sent. Waiting for device...',
tareComplete: 'Tare complete!',
tareTimedOut: 'Tare timed out — is the SpoolBuddy daemon running?',
tareFailed: 'Failed to send tare command',
zeroSet: 'Zero point set. Place known weight on scale.',
calibrationDone: 'Calibration complete!',
+2
View File
@@ -5439,6 +5439,8 @@ export default {
calibrateNow: 'Calibrar',
calibrated: 'Calibrada',
tareSet: 'Comando de tara enviado. Esperando al dispositivo...',
tareComplete: '¡Tara completada!',
tareTimedOut: 'Tara agotó el tiempo de espera — ¿está activo el daemon de SpoolBuddy?',
tareFailed: 'Error al enviar el comando de tara',
zeroSet: 'Punto cero establecido. Coloque el peso conocido en la báscula.',
calibrationDone: '¡Calibración completada!',
+2
View File
@@ -5407,6 +5407,8 @@ export default {
calibrateNow: 'Calibrer',
calibrated: 'Calibré',
tareSet: 'Commande de tare envoyée. En attente de l\'appareil...',
tareComplete: 'Tare terminée !',
tareTimedOut: 'Délai de tare dépassé — le daemon SpoolBuddy fonctionne-t-il ?',
tareFailed: 'Échec de l\'envoi de la commande de tare',
zeroSet: 'Point zéro défini. Placez le poids connu sur la balance.',
calibrationDone: 'Calibration terminée !',
+2
View File
@@ -5406,6 +5406,8 @@ export default {
calibrateNow: 'Calibra',
calibrated: 'Calibrato',
tareSet: 'Comando tara inviato. In attesa del dispositivo...',
tareComplete: 'Tara completata!',
tareTimedOut: 'Tempo scaduto per la tara — il demone SpoolBuddy è in esecuzione?',
tareFailed: 'Invio comando tara fallito',
zeroSet: 'Punto zero impostato. Posizionare il peso noto sulla bilancia.',
calibrationDone: 'Calibrazione completata!',
+2
View File
@@ -5418,6 +5418,8 @@ export default {
calibrateNow: 'キャリブレーション',
calibrated: 'キャリブレーション済み',
tareSet: '風袋コマンドを送信しました。デバイスを待っています...',
tareComplete: '風袋が完了しました!',
tareTimedOut: '風袋がタイムアウトしました — SpoolBuddy デーモンは起動していますか?',
tareFailed: '風袋コマンドの送信に失敗しました',
zeroSet: 'ゼロ点を設定しました。既知の重量を計量台に置いてください。',
calibrationDone: 'キャリブレーション完了!',
+2
View File
@@ -5406,6 +5406,8 @@ export default {
calibrateNow: 'Calibrar',
calibrated: 'Calibrado',
tareSet: 'Comando de tara enviado. Aguardando dispositivo...',
tareComplete: 'Tara concluída!',
tareTimedOut: 'Tempo esgotado para a tara — o daemon do SpoolBuddy está em execução?',
tareFailed: 'Falha ao enviar comando de tara',
zeroSet: 'Ponto zero definido. Coloque o peso conhecido na balança.',
calibrationDone: 'Calibração concluída!',
+2
View File
@@ -5418,6 +5418,8 @@ export default {
calibrateNow: '校准',
calibrated: '已校准',
tareSet: '去皮命令已发送。等待设备响应...',
tareComplete: '去皮完成!',
tareTimedOut: '去皮超时 — SpoolBuddy 守护进程是否正在运行?',
tareFailed: '发送去皮命令失败',
zeroSet: '零点已设置。将已知重量放在秤上。',
calibrationDone: '校准完成!',
+2
View File
@@ -5418,6 +5418,8 @@ export default {
calibrateNow: '校準',
calibrated: '已校準',
tareSet: '去皮命令已傳送。等待裝置回應...',
tareComplete: '去皮完成!',
tareTimedOut: '去皮逾時 — SpoolBuddy 守護程序是否正在執行?',
tareFailed: '傳送去皮命令失敗',
zeroSet: '零點已設定。將已知重量放在磅秤上。',
calibrationDone: '校準完成!',
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
@@ -383,11 +383,70 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
rawAdc: number | null;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');
const [knownWeight, setKnownWeight] = useState('500');
const [tareRawAdc, setTareRawAdc] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ type: 'ok' | 'error'; msg: string } | null>(null);
// Snapshot of device.last_calibrated_at taken when a tare is dispatched.
// The completion watcher below polls the device list and flips the banner
// to "Tare complete!" (or "Tare timed out") once the daemon writes back
// a new last_calibrated_at. Without this watcher the banner just sat at
// "Waiting for device..." forever (#1536).
const [awaitingTareSince, setAwaitingTareSince] = useState<{ snapshot: string | null; startedAtMs: number } | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear any pending auto-dismiss when status changes manually or on unmount.
useEffect(() => {
return () => {
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
};
}, []);
const scheduleStatusDismiss = useCallback((ms: number) => {
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = setTimeout(() => setStatus(null), ms);
}, []);
// Tare-completion watcher: aggressively re-fetch device data while waiting,
// detect when last_calibrated_at advances past the snapshot, then settle
// the banner. Fails open on a 15s timeout so a dead daemon doesn't leave
// the user staring at "Waiting for device..." indefinitely.
useEffect(() => {
if (!awaitingTareSince) return;
const TARE_TIMEOUT_MS = 15000;
const POLL_INTERVAL_MS = 1000;
const elapsed = () => Date.now() - awaitingTareSince.startedAtMs;
const pollHandle = setInterval(() => {
queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
}, POLL_INTERVAL_MS);
const timeoutHandle = setTimeout(() => {
setAwaitingTareSince(null);
setStatus({
type: 'error',
msg: t('spoolbuddy.settings.tareTimedOut', 'Tare timed out — is the SpoolBuddy daemon running?'),
});
scheduleStatusDismiss(5000);
}, Math.max(0, TARE_TIMEOUT_MS - elapsed()));
return () => {
clearInterval(pollHandle);
clearTimeout(timeoutHandle);
};
}, [awaitingTareSince, queryClient, t, scheduleStatusDismiss]);
// When fresh device data arrives, check whether last_calibrated_at moved.
useEffect(() => {
if (!awaitingTareSince) return;
const current = device?.last_calibrated_at ?? null;
if (current !== awaitingTareSince.snapshot) {
setAwaitingTareSince(null);
setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareComplete', 'Tare complete!') });
scheduleStatusDismiss(3000);
}
}, [device?.last_calibrated_at, awaitingTareSince, t, scheduleStatusDismiss]);
const numpadPress = (key: string) => {
if (key === 'backspace') {
@@ -402,11 +461,18 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
const handleTare = async () => {
setBusy(true);
setStatus(null);
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
const snapshot = device?.last_calibrated_at ?? null;
try {
await spoolbuddyApi.tare(device.device_id);
setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareSet', 'Tare command sent. Waiting for device...') });
setAwaitingTareSince({ snapshot, startedAtMs: Date.now() });
} catch {
setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });
scheduleStatusDismiss(5000);
} finally {
setBusy(false);
}
@@ -434,9 +500,11 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
try {
await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);
setStatus({ type: 'ok', msg: t('spoolbuddy.settings.calibrationDone', 'Calibration complete!') });
scheduleStatusDismiss(3000);
setCalStep('idle');
} catch {
setStatus({ type: 'error', msg: t('spoolbuddy.settings.calibrationFailed', 'Calibration failed') });
scheduleStatusDismiss(5000);
} finally {
setBusy(false);
}
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-B9TjqVXt.js"></script>
<script type="module" crossorigin src="/assets/index-anb_3VUN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-y4woBlMv.css">
</head>
<body>