diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca082691..3a48363a6 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 +- **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. - **Archives "Step 4" docs link 404'd (#1812, reported by @Spanholz)** — The Archives page's "Some prints couldn't be archived with thumbnails" amber banner linked to `https://bambuddy.cool/wiki/getting-started/#step-4-enable-store-sent-files-on-external-storage` — the wiki lives at the `wiki.bambuddy.cool` subdomain, not under `bambuddy.cool/wiki/`, so the link 404'd. Anchor was already correct (MkDocs slugifies the existing "Step 4: Enable Store sent files on external storage" heading to that exact id). Single-line fix in `frontend/src/pages/ArchivesPage.tsx:3271`; no i18n / no tests touched. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index b5d8a05a9..99a815450 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1766,35 +1766,55 @@ class BambuMQTTClient: self.state.tray_now = parsed_tray_now elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3: # Single-nozzle printer with tray_now in 0-3 range. - # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs - # in tray_now, not global tray IDs (#420). Use the MQTT mapping field - # (snow-encoded) to resolve the correct AMS unit. - ams_exist_raw = ams_data.get("ams_exist_bits", "0") - try: - ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw) - except (ValueError, TypeError): - ams_exist = 0 - num_ams = bin(ams_exist).count("1") - - if num_ams > 1: - # Multiple AMS on single-nozzle — tray_now is likely a local slot ID. - # Cross-reference with MQTT mapping field to find the correct AMS unit. - mapping_raw = self.state.raw_data.get("mapping") - resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw) - if resolved is not None: - if resolved != parsed_tray_now: - logger.debug( - f"[{self.serial_number}] Multi-AMS tray_now: " - f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)" - ) - self.state.tray_now = resolved - else: - # No mapping available (not printing, or ambiguous) — use as-is. - # This matches the old behavior and is correct for AMS 0. - self.state.tray_now = parsed_tray_now + # #1822: H2S firmware reports tray_now as the AMS's idle + # slot (typically 0) when the active feed is actually the + # external spool. X1C / P1S / A1 correctly report 254 in + # that case; H2S does not. When the slicer-captured + # ams_mapping is all-external (every entry == -1), the + # print can only be feeding from the external spool, so + # promote tray_now to 254. Mixed (e.g. [5, -1]) and + # AMS-only mappings are NOT overridden — there's no + # evidence the firmware misreports in those cases. Prints + # started without a captured mapping (printer-screen start, + # or before Bambuddy connected) fall through unchanged. + captured = self._captured_ams_mapping + if captured and all(s == -1 for s in captured): + if self.state.tray_now != 254: + logger.debug( + f"[{self.serial_number}] tray_now external-spool override (#1822): " + f"slot {parsed_tray_now} -> 254 (ams_mapping={captured})" + ) + self.state.tray_now = 254 else: - # Single AMS — local slot 0-3 equals global ID - self.state.tray_now = parsed_tray_now + # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs + # in tray_now, not global tray IDs (#420). Use the MQTT mapping field + # (snow-encoded) to resolve the correct AMS unit. + ams_exist_raw = ams_data.get("ams_exist_bits", "0") + try: + ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw) + except (ValueError, TypeError): + ams_exist = 0 + num_ams = bin(ams_exist).count("1") + + if num_ams > 1: + # Multiple AMS on single-nozzle — tray_now is likely a local slot ID. + # Cross-reference with MQTT mapping field to find the correct AMS unit. + mapping_raw = self.state.raw_data.get("mapping") + resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw) + if resolved is not None: + if resolved != parsed_tray_now: + logger.debug( + f"[{self.serial_number}] Multi-AMS tray_now: " + f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)" + ) + self.state.tray_now = resolved + else: + # No mapping available (not printing, or ambiguous) — use as-is. + # This matches the old behavior and is correct for AMS 0. + self.state.tray_now = parsed_tray_now + else: + # Single AMS — local slot 0-3 equals global ID + self.state.tray_now = parsed_tray_now else: # tray_now > 3 means it's already a global ID, or 255 means unloaded # Note: Do NOT clear pending_tray_target on tray_now=255 here. diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 68d808025..49d46f564 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -5884,3 +5884,87 @@ class TestAmsFilamentBackupHoldTimer: assert mqtt_client.state.ams_filament_backup is True # Hold timer still armed — sub-second push didn't reset it. assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold + + +# --------------------------------------------------------------------------- +# 2c. Single-nozzle H2S — external-spool tray_now override (#1822) +# --------------------------------------------------------------------------- + + +class TestTrayNowH2SExternalSpoolOverride: + """H2S firmware reports tray_now as the AMS's idle slot (typically 0) + instead of 254 when the active feed is the external spool. + + Bambuddy detects the all-external case via the slicer-captured + ams_mapping (every entry == -1) and promotes tray_now to 254 so the + UI active-tray highlight matches the real feed. + + The override is intentionally narrow: + * only fires when ams_mapping is captured AND every entry is -1 + * does not touch mixed prints ([5, -1]) or AMS-only prints ([5]) + * does not fire when no ams_mapping is captured (printer-screen start) + """ + + @pytest.fixture + def mqtt_client(self): + from backend.app.services.bambu_mqtt import BambuMQTTClient + + return BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST_H2S", + access_code="12345678", + ) + + def test_all_external_mapping_promotes_tray_now_to_254(self, mqtt_client): + """Reporter's scenario: H2S, single nozzle, captured ams_mapping=[-1], + firmware sends tray_now=0 -> Bambuddy promotes to 254.""" + mqtt_client._captured_ams_mapping = [-1] + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 254 + + def test_multi_external_mapping_also_promotes(self, mqtt_client): + """Multi-filament print, every filament mapped to external. Still + all-external -> still promotes.""" + mqtt_client._captured_ams_mapping = [-1, -1, -1] + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 254 + + def test_ams_only_mapping_does_not_override(self, mqtt_client): + """ams_mapping=[5] (AMS slot 5 only) -> firmware value trusted as-is. + Without the all-external guard, this would falsely override real + AMS-slot prints.""" + mqtt_client._captured_ams_mapping = [5] + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 0 + + def test_mixed_mapping_does_not_override(self, mqtt_client): + """Mixed mapping (AMS slot 5 + external): we have no evidence the + firmware misreports mid-print swaps, so leave tray_now alone.""" + mqtt_client._captured_ams_mapping = [5, -1] + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 0 + + def test_no_captured_mapping_does_not_override(self, mqtt_client): + """Prints started from the printer screen (or before Bambuddy + connected) have no captured ams_mapping. Behaviour unchanged from + pre-#1822 — we accept the wrong value rather than guess.""" + mqtt_client._captured_ams_mapping = None + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 0 + + def test_empty_captured_mapping_does_not_override(self, mqtt_client): + """Empty list (defensive — should not happen in practice but + all([]) returns True). Treat as no signal, not as all-external.""" + mqtt_client._captured_ams_mapping = [] + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 0 + + def test_unloaded_after_print_still_resolves_correctly(self, mqtt_client): + """When the firmware unloads (tray_now=255), the override is skipped + because the branch only fires for tray_now in 0-3.""" + mqtt_client._captured_ams_mapping = [-1] + mqtt_client._process_message(_ams_payload(0)) + assert mqtt_client.state.tray_now == 254 + + mqtt_client._process_message(_ams_payload(255)) + assert mqtt_client.state.tray_now == 255