mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
8a9cf88a46
AZ-396: PymavlinkArdupilotAdapter.request_source_set_switch body sends MAV_CMD_SET_EKF_SOURCE_SET, awaits COMMAND_ACK with timeout, enforces Invariant 11 idempotence (1s rate-limit + skip-after-success). Adds runtime_root.SpoofRecoverySink to bridge C5 spoof-promotion-recovered signal to the C8 outbound thread via a bounded dispatch queue. FcConfig gains spoof_recovery_source_set + source_set_switch_timeout_ms. AZ-397: QgcTelemetryAdapter implements GcsAdapter strategy: MAVLink 2.0 to QGC, emit_summary downsamples 5Hz to configurable summary_rate_hz [0.5, 5.0] via integer modulo, emit_status_text mirrors to GCS link, subscribe_operator_commands translates COMMAND_LONG / PARAM_REQUEST_* / REQUEST_DATA_STREAM / MISSION_* / SET_MODE into OperatorCommand DTOs and audits each receipt to FDR. FcKind.GCS_QGC added for PortConfig. Tests: 25 new (12 AZ-396 + 13 AZ-397); full suite 501 passing, 2 skipped. Contracts unchanged (additive FcConfig fields, range relaxation on GcsConfig.summary_rate_hz, additive FcKind enum value). Co-authored-by: Cursor <cursoragent@cursor.com>
361 lines
12 KiB
Python
361 lines
12 KiB
Python
"""AZ-397 — QgcTelemetryAdapter AC tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest import mock
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from gps_denied_onboard._types.fc import (
|
|
FcKind,
|
|
OperatorCommand,
|
|
PortConfig,
|
|
Severity,
|
|
)
|
|
from gps_denied_onboard._types.geo import LatLonAlt
|
|
from gps_denied_onboard._types.pose import EstimatorOutput
|
|
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
|
CovarianceProjector,
|
|
)
|
|
from gps_denied_onboard.components.c8_fc_adapter.errors import GcsAdapterConfigError
|
|
from gps_denied_onboard.components.c8_fc_adapter.mavlink_gcs_adapter import (
|
|
QgcTelemetryAdapter,
|
|
_compute_downsample_modulo,
|
|
)
|
|
from gps_denied_onboard.config import load_config
|
|
from gps_denied_onboard.config.schema import ConfigError, GcsConfig
|
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Helpers
|
|
|
|
|
|
class _MavStub:
|
|
def __init__(self) -> None:
|
|
self.global_position_int_calls: list[tuple[Any, ...]] = []
|
|
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
|
self.statustext_calls: list[tuple[int, bytes]] = []
|
|
|
|
def global_position_int_send(self, *args: Any) -> None:
|
|
self.global_position_int_calls.append(args)
|
|
|
|
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
|
self.named_value_float_calls.append((time_boot_ms, name, value))
|
|
|
|
def statustext_send(self, severity: int, text: bytes) -> None:
|
|
self.statustext_calls.append((severity, text))
|
|
|
|
|
|
class _ConnStub:
|
|
def __init__(self) -> None:
|
|
self.mav = _MavStub()
|
|
self.message_hooks: list[Any] = []
|
|
self.closed = False
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def _config(*, summary_rate_hz: float = 2.0) -> Any:
|
|
env = {
|
|
"FC_ADAPTER": "ardupilot_plane",
|
|
"FC_PORT_DEVICE": "/dev/null",
|
|
"FC_PORT_BAUD": "921600",
|
|
"FC_SIGNING_KEY_SOURCE": "none",
|
|
"GCS_ADAPTER": "qgc_mavlink",
|
|
"GCS_PORT_DEVICE": "/dev/null",
|
|
"GCS_PORT_BAUD": "921600",
|
|
"GCS_SUMMARY_RATE_HZ": str(summary_rate_hz),
|
|
}
|
|
return load_config(env=env, paths=(), require_env=False)
|
|
|
|
|
|
def _make_output(*, source_label: str = "visual_propagated", frame_id: int = 1) -> EstimatorOutput:
|
|
return EstimatorOutput(
|
|
frame_id=frame_id,
|
|
timestamp=datetime.now(tz=timezone.utc),
|
|
pose_se3=np.eye(4),
|
|
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
|
|
source_label=source_label,
|
|
smoothed=False,
|
|
extras={"wgs84": LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)},
|
|
)
|
|
|
|
|
|
def _make_adapter(conn: _ConnStub, cfg: Any) -> QgcTelemetryAdapter:
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=cfg,
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.GCS_QGC, device="/dev/null", baud=921600)
|
|
a.open(port)
|
|
return a
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-1: 5 -> 2 Hz downsampling
|
|
|
|
|
|
def test_ac1_5hz_to_2hz_downsample() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=2.0))
|
|
try:
|
|
for i in range(100):
|
|
a.emit_summary(_make_output(frame_id=i))
|
|
# modulo = round(5/2) = 2 (round half to even gives 2; we just
|
|
# need parity with the documented choice). 100 / 2 = 50.
|
|
assert _compute_downsample_modulo(2.0) == 2
|
|
assert len(conn.mav.global_position_int_calls) == 50
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-2: configurable rate
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"rate_hz,expected_frames",
|
|
[
|
|
(1.0, 20), # modulo 5 -> 100/5
|
|
(5.0, 100), # modulo 1 -> no downsample
|
|
],
|
|
)
|
|
def test_ac2_configurable_rate(rate_hz: float, expected_frames: int) -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=rate_hz))
|
|
try:
|
|
for i in range(100):
|
|
a.emit_summary(_make_output(frame_id=i))
|
|
assert len(conn.mav.global_position_int_calls) == expected_frames
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
def test_ac2_out_of_range_rate_rejected_at_config() -> None:
|
|
with pytest.raises(ConfigError, match="summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=10.0)
|
|
with pytest.raises(ConfigError, match="summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=0.2)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-3: summary frame fields
|
|
|
|
|
|
def test_ac3_summary_frame_fields() -> None:
|
|
conn = _ConnStub()
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=_config(summary_rate_hz=5.0), # no downsample for simplicity
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.GCS_QGC, device="/dev/null", baud=921600)
|
|
a.open(port)
|
|
try:
|
|
wgs = LatLonAlt(lat_deg=50.45, lon_deg=30.52, alt_m=180.0)
|
|
output = EstimatorOutput(
|
|
frame_id=1,
|
|
timestamp=datetime.now(tz=timezone.utc),
|
|
pose_se3=np.eye(4),
|
|
covariance_6x6=np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64),
|
|
source_label="visual_propagated",
|
|
smoothed=False,
|
|
extras={"wgs84": wgs},
|
|
)
|
|
a.emit_summary(output)
|
|
assert len(conn.mav.global_position_int_calls) == 1
|
|
call = conn.mav.global_position_int_calls[0]
|
|
# global_position_int_send(time_boot_ms, lat, lon, alt_mm, rel_alt_mm, vx, vy, vz, hdg)
|
|
assert call[1] == int(wgs.lat_deg * 1e7)
|
|
assert call[2] == int(wgs.lon_deg * 1e7)
|
|
assert call[3] == int(wgs.alt_m * 1000.0)
|
|
# horiz_accuracy via NAMED_VALUE_FLOAT
|
|
assert len(conn.mav.named_value_float_calls) == 1
|
|
_, name, value = conn.mav.named_value_float_calls[0]
|
|
assert name == b"horiz_m"
|
|
expected = CovarianceProjector(fdr_client=mock.MagicMock()).to_ardupilot_horiz_accuracy_m(
|
|
output
|
|
)
|
|
assert value == pytest.approx(expected)
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-4: STATUSTEXT mirror
|
|
|
|
|
|
def test_ac4_statustext_mirror() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=2.0))
|
|
try:
|
|
a.emit_status_text("hello", Severity.INFO)
|
|
assert len(conn.mav.statustext_calls) == 1
|
|
sev, text = conn.mav.statustext_calls[0]
|
|
assert sev == int(Severity.INFO.value)
|
|
assert text == b"hello"
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-5: operator command subscription
|
|
|
|
|
|
def test_ac5_operator_command_subscription_invokes_callback() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config())
|
|
received: list[OperatorCommand] = []
|
|
try:
|
|
a.subscribe_operator_commands(received.append)
|
|
# Inject a PARAM_REQUEST_LIST stub
|
|
msg = SimpleNamespace(target_system=1, target_component=1)
|
|
msg.get_type = lambda: "PARAM_REQUEST_LIST"
|
|
msg.get_srcSystem = lambda: 42
|
|
# Fire every registered hook
|
|
for hook in conn.message_hooks:
|
|
hook(conn, msg)
|
|
assert len(received) == 1
|
|
cmd = received[0]
|
|
assert cmd.command == "PARAM_REQUEST_LIST"
|
|
assert cmd.payload.get("target_system") == 1
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-6: operator command FDR audit trail
|
|
|
|
|
|
def test_ac6_operator_command_fdr_audit_trail() -> None:
|
|
conn = _ConnStub()
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=_config(),
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.GCS_QGC, device="/dev/null", baud=921600)
|
|
a.open(port)
|
|
try:
|
|
a.subscribe_operator_commands(lambda _cmd: None)
|
|
msg = SimpleNamespace(target_system=1, target_component=1, command=400)
|
|
msg.get_type = lambda: "COMMAND_LONG"
|
|
msg.get_srcSystem = lambda: 7
|
|
for hook in conn.message_hooks:
|
|
hook(conn, msg)
|
|
operator_records = [
|
|
call
|
|
for call in fdr.enqueue.mock_calls
|
|
if call.args[0].payload.get("kind") == "c8.gcs.operator_command"
|
|
]
|
|
assert len(operator_records) == 1
|
|
rec = operator_records[0].args[0].payload
|
|
assert rec["kv"]["command"] == "COMMAND_LONG"
|
|
assert rec["kv"]["source_system"] == 7
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-7: single-writer thread for outbound
|
|
|
|
|
|
def test_ac7_single_writer_thread() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=5.0))
|
|
err: list[BaseException] = []
|
|
try:
|
|
a.emit_summary(_make_output())
|
|
|
|
def run() -> None:
|
|
try:
|
|
a.emit_summary(_make_output(frame_id=2))
|
|
except RuntimeError as e:
|
|
err.append(e)
|
|
|
|
t = threading.Thread(target=run)
|
|
t.start()
|
|
t.join(timeout=2.0)
|
|
assert len(err) == 1
|
|
assert "single-writer" in str(err[0]).lower()
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-8: first emit logged once
|
|
|
|
|
|
def test_ac8_first_emit_logged_once(caplog: pytest.LogCaptureFixture) -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=5.0))
|
|
try:
|
|
with caplog.at_level(logging.INFO):
|
|
for i in range(5):
|
|
a.emit_summary(_make_output(frame_id=i))
|
|
first = [
|
|
r for r in caplog.records if getattr(r, "kind", None) == "c8.gcs.first_summary_emit"
|
|
]
|
|
assert len(first) == 1
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-9: WGS84 round-trip (defensive)
|
|
|
|
|
|
def test_ac9_wgs84_round_trip_within_1cm() -> None:
|
|
# Round-trip a known lat/lon through WgsConverter ECEF and back.
|
|
origin = LatLonAlt(lat_deg=50.45, lon_deg=30.52, alt_m=180.0)
|
|
enu = WgsConverter.latlonalt_to_local_enu(
|
|
origin, LatLonAlt(lat_deg=50.4501, lon_deg=30.5201, alt_m=180.5)
|
|
)
|
|
recovered = WgsConverter.local_enu_to_latlonalt(origin, enu)
|
|
expected = LatLonAlt(lat_deg=50.4501, lon_deg=30.5201, alt_m=180.5)
|
|
# 1 cm threshold — pyproj's ENU round-trip is sub-millimetre at
|
|
# these distances; we keep the AC's 1 cm threshold as the contract.
|
|
enu_residual = WgsConverter.latlonalt_to_local_enu(expected, recovered)
|
|
assert np.linalg.norm(enu_residual) < 0.01
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-10: GcsAdapterConfigError on bad config (config-load path)
|
|
|
|
|
|
def test_ac10_gcs_config_error_on_bad_rate() -> None:
|
|
with pytest.raises(ConfigError, match="summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=6.0)
|
|
|
|
|
|
def test_ac10_open_rejects_wrong_fc_kind() -> None:
|
|
conn = _ConnStub()
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=_config(),
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.ARDUPILOT_PLANE, device="/dev/null", baud=921600)
|
|
with pytest.raises(GcsAdapterConfigError, match="GCS_QGC"):
|
|
a.open(port)
|