mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 22:01:14 +00:00
[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>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""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)},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user