[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
@@ -0,0 +1,70 @@
# 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.py``SpoofRecoveryPublisher` Protocol + `SpoofRecoverySink` (dispatch thread, bounded queue, error isolation).
- `src/gps_denied_onboard/components/c8_fc_adapter/mavlink_gcs_adapter.py``QgcTelemetryAdapter` + `_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.py``request_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.py``FcConfig` 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.py``ENV_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.md`**unchanged at v1.0.0**.
- `_docs/02_document/contracts/shared_config/composition_root_protocol.md`**unchanged 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.