Files
gps-denied-onboard/e2e/fixtures/injectors/fc_proxy.py
T
Oleksandr Bezdieniezhnykh 702a0c0ff3 [AZ-408] [AZ-410] [AZ-411] Batch 69: synth injectors + FT-P-02/03/14
AZ-408 (3pt) — Replace AZ-406 injector scaffolds with concrete generators:
- outlier.py: deterministic stride + far-away tile replacement; AC-2 ≥350m offset
- blackout_spoof.py: paired video blackout + FC GPS spoof with ≤40ms alignment;
  AC-4 realistic fix_type/hdop; AC-NEW-8 200-500m inter-spoof deltas
- multi_segment.py: ≥3 disjoint windows, ≥30s gaps, ≤25% coverage
- fc_proxy.py: timed-splice runtime proxy with pre-activate RuntimeError guard
- _common.py: derive_rng + tile-manifest reader + tmpfs helpers
- injector_fixtures.py: pytest fixtures wired via runner conftest

AZ-410 (3pt) — FT-P-02 cumulative drift between satellite anchors:
- anchor_pair_detector.py: AC-1 detection, AC-2/3 pass-fraction,
  AC-4 monotonicity check, CSV evidence
- test_ft_p_02_derkachi_drift.py: scenario gated on upstream helper
  NotImplementedError (frame_source_replay / fdr_reader / imu_replay)

AZ-411 (2pt) — FT-P-03 + FT-P-14 schema + WGS84:
- estimate_schema.py: AC-1 schema completeness, AC-2 source-label set
  containment, AC-3 WGS84 range + int32 1e-7 decode
- test_ft_p_03_14_schema_wgs84.py: shared single-image-push scenario

Tests: 248 unit tests pass (+91 vs batch 68).
Reports: batch_69_report.md, batch_69_review.md (PASS),
cumulative_review_batches_67-69_cycle1_report.md (PASS).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 17:54:00 +03:00

210 lines
8.0 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
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