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:
Ryan Malloy 2026-02-16 05:08:18 -07:00
parent 6c1e9da773
commit 7035d814a1
13 changed files with 2576 additions and 2 deletions

View file

@ -16,6 +16,7 @@ from textual.binding import Binding
from textual.containers import Horizontal
from textual.widgets import Button, ContentSwitcher, Footer, Header
from birdcage_tui.screens.camera import CameraOverlay
from birdcage_tui.screens.console import ConsoleOverlay
from birdcage_tui.screens.control import ControlScreen
from birdcage_tui.screens.dashboard import DashboardScreen
@ -45,6 +46,7 @@ class BirdcageApp(App):
Binding("f3", "switch_tab('signal')", "Signal"),
Binding("f4", "switch_tab('system')", "System"),
Binding("f5", "toggle_console", "Console"),
Binding("f6", "toggle_camera", "Camera"),
Binding("q", "quit", "Quit"),
Binding("d", "toggle_dark", "Dark"),
]
@ -55,6 +57,8 @@ class BirdcageApp(App):
firmware_name: str = "g2"
skip_init: bool = False
craft_url: str = "https://space.warehack.ing"
capture_dir: str = "captures"
camera_device: str = "auto"
device: object = None
shutdown_event: threading.Event = threading.Event()
@ -64,12 +68,15 @@ class BirdcageApp(App):
_prev_az: float = 0.0
_prev_el: float = 0.0
_console_visible: bool = False
_camera_visible: bool = False
_pass_detector: object = None # PassEventDetector, set by CameraOverlay
@property
def SUB_TITLE(self) -> str: # noqa: N802
tag = "a generic AZ/EL positioner that doesn't care about wavelength"
if self.demo_mode:
return "DEMO"
return self.serial_port
return f"{tag} · DEMO"
return f"{tag} · {self.serial_port}"
def compose(self) -> ComposeResult:
yield Header()
@ -114,6 +121,7 @@ class BirdcageApp(App):
self._setup_craft_client()
self._update_status_strip_connection()
self._install_console()
self._install_camera()
self._start_position_poll()
async def _initialize_device(self) -> None:
@ -159,6 +167,10 @@ class BirdcageApp(App):
"""Pre-install the console overlay so it persists across open/close."""
self.install_screen(ConsoleOverlay(), name="console-overlay")
def _install_camera(self) -> None:
"""Pre-install the camera overlay so it persists across open/close."""
self.install_screen(CameraOverlay(), name="camera-overlay")
# ------------------------------------------------------------------
# App-level position poll
# ------------------------------------------------------------------
@ -246,6 +258,25 @@ class BirdcageApp(App):
"""Called when the console overlay is dismissed."""
self._console_visible = False
# ------------------------------------------------------------------
# Camera overlay
# ------------------------------------------------------------------
def action_toggle_camera(self) -> None:
"""Push or pop the camera capture overlay."""
if self._camera_visible:
try:
self.pop_screen()
except Exception:
self._camera_visible = False
else:
self.push_screen("camera-overlay", callback=self._on_camera_dismissed)
self._camera_visible = True
def _on_camera_dismissed(self, _result=None) -> None:
"""Called when the camera overlay is dismissed."""
self._camera_visible = False
# ------------------------------------------------------------------
# Tab bar button handling
# ------------------------------------------------------------------
@ -329,6 +360,16 @@ def main() -> None:
default="https://space.warehack.ing",
help="Craft API base URL",
)
parser.add_argument(
"--capture-dir",
default="captures",
help="Output directory for camera captures",
)
parser.add_argument(
"--camera-device",
default="auto",
help="Camera device (e.g., /dev/video0) or 'auto'",
)
args = parser.parse_args()
app = BirdcageApp()
@ -337,6 +378,8 @@ def main() -> None:
app.firmware_name = args.firmware
app.skip_init = args.skip_init
app.craft_url = args.craft_url
app.capture_dir = args.capture_dir
app.camera_device = args.camera_device
try:
app.run()
except KeyboardInterrupt: