Add Camera Capture overlay (F6) with multi-trigger capture pipeline
Camera backend abstraction wrapping fswebcam/ffmpeg/libcamera-still via subprocess, with DemoCamera fallback generating valid JPEG from raw bytes. Capture pipeline writes JPEG + JSON sidecar always, optional FITS (astropy) and EXIF (Pillow) when available. Thread-safe orchestrator with session tracking, sequence numbering, and date-based output directories. Trigger system: manual capture, configurable interval timer, and pass event detection (AOS/TCA/LOS) with 0.5-degree TCA hysteresis. PassEventDetector runs in the Craft tracking loop, fires callbacks to the camera overlay. F6 overlay follows the F5 ConsoleOverlay pattern — ModalScreen with install_screen persistence. Status panel, scrollable capture log, interval controls, and AOS/TCA/LOS toggle buttons. Tagline: "a generic AZ/EL positioner that doesn't care about wavelength" added to TUI header subtitle. 51 new tests (77 total passing).
This commit is contained in:
parent
6c1e9da773
commit
7035d814a1
13 changed files with 2576 additions and 2 deletions
273
tui/tests/test_camera_overlay.py
Normal file
273
tui/tests/test_camera_overlay.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""Test camera overlay — F6 toggle, manual capture, trigger controls.
|
||||
|
||||
Uses the same testing pattern as test_craft_mode.py:
|
||||
- app.demo_mode = True for DemoDevice
|
||||
- async with app.run_test() for headless rendering
|
||||
- post_message() for button events (avoids coordinate issues)
|
||||
|
||||
Note: ModalScreen widgets are on the screen stack, not the app's regular
|
||||
DOM. Use app.screen to get the active overlay, then query within it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from birdcage_tui.app import BirdcageApp
|
||||
from birdcage_tui.screens.camera import CameraOverlay, CaptureStatusPanel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f6_opens_camera_overlay(tmp_path: Path):
|
||||
"""F6 should push the camera overlay."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
assert not app._camera_visible
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._camera_visible
|
||||
# The top screen should be the camera overlay
|
||||
assert isinstance(app.screen, CameraOverlay)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f6_closes_camera_overlay(tmp_path: Path):
|
||||
"""F6 again should dismiss the camera overlay."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert app._camera_visible
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert not app._camera_visible
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escape_closes_camera_overlay(tmp_path: Path):
|
||||
"""Escape should dismiss the camera overlay."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert app._camera_visible
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert not app._camera_visible
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_capture_creates_file(tmp_path: Path):
|
||||
"""Pressing 'c' should create a capture file."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
# Trigger manual capture
|
||||
await pilot.press("c")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Check captures directory
|
||||
capture_dir = tmp_path / "captures"
|
||||
jpg_files = list(capture_dir.rglob("*.jpg"))
|
||||
json_files = list(capture_dir.rglob("*.json"))
|
||||
|
||||
assert len(jpg_files) >= 1
|
||||
assert len(json_files) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_button(tmp_path: Path):
|
||||
"""Capture button should trigger a capture."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
# Get the overlay from the screen stack
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
from textual.widgets import Button
|
||||
|
||||
btn = overlay.query_one("#btn-capture", Button)
|
||||
btn.post_message(Button.Pressed(btn))
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
capture_dir = tmp_path / "captures"
|
||||
jpg_files = list(capture_dir.rglob("*.jpg"))
|
||||
assert len(jpg_files) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_log_updates(tmp_path: Path):
|
||||
"""Capture log should show entries after captures."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
# Two captures
|
||||
await pilot.press("c")
|
||||
await asyncio.sleep(0.3)
|
||||
await pilot.press("c")
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# Status panel should show count
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
|
||||
assert panel._capture_count >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_toggles(tmp_path: Path):
|
||||
"""AOS/TCA/LOS trigger buttons should toggle active state."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
from textual.widgets import Button
|
||||
|
||||
from birdcage_tui.capture_triggers import TriggerType
|
||||
|
||||
# Toggle AOS on
|
||||
aos_btn = overlay.query_one("#btn-trigger-aos", Button)
|
||||
aos_btn.post_message(Button.Pressed(aos_btn))
|
||||
await pilot.pause()
|
||||
assert overlay._pass_detector.is_enabled(TriggerType.AOS)
|
||||
|
||||
# Toggle AOS off
|
||||
aos_btn.post_message(Button.Pressed(aos_btn))
|
||||
await pilot.pause()
|
||||
assert not overlay._pass_detector.is_enabled(TriggerType.AOS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_interval_start_stop(tmp_path: Path):
|
||||
"""Auto interval start/stop should control the timer."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
from textual.widgets import Button
|
||||
|
||||
# Start auto
|
||||
start_btn = overlay.query_one("#btn-auto-start", Button)
|
||||
start_btn.post_message(Button.Pressed(start_btn))
|
||||
await pilot.pause()
|
||||
assert overlay._auto_running
|
||||
|
||||
# Stop auto
|
||||
stop_btn = overlay.query_one("#btn-auto-stop", Button)
|
||||
stop_btn.post_message(Button.Pressed(stop_btn))
|
||||
await pilot.pause()
|
||||
assert not overlay._auto_running
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_camera_overlay_with_console(tmp_path: Path):
|
||||
"""F5 and F6 should open independent overlays."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
# Open camera first
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert app._camera_visible
|
||||
assert not app._console_visible
|
||||
|
||||
# Close camera
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
assert not app._camera_visible
|
||||
|
||||
# Open console
|
||||
await pilot.press("f5")
|
||||
await pilot.pause()
|
||||
assert app._console_visible
|
||||
assert not app._camera_visible
|
||||
|
||||
# Close console
|
||||
await pilot.press("f5")
|
||||
await pilot.pause()
|
||||
assert not app._console_visible
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_panel_shows_formats(tmp_path: Path):
|
||||
"""Status panel should list available output formats."""
|
||||
app = BirdcageApp()
|
||||
app.demo_mode = True
|
||||
app.capture_dir = str(tmp_path / "captures")
|
||||
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
await pilot.press("f6")
|
||||
await pilot.pause()
|
||||
|
||||
overlay = app.screen
|
||||
assert isinstance(overlay, CameraOverlay)
|
||||
|
||||
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
|
||||
# Should always have JPEG + JSON at minimum
|
||||
assert "JPEG" in panel._formats
|
||||
assert "JSON" in panel._formats
|
||||
Loading…
Add table
Add a link
Reference in a new issue