mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 16:31:14 +00:00
8a9cf88a46
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>
140 lines
5.0 KiB
Python
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)},
|
|
},
|
|
)
|