mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 04:21:12 +00:00
[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:
@@ -0,0 +1,70 @@
|
||||
# Batch 10 — Cycle 1 Implementation Report
|
||||
|
||||
**Batch**: 10 of N
|
||||
**Tasks landed**: AZ-393 (C8 AP outbound) + AZ-394 (C8 iNav outbound) + AZ-395 (C8 AP MAVLink 2.0 per-flight signing)
|
||||
**Cycle**: 1
|
||||
**Date**: 2026-05-11
|
||||
|
||||
## Scope
|
||||
|
||||
| Task | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| AZ-393 | C8 FC adapter (AP outbound) | `PymavlinkArdupilotAdapter.emit_external_position` body: `GPS_INPUT` encoder via `pymavlink.mav.gps_input_send`; per-frame `NAMED_VALUE_FLOAT(name="src_lbl")` provenance side-channel; transition-only `STATUSTEXT` with 1 Hz per-severity hard cap. Single-writer thread invariant, smoothed-output guard, SPD-violation propagation. |
|
||||
| AZ-394 | C8 FC adapter (iNav outbound) | `Msp2InavAdapter` — hand-encoded `MSP2_SENSOR_GPS` (code `0x1F03`, 52-byte LE payload) over YAMSPy's `send_RAW_msg`; transition-only `STATUSTEXT` on the unsigned secondary MAVLink channel. Signing-key rejection (Invariant 2), source-set-switch unsupported, signing-asymmetry assertion (Invariant 9). |
|
||||
| AZ-395 | C8 FC adapter (AP signing) | Per-flight ephemeral signing key via `secrets.token_bytes(32)` + `setup_signing` handshake; `BUILD_DEV_STATIC_KEY=ON` enables repeatable dev path from `FcConfig.dev_static_signing_key`; mid-flight signing-failure detection (no-raise + ERROR log + WARNING STATUSTEXT); in-place key zeroisation in `bytearray` on `close()`; FDR rotation event with NO key bytes. |
|
||||
| Shared | `_types/pose.py` | Added `EstimatorOutput.smoothed: bool = False` to enable Invariant 6 gate at the C8 boundary; C5 sets to True on smoothed-history output (forward action). |
|
||||
|
||||
## Files added / modified
|
||||
|
||||
### Added (prod)
|
||||
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/_outbound_provenance.py` — canonical `SOURCE_LABEL_TO_FLOAT` table + `source_label_to_float(...)` + `StatusTextTransitionRateLimiter`.
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` — `PymavlinkArdupilotAdapter` (full outbound + signing path).
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/_msp2_sensor_gps_encoder.py` — `MSP2_SENSOR_GPS` wire encoder/decoder (52-byte LE payload).
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py` — `Msp2InavAdapter` (MSP2 primary + unsigned MAVLink secondary).
|
||||
|
||||
### Added (tests)
|
||||
|
||||
- `tests/unit/c8_fc_adapter/test_az393_ardupilot_outbound.py` — 11 AC tests (10 ACs + adapter-fixture).
|
||||
- `tests/unit/c8_fc_adapter/test_az394_inav_outbound.py` — 11 AC tests (10 ACs + iNav config rejection cross-check).
|
||||
- `tests/unit/c8_fc_adapter/test_az395_mavlink_signing.py` — 11 AC tests covering ephemeral keys, FDR no-leak, log no-leak, mid-flight no-raise, zeroisation, BUILD_DEV_STATIC_KEY path, severity mapping, AZ-393 placeholder tightening.
|
||||
|
||||
### Modified
|
||||
|
||||
- `src/gps_denied_onboard/_types/pose.py` — added `EstimatorOutput.smoothed: bool = False` (additive default).
|
||||
- `src/gps_denied_onboard/config/schema.py` — extended `FcConfig` with `dev_static_signing_key: str = ""` + `signing_failure_threshold: int = 3`; allow `"dev_static"` as a third valid `signing_key_source`; cross-field validation `(adapter='inav', signing_key_source!='none') → reject`; `(signing_key_source='dev_static', dev_static_signing_key=='') → reject`; `signing_failure_threshold < 1 → reject`.
|
||||
- `src/gps_denied_onboard/config/loader.py` — wired the two new fields into `ENV_KEY_MAP` (`FC_DEV_STATIC_SIGNING_KEY`, `FC_SIGNING_FAILURE_THRESHOLD`) and `_FIELD_COERCIONS`.
|
||||
|
||||
## Contract changes
|
||||
|
||||
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — **unchanged at v1.0.0**. Surface implemented in this batch was declared in v1.0.0 / AZ-390 (batch 8).
|
||||
- `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — **unchanged at v1.2.0**. The two new `FcConfig` fields are additive defaults; no behavioural change for existing callers.
|
||||
|
||||
## Test counts
|
||||
|
||||
| Metric | Before | After | Delta |
|
||||
|--------|--------|-------|-------|
|
||||
| Tests passing | 443 | 476 | +33 |
|
||||
| Tests skipped | 2 | 2 | 0 |
|
||||
| Tests failing | 0 | 0 | 0 |
|
||||
|
||||
## Architectural notes
|
||||
|
||||
- **Lazy import of pymavlink / yamspy**: both adapter classes import their heavy wire dependencies only at `_connect` / `_connect_msp` time. Tests inject a `connect_factory` / `msp_connect_factory`, so the AC suite runs without the C-extension transports. Production builds load pymavlink (AP) or yamspy (iNav) only when the matching `BUILD_FC_*` flag links the respective adapter into the binary — consistent with ADR-002.
|
||||
- **WGS84 source-of-truth**: both adapters require the composition root to pre-attach the WGS84 fix to `EstimatorOutput.extras["wgs84"]` (a `LatLonAlt`). If the enricher is missing the key, `_extract_wgs84` raises `FcEmitError` rather than guessing — a missing enricher is a composition bug, not an emit-time degraded mode. The `WgsConverter` dependency is still injected so a future refactor can move the enrichment inside the adapter without a public-API change.
|
||||
- **Single-writer thread**: enforced inside `emit_external_position` and `emit_status_text` for both adapters via `_enforce_single_writer`. The first-emit thread becomes the binding for the lifetime of `open(...)`; a second thread raises `RuntimeError`. Combined with the composition-root-level `bind_outbound_emit_thread` from batch 8, this gives two-tier defence-in-depth against the multi-writer race.
|
||||
- **Signing-failure poll**: `_poll_signing_failure_counter` runs once per emit. It reads `connection.mav.signing.sig_count` (provided by pymavlink), compares against `_signing_failure_threshold` (default 3, configurable via `FcConfig.signing_failure_threshold`), and logs + sends WARNING `STATUSTEXT` only on the transition past the threshold (tracked by `_signing_failure_logged_at_count`). It NEVER raises — the FC ignores unsigned messages and AC-5.2 fallback (AZ-388) takes over downstream.
|
||||
- **Key zeroisation**: the signing key is stored as a `bytearray` so we can overwrite it in place inside `close()` via `for i in range(len(buf)): buf[i] = 0`. `pymavlink.setup_signing` receives `bytes(key)` (a copy), so our buffer is the authoritative one we control. AC-7 verifies the buffer is zero post-close by capturing a direct reference to the bytearray before close.
|
||||
- **MSP2 wire format**: `MSP2_SENSOR_GPS` (code `0x1F03`) is iNav-specific and absent from YAMSPy's `MSPCodes` table. We hand-roll the 52-byte LE payload (`struct.pack`/`unpack`) and ship through YAMSPy's lower-level `send_RAW_msg(code, data)`. The unit test exercises the round-trip via `decode_msp2_sensor_gps`. An IT-tier test against real iNav firmware will exercise the on-target decode.
|
||||
|
||||
## Dependencies introduced
|
||||
|
||||
- None. `pymavlink>=2.4` and `yamspy>=0.3.3,<0.4` were already pinned in `pyproject.toml` (batch 9). This batch consumes those dependencies via the adapter shells.
|
||||
|
||||
## Known forward-actions
|
||||
|
||||
1. **AZ-389 (`smoothed` flag at C5 source)** — C5's smoothed-history output (AZ-387) must set `EstimatorOutput.smoothed=True` so the Invariant 6 gate at C8 fires correctly. The default-False field landed in this batch; C5 wiring lands in the C5 batch.
|
||||
2. **`Msp2InavAdapter.current_flight_state()`** currently returns a `FlightState.INIT` default because the iNav inbound decoder (AZ-391) is not yet composed INTO the adapter; the composition root reads `decoder.state_ring` directly. Wiring the inbound decoder INTO the adapter shell (parallel to the AP adapter's `_inbound` field) is the next composition refinement — does not affect the AC surface but tightens the producer/consumer model.
|
||||
3. **`StatusTextTransitionRateLimiter._min_interval_s` (1 s default)** is a constructor default. Promotion to `FcConfig.statustext_transition_min_interval_s` is a forward-action contract bump.
|
||||
4. **Source-set switch (AZ-396)** replaces the `NotImplementedError("Owned by source-set task; install AZ-396 to enable")` raise; AP adapter's `request_source_set_switch` is the integration point.
|
||||
5. **C8-ST-01 SITL signing handshake test** — gated by IT-3 / ADR-008. The wire surface delivered here is the input to that gate.
|
||||
Reference in New Issue
Block a user