# Fact Cards — C8: MAVLink / MSP2 FC adapter

> Mode A Phase 2 — engine Step 3 (Fact Extraction & Evidence Cards). Sources logged in [`../01_source_registry/C8_fc_adapter.md`](../01_source_registry/C8_fc_adapter.md). Per-fact mode-pinning in **bold**; per-numbered-Restriction × per-numbered-AC sub-matrix below each Fact's `**Implication**` block where relevant. Confidence labels: ✅ High (L1 / verified source code), ⚠️ Medium (L1/L2 with caveat), ❓ Low (L3/L4 inferential).
>
> Index: [`../00_summary.md`](../00_summary.md). Prior cross-cuts: [SQ6 external positioning](SQ6_fc_external_positioning.md) — established the per-FC adapter design at SQ6 closure (Facts #1–#10), which C8 batch 1 candidate rows now operationalize. Sibling component categories: [C1 VIO](C1_vio.md), [C2 VPR](C2_vpr.md), [C3 Matchers](C3_matchers.md), [C4 Pose](C4_pose_estimation.md), [C5 State estimator](C5_state_estimator.md), [C6 Tile cache](C6_tile_cache_spatial_index.md), [C7 Inference runtime](C7_inference_runtime.md).

## Scope summary

C8 batch 1 evaluates THREE candidate adapter implementations after the c8_inav_recovery=B mid-batch correction (preserves locked SQ6 + AC-4.3 + restrictions.md verdict that MSP2_SENSOR_GPS is the iNav primary, with UBX impersonation as comparative-improvement-evaluable alternate; per-FC-breadth narrowest at one ArduPilot candidate + two iNav candidates):

| # | Candidate | FC | Transport | License | Status (per Fit Matrix) |
|---|---|---|---|---|---|
| 1 | **pymavlink → MAVLink GPS_INPUT (msg 232)** | ArduPilot Plane | MAVLink over UART/USB/UDP | LGPL-3.0 (linkable from Apache-2.0 app per LGPL §6) | Mandatory primary + RECOMMENDED PRIMARY (cooperative-path, SQ6 Fact #1 lead) |
| 2 | **MSP2_SENSOR_GPS (id 7939 / 0x1F03) via Python MSP2 (YAMSPy or INAV-Toolkit msp_v2_encode)** | iNav | MSP V2 over UART/USB | MIT (libraries) | Mandatory primary + RECOMMENDED PRIMARY (cooperative-path, SQ6 Fact #6 lead) |
| 3 | **UBX impersonation via pyubx2 NAV-PVT (forged u-blox frames through standard GPS pipeline)** | iNav | UBX over UART | BSD-3-Clause (pyubx2) | Documentary-evaluable alternate (comparative-improvement assessment vs Cand 2 per user's "significant-improvement-only" bar) |

---

## C8 — On-FC adapter

### Fact #97 — ArduPilot Plane FC adapter primary: pymavlink → MAVLink GPS_INPUT (msg 232) cooperative-path; `GPS1_TYPE = 14` MAVLink + `EK3_SRC1_POSXY = 3` GPS source-set drives EKF3 ingestion via `AP_GPS_MAV` driver

- **Statement**: pymavlink (LGPL-3.0, canonical Python MAVLink stack maintained by ArduPilot per Source #106) is the single adapter library for the ArduPilot Plane side. Companion-side canonical send pattern (per pymavlink generated dialect + Source #107 ArduPilot dev docs):
  ```python
  from pymavlink import mavutil
  master = mavutil.mavlink_connection('udpout:127.0.0.1:14550', source_system=1, source_component=240)
  master.mav.gps_input_send(
      time_usec, gps_id, ignore_flags, time_week_ms, time_week, fix_type,
      lat_deg_e7, lon_deg_e7, alt_m, hdop, vdop, vn_cmps, ve_cmps, vd_cmps,
      speed_accuracy_mps, horiz_accuracy_m, vert_accuracy_m, satellites_visible, yaw_cdeg,
  )
  ```
  FC-side configuration (per Source #107): `GPS1_TYPE = 14` (MAVLink) is REQUIRED for AP_GPS to instantiate the AP_GPS_MAV driver; `EK3_SRC1_POSXY = 3` (GPS) selects the GPS_INPUT-fed virtual GPS as primary horizontal-position source for EKF3. AP's preferred non-GPS messages are `ODOMETRY` / `VISION_POSITION_ESTIMATE` at ≥4 Hz, but `GPS_INPUT` is the right transport for the project's "WGS84 coordinates as a real-GPS replacement" outcome contract (AC-4.3) AND for the project's `{satellite_anchored, visual_propagated, dead_reckoned}` source-label scheme (per SQ6 Fact #4: ODOMETRY-velocity-only is NOT supported in current AP, so `visual_propagated` cannot ride ODOMETRY — must be GPS_INPUT with widened `horiz_accuracy`).
- **Mode pinning**: `master.mav.gps_input_send(time_usec, gps_id, ignore_flags, time_week_ms, time_week, fix_type, lat, lon, alt, hdop, vdop, vn, ve, vd, speed_accuracy, horiz_accuracy, vert_accuracy, satellites_visible, yaw)` per pymavlink generated dialect (verified via SQ6 Source #4 AP_GPS_MAV.cpp ingestion path Fact #1).
- **Source**: Source #106 (pymavlink context7 + GitHub); Source #107 (ArduPilot Plane Non-GPS Position Estimation + GPS_INPUT MAVProxy module dev docs); cross-cite SQ6 Source #4 (AP_GPS_MAV.cpp master) + SQ6 Fact #1 + SQ6 Fact #2 + SQ6 Fact #3 + SQ6 Fact #4
- **Phase**: Phase 2
- **Confidence**: ✅
- **Sub-Question Binding**: SQ3 + SQ4 (per-component candidate selection for C8); SQ6 (per-FC inbound transport — already closed)
- **Related Dimension**: C8, C5 (covariance contract via `horiz_accuracy/vert_accuracy/speed_accuracy`), AC-NEW-2 (FC-side EKF source-set switch via `MAV_CMD_SET_EKF_SOURCE_SET`, SQ6 Fact #3)
- **Implication**: **supports selection** — Cand 1 (pymavlink → GPS_INPUT) satisfies AC-4.3 ArduPilot side; covariance honesty (AC-NEW-4) is wired through three fields (`horiz_accuracy`, `vert_accuracy`, `speed_accuracy`); spoof-promotion (AC-NEW-2) is companion-driven via `MAV_CMD_SET_EKF_SOURCE_SET`; visual-blackout failsafe (AC-NEW-8) maps directly to `fix_type` 0/1/2 + `horiz_accuracy = 999.0` sentinel per AP convention; source-label semantics (AC-1.4) emit out-of-band via `STATUSTEXT` / `NAMED_VALUE_FLOAT` per locked AC-4.3 wording.

#### Per-numbered-Restriction × per-numbered-AC sub-matrix (Cand 1: pymavlink → GPS_INPUT, ArduPilot Plane)

| Numbered AC / Restriction | Cand 1 (pymavlink → GPS_INPUT) verdict | Justification |
|---|---|---|
| AC-1.4 (95% covariance + source label) | **Pass** | `horiz_accuracy` = 95% covariance proxy; source label rides STATUSTEXT/NAMED_VALUE_FLOAT per AC-4.3 |
| AC-4.1 (≤400 ms p95 frame latency) | **Pass** | pymavlink Python encoding overhead is <1 ms per packet on Jetson Orin Nano Super CPU; UDP/UART transmit is sub-ms |
| AC-4.2 (<8 GB shared memory) | **Pass** | pymavlink runtime footprint is ~5-10 MB Python heap |
| AC-4.3 (FC output contract) | **Pass** | GPS_INPUT is exactly the locked AC-4.3 ArduPilot transport |
| AC-4.4 (frame-by-frame streaming) | **Pass** | pymavlink supports unbuffered immediate send |
| AC-4.5 (look-back refinement) | **N/A** | Adapter is downstream of estimator; no smoothing here |
| AC-6.1 (1-2 Hz GCS downsample) | **Pass** | Companion can throttle GPS_INPUT to FC at 1-3 Hz (AP samples at its own rate) |
| AC-NEW-1 (TTFF <30 s) | **Pass** | First valid GPS_INPUT frame is sent as soon as the estimator publishes an anchored fix |
| AC-NEW-2 (<3 s spoof promotion) | **Verify** | `MAV_CMD_SET_EKF_SOURCE_SET` round-trip latency under load — SITL validation per AC-NEW-2.Validation |
| AC-NEW-3 (FDR retains all emitted frames) | **Pass** | Companion-side raw MAVLink stream (tlog) capture is trivial via pymavlink |
| AC-NEW-4 (false-position safety budget) | **Pass IF covariance honest** | Project must publish honest `horiz_accuracy` (under-reporting defeats EKF3 quality chain per SQ6 Fact #2) |
| AC-NEW-7 (no covert GPS spoofing without consent) | **Pass** | GPS_INPUT is the documented external-positioning channel; not covert |
| AC-NEW-8 (visual-blackout failsafe) | **Pass** | Maps to `fix_type` 0/1/2 + `horiz_accuracy=999.0` sentinel per AP convention |
| Restriction "Supported FCs: ArduPilot Plane, iNav" | **Pass** for AP side | Cand 1 covers AP path only; iNav covered by Cand 2/3 |
| Restriction "Communication protocol per-FC: MAVLink for AP" | **Pass** | Exact match |
| LGPL-3.0 license posture (pymavlink) | **Pass** | LGPL §6 allows linking from Apache-2.0 app; project does not modify pymavlink, so no obligation beyond republishing modifications (none); fully dual-use compatible |

### Fact #98 — iNav FC adapter alternate: UBX impersonation via pyubx2 NAV-PVT (forging u-blox frames through standard GPS pipeline) — viability gated by iNav `gpsMapFixType()` validation: must set `flags & 0x01 (gnssFixOK) = 1` AND `fixType ∈ {2, 3}`

- **Statement**: pyubx2 (BSD-3-Clause, canonical Python UBX/NMEA/RTCM3 parser per Source #108) supports `UBXMessage(ubxClass='NAV', ubxID='NAV-PVT', mode=GET, **kwargs)` constructor with full per-attribute control, plus `serialize()` for wire-format output (sync-bytes 0xB5 0x62 + class + ID + length + payload + 8-bit Fletcher checksum). Companion-side canonical send pattern:
  ```python
  from pyubx2 import UBXMessage, GET, parsebitfield
  msg = UBXMessage(
      'NAV', 'NAV-PVT', GET,
      iTOW=ms_of_week,
      year=2026, month=5, day=8, hour=12, min=30, sec=0,
      valid=0b0111,  # validDate | validTime | fullyResolved
      tAcc=10_000_000, nano=0,
      fixType=3,  # FIX_3D — required for iNav gpsMapFixType to return GPS_FIX_3D
      flags=0b00000001,  # gnssFixOK set — required for fix_status & NAV_STATUS_FIX_VALID
      flags2=0,
      numSV=12,
      lon=int(lon_deg * 1e7), lat=int(lat_deg * 1e7),
      height=int(alt_m * 1000), hMSL=int(alt_m * 1000),
      hAcc=int(horiz_acc_m * 1000), vAcc=int(vert_acc_m * 1000),
      velN=int(vn_mps * 1000), velE=int(ve_mps * 1000), velD=int(vd_mps * 1000),
      gSpeed=int(speed_2d_mps * 1000),
      headMot=int(heading_deg * 1e5),
      sAcc=int(speed_acc_mps * 1000), headAcc=int(heading_acc_deg * 1e5),
      pDOP=int(pdop * 100),
      headVeh=0, magDec=0, magAcc=0,
  )
  serial_out.write(msg.serialize())
  ```
  iNav-side validation logic (per Source #110 `gps_ublox.c` direct read at line 654 + line 215-220):
  ```c
  // Line 654 (NAV-PVT path):
  next_fix_type = gpsMapFixType(_buffer.pvt.fix_status & NAV_STATUS_FIX_VALID, _buffer.pvt.fix_type);
  // Line 215-220 (validation gate):
  static gpsFixType_e gpsMapFixType(bool fixValid, uint8_t ubloxFixType) {
      if (fixValid && ubloxFixType == FIX_2D) return GPS_FIX_2D;
      if (fixValid && ubloxFixType == FIX_3D) return GPS_FIX_3D;
      return GPS_NO_FIX;
  }
  ```
  Two validation requirements together: (a) `_buffer.pvt.fix_status & NAV_STATUS_FIX_VALID` evaluates `flags & 0x01` (= `gnssFixOK` bit) — must be 1; (b) `_buffer.pvt.fix_type` must be `FIX_2D = 2` or `FIX_3D = 3`. iNav 9.0+ at u-blox version ≥ 15.0 configures NAV-PVT-only protocol (per Source #110 lines 1024-1028) — companion must advertise version ≥ 15.0 via NAV-VER (CLASS=0x0A, ID=0x04) at startup to drive iNav into the simpler protocol surface.
- **Mode pinning**: `pyubx2.UBXMessage('NAV', 'NAV-PVT', GET, **kwargs).serialize()` produces wire-format bytes for direct UART write to iNav's GPS port; companion is the sole GPS source (SQ6 Fact #7 — iNav has no dual-GPS arbitration).
- **Source**: Source #108 (pyubx2 context7 + canonical README); Source #109 (u-blox NEO-M9N + M8 NAV-PVT canonical specifications); Source #110 (iNav `gps_ublox.c` master validation gates); cross-cite SQ6 Fact #10 (UBX-only over UART; NMEA dropped in 7.0; UBX ≥ 15.00 in 9.0+) + SQ6 Fact #7 (single-GPS architecture)
- **Phase**: Phase 2
- **Confidence**: ✅
- **Sub-Question Binding**: SQ3 + SQ4 (per-component candidate selection for C8); SQ6 (UBX emulation alternate, Fact #10)
- **Related Dimension**: C8, C5 (covariance contract via NAV-PVT `hAcc/vAcc/sAcc`), AC-NEW-2 (no FC-side switch needed — companion is sole GPS), AC-NEW-7 (UBX impersonation IS a forgery operation; safety implication)
- **Implication**: **viable alternate, comparative-improvement gate against Cand 2** — UBX path bypasses MSP2 queueing/arbitration concerns (companion appears as a normal u-blox receiver to iNav's stock GPS pipeline) AND requires no `USE_GPS_PROTO_MSP` build flag. Trade-offs: (a) implementation cost — companion must implement a fuller protocol surface (NAV-PVT periodic + NAV-VER on startup + correct ACK/NAK behaviour for CFG-MSG/CFG-RATE polls) vs MSP2_SENSOR_GPS which is a single periodic injection message; (b) iNav-firmware-side validation contract is stricter (`gpsMapFixType()` + 100-200 lines of stateful u-blox protocol handling vs `mspGPSReceiveNewData()` direct passthrough); (c) AC-NEW-7 nuance — UBX impersonation is a clearer forgery posture (companion is pretending to be a u-blox receiver) than MSP2_SENSOR_GPS (companion is using a documented sensor-injection path); (d) per user's "significant-improvement-only" bar (carried from C6 closure precedent), the Plan-phase comparative verdict needs to weigh: does UBX add material value over MSP2_SENSOR_GPS to justify the implementation cost + AC-NEW-7 nuance?

#### Per-numbered-Restriction × per-numbered-AC sub-matrix (Cand 3: UBX impersonation via pyubx2 NAV-PVT, iNav)

| Numbered AC / Restriction | Cand 3 (UBX impersonation) verdict | Justification |
|---|---|---|
| AC-1.4 (95% covariance + source label) | **Pass** | NAV-PVT `hAcc`/`vAcc`/`sAcc` carry covariance proxies; source label rides separate MSP2_DEBUG_MSG / TextMessage off-band channel (UBX has no equivalent of MAVLink STATUSTEXT — must use a sibling iNav telemetry channel) |
| AC-4.1 (≤400 ms p95 frame latency) | **Pass** | pyubx2 serialization overhead is <1 ms per packet; UART transmit at 115200+ baud is sub-ms |
| AC-4.2 (<8 GB shared memory) | **Pass** | pyubx2 runtime footprint is ~5-10 MB Python heap |
| AC-4.3 (FC output contract) | **Pass** (UBX is iNav's documented GPS protocol) | NAV-PVT through standard GPS pipeline IS a documented external-positioning interface; AC-4.3 wording mentions MSP2_SENSOR_GPS as primary but does not exclude UBX-emulation alternate |
| AC-4.4 (frame-by-frame streaming) | **Pass** | NAV-PVT streamed at companion's chosen rate (5-10 Hz typical) |
| AC-4.5 (look-back refinement) | **N/A** | Adapter is downstream of estimator |
| AC-6.1 (1-2 Hz GCS downsample) | **N/A** for UBX path (GCS sees iNav's MAVLink outbound, not UBX inbound) | iNav still emits MAVLink telemetry to GCS regardless of UBX vs MSP2 inbound choice |
| AC-NEW-1 (TTFF <30 s) | **Pass** | First valid NAV-PVT frame is sent as soon as estimator publishes anchored fix |
| AC-NEW-2 (<3 s spoof promotion) | **Pass by architecture** | Companion is sole iNav GPS; no FC-side switch needed (per SQ6 Fact #7) |
| AC-NEW-3 (FDR retains all emitted frames) | **Pass** | Companion-side raw UBX stream capture is trivial |
| AC-NEW-4 (false-position safety budget) | **Verify** | Need to confirm iNav nav-stack actually USES NAV-PVT `hAcc/vAcc` for outlier handling (the SQ6 Fact #6 "iNav explicitly does NOT validate GPS for spoofing" caveat applies symmetrically to UBX path — companion-side honesty is mandatory because iNav-side rejection chain is minimal) |
| AC-NEW-7 (no covert spoofing without consent) | **Verify** | UBX impersonation IS a forgery posture; project must explicitly document this in the FDR audit trail (mitigates by being a documented project design, but the impersonation framing is unambiguous) |
| AC-NEW-8 (visual-blackout failsafe) | **Pass** | NAV-PVT `fixType` enum carries graceful degrade: 0=NoFix / 1=DeadReck / 2=2D / 3=3D / 4=GNSS+DR / 5=TimeOnly; companion can emit `fixType=0` for blackout-no-fix or `fixType=2` (2D) for degraded-covariance mode |
| Restriction "Supported FCs: ArduPilot Plane, iNav" | **Pass** for iNav side | Cand 3 covers iNav path only |
| Restriction "Communication protocol per-FC: MSP2 for iNav" | **Verify (alternate)** | Locked restrictions.md says MSP2; UBX is documented in SQ6 Fact #10 as fallback, not primary. Plan-phase decision (D-C8-N) chooses between MSP2 primary or UBX primary based on comparative verdict |
| BSD-3-Clause license posture (pyubx2) | **Pass** | Clean dual-use compatible |

### Fact #99 — iNav FC adapter primary: MSP2_SENSOR_GPS (id 7939 / 0x1F03) via Python MSP V2 implementation (YAMSPy or INAV-Toolkit `msp_v2_encode`) — `mspGPSReceiveNewData()` direct passthrough; covariance fields `hPosAccuracy`/`vPosAccuracy`/`hVelAccuracy` align directly with AP `GPS_INPUT.horiz_accuracy`/`vert_accuracy`/`speed_accuracy`

- **Statement**: MSP2_SENSOR_GPS (id 7939 / 0x1F03 — verified in iNav `msp_protocol_v2_sensor.h` master per Source #113) is iNav's documented sensor-plugin GPS injection path. Per Source #111 master `docs/development/msp/README.md` lines 2999-3031: payload is 36 bytes `instance/u8 + gpsWeek/u16 + msTOW/u32 + fixType/u8 + satellitesInView/u8 + hPosAccuracy/u16(mm) + vPosAccuracy/u16(mm) + hVelAccuracy/u16(cm/s) + hdop/u16 + longitude/i32(deg×1e7) + latitude/i32(deg×1e7) + mslAltitude/i32(cm) + nedVelNorth/i32(cm/s) + nedVelEast/i32(cm/s) + nedVelDown/i32(cm/s) + groundCourse/u16(deg×100) + trueYaw/u16(deg×100, 65535=unavailable) + year/u16 + month/u8 + day/u8 + hour/u8 + min/u8 + sec/u8`. iNav-side: `mspGPSReceiveNewData()` is called with no return value — direct passthrough to `gpsSol` (per Source #111 Notes block), NO additional validation gate beyond the data parse itself (contrast with UBX path's `gpsMapFixType()` validation). Required iNav build flag: `USE_GPS_PROTO_MSP` — **enabled by default in `target/common.h`** per SQ6 Source #13 (so stock firmware reaches this path).

  Companion-side canonical send pattern using INAV-Toolkit primitives (per Source #112):
  ```python
  import struct
  from inav_msp import msp_v2_encode  # CRC-8 DVB-S2 envelope encoder

  MSP2_SENSOR_GPS = 0x1F03
  payload = struct.pack(
      '<BHIBBHHHHIIIiiiHHHBBBBB',
      instance, gps_week, ms_tow, fix_type, sats_in_view,
      h_pos_acc_mm, v_pos_acc_mm, h_vel_acc_cmps, hdop_x100,
      lon_deg_e7, lat_deg_e7, msl_alt_cm,
      ned_n_cmps, ned_e_cmps, ned_d_cmps,
      ground_course_deg_x100, true_yaw_deg_x100,
      year, month, day, hour, min_, sec,
  )
  frame = msp_v2_encode(MSP2_SENSOR_GPS, payload)  # MSP V2 envelope: 0x24 'X' 0x3C flag cmd_le len_le payload crc8_dvb_s2
  serial_out.write(frame)
  ```
  Two community-maintained Python options: **YAMSPy** (MIT, community-blessed in iNav Issue #4465 for external-device communication) OR **INAV-Toolkit `inav_msp.py`** (MIT, 951-line direct primary-source reference for `msp_v2_encode` / `msp_v2_decode` with CRC-8 DVB-S2). Either route avoids the UBX-protocol-handler complexity required for Cand 3.
- **Mode pinning**: `msp_v2_encode(MSP2_SENSOR_GPS=0x1F03, payload)` + `serial_out.write(frame)` per iNav MSP V2 envelope spec; iNav-side `mspGPSReceiveNewData()` direct passthrough per Source #111 Notes.
- **Source**: Source #111 (iNav MSP message reference master, MSP2_SENSOR_GPS canonical payload spec); Source #112 (YAMSPy + INAV-Toolkit Python implementations); Source #113 (iNav `msp_protocol_v2_sensor.h` MSP V2 sensor-message-range definition); cross-cite SQ6 Source #12 (master MSP message reference) + SQ6 Source #13 (`USE_GPS_PROTO_MSP` enabled by default) + SQ6 Fact #6 (MSP2_SENSOR_GPS covariance-rich path)
- **Phase**: Phase 2
- **Confidence**: ✅ for protocol spec; ⚠️ for community-library version-stability (YAMSPy / INAV-Toolkit may need extension or thin-custom-encoder replacement at design phase if MSP V2 sensor-message-range support lags upstream iNav)
- **Sub-Question Binding**: SQ3 + SQ4 (per-component candidate selection for C8); SQ6 (per-FC inbound transport, Fact #6 — already SQ6 Selected lead)
- **Related Dimension**: C8, C5 (covariance contract via `hPosAccuracy`/`vPosAccuracy`/`hVelAccuracy`), AC-NEW-2 (no FC-side switch needed — companion is sole GPS), AC-NEW-4 (covariance honesty is companion-side responsibility per SQ6 Fact #8)
- **Implication**: **supports selection** — Cand 2 (MSP2_SENSOR_GPS via Python MSP V2) is the SQ6 closure lead AND is the locked AC-4.3 transport for iNav; covariance honesty (AC-NEW-4) is wired through three fields aligning DIRECTLY with AP `GPS_INPUT.horiz_accuracy/vert_accuracy/speed_accuracy` — same companion-side covariance contract for both FCs. AC-NEW-7 framing: MSP2_SENSOR_GPS is a documented sensor-injection path, NOT an impersonation — clearer audit-trail posture than Cand 3 UBX. AC-NEW-8 graceful-degrade: `fixType` enum carries 6 levels (per `gpsFixType_e`) — companion can emit `GPS_NO_FIX` (0) for blackout-no-fix, `GPS_FIX_2D` (1) for degraded-covariance >100 m mode. Single-message contract = simpler than UBX's NAV-PVT + NAV-VER + CFG-* protocol surface.

#### Per-numbered-Restriction × per-numbered-AC sub-matrix (Cand 2: MSP2_SENSOR_GPS via Python MSP V2, iNav)

| Numbered AC / Restriction | Cand 2 (MSP2_SENSOR_GPS) verdict | Justification |
|---|---|---|
| AC-1.4 (95% covariance + source label) | **Pass** | `hPosAccuracy` = 95% covariance proxy; source label rides separate MSP2 telemetry channel (e.g. MSP2_SENSOR_RANGEFINDER spare bytes or a custom MSP2_INAV_DEBUG variant) |
| AC-4.1 (≤400 ms p95 frame latency) | **Pass** | Python `struct.pack` + `msp_v2_encode` overhead is <1 ms per frame on Jetson |
| AC-4.2 (<8 GB shared memory) | **Pass** | YAMSPy or INAV-Toolkit runtime footprint is ~5-10 MB Python heap |
| AC-4.3 (FC output contract) | **Pass** | MSP2_SENSOR_GPS is exactly the locked AC-4.3 iNav transport |
| AC-4.4 (frame-by-frame streaming) | **Pass** | MSP2 supports periodic injection at companion's chosen rate (5-10 Hz typical) |
| AC-4.5 (look-back refinement) | **N/A** | Adapter is downstream of estimator |
| AC-6.1 (1-2 Hz GCS downsample) | **N/A** for MSP2 path (GCS sees iNav's MAVLink outbound, not MSP2 inbound) | iNav still emits MAVLink telemetry to GCS regardless of MSP2 vs UBX inbound choice |
| AC-NEW-1 (TTFF <30 s) | **Pass** | First valid MSP2_SENSOR_GPS frame is sent as soon as estimator publishes anchored fix |
| AC-NEW-2 (<3 s spoof promotion) | **Pass by architecture** | Companion is sole iNav GPS; no FC-side switch needed (per SQ6 Fact #7) |
| AC-NEW-3 (FDR retains all emitted frames) | **Pass** | Companion-side raw MSP V2 stream capture is trivial |
| AC-NEW-4 (false-position safety budget) | **Verify** | Need to confirm iNav nav-stack actually USES `hPosAccuracy/vPosAccuracy/hVelAccuracy` for outlier handling per SQ6 Fact #6 + SQ6 Fact #8 — design-phase task to read `src/main/io/gps_msp.c` `mspGPSReceiveNewData()` body |
| AC-NEW-7 (no covert spoofing without consent) | **Pass** | MSP2_SENSOR_GPS is the documented sensor-injection path; not covert/forgery |
| AC-NEW-8 (visual-blackout failsafe) | **Pass** | `fixType` enum (`gpsFixType_e`) carries graceful degrade levels; companion can emit `GPS_NO_FIX` (0) or `GPS_FIX_2D` (1) for the covariance>100 m / blackout thresholds |
| Restriction "Supported FCs: ArduPilot Plane, iNav" | **Pass** for iNav side | Cand 2 covers iNav path only |
| Restriction "Communication protocol per-FC: MSP2 for iNav" | **Pass** | Exact match — locked SQ6 + AC-4.3 + restrictions.md |
| MIT license posture (YAMSPy + INAV-Toolkit) | **Pass** | Clean dual-use compatible |

---

## C8 — Cand 2 vs Cand 3 comparative-improvement-vs-Cand-2 verdict (closure of batch 1, iNav side)

Per user's session-start "significant-improvement-only" bar (same calibration as C6 closure verdict that locked Cand 1 PostgreSQL+btree+FAISS as primary over Cand 2 PostGIS+pgvector secondary):

| Lever | Cand 2 (MSP2_SENSOR_GPS) | Cand 3 (UBX impersonation) | Material improvement of Cand 3 over Cand 2? |
|---|---|---|---|
| Wire format complexity | Single MSP2 envelope + 36-byte payload + CRC-8 DVB-S2 | NAV-PVT (92 bytes) + NAV-VER startup + CFG-MSG/CFG-RATE ACK behaviour | **Cand 3 ADDS complexity (negative)** |
| Protocol-surface footprint | One message ID (0x1F03) | NAV-PVT + NAV-VER (CLASS=0x0A,ID=0x04) + ACK/NAK protocol | **Cand 3 ADDS surface (negative)** |
| iNav-side validation gate | `mspGPSReceiveNewData()` direct passthrough | `gpsMapFixType()` requires `flags & 0x01 = 1` AND `fixType ∈ {2,3}` | **Cand 3 ADDS validation gate (mixed: stricter = more brittle to companion bugs, but also catches malformed frames earlier)** |
| Covariance-honesty contract | `hPosAccuracy/vPosAccuracy/hVelAccuracy` aligned with AP `GPS_INPUT.horiz_accuracy/vert_accuracy/speed_accuracy` | NAV-PVT `hAcc/vAcc/sAcc` aligned with same | **Tie** |
| AC-NEW-7 audit-trail posture | Documented sensor-injection path (clean) | Forgery posture (companion impersonates u-blox receiver) | **Cand 3 WORSE for AC-NEW-7** |
| Dependency on iNav build flags | `USE_GPS_PROTO_MSP` (enabled by default) | None (UBX path always available) | **Cand 3 marginally better — no dependency on a build flag** |
| Library maturity | YAMSPy / INAV-Toolkit (community, MIT, ~951-line reference impl); ⚠️ may need extension for MSP2 sensor-message-range | pyubx2 (canonical, BSD-3-Clause, daily-active, 139+239 context7 code snippets) | **Cand 3 has more mature library** |
| AC-NEW-2 architectural fit | Pass-by-architecture (companion is sole GPS) | Pass-by-architecture (same) | **Tie** |
| AC-NEW-8 graceful-degrade | `gpsFixType_e` 6-level enum | NAV-PVT `fixType` 6-level enum | **Tie** |
| Cross-FC consistency with AP path | MSP2 ≠ MAVLink — different protocol on the wire, but same logical companion-side covariance contract | UBX ≠ MAVLink — same | **Tie** |

**Verdict (closure)**: Cand 3 (UBX impersonation) does NOT clear the user's "significant-improvement-only" bar over Cand 2 (MSP2_SENSOR_GPS). UBX's sole real upside is library maturity (pyubx2 vs YAMSPy/INAV-Toolkit) — but YAMSPy + INAV-Toolkit are MIT-clean and the canonical msp_v2_encode primitive is well-documented (951 lines of primary-source reference in INAV-Toolkit). Cand 3's downsides (added protocol-surface complexity + AC-NEW-7 forgery posture + stricter validation gate) outweigh the upside.

**Recommendation**: Cand 2 (MSP2_SENSOR_GPS) is **RECOMMENDED PRIMARY** for the iNav side; Cand 3 (UBX impersonation) is **DEFERRED secondary** with explicit re-evaluation criteria — promote to primary IF (a) YAMSPy + INAV-Toolkit prove insufficient at Plan-phase MSP V2 sensor-message-range support and project chooses NOT to extend them, OR (b) Plan-phase iNav MVE reveals that `mspGPSReceiveNewData()` does NOT use the covariance fields per AC-NEW-4 verify-cell and the project needs the stricter `gpsMapFixType()` validation contract for runtime sanity-checking, OR (c) the project re-opens AC-NEW-7 and decides UBX impersonation is preferred for some yet-to-be-identified safety reason.

---

## C8 — Working conclusions and decisions (compounded from Fact #97 + Fact #98 + Fact #99 closures)

### Per-FC adapter design (re-confirmed from SQ6 closure, now operationalized)

| FC | Adapter library | Transport | Lead candidate fact | License posture |
|---|---|---|---|---|
| **ArduPilot Plane** | pymavlink | MAVLink GPS_INPUT (msg 232) over UART/USB/UDP | Fact #97 | LGPL-3.0 (linkable from Apache-2.0 app per LGPL §6) |
| **iNav (RECOMMENDED PRIMARY)** | YAMSPy or INAV-Toolkit msp_v2_encode | MSP2_SENSOR_GPS (id 7939 / 0x1F03) over UART/USB | Fact #99 | MIT |
| **iNav (DEFERRED secondary)** | pyubx2 | UBX NAV-PVT impersonation over UART | Fact #98 | BSD-3-Clause |

### Plan-phase Decision Gates raised by C8 batch 1

- **D-C8-1 (NEW from Fact #97 closure 2026-05-08, Cand-1-only)** — pymavlink connection-string transport choice
  - Options: (a) `udpout:127.0.0.1:14550` for in-process companion + autopilot UDP; (b) `serial:/dev/ttyTHS1:921600` for direct UART to AP TELEM port (no companion-router middlebox); (c) `tcp:127.0.0.1:5760` for SITL replay; (d) **all three configurable via env var, default UART (b) for production deployment, UDP (a) for SITL replay, TCP (c) for unit tests RECOMMENDED**.
  - Owner: Plan-phase architect.
  - Rationale: pymavlink supports all three transports identically; choice depends on deployment topology. Default to UART for production reduces moving parts.

- **D-C8-2 (NEW from Fact #97 closure 2026-05-08, Cand-1-only CROSS-COMPONENT with AC-NEW-2)** — `MAV_CMD_SET_EKF_SOURCE_SET` companion-driven switch ownership
  - Options: (a) companion always claims source-set 1 and FC keeps real-GPS at source-set 2 (companion reactive only); (b) **companion publishes to source-set 2 and switches FC to set 2 when companion publishes its first valid fix; switches back to set 1 when companion is unavailable RECOMMENDED ~mirrors NGPS/Auterion pattern**; (c) operator manually flips source-set via RC aux switch (option 90).
  - Owner: Plan-phase architect + AC-NEW-2 owner.
  - Rationale: per SQ6 Fact #3, "no GCSs are currently known to implement" companion-driven `MAV_CMD_SET_EKF_SOURCE_SET` — but it works at firmware level. The project gets to define the canonical pattern.

- **D-C8-3 (NEW from Fact #97 closure 2026-05-08, Cand-1-only)** — pymavlink LGPL-3.0 license-posture verification
  - Options: (a) **bundle pymavlink unmodified + publish requirements.txt with version pin RECOMMENDED ~standard LGPL §6 compliance**; (b) statically link via Cython compilation (LGPL §6 obligation: provide relinkable form); (c) wrap pymavlink behind a thin C++/Rust process boundary to keep companion-app fully Apache-2.0 (over-engineered; not justified by project posture).
  - Owner: Plan-phase architect + license owner.
  - Rationale: aligns with D-C1-1 license-posture-track decision; pymavlink LGPL-3.0 vs project Apache-2.0 dual-use track is straightforward.

- **D-C8-4 (NEW from Fact #99 closure 2026-05-08, Cand-2-only)** — Python MSP V2 implementation choice
  - Options: (a) **YAMSPy (community-blessed for iNav external-device comms per Issue #4465); MIT; latest commit pre-2025-Q4 RECOMMENDED ~widest community usage**; (b) INAV-Toolkit `msp_v2_encode` primitive lifted into the project (951-line MIT module, direct primary-source reference); (c) thin custom encoder using `struct.pack` + CRC-8 DVB-S2 helper (50-line bespoke); (d) project-side fork of one of the above.
  - Owner: Plan-phase architect.
  - Rationale: all options are MIT and produce identical wire bytes; choice depends on maintainability vs minimum-dependency-surface preference.

- **D-C8-5 (NEW from Fact #99 closure 2026-05-08, Cand-2-only)** — MSP2_SENSOR_GPS injection rate
  - Options: (a) **5 Hz periodic RECOMMENDED ~matches GPS_INPUT 5 Hz cadence on AP side, single-rate cross-FC consistency**; (b) 10 Hz to match iNav nav-cycle frequency; (c) variable rate matching estimator publication rate (3 Hz nominal, up to 10 Hz when matcher confidence is high).
  - Owner: Plan-phase architect.
  - Rationale: estimator publishes at 3 Hz nominal (per pinned dual-rate camera pipeline Fact #40); 5 Hz adapter-side rate has spare headroom for IMU-propagation between estimator updates.

- **D-C8-6 (NEW from Fact #98 closure 2026-05-08, Cand-3-only contingent)** — IF Cand 3 selected → UBX-version-advertisement strategy
  - Options: (a) **advertise hwVersion ≥ M9 + swVersion ≥ 15.00 via NAV-VER (CLASS=0x0A, ID=0x04) at startup + every reset; force iNav into NAV-PVT-only protocol surface RECOMMENDED ~simplest configuration path**; (b) advertise hwVersion = M8 + swVersion = 14.x to drive iNav into legacy NAV-POSLLH+NAV-SOL+NAV-VELNED+NAV-TIMEUTC quad mode (more messages but historical iNav-friendly path); (c) implement adaptive advertisement based on iNav firmware-version probe.
  - Owner: Plan-phase architect.
  - Rationale: per Source #110 lines 1024-1060, iNav configures the simpler NAV-PVT-only path for u-blox version ≥ 15.0 — companion impersonator should advertise this version to minimize protocol surface.

- **D-C8-7 (NEW from Fact #98 closure 2026-05-08, Cand-3-only contingent)** — IF Cand 3 selected → AC-NEW-7 audit-trail posture
  - Options: (a) **explicit FDR audit entry on every UBX impersonation session start, naming companion as the UBX source + providing operator-consent provenance check at boot RECOMMENDED**; (b) silent operation with user-manual disclosure only; (c) require runtime parameter `gps-denied-onboard.enable_ubx_impersonation = true` to be set explicitly by the user via QGC (active opt-in).
  - Owner: Plan-phase architect + AC-NEW-7 owner.
  - Rationale: UBX impersonation is unambiguously a forgery posture (companion pretends to be u-blox receiver); AC-NEW-7 (no covert GPS spoofing without consent) requires an audit trail.

- **D-C8-8 (NEW from Fact #97 + Fact #99 closures 2026-05-08, CROSS-COMPONENT — affects both Cand 1 and Cand 2; CROSS-COMPONENT with C5 covariance contract)** — covariance-honesty cross-FC enforcement
  - Options: (a) project always publishes the SAME covariance value to both FCs (single shared contract, simpler test surface); (b) **per-FC covariance unit conversion: AP `GPS_INPUT.horiz_accuracy` (m) vs iNav `MSP2_SENSOR_GPS.hPosAccuracy` (mm) — companion publishes the same source covariance, formatted per-FC RECOMMENDED**; (c) per-FC covariance smoothing (different filter parameters per FC) — over-engineered and adds covariance-monotonicity-violation risk under C5 D-C5-2 long-cruise observability.
  - Owner: Plan-phase architect + AC-NEW-4 owner.
  - Rationale: AC-NEW-4 covariance-honesty obligation is the same for both FCs; only the unit and field-name change.

### Cross-row dependencies

- **C5 covariance contract integration**: Both Cand 1 (AP) and Cand 2 (iNav) require honest covariance from C5 estimator. The C5 GTSAM `Marginals.marginalCovariance` path (Fact #89) produces a 6×6 pose covariance matrix; the C8 adapter extracts the 2×2 horizontal sub-matrix (rows 3-4 = x, y in GTSAM's `Pose3` ordering) and converts to scalar `horiz_accuracy` (m) for AP or `hPosAccuracy` (mm) for iNav using the 95% confidence ellipse semi-major axis approximation `sqrt(2.0 * 5.991 * λ_max)` where λ_max is the largest eigenvalue of the 2×2 horizontal covariance.
- **AC-NEW-2 spoof-promotion latency cross-FC validation**: SITL test on each FC under spoof-injection — AP path validates `MAV_CMD_SET_EKF_SOURCE_SET` round-trip; iNav path validates companion-internal reaction time (companion is sole GPS, FC does not participate in source switching). Both should hit 95th percentile <3 s.
- **AC-NEW-8 visual-blackout cross-FC behaviour**: AP `fix_type` enum (0/1/2/3/4/5/6) and iNav `gpsFixType_e` enum (`GPS_NO_FIX/GPS_FIX_2D/GPS_FIX_3D/...`) carry the same 0/1/2/3 ordering, simplifying cross-FC graceful-degrade implementation.

---

### Boundary check: C8 batch 1 saturation status

C8 batch 1 (3 of N candidate adapters with explicit per-FC pinning) closed at the documentary level on 2026-05-08:
- **Cand 1 (Fact #97, ArduPilot pymavlink → GPS_INPUT)** — RECOMMENDED PRIMARY. Documentary verification ✅ via context7 + ArduPilot dev docs + cross-cite SQ6 Source #4 ingestion path; mode-pinned send pattern verified; per-AC sub-matrix complete.
- **Cand 2 (Fact #99, iNav MSP2_SENSOR_GPS via Python MSP V2)** — RECOMMENDED PRIMARY for iNav side. Documentary verification ✅ via iNav master MSP message reference + msp_protocol_v2_sensor.h source + community library landscape (YAMSPy + INAV-Toolkit); mode-pinned send pattern verified; per-AC sub-matrix complete.
- **Cand 3 (Fact #98, iNav UBX impersonation via pyubx2 NAV-PVT)** — DEFERRED secondary for iNav side after comparative-improvement verdict. Documentary verification ✅ via pyubx2 context7 + u-blox NAV-PVT canonical specs + iNav `gps_ublox.c` direct source read of validation gate; mode-pinned send pattern verified; per-AC sub-matrix complete.

Saturation rationale: SQ6 closure already covered the per-FC inbound architecture; C8 batch 1 was the operationalization step for the three viable candidates (one per FC for AP; two per FC for iNav since user requested parallel evaluation). No additional candidates surfaced during research that haven't been considered-and-rejected per c8_overkill_options=A (MAVProxy/mavp2p/ardupilot-router are router-class not adapter-class; full MAVSDK C++/Rust SDKs are out-of-budget vs Python pymavlink; no sibling third iNav transport beyond MSP2 + UBX exists in iNav 9.0 master).

C8 batch 1 closure is gated only on the eight Plan-phase decisions D-C8-1..8 and the cross-row C5 covariance contract integration. Plan-phase Choose blocks are recorded in [`../06_component_fit_matrix/99_cross_component_gates.md`](../06_component_fit_matrix/99_cross_component_gates.md).
