fix(spoolbuddy): route weight sync by inventory mode exclusively (#1530)

POST /spoolbuddy/scale/update-spool-weight tried the local DB first
  and only fell back to Spoolman on a local miss. Combined with
  nfc/tag-scanned's post-#1119 always-Spoolman routing, a stale local
  Spool row sharing a numeric id with a Spoolman spool would absorb
  the sync silently while the Spoolman row stayed unchanged.

  Mirror the routing already used by nfc/tag-scanned: pick the branch
  via _get_spoolman_client_or_none() and never cross. Local mode now
  returns 404 on a local miss instead of falling through.

  New TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman
  asserts both directions: Spoolman gets the update, the colliding
  local row's weight_used and last_scale_weight are untouched.
This commit is contained in:
maziggy
2026-05-26 10:36:37 +02:00
parent e34958c3fa
commit 4387a09162
3 changed files with 53 additions and 10 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 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.
- **Paused prints no longer inflate maintenance hours (#1521, reported by @TempleClause)** — The `track_printer_runtime` background task in `backend/app/main.py` counted both `RUNNING` and `PAUSE` states equally toward `runtime_seconds`, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure *mechanical wear*, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line in `main.py` and three ranked solution options. **Fix**: option 1 (exclude PAUSE entirely) — `state.state in ("RUNNING", "PAUSE")` → `state.state == "RUNNING"`. PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, and `last_runtime_update` is cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated across `main.py`, `models/printer.py:23`, and the two `api/routes/maintenance.py` route docstrings that all previously described the field as covering "RUNNING and PAUSE states". **Out of scope**: retroactive backfill of existing `runtime_seconds` values — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. **Tests**: 3 new in `test_runtime_tracking_pause.py` pinning the new contract — PAUSE does NOT accumulate and clears `last_runtime_update`; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clears `last_runtime_update` to prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actual `track_printer_runtime()` coroutine through a single iteration via patched `asyncio.sleep` against an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green.
- **Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by @IndividualGhost1905)** — Reporter saw `Total prints: 20 / Success: 18 / Failed: 1` and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that *locked in* the bug — `it('uses total_prints as denominator so cancelled/stopped events count')` asserted the gauge should divide by `total_prints`, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. **Cause**: `PrintLogEntry.status` has six values in production (`completed`, `failed`, `aborted`, `stopped`, `cancelled`, `skipped`) but the Quick Stats endpoint in `api/routes/archives.py` only counted two — `completed` → Successful, `status == "failed"` → Failed — and used a raw `count(*)` for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row. `aborted` was particularly silent: classified as a failure elsewhere in the codebase (`failure_analysis.py`, `main.py:430,1729`) but not counted toward `failed_prints` in stats. **Fix**: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returns `successful_prints` (completed), `failed_prints` (failed + aborted — printer-detected quality failures), and a new `cancelled_prints` (stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides by `successful + failed` only, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (`failure_rate = failed / (successful + failed)`) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. **Schema change is additive-safe**: `ArchiveStats.cancelled_prints` defaults to `0` so any historical fixture validating against the model still parses; the frontend type also defaults the display to `0` when the field is missing. **i18n**: new `stats.cancelled` key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per [[feedback_translate_dont_fallback]]; parity script clean at 4994 leaves per locale. **Tests**: existing `it('uses total_prints as denominator …')` test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35), `cancelled_prints: 0` added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since `140 / (140 + 10) = 93.33%` rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. **Follow-up (cosmetic):** the new Cancelled row's Ban icon rendered in `text-bambu-gray` while the Successful and Failed icons used semantic `text-status-ok` / `text-status-error` tokens — reporter (@IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row to `text-status-warning` (amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI.
- **Support bundle + bug-report submission now include the live diagnostic snapshot** — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since 6bc6a1d6 / e222a0ef / ed31b8f4, but the results were only ever shown to the *user* — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. **Fix**: new `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size — `asyncio.gather`, total ≈ max(per-cap) not sum). Fail-soft per probe: a crash inside one printer's check emits `{"printer_id": N, "error": "..."}` for that entry rather than nuking the whole snapshot — partial result beats a 500. Wired into `_collect_support_info()` so both flows (`POST /support/bundle` and `POST /bug-report/submit` via `support_info=...`) pick up the new `diagnostics` top-level key without their own changes. **Private-data sanitization** — the diagnostic schemas embed raw IPv4 in three places (`PrinterDiagnosticResult.ip_address`, network-mode check's `params.{printer_ip, host_ip}`, VP diagnostic's `params.bind_ip`), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer already applies (via the shared `collect_sensitive_strings`), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by `_get_host_ip()` and any VP `bind_ip` the user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. **Progress indicators**: the bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit/build ZIP) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. **Tests**: 6 new in `test_diagnostic_snapshot.py` — empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash, `timed_out` marker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s), end-to-end IP sanitization across all five field shapes (top-level `ip_address`, `printer_ip`, `host_ip`, `bind_ip`, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean.
+19 -10
View File
@@ -867,15 +867,28 @@ async def update_spool_weight(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Update spool's used weight from scale reading."""
"""Update spool's used weight from scale reading.
Routes the update to whichever inventory backend Bambuddy is configured
for: Spoolman exclusively when ``spoolman_enabled`` is true, local DB
exclusively otherwise. The previous implementation tried local first and
only consulted Spoolman on a local-DB miss, which meant a stale local row
sharing a numeric id with a Spoolman spool would silently absorb the
update while the Spoolman row the user is actually looking at stayed
unchanged (#1530). Mirrors the routing already used by ``nfc/tag-scanned``.
"""
from backend.app.api.routes._spoolman_helpers import _safe_float
from backend.app.models.spool import Spool
# Try local DB first — local spool IDs must not be forwarded to Spoolman.
db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = db_result.scalar_one_or_none()
sm_client = await _get_spoolman_client_or_none(db)
if sm_client is None:
# Local mode — exclusive update, no Spoolman fallback.
db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = db_result.scalar_one_or_none()
if not spool:
raise HTTPException(status_code=404, detail="Spool not found")
if spool:
net_filament = max(0, req.weight_grams - spool.core_weight)
spool.weight_used = max(0, spool.label_weight - net_filament)
spool.last_scale_weight = req.weight_grams
@@ -889,11 +902,7 @@ async def update_spool_weight(
)
return {"status": "ok", "weight_used": spool.weight_used}
# Local miss — fall back to Spoolman when enabled.
sm_client = await _get_spoolman_client_or_none(db)
if sm_client is None:
raise HTTPException(status_code=404, detail="Spool not found")
# Spoolman mode — exclusive update, never touch local DB.
async with _translate_spoolbuddy_errors():
sm_spool = await sm_client.get_spool(req.spool_id)
@@ -1608,6 +1608,39 @@ class TestUpdateSpoolWeightSpoolman:
assert resp.status_code == 200
assert resp.json()["weight_used"] == 500
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stale_local_row_does_not_shadow_spoolman(
self, async_client: AsyncClient, db_session, spool_factory, spoolman_settings
):
"""Regression for #1530: when Spoolman mode is on, a stale local Spool
sharing the same numeric id must NOT absorb the update — Spoolman is
the authoritative target."""
local_spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
# Spoolman spool with the SAME numeric id as the local stale row.
sm_spool = _spoolman_spool_fixture(local_spool.id, spool_weight=250.0, filament_weight=1000.0)
mock_client = _mock_spoolman_client()
mock_client.get_spool = AsyncMock(return_value=sm_spool)
mock_client.update_spool = AsyncMock(return_value=sm_spool)
with (
patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
):
resp = await async_client.post(
f"{API}/scale/update-spool-weight",
json={"spool_id": local_spool.id, "weight_grams": 750},
)
assert resp.status_code == 200
# Spoolman got the update.
mock_client.update_spool.assert_called_once_with(spool_id=local_spool.id, remaining_weight=pytest.approx(500.0))
# Local row is untouched — the bug was that the local update silently
# absorbed the request while Spoolman stayed stale.
await db_session.refresh(local_spool)
assert local_spool.weight_used == 0
assert local_spool.last_scale_weight is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):