Files
gps-denied-onboard/_docs/02_tasks/done/AZ-393_c8_ardupilot_outbound.md
T
Oleksandr Bezdieniezhnykh 1e0be08e8a [AZ-393] [AZ-394] [AZ-395] C8 outbound chain + AP MAVLink2 signing
AZ-393 ArduPilot outbound: PymavlinkArdupilotAdapter encodes
EstimatorOutput to MAVLink2 GPS_INPUT via gps_input_send; emits
NAMED_VALUE_FLOAT(name="src_lbl") every frame and STATUSTEXT on
source_label transition (1 Hz per-severity cap). Smoothed-output
guard (Invariant 6), single-writer thread (Invariant 8), SPD
propagation. Shared helper _outbound_provenance.py owns the
canonical source-label-to-float table + transition rate-limiter.

AZ-394 iNav outbound: Msp2InavAdapter encodes EstimatorOutput to
hand-rolled MSP2_SENSOR_GPS (0x1F03, 52-byte LE payload via
_msp2_sensor_gps_encoder.py + YAMSPy send_RAW_msg). Secondary
unsigned MAVLink channel for STATUSTEXT transitions. open()
rejects non-None signing_key (RESTRICT-COMM-2 / Invariant 2);
request_source_set_switch raises SourceSetSwitchNotSupportedError
(Invariant 9 verified: never calls setup_signing on secondary).

AZ-395 AP MAVLink2 signing: ephemeral per-flight 32-byte key
from secrets.token_bytes; pymavlink setup_signing handshake at
open(); in-place bytearray zeroisation on close(); mid-flight
signing-failure detection (ERROR log + WARNING STATUSTEXT + no
raise; threshold configurable). Key never logged / persisted /
serialised (regex-scanned by AC-4/AC-5). BUILD_DEV_STATIC_KEY=ON
enables repeatable static-key dev path; rejected at open() when
the build flag is absent.

Shared: EstimatorOutput.smoothed (default False) added for the
Invariant 6 gate at the C8 boundary; FcConfig extended with
dev_static_signing_key + signing_failure_threshold (additive
defaults; cross-field validation in __post_init__).

Tests: 33 new AC tests (11 + 11 + 11) covering all 30 ACs; full
suite 476 passing / 2 skipped / 0 failing (was 443). Contract
surfaces unchanged at fc_adapter_protocol v1.0.0 and
composition_root v1.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 04:47:44 +03:00

7.9 KiB

C8 PymavlinkArdupilotAdapter — outbound GPS_INPUT + STATUSTEXT + NAMED_VALUE_FLOAT

Task: AZ-393_c8_ardupilot_outbound Name: C8 PymavlinkArdupilotAdapter outbound — GPS_INPUT 5 Hz + provenance side-channel Description: Implement the PymavlinkArdupilotAdapter.emit_external_position(EstimatorOutput) body: encode EstimatorOutput into a MAVLink 2.0 GPS_INPUT frame (lat/lon/alt from WGS84 conversion via injected WgsConverter; horiz_accuracy from the injected CovarianceProjector.to_ardupilot_horiz_accuracy_m; vel_n/vel_e/vel_d from the velocity sub-vector if present in the C5 estimate); write to the pymavlink connection via mav.gps_input_send(...). Side-channel: emit NAMED_VALUE_FLOAT with name="src_lbl" carrying the EstimatorOutput.source_label enum value (encoded as float per the documented enum-to-float mapping); also emit STATUSTEXT(severity=INFO, "src=<label>") once per source-label transition (rate-limited — not on every frame). Body of emit_status_text(msg, severity) writes pymavlink STATUSTEXT. request_source_set_switch raises NotImplementedError("source-set switch owned by AZ-396 task") — replaced when that task lands. Smoothed-output guard (Invariant 6): output.smoothed == TrueFcEmitError. SPD-violation propagates from CovarianceProjector as FcEmitError; logged + dropped + continue per § 5 error-handling. Single-writer-thread invariant enforced (Invariant 8). NO signing logic — that's a separate task; open(...) here accepts signing_key=None for now (signing task replaces this). Complexity: 5 points Dependencies: AZ-390 (Protocol + DTOs), AZ-392 (CovarianceProjector helper), AZ-279 (WgsConverter), AZ-273 (FDR), AZ-263, AZ-269, AZ-266, AZ-272 (FDR record schema) Component: c8_fc_adapter (epic AZ-261 / E-C8) Tracker: AZ-393 Epic: AZ-261 (E-C8)

Document Dependencies

  • _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md — Invariants 3, 4, 5, 6, 8.
  • _docs/02_document/components/10_c8_fc_adapter/description.md — § 3 External API (MAVLink 2.0), § 5 implementation details.
  • _docs/02_document/architecture.md — § 5 External Integrations.

Problem

Without this task, AP-targeted flights cannot run: there is no GPS_INPUT being emitted to ArduPilot's EKF, and the provenance side-channel (source_label via NAMED_VALUE_FLOAT + STATUSTEXT) is not feeding GCS observability. The C5 emit driver has no sink.

Outcome

  • src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.pyPymavlinkArdupilotAdapter class implementing FcAdapter.
  • Constructor: __init__(self, config, wgs_converter, covariance_projector, fdr_client, clock).
  • Body of emit_external_position — encode + emit GPS_INPUT; emit NAMED_VALUE_FLOAT(name="src_lbl") every frame; emit STATUSTEXT on source_label transition (rate-limited).
  • Body of emit_status_text — pymavlink statustext_send.
  • Body of open(...) — open pymavlink connection on the configured port with mavlink_version=2; reject signing_key=None only when the signing task has landed (this task accepts None and emits unsigned frames; a follow-up integration step in the signing task converts this to require non-None).
  • Body of close() — flush pending writes, close connection.
  • request_source_set_switch raises NotImplementedError("Owned by source-set task; install AZ-396 to enable").
  • current_flight_state already implemented by AZ-391 (inbound task) — this task just doesn't override it.
  • INFO log on first successful GPS_INPUT emit: kind="c8.ap.first_emit".
  • DEBUG log per emit: kind="c8.ap.emit" with {frame_seq, horiz_accuracy_m, source_label}.
  • ERROR log on FcEmitError: kind="c8.ap.emit_failed" with reason.

Scope

Included

  • PymavlinkArdupilotAdapter class implementing FcAdapter.
  • GPS_INPUT encoding from EstimatorOutput.
  • NAMED_VALUE_FLOAT source-label side-channel (every frame).
  • STATUSTEXT source-label transition (rate-limited).
  • emit_status_text body.
  • open / close body (without signing — placeholder).
  • Unit tests: encoding fidelity (decode the bytes back via pymavlink + assert fields); rate-limited STATUSTEXT (no flood); smoothed-output rejected; non-SPD covariance rejected; single-writer-thread enforcement.

Excluded

  • MAVLink 2.0 signing handshake — owned by signing task.
  • D-C8-2 source-set switch — owned by source-set task.
  • iNav adapter — owned by iNav outbound task.
  • GCS adapter — owned by GCS task.
  • Inbound subscribe (already implemented in _inbound_mavlink.py via AZ-391 — this class composes it).
  • current_flight_state body — already implemented in inbound task.
  • C8-IT/PT/ST tests — deferred to E-BBT.

Acceptance Criteria

AC-1: GPS_INPUT field fidelity — emit a known EstimatorOutput; decode the wire bytes via pymavlink; assert lat/lon/alt match WgsConverter output; horiz_accuracy matches CovarianceProjector.to_ardupilot_horiz_accuracy_m(cov_6x6) to within 1e-3 m.

AC-2: GPS_INPUT every frame — drive 100 frames; assert exactly 100 GPS_INPUT messages on the wire.

AC-3: NAMED_VALUE_FLOAT every frame — drive 100 frames; assert 100 NAMED_VALUE_FLOAT messages with name="src_lbl"; values match the source-label-to-float mapping.

AC-4: STATUSTEXT rate-limited on transition — drive 100 frames with source_label toggling every 10 frames; assert exactly 10 STATUSTEXT messages (one per transition); within-state frames emit zero STATUSTEXT.

AC-5: Smoothed output rejected — emit an EstimatorOutput with smoothed=True; assert FcEmitError raised + kind="c8.ap.emit_failed" logged + no GPS_INPUT on the wire.

AC-6: Non-SPD covariance rejected — emit an EstimatorOutput with non-SPD covariance_6x6; assert FcEmitError raised (propagated from CovarianceProjector) + log + no GPS_INPUT.

AC-7: Single-writer thread — call emit_external_position from a second thread; assert RuntimeError.

AC-8: Open without signing key (placeholder)open(port, signing_key=None) succeeds in this task's context; signing task tightens this to reject None.

AC-9: source-set switch raises NotImplementedErrorrequest_source_set_switch() raises with the message "Owned by source-set task; install AZ-396 to enable".

AC-10: First emit logged once — the kind="c8.ap.first_emit" INFO log fires exactly once per open(...) lifetime.

Non-Functional Requirements

  • emit_external_position p95 ≤ 5 ms (C8-PT-01 budget).
  • p99 ≤ 8 ms (defensive headroom).

Constraints

  • pymavlink bundled unmodified per D-C8-3.
  • Single-writer thread enforced.
  • The source-label-to-float encoding mapping MUST be documented inline + match the operator-side decoder (E-C12).
  • STATUSTEXT rate-limit: at most 1 per source-label transition AND at most 1 per second per severity (defensive against pathological transition spam).

Risks & Mitigation

  • Risk: pymavlink GPS_INPUT field semantics differ across ArduPilot versionsMitigation: pinned pymavlink version + integration test against the targeted ArduPilot Plane firmware (verified in IT-3 SITL).
  • Risk: STATUSTEXT flood under rapid source-label transitionsMitigation: AC-4 verifies one-per-transition; secondary 1 Hz cap as a safety net.
  • Risk: NAMED_VALUE_FLOAT name truncationMitigation: "src_lbl" is 7 chars (within MAVLink 10-char limit).

Runtime Completeness

  • Named capability: AP outbound external-position emission with provenance side-channel.
  • Production code: real pymavlink encode + send; real WgsConverter + CovarianceProjector usage; real STATUSTEXT rate-limit.
  • Unacceptable substitutes: a fake GPS_INPUT builder that doesn't go through pymavlink (defeats AC-1 wire-level fidelity).

Contract

Implements _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.mdemit_external_position, emit_status_text for AP; Invariants 3, 4, 5, 6, 8.