mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 23:51:12 +00:00
6554d568f1
Add `runner/helpers/fc_proxy_runtime.py` wrapping the existing
`BlackoutSpoofProxy` (AZ-406) with a scenario-facing `drive_fc_proxy`
entry point. FDR-replay mode only: loads `schedule.json`, optionally
activates the proxy against a caller clock for alignment verification,
and writes a `proxy_drive_report.json` audit record into
`${E2E_SITL_REPLAY_DIR}` for downstream evaluators.
Replaces the local `_drive_fc_proxy` stub in FT-N-04. Adds 3
@property accessors on `BlackoutSpoofProxy` so the wrapper does not
reach into private attributes. +11 unit tests (608 total, up from
596). Live-mode router wiring remains out of scope (future ticket).
Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
8.3 KiB
Python
222 lines
8.3 KiB
Python
"""FC-inbound proxy patch for blackout_spoof — coordinated GPS spoof injection.
|
|
|
|
The blackout_spoof injector ships a ``schedule.json`` with two paired
|
|
artefacts:
|
|
|
|
1. ``blackout_frame_indices`` — which video frames are replaced with
|
|
black frames (the video-overlay portion writes them to disk).
|
|
2. ``spoof_gps`` — the pre-computed spoofed GPS frames that must appear
|
|
on the FC inbound stream *during the same wall-clock window*.
|
|
|
|
This module is the runtime piece that consumes the ``spoof_gps`` list:
|
|
a stateless **pass-through proxy** with a "timed splice" rule.
|
|
|
|
Default behaviour: every inbound MAVLink GPS message is forwarded
|
|
unchanged to the FC. While the proxy's monotonic clock falls inside
|
|
``[window_start_ms, window_end_ms]``, the proxy *replaces* the next
|
|
inbound GPS frame with the next pre-computed spoofed record. The
|
|
``window_start_ms`` / ``window_end_ms`` are anchored to the proxy's own
|
|
monotonic clock (started by ``activate(now_ms_provider, t0)``), which the
|
|
test harness aligns with the video-overlay's first black-frame timestamp
|
|
to satisfy AC-3 (≤40 ms alignment).
|
|
|
|
The module is intentionally **transport-agnostic**: it takes a callable
|
|
that returns ``now_ms`` (for testability — pytest passes a fake clock)
|
|
and exposes ``process_inbound_message(raw_gps)`` which the actual
|
|
MAVLink-frame router calls. The router lives outside the AZ-408 task
|
|
scope (it's part of the runner image's docker-compose wiring, not the
|
|
injector module).
|
|
|
|
Public-boundary discipline: this module does NOT import any
|
|
``src/gps_denied_onboard`` symbol; it operates on opaque "raw GPS frame"
|
|
bytes/dicts at the MAVLink protocol level.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
NowMsProvider = Callable[[], int]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SpoofGpsRecord:
|
|
"""Mirror of `blackout_spoof.SpoofGpsFrame` — JSON-parsed at proxy init."""
|
|
|
|
monotonic_ms: int
|
|
lat_deg: float
|
|
lon_deg: float
|
|
alt_m: float
|
|
fix_type: int
|
|
hdop: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProxyAlignmentReport:
|
|
"""Reports the actual wall-clock alignment achieved at activation.
|
|
|
|
Tests assert ``alignment_err_ms <= max_alignment_err_ms`` (AC-3 / AC-NEW-3).
|
|
"""
|
|
|
|
window_start_ms: int
|
|
activation_now_ms: int
|
|
alignment_err_ms: int
|
|
|
|
|
|
class BlackoutSpoofProxy:
|
|
"""Coordinated pass-through proxy. NOT thread-safe; one per scenario.
|
|
|
|
Lifecycle:
|
|
|
|
proxy = BlackoutSpoofProxy.from_schedule_file(Path("schedule.json"))
|
|
report = proxy.activate(now_ms_provider=time.monotonic_ms)
|
|
# … runner forwards GPS frames …
|
|
while gps := router.next_inbound_gps():
|
|
forwarded = proxy.process_inbound_message(gps)
|
|
router.send_to_fc(forwarded)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
window_start_ms: int,
|
|
window_end_ms: int,
|
|
spoof_gps: list[SpoofGpsRecord],
|
|
max_alignment_err_ms: float = 40.0,
|
|
) -> None:
|
|
self._window_start_ms = window_start_ms
|
|
self._window_end_ms = window_end_ms
|
|
self._spoof_gps = list(spoof_gps)
|
|
self._max_alignment_err_ms = max_alignment_err_ms
|
|
self._now_ms_provider: NowMsProvider | None = None
|
|
self._t0_ms: int | None = None
|
|
self._next_spoof_idx = 0
|
|
self._activated = False
|
|
self._activation_report: ProxyAlignmentReport | None = None
|
|
|
|
@classmethod
|
|
def from_schedule_file(cls, schedule_path: Path) -> "BlackoutSpoofProxy":
|
|
"""Load the proxy from a ``schedule.json`` written by blackout_spoof."""
|
|
if not schedule_path.is_file():
|
|
raise FileNotFoundError(f"schedule.json not found: {schedule_path}")
|
|
payload = json.loads(schedule_path.read_text())
|
|
spoof_gps = [
|
|
SpoofGpsRecord(
|
|
monotonic_ms=int(s["monotonic_ms"]),
|
|
lat_deg=float(s["lat_deg"]),
|
|
lon_deg=float(s["lon_deg"]),
|
|
alt_m=float(s["alt_m"]),
|
|
fix_type=int(s["fix_type"]),
|
|
hdop=float(s["hdop"]),
|
|
)
|
|
for s in payload["spoof_gps"]
|
|
]
|
|
return cls(
|
|
window_start_ms=int(payload["window_start_ms"]),
|
|
window_end_ms=int(payload["window_end_ms"]),
|
|
spoof_gps=spoof_gps,
|
|
max_alignment_err_ms=float(payload.get("max_alignment_err_ms", 40.0)),
|
|
)
|
|
|
|
def activate(
|
|
self,
|
|
now_ms_provider: NowMsProvider,
|
|
first_blackout_ms: int | None = None,
|
|
) -> ProxyAlignmentReport:
|
|
"""Bind the proxy to a clock and align ``t0`` to the first blackout frame.
|
|
|
|
``first_blackout_ms`` (in the proxy's monotonic clock space) is the
|
|
timestamp at which the video-overlay emitted its first all-black
|
|
frame. The proxy sets ``t0`` so that ``window_start_ms`` matches
|
|
that instant; this is what enforces AC-3 (≤40 ms alignment).
|
|
|
|
If ``first_blackout_ms`` is ``None`` the proxy uses ``now`` as the
|
|
anchor — useful for unit tests where the schedule's window starts
|
|
at t=0 in proxy time.
|
|
"""
|
|
now_ms = now_ms_provider()
|
|
anchor = first_blackout_ms if first_blackout_ms is not None else now_ms
|
|
# Adjust t0 so that ``proxy_time(now) = (now - t0) ≈ window_start_ms``
|
|
# at the moment of the first black frame.
|
|
self._t0_ms = anchor - self._window_start_ms
|
|
self._now_ms_provider = now_ms_provider
|
|
self._activated = True
|
|
self._activation_report = ProxyAlignmentReport(
|
|
window_start_ms=self._window_start_ms,
|
|
activation_now_ms=now_ms,
|
|
alignment_err_ms=abs(now_ms - anchor),
|
|
)
|
|
return self._activation_report
|
|
|
|
@property
|
|
def activation_report(self) -> ProxyAlignmentReport | None:
|
|
return self._activation_report
|
|
|
|
@property
|
|
def window_start_ms(self) -> int:
|
|
return self._window_start_ms
|
|
|
|
@property
|
|
def window_end_ms(self) -> int:
|
|
return self._window_end_ms
|
|
|
|
@property
|
|
def spoof_frame_count(self) -> int:
|
|
return len(self._spoof_gps)
|
|
|
|
def _proxy_time_ms(self) -> int:
|
|
if not self._activated or self._now_ms_provider is None or self._t0_ms is None:
|
|
raise RuntimeError("proxy not activated — call activate(...) first")
|
|
return self._now_ms_provider() - self._t0_ms
|
|
|
|
def in_window(self) -> bool:
|
|
"""True iff the proxy clock is inside the blackout window."""
|
|
if not self._activated:
|
|
return False
|
|
t = self._proxy_time_ms()
|
|
return self._window_start_ms <= t <= self._window_end_ms
|
|
|
|
def process_inbound_message(self, raw_gps: dict) -> dict:
|
|
"""Pass-through (no-op) outside the window; spoofed-replace inside it.
|
|
|
|
``raw_gps`` is a dict in the shape of MAVLink ``GPS_INPUT`` /
|
|
``GPS_RAW_INT`` (we treat it as opaque; we just clone the keys
|
|
and overwrite the position fields). When the spoof list is
|
|
exhausted, the last spoofed frame keeps being emitted (the FC
|
|
sees a "stuck" spoofed position — that's what triggers
|
|
downstream failsafe escalation).
|
|
|
|
Calling this before ``activate()`` is a programming error and
|
|
raises ``RuntimeError`` — it would otherwise be a silent
|
|
passthrough that hides a mis-wired test setup.
|
|
"""
|
|
if not self._activated:
|
|
raise RuntimeError("proxy not activated — call activate(...) first")
|
|
if not self.in_window():
|
|
return raw_gps
|
|
spoof = self._next_spoof_record()
|
|
out = dict(raw_gps)
|
|
# Normalised + protocol-natural fields (the MAVLink router maps
|
|
# these to GPS_INPUT.lat / lon / alt / fix_type / hdop with the
|
|
# appropriate scaling; we keep degrees so the layer responsible
|
|
# for scaling owns it).
|
|
out["lat_deg"] = spoof.lat_deg
|
|
out["lon_deg"] = spoof.lon_deg
|
|
out["alt_m"] = spoof.alt_m
|
|
out["fix_type"] = spoof.fix_type
|
|
out["hdop"] = spoof.hdop
|
|
out["__spoofed__"] = True
|
|
return out
|
|
|
|
def _next_spoof_record(self) -> SpoofGpsRecord:
|
|
if self._next_spoof_idx < len(self._spoof_gps):
|
|
rec = self._spoof_gps[self._next_spoof_idx]
|
|
self._next_spoof_idx += 1
|
|
return rec
|
|
return self._spoof_gps[-1]
|
|
|
|
def emitted_spoof_count(self) -> int:
|
|
return self._next_spoof_idx
|