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

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

View file

@ -0,0 +1,273 @@
"""Test camera overlay — F6 toggle, manual capture, trigger controls.
Uses the same testing pattern as test_craft_mode.py:
- app.demo_mode = True for DemoDevice
- async with app.run_test() for headless rendering
- post_message() for button events (avoids coordinate issues)
Note: ModalScreen widgets are on the screen stack, not the app's regular
DOM. Use app.screen to get the active overlay, then query within it.
"""
import asyncio
from pathlib import Path
import pytest
from birdcage_tui.app import BirdcageApp
from birdcage_tui.screens.camera import CameraOverlay, CaptureStatusPanel
@pytest.mark.asyncio
async def test_f6_opens_camera_overlay(tmp_path: Path):
"""F6 should push the camera overlay."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
assert not app._camera_visible
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
# The top screen should be the camera overlay
assert isinstance(app.screen, CameraOverlay)
@pytest.mark.asyncio
async def test_f6_closes_camera_overlay(tmp_path: Path):
"""F6 again should dismiss the camera overlay."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
await pilot.press("f6")
await pilot.pause()
assert not app._camera_visible
@pytest.mark.asyncio
async def test_escape_closes_camera_overlay(tmp_path: Path):
"""Escape should dismiss the camera overlay."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
await pilot.press("escape")
await pilot.pause()
assert not app._camera_visible
@pytest.mark.asyncio
async def test_manual_capture_creates_file(tmp_path: Path):
"""Pressing 'c' should create a capture file."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
# Trigger manual capture
await pilot.press("c")
await asyncio.sleep(0.5)
# Check captures directory
capture_dir = tmp_path / "captures"
jpg_files = list(capture_dir.rglob("*.jpg"))
json_files = list(capture_dir.rglob("*.json"))
assert len(jpg_files) >= 1
assert len(json_files) >= 1
@pytest.mark.asyncio
async def test_capture_button(tmp_path: Path):
"""Capture button should trigger a capture."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
# Get the overlay from the screen stack
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
from textual.widgets import Button
btn = overlay.query_one("#btn-capture", Button)
btn.post_message(Button.Pressed(btn))
await asyncio.sleep(0.5)
capture_dir = tmp_path / "captures"
jpg_files = list(capture_dir.rglob("*.jpg"))
assert len(jpg_files) >= 1
@pytest.mark.asyncio
async def test_capture_log_updates(tmp_path: Path):
"""Capture log should show entries after captures."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
# Two captures
await pilot.press("c")
await asyncio.sleep(0.3)
await pilot.press("c")
await asyncio.sleep(0.3)
# Status panel should show count
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
assert panel._capture_count >= 2
@pytest.mark.asyncio
async def test_trigger_toggles(tmp_path: Path):
"""AOS/TCA/LOS trigger buttons should toggle active state."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
from textual.widgets import Button
from birdcage_tui.capture_triggers import TriggerType
# Toggle AOS on
aos_btn = overlay.query_one("#btn-trigger-aos", Button)
aos_btn.post_message(Button.Pressed(aos_btn))
await pilot.pause()
assert overlay._pass_detector.is_enabled(TriggerType.AOS)
# Toggle AOS off
aos_btn.post_message(Button.Pressed(aos_btn))
await pilot.pause()
assert not overlay._pass_detector.is_enabled(TriggerType.AOS)
@pytest.mark.asyncio
async def test_auto_interval_start_stop(tmp_path: Path):
"""Auto interval start/stop should control the timer."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
from textual.widgets import Button
# Start auto
start_btn = overlay.query_one("#btn-auto-start", Button)
start_btn.post_message(Button.Pressed(start_btn))
await pilot.pause()
assert overlay._auto_running
# Stop auto
stop_btn = overlay.query_one("#btn-auto-stop", Button)
stop_btn.post_message(Button.Pressed(stop_btn))
await pilot.pause()
assert not overlay._auto_running
@pytest.mark.asyncio
async def test_camera_overlay_with_console(tmp_path: Path):
"""F5 and F6 should open independent overlays."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
# Open camera first
await pilot.press("f6")
await pilot.pause()
assert app._camera_visible
assert not app._console_visible
# Close camera
await pilot.press("f6")
await pilot.pause()
assert not app._camera_visible
# Open console
await pilot.press("f5")
await pilot.pause()
assert app._console_visible
assert not app._camera_visible
# Close console
await pilot.press("f5")
await pilot.pause()
assert not app._console_visible
@pytest.mark.asyncio
async def test_status_panel_shows_formats(tmp_path: Path):
"""Status panel should list available output formats."""
app = BirdcageApp()
app.demo_mode = True
app.capture_dir = str(tmp_path / "captures")
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.press("f6")
await pilot.pause()
overlay = app.screen
assert isinstance(overlay, CameraOverlay)
panel = overlay.query_one("#capture-status", CaptureStatusPanel)
# Should always have JPEG + JSON at minimum
assert "JPEG" in panel._formats
assert "JSON" in panel._formats

View file

@ -0,0 +1,171 @@
"""Test capture manager — file naming, JSON sidecars, session tracking.
All tests use DemoCamera for zero-hardware-dependency captures.
"""
import json
from pathlib import Path
import pytest
from birdcage_tui.camera import DemoCamera
from birdcage_tui.capture_manager import (
CaptureManager,
CaptureMetadata,
CaptureSession,
_build_filename,
_sanitize_name,
)
@pytest.fixture
def manager(tmp_path: Path) -> CaptureManager:
"""CaptureManager with DemoCamera in a temp directory."""
backend = DemoCamera()
return CaptureManager(backend, output_dir=tmp_path)
@pytest.fixture
def tracking_metadata() -> CaptureMetadata:
"""Metadata simulating an active tracking capture."""
return CaptureMetadata(
mount_az=245.30,
mount_el=34.20,
target_name="ISS (ZARYA)",
target_type="satellite",
target_id="25544",
target_az=245.30,
target_el=34.20,
target_distance_km=420.0,
target_range_rate=-2.1,
tracking_mode="craft",
tracking_status="TRACKING",
trigger="manual",
)
def test_capture_writes_jpeg(manager: CaptureManager, tracking_metadata):
"""Capture should produce a JPEG file."""
result = manager.capture(tracking_metadata)
assert result.success
assert result.path.exists()
assert result.path.suffix == ".jpg"
def test_capture_writes_json_sidecar(manager: CaptureManager, tracking_metadata):
"""JSON sidecar should always be written alongside JPEG."""
result = manager.capture(tracking_metadata)
assert result.success
# Find the JSON sidecar
json_files = list(result.path.parent.glob("*.json"))
assert len(json_files) == 1
data = json.loads(json_files[0].read_text())
assert data["mount_az"] == 245.30
assert data["mount_el"] == 34.20
assert data["target_name"] == "ISS (ZARYA)"
assert data["trigger"] == "manual"
def test_filename_generation():
"""Filenames should follow the naming convention."""
meta = CaptureMetadata(
mount_az=245.30,
mount_el=34.20,
target_name="ISS (ZARYA)",
timestamp="2026-02-16T08:25:00Z",
)
name = _build_filename(meta, "jpg")
assert name.startswith("ISS-ZARYA_")
assert "245.30" in name
assert "34.20" in name
assert name.endswith(".jpg")
def test_filename_sanitization():
"""Special characters should be stripped from target names."""
assert _sanitize_name("ISS (ZARYA)") == "ISS-ZARYA"
assert _sanitize_name("NOAA 19") == "NOAA-19"
assert _sanitize_name("") == "manual"
assert _sanitize_name("a/b\\c:d") == "abcd"
def test_filename_no_target():
"""Without a target name, filename should use 'manual'."""
meta = CaptureMetadata(
mount_az=180.0,
mount_el=45.0,
timestamp="2026-02-16T12:00:00Z",
)
name = _build_filename(meta, "jpg")
assert name.startswith("manual_")
def test_session_counter():
"""Session sequence should increment monotonically."""
session = CaptureSession()
assert session.next_sequence() == 1
assert session.next_sequence() == 2
assert session.next_sequence() == 3
assert session.capture_count == 3
def test_session_id_unique():
"""Each session should have a unique ID."""
s1 = CaptureSession()
s2 = CaptureSession()
assert s1.session_id != s2.session_id
def test_subdirectory_creation(manager: CaptureManager, tracking_metadata):
"""Captures should go into date-based subdirectories."""
result = manager.capture(tracking_metadata)
assert result.success
# The parent should be a date directory like "2026-02-16"
parent = result.path.parent.name
assert len(parent) == 10 # YYYY-MM-DD
assert parent.count("-") == 2
def test_multiple_captures_increment(manager: CaptureManager, tracking_metadata):
"""Multiple captures should increment the session counter."""
r1 = manager.capture(tracking_metadata)
r2 = manager.capture(tracking_metadata)
assert r1.success and r2.success
assert manager.session.capture_count == 2
# Filenames should differ
assert r1.path.name != r2.path.name
def test_pillow_fallback(manager: CaptureManager):
"""Manager should report Pillow status without crashing."""
# This test just verifies the property doesn't throw
_ = manager.has_pillow # True if installed, False if not
def test_astropy_fallback(manager: CaptureManager):
"""Manager should report astropy status without crashing."""
_ = manager.has_astropy # True if installed, False if not
def test_metadata_session_populated(manager: CaptureManager, tracking_metadata):
"""After capture, metadata should have session info populated."""
manager.capture(tracking_metadata)
assert tracking_metadata.session_id == manager.session.session_id
assert tracking_metadata.sequence_number == 1
def test_manual_capture_no_target(manager: CaptureManager):
"""Capture without tracking should work with minimal metadata."""
meta = CaptureMetadata(
mount_az=180.0,
mount_el=45.0,
trigger="manual",
)
result = manager.capture(meta)
assert result.success
# JSON sidecar should exist
json_files = list(result.path.parent.glob("*.json"))
assert len(json_files) >= 1

View file

@ -0,0 +1,283 @@
"""Test capture triggers — interval timer and pass event detection.
IntervalTimer fires at configurable intervals.
PassEventDetector detects AOS/TCA/LOS transitions with hysteresis.
"""
import threading
import time
from birdcage_tui.capture_triggers import (
IntervalTimer,
PassEventDetector,
TriggerEvent,
TriggerType,
)
class _EventCollector:
"""Collects trigger events for test assertions."""
def __init__(self):
self.events: list[TriggerEvent] = []
self._lock = threading.Lock()
def __call__(self, event: TriggerEvent) -> None:
with self._lock:
self.events.append(event)
@property
def count(self) -> int:
with self._lock:
return len(self.events)
def types(self) -> list[TriggerType]:
with self._lock:
return [e.trigger_type for e in self.events]
# ------------------------------------------------------------------
# IntervalTimer tests
# ------------------------------------------------------------------
def test_interval_timer_fires():
"""Timer should fire at least once within 2x the interval."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=0.2)
timer.start()
time.sleep(0.5)
timer.stop()
assert collector.count >= 1
assert all(e.trigger_type == TriggerType.INTERVAL for e in collector.events)
def test_interval_timer_stop():
"""Timer should stop cleanly and not fire after stop()."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=0.1)
timer.start()
time.sleep(0.25)
timer.stop()
count_at_stop = collector.count
time.sleep(0.3)
assert collector.count == count_at_stop # No new events
def test_interval_timer_set_interval():
"""set_interval should update the interval property."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=10.0)
assert timer.interval == 10.0
timer.set_interval(5.0)
assert timer.interval == 5.0
# Minimum clamp
timer.set_interval(0.5)
assert timer.interval >= 0.5
def test_interval_timer_double_start():
"""Starting twice should be safe (no duplicate threads)."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=0.2)
timer.start()
timer.start() # Should be no-op
time.sleep(0.5)
timer.stop()
assert collector.count >= 1
def test_interval_timer_is_running():
"""is_running property should reflect timer state."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=1.0)
assert not timer.is_running
timer.start()
assert timer.is_running
timer.stop()
assert not timer.is_running
# ------------------------------------------------------------------
# PassEventDetector tests
# ------------------------------------------------------------------
def test_pass_detector_aos():
"""WAITING -> TRACKING should fire AOS."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS)
detector.update("WAITING", "ISS", 0.0)
detector.update("TRACKING", "ISS", 20.0)
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.AOS
assert "ISS" in collector.events[0].detail
def test_pass_detector_los():
"""TRACKING -> WAITING should fire LOS."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.LOS)
detector.update("TRACKING", "ISS", 45.0)
detector.update("WAITING", "ISS", 5.0)
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.LOS
def test_pass_detector_tca():
"""Elevation peak detection with hysteresis."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
# Simulate ascending pass
detector.update("TRACKING", "ISS", 20.0)
detector.update("TRACKING", "ISS", 30.0)
detector.update("TRACKING", "ISS", 40.0)
detector.update("TRACKING", "ISS", 45.0) # Peak
detector.update("TRACKING", "ISS", 44.8) # Small jitter, no trigger
detector.update("TRACKING", "ISS", 44.4) # Below threshold
detector.update("TRACKING", "ISS", 43.0) # Well below peak
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.TCA
assert "45.0" in collector.events[0].detail
def test_pass_detector_hysteresis():
"""Small jitter around peak should NOT trigger TCA."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
# Simulate noisy plateau at peak
detector.update("TRACKING", "ISS", 44.8)
detector.update("TRACKING", "ISS", 45.0)
detector.update("TRACKING", "ISS", 44.9) # -0.1 from peak
detector.update("TRACKING", "ISS", 44.8) # -0.2 from peak
detector.update("TRACKING", "ISS", 44.7) # -0.3 from peak
# Still within hysteresis threshold (0.5 deg)
assert collector.count == 0
def test_pass_detector_tca_fires_once():
"""TCA should fire only once per pass."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
detector.update("TRACKING", "ISS", 45.0) # Peak
detector.update("TRACKING", "ISS", 44.0) # -1.0, triggers TCA
detector.update("TRACKING", "ISS", 43.0) # Should NOT trigger again
detector.update("TRACKING", "ISS", 42.0)
assert collector.count == 1
def test_pass_detector_full_pass():
"""Full pass should generate AOS + TCA + LOS in order."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS, TriggerType.TCA, TriggerType.LOS)
# Pre-pass
detector.update("WAITING", "ISS", 0.0)
# AOS
detector.update("TRACKING", "ISS", 5.0)
# Ascending
detector.update("TRACKING", "ISS", 20.0)
detector.update("TRACKING", "ISS", 35.0)
detector.update("TRACKING", "ISS", 45.0) # Peak
# Descending (triggers TCA)
detector.update("TRACKING", "ISS", 44.0)
detector.update("TRACKING", "ISS", 30.0)
detector.update("TRACKING", "ISS", 10.0)
# LOS
detector.update("WAITING", "ISS", 2.0)
types = collector.types()
assert types == [TriggerType.AOS, TriggerType.TCA, TriggerType.LOS]
def test_pass_detector_disabled_triggers():
"""Disabled triggers should not fire."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
# Enable only AOS, not TCA or LOS
detector.enable(TriggerType.AOS)
detector.update("WAITING", "ISS", 0.0)
detector.update("TRACKING", "ISS", 45.0)
detector.update("TRACKING", "ISS", 30.0)
detector.update("WAITING", "ISS", 0.0)
# Only AOS should fire
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.AOS
def test_pass_detector_target_change_resets():
"""Changing target should reset peak tracking."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
# First target peaks at 45
detector.update("TRACKING", "ISS", 45.0)
# Switch to new target — peak resets
detector.update("TRACKING", "NOAA 19", 30.0)
detector.update("TRACKING", "NOAA 19", 35.0) # New peak
detector.update("TRACKING", "NOAA 19", 34.0) # -1.0, triggers TCA
assert collector.count == 1
assert "NOAA 19" in collector.events[0].detail
def test_pass_detector_reset():
"""reset() should clear all internal state."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS)
detector.update("TRACKING", "ISS", 45.0)
detector.reset()
# After reset, a new TRACKING should fire AOS again
detector.update("WAITING", "ISS", 0.0)
detector.update("TRACKING", "ISS", 20.0)
assert collector.count == 2 # Initial + after reset
def test_pass_detector_enable_disable():
"""enable/disable should toggle trigger states."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS, TriggerType.LOS)
assert detector.is_enabled(TriggerType.AOS)
assert detector.is_enabled(TriggerType.LOS)
assert not detector.is_enabled(TriggerType.TCA)
detector.disable(TriggerType.AOS)
assert not detector.is_enabled(TriggerType.AOS)
assert detector.is_enabled(TriggerType.LOS)