diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d5ff98b..2fc403ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,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 +- **Connection diagnostic reported false camera-port warning on A1 / A1 Mini / P1 (#1798, reported and fixed by @lesbass / Stefano Maffeis in #1799)** — The general Connection Diagnostic (`printer_diagnostic.py:124`) probed RTSPS port 322 unconditionally, even for printers that don't speak RTSP. A1, A1 Mini, and P1-family cameras use the chamber-image protocol on port 6000 — Bambuddy's live camera client already routes them there via `camera.py::get_camera_port(model)`. Result: a saved A1 Mini with a working live webcam still saw "Camera port (RTSPS 322) — Port 322 is unreachable" in the diagnostic, and overall status flipped to `warnings` for a healthy printer. Reporter confirmed: TCP probe from the container showed 6000 open, 322 refused; camera-specific diagnostic returned `protocol=chamber_image, port=6000, overall_status=ok`. **Fix.** The diagnostic now resolves the camera port via the same `get_camera_port()` the camera client uses (single source of truth — adding a new model in `camera.py` propagates here for free). Saved A1 / A1 Mini / P1 → probe 6000, render "Camera port (Chamber Image 6000)". Saved X1 / X2 / H2 / P2 → unchanged, still probe 322 / RTSPS. Pre-save Add-Printer diagnostic where the model isn't known yet falls back to 322 / RTSPS — same conservative default as before, so the Add-Printer flow doesn't regress for users on RTSP models. The diagnostic check id stays `port_rtsps` for backward compatibility with snapshot JSON consumers; the actual port / protocol now travel in `params` and the localized title and warn text interpolate `{{protocol}}` / `{{port}}`. **Frontend.** `ConnectionDiagnostic.tsx` interpolates the title (previously static) and merges defensive defaults (`{ protocol: 'RTSPS', port: 322, ...check.params }`) for `port_rtsps` so an older backend or cached response still renders sensibly. **i18n.** Title and warn strings updated in all 11 locales (en / de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) to use `{{protocol}}` / `{{port}}` interpolation. `npm run check:i18n` clean. **Tests.** 2 new backend cases (A1 Mini → 6000 / Chamber Image with the 322 fallback closed asserting `port_rtsps=pass`; X1C → still probes 322 / RTSPS asserting the existing behavior is unchanged) and 1 new frontend case asserting the rendered "Camera port (Chamber Image 6000)" + "Port 6000 is unreachable" text. `_port_probe` defaults extended to include port 6000. Existing model-less tests unchanged — they go through the `printer.model is None` fallback path and still probe 322. + - **Print-complete notification dropped the finish photo when the FINISH-state fallback fired (#1790, reported by @needo37)** — On the FINISH-state fallback path (`bambu_mqtt.py:3258-3297`, used when stage-22 doesn't fire — cancel, external-spool-only, HMS halt, firmware variants that skip the unload phase), `on_finish_photo_moment` and `on_print_complete` were dispatched as two **independent** asyncio tasks back-to-back from the same MQTT handler. The producer (`on_finish_photo_moment`) ran the RTSP grab (15s timeout) and stored the JPEG into `_stage22_finish_frames[printer_id]` only after the grab returned; the consumer (`_background_finish_photo`, spawned by `on_print_complete`) read the cache with a single `pop()` at `main.py:4681` — no wait, no retry. On the stage-22 happy path the producer fires seconds before FINISH-state arrives so the race is invisible; on the FINISH-state fallback the gap collapses to ~0 and the consumer always wins the empty pop. After the empty pop, the fallback chain called `capture_finish_photo()` at `main.py:4739` — but the producer's RTSP grab was still in flight against the same printer, and Bambu printers allow exactly one RTSP client at a time. The consumer's grab timed out at the camera service's 30s ceiling. Reporter's log shows it exactly: `[FINISH-PHOTO-MOMENT] captured RTSP frame (394037 bytes)` at 05:31:20, then `[PHOTO-NOTIFY] Photo task returned: None` at 05:31:49 — 30s after `[PHOTO-BG] Starting`. A 394 KB frame was captured, the notification went text-only. **Why this only surfaced after #1721:** before #1721, Bambuddy force-enabled timelapse at dispatch so a video always existed and the finish photo was extracted from its last frame regardless of timing. #1721 removed the force-on (it was causing per-layer nozzle parking on Smooth-mode slicer profiles) and made the racy stage-22 cache the only good framing source. For timelapse-off prints completing via the FINISH-state fallback, there was no resilient source left. **Fix.** New per-printer `_stage22_finish_in_flight: dict[int, asyncio.Event]` synchronizes producer→consumer. The producer registers an `asyncio.Event` BEFORE its first `await` (so the consumer always sees it the moment it polls — registration is purely synchronous before any await yields control), sets the event in a `finally` block on EVERY exit path (success, no-frame, setting-disabled early return, exception), and the consumer awaits the event with `asyncio.wait_for(event.wait(), timeout=20.0)` before reading the cache. The 20s ceiling is sized against the producer's 15s RTSP timeout — bounded headroom, can't hang notifications. The consumer pops the dict entry when it starts waiting so cleanup is a single side; the producer's `set()` works on a local ref. Side-effect win: because the consumer is blocked behind the producer's completion, the consumer's own RTSP fallback can no longer collide with the producer's in-flight grab — Failure 2 (concurrent RTSP timeout) is closed alongside Failure 1 (cache race) by the same change. **What this does NOT change.** The timelapse path (`timelapse_was_active=True`) returns before registering the event — the consumer takes the `_capture_finish_photo_from_timelapse` branch and never waits; no regression. Aborted / failed prints don't dispatch `on_finish_photo_moment` at all (status="completed" gate at `bambu_mqtt.py:3258`) — no event registered, consumer behaves as today. External-camera printers (`external_camera_enabled`) and printers with a live stream open in the UI (buffered RTSP frame) are unaffected — the producer still uses those non-contended sources first. No change to `camera.py` lock semantics. **Tests.** 7 new cases in `test_finish_photo_moment_sync.py` pin every limb of the contract: event is registered before the first await (uses a slow-capture stub to observe the dict mid-run), event is set after successful capture, event is set when the producer captured no frame, event is set even when the capture function raises (the `finally` is load-bearing), event is NOT registered on the `timelapse_was_active=True` early-return, event IS set when the `capture_finish_photo` setting is disabled (the late early return — important so the consumer doesn't hang on a no-op producer), and an end-to-end producer/consumer pair finishes promptly with the cached frame visible to the consumer. Adjacent tests (`test_finish_photo_from_timelapse.py`, `test_reprint_clears_stale_timelapse.py`) still green. Ruff clean. - **Archives drag-and-drop overlay stuck after cancel (#1510, reported by @maikolscripts)** — Cancelling a drag on the Archives page — by dragging back out of the browser window, releasing outside the page, or pressing Escape mid-drag — left the full-screen "Drop .3mf files here" overlay visible until the user refreshed. **Cause.** The old inline `handleDragLeave` only hid the overlay when `e.currentTarget === e.target` (i.e. the dragLeave event fired on the wrapper itself, not a child). That condition was structurally safe for crossing internal element boundaries but rarely held for the three cancel paths above — drag-out-of-window fires dragLeave with `target` at the nearest child to the cursor; Escape and drag-abort fire no leave event at all on the wrapper. **Fix.** Moved the page-wide drop handling into the new `usePageFileDrop` hook (also consumed by File Manager — see the linked Added entry). The hook checks `relatedTarget` containment instead of `currentTarget === target`, and adds document-level `drop` / `dragend` / `keydown(Escape)` listeners that only register while `isDraggingOver === true` so the cancel paths all reset uniformly. Three of the 13 new hook test cases pin the cancel paths explicitly so a future regression on any one of them fails its own case. Also moved the previously-hardcoded English "Drop .3mf files here" string in `ArchivesPage.tsx:3202` to the existing `archives.page.dropFilesHere` i18n key (which already had translations in all 11 locales) so the overlay localises correctly — same change of behaviour as `archives.releaseToUpload` already had.