Files
gps-denied-onboard/src/gps_denied_onboard/runtime_root/spoof_recovery_sink.py
T
Oleksandr Bezdieniezhnykh 8a9cf88a46 [AZ-396] [AZ-397] Batch 11: C8 source-set switch + QGC telemetry adapter
AZ-396: PymavlinkArdupilotAdapter.request_source_set_switch body sends
MAV_CMD_SET_EKF_SOURCE_SET, awaits COMMAND_ACK with timeout, enforces
Invariant 11 idempotence (1s rate-limit + skip-after-success). Adds
runtime_root.SpoofRecoverySink to bridge C5 spoof-promotion-recovered
signal to the C8 outbound thread via a bounded dispatch queue.
FcConfig gains spoof_recovery_source_set + source_set_switch_timeout_ms.

AZ-397: QgcTelemetryAdapter implements GcsAdapter strategy: MAVLink 2.0
to QGC, emit_summary downsamples 5Hz to configurable summary_rate_hz
[0.5, 5.0] via integer modulo, emit_status_text mirrors to GCS link,
subscribe_operator_commands translates COMMAND_LONG / PARAM_REQUEST_*
/ REQUEST_DATA_STREAM / MISSION_* / SET_MODE into OperatorCommand DTOs
and audits each receipt to FDR. FcKind.GCS_QGC added for PortConfig.

Tests: 25 new (12 AZ-396 + 13 AZ-397); full suite 501 passing, 2 skipped.
Contracts unchanged (additive FcConfig fields, range relaxation on
GcsConfig.summary_rate_hz, additive FcKind enum value).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 05:06:56 +03:00

140 lines
5.0 KiB
Python

"""Spoof-recovery signal sink (AZ-396 / E-C8 runtime-root wiring).
C5's spoof-promotion gate (AZ-385) publishes a ``spoof_promotion_recovered``
signal when a previously-spoofed FC GPS source clears the recovery
window. The runtime root forwards that signal to the C8 AP adapter's
:meth:`request_source_set_switch` on the OUTBOUND emit thread so the
single-writer invariant (Invariant 8) is preserved.
This module owns ONLY the wiring side; the publisher side lives in
C5 (AZ-385 — not yet landed). The sink exposes a Protocol the
runtime root binds at composition-root build time:
- :class:`SpoofRecoveryPublisher` — Protocol the C5 gate implements.
- :class:`SpoofRecoverySink` — registers itself with a publisher and
fires ``request_source_set_switch`` on the C8 adapter.
Single-writer enforcement: the sink ALWAYS dispatches the C8 adapter
call on a single bound thread (the C8 outbound thread). It uses a
thread-safe queue so the C5 thread can publish without blocking on
the C8 wire.
"""
from __future__ import annotations
import queue
import threading
from typing import Protocol
from gps_denied_onboard.components.c8_fc_adapter.errors import (
FcAdapterError,
SourceSetSwitchError,
)
from gps_denied_onboard.components.c8_fc_adapter.interface import FcAdapter
from gps_denied_onboard.logging import get_logger
__all__ = [
"SpoofRecoveryPublisher",
"SpoofRecoverySink",
]
class SpoofRecoveryPublisher(Protocol):
"""Protocol C5 (AZ-385) implements to surface the recovery signal."""
def subscribe_spoof_promotion_recovered(self, callback: _Callback) -> object:
"""Register ``callback`` to be invoked when the gate recovers.
Returns a handle whose ``cancel()`` method removes the subscription.
"""
class _Callback(Protocol):
def __call__(self) -> None: ...
class SpoofRecoverySink:
"""Dispatches C5 spoof-recovery signals to the AP adapter outbound thread.
Usage from the composition root::
sink = SpoofRecoverySink(fc_adapter)
sink.start()
publisher.subscribe_spoof_promotion_recovered(sink.publish)
# ... at shutdown ...
sink.stop()
"""
def __init__(self, fc_adapter: FcAdapter) -> None:
self._fc = fc_adapter
self._queue: queue.Queue[None] = queue.Queue(maxsize=16)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._log = get_logger("runtime_root.spoof_recovery_sink")
def start(self) -> None:
"""Start the dispatch thread; idempotent."""
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._run, name="c8.spoof_recovery_sink", daemon=True
)
self._thread.start()
def stop(self, *, join_timeout_s: float = 1.0) -> None:
"""Signal the dispatch thread to exit and join."""
self._stop.set()
# Wake the queue.get(...) blocker without leaking a real signal.
try:
self._queue.put_nowait(None)
except queue.Full:
pass
if self._thread is not None:
self._thread.join(timeout=join_timeout_s)
self._thread = None
def publish(self) -> None:
"""Invoked by the C5 publisher thread; enqueues a switch request."""
try:
self._queue.put_nowait(None)
except queue.Full:
# Bounded queue: 16 pending switches is far more than the
# recovery gate can produce; if we ever hit this, something
# upstream is flooding. Emit a WARN and drop — the C8
# idempotence gate (Invariant 11) would suppress them anyway.
self._log.warning(
"c8.spoof_recovery_sink_queue_full",
extra={"kind": "c8.spoof_recovery_sink_queue_full", "kv": {}},
)
def _run(self) -> None:
while not self._stop.is_set():
try:
_ = self._queue.get(timeout=0.5)
except queue.Empty:
continue
if self._stop.is_set():
return
try:
self._fc.request_source_set_switch()
except SourceSetSwitchError as exc:
# Per spec § 5 error-handling: ERROR log + STATUSTEXT
# already happened inside the adapter; we re-log at
# DEBUG here so the sink-level audit shows the catch.
self._log.debug(
f"c8.spoof_recovery_sink_switch_failed: {exc!r}",
extra={
"kind": "c8.spoof_recovery_sink_switch_failed",
"kv": {"error": repr(exc)},
},
)
except FcAdapterError as exc:
self._log.warning(
f"c8.spoof_recovery_sink_adapter_error: {exc!r}",
extra={
"kind": "c8.spoof_recovery_sink_adapter_error",
"kv": {"error": repr(exc)},
},
)