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:

View file

@ -0,0 +1,375 @@
"""Camera backend abstraction — capture images from USB/CSI cameras.
Wraps fswebcam/ffmpeg/libcamera-still via subprocess for zero-dependency
operation. DemoCamera generates placeholder frames without hardware.
No TUI dependency. All methods are blocking designed for
@work(thread=True) workers in the TUI.
"""
import glob
import logging
import shutil
import struct
import subprocess
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger(__name__)
@dataclass
class CameraConfig:
"""Camera hardware and capture settings."""
device: str = "/dev/video0"
resolution: tuple[int, int] = (1280, 720)
backend_cmd: str = "fswebcam" # fswebcam | ffmpeg | libcamera-still
extra_args: list[str] = field(default_factory=list)
@dataclass
class CaptureResult:
"""Outcome of a single capture attempt."""
path: Path
timestamp: str # ISO-8601 UTC
duration_ms: float
success: bool
error: str = ""
class CameraBackend(ABC):
"""Abstract camera backend. Subclasses wrap specific capture tools."""
@abstractmethod
def capture(self, output_path: Path) -> CaptureResult:
"""Capture a single frame to output_path. Blocking."""
@abstractmethod
def is_available(self) -> bool:
"""Check if the backend tool and device are accessible."""
@property
@abstractmethod
def name(self) -> str:
"""Human-readable backend identifier."""
class SubprocessCamera(CameraBackend):
"""Camera backend wrapping fswebcam/ffmpeg/libcamera-still.
Each tool is invoked via subprocess.run() with a 10-second timeout.
The caller provides the output path; the backend builds the command.
"""
_COMMANDS: dict[str, list[str]] = {
"fswebcam": [
"fswebcam",
"-r",
"{width}x{height}",
"--no-banner",
"-d",
"{device}",
"{output}",
],
"ffmpeg": [
"ffmpeg",
"-y",
"-f",
"v4l2",
"-video_size",
"{width}x{height}",
"-i",
"{device}",
"-frames:v",
"1",
"{output}",
],
"libcamera-still": [
"libcamera-still",
"--width",
"{width}",
"--height",
"{height}",
"-t",
"1",
"-o",
"{output}",
],
}
def __init__(self, config: CameraConfig) -> None:
self._config = config
def capture(self, output_path: Path) -> CaptureResult:
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
t0 = time.monotonic()
template = self._COMMANDS.get(self._config.backend_cmd)
if template is None:
return CaptureResult(
path=output_path,
timestamp=ts,
duration_ms=0.0,
success=False,
error=f"Unknown backend: {self._config.backend_cmd}",
)
w, h = self._config.resolution
cmd = [
part.format(
width=w,
height=h,
device=self._config.device,
output=str(output_path),
)
for part in template
]
cmd.extend(self._config.extra_args)
try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=10,
check=False,
)
elapsed = (time.monotonic() - t0) * 1000
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace")[:200]
return CaptureResult(
path=output_path,
timestamp=ts,
duration_ms=elapsed,
success=False,
error=f"Exit {result.returncode}: {stderr}",
)
return CaptureResult(
path=output_path,
timestamp=ts,
duration_ms=elapsed,
success=True,
)
except subprocess.TimeoutExpired:
elapsed = (time.monotonic() - t0) * 1000
return CaptureResult(
path=output_path,
timestamp=ts,
duration_ms=elapsed,
success=False,
error="Capture timed out (10s)",
)
except FileNotFoundError:
return CaptureResult(
path=output_path,
timestamp=ts,
duration_ms=0.0,
success=False,
error=f"{self._config.backend_cmd} not found",
)
def is_available(self) -> bool:
return shutil.which(self._config.backend_cmd) is not None
@property
def name(self) -> str:
return f"{self._config.backend_cmd} ({self._config.device})"
class DemoCamera(CameraBackend):
"""Generates a minimal valid JPEG without external dependencies.
When Pillow is available, renders a placeholder frame with a timestamp
overlay. Otherwise creates a tiny valid JPEG (gray 1x1 pixel). Always
available used in --demo mode.
"""
def __init__(self, resolution: tuple[int, int] = (1280, 720)) -> None:
self._resolution = resolution
def capture(self, output_path: Path) -> CaptureResult:
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
t0 = time.monotonic()
try:
self._write_frame(output_path, ts)
elapsed = (time.monotonic() - t0) * 1000
return CaptureResult(
path=output_path,
timestamp=ts,
duration_ms=elapsed,
success=True,
)
except Exception as exc:
elapsed = (time.monotonic() - t0) * 1000
return CaptureResult(
path=output_path,
timestamp=ts,
duration_ms=elapsed,
success=False,
error=str(exc),
)
def _write_frame(self, output_path: Path, timestamp: str) -> None:
"""Try Pillow first, fall back to minimal JPEG."""
try:
from PIL import Image, ImageDraw
w, h = self._resolution
img = Image.new("RGB", (w, h), color=(14, 20, 32))
draw = ImageDraw.Draw(img)
draw.text((20, 20), "DEMO CAPTURE", fill=(0, 212, 170))
draw.text((20, 50), timestamp, fill=(200, 208, 216))
draw.text((20, 80), f"{w}x{h}", fill=(80, 104, 120))
img.save(output_path, "JPEG", quality=85)
except ImportError:
self._write_minimal_jpeg(output_path)
def _write_minimal_jpeg(self, output_path: Path) -> None:
"""Write a valid 1x1 gray JPEG (smallest possible valid file)."""
# Minimal JFIF: SOI + APP0 + DQT + SOF0 + DHT + SOS + data + EOI
# This is a pre-computed 1x1 gray pixel JPEG.
data = bytes(
[
0xFF,
0xD8, # SOI
0xFF,
0xE0, # APP0 (JFIF)
]
)
app0_payload = b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
data += struct.pack(">H", len(app0_payload) + 2) + app0_payload
# Quantization table (all 1s for simplicity)
dqt = bytes([0xFF, 0xDB, 0x00, 0x43, 0x00]) + bytes([1] * 64)
data += dqt
# SOF0 (baseline, 1x1, 1 component, Y only)
sof0 = bytes(
[
0xFF,
0xC0,
0x00,
0x0B,
0x08,
0x00,
0x01,
0x00,
0x01, # height=1, width=1
0x01,
0x01,
0x11,
0x00, # 1 component, sampling 1x1, quant table 0
]
)
data += sof0
# DHT (minimal Huffman table for DC)
dht = bytes(
[
0xFF,
0xC4,
0x00,
0x1F,
0x00,
0x00,
0x01,
0x05,
0x01,
0x01,
0x01,
0x01,
0x01,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x01,
0x02,
0x03,
0x04,
0x05,
0x06,
0x07,
0x08,
0x09,
0x0A,
0x0B,
]
)
data += dht
# SOS + encoded data (gray pixel value 128)
sos = bytes(
[
0xFF,
0xDA,
0x00,
0x08,
0x01,
0x01,
0x00,
0x00,
0x3F,
0x00,
0x7F,
0x49, # encoded pixel data
]
)
data += sos
# EOI
data += bytes([0xFF, 0xD9])
output_path.write_bytes(data)
def is_available(self) -> bool:
return True
@property
def name(self) -> str:
return "demo"
def detect_cameras() -> list[str]:
"""Discover available video capture devices."""
devices = sorted(glob.glob("/dev/video*"))
return devices
def auto_select_backend(config: CameraConfig) -> CameraBackend:
"""Pick the best available backend for the given config.
Tries the configured backend first, then falls through alternatives.
Falls back to DemoCamera if nothing is available.
"""
# Try configured backend first
candidate = SubprocessCamera(config)
if candidate.is_available():
log.info("Using %s backend", config.backend_cmd)
return candidate
# Try alternatives in preference order
for alt_cmd in ("fswebcam", "ffmpeg", "libcamera-still"):
if alt_cmd == config.backend_cmd:
continue
alt_config = CameraConfig(
device=config.device,
resolution=config.resolution,
backend_cmd=alt_cmd,
)
alt = SubprocessCamera(alt_config)
if alt.is_available():
log.info("Falling back to %s backend", alt_cmd)
return alt
log.info("No camera backend available, using demo")
return DemoCamera(config.resolution)

View file

@ -0,0 +1,286 @@
"""Capture orchestrator — coordinates camera backend + metadata + output files.
Thread-safe (internal lock). All methods blocking designed for
@work(thread=True) workers. Writes JPEG + JSON sidecar always;
optionally adds EXIF (Pillow) and FITS (astropy) when available.
"""
import json
import logging
import re
import threading
import time
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from birdcage_tui.camera import CameraBackend, CaptureResult
log = logging.getLogger(__name__)
# Optional dependencies — graceful degradation
try:
from PIL import Image
HAS_PILLOW = True
except ImportError:
HAS_PILLOW = False
try:
from astropy.io import fits as astropy_fits
HAS_ASTROPY = True
except ImportError:
HAS_ASTROPY = False
# Filename-safe character filter
_SAFE_RE = re.compile(r"[^a-zA-Z0-9_\-]")
@dataclass
class CaptureMetadata:
"""Everything we know about a capture at the moment the shutter fires."""
# Timing
timestamp: str = "" # ISO-8601 UTC
capture_duration_ms: float = 0.0
# Mount position (actual dish position at capture time)
mount_az: float = 0.0
mount_el: float = 0.0
# Target info (from active tracking, if any)
target_name: str = ""
target_type: str = "" # satellite, planet, star, comet, ""
target_id: str = ""
target_az: float | None = None
target_el: float | None = None
target_distance_km: float | None = None
target_range_rate: float | None = None
# Context
tracking_mode: str = "manual" # manual, craft, rotctld
tracking_status: str = "" # TRACKING, WAITING, IDLE
trigger: str = "manual" # manual, interval, aos, tca, los
# Sequence
sequence_number: int = 0
session_id: str = ""
# Camera
camera_backend: str = ""
camera_device: str = ""
resolution: tuple[int, int] = (0, 0)
# Device
firmware_version: str = ""
@dataclass
class CaptureSession:
"""Tracks a capture session with sequential numbering."""
session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
start_time: str = field(
default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
)
capture_count: int = 0
output_dir: Path = Path("captures")
def next_sequence(self) -> int:
self.capture_count += 1
return self.capture_count
def _sanitize_name(name: str) -> str:
"""Make a target name filesystem-safe."""
if not name:
return "manual"
cleaned = name.replace(" ", "-")
cleaned = _SAFE_RE.sub("", cleaned)
return cleaned[:40] or "capture"
def _build_filename(metadata: CaptureMetadata, ext: str) -> str:
"""Generate filename: {target}_{az:06.2f}_{el:05.2f}_{timestamp}.{ext}"""
name = _sanitize_name(metadata.target_name)
az = f"{metadata.mount_az:06.2f}"
el = f"{metadata.mount_el:05.2f}"
# Compact timestamp: 20260216T082500Z
ts = metadata.timestamp.replace("-", "").replace(":", "").replace(" ", "")
return f"{name}_{az}_{el}_{ts}.{ext}"
class CaptureManager:
"""Orchestrates capture → metadata → output pipeline.
Usage:
manager = CaptureManager(backend, output_dir=Path("captures"))
result = manager.capture(metadata) # blocking
Output files per capture:
- JPEG (always) from camera backend
- JSON sidecar (always) full metadata
- FITS (when astropy available) with WCS headers
"""
def __init__(
self, backend: CameraBackend, output_dir: Path = Path("captures")
) -> None:
self._backend = backend
self._output_dir = output_dir
self._session = CaptureSession(output_dir=output_dir)
self._lock = threading.Lock()
def capture(self, metadata: CaptureMetadata) -> CaptureResult:
"""Execute a full capture cycle. Blocking, thread-safe."""
with self._lock:
seq = self._session.next_sequence()
metadata.sequence_number = seq
metadata.session_id = self._session.session_id
metadata.camera_backend = self._backend.name
# Date-based subdirectory
date_str = time.strftime("%Y-%m-%d", time.gmtime())
capture_dir = self._output_dir / date_str
capture_dir.mkdir(parents=True, exist_ok=True)
# Capture the frame
jpeg_name = _build_filename(metadata, "jpg")
jpeg_path = capture_dir / jpeg_name
result = self._backend.capture(jpeg_path)
# Populate timing metadata from result
metadata.timestamp = result.timestamp
metadata.capture_duration_ms = result.duration_ms
# Always write JSON sidecar
self._write_json_sidecar(capture_dir, metadata)
if result.success:
# EXIF embedding (optional)
if HAS_PILLOW:
self._add_jpeg_exif(jpeg_path, metadata)
# FITS output (optional)
if HAS_ASTROPY:
fits_name = _build_filename(metadata, "fits")
fits_path = capture_dir / fits_name
self._write_fits(jpeg_path, fits_path, metadata)
log.info(
"Capture #%d: %s (%.1fms)",
seq,
jpeg_name,
result.duration_ms,
)
else:
log.warning("Capture #%d failed: %s", seq, result.error)
return result
@property
def session(self) -> CaptureSession:
return self._session
@property
def has_pillow(self) -> bool:
return HAS_PILLOW
@property
def has_astropy(self) -> bool:
return HAS_ASTROPY
def _write_json_sidecar(self, capture_dir: Path, metadata: CaptureMetadata) -> None:
"""Write metadata as a JSON sidecar file. Always succeeds or logs."""
try:
json_name = _build_filename(metadata, "json")
json_path = capture_dir / json_name
data = asdict(metadata)
# Convert tuple to list for JSON serialization
data["resolution"] = list(data["resolution"])
json_path.write_text(json.dumps(data, indent=2, default=str))
except Exception:
log.exception("Failed to write JSON sidecar")
def _add_jpeg_exif(self, jpeg_path: Path, metadata: CaptureMetadata) -> None:
"""Embed metadata into JPEG EXIF. Requires Pillow."""
try:
import piexif
exif_dict = piexif.load(str(jpeg_path))
# UserComment field for metadata
comment = json.dumps(
{
"mount_az": metadata.mount_az,
"mount_el": metadata.mount_el,
"target": metadata.target_name,
"trigger": metadata.trigger,
"session": metadata.session_id,
}
)
exif_dict["Exif"][piexif.ExifIFD.UserComment] = (
piexif.helper.UserComment.dump(comment, encoding="unicode")
)
exif_bytes = piexif.dump(exif_dict)
piexif.insert(exif_bytes, str(jpeg_path))
except Exception:
# piexif not available or EXIF write failed — non-fatal
log.debug("EXIF embedding skipped", exc_info=True)
def _write_fits(
self, jpeg_path: Path, fits_path: Path, metadata: CaptureMetadata
) -> None:
"""Write FITS file with WCS headers. Requires astropy."""
try:
if HAS_PILLOW:
img = Image.open(jpeg_path)
import numpy as np
data = np.array(img)
else:
# Without Pillow, create a minimal FITS with no image data
data = None
hdu = astropy_fits.PrimaryHDU(data=data)
hdr = hdu.header
# Standard FITS keywords
hdr["DATE-OBS"] = metadata.timestamp
hdr["OBJECT"] = metadata.target_name or "MANUAL"
hdr["TELESCOP"] = "Birdcage/Winegard"
hdr["INSTRUME"] = metadata.camera_backend
# WCS — AZ/EL tangent plane projection
hdr["CTYPE1"] = "AZ---TAN"
hdr["CTYPE2"] = "EL---TAN"
hdr["CRVAL1"] = metadata.mount_az
hdr["CRVAL2"] = metadata.mount_el
hdr["CRPIX1"] = metadata.resolution[0] / 2 if metadata.resolution[0] else 1
hdr["CRPIX2"] = metadata.resolution[1] / 2 if metadata.resolution[1] else 1
# Custom headers
hdr["MOUNT-AZ"] = (metadata.mount_az, "Mount azimuth (deg)")
hdr["MOUNT-EL"] = (metadata.mount_el, "Mount elevation (deg)")
hdr["TRIGGER"] = (metadata.trigger, "Capture trigger type")
hdr["SEQNUM"] = (metadata.sequence_number, "Capture sequence number")
hdr["SESSID"] = (metadata.session_id, "Capture session ID")
if metadata.target_name:
hdr["TGT-NAME"] = (metadata.target_name, "Target name")
hdr["TGT-TYPE"] = (metadata.target_type, "Target type")
hdr["TGT-ID"] = (metadata.target_id, "Target ID")
if metadata.target_distance_km is not None:
hdr["TGT-DIST"] = (metadata.target_distance_km, "Target distance (km)")
if metadata.target_range_rate is not None:
hdr["TGT-RATE"] = (
metadata.target_range_rate,
"Target range rate (km/s)",
)
hdu.writeto(str(fits_path), overwrite=True)
log.debug("FITS written: %s", fits_path.name)
except Exception:
log.exception("Failed to write FITS file")

View file

@ -0,0 +1,212 @@
"""Capture trigger system — manual, interval, and pass-event triggers.
Decoupled from TUI operates on callbacks. The PassEventDetector is a
stateful edge detector: call update() from the tracking loop each iteration,
and it fires callbacks on AOS/TCA/LOS transitions.
IntervalTimer runs a daemon thread that fires at configurable intervals.
"""
import logging
import threading
import time
from enum import Enum, auto
log = logging.getLogger(__name__)
class TriggerType(Enum):
"""Types of capture triggers."""
MANUAL = auto()
INTERVAL = auto()
AOS = auto() # Acquisition of Signal: WAITING/IDLE -> TRACKING
TCA = auto() # Time of Closest Approach: elevation peak during TRACKING
LOS = auto() # Loss of Signal: TRACKING -> WAITING/IDLE
class TriggerEvent:
"""A trigger event with type, timestamp, and optional detail."""
__slots__ = ("trigger_type", "timestamp", "detail")
def __init__(
self,
trigger_type: TriggerType,
timestamp: str = "",
detail: str = "",
) -> None:
self.trigger_type = trigger_type
self.timestamp = timestamp or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
self.detail = detail
class IntervalTimer:
"""Background thread that fires a callback every N seconds.
Thread-safe start/stop/pause. Daemon thread doesn't block shutdown.
"""
def __init__(
self,
callback: "callable",
interval_seconds: float = 10.0,
) -> None:
self._callback = callback
self._interval = interval_seconds
self._running = False
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
def start(self) -> None:
"""Start firing at the configured interval."""
if self._running:
return
self._running = True
self._stop_event.clear()
self._thread = threading.Thread(
target=self._loop,
name="capture-interval",
daemon=True,
)
self._thread.start()
def stop(self) -> None:
"""Stop the timer. Safe to call multiple times."""
self._running = False
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
def set_interval(self, seconds: float) -> None:
"""Update the interval. Takes effect on the next cycle."""
self._interval = max(1.0, seconds)
@property
def is_running(self) -> bool:
return self._running
@property
def interval(self) -> float:
return self._interval
def _loop(self) -> None:
while self._running and not self._stop_event.is_set():
self._stop_event.wait(self._interval)
if not self._running:
break
try:
event = TriggerEvent(
trigger_type=TriggerType.INTERVAL,
detail=f"interval={self._interval:.0f}s",
)
self._callback(event)
except Exception:
log.exception("Interval trigger callback failed")
class PassEventDetector:
"""Stateful edge detector for satellite pass events.
Call update() each tracking iteration with the current status, target
name, and elevation. The detector fires callbacks on state transitions:
- AOS: status transitions from WAITING/IDLE to TRACKING
- LOS: status transitions from TRACKING to WAITING/IDLE
- TCA: during TRACKING, elevation stops ascending and drops by >0.5 deg
from the observed peak (hysteresis prevents jitter false triggers)
The detector tracks per-target state and resets on target change.
"""
# Minimum elevation drop from peak before we fire TCA (hysteresis)
TCA_HYSTERESIS_DEG = 0.5
def __init__(self, on_event: "callable") -> None:
self._on_event = on_event
self._enabled: set[TriggerType] = set()
self._prev_status: str = ""
self._prev_target: str = ""
self._peak_el: float = -90.0
self._tca_fired: bool = False
def enable(self, *trigger_types: TriggerType) -> None:
"""Enable one or more trigger types."""
for tt in trigger_types:
self._enabled.add(tt)
def disable(self, *trigger_types: TriggerType) -> None:
"""Disable one or more trigger types."""
for tt in trigger_types:
self._enabled.discard(tt)
def is_enabled(self, trigger_type: TriggerType) -> bool:
return trigger_type in self._enabled
def update(self, status: str, target_name: str, elevation: float) -> None:
"""Feed current tracking state. Call each iteration (~1 Hz)."""
# Reset on target change
if target_name != self._prev_target:
self._peak_el = -90.0
self._tca_fired = False
self._prev_status = ""
self._prev_target = target_name
# AOS detection: non-tracking -> TRACKING
if (
TriggerType.AOS in self._enabled
and status == "TRACKING"
and self._prev_status in ("WAITING", "IDLE", "")
):
self._fire(
TriggerType.AOS,
f"AOS: {target_name} at EL {elevation:.1f}\u00b0",
)
self._peak_el = elevation
self._tca_fired = False
# LOS detection: TRACKING -> non-tracking
if (
TriggerType.LOS in self._enabled
and self._prev_status == "TRACKING"
and status in ("WAITING", "IDLE")
):
self._fire(
TriggerType.LOS,
f"LOS: {target_name} at EL {elevation:.1f}\u00b0",
)
# TCA detection: elevation peak with hysteresis
if status == "TRACKING":
if elevation > self._peak_el:
self._peak_el = elevation
self._tca_fired = False
elif (
TriggerType.TCA in self._enabled
and not self._tca_fired
and (self._peak_el - elevation) > self.TCA_HYSTERESIS_DEG
):
self._fire(
TriggerType.TCA,
f"TCA: {target_name} peak EL {self._peak_el:.1f}\u00b0",
)
self._tca_fired = True
self._prev_status = status
def reset(self) -> None:
"""Reset all state. Call when tracking stops."""
self._prev_status = ""
self._prev_target = ""
self._peak_el = -90.0
self._tca_fired = False
def _fire(self, trigger_type: TriggerType, detail: str) -> None:
"""Dispatch a trigger event."""
try:
event = TriggerEvent(trigger_type=trigger_type, detail=detail)
self._on_event(event)
log.info("Pass event: %s%s", trigger_type.name, detail)
except Exception:
log.exception("Pass event callback failed for %s", trigger_type.name)

View file

@ -0,0 +1,426 @@
"""Camera capture overlay — F6 slide-up modal for image acquisition.
Follows the ConsoleOverlay pattern: ModalScreen pushed via F6, dismissed
via Escape or F6 again. Pre-installed via install_screen() for persistence.
Provides manual capture, interval timer, and AOS/TCA/LOS pass-event
triggers. Each capture writes JPEG + JSON sidecar (+ optional FITS).
"""
import logging
import time
from textual import work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Button, Input, RichLog, Static
from birdcage_tui.camera import CaptureResult
from birdcage_tui.capture_manager import CaptureManager, CaptureMetadata
from birdcage_tui.capture_triggers import (
IntervalTimer,
PassEventDetector,
TriggerEvent,
TriggerType,
)
log = logging.getLogger(__name__)
# Map trigger types to display colors (Rich markup)
_TRIGGER_COLORS: dict[TriggerType, str] = {
TriggerType.MANUAL: "#00d4aa",
TriggerType.INTERVAL: "#00b8c8",
TriggerType.AOS: "#00e060",
TriggerType.TCA: "#e8c020",
TriggerType.LOS: "#e04040",
}
class CaptureStatusPanel(Static):
"""Top status bar showing camera state, capture count, and formats."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._camera_name = "none"
self._capture_count = 0
self._last_capture = ""
self._formats = "JPEG + JSON"
self._triggers_text = "none"
self._auto_interval = 0.0
def update_status(
self,
camera_name: str = "",
capture_count: int = 0,
last_capture: str = "",
formats: str = "",
triggers_text: str = "",
auto_interval: float = 0.0,
) -> None:
if camera_name:
self._camera_name = camera_name
self._capture_count = capture_count
if last_capture:
self._last_capture = last_capture
if formats:
self._formats = formats
if triggers_text:
self._triggers_text = triggers_text
self._auto_interval = auto_interval
self._refresh_display()
def _refresh_display(self) -> None:
last = self._last_capture or "---"
auto = f" Auto: {self._auto_interval:.0f}s" if self._auto_interval > 0 else ""
self.update(
f" Camera: {self._camera_name} | "
f"Captures: {self._capture_count} | "
f"Last: {last}\n"
f" Formats: {self._formats} | "
f"Triggers: {self._triggers_text}{auto}"
)
class CameraOverlay(ModalScreen):
"""F6: Camera capture overlay as a slide-up panel.
Slides up from the bottom of the terminal, taking ~45% of the viewport.
Provides manual capture, interval timer, and pass-event trigger controls.
"""
BINDINGS = [
Binding("escape", "dismiss_overlay", "Close", priority=True),
Binding("f6", "dismiss_overlay", "Close", priority=True),
Binding("c", "manual_capture", "Capture", priority=True),
]
def __init__(self) -> None:
super().__init__()
self._capture_manager: CaptureManager | None = None
self._interval_timer: IntervalTimer | None = None
self._pass_detector: PassEventDetector | None = None
self._auto_running = False
def compose(self) -> ComposeResult:
with Container(id="camera-overlay"):
yield CaptureStatusPanel(id="capture-status")
yield RichLog(id="capture-log", markup=True, wrap=True)
with Horizontal(classes="camera-controls"):
yield Button("Capture", id="btn-capture", variant="primary")
yield Static("Interval:", classes="label")
yield Input(
value="10",
id="camera-interval-input",
type="integer",
)
yield Static("s", classes="label")
yield Button("Start", id="btn-auto-start")
yield Button("Stop", id="btn-auto-stop")
with Horizontal(classes="camera-trigger-bar"):
yield Static("Triggers:", classes="label")
yield Button("AOS", id="btn-trigger-aos", classes="trigger-btn")
yield Button("TCA", id="btn-trigger-tca", classes="trigger-btn")
yield Button("LOS", id="btn-trigger-los", classes="trigger-btn")
yield Static(" Cam:", classes="label")
yield Static("---", id="camera-backend-label")
def on_mount(self) -> None:
"""Initialize camera system on first mount."""
self._setup_camera()
def on_screen_resume(self) -> None:
"""Refresh status when the overlay is re-opened."""
self._refresh_status()
def action_dismiss_overlay(self) -> None:
self.dismiss()
# ------------------------------------------------------------------
# Camera setup
# ------------------------------------------------------------------
def _setup_camera(self) -> None:
"""Initialize camera backend and capture manager."""
from pathlib import Path
from birdcage_tui.camera import CameraConfig, auto_select_backend
capture_dir = getattr(self.app, "capture_dir", "captures")
camera_device = getattr(self.app, "camera_device", "auto")
config = CameraConfig()
if camera_device != "auto":
config.device = camera_device
# In demo mode, always use the demo camera
is_demo = getattr(self.app, "demo_mode", False)
if is_demo:
from birdcage_tui.camera import DemoCamera
backend = DemoCamera(config.resolution)
else:
backend = auto_select_backend(config)
output_dir = Path(capture_dir)
self._capture_manager = CaptureManager(backend, output_dir)
# Set up interval timer
self._interval_timer = IntervalTimer(
callback=self._on_interval_trigger,
interval_seconds=10.0,
)
# Set up pass event detector (shared at app level)
self._pass_detector = getattr(self.app, "_pass_detector", None)
if self._pass_detector is None:
self._pass_detector = PassEventDetector(
on_event=self._on_pass_trigger,
)
# Store at app level so control.py can feed it
self.app._pass_detector = self._pass_detector
self._refresh_status()
# Update backend label
try:
label = self.query_one("#camera-backend-label", Static)
label.update(backend.name)
except Exception:
pass
# Log welcome
capture_log = self.query_one("#capture-log", RichLog)
capture_log.write(f"[#506878]Camera ready: {backend.name}[/]")
formats = self._format_list()
capture_log.write(f"[#506878]Output: {formats}[/]")
# ------------------------------------------------------------------
# Manual capture
# ------------------------------------------------------------------
def action_manual_capture(self) -> None:
"""Trigger a manual capture via keyboard shortcut."""
self._do_capture(TriggerType.MANUAL)
# ------------------------------------------------------------------
# Capture execution
# ------------------------------------------------------------------
@work(thread=True)
def _do_capture(self, trigger_type: TriggerType) -> None:
"""Execute a capture in a worker thread."""
if self._capture_manager is None:
return
metadata = self._collect_metadata(trigger_type)
result = self._capture_manager.capture(metadata)
self.app.call_from_thread(self._on_capture_complete, result, metadata)
def _collect_metadata(self, trigger_type: TriggerType) -> CaptureMetadata:
"""Snapshot current app state into capture metadata."""
meta = CaptureMetadata(
mount_az=getattr(self.app, "_current_az", 0.0),
mount_el=getattr(self.app, "_current_el", 0.0),
trigger=trigger_type.name.lower(),
)
# Try to get tracking info from the Craft tracking state
try:
control = self.app.query_one("#control")
if hasattr(control, "_craft_state") and control._craft_tracking:
state = control._craft_state
meta.target_name = state.target_name
meta.target_az = state.azimuth
meta.target_el = state.elevation
meta.target_distance_km = state.distance_km
meta.target_range_rate = state.range_rate
meta.tracking_mode = "craft"
meta.tracking_status = state.status
meta.target_type = getattr(control, "_craft_target_type", "")
meta.target_id = getattr(control, "_craft_target_id", "")
except Exception:
pass
# Camera info
if self._capture_manager:
meta.camera_backend = self._capture_manager._backend.name
mgr = self._capture_manager
meta.resolution = (
getattr(mgr._backend, "_resolution", (0, 0))
if hasattr(mgr._backend, "_resolution")
else (0, 0)
)
return meta
def _on_capture_complete(
self, result: CaptureResult, metadata: CaptureMetadata
) -> None:
"""Update UI after capture completes (main thread)."""
capture_log = self.query_one("#capture-log", RichLog)
ts = time.strftime("%H:%M:%S", time.gmtime())
trigger_name = metadata.trigger.upper()
color = (
_TRIGGER_COLORS.get(TriggerType[trigger_name], "#506878")
if trigger_name in TriggerType.__members__
else "#506878"
)
if result.success:
name = result.path.name
if len(name) > 45:
name = name[:42] + "..."
capture_log.write(
f"[{color}][{ts}] {trigger_name:<8}[/] "
f"[#c8d0d8]{name}[/] "
f"[#506878]{result.duration_ms:.0f}ms[/]"
)
else:
capture_log.write(
f"[{color}][{ts}] {trigger_name:<8}[/] "
f"[#e04040]FAILED: {result.error}[/]"
)
self._refresh_status()
# ------------------------------------------------------------------
# Interval timer
# ------------------------------------------------------------------
def _on_interval_trigger(self, event: TriggerEvent) -> None:
"""Called from the interval timer thread."""
self.app.call_from_thread(self._do_capture, TriggerType.INTERVAL)
def _start_auto(self) -> None:
"""Start the interval timer."""
if self._interval_timer is None or self._auto_running:
return
try:
interval_input = self.query_one("#camera-interval-input", Input)
seconds = float(interval_input.value)
if seconds < 1:
seconds = 1
self._interval_timer.set_interval(seconds)
except (ValueError, Exception):
self._interval_timer.set_interval(10.0)
self._interval_timer.start()
self._auto_running = True
self._refresh_status()
capture_log = self.query_one("#capture-log", RichLog)
interval = self._interval_timer.interval
capture_log.write(f"[#00b8c8]Auto-capture started ({interval:.0f}s)[/]")
def _stop_auto(self) -> None:
"""Stop the interval timer."""
if self._interval_timer is None or not self._auto_running:
return
self._interval_timer.stop()
self._auto_running = False
self._refresh_status()
capture_log = self.query_one("#capture-log", RichLog)
capture_log.write("[#506878]Auto-capture stopped[/]")
# ------------------------------------------------------------------
# Pass event triggers
# ------------------------------------------------------------------
def _on_pass_trigger(self, event: TriggerEvent) -> None:
"""Called from the tracking loop thread via PassEventDetector."""
self.app.call_from_thread(self._do_capture, event.trigger_type)
def _toggle_trigger(self, trigger_type: TriggerType, button: Button) -> None:
"""Toggle a pass event trigger on/off."""
if self._pass_detector is None:
return
if self._pass_detector.is_enabled(trigger_type):
self._pass_detector.disable(trigger_type)
button.remove_class("active")
else:
self._pass_detector.enable(trigger_type)
button.add_class("active")
self._refresh_status()
# ------------------------------------------------------------------
# Button handlers
# ------------------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id or ""
if button_id == "btn-capture":
self._do_capture(TriggerType.MANUAL)
elif button_id == "btn-auto-start":
self._start_auto()
elif button_id == "btn-auto-stop":
self._stop_auto()
elif button_id == "btn-trigger-aos":
self._toggle_trigger(TriggerType.AOS, event.button)
elif button_id == "btn-trigger-tca":
self._toggle_trigger(TriggerType.TCA, event.button)
elif button_id == "btn-trigger-los":
self._toggle_trigger(TriggerType.LOS, event.button)
# ------------------------------------------------------------------
# Status helpers
# ------------------------------------------------------------------
def _refresh_status(self) -> None:
"""Update the status panel with current state."""
try:
panel = self.query_one("#capture-status", CaptureStatusPanel)
except Exception:
return
if self._capture_manager is None:
return
session = self._capture_manager.session
formats = self._format_list()
triggers = []
if self._pass_detector:
for tt in (TriggerType.AOS, TriggerType.TCA, TriggerType.LOS):
if self._pass_detector.is_enabled(tt):
triggers.append(tt.name)
triggers_text = " ".join(triggers) if triggers else "none"
auto_interval = self._interval_timer.interval if self._auto_running else 0.0
panel.update_status(
camera_name=(
self._capture_manager._backend.name if self._capture_manager else "none"
),
capture_count=session.capture_count,
last_capture="",
formats=formats,
triggers_text=triggers_text,
auto_interval=auto_interval,
)
def _format_list(self) -> str:
"""Build a string describing available output formats."""
parts = ["JPEG", "JSON"]
if self._capture_manager:
if self._capture_manager.has_pillow:
parts.append("EXIF")
if self._capture_manager.has_astropy:
parts.append("FITS")
return " + ".join(parts)
# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
def on_unmount(self) -> None:
"""Stop timers on teardown."""
if self._interval_timer:
self._interval_timer.stop()

View file

@ -687,6 +687,9 @@ class ControlScreen(Container):
state.target_name = name
state.error = ""
# Hook into pass event detector for camera triggers (if installed)
pass_detector = getattr(self.app, "_pass_detector", None)
self.app.call_from_thread(self._apply_craft_state, state)
while self._craft_tracking and not shutdown.is_set():
@ -739,6 +742,11 @@ class ControlScreen(Container):
state.error = "Motor command failed"
self.app.call_from_thread(self._apply_craft_state, state)
# Feed pass event detector for camera triggers
if pass_detector is not None:
pass_detector.update(state.status, name, state.elevation)
shutdown.wait(1.0)
# Clean exit
@ -746,6 +754,11 @@ class ControlScreen(Container):
state.error = ""
self.app.call_from_thread(self._apply_craft_state, state)
# Signal LOS on tracking stop
if pass_detector is not None:
pass_detector.update("IDLE", name, 0.0)
pass_detector.reset()
def _apply_craft_state(self, state: CraftTrackingState) -> None:
try:
panel = self.query_one("#ctrl-craft-panel", CraftPanel)

View file

@ -774,6 +774,102 @@ ProgressBar PercentageStatus {
margin-right: 1;
}
/* ── Camera Overlay (ModalScreen) ─────────────────── */
CameraOverlay {
background: rgba(10, 10, 18, 0.6);
}
#camera-overlay {
dock: bottom;
height: 45%;
width: 100%;
background: #0a0a12;
border-top: double #00b8c8;
padding: 0;
}
#capture-status {
height: auto;
min-height: 2;
padding: 0 1;
background: #0e1420;
color: #c8d0d8;
border-bottom: solid #1a2a38;
}
#capture-log {
height: 1fr;
}
.camera-controls {
dock: bottom;
height: auto;
width: 100%;
layout: horizontal;
padding: 0 1;
background: #0e1420;
border-top: solid #1a2a38;
}
.camera-controls .label {
width: auto;
padding: 1 1 0 0;
}
#camera-interval-input {
width: 6;
margin-right: 0;
}
.camera-controls Button {
margin-right: 1;
}
.camera-trigger-bar {
dock: bottom;
height: auto;
width: 100%;
layout: horizontal;
padding: 0 1;
background: #0e1420;
border-top: solid #1a2a38;
}
.camera-trigger-bar .label {
width: auto;
padding: 1 1 0 0;
}
#camera-backend-label {
width: auto;
padding: 1 1 0 0;
color: #506878;
}
.trigger-btn {
min-width: 8;
height: 3;
margin: 0 0 0 0;
background: #121c2a;
color: #7090a8;
border: round #1a3050;
text-align: center;
}
.trigger-btn:hover {
background: #1a2a40;
color: #00b8c8;
border: round #00b8c8;
}
.trigger-btn.active {
background: #0a2a3a;
color: #00b8c8;
border: round #00b8c8;
text-style: bold;
}
/* ── Scrollbar Styling ─────────────────────────────── */
* {