[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:
Oleksandr Bezdieniezhnykh
2026-05-11 05:06:56 +03:00
parent 1e0be08e8a
commit 8a9cf88a46
16 changed files with 1608 additions and 12 deletions
+4
View File
@@ -58,6 +58,8 @@ ENV_KEY_MAP: Final[dict[str, tuple[str, str]]] = {
"FC_SIGNING_KEY_SOURCE": ("fc", "signing_key_source"),
"FC_DEV_STATIC_SIGNING_KEY": ("fc", "dev_static_signing_key"),
"FC_SIGNING_FAILURE_THRESHOLD": ("fc", "signing_failure_threshold"),
"FC_SPOOF_RECOVERY_SOURCE_SET": ("fc", "spoof_recovery_source_set"),
"FC_SOURCE_SET_SWITCH_TIMEOUT_MS": ("fc", "source_set_switch_timeout_ms"),
"GCS_ADAPTER": ("gcs", "adapter"),
"GCS_PORT_DEVICE": ("gcs", "port_device"),
"GCS_PORT_BAUD": ("gcs", "port_baud"),
@@ -101,6 +103,8 @@ _FIELD_COERCIONS: Final[dict[str, type]] = {
"signing_key_source": str,
"dev_static_signing_key": str,
"signing_failure_threshold": int,
"spoof_recovery_source_set": int,
"source_set_switch_timeout_ms": int,
"summary_rate_hz": float,
}
+15 -3
View File
@@ -209,6 +209,8 @@ class FcConfig:
signing_key_source: str = "ephemeral_per_flight"
dev_static_signing_key: str = ""
signing_failure_threshold: int = 3
spoof_recovery_source_set: int = 1
source_set_switch_timeout_ms: int = 1500
def __post_init__(self) -> None:
if self.adapter not in KNOWN_FC_STRATEGIES:
@@ -238,6 +240,16 @@ class FcConfig:
"FcConfig.signing_failure_threshold must be >= 1; got "
f"{self.signing_failure_threshold}"
)
if self.spoof_recovery_source_set < 0:
raise ConfigError(
"FcConfig.spoof_recovery_source_set must be >= 0; got "
f"{self.spoof_recovery_source_set}"
)
if self.source_set_switch_timeout_ms < 100:
raise ConfigError(
"FcConfig.source_set_switch_timeout_ms must be >= 100 ms; got "
f"{self.source_set_switch_timeout_ms}"
)
@dataclass(frozen=True)
@@ -246,7 +258,7 @@ class GcsConfig:
``adapter`` selects one of :data:`KNOWN_GCS_STRATEGIES`.
``summary_rate_hz`` is the per-emitter downsample target
(Invariant 12; default 2 Hz; range [1, 2]).
(Invariant 12; default 2 Hz; range [0.5, 5.0] per AZ-397 AC-10).
"""
adapter: str = "qgc_mavlink"
@@ -259,9 +271,9 @@ class GcsConfig:
raise ConfigError(
f"GcsConfig.adapter={self.adapter!r} not in {sorted(KNOWN_GCS_STRATEGIES)}"
)
if not (1.0 <= self.summary_rate_hz <= 2.0):
if not (0.5 <= self.summary_rate_hz <= 5.0):
raise ConfigError(
f"GcsConfig.summary_rate_hz must be in [1.0, 2.0]; got {self.summary_rate_hz}"
f"GcsConfig.summary_rate_hz must be in [0.5, 5.0]; got {self.summary_rate_hz}"
)