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
|
|
@ -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:
|
||||
|
|
|
|||
375
tui/src/birdcage_tui/camera.py
Normal file
375
tui/src/birdcage_tui/camera.py
Normal 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)
|
||||
286
tui/src/birdcage_tui/capture_manager.py
Normal file
286
tui/src/birdcage_tui/capture_manager.py
Normal 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")
|
||||
212
tui/src/birdcage_tui/capture_triggers.py
Normal file
212
tui/src/birdcage_tui/capture_triggers.py
Normal 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)
|
||||
426
tui/src/birdcage_tui/screens/camera.py
Normal file
426
tui/src/birdcage_tui/screens/camera.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────── */
|
||||
|
||||
* {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue