# C8 Inbound subscription — MAVLink + MSP2 telemetry decoders

**Task**: AZ-391_c8_inbound_subscription
**Name**: C8 inbound subscription path — IMU/attitude/GPS-health/MAV_STATE producer
**Description**: Implement the inbound telemetry decode path for both `PymavlinkArdupilotAdapter` and `Msp2InavAdapter`. Decode AP wire frames (`RAW_IMU`/`SCALED_IMU2`, `ATTITUDE`, `GPS_RAW_INT`/`GPS2_RAW`, `HEARTBEAT`, `MAV_STATE` from `HEARTBEAT.system_status`, `STATUSTEXT`) via pymavlink. Decode iNav wire frames (`MSP2_INAV_ANALOG`, attitude+IMU stream) via YAMSPy. Both paths produce a unified `FcTelemetryFrame` stream + maintain bounded telemetry rings (drop-oldest on overflow per § 7) for `ImuWindow` (AZ-276 helper consumer side), `AttitudeWindow`, `GpsHealth`, `FlightStateSignal`. The decode thread is independent from the outbound emit thread (per Invariant 8). `subscribe_telemetry(callback)` returns a `Subscription` handle; multiple subscribers fan out from a single decode loop. Out-of-order timestamp drop + WARN log per Invariant 7. AC-5.1 warm-start: at first GPS_RAW_INT with valid fix, populate `FlightStateSignal.last_valid_gps_hint_wgs84` + `last_valid_gps_age_ms` for the C5 warm-start consumer. AC-5.1 surface deadline ≤ 1 s after C8 ready.
**Complexity**: 5 points
**Dependencies**: AZ-390 (Protocol + DTOs + composition), AZ-263, AZ-269, AZ-266, AZ-272 (FDR), AZ-273 (`FdrClient`), AZ-276 (`ImuPreintegrator` consumer side — this task feeds raw IMU samples)
**Component**: c8_fc_adapter (epic AZ-261 / E-C8)
**Tracker**: AZ-391
**Epic**: AZ-261 (E-C8)

### Document Dependencies

- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — Invariants 1, 7, 8.
- `_docs/02_document/components/10_c8_fc_adapter/description.md` — § 1 inbound, § 4 caching strategy, § 7 race conditions.
- `_docs/02_document/architecture.md` — § 5 External Integrations (per-message rate/auth/failure-mode table).

## Problem

Without this task, C8 has no inbound — C1 (VIO) gets no FC IMU prior, C5 (StateEstimator) gets no FC IMU window or GpsHealth or warm-start hint, and the per-FC ports never decode the wire stream. The Protocol from AZ-390 has `subscribe_telemetry` declared but no body.

## Outcome

- `src/gps_denied_onboard/components/c8_fc_adapter/_inbound_mavlink.py` — AP inbound decoder (pymavlink-based loop, `MAVLink_message_handler`, frame → `FcTelemetryFrame` translation).
- `src/gps_denied_onboard/components/c8_fc_adapter/_inbound_msp2.py` — iNav inbound decoder (YAMSPy + INAV-Toolkit; periodic poll loop since MSP2 is request-response).
- `src/gps_denied_onboard/components/c8_fc_adapter/_telemetry_rings.py` — bounded ring buffers per kind; drop-oldest semantics; thread-safe deque-based.
- `src/gps_denied_onboard/components/c8_fc_adapter/_subscription.py` — `Subscription` handle + multi-subscriber fan-out.
- Body of `subscribe_telemetry` on both `PymavlinkArdupilotAdapter` (AZ-393 produces the class shell — this task fills the inbound body) and `Msp2InavAdapter` (AZ-394 — same) — registers the callback against the multi-subscriber bus.
- Body of `current_flight_state` on both adapters — returns the latest `FlightStateSignal` from the cached ring.
- WARN log on out-of-order frame: `kind="c8.inbound.out_of_order_frame_dropped"` with `{kind, prev_ns, this_ns}`.
- DEBUG log on every decode error: `kind="c8.inbound.decode_error"`.

## Scope

### Included
- AP MAVLink 2.0 inbound decoder (5 message types).
- iNav MSP2 inbound decoder (poll loop + decode).
- Bounded telemetry rings + drop-oldest.
- Multi-subscriber fan-out + `Subscription` handle.
- AC-5.1 warm-start hint surfacing.
- Out-of-order drop + log per Invariant 7.
- Unit tests: AP frame decode, iNav frame decode, ring overflow drops oldest, multi-subscriber fan-out, out-of-order drop logged, warm-start hint surfaces within 1 s of first GPS_RAW_INT.

### Excluded
- Outbound encoding paths — owned by AP / iNav outbound tasks.
- Signing handshake — owned by signing task.
- `CovarianceProjector` — owned by projector task.
- `GcsAdapter` inbound (operator commands) — owned by GCS task.
- C8-IT/PT/ST tests — deferred to E-BBT.

## Acceptance Criteria

**AC-1: AP RAW_IMU decode** — pymavlink `RAW_IMU` frame → `FcTelemetryFrame(kind=IMU_SAMPLE, payload=ImuSample(...))` with timestamp + 6-axis values; AC fails if `received_at` not set to `monotonic_ns()` at decode boundary.

**AC-2: AP ATTITUDE decode** — `ATTITUDE` frame → `FcTelemetryFrame(kind=ATTITUDE, payload=AttitudeSample(...))` with roll/pitch/yaw.

**AC-3: AP GPS_RAW_INT → GpsHealth** — `GPS_RAW_INT.fix_type` mapped to `GpsStatus` per the documented table (NO_FIX/DEGRADED/STABLE); `STABLE_NON_SPOOFED` requires the GPS_RAW_INT.signed_flag (or equivalent) to be set; `SPOOFED` requires the FC's spoofing-detection telemetry (not always present — degraded to STABLE if absent).

**AC-4: AP HEARTBEAT → FlightState** — `HEARTBEAT.system_status` mapped to `FlightState` per the table.

**AC-5: iNav MSP2 decode** — `MSP2_INAV_ANALOG` + attitude/IMU poll responses → matching `FcTelemetryFrame`s with the SAME unified DTO shape as AP. iNav has no spoofing-detection — `GpsStatus.SPOOFED` is unreachable for iNav.

**AC-6: Bounded ring drop-oldest** — push 1000 frames into a 100-capacity ring; assert oldest 900 dropped; ring contains the latest 100; INFO log emitted at first overflow with `kind="c8.inbound.ring_overflow"`.

**AC-7: Multi-subscriber fan-out** — register 3 subscribers; emit one frame; assert all 3 callbacks invoked; cancel one subscription; emit another frame; assert remaining 2 invoked.

**AC-8: AC-5.1 warm-start hint within 1 s** — `current_flight_state()` returns `FlightStateSignal` with `last_valid_gps_hint_wgs84 != None` within 1 s of the first GPS_RAW_INT decode.

**AC-9: Out-of-order drop + WARN** — inject a frame with `received_at` < previous frame of same kind; assert frame dropped + WARN log emitted.

**AC-10: Decode-error isolation** — corrupt frame → DEBUG log + frame dropped; subsequent valid frames still processed (the decoder MUST NOT crash on a single malformed frame).

## Non-Functional Requirements

- Inbound IMU callback p95 ≤ 1 ms (C8-PT-01 budget).
- AP decode loop: 200 Hz IMU sustained without dropping > 1% of frames.
- iNav poll loop: 100 Hz attitude+IMU sustained.

## Constraints

- Single decode thread per adapter; thread-safe ring access.
- pymavlink is bundled unmodified per D-C8-3.
- YAMSPy + INAV-Toolkit at the project's pinned versions.
- The decode thread MUST NOT block on subscriber callbacks longer than 100 µs; slow subscribers must use a non-blocking enqueue + drain on their own thread.

## Risks & Mitigation

- **Risk: pymavlink message-handler performance under 200 Hz IMU** — *Mitigation*: profile early; if marginal, offload decode to a small C extension. Project's pin of pymavlink is known to handle this rate.
- **Risk: iNav poll-rate drift on slow UART** — *Mitigation*: configurable poll period; degrade gracefully (lower rate, log WARN once per minute).
- **Risk: Out-of-order frames silently mask bugs (R05-style)** — *Mitigation*: AC-9 mandates WARN on every drop; aggregate counter + INFO log every 60 s.

## Runtime Completeness

- **Named capability**: C8 inbound telemetry decode + multi-subscriber fan-out.
- **Production code**: real pymavlink decoder, real YAMSPy decoder, real ring buffers, real subscription handles.
- **Allowed external stubs**: SITL fakes for tests; no production stubs.
- **Unacceptable substitutes**: a periodic-fake-IMU generator in production.

## Contract

Implements `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — `subscribe_telemetry`, `current_flight_state`, Invariants 1, 7, 8.
