diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc7b759b..344a7e57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ All notable changes to Bambuddy will be documented in this file. - **Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0)** — Until now the folder tree was always sorted alphabetically by name, both backend (`order_by(LibraryFolder.name)`) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. **What changed.** The folder sidebar header gains a small dropdown (**By name** / **By recent activity**) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser via `localStorage` (`library-folder-sort-field`, `library-folder-sort-direction`) so the preference survives reloads. **Activity semantics.** `latest_activity_at` per folder = `MAX(folder.updated_at, MAX(immediate-child file.updated_at))`. The DB had the data — `LibraryFile.updated_at` is `onupdate=func.now()` and `LibraryFolder.updated_at` the same — but `LibraryFolder.updated_at` alone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally **NOT** computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a single `GROUP BY` rather than a recursive CTE, matching the existing file_counts subquery shape sibling at `library.py:746`. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. **Backend.** New `latest_activity_at: datetime | None` field on `FolderResponse` and `FolderTreeItem` schemas. The `/folders` tree route picks up a sibling `func.max(LibraryFile.updated_at)` group-by alongside the existing file-count subquery; resolves the field per row. The `/folders/by-project/{id}` and `/folders/by-archive/{id}` routes collapse their per-row file-count subquery to fetch `count + max` in one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST `/folders`, GET `/folders/{id}`, PUT `/folders/{id}`, POST `/folders/external`, the create flows) populate the field with `max(folder.updated_at, latest_file)` or fall back to `folder.updated_at` when there are no files, so the API surface is consistent across every route that returns a folder. **External folders.** `LibraryFile` rows are created for scanned external files too (`library.py:526`), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last *scanned / re-indexed* the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over with `os.stat()` on every list call, which would stall the route on slow mounts. **Frontend.** A new recursive `sortedFolders` `useMemo` applies the comparator uniformly to top-level + every nested `children` level so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consume `sortedFolders` so the order is identical across breakpoints. The single-folder `findFolder()` traversal and `selectedFolder` memo still operate on the unsorted `folders` because they index by ID — sort-order-independent. **Recursion safety.** The sort creates fresh object refs at every level on every memo invocation; the `FolderTreeItem` keys stay ID-based (`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`) so React reconciliation by ID preserves folder expansion state across sort flips. **i18n.** 3 new keys in `fileManager.*` (`folderSort`, `folderSortByName`, `folderSortByActivity`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. **Tests.** 2 new backend integration cases in `test_library_api.py` (file-in-folder bubbles `latest_activity_at` to the file's timestamp, empty folder falls back to `folder.updated_at`). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass; `npm run build` clean; `ruff` clean; i18n parity green. ### Fixed +- **Finish photo still caught the swapped/empty plate intermittently on A1 Mini + SwapMod (#1867 follow-on, reporter @qoatzelcoat)** — The last-layer edge trigger shipped in 0.2.4.9 fixed most cases but the reporter still saw the wrong (post-swap) plate now and then — "nothing changed, just kept adding files to the queue." Root cause, confirmed from the support bundle: this A1 Mini firmware (01.08.01.00) **never emits `stg_cur=22`** — across the whole 35k-line log (24 completions) the only stages it reports are 0/2/3/4/13/14/54/77/255, so every completion falls through to the `FINISH`-state fallback. Bambu only reports `gcode_state=FINISH` *after* the user End G-code runs (the print's last object layer was laid ~2 min before FINISH), so a live grab there is guaranteed to show the SwapMod-ejected plate. The last-layer edge (`layer_num >= total_layer_num` while RUNNING) is the right window but depends on catching **one transient MQTT packet** — if the firmware coalesces or drops the final `layer_num == total` push and jumps straight to FINISH, the edge is missed and it silently reverts to the post-swap grab. That's the intermittency; queued prints run unattended so the misses accumulate. **Fix — bank a frame instead of chasing an edge.** Bambuddy now keeps a rolling "last in-print camera frame" per printer, refreshed on layer change (throttled to ~25 s, always refreshed on the final object layer) via the same snapshot path the finish photo uses — so it honours the `capture_finish_photo` setting and works for external cameras, buffered RTSP, and fresh RTSP grabs alike. Because banking is **layer-driven it freezes automatically the instant printing ends**: the End G-code (plate swap) emits no further `layer_num` increases, so the last banked frame is always the finished print before the swap. On the `FINISH`-state fallback the finish-photo path now prefers the banked frame over a live grab; the `stage_22` and `last_layer` triggers still live-grab (they fire before the swap and give cleaner parked-toolhead framing), and if no banked frame exists it degrades to the old live grab rather than sending a text-only notification. Correctness no longer depends on *which* signal the firmware emits or on catching the edge — a missed edge just means the photo is one layer-frame stale (a finished print, not an empty plate). The bank is cleared on print start so a queued job can't reuse the prior job's frame. **Tests.** 8 new cases in `test_finish_photo_moment_sync.py`: `finish_state` prefers the banked frame and skips the live grab, falls back to live when no bank exists, and `last_layer` ignores the bank; plus 5 for the banking helper — stores while printing, throttles within the interval, always refreshes on the last layer, skips when not RUNNING (the freeze), and skips during calibration sub-stages. Full finish-photo + MQTT + layer-timelapse suites 355/355 green; `ruff check` clean. **Scope.** Backend-only. No DB migration, no new permission, no i18n change. - **P1/A1 camera stayed black on load until a ~20-minute self-heal (#2521, reporter @nnimby848)** — On chamber-image printers (P1S/A1, port 6000) the camera view frequently came up black on every load/reload and only recovered ~20 min later. The reporter supplied excellent evidence — backend logs, tcpdump (frames actively flowing on port 6000), and a HAR — and correctly identified the trigger: the viewer mounts twice in quick succession (React StrictMode + a self-inflicted reconnect loop), so a short-lived first viewer attaches and detaches within tens of ms while a second viewer persists. Their proposed mechanism (the connection being "attributed" to the dead viewer's trace ID) was a misread of the architecture — `camera_fanout.py` is a **shared** fan-out (one upstream socket per printer, keyed `{id}-fanout`), so only the first subscriber logs "Starting/connected" and every later viewer taps the same pump; the trace ID in the log is just `contextvars` context, and the HAR `status:0` is a mid-stream capture artifact, not a missing response. The real defects were two, both real: **(1) Late-subscriber cold-start.** Every viewer after the first got a fresh empty queue and had to wait for the *next* upstream frame; on a slow chamber cam plus the churn the `` never fired `onLoad`, so the page's stall-detector reconnected every few seconds, minting yet another short-lived subscriber — a self-sustaining loop. **(2) Single-connection socket overlap.** Port 6000 allows one connection; the churn tore the upstream down and reopened it, and a replacement broadcaster could open a **new** socket before the old one finished closing (`_grace_then_stop` exposed `stopped=True` before the pump's socket-close `finally` completed). The printer kept feeding the orphaned socket and starved the live one until its TCP keepalive reaped it — the ~20 min self-heal. **Fixes.** *Backend fan-out:* the broadcaster now remembers the last chunk it pumped and **primes a late/surviving subscriber with it on `subscribe()`**, so any viewer after the first renders a frame instantly (fires `onLoad`, resets the reconnect loop, ends the churn); and a replacement broadcaster's **pump now waits for the displaced broadcaster's upstream socket to fully close** (`wait_until_torn_down()`, set only after the pump's cancellation + socket-close `finally`) before it dials the printer, so two sockets to a single-connection printer never overlap. Guarding at the pump rather than at `get_or_create_broadcaster` keeps it correct when concurrent viewers race to replace the same stopped broadcaster — only the single pump dials — and it's bounded by a 10 s cap so a wedged close degrades to the old behaviour instead of never producing a frame. *Frontend (`CameraPage`):* the stall-detector now requires **two consecutive** stalled/inactive status reads (~10 s) before reconnecting, so a single blip during fan-out startup/handover no longer nukes a stream that's about to deliver frames; the strike counter resets on a rendered frame and on each fresh load. **Tests.** 6 new fan-out unit cases in `test_camera_fanout.py` — late subscriber primed with last frame, first subscriber not primed, `wait_until_torn_down` completes after shutdown, the replacement barrier blocks until the prior teardown completes, and the barrier's bounded-timeout fallback. Full camera suite (fan-out + `test_camera_api` + stderr-summary) 70/70 green; `ruff check` clean; frontend `npm run build` + ESLint clean; existing 13 `CameraPage` cases stay green. **Scope.** No DB migration, no new permission, no new i18n key. The single-connection socket-overlap fix also benefits any single-camera-slot model (e.g. X2D on firmware that permits one connection). If the black screen ever persists on a specific firmware, a per-frame debug counter or `ss -tn | grep :6000` during the episode would confirm whether the upstream is delivering frames — but priming + teardown discipline address both observed mechanisms. - **Scanning an external folder no longer deletes the README.md record (and now indexes pre-existing markdown) (#2520, reporter @zumik3-del)** — The Folder Readme panel (#1268) worked for a `README.md` uploaded through Bambuddy's Upload button, but clicking **Scan External Folder** afterwards made the panel vanish. The reporter root-caused it precisely: `.md` was absent from `_SCANNABLE_EXTENSIONS` (`backend/app/api/routes/library.py:1342`), so the `os.walk` pass skipped markdown files (`:1619`) and never added them to `found_paths` — and the end-of-scan cleanup loop deleted any existing external `LibraryFile` whose path wasn't in `found_paths` (`:1724`), assuming it had been removed from disk. The md file was untouched on disk; only its DB row was destroyed, after which the readme endpoint (`GET /folders/{id}/readme`, which matches `filename LIKE '%.md'`) 404'd and the panel hid. **Two-part fix.** **(1)** Added `.md` to `_SCANNABLE_EXTENSIONS`, so the scan now *indexes* markdown that already exists on disk — markdown dropped in by external tools or copied in manually (feature-request item 1 in the same issue) is picked up and shown, and an uploaded md file is re-found instead of being treated as deleted. `.md` classifies as `file_type="md"` and hits none of the 3mf/gcode/image thumbnail gates, so it just creates a plain record. **(2)** Hardened the cleanup loop to gate deletion on actual disk presence (`path_str not in found_paths and not os.path.exists(path_str)`) rather than mere absence from the extension-filtered `found_paths`. This closes the broader class the reporter flagged: *any* file the upload path admitted whose extension is outside the scannable set (e.g. a `.txt` note) would previously be purged from the DB on the next scan even though it still exists on disk — now such records survive, while genuinely-deleted files (absent from disk) are still cleaned up. **Tests.** 3 new cases in `test_external_folders_api.py::TestExternalFolderScan`: a pre-existing `README.md` on disk is discovered by scan and served by the readme endpoint; an uploaded `README.md` survives a scan (`removed == 0`) and the panel still resolves it — the exact reported bug; a non-scannable `.txt` upload survives a scan via the disk-presence guard. Full suite 39/39 green; `ruff check backend/` clean. **Scope.** Backend-only. No DB migration, no new permission, no i18n change. Item 2 of the issue (the readme panel layout) is addressed in the separate frontend entry below. - **Folder README panel no longer crowds out the file list — now a collapsible right-hand rail (#2520 item 2, reporter @zumik3-del)** — The Folder Readme panel (#1268) rendered as a full-width block stacked *above* the file grid, so on a laptop a moderately long README pushed the actual model files (3MF/STL) below the fold, and — because the file list scrolls in its own container on wide screens — there was no single page scroll to get past it; you had to scroll inside the README separately. **Fix.** On wide screens (`lg+`) the panel now docks as a fixed-width **right-hand column** (`w-80` / `xl:w-96`) beside the file list instead of on top of it, so files stay visible and the README scrolls within its own full-height rail. On narrow screens it stacks above the list (`order-first`) where the page itself scrolls (the reporter's simpler Option A, which is the right behaviour for phones). The panel is **collapsible** — a header toggle shrinks it to a thin vertical strip (desktop) / slim bar (mobile) with a one-click reopen — and the collapsed/expanded choice is **persisted to `localStorage`** so hiding it once keeps it hidden across folder switches and reloads (the reporter's Option B — "open it when you need the description, then hide it to free up space"). Implementation: `FolderReadmePanel` gains the responsive rail layout + persisted collapse state; `FileManagerPage` wraps the files column and the panel in a `flex-col lg:flex-row` content wrapper so the panel is a sibling *column* of the list rather than a block *inside* it. **i18n.** 3 new keys (`fileManager.readme.show` / `.hide` / `.label`) translated across all 11 locales (`.label` is the proper-noun filename "README", identical by design); parity check green at 5579 leaves per locale. **Tests.** 2 new cases in `FolderReadmePanel.test.tsx`: collapsing hides the markdown body, exposes a reopen control, and persists the choice; a persisted-collapsed preference starts the panel collapsed. Existing 3 panel cases + 51 `FileManagerPage` cases stay green; `npm run build` and ESLint clean. **Scope.** Frontend-only. No backend change, no DB migration, no new permission. diff --git a/backend/app/main.py b/backend/app/main.py index 1f0cde424..5aa929731 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -356,6 +356,23 @@ _stage22_finish_frames: dict[int, bytes] = {} # grab (Bambu printers allow only one RTSP client at a time). _stage22_finish_in_flight: dict[int, asyncio.Event] = {} +# #1867: rolling "last in-print camera frame" per printer. Refreshed on +# layer-change while the model is still printing, then consumed by the +# FINISH-state finish-photo path. Firmware that never emits `stg_cur=22` +# (A1 Mini, confirmed) only reaches `on_finish_photo_moment` at the +# gcode_state=FINISH transition — which Bambu reports AFTER the user End +# G-code (e.g. SwapMod plate-swap) has run, so a live grab there captures the +# swapped/empty plate. Banking is layer-driven, so it naturally freezes at the +# final object layer: the End G-code emits no further layer_num increases, so +# the last banked frame is always the finished print before the swap. +_inprint_frame_bank: dict[int, bytes] = {} +# Monotonic timestamp of the last banked frame per printer — throttles banking +# so tall prints don't add a camera grab on every layer. +_inprint_frame_bank_ts: dict[int, float] = {} +# Minimum seconds between banked frames, except the final object layer which +# always refreshes for the best framing. +_INPRINT_BANK_MIN_INTERVAL = 25.0 + # Per-printer "connected" edge tracker. Used by `on_printer_status_change` # to fire `reconcile_stale_active_prints` exactly once per (re)connection # (#1542 follow-up — power-cycle ghost prints). The value is True after @@ -2125,6 +2142,60 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) - return None +async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None: + """#1867: bank a recent in-print camera frame for the finish photo. + + Called on every layer change. Grabs one frame (throttled) into + ``_inprint_frame_bank`` so the FINISH-state finish-photo path has a + pre-swap image on firmware that never emits ``stg_cur=22``. Because it is + driven by layer_num increases, banking stops the instant printing ends and + the End G-code (e.g. SwapMod plate swap) runs — no further layer changes + arrive — so the last banked frame is the finished print, not the swapped + plate. Best-effort: any failure just leaves the previous banked frame. + """ + logger = logging.getLogger(__name__) + client = printer_manager.get_client(printer_id) + state = client.state if client else None + if not state or state.state != "RUNNING": + return + # Only during actual extrusion — firmware ticks layer_num during the + # pre-print calibration sequence, whose sub-stages are non-zero. + if state.mc_print_sub_stage not in (None, 0): + return + + total = state.total_layers or 0 + is_last_layer = total > 0 and layer_num >= total + now = time.monotonic() + last = _inprint_frame_bank_ts.get(printer_id, 0.0) + if not is_last_layer and (now - last) < _INPRINT_BANK_MIN_INTERVAL: + return + + try: + async with async_session() as db: + from backend.app.models.printer import Printer + + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + return + # Reuses the notification snapshot path, which honours the + # `capture_finish_photo` setting (returns None when disabled) so we + # don't bank frames the user never asked for. + frame = await _capture_snapshot_for_notification(printer_id, printer, logger) + if frame: + _inprint_frame_bank[printer_id] = frame + _inprint_frame_bank_ts[printer_id] = now + logger.debug( + "[FINISH-PHOTO-BANK] banked in-print frame for printer %s at layer %s/%s (%d bytes)", + printer_id, + layer_num, + total, + len(frame), + ) + except Exception as e: + logger.debug("[FINISH-PHOTO-BANK] bank failed for printer %s: %s", printer_id, e) + + def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes: """Apply camera rotation to snapshot image if configured.""" rotation = getattr(printer, "camera_rotation", 0) @@ -2258,6 +2329,10 @@ async def on_print_start(printer_id: int, data: dict): # #1721: drop any leftover pre-captured finish frame from a prior print # so a never-consumed cache entry can't bleed into the new print's photo. _stage22_finish_frames.pop(printer_id, None) + # #1867: same for the in-print frame bank — a queued print must not reuse + # the previous job's banked frame. + _inprint_frame_bank.pop(printer_id, None) + _inprint_frame_bank_ts.pop(printer_id, None) # Cancel any active bed cooldown waiter for this printer if _bed_cool_waiters.pop(printer_id, None): @@ -3905,7 +3980,23 @@ async def on_finish_photo_moment(printer_id: int, data: dict): frame_bytes: bytes | None = None - if printer.external_camera_enabled and printer.external_camera_url: + # #1867: on the FINISH-state fallback the End G-code (e.g. SwapMod + # plate-swap) has already run, so a live grab now captures the swapped + # or empty plate. Prefer the banked in-print frame — the finished + # print from the last object layer, before the swap. Only for + # `finish_state`: the `stage_22` and `last_layer` triggers fire before + # the swap and give cleaner (parked-toolhead) framing via a live grab. + if trigger == "finish_state": + banked = _inprint_frame_bank.get(printer_id) + if banked: + frame_bytes = banked + logger.info( + "[FINISH-PHOTO-MOMENT] using banked in-print frame (%d bytes) — " + "avoids post-swap live grab on stage-22-less firmware", + len(banked), + ) + + if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url: from backend.app.services.external_camera import capture_frame frame_bytes = await capture_frame( @@ -3918,7 +4009,7 @@ async def on_finish_photo_moment(printer_id: int, data: dict): "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)", len(frame_bytes), ) - else: + elif frame_bytes is None: from backend.app.api.routes.camera import get_buffered_frame buffered = get_buffered_frame(printer_id) @@ -6021,6 +6112,12 @@ async def lifespan(app: FastAPI): await tl_layer_change(printer_id, layer_num) + # #1867: bank a recent in-print frame so the FINISH-state finish-photo + # path (firmware that never emits stg_cur=22, e.g. A1 Mini) has a + # pre-swap image to fall back on instead of a live grab of the swapped + # plate. Layer-driven, so it freezes at the final object layer. + await _maybe_bank_inprint_frame(printer_id, layer_num) + # First layer complete notification (layer_num >= 2 means layer 1 is done). # Gate on actual printing state — Bambu firmware ticks layer_num during # the pre-print calibration sequence (homing / mesh-level / bed scan / diff --git a/backend/tests/unit/test_finish_photo_moment_sync.py b/backend/tests/unit/test_finish_photo_moment_sync.py index 28f91b4d5..f9e3494e4 100644 --- a/backend/tests/unit/test_finish_photo_moment_sync.py +++ b/backend/tests/unit/test_finish_photo_moment_sync.py @@ -52,9 +52,13 @@ def _clean_state(): """Don't leak event/cache dict entries across tests.""" main_module._stage22_finish_in_flight.clear() main_module._stage22_finish_frames.clear() + main_module._inprint_frame_bank.clear() + main_module._inprint_frame_bank_ts.clear() yield main_module._stage22_finish_in_flight.clear() main_module._stage22_finish_frames.clear() + main_module._inprint_frame_bank.clear() + main_module._inprint_frame_bank_ts.clear() @pytest.fixture @@ -206,3 +210,115 @@ async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monk assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame" await producer + + +async def test_finish_state_prefers_banked_frame(patched_env, monkeypatch): + """#1867: on the FINISH-state fallback (stage-22-less firmware, e.g. A1 + Mini) a live grab captures the post-swap plate. When a banked in-print + frame exists it must be used instead, and the live grab must not run.""" + main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked" + + live_called = {"n": 0} + + async def _live(**_kwargs): + live_called["n"] += 1 + return b"\xff\xd8live-post-swap" + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8banked" + assert live_called["n"] == 0 + + +async def test_finish_state_falls_back_to_live_when_no_bank(patched_env, monkeypatch): + """No banked frame (feature just enabled, tiny print, capture failures) — + the FINISH-state path still live-grabs so we degrade to the old behaviour + rather than sending a text-only notification.""" + + async def _live(**_kwargs): + return b"\xff\xd8live" + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live) + + await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}) + + assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live" + + +async def test_last_layer_trigger_ignores_bank(patched_env, monkeypatch): + """The `last_layer` trigger fires before the swap and gives cleaner + (parked-toolhead) framing via a live grab — the bank is only for the + post-swap `finish_state` fallback, so it must be ignored here.""" + main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked" + + async def _live(**_kwargs): + return b"\xff\xd8live" + + monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live) + + await on_finish_photo_moment(patched_env.id, {"trigger": "last_layer"}) + + assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8live" + + +# --- #1867 banking helper (_maybe_bank_inprint_frame) -------------------- + + +def _bank_env(monkeypatch, *, state="RUNNING", sub_stage=0, total_layers=10, printer=object()): + """Wire printer_manager.get_client + the snapshot capture for the bank + helper. Capture returns a distinct frame per call so updates are visible.""" + client = SimpleNamespace( + state=SimpleNamespace(state=state, mc_print_sub_stage=sub_stage, total_layers=total_layers) + ) + monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client) + monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(printer)) + + counter = {"n": 0} + + async def _capture(_pid, _printer, _logger): + counter["n"] += 1 + return f"frame-{counter['n']}".encode() + + monkeypatch.setattr(main_module, "_capture_snapshot_for_notification", _capture) + return counter + + +async def test_bank_stores_frame_while_printing(monkeypatch): + _bank_env(monkeypatch) + await main_module._maybe_bank_inprint_frame(3, 5) + assert main_module._inprint_frame_bank[3] == b"frame-1" + + +async def test_bank_throttles_within_interval(monkeypatch): + counter = _bank_env(monkeypatch) + await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1 + await main_module._maybe_bank_inprint_frame(3, 6) # within 25s -> skipped + assert counter["n"] == 1 + assert main_module._inprint_frame_bank[3] == b"frame-1" + + +async def test_bank_always_refreshes_on_last_layer(monkeypatch): + counter = _bank_env(monkeypatch, total_layers=10) + await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1 + # Last layer bypasses the throttle for the best final framing. + await main_module._maybe_bank_inprint_frame(3, 10) + assert counter["n"] == 2 + assert main_module._inprint_frame_bank[3] == b"frame-2" + + +async def test_bank_skips_when_not_running(monkeypatch): + """End G-code (plate swap) runs after RUNNING ends — the bank must not + update then, which is what freezes it on the finished print.""" + _bank_env(monkeypatch, state="FINISH") + await main_module._maybe_bank_inprint_frame(3, 10) + assert 3 not in main_module._inprint_frame_bank + + +async def test_bank_skips_during_calibration_substage(monkeypatch): + """layer_num ticks during pre-print calibration (non-zero sub-stage) — + banking then would capture an empty bed.""" + _bank_env(monkeypatch, sub_stage=14) + await main_module._maybe_bank_inprint_frame(3, 2) + assert 3 not in main_module._inprint_frame_bank