[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 04:47:44 +03:00
parent a61d2d3f4b
commit 1e0be08e8a
16 changed files with 2198 additions and 4 deletions
@@ -0,0 +1,101 @@
# C8 Msp2InavAdapter — outbound MSP2_SENSOR_GPS
**Task**: AZ-394_c8_inav_outbound
**Name**: C8 `Msp2InavAdapter` outbound — `MSP2_SENSOR_GPS` 5 Hz (D-C8-8 = (b))
**Description**: Implement the `Msp2InavAdapter.emit_external_position(EstimatorOutput)` body: encode `EstimatorOutput` into an MSP2 `MSP2_SENSOR_GPS` frame (lat/lon/alt × 1e7 in int32 per MSP2 convention; `hPosAccuracy` in mm from the injected `CovarianceProjector.to_inav_h_pos_accuracy_mm`; ground-speed/heading from C5 velocity sub-vector if present); write to the iNav UART via YAMSPy + INAV-Toolkit. Side-channel: emit `STATUSTEXT` (severity=INFO, "src=<label>") on `source_label` transition (rate-limited) over the SECONDARY MAVLink telemetry channel (iNav supports MAVLink for telemetry but not for primary positioning per RESTRICT-COMM-2). Body of `emit_status_text(msg, severity)` writes to the secondary MAVLink channel. `open(...)` rejects any non-None `signing_key` per Invariant 2 (RESTRICT-COMM-2). `request_source_set_switch` raises `SourceSetSwitchNotSupportedError` (iNav has no equivalent). Smoothed-output guard (Invariant 6). SPD-violation propagates from projector (per Invariant 4). Single-writer-thread invariant (Invariant 8). Sequence-number management for MSP2 frames maintained internally.
**Complexity**: 3 points
**Dependencies**: AZ-390 (Protocol + DTOs + errors), 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-394
**Epic**: AZ-261 (E-C8)
### Document Dependencies
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — Invariants 2, 3, 4, 6, 8, 9.
- `_docs/02_document/components/10_c8_fc_adapter/description.md` — § 3 External API (MSP2), § 5 implementation details.
- `_docs/02_document/architecture.md` — § 5 RESTRICT-COMM-2 (iNav signing-asymmetry).
## Problem
Without this task, iNav-targeted flights cannot run: no `MSP2_SENSOR_GPS` is emitted to the iNav EKF; the system is AP-only. RESTRICT-COMM-2 documents iNav as a supported FC for the program; this task implements the wire surface.
## Outcome
- `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py``Msp2InavAdapter` class implementing `FcAdapter`.
- Constructor: `__init__(self, config, wgs_converter, covariance_projector, fdr_client, clock)`.
- Body of `emit_external_position` — encode + emit `MSP2_SENSOR_GPS` via YAMSPy.
- Body of `emit_status_text` — emit STATUSTEXT on the secondary MAVLink channel.
- Body of `open(...)` — open MSP2 UART; raise `FcAdapterConfigError` on `signing_key != None` (RESTRICT-COMM-2).
- Body of `close()` — close UART connection.
- `request_source_set_switch` raises `SourceSetSwitchNotSupportedError`.
- Internal sequence-number counter for MSP2 frames.
- INFO log on first `MSP2_SENSOR_GPS` emit: `kind="c8.inav.first_emit"`.
- DEBUG log per emit: `kind="c8.inav.emit"` with `{frame_seq, h_pos_accuracy_mm, source_label}`.
## Scope
### Included
- `Msp2InavAdapter` class implementing `FcAdapter`.
- `MSP2_SENSOR_GPS` encoding from `EstimatorOutput`.
- STATUSTEXT side-channel via secondary MAVLink (transitions only, rate-limited).
- `emit_status_text` body.
- `open` / `close` body.
- Signing-key rejection (Invariant 2).
- `request_source_set_switch` not-supported.
- iNav signing-asymmetry assertion (Invariant 9): NEVER set the MAVLink2 signed-flag on the side-channel.
- Unit tests: encoding fidelity (decode via INAV-Toolkit reference), signing-key rejection, signing-asymmetry assertion (capture wire bytes, no signed-flag), source-set-switch unsupported, smoothed-output rejected.
### Excluded
- AP outbound — owned by AP task.
- GCS adapter — owned by GCS task.
- Inbound subscription — owned by inbound task.
- C8-IT/PT/ST tests — deferred to E-BBT.
## Acceptance Criteria
**AC-1: MSP2_SENSOR_GPS field fidelity** — emit a known `EstimatorOutput`; decode the wire bytes via INAV-Toolkit reference decoder; assert lat/lon/alt match `WgsConverter` output × 1e7; `hPosAccuracy` matches `CovarianceProjector.to_inav_h_pos_accuracy_mm(cov_6x6)`.
**AC-2: MSP2_SENSOR_GPS every frame** — drive 100 frames; assert 100 MSP2 frames on the wire with monotonically incrementing sequence numbers.
**AC-3: STATUSTEXT secondary channel transition** — drive 100 frames with `source_label` toggling every 10 frames; assert exactly 10 STATUSTEXT on the secondary MAVLink channel; never on the primary MSP2 channel.
**AC-4: Signing key rejection (Invariant 2)**`open(port, signing_key=b"...")``FcAdapterConfigError("iNav does not support MAVLink signing per RESTRICT-COMM-2")`.
**AC-5: Signing-asymmetry assertion (Invariant 9)** — capture every byte the iNav adapter writes to the secondary MAVLink channel for 60 s; assert no frame has the MAVLink2 signed-flag set. (This is the unit-level form of C8-IT-08.)
**AC-6: Source-set-switch not-supported**`request_source_set_switch()``SourceSetSwitchNotSupportedError("iNav: no MAV_CMD_SET_EKF_SOURCE_SET equivalent")`.
**AC-7: Smoothed output rejected**`output.smoothed=True``FcEmitError` (parallel to AC-5 of the AP task).
**AC-8: Non-SPD covariance rejected** — propagated from projector → `FcEmitError`.
**AC-9: Single-writer thread** — second-thread emit raises `RuntimeError`.
**AC-10: First emit logged once**`kind="c8.inav.first_emit"` INFO log exactly once per `open(...)` lifetime.
## Non-Functional Requirements
- `emit_external_position` p95 ≤ 5 ms (C8-PT-01 budget; iNav side mirrors AP side).
## Constraints
- YAMSPy + INAV-Toolkit at the project's pinned versions.
- Single-writer thread enforced.
- Secondary MAVLink telemetry channel MUST never set the MAVLink2 signed-flag (Invariant 9).
- MSP2 wire format requires explicit sequence numbers — counter persisted in adapter instance state for the flight lifetime.
## Risks & Mitigation
- **Risk: YAMSPy + INAV-Toolkit cross-version drift** — *Mitigation*: pinned versions; integration test against the targeted iNav firmware in IT-3 (separate SITL).
- **Risk: int32 lat/lon overflow at extreme values** — *Mitigation*: WgsConverter clamps to ±90° latitude / ±180° longitude before scaling; documented.
- **Risk: Sequence number wrap-around mid-flight** — *Mitigation*: sequence counter is `uint8` per MSP2; modular increment is the wire convention; no special handling needed.
## Runtime Completeness
- **Named capability**: iNav outbound external-position emission.
- **Production code**: real YAMSPy encode + send; real `CovarianceProjector` usage.
- **Unacceptable substitutes**: a `MAVLink_GPS_INPUT_send` against iNav (wrong wire format; defeats AC-1).
## Contract
Implements `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md``emit_external_position`, `emit_status_text` for iNav; Invariants 2, 3, 4, 6, 8, 9.