"""SITL Integration Tests — GPS_INPUT delivery to ArduPilot SITL. These tests verify the full MAVLink GPS_INPUT pipeline against a real ArduPilot SITL flight controller. They are **skipped** unless the ``ARDUPILOT_SITL_HOST`` environment variable is set. Run via Docker Compose SITL harness: docker compose -f docker-compose.sitl.yml run integration-tests Or manually with SITL running locally: ARDUPILOT_SITL_HOST=localhost ARDUPILOT_SITL_PORT=5762 pytest tests/test_sitl_integration.py -v Test IDs: SITL-01: MAVLink connection to ArduPilot SITL succeeds. SITL-02: GPS_INPUT message accepted by SITL FC (GPS_RAW_INT shows 3D fix). SITL-03: MAVLinkBridge.start/stop lifecycle with real connection. SITL-04: IMU RAW_IMU callback fires after connecting to SITL. SITL-05: 5 consecutive GPS_INPUT messages delivered within 1.1s (≥5 Hz). SITL-06: Telemetry NAMED_VALUE_FLOAT messages reach SITL at 1 Hz. SITL-07: After 3 consecutive FAILED-confidence updates, reloc request fires. """ from __future__ import annotations import asyncio import os import socket import time import numpy as np import pytest from gps_denied.schemas import GPSPoint from gps_denied.schemas.eskf import ConfidenceTier, ESKFState # --------------------------------------------------------------------------- # Skip guard — all tests in this file are skipped unless SITL is available # --------------------------------------------------------------------------- SITL_HOST = os.environ.get("ARDUPILOT_SITL_HOST", "") SITL_PORT = int(os.environ.get("ARDUPILOT_SITL_PORT", "5762")) _SITL_AVAILABLE = bool(SITL_HOST) pytestmark = pytest.mark.skipif( not _SITL_AVAILABLE, reason="SITL integration tests require ARDUPILOT_SITL_HOST env var", ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _ORIGIN = GPSPoint(lat=49.0, lon=32.0) _MAVLINK_CONN = f"tcp:{SITL_HOST}:{SITL_PORT}" if SITL_HOST else "mock://" def _make_eskf_state( pos=(0.0, 0.0, 0.0), vel=(0.0, 0.0, 0.0), confidence: ConfidenceTier = ConfidenceTier.HIGH, cov_scale: float = 1.0, ) -> ESKFState: cov = np.eye(15) * cov_scale return ESKFState( position=np.array(pos, dtype=float), velocity=np.array(vel, dtype=float), quaternion=np.array([1.0, 0.0, 0.0, 0.0]), accel_bias=np.zeros(3), gyro_bias=np.zeros(3), covariance=cov, timestamp=time.time(), confidence=confidence, ) def _wait_for_tcp(host: str, port: int, timeout: float = 30.0) -> bool: """Block until TCP port is accepting connections (or timeout).""" deadline = time.time() + timeout while time.time() < deadline: try: with socket.create_connection((host, port), timeout=2.0): return True except OSError: time.sleep(1.0) return False # --------------------------------------------------------------------------- # SITL-01: Connection # --------------------------------------------------------------------------- def test_sitl_tcp_port_reachable(): """SITL-01: ArduPilot SITL TCP port is reachable before running tests.""" reachable = _wait_for_tcp(SITL_HOST, SITL_PORT, timeout=30.0) assert reachable, ( f"SITL not reachable at {SITL_HOST}:{SITL_PORT} — " "is docker-compose.sitl.yml running?" ) def test_pymavlink_connection_to_sitl(): """SITL-01: pymavlink connects to SITL without error.""" pytest.importorskip("pymavlink", reason="pymavlink not installed") from pymavlink import mavutil mav = mavutil.mavlink_connection(_MAVLINK_CONN) # Wait for heartbeat (up to 15s) msg = mav.recv_match(type="HEARTBEAT", blocking=True, timeout=15) mav.close() assert msg is not None, "No HEARTBEAT received from SITL within 15s" # --------------------------------------------------------------------------- # SITL-02: GPS_INPUT accepted by SITL EKF # --------------------------------------------------------------------------- def test_gps_input_accepted_by_sitl(): """SITL-02: Sending GPS_INPUT produces GPS_RAW_INT with fix_type >= 3.""" pytest.importorskip("pymavlink", reason="pymavlink not installed") from pymavlink import mavutil mav = mavutil.mavlink_connection(_MAVLINK_CONN) # Wait for SITL ready mav.recv_match(type="HEARTBEAT", blocking=True, timeout=15) # Send 10 GPS_INPUT messages at ~5 Hz for _ in range(10): now = time.time() gps_s = now - 315_964_800 week = int(gps_s // (7 * 86400)) week_ms = int((gps_s % (7 * 86400)) * 1000) mav.mav.gps_input_send( int(now * 1_000_000), # time_usec 0, # gps_id 0, # ignore_flags week_ms, # time_week_ms week, # time_week 3, # fix_type (3D) int(_ORIGIN.lat * 1e7), # lat int(_ORIGIN.lon * 1e7), # lon 600.0, # alt MSL 1.0, # hdop 1.5, # vdop 0.0, # vn 0.0, # ve 0.0, # vd 0.3, # speed_accuracy 5.0, # horiz_accuracy 2.0, # vert_accuracy 10, # satellites_visible ) time.sleep(0.2) # Wait for GPS_RAW_INT confirming fix deadline = time.time() + 10.0 fix_type = 0 while time.time() < deadline: msg = mav.recv_match(type="GPS_RAW_INT", blocking=True, timeout=2.0) if msg and msg.fix_type >= 3: fix_type = msg.fix_type break mav.close() assert fix_type >= 3, ( f"SITL GPS_RAW_INT fix_type={fix_type} after GPS_INPUT — " "expected 3D fix (≥3)" ) # --------------------------------------------------------------------------- # SITL-03: MAVLinkBridge lifecycle # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_mavlink_bridge_start_stop_with_sitl(): """SITL-03: MAVLinkBridge.start/stop with real SITL TCP connection.""" pytest.importorskip("pymavlink", reason="pymavlink not installed") from gps_denied.core.mavlink import MAVLinkBridge bridge = MAVLinkBridge( connection_string=_MAVLINK_CONN, output_hz=5.0, telemetry_hz=1.0, ) bridge.update_state(_make_eskf_state(), altitude_m=600.0) await bridge.start(_ORIGIN) # Let it run for one output period await asyncio.sleep(0.25) await bridge.stop() assert not bridge._running # --------------------------------------------------------------------------- # SITL-04: IMU receive callback # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_imu_callback_fires_from_sitl(): """SITL-04: IMU callback is invoked when SITL sends RAW_IMU messages.""" pytest.importorskip("pymavlink", reason="pymavlink not installed") from gps_denied.core.mavlink import MAVLinkBridge from gps_denied.schemas.eskf import IMUMeasurement received: list[IMUMeasurement] = [] bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0) bridge.set_imu_callback(received.append) bridge.update_state(_make_eskf_state(), altitude_m=600.0) await bridge.start(_ORIGIN) # SITL sends RAW_IMU at ~50-200 Hz; wait 1s await asyncio.sleep(1.0) await bridge.stop() assert len(received) >= 1, ( "No IMUMeasurement received from SITL in 1s — " "check that SITL is sending RAW_IMU messages" ) # --------------------------------------------------------------------------- # SITL-05: GPS_INPUT rate ≥ 5 Hz # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_gps_input_rate_at_least_5hz(): """SITL-05: MAVLinkBridge delivers GPS_INPUT at ≥5 Hz over 1 second.""" pytest.importorskip("pymavlink", reason="pymavlink not installed") from pymavlink import mavutil from gps_denied.core.mavlink import MAVLinkBridge # Monitor incoming GPS_INPUT on a separate MAVLink connection monitor = mavutil.mavlink_connection(_MAVLINK_CONN) monitor.recv_match(type="HEARTBEAT", blocking=True, timeout=10) bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0) bridge.update_state(_make_eskf_state(confidence=ConfidenceTier.HIGH), altitude_m=600.0) await bridge.start(_ORIGIN) t_start = time.time() count = 0 while time.time() - t_start < 1.1: msg = monitor.recv_match(type="GPS_INPUT", blocking=True, timeout=0.5) if msg: count += 1 await asyncio.sleep(0) await bridge.stop() monitor.close() assert count >= 5, f"Only {count} GPS_INPUT messages in 1.1s — expected ≥5 (5 Hz)" # --------------------------------------------------------------------------- # SITL-06: Telemetry at 1 Hz # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_telemetry_reaches_sitl_at_1hz(): """SITL-06: NAMED_VALUE_FLOAT CONF_SCORE delivered at ~1 Hz.""" pytest.importorskip("pymavlink", reason="pymavlink not installed") from pymavlink import mavutil from gps_denied.core.mavlink import MAVLinkBridge monitor = mavutil.mavlink_connection(_MAVLINK_CONN) monitor.recv_match(type="HEARTBEAT", blocking=True, timeout=10) bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0, telemetry_hz=1.0) bridge.update_state(_make_eskf_state(confidence=ConfidenceTier.MEDIUM), altitude_m=600.0) await bridge.start(_ORIGIN) t_start = time.time() conf_count = 0 while time.time() - t_start < 2.2: msg = monitor.recv_match(type="NAMED_VALUE_FLOAT", blocking=True, timeout=0.5) if msg and getattr(msg, "name", "").startswith("CONF"): conf_count += 1 await asyncio.sleep(0) await bridge.stop() monitor.close() assert conf_count >= 2, ( f"Only {conf_count} CONF_SCORE messages in 2.2s — expected ≥2 (1 Hz)" ) # --------------------------------------------------------------------------- # SITL-07: Reloc request after 3 consecutive failures # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_reloc_request_after_3_failures_with_sitl(): """SITL-07: After 3 FAILED-confidence updates, reloc callback fires.""" pytest.importorskip("pymavlink", reason="pymavlink not installed") from gps_denied.core.mavlink import MAVLinkBridge from gps_denied.schemas.mavlink import RelocalizationRequest received: list[RelocalizationRequest] = [] bridge = MAVLinkBridge(connection_string=_MAVLINK_CONN, output_hz=5.0) bridge.set_reloc_callback(received.append) bridge._origin = _ORIGIN bridge._last_state = _make_eskf_state() bridge._consecutive_failures = 3 await bridge.start(_ORIGIN) await asyncio.sleep(0.1) # Trigger reloc manually (simulates 3 consecutive failures) bridge._send_reloc_request() await asyncio.sleep(0.1) await bridge.stop() assert len(received) == 1, f"Expected 1 reloc request, got {len(received)}" assert received[0].consecutive_failures == 3 assert received[0].last_lat is not None