mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
43fdef1aac
Implement all 11 `sitl_observer` public surfaces as an offline
FDR-replay strategy (reads JSON fixtures under `${E2E_SITL_REPLAY_DIR}`
instead of live pymavlink/yamspy). Replace 12 per-scenario
`_harness_helpers_implemented` probes with one shared session-scoped
`sitl_replay_ready` fixture in `e2e/tests/conftest.py`.
Net: -636 LoC of duplicated scenario gating, +17 LoC shared fixture,
+38 new unit tests (596 total, up from 558). Includes K=3 cumulative
review for batches 73-75 (PASS).
Co-authored-by: Cursor <cursoragent@cursor.com>
416 lines
14 KiB
Python
416 lines
14 KiB
Python
"""ArduPilot Plane / iNav SITL state-read observers (AZ-595 FDR-replay strategy).
|
|
|
|
All 11 public surfaces are backed by JSON files under
|
|
``${E2E_SITL_REPLAY_DIR}/`` — there is no live pymavlink / yamspy / TCP
|
|
connection in this implementation. This intentionally decouples scenario
|
|
execution from live SITL infrastructure: tests can run deterministically
|
|
against runner-produced fixture files, and a future "live" strategy can
|
|
plug in behind the same surface without changing any scenario code.
|
|
|
|
When ``E2E_SITL_REPLAY_DIR`` is unset OR the corresponding fixture file
|
|
is missing:
|
|
|
|
* `read_*` surfaces return an **empty list** (vacuous). Scenarios use the
|
|
module-level ``replay_dir_available()`` probe to detect this and skip.
|
|
* `prepare_sitl_*` surfaces are no-ops (FDR-replay does not need to
|
|
actually configure SITL state — the fixture file IS the prepared state).
|
|
* `capture_ap_tlog` / `read_ap_parameter` / `query_inav_gps_state` /
|
|
`observe_inav_tcp_handshake` / `collect_inav_msp_frames` raise
|
|
``RuntimeError`` because they require non-empty fixture data to produce
|
|
a meaningful result.
|
|
|
|
Fixture file naming (under `${E2E_SITL_REPLAY_DIR}/`):
|
|
|
|
* `ekf_divergence_events.json` — list[{monotonic_ms, severity, message}]
|
|
* `gps_health_samples.json` — list[{monotonic_ms, healthy, spoofed}]
|
|
* `consistency_check_events.json` — list[{monotonic_ms, passed}]
|
|
* `observer_<fc_kind>_<host>.json` — {gps_state: {...}, parameters: {...}}
|
|
* `ap_parameters_<host>.json` — {<param_name>: <value>, ...}
|
|
* `ap_tlog_<host>.tlog` — raw mavproxy tlog (any binary content)
|
|
* `inav_handshake_<host>.json` — {established_within_s: float | None}
|
|
* `inav_msp_frames_<host>.json` — {frames: [...], expected_num_sat: int}
|
|
* `inav_gps_state_<host>.json` — {fix_type, num_sat, provider}
|
|
|
|
Public-boundary discipline: this module does NOT import any
|
|
``src/gps_denied_onboard`` symbol.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, Literal
|
|
|
|
_ENV_VAR = "E2E_SITL_REPLAY_DIR"
|
|
|
|
FcKind = Literal["ardupilot", "inav"]
|
|
|
|
|
|
# Dataclasses
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FcGpsState:
|
|
"""The subset of FC state the e2e tests assert against."""
|
|
|
|
primary_source: str
|
|
last_position_lat_deg: float
|
|
last_position_lon_deg: float
|
|
last_position_alt_m: float
|
|
fix_quality: int
|
|
horizontal_accuracy_m: float
|
|
last_update_age_ms: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EkfDivergenceEvent:
|
|
monotonic_ms: int
|
|
severity: str
|
|
message: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GpsHealthSample:
|
|
monotonic_ms: int
|
|
healthy: bool
|
|
spoofed: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConsistencyCheckEvent:
|
|
monotonic_ms: int
|
|
passed: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TcpHandshakeReport:
|
|
"""Result of an iNav SITL TCP handshake observation."""
|
|
|
|
established_within_s: float | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MspFrameSample:
|
|
monotonic_ms: int
|
|
function_id: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MspFrameCapture:
|
|
"""One window of MSP frame samples from the iNav SITL."""
|
|
|
|
frames: list[MspFrameSample]
|
|
expected_num_sat: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InavGpsState:
|
|
fix_type: int
|
|
num_sat: int
|
|
provider: str
|
|
|
|
|
|
# Observer interface (returned by ``get_observer``)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _FdrReplayObserver:
|
|
"""FDR-replay observer — reads gps_state + parameters from one JSON file."""
|
|
|
|
fc_kind: FcKind
|
|
host: str
|
|
_payload: dict
|
|
|
|
def read_gps_state(self) -> FcGpsState:
|
|
gps = self._payload.get("gps_state")
|
|
if not isinstance(gps, dict):
|
|
raise RuntimeError(
|
|
f"sitl_observer ({self.fc_kind}/{self.host}): fixture missing `gps_state` object"
|
|
)
|
|
return FcGpsState(
|
|
primary_source=str(gps["primary_source"]),
|
|
last_position_lat_deg=float(gps["last_position_lat_deg"]),
|
|
last_position_lon_deg=float(gps["last_position_lon_deg"]),
|
|
last_position_alt_m=float(gps["last_position_alt_m"]),
|
|
fix_quality=int(gps["fix_quality"]),
|
|
horizontal_accuracy_m=float(gps["horizontal_accuracy_m"]),
|
|
last_update_age_ms=int(gps["last_update_age_ms"]),
|
|
)
|
|
|
|
def read_parameter(self, name: str) -> float | int | str | None:
|
|
params = self._payload.get("parameters", {})
|
|
if not isinstance(params, dict):
|
|
raise RuntimeError(
|
|
f"sitl_observer ({self.fc_kind}/{self.host}): fixture `parameters` must be an object"
|
|
)
|
|
return params.get(name)
|
|
|
|
|
|
# Module-level helpers
|
|
|
|
|
|
def replay_dir() -> Path | None:
|
|
"""Resolve the FDR-replay fixture root from the env var, or None if unset."""
|
|
raw = os.environ.get(_ENV_VAR, "").strip()
|
|
if not raw:
|
|
return None
|
|
return Path(raw)
|
|
|
|
|
|
def replay_dir_available() -> bool:
|
|
"""True iff ``E2E_SITL_REPLAY_DIR`` is set AND points to an existing directory."""
|
|
root = replay_dir()
|
|
return root is not None and root.is_dir()
|
|
|
|
|
|
def _load_optional_json_list(filename: str, parser) -> list:
|
|
"""Load `${E2E_SITL_REPLAY_DIR}/<filename>`; return [] when absent."""
|
|
root = replay_dir()
|
|
if root is None:
|
|
return []
|
|
path = root / filename
|
|
if not path.exists():
|
|
return []
|
|
decoded = json.loads(path.read_text())
|
|
if not isinstance(decoded, list):
|
|
raise RuntimeError(
|
|
f"sitl_observer fixture {path} must be a JSON list; got {type(decoded).__name__}"
|
|
)
|
|
return [parser(item, path) for item in decoded]
|
|
|
|
|
|
def _load_required_json(filename: str) -> tuple[dict, Path]:
|
|
"""Load `${E2E_SITL_REPLAY_DIR}/<filename>`; raise RuntimeError when absent."""
|
|
root = replay_dir()
|
|
if root is None:
|
|
raise RuntimeError(
|
|
f"sitl_observer: {_ENV_VAR} env var not set; cannot read fixture {filename}"
|
|
)
|
|
path = root / filename
|
|
if not path.exists():
|
|
raise RuntimeError(
|
|
f"sitl_observer: required fixture not found: {path}"
|
|
)
|
|
decoded = json.loads(path.read_text())
|
|
if not isinstance(decoded, dict):
|
|
raise RuntimeError(
|
|
f"sitl_observer fixture {path} must be a JSON object; got {type(decoded).__name__}"
|
|
)
|
|
return decoded, path
|
|
|
|
|
|
# get_observer factory
|
|
|
|
|
|
def get_observer(fc_kind: FcKind, host: str) -> _FdrReplayObserver:
|
|
"""Return an FDR-replay observer bound to a fixture file.
|
|
|
|
Fixture path: ``${E2E_SITL_REPLAY_DIR}/observer_<fc_kind>_<host>.json``.
|
|
Raises ``RuntimeError`` if the env var is unset or the fixture is missing.
|
|
"""
|
|
payload, _ = _load_required_json(f"observer_{fc_kind}_{host}.json")
|
|
return _FdrReplayObserver(fc_kind=fc_kind, host=host, _payload=payload)
|
|
|
|
|
|
# read_* surfaces (return [] when fixtures absent)
|
|
|
|
|
|
def _parse_ekf_event(item: dict, source: Path) -> EkfDivergenceEvent:
|
|
try:
|
|
return EkfDivergenceEvent(
|
|
monotonic_ms=int(item["monotonic_ms"]),
|
|
severity=str(item["severity"]),
|
|
message=str(item["message"]),
|
|
)
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise RuntimeError(
|
|
f"sitl_observer EKF divergence fixture malformed at {source}: {exc}"
|
|
) from exc
|
|
|
|
|
|
def read_ekf_divergence_events() -> list[EkfDivergenceEvent]:
|
|
"""Return EKF divergence events. Empty list when fixture absent."""
|
|
return _load_optional_json_list("ekf_divergence_events.json", _parse_ekf_event)
|
|
|
|
|
|
def _parse_gps_health(item: dict, source: Path) -> GpsHealthSample:
|
|
try:
|
|
return GpsHealthSample(
|
|
monotonic_ms=int(item["monotonic_ms"]),
|
|
healthy=bool(item["healthy"]),
|
|
spoofed=bool(item["spoofed"]),
|
|
)
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise RuntimeError(
|
|
f"sitl_observer GPS health fixture malformed at {source}: {exc}"
|
|
) from exc
|
|
|
|
|
|
def read_gps_health_samples() -> list[GpsHealthSample]:
|
|
"""Return FC-side GPS health samples. Empty list when fixture absent."""
|
|
return _load_optional_json_list("gps_health_samples.json", _parse_gps_health)
|
|
|
|
|
|
def _parse_consistency_event(item: dict, source: Path) -> ConsistencyCheckEvent:
|
|
try:
|
|
return ConsistencyCheckEvent(
|
|
monotonic_ms=int(item["monotonic_ms"]),
|
|
passed=bool(item["passed"]),
|
|
)
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise RuntimeError(
|
|
f"sitl_observer consistency-check fixture malformed at {source}: {exc}"
|
|
) from exc
|
|
|
|
|
|
def read_consistency_check_events() -> list[ConsistencyCheckEvent]:
|
|
"""Return visual/satellite consistency-check events. Empty list when fixture absent."""
|
|
return _load_optional_json_list(
|
|
"consistency_check_events.json", _parse_consistency_event
|
|
)
|
|
|
|
|
|
# prepare_sitl_* — no-ops under FDR-replay
|
|
|
|
|
|
def prepare_sitl_cold_boot(host: str, fixture_path: Path) -> None:
|
|
"""No-op under FDR-replay: the cold-boot state IS the fixture file.
|
|
|
|
Raises ``RuntimeError`` if either ``host`` or ``fixture_path`` is empty —
|
|
these are required for the future live-SITL implementation and surfacing
|
|
the missing input early avoids confusing downstream errors.
|
|
"""
|
|
if not host:
|
|
raise RuntimeError("prepare_sitl_cold_boot: host must be non-empty")
|
|
if fixture_path is None:
|
|
raise RuntimeError("prepare_sitl_cold_boot: fixture_path is required")
|
|
|
|
|
|
def prepare_sitl_no_gps(host: str) -> None:
|
|
"""No-op under FDR-replay (the "no GPS" condition is encoded in the fixture)."""
|
|
if not host:
|
|
raise RuntimeError("prepare_sitl_no_gps: host must be non-empty")
|
|
|
|
|
|
# capture_ap_tlog — returns synthetic tlog path
|
|
|
|
|
|
def capture_ap_tlog(host: str, duration_s: float) -> Path:
|
|
"""Return the path to the AP mavproxy tlog fixture for ``host``.
|
|
|
|
Fixture: ``${E2E_SITL_REPLAY_DIR}/ap_tlog_<host>.tlog``.
|
|
Raises ``RuntimeError`` if env var unset or fixture missing.
|
|
``duration_s`` is recorded for future live-mode use but ignored here.
|
|
"""
|
|
if duration_s <= 0:
|
|
raise RuntimeError(f"capture_ap_tlog: duration_s must be positive; got {duration_s}")
|
|
root = replay_dir()
|
|
if root is None:
|
|
raise RuntimeError(
|
|
f"capture_ap_tlog: {_ENV_VAR} env var not set"
|
|
)
|
|
path = root / f"ap_tlog_{host}.tlog"
|
|
if not path.exists():
|
|
raise RuntimeError(
|
|
f"capture_ap_tlog: fixture not found at {path}"
|
|
)
|
|
return path
|
|
|
|
|
|
# read_ap_parameter — reads from param-dump JSON
|
|
|
|
|
|
def read_ap_parameter(host: str, name: str) -> float | int | str | None:
|
|
"""Read AP parameter ``name`` from the per-host param dump.
|
|
|
|
Fixture: ``${E2E_SITL_REPLAY_DIR}/ap_parameters_<host>.json`` ({name: value}).
|
|
Raises ``RuntimeError`` if env var unset or fixture missing.
|
|
Returns ``None`` if the parameter is not in the dump.
|
|
"""
|
|
payload, _ = _load_required_json(f"ap_parameters_{host}.json")
|
|
return payload.get(name)
|
|
|
|
|
|
# iNav surfaces
|
|
|
|
|
|
def observe_inav_tcp_handshake(host: str, port: int, timeout_s: float) -> TcpHandshakeReport:
|
|
"""Return the recorded TCP handshake outcome for ``(host, port)``.
|
|
|
|
Fixture: ``${E2E_SITL_REPLAY_DIR}/inav_handshake_<host>_<port>.json``.
|
|
Raises ``RuntimeError`` on missing fixture. ``timeout_s`` is recorded
|
|
for future live-mode use but ignored here.
|
|
"""
|
|
if timeout_s <= 0:
|
|
raise RuntimeError(
|
|
f"observe_inav_tcp_handshake: timeout_s must be positive; got {timeout_s}"
|
|
)
|
|
payload, path = _load_required_json(f"inav_handshake_{host}_{port}.json")
|
|
raw = payload.get("established_within_s")
|
|
if raw is not None and not isinstance(raw, (int, float)):
|
|
raise RuntimeError(
|
|
f"sitl_observer inav handshake fixture {path}: "
|
|
f"`established_within_s` must be a number or null; got {type(raw).__name__}"
|
|
)
|
|
return TcpHandshakeReport(established_within_s=float(raw) if raw is not None else None)
|
|
|
|
|
|
def collect_inav_msp_frames(host: str, port: int, window_s: float) -> MspFrameCapture:
|
|
"""Return the recorded MSP frame window for ``(host, port)``.
|
|
|
|
Fixture: ``${E2E_SITL_REPLAY_DIR}/inav_msp_frames_<host>_<port>.json``
|
|
with shape ``{frames: [{monotonic_ms, function_id}, ...], expected_num_sat: int}``.
|
|
Raises ``RuntimeError`` if env var unset or fixture missing.
|
|
"""
|
|
if window_s <= 0:
|
|
raise RuntimeError(
|
|
f"collect_inav_msp_frames: window_s must be positive; got {window_s}"
|
|
)
|
|
payload, path = _load_required_json(f"inav_msp_frames_{host}_{port}.json")
|
|
raw_frames = payload.get("frames", [])
|
|
if not isinstance(raw_frames, list):
|
|
raise RuntimeError(
|
|
f"sitl_observer inav msp frames fixture {path}: `frames` must be a list"
|
|
)
|
|
frames: list[MspFrameSample] = []
|
|
for item in raw_frames:
|
|
try:
|
|
frames.append(
|
|
MspFrameSample(
|
|
monotonic_ms=int(item["monotonic_ms"]),
|
|
function_id=int(item["function_id"]),
|
|
)
|
|
)
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise RuntimeError(
|
|
f"sitl_observer inav msp frames fixture {path}: malformed frame: {exc}"
|
|
) from exc
|
|
expected_num_sat = payload.get("expected_num_sat")
|
|
if not isinstance(expected_num_sat, int):
|
|
raise RuntimeError(
|
|
f"sitl_observer inav msp frames fixture {path}: "
|
|
f"`expected_num_sat` must be an int; got {type(expected_num_sat).__name__}"
|
|
)
|
|
return MspFrameCapture(frames=frames, expected_num_sat=expected_num_sat)
|
|
|
|
|
|
def query_inav_gps_state(host: str) -> InavGpsState:
|
|
"""Return the recorded iNav GPS state snapshot for ``host``.
|
|
|
|
Fixture: ``${E2E_SITL_REPLAY_DIR}/inav_gps_state_<host>.json``.
|
|
Raises ``RuntimeError`` if env var unset or fixture missing.
|
|
"""
|
|
payload, path = _load_required_json(f"inav_gps_state_{host}.json")
|
|
try:
|
|
return InavGpsState(
|
|
fix_type=int(payload["fix_type"]),
|
|
num_sat=int(payload["num_sat"]),
|
|
provider=str(payload["provider"]),
|
|
)
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise RuntimeError(
|
|
f"sitl_observer iNav GPS state fixture {path} malformed: {exc}"
|
|
) from exc
|