| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198 |
- """Regression tests for the #1790 producer-consumer synchronization.
- `on_finish_photo_moment` (producer) and `_background_finish_photo`
- (consumer) are dispatched back-to-back on the FINISH-state fallback path
- (`bambu_mqtt.py:3258-3297`). Before #1790, the consumer ran a single
- `pop()` on `_stage22_finish_frames` with no wait — racing past the
- producer with an empty result, then doing its own RTSP grab that
- collided with the producer's still-in-flight grab (Bambu printers allow
- one RTSP client). Net result: a captured frame was logged, the cache
- was populated ~1s later, but the notification went text-only.
- The fix is an `asyncio.Event` per printer registered in
- `_stage22_finish_in_flight` by the producer and awaited (with timeout)
- by the consumer. These tests pin the producer side of that contract.
- """
- import asyncio
- import logging
- from contextlib import asynccontextmanager
- from datetime import datetime, timezone
- from types import SimpleNamespace
- from unittest.mock import AsyncMock
- import pytest
- from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- from backend.app import main as main_module
- from backend.app.core.database import Base
- from backend.app.main import on_finish_photo_moment
- from backend.app.models.archive import PrintArchive
- from backend.app.services import print_dispatch_context
- @asynccontextmanager
- async def _fake_session(printer):
- """Async-session stub that returns `printer` from scalar_one_or_none()."""
- result = SimpleNamespace(scalar_one_or_none=lambda: printer)
- session = SimpleNamespace(execute=AsyncMock(return_value=result))
- yield session
- @pytest.fixture
- def fake_printer():
- return SimpleNamespace(
- id=7,
- ip_address="192.0.2.7",
- access_code="x",
- model="X1C",
- external_camera_enabled=False,
- external_camera_url=None,
- external_camera_type=None,
- external_camera_snapshot_url=None,
- )
- @pytest.fixture(autouse=True)
- 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()
- print_dispatch_context.clear(7)
- 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()
- print_dispatch_context.clear(7)
- @pytest.fixture
- def patched_env(fake_printer, monkeypatch):
- monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_printer))
- async def _get_setting(_db, key):
- if key == "capture_finish_photo":
- return "true"
- return None
- monkeypatch.setattr(
- "backend.app.api.routes.settings.get_setting",
- _get_setting,
- )
- monkeypatch.setattr(
- "backend.app.api.routes.camera.get_buffered_frame",
- lambda _pid: None,
- )
- # #2547: default the plate restore to "print height unknown", so tests that
- # aren't about the restore never reach the G-code path. Tests that ARE about
- # it override these two.
- async def _no_height(_printer_id, _data, _logger, _archive_id, _plate_id=None):
- return None
- async def _not_blocked(_printer_id):
- return False
- monkeypatch.setattr(main_module, "_max_z_for_current_print", _no_height)
- monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _not_blocked)
- return fake_printer
- async def test_event_registered_before_first_await(patched_env, monkeypatch):
- """The consumer needs to find the event the moment it polls — that
- means registration must complete BEFORE any `await` yields control
- back to the loop."""
- # Slow the first await (DB session entry) so we can observe the dict
- # before the producer makes any real progress.
- seen_during_capture = {}
- async def _slow_capture(**_kwargs):
- seen_during_capture["registered"] = patched_env.id in main_module._stage22_finish_in_flight
- await asyncio.sleep(0)
- return b"\xff\xd8frame"
- monkeypatch.setattr(
- "backend.app.services.camera.capture_camera_frame_bytes",
- _slow_capture,
- )
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert seen_during_capture["registered"] is True
- async def test_event_set_after_successful_capture(patched_env, monkeypatch):
- async def _capture(**_kwargs):
- return b"\xff\xd8frame"
- monkeypatch.setattr(
- "backend.app.services.camera.capture_camera_frame_bytes",
- _capture,
- )
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- event = main_module._stage22_finish_in_flight[patched_env.id]
- assert event.is_set()
- assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
- async def test_event_set_when_capture_returns_no_frame(patched_env, monkeypatch):
- """Producer gives up (RTSP timeout, no buffered frame, no external
- camera) — consumer must NOT wait the full 20s for nothing."""
- async def _capture(**_kwargs):
- return None
- monkeypatch.setattr(
- "backend.app.services.camera.capture_camera_frame_bytes",
- _capture,
- )
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- event = main_module._stage22_finish_in_flight[patched_env.id]
- assert event.is_set()
- assert patched_env.id not in main_module._stage22_finish_frames
- async def test_event_set_even_when_capture_raises(patched_env, monkeypatch):
- """Producer hit a bug or network error — `finally` still has to
- release the consumer."""
- async def _capture(**_kwargs):
- raise RuntimeError("camera went away")
- monkeypatch.setattr(
- "backend.app.services.camera.capture_camera_frame_bytes",
- _capture,
- )
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- event = main_module._stage22_finish_in_flight[patched_env.id]
- assert event.is_set()
- async def test_no_event_when_timelapse_was_active(patched_env):
- """On the timelapse-on path the consumer takes the
- `_capture_finish_photo_from_timelapse` branch and shouldn't be
- blocked by a producer wait — the producer doesn't enter the
- lifecycle."""
- await on_finish_photo_moment(
- patched_env.id,
- {"trigger": "stage_22", "timelapse_was_active": True},
- )
- assert patched_env.id not in main_module._stage22_finish_in_flight
- async def test_event_set_when_capture_setting_disabled(patched_env, monkeypatch):
- """Even on the early-return-before-capture path, the event must be
- released so the consumer doesn't hang on a no-op producer."""
- async def _disabled_setting(_db, _key):
- return "false"
- monkeypatch.setattr(
- "backend.app.api.routes.settings.get_setting",
- _disabled_setting,
- )
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- event = main_module._stage22_finish_in_flight[patched_env.id]
- assert event.is_set()
- async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monkeypatch):
- """End-to-end sync check: a consumer-style waiter awaiting the
- event finishes promptly once the producer's finally fires."""
- async def _capture(**_kwargs):
- await asyncio.sleep(0.05)
- return b"\xff\xd8frame"
- monkeypatch.setattr(
- "backend.app.services.camera.capture_camera_frame_bytes",
- _capture,
- )
- producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}))
- await asyncio.sleep(0) # let the producer register
- event = main_module._stage22_finish_in_flight[patched_env.id]
- await asyncio.wait_for(event.wait(), timeout=1.0)
- assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
- await producer
- async def test_finish_state_prefers_banked_frame_when_end_gcode_was_injected(patched_env, monkeypatch):
- """#1867: when Bambuddy injected End G-code, a SwapMod snippet may already
- have ejected the plate by FINISH — so the banked in-print frame is used and
- the live grab must not run."""
- print_dispatch_context.mark_pending(patched_env.id)
- print_dispatch_context.adopt(patched_env.id)
- 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_grabs_live_when_no_end_gcode_was_injected(patched_env, monkeypatch):
- """#2547: the ordinary case. Nothing moved the plate, the toolhead is
- parked, and the print is still sitting there — so the live frame is the
- finished print, and a banked mid-print frame must NOT win over it.
- Preferring the bank here unconditionally, which is what this code used to
- do, is how the H2C shipped a photo with the toolhead over the part."""
- main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked-midprint"
- async def _live(**_kwargs):
- return b"\xff\xd8live-finished-print"
- 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-finished-print"
- async def test_finish_state_falls_back_to_live_when_bank_is_empty(patched_env, monkeypatch):
- """End G-code was injected but nothing was ever banked (feature just
- enabled, tiny print, capture failures). Degrade to a live grab rather than
- sending a text-only notification."""
- print_dispatch_context.mark_pending(patched_env.id)
- print_dispatch_context.adopt(patched_env.id)
- 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_stage_22_trigger_ignores_bank(patched_env, monkeypatch):
- """The `stage_22` trigger fires before any End G-code and gives cleaner
- (parked-toolhead, plate-still-up) framing via a live grab — the bank is only
- for the post-swap `finish_state` path, so it must be ignored here."""
- print_dispatch_context.mark_pending(patched_env.id)
- print_dispatch_context.adopt(patched_env.id)
- 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": "stage_22"})
- 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, "light": []}
- async def _capture(_pid, _printer, _logger, *, light=True):
- counter["n"] += 1
- counter["light"].append(light)
- 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_leaves_the_chamber_light_alone(monkeypatch):
- """#1655: the bank grabs a frame every 25 seconds all through a print, so
- holding the chamber light for it would make the light flash."""
- counter = _bank_env(monkeypatch)
- await main_module._maybe_bank_inprint_frame(3, 5)
- assert counter["light"] == [False]
- 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_throttles_on_the_last_layer_too(monkeypatch):
- """#2547: the last layer used to bypass the throttle so it always got a
- fresh frame. Now that progress advances also drive banking, that exemption
- would fire a camera grab on every percent tick of a multi-minute last layer
- — and each grab contends with the live view for the single RTSP slot."""
- counter = _bank_env(monkeypatch, total_layers=10)
- await main_module._maybe_bank_inprint_frame(3, 5) # banks frame-1
- await main_module._maybe_bank_inprint_frame(3, 10) # last layer, within 25s
- assert counter["n"] == 1
- assert main_module._inprint_frame_bank[3] == b"frame-1"
- async def test_bank_refreshes_on_the_last_layer_once_the_throttle_elapses(monkeypatch):
- """The point of banking on progress: a three-minute last layer keeps
- refreshing instead of freezing at the moment that layer began."""
- counter = _bank_env(monkeypatch, total_layers=10)
- await main_module._maybe_bank_inprint_frame(3, 10) # banks frame-1
- # Pretend the throttle window has passed, as it does mid-last-layer.
- main_module._inprint_frame_bank_ts[3] -= main_module._INPRINT_BANK_MIN_INTERVAL + 1
- 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
- class TestStage22CacheHoldsExactlyOneRotation:
- """#2708. `_stage22_finish_frames` is fed from two kinds of source: live
- grabs, which are raw, and the #1867 in-print bank, whose bytes came from
- `_capture_snapshot_for_notification` and are therefore ALREADY rotated.
- The consumer cannot tell them apart, so the producer normalises: every
- entry in the cache has had the rotation applied exactly once.
- Rotating on the consumer side instead put two rotations on the banked
- path — at 180 degrees that is the reported bug reproduced exactly, and at
- 90/270 it lands the photo 180 degrees out.
- """
- @staticmethod
- def _jpeg(width, height):
- import io
- from PIL import Image
- buf = io.BytesIO()
- Image.new("RGB", (width, height), (0, 0, 255)).save(buf, format="JPEG")
- return buf.getvalue()
- @staticmethod
- def _size(data):
- import io
- from PIL import Image
- return Image.open(io.BytesIO(data)).size
- async def test_a_live_grab_is_rotated_before_caching(self, patched_env, monkeypatch):
- monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
- raw = self._jpeg(64, 32)
- async def _capture(**_kwargs):
- return raw
- monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- cached = main_module._stage22_finish_frames[patched_env.id]
- assert self._size(cached) == (32, 64)
- async def test_the_banked_frame_is_cached_verbatim(self, patched_env, monkeypatch):
- """The bank is filled by `_capture_snapshot_for_notification`, which
- rotates before it returns — so the producer must pass those bytes
- through untouched rather than rotating them a second time.
- Note this pins the invariant forward; it does not on its own prove the
- bug fixed, because the old producer didn't rotate anything either. The
- pair that discriminates is `test_a_live_grab_is_rotated_before_caching`
- (producer now rotates) plus the source guard below (consumer no longer
- does).
- """
- monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
- # #2547: the bank is only preferred when End G-code was injected.
- print_dispatch_context.mark_pending(patched_env.id)
- print_dispatch_context.adopt(patched_env.id)
- already_rotated = self._jpeg(32, 64) # what one rotation of a 64x32 frame looks like
- main_module._inprint_frame_bank[patched_env.id] = already_rotated
- async def _capture(**_kwargs): # pragma: no cover - must not be reached
- raise AssertionError("the banked frame should have been preferred")
- monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- cached = main_module._stage22_finish_frames[patched_env.id]
- assert cached is already_rotated
- assert self._size(cached) == (32, 64)
- async def test_a_stage22_grab_is_rotated_even_though_the_bank_is_full(self, patched_env, monkeypatch):
- """Only the `finish_state` trigger reads the bank. The `stage_22` and
- `last_layer` triggers take a live grab, which still needs rotating —
- a shared "did we use the bank" flag must not latch on the bank merely
- existing."""
- monkeypatch.setattr(patched_env, "camera_rotation", 90, raising=False)
- main_module._inprint_frame_bank[patched_env.id] = self._jpeg(999, 1)
- async def _capture(**_kwargs):
- return self._jpeg(64, 32)
- monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
- await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
- cached = main_module._stage22_finish_frames[patched_env.id]
- assert self._size(cached) == (32, 64)
- async def test_no_rotation_configured_caches_the_bytes_as_captured(self, patched_env, monkeypatch):
- raw = self._jpeg(64, 32)
- async def _capture(**_kwargs):
- return raw
- monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _capture)
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert main_module._stage22_finish_frames[patched_env.id] is raw
- def test_the_consumer_does_not_rotate_the_cached_frame():
- """The other half of the #2708 invariant, and the half with no runtime
- harness: `_background_finish_photo` is a closure nested inside
- `on_print_complete`, so nothing can drive its cached-frame branch
- directly. What it must NOT do is rotate what it pops from
- `_stage22_finish_frames` — the producer has already done that, and doing
- it again upside-downs the banked path, which is the bug this fixed.
- Checked against the source because the alternative is no check at all.
- """
- import ast
- from pathlib import Path
- main_py = Path(__file__).resolve().parents[2] / "app" / "main.py"
- assert main_py.exists(), f"guard is looking in the wrong place: {main_py}"
- tree = ast.parse(main_py.read_text())
- offenders = [
- node.lineno
- for node in ast.walk(tree)
- if isinstance(node, ast.Call)
- and isinstance(node.func, ast.Name)
- and node.func.id == "_apply_camera_rotation"
- and node.args
- and isinstance(node.args[0], ast.Name)
- and node.args[0].id == "cached_frame"
- ]
- assert not offenders, (
- f"main.py:{offenders} rotates the frame popped from _stage22_finish_frames. "
- "Those bytes are already rotated by on_finish_photo_moment (#2708); rotating "
- "again returns a 180-degree print to upside-down."
- )
- class TestPlateRestore:
- """#2547 / #1145 / #1397 / #1565: put the plate back into camera framing.
- Bambu's end G-code drops the plate ~100mm as the last thing it does, so by
- FINISH the finished print sits well below where the camera frames it. The
- restore commands an ABSOLUTE Z back to just above the last printed layer.
- Absolute is the safety argument, and these tests pin it: the target is a
- height the toolhead was physically at seconds earlier, so it is inside the
- travel limits and leaves the nozzle above the part. It is also unambiguous
- across model families — Z is the nozzle-to-bed gap whether the bed moves or
- the toolhead does — so there is no sign to get wrong the way the relative
- bed-jog path had (#1334).
- """
- @pytest.fixture
- def printer_client(self, monkeypatch):
- sent: list[str] = []
- client = SimpleNamespace(
- state=SimpleNamespace(state="FINISH"),
- send_gcode=lambda gcode: (sent.append(gcode), True)[1],
- )
- monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
- monkeypatch.setattr(main_module.asyncio, "sleep", AsyncMock())
- client.sent = sent
- return client
- async def test_commands_an_absolute_move_above_the_print(self, printer_client):
- ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
- assert ok is True
- assert printer_client.sent == ["G90\nG1 Z26.00 F600"]
- async def test_never_touches_m211(self, printer_client):
- """#2579: disabling soft endstops is what let a jog drive the nozzle
- into the bed. This path must not reintroduce it."""
- await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
- assert not any("M211" in line for line in printer_client.sent)
- async def test_skipped_when_the_printer_is_no_longer_in_finish(self, printer_client):
- """The queue dispatches the next job the instant a print completes.
- Commanding a plate move into a starting print is not a race worth
- having, so state is re-read immediately before the move."""
- printer_client.state.state = "RUNNING"
- ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
- assert ok is False
- assert printer_client.sent == []
- async def test_skipped_when_the_printer_is_gone(self, monkeypatch):
- monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: None)
- ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
- assert ok is False
- async def test_reports_failure_when_the_send_fails(self, printer_client, monkeypatch):
- """A failed send means the plate never moved — the caller must not go on
- to owe it a move back down."""
- monkeypatch.setattr(printer_client, "send_gcode", lambda _g: False)
- ok = await main_module._restore_plate_for_finish_photo(7, 16.0, logging.getLogger(__name__))
- assert ok is False
- def test_park_lowers_the_plate_again(self, printer_client):
- main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
- assert printer_client.sent == ["G90\nG1 Z116.00 F600"]
- def test_park_skipped_once_the_next_print_has_started(self, printer_client):
- printer_client.state.state = "RUNNING"
- main_module._park_plate_after_finish_photo(7, 16.0, logging.getLogger(__name__))
- assert printer_client.sent == []
- class TestPlateRestoreWiring:
- """The restore only runs in the one situation it is correct for, and the
- plate always comes back down afterwards."""
- @pytest.fixture
- def restore_env(self, patched_env, monkeypatch):
- calls = {"restore": [], "park": [], "blocked": False, "height": 16.0, "bound": []}
- async def _height(_printer_id, _data, _logger, archive_id, plate_id=None):
- calls["bound"].append((archive_id, plate_id))
- return calls["height"]
- async def _blocked(_printer_id):
- return calls["blocked"]
- async def _restore(printer_id, max_z, _logger):
- calls["restore"].append((printer_id, max_z))
- return True
- def _park(printer_id, max_z, _logger):
- calls["park"].append((printer_id, max_z))
- monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
- monkeypatch.setattr(main_module, "_plate_restore_is_blocked_by_queue", _blocked)
- monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
- monkeypatch.setattr(main_module, "_park_plate_after_finish_photo", _park)
- async def _live(**_kwargs):
- return b"\xff\xd8live"
- monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
- return calls
- async def test_restores_then_parks_on_the_finish_state_path(self, patched_env, restore_env):
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert restore_env["restore"] == [(patched_env.id, 16.0)]
- assert restore_env["park"] == [(patched_env.id, 16.0)]
- async def test_reads_the_binding_before_on_print_complete_pops_it(self, patched_env, restore_env, monkeypatch):
- """#3240: on the FINISH-state path on_print_complete is dispatched right
- behind the producer and pops the print's archive binding and plate. The
- producer must have read them first, or the restore has nothing bound to
- move for and never runs."""
- async def _yielding_get_setting(_db, key):
- # A real settings read suspends; this is where on_print_complete
- # gets to run.
- await asyncio.sleep(0)
- return "true" if key == "capture_finish_photo" else None
- monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _yielding_get_setting)
- main_module._active_prints[(patched_env.id, "job.3mf")] = 5
- main_module._print_plate_ids[5] = 3
- async def _on_print_complete_pops():
- main_module._active_prints.clear()
- main_module._print_plate_ids.clear()
- try:
- data = {"trigger": "finish_state", "filename": "job.3mf", "subtask_name": "job"}
- producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, data))
- completion = asyncio.create_task(_on_print_complete_pops())
- await asyncio.gather(producer, completion)
- finally:
- main_module._active_prints.clear()
- main_module._print_plate_ids.clear()
- assert restore_env["bound"] == [(5, 3)]
- async def test_not_restored_on_the_stage_22_path(self, patched_env, restore_env):
- """Stage 22 fires before the end G-code drops the plate — it is already
- where we want it, and moving it would only cost the settle delay."""
- await on_finish_photo_moment(patched_env.id, {"trigger": "stage_22"})
- assert restore_env["restore"] == []
- assert restore_env["park"] == []
- async def test_not_restored_when_the_banked_frame_is_used(self, patched_env, restore_env):
- """The plate has been swapped out — no move brings the print back."""
- print_dispatch_context.mark_pending(patched_env.id)
- print_dispatch_context.adopt(patched_env.id)
- main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert restore_env["restore"] == []
- assert restore_env["park"] == []
- async def test_not_restored_when_end_gcode_was_injected_but_the_bank_is_empty(self, patched_env, restore_env):
- """A plate-swap machine may have just ejected its plate. Even with no
- banked frame to fall back on, driving Z into whatever a swap mechanism
- is doing is not worth a photo of a bed we know may be bare."""
- print_dispatch_context.mark_pending(patched_env.id)
- print_dispatch_context.adopt(patched_env.id)
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert restore_env["restore"] == []
- assert restore_env["park"] == []
- async def test_not_restored_when_the_print_height_is_unknown(self, patched_env, restore_env):
- restore_env["height"] = None
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert restore_env["restore"] == []
- assert restore_env["park"] == []
- async def test_not_restored_when_another_job_is_queued(self, patched_env, restore_env):
- restore_env["blocked"] = True
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert restore_env["restore"] == []
- assert restore_env["park"] == []
- async def test_not_restored_when_the_setting_is_off(self, patched_env, restore_env, monkeypatch):
- async def _get_setting(_db, key):
- if key == "capture_finish_photo":
- return "true"
- if key == "finish_photo_restore_plate":
- return "false"
- return None
- monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert restore_env["restore"] == []
- assert restore_env["park"] == []
- async def test_plate_is_parked_even_when_the_capture_throws(self, patched_env, restore_env, monkeypatch):
- """We raised it, so we owe the move back down — including when the grab
- between the two fails. Otherwise the user finds the print pinned under
- the nozzle."""
- async def _boom(**_kwargs):
- raise RuntimeError("camera gone")
- monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _boom)
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert restore_env["restore"] == [(patched_env.id, 16.0)]
- assert restore_env["park"] == [(patched_env.id, 16.0)]
- async def test_producer_event_is_still_set_after_a_restore(self, patched_env, restore_env):
- """#1790: the consumer's bounded wait must be released on every exit,
- and the restore added a new path through the producer."""
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert main_module._stage22_finish_in_flight[patched_env.id].is_set()
- async def test_producer_wait_budget_covers_the_restore():
- """The consumer's wait has to outlast settle + a worst-case RTSP grab, and
- still finish inside the notification's own photo budget — otherwise the
- restore path is cut off by a timeout somewhere above it."""
- assert main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS > main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
- def test_producer_wait_budget_covers_the_chamber_light_delay():
- """#1655: the producer's live grab can first wait for the chamber light.
- Settle + the longest light delay + a worst-case grab must still fit, or the
- consumer gives up and its fallback grab races the producer's (#1790)."""
- from backend.app.services.camera_light import MAX_DELAY_SECONDS
- assert (
- main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS
- > main_module._PLATE_RESTORE_SETTLE_SECONDS + MAX_DELAY_SECONDS + 15
- )
- class TestMaxZResolution:
- """#2547 safety: the height that becomes a Z-move target must provably
- belong to the print that just finished.
- A height from another print is the one failure mode that could drive the
- nozzle into the model — 20mm carried onto a 200mm print commands the plate
- up through the part. So the resolver refuses on every ambiguity rather than
- falling back to "whatever ran last on this printer".
- Run against a real database (#3240): the earlier tests faked the session,
- so neither the query nor the path the archive's file is read from was ever
- exercised, and both were wrong.
- """
- @pytest.fixture
- async def resolver_env(self, monkeypatch, tmp_path):
- engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- session_maker = async_sessionmaker(engine, expire_on_commit=False)
- env = {"reported_layers": 30, "height": 16.0, "read": [], "session_maker": session_maker}
- monkeypatch.setattr(main_module, "async_session", session_maker)
- monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
- monkeypatch.setattr(
- main_module.printer_manager,
- "get_client",
- lambda _pid: SimpleNamespace(state=SimpleNamespace(total_layers=env["reported_layers"])),
- )
- def _extract(path, plate):
- env["read"].append((path, plate))
- return env["height"]
- monkeypatch.setattr("backend.app.utils.threemf_tools.extract_max_z_height_from_3mf", _extract)
- try:
- yield env
- finally:
- await engine.dispose()
- @staticmethod
- async def _archive(env, **overrides) -> int:
- values = {
- "printer_id": 1,
- "filename": "job.gcode.3mf",
- "file_path": "archive/1/job/job.gcode.3mf",
- "file_size": 1,
- "print_name": "job",
- "status": "completed",
- "plate_id": 1,
- "total_layers": 30,
- }
- values.update(overrides)
- async with env["session_maker"]() as db:
- archive = PrintArchive(**values)
- db.add(archive)
- await db.commit()
- return archive.id
- @staticmethod
- async def _resolve(name, archive_id, plate_id=None):
- data = {"subtask_name": name} if name is not None else {}
- return await main_module._max_z_for_current_print(1, data, logging.getLogger(__name__), archive_id, plate_id)
- async def test_returns_the_height_when_binding_name_and_layers_agree(self, resolver_env):
- archive_id = await self._archive(resolver_env)
- assert await self._resolve("job", archive_id) == 16.0
- async def test_refuses_a_print_that_is_not_bound_to_an_archive(self, resolver_env):
- """A name alone is never enough: with no binding there is no move, even
- when an archive of exactly that name exists."""
- await self._archive(resolver_env)
- assert await self._resolve("job", None) is None
- assert resolver_env["read"] == []
- async def test_uses_the_bound_archive_not_a_newer_one_of_the_same_name(self, resolver_env):
- """The support-bundle case: a reprint reuses archive 169, while a newer
- archive of the same file (another plate) is 288. The name lookup took
- 288; only the layer check stopped it."""
- bound = await self._archive(resolver_env, file_path="archive/reprinted.gcode.3mf", total_layers=125)
- await self._archive(resolver_env, file_path="archive/other-plate.gcode.3mf", total_layers=313)
- resolver_env["reported_layers"] = 125
- assert await self._resolve("job", bound) == 16.0
- assert resolver_env["read"][0][0].name == "reprinted.gcode.3mf"
- async def test_reads_the_plate_the_job_was_dispatched_with(self, resolver_env):
- """A reprint of one plate reuses the archive row without updating its
- plate_id, so the dispatched plate wins."""
- archive_id = await self._archive(resolver_env, plate_id=1)
- await self._resolve("job", archive_id, plate_id=12)
- assert resolver_env["read"][0][1] == 12
- await self._resolve("job", archive_id)
- assert resolver_env["read"][1][1] == 1
- async def test_reads_a_relative_file_path_from_the_data_directory(self, resolver_env, tmp_path):
- """#3240: archive paths are stored relative to the data directory. The
- resolver joined them to a `data_dir` setting that does not exist, so the
- lookup raised, was logged at debug level, and the plate never moved."""
- archive_id = await self._archive(resolver_env, file_path="archive/1/job/job.gcode.3mf", plate_id=2)
- assert await self._resolve("job", archive_id) == 16.0
- assert resolver_env["read"] == [(tmp_path / "archive/1/job/job.gcode.3mf", 2)]
- async def test_an_absolute_file_path_is_read_as_stored(self, resolver_env):
- archive_id = await self._archive(resolver_env, file_path="/srv/bambuddy/archive/job.gcode.3mf")
- assert await self._resolve("job", archive_id) == 16.0
- assert str(resolver_env["read"][0][0]) == "/srv/bambuddy/archive/job.gcode.3mf"
- async def test_matches_a_name_the_printer_echoed_with_underscores(self, resolver_env):
- """#3240: the printer reports "Cube v2" as "Cube_v2"."""
- archive_id = await self._archive(resolver_env, print_name="Cube v2", filename="Cube v2.gcode.3mf")
- assert await self._resolve("Cube_v2", archive_id) == 16.0
- async def test_matches_on_the_filename_with_or_without_its_extension(self, resolver_env):
- archive_id = await self._archive(resolver_env, print_name=None, filename="Bracket Left.gcode.3mf")
- assert await self._resolve("Bracket_Left", archive_id) == 16.0
- assert await self._resolve("Bracket_Left.gcode", archive_id) == 16.0
- assert await self._resolve("Bracket Left.gcode.3mf", archive_id) == 16.0
- async def test_matches_a_file_name_that_ends_in_a_space(self, resolver_env):
- """#3241: "Part .gcode.3mf" is echoed as "Part_"."""
- archive_id = await self._archive(resolver_env, print_name="Part ", filename="Part .gcode.3mf")
- assert await self._resolve("Part_", archive_id) == 16.0
- async def test_refuses_a_bound_archive_with_another_name(self, resolver_env):
- archive_id = await self._archive(resolver_env)
- assert await self._resolve("other", archive_id) is None
- async def test_matches_by_equality_not_substring(self, resolver_env):
- """ "Cube" must never resolve to "Cube v2" — a different print, quite
- possibly a much taller one."""
- archive_id = await self._archive(resolver_env, print_name="Cube v2", filename="Cube v2.gcode.3mf")
- assert await self._resolve("Cube", archive_id) is None
- async def test_a_truncated_echo_does_not_match(self, resolver_env):
- """The completion check accepts the printer's "..." truncation; a Z
- move must not, since the cut-off name could belong to another print."""
- archive_id = await self._archive(resolver_env, print_name="A very long print name indeed")
- assert await self._resolve("A_very_long_print...", archive_id) is None
- async def test_refuses_another_printers_or_a_failed_or_deleted_archive(self, resolver_env):
- for overrides in ({"printer_id": 2}, {"status": "failed"}, {"deleted_at": datetime.now(timezone.utc)}):
- archive_id = await self._archive(resolver_env, **overrides)
- assert await self._resolve("job", archive_id) is None, overrides
- async def test_refuses_when_the_print_has_no_name_to_match_on(self, resolver_env):
- archive_id = await self._archive(resolver_env)
- assert await self._resolve(None, archive_id) is None
- assert resolver_env["read"] == []
- async def test_refuses_when_the_layer_counts_disagree(self, resolver_env):
- """The corroboration check. The archive's layer count comes from the
- 3MF; the printer's comes from MQTT. If two independent sources disagree,
- the row is not this print whatever its name says."""
- archive_id = await self._archive(resolver_env)
- resolver_env["reported_layers"] = 240
- assert await self._resolve("job", archive_id) is None
- async def test_proceeds_when_a_layer_count_is_simply_unknown(self, resolver_env):
- """Absent is not the same as contradictory — a print Bambuddy has no
- layer count for still gets its height, because binding and name agree."""
- archive_id = await self._archive(resolver_env)
- resolver_env["reported_layers"] = 0
- assert await self._resolve("job", archive_id) == 16.0
- resolver_env["reported_layers"] = 30
- unknown_layers = await self._archive(resolver_env, total_layers=None)
- assert await self._resolve("job", unknown_layers) == 16.0
- async def test_refuses_when_the_archive_has_no_file(self, resolver_env):
- archive_id = await self._archive(resolver_env, file_path="")
- assert await self._resolve("job", archive_id) is None
- async def test_refuses_when_the_3mf_has_no_height(self, resolver_env):
- archive_id = await self._archive(resolver_env)
- resolver_env["height"] = None
- assert await self._resolve("job", archive_id) is None
- class TestBoundPrint:
- """The binding the plate restore reads (#3240)."""
- @pytest.fixture(autouse=True)
- def _clean(self):
- main_module._active_prints.clear()
- main_module._print_plate_ids.clear()
- yield
- main_module._active_prints.clear()
- main_module._print_plate_ids.clear()
- def test_finds_the_archive_under_the_keys_on_print_complete_tries(self):
- main_module._active_prints[(7, "Cube v2.gcode.3mf")] = 169
- main_module._active_prints[(7, "Cube_v2.3mf")] = 169
- main_module._print_plate_ids[169] = 12
- bound = main_module._bound_print(7, {"filename": "Cube_v2.3mf", "subtask_name": "Cube_v2"})
- assert bound == (169, 12)
- def test_reads_without_consuming(self):
- """on_print_complete still has to find the binding after we looked."""
- main_module._active_prints[(7, "job.3mf")] = 5
- main_module._print_plate_ids[5] = 2
- main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"})
- assert main_module._active_prints == {(7, "job.3mf"): 5}
- assert main_module._print_plate_ids == {5: 2}
- def test_unbound_print_and_other_printers(self):
- main_module._active_prints[(8, "job.3mf")] = 5
- assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"}) == (None, None)
- assert main_module._bound_print(7, {}) == (None, None)
- def test_no_plate_recorded(self):
- main_module._active_prints[(7, "job.3mf")] = 5
- assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"}) == (5, None)
- def test_same_archive_as_on_print_complete_takes(self):
- """Keys tried in the same order, so the first one present wins in both."""
- main_module._active_prints[(7, "job.gcode.3mf")] = 1
- main_module._active_prints[(7, "job.3mf")] = 2
- keys = main_module._completion_print_keys(7, "job.3mf", "job")
- first = next(main_module._active_prints[k] for k in keys if k in main_module._active_prints)
- assert main_module._bound_print(7, {"filename": "job.3mf", "subtask_name": "job"})[0] == first == 2
- class TestTimelapsePathPlateRestore:
- """#2547: the timelapse path falls through to a live grab whenever the
- video hasn't landed yet — the documented usual outcome on P1-series.
- `on_finish_photo_moment` returns early for those prints without raising the
- plate, so the photo that actually ships in the notification would be of an
- already-dropped plate. The consumer therefore does the restore itself, but
- only on that path — everywhere else the producer has already done it.
- """
- def test_notification_budget_outlasts_the_video_poll_plus_a_restore(self):
- """The wait has to cover polling for the video AND the restore that
- follows when it doesn't arrive. At the old flat 75s the fallback was
- cut off mid-settle, so the plate would have moved for a photo nobody
- was still waiting for."""
- assert (
- main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS
- > main_module._FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS + main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
- )
- async def test_producer_skips_the_restore_when_a_timelapse_was_recording(self, patched_env, monkeypatch):
- """The producer returns before any of the restore code — the consumer
- owns it on this path, and doing it in both would move the plate twice."""
- moved = []
- async def _restore(printer_id, max_z, _logger):
- moved.append((printer_id, max_z))
- return True
- async def _height(_printer_id, _data, _logger, _archive_id, _plate_id=None):
- return 16.0
- monkeypatch.setattr(main_module, "_restore_plate_for_finish_photo", _restore)
- monkeypatch.setattr(main_module, "_max_z_for_current_print", _height)
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True})
- assert moved == []
- # --- #1655 chamber light for the camera ------------------------------------
- class _LightClient:
- def __init__(self):
- self.state = SimpleNamespace(chamber_light=False)
- self.calls: list[bool] = []
- self.mode = "off" # the camera_light_mode setting
- def set_chamber_light(self, on):
- self.calls.append(on)
- self.state.chamber_light = on
- return True
- @pytest.fixture
- def light_client(monkeypatch):
- from backend.app.services import camera_light as light_svc
- client = _LightClient()
- monkeypatch.setattr(light_svc, "_get_client", lambda _pid: client)
- async def _settings():
- return client.mode, 0.0
- monkeypatch.setattr(light_svc, "_read_settings", _settings)
- monkeypatch.setattr(light_svc, "_settings_cache", None)
- monkeypatch.setattr(light_svc, "_holders", {})
- monkeypatch.setattr(light_svc, "_lit_at", {})
- off_tasks: dict = {}
- monkeypatch.setattr(light_svc, "_off_tasks", off_tasks)
- yield client
- for task in off_tasks.values():
- task.cancel()
- async def test_finish_photo_live_grab_is_lit_when_the_light_mode_covers_it(patched_env, monkeypatch, light_client):
- light_client.mode = "all"
- seen = {}
- async def _live(**_kwargs):
- seen["light"] = light_client.state.chamber_light
- 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 seen["light"] is True
- assert light_client.calls == [True] # off follows after the grace
- async def test_finish_photo_live_grab_leaves_the_light_alone_by_default(patched_env, monkeypatch, light_client):
- 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 light_client.calls == []
- async def test_finish_photo_from_the_bank_does_not_turn_the_light_on(patched_env, monkeypatch, light_client):
- """The banked frame is already taken; lighting the printer for it would
- only switch the light on and off for nothing."""
- light_client.mode = "all"
- print_dispatch_context.mark_pending(patched_env.id)
- print_dispatch_context.adopt(patched_env.id)
- main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
- await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
- assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8banked"
- assert light_client.calls == []
- async def test_notification_snapshot_is_lit_for_a_selected_printer(fake_printer, monkeypatch, light_client):
- light_client.mode = "selected"
- fake_printer.camera_light_auto = True
- seen = {}
- async def _frame(_pid, _printer, _logger):
- seen["light"] = light_client.state.chamber_light
- return b"\xff\xd8snap"
- monkeypatch.setattr(main_module, "_capture_snapshot_frame", _frame)
- assert await main_module._capture_snapshot_for_notification(7, fake_printer, logging.getLogger()) == b"\xff\xd8snap"
- assert seen["light"] is True
- assert light_client.calls == [True]
- def test_every_live_capture_in_main_holds_the_light():
- """#1655: the finish photo's last fallback lives in `_background_finish_photo`,
- a closure nothing can drive directly (see the rotation guard above). So the
- source is checked: every live camera grab in main.py runs inside
- `camera_light(...)`, except in `_capture_snapshot_frame`, whose only caller
- is the wrapper that holds the light for it.
- """
- import ast
- from pathlib import Path
- main_py = Path(__file__).resolve().parents[2] / "app" / "main.py"
- tree = ast.parse(main_py.read_text())
- parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
- grabs = {"capture_frame", "capture_camera_frame", "capture_camera_frame_bytes", "capture_finish_photo"}
- def _held(node) -> bool:
- while node in parents:
- node = parents[node]
- if isinstance(node, ast.AsyncFunctionDef) and node.name == "_capture_snapshot_frame":
- return True
- if isinstance(node, ast.AsyncWith) and any(
- isinstance(item.context_expr, ast.Call)
- and getattr(item.context_expr.func, "id", None) == "camera_light"
- for item in node.items
- ):
- return True
- return False
- calls = [
- node
- for node in ast.walk(tree)
- if isinstance(node, ast.Call) and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) in grabs
- ]
- assert len(calls) >= 6, "guard found fewer grabs than main.py has; is it parsing the right file?"
- unlit = [node.lineno for node in calls if not _held(node)]
- assert not unlit, f"main.py:{unlit} grabs a camera frame without camera_light(...)"
- wrapper_callers = [
- node.lineno
- for node in ast.walk(tree)
- if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_capture_snapshot_frame"
- ]
- assert len(wrapper_callers) == 1, f"_capture_snapshot_frame is called from main.py:{wrapper_callers}"
|