Files
gps-denied-onboard/_docs/03_implementation/reviews/batch_11_review.md
T
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

13 KiB

Batch 11 — Code Review

Batch: 11 of N Tasks: AZ-396 (D-C8-2 source-set switch + spoof-recovery sink) + AZ-397 (QgcTelemetryAdapter) Reviewer: autodev (7-phase) Verdict: PASS_WITH_INFO Date: 2026-05-11

Scope

Task Files touched (prod) Files touched (tests)
AZ-396 components/c8_fc_adapter/pymavlink_ardupilot_adapter.py (extension), config/schema.py, config/loader.py, runtime_root/__init__.py, runtime_root/spoof_recovery_sink.py tests/unit/c8_fc_adapter/test_az396_source_set_switch.py, tests/unit/c8_fc_adapter/test_az393_ardupilot_outbound.py (AC-9 update)
AZ-397 components/c8_fc_adapter/mavlink_gcs_adapter.py, _types/fc.py (GCS_QGC enum), config/schema.py (range widening) tests/unit/c8_fc_adapter/test_az397_qgc_telemetry.py, tests/unit/c8_fc_adapter/test_az390_adapter_protocol.py (FcKind members + GcsConfig range adjustments)

Phase 1 — AC compliance

AZ-396 — 10 ACs (12 tests including 2 sink-isolation cases)

AC Coverage
AC-1 ACK success → INFO log + FDR + STATUSTEXT test_ac1_ack_success
AC-2 non-success ACK → SourceSetSwitchError + ERROR log + STATUSTEXT(ERROR) test_ac2_non_success_ack_raises
AC-3 ACK timeout (1500ms default) test_ac3_ack_timeout_raises
AC-4 Configurable timeout test_ac4_configurable_timeout_uses_config_value (500 ms)
AC-5 Idempotence within 1 s — rate-limited test_ac5_idempotence_within_1s_rate_limited (1 wire call after 2 invokes)
AC-6 Idempotence after success — no re-issue test_ac6_idempotence_after_success_no_reissue
AC-7 Runtime-root signal triggers switch test_ac7_spoof_recovery_sink_triggers_switch + isolation case
AC-8 source_set_id from config test_ac8_source_set_id_from_config (p1=2.0 when config=2)
AC-9 Placeholder NotImplementedError replaced test_ac9_no_longer_raises_not_implemented
AC-10 STATUSTEXT severity matrix test_ac10_statustext_severity_matrix (success=INFO, fail=ERROR)

AZ-397 — 10 ACs (13 tests including parametrised cases)

AC Coverage
AC-1 5 → 2 Hz downsample test_ac1_5hz_to_2hz_downsample (100 calls → 50 frames; modulo=2)
AC-2 Configurable rate test_ac2_configurable_rate[1.0-20], [5.0-100] + test_ac2_out_of_range_rate_rejected_at_config (10 / 0.2)
AC-3 Summary frame fields test_ac3_summary_frame_fieldsglobal_position_int_send lat/lon/alt match; NAMED_VALUE_FLOAT("horiz_m") matches projector
AC-4 STATUSTEXT mirror test_ac4_statustext_mirror
AC-5 Operator command callback test_ac5_operator_command_subscription_invokes_callback (PARAM_REQUEST_LIST → OperatorCommand)
AC-6 FDR audit trail test_ac6_operator_command_fdr_audit_trail (kind="c8.gcs.operator_command")
AC-7 Single-writer thread test_ac7_single_writer_thread
AC-8 First emit logged once test_ac8_first_emit_logged_once
AC-9 WGS84 round-trip ≤ 1 cm test_ac9_wgs84_round_trip_within_1cm (defensive against the shared helper C4 + C8 use)
AC-10 GcsAdapterConfigError on bad config test_ac10_gcs_config_error_on_bad_rate + test_ac10_open_rejects_wrong_fc_kind

25 new tests added (12 + 13); 501 total in suite (was 476), 2 pre-existing skips, 0 failures.

Phase 2 — Contract drift

  • _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.mdunchanged at v1.0.0. The request_source_set_switch and GcsAdapter Protocol entries were declared in v1.0.0 / AZ-390; this batch wires the bodies.
  • _docs/02_document/contracts/shared_config/composition_root_protocol.mdunchanged at v1.2.0. The new FcConfig.spoof_recovery_source_set + FcConfig.source_set_switch_timeout_ms fields are additive defaults; GcsConfig.summary_rate_hz valid range widened from [1.0, 2.0] to [0.5, 5.0] per AZ-397 AC-10 — this is a constraint relaxation, NOT a tightening; existing valid configs remain valid.
  • _types/fc.py FcKind — additive GCS_QGC enum value. The shared PortConfig.fc_kind discriminator now also marks GCS link variants. Existing AP/iNav callers unaffected.

Phase 3 — Architectural compliance

  • ADR-002 (build-time exclusion)QgcTelemetryAdapter is registered in runtime_root.fc_factory._GCS_BUILD_FLAGS["qgc_mavlink"] = "BUILD_GCS_QGC_MAVLINK" (batch 8); the lazy from pymavlink import mavutil inside _connect keeps the wire dependency out of the binary's import graph when the flag is OFF. Tests inject connect_factory so neither pymavlink nor a real UART is required.
  • ADR-009 (interface-first DI) — both new helpers (SpoofRecoverySink, QgcTelemetryAdapter) accept all deps via the constructor. The sink only depends on the FcAdapter Protocol; it never imports a concrete adapter class.
  • Module layeringmavlink_gcs_adapter.py is the public GcsAdapter strategy (no _ prefix); the SpoofRecoverySink lives in runtime_root/spoof_recovery_sink.py and is exported from runtime_root/__init__.__all__ (single-source-of-truth for composition-root surface).
  • Single-writer outbound thread (Invariant 8) — enforced on emit_summary and emit_status_text in QgcTelemetryAdapter. The request_source_set_switch body in the AP adapter also checks single-writer.
  • Single-writer dispatch via SpoofRecoverySink — the sink uses a bounded queue.Queue to deliver C5's spoof-recovery signal to a dedicated dispatcher thread, which is the SAME thread that calls request_source_set_switch for the entire sink lifetime. This makes the sink itself the writer-of-record from C8's perspective; no race with the C5 publisher thread.
  • Idempotence (Invariant 11) — implemented via _last_switch_attempt_ns + _last_switch_succeeded; rate-limit window _SWITCH_RATE_LIMIT_S = 1.0 is constant. Promotion to config knob is a forward action (informational finding).
  • Downsampling counter (Invariant 12) — modulo arithmetic (_invocation_count % _downsample_modulo). The modulo is computed ONCE at construction time from config.gcs.summary_rate_hz; the chosen mapping is documented in _compute_downsample_modulo (5 Hz → 1, 2 Hz → 2, 1 Hz → 5, 0.5 Hz → 10) — operator-side decoder must use the same table when reasoning about expected frame rate.

Phase 4 — Performance & reliability

  • AP source-set switch — single command_long_send + ACK loop with monotonic clock. p95 is dominated by the FC's ACK round-trip; the adapter contributes <1 ms of overhead (one command_long_send + one recv_match per iteration). The _wait_for_command_ack filter ignores non-COMMAND_ACK messages, so cross-talk with the inbound decoder thread is preserved (verified by the test stub honouring type=).
  • GCS downsample emitemit_summary is one modulo compare + (on every Nth call) two mav.*_send calls. The frame counter is integer; no allocations per drop.
  • Operator command path — pymavlink's message-hooks list is appended once on first subscribe_operator_commands. Subsequent subscribers piggy-back on the same hook (the SubscriptionBus does the fan-out; subscribers can be cancelled independently).
  • Sink queue capacity — bounded to 16 pending switches. The recovery gate produces at most one signal per spoof-promotion event; 16 is a defensive cushion. Overflow emits a WARN and drops; the C8 idempotence gate (Invariant 11) would suppress duplicates anyway.
  • Sink stop semanticsstop(join_timeout_s=1.0) flips the stop event and pushes a sentinel; the dispatcher exits on the next queue.get cycle (worst case 500 ms). Idempotent — multiple stop() calls are safe.

Phase 5 — Test quality

  • pymavlink message-hook + recv_match are stubbed correctly — the AZ-396 stub's recv_match filters by type=, mirroring real pymavlink behaviour. Without the filter the inbound decoder thread (which also calls recv_match) ate the ACK. The fix is documented inline in the test file.
  • Sink AC-7 covers both happy path and SourceSetSwitchError isolation — the dispatcher survives a raised exception from the adapter and continues to process subsequent publish() calls. This protects against C5 floods.
  • AZ-397 AC-2 is parametrised — single test exercises 1 Hz and 5 Hz endpoints; one separate test exercises the out-of-range path.
  • AZ-397 AC-9 uses the real WgsConverter — the round-trip is computed on the real helper, not a mock; defensive against silent regressions in the shared converter (C4 + C8 both depend on it).
  • AZ-393 AC-9 retitled — the original placeholder-NotImplementedError assertion is replaced by a presence/callability check; the real behaviour is now under AZ-396's coverage. The change is documented inline.
  • Arrange/Act/Assert pattern consistently applied.

Phase 6 — Logging & FDR coverage

  • AP adapter (AZ-396 additions): c8.ap.source_set_switch_executed (INFO + FDR), c8.ap.source_set_switch_failed (ERROR + FDR), c8.ap.source_set_switch_rate_limited (INFO), c8.ap.source_set_switch_already_active (INFO), c8.ap.recv_match_failed (DEBUG).
  • Spoof recovery sink: c8.spoof_recovery_sink_switch_failed (DEBUG), c8.spoof_recovery_sink_adapter_error (WARN), c8.spoof_recovery_sink_queue_full (WARN).
  • GCS adapter: c8.gcs.first_summary_emit (INFO, once), c8.gcs.summary_emit (DEBUG, per emit), c8.gcs.summary_emit_failed (ERROR), c8.gcs.statustext_failed (DEBUG), c8.gcs.operator_command_fdr_enqueue_failed (DEBUG).
  • FDR record kinds: c8.ap.source_set_switch_executed (INFO), c8.ap.source_set_switch_failed (ERROR), c8.gcs.operator_command (INFO; per inbound operator command — § 9 audit trail).

Phase 7 — Security & risk surface

  • R-D-C8-2 (firmware-supported but no operator-deployed precedent) — AZ-396 delivers the wire surface; the production gate is IT-3 SITL (ADR-008). On failure paths, the system continues to emit GPS_INPUT and the operator can manually switch via RC aux (D-C8-2-FALLBACK); the failure is surfaced via STATUSTEXT(ERROR) + FDR for operator audit.
  • Spoof recovery wiring — the C5 publisher side (AZ-385) is not yet landed. The sink is constructed but not wired; AC-7 verifies the wiring shape works with a mocked publisher. When AZ-385 lands the composition root makes ONE call: publisher.subscribe_spoof_promotion_recovered(sink.publish).
  • GCS operator-command audit — every inbound operator command emits an FDR record before the subscriber callback fires; an audit trail survives even if the subscriber crashes. The SubscriptionBus isolation guarantees a misbehaving subscriber cannot kill the message-hook dispatch.
  • STATUSTEXT mirror is one-shotemit_status_text truncates the message to 50 bytes (MAVLink constraint) and emits exactly one frame; no rate-limit on this path (the C5/C8 emitters rate-limit at the source).
  • No new external dependencies — pymavlink and pyserial were already pinned; no new packages introduced.
  • Wider GcsConfig range is a relaxation, not a tightening — operator misconfiguration that previously raised ConfigError at boot may now silently use an unexpected rate. The contract clarifies the rate range; operator-side documentation should note the new ceiling.

Informational findings (non-blocking)

  1. _SWITCH_RATE_LIMIT_S = 1.0 is a module-level constant. Promotion to FcConfig.source_set_switch_rate_limit_s is a forward-action contract bump.
  2. Spoof-recovery sink queue capacity (16) is a module-level constant. The C5 publisher is the only producer; the cap is defensive. Promotion to config is a forward action.
  3. AZ-397's global_position_int encoding uses alt_m * 1000.0 for both the absolute and relative-altitude fields — the relative-altitude needs a real take-off-relative offset, which only the runtime root knows at this point. The placeholder is acceptable for the GCS-display use case; promotion to a real relative-alt computation is a forward action when the composition root surfaces the launch reference frame.
  4. AZ-397 NAMED_VALUE_FLOAT name "horiz_m" is documented inline as the canonical GCS-side decoder key. The operator-side decoder (E-C12) MUST mirror this string.
  5. FcKind.GCS_QGC — adding GCS to a previously FC-only enum is a pragmatic compromise to keep PortConfig single-typed. A future refactor could split this into a LinkKind superclass without breaking external callers.

Verdict

PASS_WITH_INFO — all 20 ACs (10 + 10) satisfied; 25 new tests added (501 total, 0 failures); contract surface unchanged at v1.0.0 / composition_root v1.2.0; constraint relaxations and one additive enum value are non-breaking; five informational findings are forward-action enhancements.