mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 12:21:14 +00:00
Decompose Step 6 snapshot: 140 task specs + contract docs
Closes out greenfield Step 6 (Decompose) for all 14 components (C1-C13 + cross-cutting helpers/replay). Covers tasks AZ-266..AZ-446 plus the _dependencies_table.md and component contract documents. State file updated to greenfield Step 7 (Implement), not_started. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
# Contract: `FcAdapter` / `GcsAdapter` Protocols
|
||||
|
||||
**Owner**: c8_fc_adapter (epic AZ-261 / E-C8)
|
||||
**Producer task**: AZ-390 (FcAdapter / GcsAdapter Protocols + DTOs + errors + factories + composition)
|
||||
**Consumer tasks**: AZ-391 (Inbound subscription + telemetry dispatch), AZ-392 (CovarianceProjector), AZ-393 (PymavlinkArdupilotAdapter outbound), AZ-394 (Msp2InavAdapter outbound), AZ-395 (MAVLink 2.0 signing handshake), AZ-396 (Source-set switch), AZ-397 (GcsAdapter + QgcTelemetryAdapter).
|
||||
**Version**: 1.0.0
|
||||
**Status**: draft
|
||||
**Last Updated**: 2026-05-10
|
||||
**Module-layout home**: `src/gps_denied_onboard/components/c8_fc_adapter/interface.py`, `src/gps_denied_onboard/components/c8_fc_adapter/__init__.py`, `src/gps_denied_onboard/runtime_root/fc_factory.py`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the public interfaces for C8: per-FC inbound telemetry subscription + outbound external-position emission, plus the GCS link. Two production `FcAdapter` strategies linked at build time per ADR-002: `PymavlinkArdupilotAdapter` (ArduPilot Plane via MAVLink 2.0 with signing) and `Msp2InavAdapter` (iNav via MSP2, unsigned per RESTRICT-COMM-2). One production `GcsAdapter` strategy: `QgcTelemetryAdapter` (downsampled 1–2 Hz summary to QGroundControl + operator command ingestion). Selected at startup via `config.fc.adapter` and `config.gcs.adapter` with `BUILD_FC_<variant>` / `BUILD_GCS_<variant>` flag gating per ADR-002.
|
||||
|
||||
C8 is the **single source** of FC inbound telemetry — C1 (VIO) and C5 (StateEstimator) receive `ImuWindow` / `AttitudeWindow` / `GpsHealth` / `FlightStateSignal` exclusively via a constructor-injected `FcAdapter`. C8 is also the **single sink** of outbound external-position — C5's `EstimatorOutput` is encoded into the per-FC wire format at 5 Hz with honest 6×6 → 2×2 covariance projection.
|
||||
|
||||
Replay extensions (AZ-265 / E-DEMO-REPLAY) live inside the same component but ship under separate `BUILD_TLOG_REPLAY_ADAPTER` / `BUILD_REPLAY_SINK_JSONL` flags; they implement the same Protocols and are out of scope for E-C8 itself.
|
||||
|
||||
The shared `WgsConverter` (AZ-279), `SE3Utils` (AZ-277), and `FdrClient` (AZ-273) helpers are constructor-injected.
|
||||
|
||||
## Public API
|
||||
|
||||
### Protocol: `FcAdapter`
|
||||
|
||||
```python
|
||||
@runtime_checkable
|
||||
class FcAdapter(Protocol):
|
||||
def open(self, port: PortConfig, signing_key: bytes | None) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
def subscribe_telemetry(
|
||||
self, callback: Callable[[FcTelemetryFrame], None]
|
||||
) -> Subscription: ...
|
||||
def emit_external_position(self, output: EstimatorOutput) -> None: ...
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None: ...
|
||||
def request_source_set_switch(self) -> None: ... # AP-only; iNav raises SourceSetSwitchNotSupportedError
|
||||
def current_flight_state(self) -> FlightStateSignal: ...
|
||||
```
|
||||
|
||||
### Protocol: `GcsAdapter`
|
||||
|
||||
```python
|
||||
@runtime_checkable
|
||||
class GcsAdapter(Protocol):
|
||||
def open(self, port: PortConfig) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
def emit_summary(self, output: EstimatorOutput) -> None: ... # internally rate-limited to 1–2 Hz
|
||||
def subscribe_operator_commands(
|
||||
self, callback: Callable[[OperatorCommand], None]
|
||||
) -> Subscription: ...
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None: ...
|
||||
```
|
||||
|
||||
### DTOs (frozen, slotted)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PortConfig:
|
||||
device: str # e.g. /dev/ttyTHS1
|
||||
baud: int
|
||||
fc_kind: FcKind # enum {ARDUPILOT_PLANE, INAV}
|
||||
|
||||
class FcKind(Enum):
|
||||
ARDUPILOT_PLANE = "ardupilot_plane"
|
||||
INAV = "inav"
|
||||
|
||||
class Severity(Enum):
|
||||
INFO = 6
|
||||
WARNING = 4
|
||||
ERROR = 3 # values mirror MAVLink STATUSTEXT severities
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FcTelemetryFrame:
|
||||
kind: TelemetryKind # enum {IMU_SAMPLE, ATTITUDE, GPS_HEALTH, MAV_STATE}
|
||||
payload: TelemetryPayload # union; see _types/fc.py
|
||||
received_at: int # monotonic_ns
|
||||
signed: bool # true ONLY for AP signed frames
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FlightStateSignal:
|
||||
state: FlightState # enum {INIT, ARMED, IN_FLIGHT, ON_GROUND, FAILED}
|
||||
last_valid_gps_hint_wgs84: LatLonAlt | None # for AC-5.1 warm-start
|
||||
last_valid_gps_age_ms: int | None
|
||||
captured_at: int # monotonic_ns
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GpsHealth:
|
||||
status: GpsStatus # enum {NO_FIX, DEGRADED, STABLE, STABLE_NON_SPOOFED, SPOOFED}
|
||||
fix_age_ms: int
|
||||
captured_at: int
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmittedExternalPosition:
|
||||
fc_kind: FcKind
|
||||
horiz_accuracy_m: float # AP horiz_accuracy / iNav hPosAccuracy (mm internally)
|
||||
source_label: PoseSourceLabel
|
||||
emitted_at: int # monotonic_ns
|
||||
sequence_number: int
|
||||
```
|
||||
|
||||
### Error hierarchy
|
||||
|
||||
```python
|
||||
class FcAdapterError(Exception): ...
|
||||
class FcOpenError(FcAdapterError): ...
|
||||
class FcEmitError(FcAdapterError): ...
|
||||
class SigningHandshakeError(FcAdapterError): ...
|
||||
class SigningKeyExpiredError(FcAdapterError): ...
|
||||
class SourceSetSwitchError(FcAdapterError): ...
|
||||
class SourceSetSwitchNotSupportedError(SourceSetSwitchError): ...
|
||||
class FcAdapterConfigError(FcAdapterError): ...
|
||||
|
||||
class GcsAdapterError(Exception): ...
|
||||
class GcsEmitError(GcsAdapterError): ...
|
||||
class GcsAdapterConfigError(GcsAdapterError): ...
|
||||
```
|
||||
|
||||
### Composition-root factories
|
||||
|
||||
```python
|
||||
def build_fc_adapter(
|
||||
config: AppConfig,
|
||||
wgs_converter: WgsConverter,
|
||||
se3_utils: SE3Utils,
|
||||
covariance_projector: CovarianceProjector,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock,
|
||||
) -> FcAdapter: ...
|
||||
|
||||
def build_gcs_adapter(
|
||||
config: AppConfig,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock,
|
||||
) -> GcsAdapter: ...
|
||||
```
|
||||
|
||||
Selection: `config.fc.adapter ∈ {"ardupilot_plane", "inav"}` → corresponding strategy, gated by `BUILD_FC_ARDUPILOT_PLANE` / `BUILD_FC_INAV`. `config.gcs.adapter ∈ {"qgc_mavlink"}` → `QgcTelemetryAdapter`, gated by `BUILD_GCS_QGC_MAVLINK`. Unknown strategy → `FcAdapterConfigError` / `GcsAdapterConfigError` at config load. Build-flag OFF for the requested strategy → same error class with the disabled-flag name in the message.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **Single open**: `open(...)` MUST be called exactly once per adapter instance. Re-open raises `FcOpenError`. `close()` is idempotent.
|
||||
2. **Signing key required for AP**: `PymavlinkArdupilotAdapter.open(...)` with `signing_key=None` raises `SigningHandshakeError`. `Msp2InavAdapter.open(...)` MUST reject any non-None `signing_key` with `FcAdapterConfigError` (RESTRICT-COMM-2 — iNav has no signing).
|
||||
3. **5 Hz periodic emit**: `emit_external_position` is consumed at exactly 5 Hz by the runtime root's emit timer. The adapter does NOT drive its own timer; it only encodes + writes when called. Internal emission rate-limit lives in the runtime root.
|
||||
4. **Honest covariance projection**: every emitted external-position MUST have `horiz_accuracy_m` derived from the input `EstimatorOutput.covariance_6x6` via the shared `CovarianceProjector` — Frobenius-norm equivalence to the source 3×3 horizontal block within 1% (C8-IT-01). NEVER substitute a constant or downsampled estimate.
|
||||
5. **Source-label propagation**: `EstimatorOutput.source_label` MUST be re-emitted via the per-FC out-of-band channel (AP: `NAMED_VALUE_FLOAT` + STATUSTEXT; iNav: STATUSTEXT only via the MAVLink telemetry side-channel).
|
||||
6. **Smoothed estimates rejected**: `emit_external_position` MUST raise `FcEmitError` if `output.smoothed == True`. The forward-time invariant (AC-4.5 revised) is enforced at the C8 boundary as a defensive backstop on top of C5's filtering.
|
||||
7. **Inbound timestamp monotonicity**: `FcTelemetryFrame.received_at` MUST be monotonically non-decreasing per kind. Out-of-order frames are dropped + logged at WARN.
|
||||
8. **Single-writer thread for outbound**: `emit_external_position`, `emit_status_text`, and `request_source_set_switch` MUST be called from the same thread. Multi-thread write raises `RuntimeError`. Inbound subscribe-callbacks fire on the inbound decode thread; consumers must handle the thread boundary themselves.
|
||||
9. **iNav signing assertion**: the iNav adapter MUST never emit a MAVLink2 frame with the signed-flag set, even on the side-channel telemetry link. Verified by C8-IT-08.
|
||||
10. **Per-flight key zeroisation**: at `close()` (or process exit), the AP signing key buffer MUST be overwritten with zeroes before deallocation. The key MUST never be written to disk. Verified by C8-ST-02.
|
||||
11. **Source-set switch idempotence**: `request_source_set_switch()` is safe to call multiple times in the same flight. Re-entry within 1 s is no-op'd (rate-limited); re-entry after a successful switch logs INFO + sends STATUSTEXT but does not re-issue the command.
|
||||
12. **GcsAdapter downsampling**: `emit_summary` is invoked at 5 Hz by the runtime; the adapter internally downsamples to 1–2 Hz (configurable; default 2 Hz). Downsampling is rate-based (every Nth call), not selection-based.
|
||||
|
||||
## Producer / Consumer Split
|
||||
|
||||
| Task ID | Scope |
|
||||
|---------|-------|
|
||||
| AZ-390 (Producer) | Protocols, DTOs, error hierarchy, factories, composition root extension, `FcKind` / `FlightState` / `GpsStatus` / `Severity` enums, `FcAdapterStub` baseline (test-only no-op accepted by composition). NO concrete production adapter, NO wire encoding. |
|
||||
| AZ-391 (Consumer 1) | Inbound subscription path: MAVLink 2.0 telemetry decoder (RAW_IMU/ATTITUDE/GPS_RAW_INT/MAV_STATE/HEARTBEAT) for AP + MSP2 telemetry decoder for iNav; produces `FcTelemetryFrame` + bounded ring buffers; emits `ImuWindow` / `AttitudeWindow` / `GpsHealth` / `FlightStateSignal` to subscribers. Backpressure + drop-oldest on overflow. |
|
||||
| AZ-392 (Consumer 2) | `CovarianceProjector` helper inside C8: 6×6 → 3×3 position sub-matrix → 2×2 horizontal sub-matrix → equivalent_radius (m for AP, mm for iNav). Honest projection per the AC-4.3 formula. |
|
||||
| AZ-393 (Consumer 3) | `PymavlinkArdupilotAdapter` outbound path: encode `EstimatorOutput` as `GPS_INPUT` (5 Hz); side-channel `NAMED_VALUE_FLOAT` for `source_label` + `STATUSTEXT` mirror; uses the CovarianceProjector. NO signing logic (delivered separately). |
|
||||
| AZ-394 (Consumer 4) | `Msp2InavAdapter` outbound path: encode `EstimatorOutput` as `MSP2_SENSOR_GPS` (5 Hz) via YAMSPy + INAV-Toolkit; STATUSTEXT mirror via the secondary MAVLink telemetry channel. iNav-specific quirks (mm units, sequence numbers). |
|
||||
| AZ-395 (Consumer 5) | MAVLink 2.0 per-flight signing handshake (AP only): generate ephemeral key at `open(...)`, complete pymavlink signing handshake, key rotation logging to FDR, key zeroisation on close. D-C8-9 R03 risk; gated for production by IT-3 SITL pass. |
|
||||
| AZ-396 (Consumer 6) | `MAV_CMD_SET_EKF_SOURCE_SET` D-C8-2 source-set switch (AP only): `request_source_set_switch()` body, ACK handling, `SourceSetSwitchError` on timeout, idempotence per Invariant 11. Wired to C5's spoof-recovery gate via the runtime root. |
|
||||
| AZ-397 (Consumer 7) | `QgcTelemetryAdapter` GcsAdapter: open MAVLink 2.0 channel, downsample 5 Hz → 1–2 Hz `emit_summary`, operator command ingestion (`subscribe_operator_commands`), STATUSTEXT mirror. |
|
||||
|
||||
Tests C8-IT-01..08 + C8-PT-01 + C8-ST-01..02 are deferred to E-BBT (AZ-262) per the project's E-BBT pattern.
|
||||
|
||||
## Constraints
|
||||
|
||||
- `@runtime_checkable` Protocols; DTOs `frozen=True, slots=True`.
|
||||
- Lazy-import per ADR-002.
|
||||
- Public API restricted to `interface.py` + `__init__.py` re-exports per `module-layout.md`.
|
||||
- `pymavlink` is bundled unmodified per D-C8-3.
|
||||
- Signing key MUST never appear in a log line, FDR record, or stderr trace.
|
||||
- The `PortConfig.device` and `signing_key` are constructor-time inputs to `open(...)`; they MUST NOT be re-readable from the adapter post-open (no `get_port_config()` accessor).
|
||||
|
||||
## Risks / Mitigations
|
||||
|
||||
- **R03** (MAVLink 2.0 per-flight signing has no operator-deployed precedent): gated by IT-3 SITL pass before flight-test sign-off.
|
||||
- **R09** (signing key compromise): per-flight ephemeral keys + zeroisation; never persisted; never logged.
|
||||
- Cross-adapter drift (AP vs iNav contract): the shared `CovarianceProjector` + `FcTelemetryFrame` enforce wire-agnostic semantics. Per-FC quirks (mm units, signing) are quarantined to the variant adapter.
|
||||
Reference in New Issue
Block a user