"""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