Jelajahi Sumber

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.
maziggy 1 bulan lalu
induk
melakukan
5dd0370397
3 mengubah file dengan 215 tambahan dan 2 penghapusan
  1. 0 0
      CHANGELOG.md
  2. 99 2
      backend/app/main.py
  3. 116 0
      backend/tests/unit/test_finish_photo_moment_sync.py

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 99 - 2
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).
 # grab (Bambu printers allow only one RTSP client at a time).
 _stage22_finish_in_flight: dict[int, asyncio.Event] = {}
 _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`
 # Per-printer "connected" edge tracker. Used by `on_printer_status_change`
 # to fire `reconcile_stale_active_prints` exactly once per (re)connection
 # to fire `reconcile_stale_active_prints` exactly once per (re)connection
 # (#1542 follow-up — power-cycle ghost prints). The value is True after
 # (#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
     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:
 def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:
     """Apply camera rotation to snapshot image if configured."""
     """Apply camera rotation to snapshot image if configured."""
     rotation = getattr(printer, "camera_rotation", 0)
     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
     # #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.
     # so a never-consumed cache entry can't bleed into the new print's photo.
     _stage22_finish_frames.pop(printer_id, None)
     _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
     # Cancel any active bed cooldown waiter for this printer
     if _bed_cool_waiters.pop(printer_id, None):
     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
         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
             from backend.app.services.external_camera import capture_frame
 
 
             frame_bytes = await 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)",
                     "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
                     len(frame_bytes),
                     len(frame_bytes),
                 )
                 )
-        else:
+        elif frame_bytes is None:
             from backend.app.api.routes.camera import get_buffered_frame
             from backend.app.api.routes.camera import get_buffered_frame
 
 
             buffered = get_buffered_frame(printer_id)
             buffered = get_buffered_frame(printer_id)
@@ -6021,6 +6112,12 @@ async def lifespan(app: FastAPI):
 
 
         await tl_layer_change(printer_id, layer_num)
         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).
         # First layer complete notification (layer_num >= 2 means layer 1 is done).
         # Gate on actual printing state — Bambu firmware ticks layer_num during
         # Gate on actual printing state — Bambu firmware ticks layer_num during
         # the pre-print calibration sequence (homing / mesh-level / bed scan /
         # the pre-print calibration sequence (homing / mesh-level / bed scan /

+ 116 - 0
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."""
     """Don't leak event/cache dict entries across tests."""
     main_module._stage22_finish_in_flight.clear()
     main_module._stage22_finish_in_flight.clear()
     main_module._stage22_finish_frames.clear()
     main_module._stage22_finish_frames.clear()
+    main_module._inprint_frame_bank.clear()
+    main_module._inprint_frame_bank_ts.clear()
     yield
     yield
     main_module._stage22_finish_in_flight.clear()
     main_module._stage22_finish_in_flight.clear()
     main_module._stage22_finish_frames.clear()
     main_module._stage22_finish_frames.clear()
+    main_module._inprint_frame_bank.clear()
+    main_module._inprint_frame_bank_ts.clear()
 
 
 
 
 @pytest.fixture
 @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"
     assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
     await producer
     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

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini