diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb25271f..18923f0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ All notable changes to Bambuddy will be documented in this file. - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. ### Fixed +- **SpoolBuddy "Assign to AMS" pushed Generic instead of the user's custom slicer preset (#1815, reported by @Bgabor997)** — On P1S, assigning a spool with a Bambu Cloud user preset (PFUS-prefix `slicer_filament`) via SpoolBuddy left Bambu Studio's Device tab showing "Generic " instead of the actual custom preset (e.g. "Jayo PETG HF (Custom)"). Manual "Configure" from Bambuddy's AMS card with the same preset worked correctly — reporter triple-checked the preset existed in their slicer and that Bambuddy's inventory showed the right name. **Root cause** in `slicer_filament_resolver.py:197-203`: the defensive filter that catches PFUS / PFCN cloud-preset IDs leaking into `tray_info_idx` (the printer's calibration table can't key on cloud-preset hashes) cleared `setting_id` alongside `tray_info_idx`. `setting_id` is what the slicer uses to find the actual preset, and PFUS / PFCN are VALID values there — they're only invalid as `tray_info_idx`. When the Bambu Cloud detail lookup didn't return a `filament_id` (cloud-unauth on the `on_ams_change` replay path, transient cloud failure, or older custom presets whose detail JSON omits `filament_id`), the resolver fell through to `normalize_slicer_filament` which round-tripped the PFUS as `tray_info_idx`, the defensive filter fired, BOTH fields were cleared, and the caller's generic-material fallback at `inventory.py:157-165` filled `tray_info_idx=GFG99` AND `setting_id=GFSG99` — Bambu Studio resolved the slot to Generic PETG. The manual Configure modal works because the frontend at `ConfigureAmsSlotModal.tsx:503-512` calls the cloud detail API itself, sets `trayInfoIdx` to the resolved `filament_id`, and **preserves** the `PFUS` as `setting_id` in the request — so the backend's MQTT push lands both correct fields. **Fix.** The defensive filter still clears `tray_info_idx` for PFUS / PFCN / material-name leaks, but now preserves `setting_id` when it's a valid slicer reference (`PFUS` / `PFCN` cloud user/shared preset, or `GFS` Bambu official preset). Material-name leaks (e.g. `setting_id="PETG"`) are still cleared — those are never valid slicer references. Post-fix MQTT push carries `tray_info_idx=GFG99` (generic — firmware-acceptable for HMS / drying / colour matching) AND `setting_id=PFUS` (slicer uses this to load the user's actual custom preset). **What stays the same.** Bambuddy's own AMS card still reads `tray_info_idx` from `raw_data` and displays the generic material on cloud-unauth paths — same fundamental limitation as today, because without cloud resolution the backend has no way to know the real `P*` filament_id. This is the secondary symptom the reporter mentioned ("Bambuddy configure modal also shows Generic as default"); fixing it requires a deeper layered fallback (look up `LocalPreset` by name, or query the printer's live `kprofiles`, or cache the resolved cloud detail) and is out of scope for this drop. The slicer-side fix is the user's explicit ask. **Tests.** 5 new cases in `test_slicer_filament_resolver.py`: PFUS cloud-unavailable preserves setting_id (reporter's scenario); PFCN cloud-unavailable preserves setting_id (#1648 partner-preset shape); PFUS cloud-resolved still works as before (regression guard); GFS cloud-unavailable resolves via `normalize_slicer_filament` (regression guard for the Bambu-official cloud-down path); literal material name ("PETG") still clears both (regression guard that PFUS preservation doesn't accidentally preserve material-name leaks). Full backend `pytest -n 30`: 6410/6410 in 73 s. Ruff clean. **Scope.** No DB / API surface / i18n / frontend changes. No change to the caller (`apply_spool_to_slot_via_mqtt`) — its `if tray_info_idx and not setting_id` guard at `inventory.py:172` already preserves whatever setting_id the resolver returns. No change to the manual Configure modal path (already carried both fields end-to-end). The `on_ams_change` replay path in `main.py` (which passes `current_user=None` and was the original motivator for the defensive filter) now also preserves setting_id — same desired outcome since the replay only fires when SpoolBuddy pre-assigned an empty slot and the spool was later inserted, and the slicer needs the setting_id to resolve the right preset. + - **H2S active-tray highlight stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo)** — On H2S (single-nozzle, `n3f` AMS), prints feeding from the external spool showed AMS SLOT 1 highlighted in the UI for the entire job. Display-only — Spoolman usage credit was already correct via the #1276 `ams_mapping=[-1]→254` path — but the active-tray ring on the printer card pointed at the wrong spool. **Root cause** in `bambu_mqtt.py::_handle_ams_data`: X1C / P1S / A1 firmware correctly reports `tray_now=254` when the external spool is the active feed, so the single-nozzle branch's `0–3` passthrough never sees it. H2S firmware instead reports `tray_now=0` (the AMS's idle slot) throughout external-only prints — reporter's MQTT debug log captured 2883 pushes with `tray_now=0`, 143 with `255` (unloaded), zero with `254`. The single-nozzle branch then trusted the wire value and `state.tray_now` landed on slot 0. **Fix.** The single-nozzle branch now checks `_captured_ams_mapping` (the slicer-captured per-filament mapping that the request-topic intercept already tracks) before the existing P2S multi-AMS resolver runs. When every entry is `-1` (the print uses ONLY the external spool — `-1` is the canonical external sentinel), `state.tray_now` is promoted to `254` regardless of what the AMS dict says. The override is intentionally narrow: it only fires when the captured mapping is non-empty AND every entry equals -1. Mixed prints (`[5, -1]`) and AMS-only prints (`[5]`) are NOT overridden — reporter only confirmed the bug for the all-external case, and we have no evidence H2S misreports mid-print swaps; trusting the firmware on those paths preserves correctness for users with multi-filament setups. Prints started without a captured mapping (printer-screen start, or before Bambuddy connected to MQTT) fall through unchanged — the wrong value persists in that edge case, but no other regression. **No model gating.** Future single-nozzle models with the same firmware quirk inherit the fix for free, and printers that already report `254` correctly enter the override branch but the assignment is a no-op (assigning 254 when the wire said `tray_now=254` requires `parsed_tray_now <= 3` to be false in the first place — the branch never even reaches them). Dual-nozzle (H2D / H2C / X2D), the P2S multi-AMS local-slot resolver (#420), `tray_now > 3` (already a global ID), and `tray_now=255` (unloaded) are all unchanged. **Tests.** 7 new cases in `TestTrayNowH2SExternalSpoolOverride` pinning every limb of the contract: all-external `[-1]` promotes; multi-external `[-1, -1, -1]` also promotes; AMS-only `[5]` does NOT override; mixed `[5, -1]` does NOT override; `_captured_ams_mapping=None` does NOT override; empty list `[]` does NOT override (defensive — `all([])` returns True, so we explicitly guard); unload after override correctly transitions `254 → 255`. Adjacent single-nozzle X1E and P2S test classes stay green — they don't set `_captured_ams_mapping` so the new branch falls through to the unchanged path. Full backend `pytest -n 30`: 6405/6405 in 71 s. Ruff clean. **No frontend, schema, or API surface change** — the wire format and the `PrinterStatus.tray_now` field shape are unchanged; only the value computed for that field on H2S external-only prints is now correct. - **`require_previous_success` permanently blocked a printer's queue after one failure (#1818, reported by @jmassardo)** — Reporter scenario (P1S, farm-style queue): every queued job had "Only start if previous print succeeded" set; the first job failed (filament tangle / runout / clog); every subsequent job — including brand-new ones added after the printer was fixed and back online — was silently marked `skipped` with no path back. The only workaround was to delete each item and re-create it through PrintModal, impractical at farm scale. **Root cause** in `print_scheduler.py::_check_previous_success`: the lookback query returned the most recent terminal item in (`completed`, `failed`, `cancelled`, `aborted`) — `skipped` is intentionally excluded (#1667). Once the original failure landed, the lookback always walked back to that same failed item — every new skip is excluded from the lookback so it doesn't shift the window. With no code path to dismiss the originating failure, the gate stayed closed forever. "Clear Plate" only resolved the orthogonal plate-clear gate (`_is_printer_idle`); nothing in Bambuddy acknowledged a resolved failure. **Fix.** New `PrintQueueItem.gate_acknowledged: bool` column (default False). Postgres/SQLite-safe ALTER following the existing #1794 / stock-alert migration shape (`DEFAULT 0` on SQLite, `DEFAULT false` on Postgres). `_check_previous_success` adds `AND gate_acknowledged == False` to its lookback so acknowledged failures are walked past — back to whatever real predecessor came before, or to the no-predecessor-passes case. **New per-printer endpoint.** `POST /api/v1/queue/printer/{printer_id}/resume` (gated on `QUEUE_UPDATE_ALL`) does both halves of the resume in one atomic transaction: (1) `gate_acknowledged=True` on every failed/aborted item for that printer that's still gating; (2) flips items where `status='skipped' AND error_message='Previous print failed or was aborted'` back to `pending`, clears `error_message` + `completed_at`. Returns `{acknowledged, restored}` counts so the UI can render a precise toast. Per-printer scoped — a resume on printer A never touches printer B. Per-item acknowledgement is independent — a fresh failure AFTER a resume re-gates downstream items, so users don't silently steamroll past a new real problem. The endpoint is idempotent (second call after the first sees acknowledged=0, restored=0). Also intentionally narrow: skipped items whose `error_message` is something OTHER than the exact gate string (e.g. future skip reasons, manual UI skips) are left untouched — those encode different user intent. **Frontend.** Per-printer alert banner at the top of the Queue tab (above the layout / filter row) shown when a printer has at least one skipped item with the gate `error_message`. Banner is permission-gated on `queue:update_all` so viewers don't see a button they can't use. Each blocked printer gets its own row: an `AlertCircle` icon, a one-line "{printer} is blocked by a previous-print failure — N job(s) skipped" headline, a "Fix the printer issue, then resume to restore the skipped jobs and clear the gate." hint, and a "Resume after failure" button on the right that opens a warning-variant `ConfirmModal` with the printer name + count. Confirm fires the new mutation, invalidates `['queue']`, and shows a toast — "Resumed queue — N job(s) restored to pending". Banner disappears the moment the action lands. Visible on the active Queue tab regardless of layout (`position` or `printer`); History/Timeline tabs unchanged. **Backend tests** (12 new): 4 new `_check_previous_success` cases in `test_check_previous_success.py` covering acknowledged failure ignored, acknowledged aborted ignored, fresh failure after ack STILL gates (independence guarantee), acknowledged failure walks back to the prior completed predecessor. 7 new `TestResumeQueueAfterFailure` cases in `test_print_queue_api.py` covering unknown printer 404, clean-queue no-op, reporter's failed+N-skipped scenario, scoped to the requested printer only, aborted-status acknowledgement, narrow `error_message` filter (don't touch other skip reasons), second call is a no-op. Full backend `pytest -n 30`: 6398/6398 green, 75 s. `ruff check backend/` clean. **Frontend** — 1 new API client method (`resumeQueueAfterFailure`), parity check 5352 × 11 locales green (no English fallback). `npm run build` clean, `npm run lint` clean. **i18n.** 7 new keys (`queue.toast.resumedAfterFailure`, `queue.toast.resumeAfterFailureFailed`, `queue.resumeAfterFailure.banner` / `bannerHint` / `button` / `confirmTitle` / `confirmMessage`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Scope.** No change to `_is_printer_idle` (Clear Plate stays orthogonal). No change to `#1667` `skipped`-excluded-from-lookback semantics — those are still load-bearing for the cancellation-cascade fix. No change to filament-deficit-skip path (different error_message → untouched). One legacy column carries a row state where `gate_acknowledged=True`; the column never gets written by anything except the resume endpoint, so it's effectively an opt-in row marker. No new permission — `QUEUE_UPDATE_ALL` already exists and is the appropriate scope for a printer-level operation that may affect items the user doesn't own. diff --git a/backend/app/services/slicer_filament_resolver.py b/backend/app/services/slicer_filament_resolver.py index 38b9c1d9f..22a69b80d 100644 --- a/backend/app/services/slicer_filament_resolver.py +++ b/backend/app/services/slicer_filament_resolver.py @@ -200,6 +200,19 @@ async def resolve_slicer_filament( or tray_info_idx.startswith("PFCN") ): tray_info_idx = "" - setting_id = "" + # Preserve setting_id when it's still a valid slicer reference + # (PFUS / PFCN cloud user/shared preset, or GFS Bambu official + # preset). The slicer accepts these as setting_id even though + # they're rejected as tray_info_idx; without preservation the + # slicer falls back to whatever generic filament the caller's + # tray_info_idx fallback produces and shows "Generic " + # instead of the user's actual custom preset (#1815). Material-name + # leaks (e.g. setting_id="PETG") are still cleared — those are + # never valid slicer references. + if not ( + setting_id + and (setting_id.startswith("PFUS") or setting_id.startswith("PFCN") or setting_id.startswith("GFS")) + ): + setting_id = "" return (tray_info_idx, setting_id, sub_brand_override) diff --git a/backend/tests/unit/services/test_slicer_filament_resolver.py b/backend/tests/unit/services/test_slicer_filament_resolver.py new file mode 100644 index 000000000..29932c3dd --- /dev/null +++ b/backend/tests/unit/services/test_slicer_filament_resolver.py @@ -0,0 +1,142 @@ +"""Tests for ``resolve_slicer_filament`` (#1815). + +The defensive filter at the end of the resolver clears ``tray_info_idx`` +when its value isn't slicer-acceptable (literal material names + PFUS / +PFCN cloud-preset prefixes that the printer's calibration table can't +key on). Pre-#1815 it cleared ``setting_id`` alongside, which dropped +the slicer's only handle on the user's actual custom preset and forced +the caller into the generic-material fallback — Bambu Studio then +displayed "Generic " for spools whose Bambu Cloud detail +lookup didn't resolve a ``filament_id`` (cloud unauth on the on_ams_change +replay path, transient cloud failure, or custom presets whose detail +JSON omits ``filament_id``). + +Post-#1815 the filter preserves a setting_id that's still a valid +slicer reference (PFUS / PFCN cloud user/shared preset, or GFS Bambu +official preset) even when ``tray_info_idx`` is cleared. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.slicer_filament_resolver import resolve_slicer_filament + + +@pytest.mark.asyncio +async def test_pfus_cloud_unavailable_preserves_setting_id(): + """Reporter scenario: PFUS cloud user preset, cloud lookup fails to + return a filament_id. setting_id must survive so the slicer can + still find the user's actual custom preset.""" + db = MagicMock() + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + AsyncMock(return_value=None), + ): + tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament( + db=db, + current_user=None, + slicer_filament="PFUS990b6e19965353", + slicer_filament_name="Jayo PETG HF", + material="PETG", + ) + assert tray_info_idx == "" + assert setting_id == "PFUS990b6e19965353" + assert sub_brand is None + + +@pytest.mark.asyncio +async def test_pfcn_cloud_unavailable_preserves_setting_id(): + """PFCN partner/shared cloud preset (e.g. Polymaker H2D variants, + #1648) shares the same shape problem as PFUS.""" + db = MagicMock() + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + AsyncMock(return_value=None), + ): + tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament( + db=db, + current_user=None, + slicer_filament="PFCN1234567890", + slicer_filament_name="Polymaker PolyTerra PLA", + material="PLA", + ) + assert tray_info_idx == "" + assert setting_id == "PFCN1234567890" + assert sub_brand is None + + +@pytest.mark.asyncio +async def test_pfus_cloud_resolves_filament_id_regression_guard(): + """When cloud auth works and returns a filament_id, the resolver + keeps its existing behaviour: tray_info_idx = real filament_id, + setting_id = original PFUS reference.""" + db = MagicMock() + cloud_mock = MagicMock() + cloud_mock.is_authenticated = True + cloud_mock.get_setting_detail = AsyncMock(return_value={"filament_id": "P285e239", "name": "Jayo PETG HF @P1S"}) + cloud_mock.close = AsyncMock() + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + AsyncMock(return_value=cloud_mock), + ): + tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament( + db=db, + current_user=MagicMock(), + slicer_filament="PFUS990b6e19965353", + slicer_filament_name="Jayo PETG HF", + material="PETG", + ) + assert tray_info_idx == "P285e239" + assert setting_id == "PFUS990b6e19965353" + assert sub_brand == "Jayo PETG HF" + + +@pytest.mark.asyncio +async def test_gfs_cloud_unavailable_resolves_via_normalize(): + """GFS Bambu official preset + cloud unavailable: normalize strips + the 'S' to give a real filament_id ('GFG02'), so tray_info_idx is + valid and the defensive filter doesn't trigger. setting_id stays as + the original GFS reference. Regression guard for the cloud-down + Bambu-official path.""" + db = MagicMock() + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + AsyncMock(return_value=None), + ): + tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament( + db=db, + current_user=None, + slicer_filament="GFSG02", + slicer_filament_name=None, + material="PETG", + ) + assert tray_info_idx == "GFG02" + assert setting_id == "GFSG02" + assert sub_brand is None + + +@pytest.mark.asyncio +async def test_literal_material_name_clears_both(): + """slicer_filament='PETG' (free-text material leak from legacy + spools): both tray_info_idx and setting_id must be cleared so the + caller's generic-material fallback rescues the slot. Regression + guard that the PFUS preservation doesn't accidentally preserve + literal material names.""" + db = MagicMock() + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + AsyncMock(return_value=None), + ): + tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament( + db=db, + current_user=None, + slicer_filament="PETG", + slicer_filament_name=None, + material="PETG", + ) + assert tray_info_idx == "" + assert setting_id == "" + assert sub_brand is None