mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 10:11:12 +00:00
702a0c0ff3
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>
210 lines
8.0 KiB
Python
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
|