mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(finish-photo): bank in-print frame for FINISH-state fallback (#1867)
A1 Mini firmware never emits stg_cur=22, so every completion hits the FINISH-state fallback — which fires after the End G-code (SwapMod plate swap) runs, capturing the swapped plate. The last-layer edge trigger depends on catching one transient MQTT packet and is dropped intermittently, reverting to the post-swap grab. Bank a rolling in-print camera frame per printer, refreshed on layer change. Because it's layer-driven it freezes when printing ends (no more layer increases during the swap), so the last banked frame is the finished print. The FINISH-state finish-photo path now prefers the banked frame over a live grab; stage_22/last_layer still live-grab.
This commit is contained in:
@@ -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 `<img>` 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.
|
||||
|
||||
+99
-2
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user