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
113
tui/tests/test_camera_backend.py
Normal file
113
tui/tests/test_camera_backend.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Test camera backend abstraction — DemoCamera, SubprocessCamera, detection.
|
||||
|
||||
DemoCamera is always available and produces valid output files.
|
||||
SubprocessCamera reports unavailable when the tool doesn't exist.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from birdcage_tui.camera import (
|
||||
CameraConfig,
|
||||
DemoCamera,
|
||||
SubprocessCamera,
|
||||
auto_select_backend,
|
||||
detect_cameras,
|
||||
)
|
||||
|
||||
|
||||
def test_demo_camera_captures_file(tmp_path: Path):
|
||||
"""DemoCamera should produce a file at the requested path."""
|
||||
camera = DemoCamera()
|
||||
output = tmp_path / "test.jpg"
|
||||
|
||||
result = camera.capture(output)
|
||||
|
||||
assert result.success
|
||||
assert output.exists()
|
||||
assert output.stat().st_size > 0
|
||||
assert result.timestamp # ISO-8601 UTC string
|
||||
assert result.duration_ms >= 0
|
||||
|
||||
|
||||
def test_demo_camera_always_available():
|
||||
"""DemoCamera.is_available() should always return True."""
|
||||
camera = DemoCamera()
|
||||
assert camera.is_available()
|
||||
|
||||
|
||||
def test_demo_camera_name():
|
||||
"""DemoCamera.name should be 'demo'."""
|
||||
camera = DemoCamera()
|
||||
assert camera.name == "demo"
|
||||
|
||||
|
||||
def test_demo_camera_valid_jpeg(tmp_path: Path):
|
||||
"""DemoCamera should produce a file starting with JPEG SOI marker."""
|
||||
camera = DemoCamera()
|
||||
output = tmp_path / "test.jpg"
|
||||
|
||||
result = camera.capture(output)
|
||||
assert result.success
|
||||
|
||||
data = output.read_bytes()
|
||||
# JPEG files start with SOI marker: FF D8
|
||||
assert data[:2] == b"\xff\xd8"
|
||||
|
||||
|
||||
def test_demo_camera_custom_resolution():
|
||||
"""DemoCamera should accept custom resolution."""
|
||||
camera = DemoCamera(resolution=(640, 480))
|
||||
assert camera._resolution == (640, 480)
|
||||
|
||||
|
||||
def test_subprocess_camera_not_available():
|
||||
"""SubprocessCamera with nonexistent tool should be unavailable."""
|
||||
config = CameraConfig(backend_cmd="nonexistent_camera_tool_xyz")
|
||||
camera = SubprocessCamera(config)
|
||||
assert not camera.is_available()
|
||||
|
||||
|
||||
def test_subprocess_camera_capture_missing_tool(tmp_path: Path):
|
||||
"""Capture with missing tool should return failure."""
|
||||
config = CameraConfig(backend_cmd="nonexistent_camera_tool_xyz")
|
||||
camera = SubprocessCamera(config)
|
||||
output = tmp_path / "test.jpg"
|
||||
|
||||
result = camera.capture(output)
|
||||
assert not result.success
|
||||
assert result.error # Has a meaningful error message
|
||||
|
||||
|
||||
def test_subprocess_camera_name():
|
||||
"""SubprocessCamera.name should include tool and device."""
|
||||
config = CameraConfig(backend_cmd="fswebcam", device="/dev/video0")
|
||||
camera = SubprocessCamera(config)
|
||||
assert "fswebcam" in camera.name
|
||||
assert "/dev/video0" in camera.name
|
||||
|
||||
|
||||
def test_detect_cameras_returns_list():
|
||||
"""detect_cameras() should return a list (may be empty in CI)."""
|
||||
devices = detect_cameras()
|
||||
assert isinstance(devices, list)
|
||||
|
||||
|
||||
def test_auto_select_returns_available_backend():
|
||||
"""auto_select should return an available backend (demo or real)."""
|
||||
config = CameraConfig(backend_cmd="nonexistent_tool_xyz")
|
||||
backend = auto_select_backend(config)
|
||||
# Should always return something available
|
||||
assert backend.is_available()
|
||||
# If no real backends exist, should be demo; otherwise a real one
|
||||
assert backend.name # Has a name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend_cmd", ["fswebcam", "ffmpeg", "libcamera-still"])
|
||||
def test_subprocess_unknown_backend_fails(tmp_path: Path, backend_cmd: str):
|
||||
"""Unknown backend command pattern should be in the lookup table."""
|
||||
config = CameraConfig(backend_cmd=backend_cmd)
|
||||
SubprocessCamera(config)
|
||||
# The command template should exist even if the tool isn't installed
|
||||
assert backend_cmd in SubprocessCamera._COMMANDS
|
||||
Loading…
Add table
Add a link
Reference in a new issue