mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 19:21:12 +00:00
8a9cf88a46
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>
13 KiB
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_fields — global_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.md— unchanged at v1.0.0. Therequest_source_set_switchandGcsAdapterProtocol entries were declared in v1.0.0 / AZ-390; this batch wires the bodies._docs/02_document/contracts/shared_config/composition_root_protocol.md— unchanged at v1.2.0. The newFcConfig.spoof_recovery_source_set+FcConfig.source_set_switch_timeout_msfields are additive defaults;GcsConfig.summary_rate_hzvalid 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.pyFcKind— additiveGCS_QGCenum value. The sharedPortConfig.fc_kinddiscriminator now also marks GCS link variants. Existing AP/iNav callers unaffected.
Phase 3 — Architectural compliance
- ADR-002 (build-time exclusion) —
QgcTelemetryAdapteris registered inruntime_root.fc_factory._GCS_BUILD_FLAGS["qgc_mavlink"] = "BUILD_GCS_QGC_MAVLINK"(batch 8); the lazyfrom pymavlink import mavutilinside_connectkeeps the wire dependency out of the binary's import graph when the flag is OFF. Tests injectconnect_factoryso 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 theFcAdapterProtocol; it never imports a concrete adapter class. - Module layering —
mavlink_gcs_adapter.pyis the publicGcsAdapterstrategy (no_prefix); theSpoofRecoverySinklives inruntime_root/spoof_recovery_sink.pyand is exported fromruntime_root/__init__.__all__(single-source-of-truth for composition-root surface). - Single-writer outbound thread (Invariant 8) — enforced on
emit_summaryandemit_status_textinQgcTelemetryAdapter. Therequest_source_set_switchbody in the AP adapter also checks single-writer. - Single-writer dispatch via SpoofRecoverySink — the sink uses a bounded
queue.Queueto deliver C5's spoof-recovery signal to a dedicated dispatcher thread, which is the SAME thread that callsrequest_source_set_switchfor 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.0is 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 fromconfig.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 (onecommand_long_send+ onerecv_matchper iteration). The_wait_for_command_ackfilter ignores non-COMMAND_ACK messages, so cross-talk with the inbound decoder thread is preserved (verified by the test stub honouringtype=). - GCS downsample emit —
emit_summaryis one modulo compare + (on every Nth call) twomav.*_sendcalls. 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 (theSubscriptionBusdoes 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 semantics —
stop(join_timeout_s=1.0)flips the stop event and pushes a sentinel; the dispatcher exits on the nextqueue.getcycle (worst case 500 ms). Idempotent — multiplestop()calls are safe.
Phase 5 — Test quality
- pymavlink message-hook + recv_match are stubbed correctly — the AZ-396 stub's
recv_matchfilters bytype=, mirroring real pymavlink behaviour. Without the filter the inbound decoder thread (which also callsrecv_match) ate the ACK. The fix is documented inline in the test file. - Sink AC-7 covers both happy path and
SourceSetSwitchErrorisolation — the dispatcher survives a raised exception from the adapter and continues to process subsequentpublish()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-
NotImplementedErrorassertion 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_INPUTand 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
SubscriptionBusisolation guarantees a misbehaving subscriber cannot kill the message-hook dispatch. - STATUSTEXT mirror is one-shot —
emit_status_texttruncates 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
ConfigErrorat 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)
_SWITCH_RATE_LIMIT_S = 1.0is a module-level constant. Promotion toFcConfig.source_set_switch_rate_limit_sis a forward-action contract bump.- 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.
- AZ-397's
global_position_intencoding usesalt_m * 1000.0for 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. - 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. FcKind.GCS_QGC— adding GCS to a previously FC-only enum is a pragmatic compromise to keepPortConfigsingle-typed. A future refactor could split this into aLinkKindsuperclass 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.