Files
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

6.6 KiB

Batch 11 — Cycle 1 Implementation Report

Batch: 11 of N Tasks landed: AZ-396 (D-C8-2 source-set switch + spoof-recovery sink) + AZ-397 (QgcTelemetryAdapter) Cycle: 1 Date: 2026-05-11

Scope

Task Component Purpose
AZ-396 C8 FC adapter (AP) + runtime root PymavlinkArdupilotAdapter.request_source_set_switch body — sends MAV_CMD_SET_EKF_SOURCE_SET (ardupilotmega cmd 42007) with param1 = config.fc.spoof_recovery_source_set, waits for COMMAND_ACK up to config.fc.source_set_switch_timeout_ms, idempotence per Invariant 11 (rate-limit within 1 s + skip after success). runtime_root.SpoofRecoverySink provides a bounded-queue thread sink that consumes C5's spoof-promotion-recovered signal (AZ-385 publisher side; future task) and dispatches the source-set switch from a single dedicated thread (Invariant 8 preserved).
AZ-397 C8 GCS adapter QgcTelemetryAdapter — concrete GcsAdapter strategy. Open/close a MAVLink 2.0 channel to QGC on the configured UART; emit_summary downsamples 5 Hz → summary_rate_hz via modulo arithmetic and emits GLOBAL_POSITION_INT + NAMED_VALUE_FLOAT("horiz_m"); emit_status_text mirrors a STATUSTEXT to the GCS link; subscribe_operator_commands registers a pymavlink message-hook that translates COMMAND_LONG / PARAM_REQUEST_* / REQUEST_DATA_STREAM / MISSION_* / SET_MODE into OperatorCommand DTOs and writes every receipt to FDR (kind="c8.gcs.operator_command") as the audit trail.

Files added / modified

Added (prod)

  • src/gps_denied_onboard/runtime_root/spoof_recovery_sink.pySpoofRecoveryPublisher Protocol + SpoofRecoverySink (dispatch thread, bounded queue, error isolation).
  • src/gps_denied_onboard/components/c8_fc_adapter/mavlink_gcs_adapter.pyQgcTelemetryAdapter + _compute_downsample_modulo helper.

Added (tests)

  • tests/unit/c8_fc_adapter/test_az396_source_set_switch.py — 12 AC tests (10 ACs + 2 sink-isolation cases).
  • tests/unit/c8_fc_adapter/test_az397_qgc_telemetry.py — 13 AC tests (10 ACs + parametrised rate cases).

Modified (prod)

  • src/gps_denied_onboard/_types/fc.py — added FcKind.GCS_QGC enum value (additive; lets PortConfig.fc_kind discriminate the GCS link variant alongside AP/iNav).
  • src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.pyrequest_source_set_switch body (AZ-396) replaces the NotImplementedError placeholder; new helpers _wait_for_command_ack and _handle_source_set_switch_failure; ctor fields _last_switch_attempt_ns + _last_switch_succeeded.
  • src/gps_denied_onboard/config/schema.pyFcConfig extended with spoof_recovery_source_set: int = 1 + source_set_switch_timeout_ms: int = 1500 (defaults align with AZ-396 spec); GcsConfig.summary_rate_hz valid range widened from [1.0, 2.0] to [0.5, 5.0] per AZ-397 AC-10.
  • src/gps_denied_onboard/config/loader.pyENV_KEY_MAP extended with FC_SPOOF_RECOVERY_SOURCE_SET + FC_SOURCE_SET_SWITCH_TIMEOUT_MS; _FIELD_COERCIONS extended with the two new int fields.
  • src/gps_denied_onboard/runtime_root/__init__.py — re-exports SpoofRecoveryPublisher + SpoofRecoverySink.

Modified (tests)

  • tests/unit/c8_fc_adapter/test_az390_adapter_protocol.py — updated test_ac3_fc_kind_has_two_members to reflect the additive GCS_QGC enum value; updated test_gcs_summary_rate_out_of_range_rejected boundaries (5.1 / 0.4) to match the new valid range.
  • tests/unit/c8_fc_adapter/test_az393_ardupilot_outbound.py — AC-9 placeholder check (NotImplementedError) replaced by a callability check; the real source-set-switch behaviour now lives under AZ-396 tests.

Contract changes

  • _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.mdunchanged at v1.0.0.
  • _docs/02_document/contracts/shared_config/composition_root_protocol.mdunchanged at v1.2.0. Additive FcConfig fields + valid-range relaxation for GcsConfig.summary_rate_hz (existing configs remain valid).

Test counts

Metric Before After Delta
Tests passing 476 501 +25
Tests skipped 2 2 0
Tests failing 0 0 0

Architectural notes

  • Source-set switch threading: the AP adapter's request_source_set_switch enforces single-writer via the same _enforce_single_writer used by emit_external_position. The SpoofRecoverySink dispatch thread is the documented single writer; the C5 publisher thread calls sink.publish() which only enqueues. The bounded queue guarantees the publisher never blocks on UART.
  • _wait_for_command_ack filters by type="COMMAND_ACK" — necessary because the inbound MAVLink decoder thread (AZ-391) also calls recv_match on the same connection. Real pymavlink routes by type internally; the unit-test stub explicitly mirrors that behaviour so the test-vs-production gap stays small.
  • GCS adapter downsampling: _compute_downsample_modulo = round(_COMPOSE_ROOT_INVOKE_HZ / summary_rate_hz). Tests pin the exact integer modulo so any future change to _COMPOSE_ROOT_INVOKE_HZ shows up as a deliberate AC update. The modulo is computed once at construction; updates to summary_rate_hz require a reopen.
  • Operator command dispatch: pymavlink's message-hook list is the canonical inbound-routing seam. _ensure_operator_handler_attached is idempotent — multiple subscribe_operator_commands calls share a single hook; subscriber fan-out happens via the SubscriptionBus (C8 inbound pattern, AZ-391). Subscriber crashes are isolated by the bus.
  • FcKind.GCS_QGC: pragmatic compromise to keep PortConfig single-typed across FC and GCS variants. A future LinkKind superclass refactor would split the two; documented in the AZ-390 follow-up.

Dependencies introduced

  • None.

Known forward-actions

  1. AZ-385 (C5 spoof-promotion gate) publishes the recovery signal the SpoofRecoverySink consumes. The composition root wires the two with one call: publisher.subscribe_spoof_promotion_recovered(sink.publish). Until AZ-385 lands, the sink is constructed but inactive.
  2. _SWITCH_RATE_LIMIT_S and the GCS NAMED_VALUE_FLOAT name ("horiz_m") — both module-level constants; promotion to config + contract bump deferred.
  3. global_position_int relative-altitude placeholder — currently mirrors absolute altitude; the runtime root must surface the launch reference frame before the relative field is meaningful.
  4. C8-IT-07 SITL end-to-end (3 s spoofing-promotion latency) — deferred to E-BBT; this batch delivers the wire surface for that gate.