mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 07:01:14 +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.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Batch 10 — Code Review
|
||||
|
||||
**Batch**: 10 of N
|
||||
**Tasks**: AZ-393 (AP outbound) + AZ-394 (iNav outbound) + AZ-395 (AP MAVLink 2.0 per-flight signing)
|
||||
**Reviewer**: autodev (7-phase)
|
||||
**Verdict**: **PASS_WITH_INFO**
|
||||
**Date**: 2026-05-11
|
||||
|
||||
## Scope
|
||||
|
||||
| Task | Component / Concern | Files touched (prod) | Files touched (tests) |
|
||||
|------|---------------------|----------------------|------------------------|
|
||||
| AZ-393 | C8 AP outbound — `GPS_INPUT` + `NAMED_VALUE_FLOAT(src_lbl)` + transition `STATUSTEXT` | `components/c8_fc_adapter/_outbound_provenance.py`, `components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` | `tests/unit/c8_fc_adapter/test_az393_ardupilot_outbound.py` |
|
||||
| AZ-394 | C8 iNav outbound — `MSP2_SENSOR_GPS` + secondary unsigned MAVLink `STATUSTEXT` | `components/c8_fc_adapter/_msp2_sensor_gps_encoder.py`, `components/c8_fc_adapter/msp2_inav_adapter.py` | `tests/unit/c8_fc_adapter/test_az394_inav_outbound.py` |
|
||||
| AZ-395 | C8 AP per-flight signing — ephemeral key gen, handshake, mid-flight failure no-raise, zeroisation, `BUILD_DEV_STATIC_KEY` dev path | `components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` (extension), `config/schema.py`, `config/loader.py` | `tests/unit/c8_fc_adapter/test_az395_mavlink_signing.py` |
|
||||
| Shared | `EstimatorOutput.smoothed` field (default-False) for Invariant 6 enforcement | `_types/pose.py` | — |
|
||||
|
||||
## Phase 1 — AC compliance
|
||||
|
||||
### AZ-393 — 10 ACs
|
||||
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 GPS_INPUT field fidelity | `test_ac1_gps_input_field_fidelity` — lat/lon/alt + `horiz_accuracy_m` match injected WGS84 + `CovarianceProjector.to_ardupilot_horiz_accuracy_m(...)` to within 1e-3 m. |
|
||||
| AC-2 every-frame emission | `test_ac2_gps_input_every_frame` — 100 frames → 100 `gps_input_send` calls. |
|
||||
| AC-3 NAMED_VALUE_FLOAT every frame | `test_ac3_named_value_float_every_frame` — name=`b"src_lbl"`; value matches `source_label_to_float` mapping for every frame. |
|
||||
| AC-4 STATUSTEXT rate-limited on transition | `test_ac4_statustext_only_on_transition` (10 transitions in 100 frames toggling every 10) + `test_ac4_statustext_zero_within_state` (constant label → 1 bootstrap STATUSTEXT only). |
|
||||
| AC-5 Smoothed output rejected | `test_ac5_smoothed_output_rejected` — `output.smoothed=True` → `FcEmitError`, ERROR log kind=`c8.ap.emit_failed`, zero wire calls. |
|
||||
| AC-6 Non-SPD covariance rejected | `test_ac6_non_spd_covariance_rejected` — propagated `FcEmitError` from `CovarianceProjector`; zero wire calls. |
|
||||
| AC-7 Single-writer thread | `test_ac7_single_writer_thread` — second-thread emit raises `RuntimeError("single-writer ...")`. |
|
||||
| AC-8 Open without signing key (placeholder) | `test_ac8_open_without_signing_key_succeeds` (`signing_key_source="none"`). AZ-395 AC-1 tightens this to reject None on the `ephemeral_per_flight` path. |
|
||||
| AC-9 source-set switch NotImplementedError | `test_ac9_source_set_switch_not_implemented` — message contains `"AZ-396"`. |
|
||||
| AC-10 First emit logged once | `test_ac10_first_emit_logged_once` — 5 emits → exactly 1 `c8.ap.first_emit` INFO record. |
|
||||
|
||||
### AZ-394 — 10 ACs
|
||||
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 MSP2_SENSOR_GPS field fidelity | `test_ac1_msp2_field_fidelity` — wire byte payload round-trips through `decode_msp2_sensor_gps`; lat/lon/alt × 1e7 / × 100 match; `h_pos_accuracy_mm` matches `CovarianceProjector.to_inav_h_pos_accuracy_mm(...)`; code = `0x1F03`. |
|
||||
| AC-2 every frame, monotonic seq | `test_ac2_msp2_every_frame_with_seq` — 100 frames → 100 frames; seq[0]=1, seq[-1]=100 mod 256. |
|
||||
| AC-3 STATUSTEXT secondary, transitions only | `test_ac3_statustext_secondary_only_on_transitions` — 10 transitions → 10 secondary-MAVLink STATUSTEXT; zero on the primary MSP2 channel. |
|
||||
| AC-4 signing-key rejection (Invariant 2) | `test_ac4_signing_key_rejected` — `open(..., signing_key=b"\x00"*32)` → `FcAdapterConfigError("iNav does not support MAVLink signing per RESTRICT-COMM-2")`. |
|
||||
| AC-5 signing-asymmetry (Invariant 9) | `test_ac5_signing_asymmetry_no_signed_flag` — secondary MAVLink stub never receives `setup_signing` and never sets a signed-flag. |
|
||||
| AC-6 source-set-switch unsupported | `test_ac6_source_set_switch_unsupported` → `SourceSetSwitchNotSupportedError("iNav...")`. |
|
||||
| AC-7 smoothed rejected | `test_ac7_smoothed_rejected`. |
|
||||
| AC-8 non-SPD cov rejected | `test_ac8_non_spd_covariance_rejected`. |
|
||||
| AC-9 single-writer thread | `test_ac9_single_writer_thread`. |
|
||||
| AC-10 first emit logged once | `test_ac10_first_emit_logged_once`. |
|
||||
|
||||
Cross-check: `test_inav_config_rejects_signing` asserts `FcConfig.__post_init__` blocks `(adapter='inav', signing_key_source='ephemeral_per_flight')` at config-load time.
|
||||
|
||||
### AZ-395 — 10 ACs
|
||||
|
||||
| AC | Coverage |
|
||||
|----|----------|
|
||||
| AC-1 signing_key=None on ephemeral source → generates internally | `test_ac1_ephemeral_generates_key_when_none_passed` — `setup_signing` called with a 32-byte key. |
|
||||
| AC-2 fresh key per open() | `test_ac2_ephemeral_distinct_per_flight` — two opens produce distinct 32-byte keys (probabilistic; `secrets.token_bytes`). |
|
||||
| AC-3 handshake failure raises | `test_ac3_handshake_failure_raises` — `setup_signing` raises → `SigningHandshakeError` + ERROR log kind=`c8.ap.signing_handshake_failed`. |
|
||||
| AC-4 FDR record has NO key bytes | `test_ac4_handshake_success_fdr_has_no_key_bytes` — scans the rendered FDR payload for the full key hex AND every 4-byte sub-sequence; none present. |
|
||||
| AC-5 key never in any log | `test_ac5_key_never_in_logs` — captures all log records at DEBUG; key hex absent (full + first 4-byte chunk). |
|
||||
| AC-6 mid-flight failure no-raise | `test_ac6_mid_flight_signing_failure_no_raise` — `sig_count=5` → ERROR log + WARNING STATUSTEXT + `emit_external_position` returns successfully. |
|
||||
| AC-7 key zeroisation on close | `test_ac7_key_zeroisation_on_close` — bytearray buffer captured pre-close; post-close all bytes are `0x00`; INFO log kind=`c8.ap.signing_key_zeroised`. |
|
||||
| AC-8 BUILD_DEV_STATIC_KEY repeatability + production block | `test_ac8_dev_static_key_repeatable` (flag ON → two opens use the same static key) + `test_ac8_dev_static_key_blocked_without_build_flag` (flag absent → `FcOpenError("BUILD_DEV_STATIC_KEY")`). |
|
||||
| AC-9 STATUSTEXT severity = WARNING for mid-flight failure | `test_ac9_statustext_severity_on_mid_flight_failure` — `Severity.WARNING.value` emitted on mid-flight failure path. |
|
||||
| AC-10 AZ-393 placeholder tightened | `test_ac10_unknown_signing_source_rejected` — unknown source slipped past validation → `FcOpenError("unknown signing_key_source")`. AZ-393 AC-8 still passes with `signing_key_source="none"`; the `ephemeral_per_flight` path now requires either an internally-generated key or an explicit 32-byte buffer. |
|
||||
|
||||
33 new tests added; 476 total in suite (was 443), 2 pre-existing skips, 0 failures.
|
||||
|
||||
## Phase 2 — Contract drift
|
||||
|
||||
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — **unchanged at v1.0.0**. All three tasks implement the existing surface; no signature changes. The error class additions (`SourceSetSwitchNotSupportedError`, `SigningHandshakeError`, `FcAdapterConfigError`, `FcOpenError`, `FcEmitError`) were already declared in batch 8 (AZ-390); this batch wires them.
|
||||
- `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — **unchanged at v1.2.0**. The new `FcConfig` fields (`dev_static_signing_key`, `signing_failure_threshold`) are additive defaults that do not break existing callers; they are validated under `__post_init__` and only enforced when `signing_key_source="dev_static"` is selected.
|
||||
- `_types/pose.py` `EstimatorOutput.smoothed` — additive default-`False` field. C5 callers that produce smoothed estimates will set this to `True`; existing callers continue to work unmodified.
|
||||
|
||||
## Phase 3 — Architectural compliance
|
||||
|
||||
- **ADR-002 (build-time exclusion)** — the new AP and iNav adapter classes are in `components/c8_fc_adapter/` and registered through the `runtime_root.fc_factory` registry (batch 8). The lazy `from pymavlink import mavutil` / `from yamspy import MSPy` inside the adapter's `_connect` / `_connect_msp` keeps the heavy wire dependencies out of the binary's import graph until the corresponding `BUILD_FC_*` is ON. Tests inject `connect_factory` / `msp_connect_factory` so neither pymavlink nor yamspy is required for the AC tests to run.
|
||||
- **ADR-009 (interface-first DI)** — both adapters accept their deps (`config`, `wgs_converter`, `covariance_projector`, `fdr_client`, optional `clock`, optional factory) as ctor arguments; nothing reaches out to globals. The `SubscriptionBus` and `StatusTextTransitionRateLimiter` follow the same shape — pure objects, no I/O at construction.
|
||||
- **Module layering** — internal helpers prefixed `_` (`_outbound_provenance.py`, `_msp2_sensor_gps_encoder.py`) and not re-exported by `c8_fc_adapter/__init__.__all__`; only the two concrete adapter classes are public surface.
|
||||
- **Single-writer outbound thread (Invariant 8)** — 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 different thread raises `RuntimeError`. The `runtime_root.fc_factory.bind_outbound_emit_thread` (batch 8) provides the composition-root-level enforcement; the per-adapter check is defence-in-depth.
|
||||
- **Two-gate defence-in-depth on signing** — config-load gate (`FcConfig.__post_init__` blocks `inav` + signing) + adapter-open gate (`Msp2InavAdapter.open` rejects `signing_key != None`). Both fire independently; the inav config-rejection cross-check test exercises the first gate.
|
||||
- **Single-source-of-truth for source_label-to-float mapping** — the canonical `SOURCE_LABEL_TO_FLOAT` table lives in `_outbound_provenance.py`; the operator-side decoder in C12 must mirror it. Documented inline.
|
||||
|
||||
## Phase 4 — Performance & reliability
|
||||
|
||||
- **Outbound emit allocation profile**: each `emit_external_position` does one `bytes.encode("utf-8")` for the NAMED_VALUE_FLOAT name (constant), one `struct.pack` for MSP2 (52 bytes), and zero per-emit dynamic dispatch. The `StatusTextTransitionRateLimiter` short-circuits on the same-label path under the lock without invoking `send_statustext`.
|
||||
- **Rate-limiter lock scope**: the lock is held ONLY during the transition + last-emit-time update; the `send_statustext` call is OUTSIDE the lock so a UART-blocked send cannot wedge other senders.
|
||||
- **Signing-failure poll is per-emit, O(1)**: one attribute lookup + one integer compare; only emits an ERROR log on the transition past the threshold (`_signing_failure_logged_at_count`), so a single signing-failure burst does not spam logs.
|
||||
- **Key zeroisation**: explicit `for i in range(len(buf)): buf[i] = 0` on the `bytearray` buffer, in `close()`, before GC-eligible. AC-7 verifies the buffer is zero post-close. The buffer is the SAME object pymavlink received via `bytes(key)` — pymavlink copies into its own buffer at `setup_signing`, and we zeroise ours; the pymavlink-side buffer is owned by the now-closed connection and is GC-eligible.
|
||||
- **No silent error suppression**: every error path emits a DEBUG/INFO/WARN/ERROR record and (where appropriate) an FDR record. The `FdrClient.enqueue` failures are caught + DEBUG-logged but not re-raised (defence-in-depth for the FDR layer, which has its own back-pressure handling).
|
||||
|
||||
## Phase 5 — Test quality
|
||||
|
||||
- **Pymavlink and YAMSPy are stubbed at the connection level** — the tests inject `connect_factory` / `msp_connect_factory`; nothing touches a real serial port. The MSP2 wire round-trip is verified by `decode_msp2_sensor_gps` on the bytes the stub captured, not by yamspy's transport.
|
||||
- **AC-4 (AP) loosens the 1 s rate-limiter cap explicitly** (`adapter._provenance._min_interval_s = 0.0`). The cap remains active in production; the test reaches into the private to verify the transition behaviour without coupling to wall-clock time.
|
||||
- **AC-7 (AZ-395) captures the bytearray buffer before `close()`** — bytearrays are mutable, so the same object is observable post-close. Zero-byte check uses `all(b == 0 for b in key_buf)`.
|
||||
- **AC-4 / AC-5 (AZ-395) regex / hex scan** — both whole-key hex and rolling 4-byte sub-sequence checks. A regression that logged the key would fail.
|
||||
- **Arrange / Act / Assert pattern** consistently applied; comments restricted to AC headers + safety invariants. No narrative comments in test bodies.
|
||||
|
||||
## Phase 6 — Logging & FDR coverage
|
||||
|
||||
- **`PymavlinkArdupilotAdapter` log kinds**: `c8.ap.first_emit` (INFO, once), `c8.ap.emit` (DEBUG, per emit), `c8.ap.emit_failed` (ERROR), `c8.ap.signing_handshake_failed` (ERROR), `c8.ap.signing_failure` (ERROR), `c8.ap.signing_key_zeroised` (INFO), `c8.ap.signing_dev_static_key` (WARN), `c8.ap.statustext_failed` (DEBUG), `c8.ap.fdr_enqueue_failed` (DEBUG).
|
||||
- **`Msp2InavAdapter` log kinds**: `c8.inav.first_emit` (INFO), `c8.inav.emit` (DEBUG), `c8.inav.emit_failed` (ERROR), `c8.inav.secondary_mavlink_open_failed` (WARN), `c8.inav.secondary_statustext_failed` (DEBUG).
|
||||
- **FDR record kinds**: `c8.ap.signing_key_rotated` (INFO; once at open), `c8.ap.signing_handshake_failed` (ERROR), `c8.ap.signing_failure` (ERROR; per-emit when threshold crossed).
|
||||
- **Zero key bytes in logs/FDR** — explicitly tested by AC-4 (FDR) and AC-5 (logs) of AZ-395.
|
||||
|
||||
## Phase 7 — Security & risk surface
|
||||
|
||||
- **R03 (signing on operator-deployed channel)** — addressed by AZ-395's per-flight ephemeral keys + zeroisation + STATUSTEXT escalation on mid-flight failure. IT-3 SITL gate (ADR-008) is still the production-promotion gate; this batch delivers the surface for that gate.
|
||||
- **R09 (key compromise)** — ephemeral per-flight key (`secrets.token_bytes(32)`) + in-place zeroisation on `close()` + key never written to disk, never logged, never serialised. AC-4 / AC-5 verify the no-leak invariant by hex scan.
|
||||
- **`BUILD_DEV_STATIC_KEY` is OFF by default and rejected at runtime when the source is `dev_static` without the build flag** — `test_ac8_dev_static_key_blocked_without_build_flag` enforces this. The dev path is intentionally restricted to repeatable test environments.
|
||||
- **`FcConfig.__post_init__` enforces three cross-field constraints**: (1) `inav` + non-`none` signing → rejected (Invariant 2); (2) `dev_static` source requires non-empty `dev_static_signing_key`; (3) `signing_failure_threshold` ≥ 1. All three have test coverage.
|
||||
- **iNav signing asymmetry (Invariant 9)** — the iNav adapter NEVER calls `setup_signing` on the secondary MAVLink channel (AC-5 verified). The secondary channel is intentionally unsigned per RESTRICT-COMM-2.
|
||||
- **`EstimatorOutput.smoothed` Invariant 6 gate** — both adapters check `output.smoothed` BEFORE any wire emit; rejection produces ERROR log + zero wire bytes. The CovarianceProjector SPD gate runs before WGS84 extraction so a bad-cov frame never leaks even partial state to the bus.
|
||||
|
||||
## Informational findings (non-blocking)
|
||||
|
||||
1. **Signing-failure ERROR log fires on the EMIT after threshold crossing**, not on the failure event itself. This means a flight that produces no further emits after a counter spike would NOT log the threshold cross. In practice, the AP adapter emits at 5 Hz, so the latency is sub-second. Promotion to a dedicated `signing_failure_count` poll thread is a forward action and would require its own thread-safety review.
|
||||
2. **`STATUSTEXT` transition rate-limiter's 1 s per-severity cap** is a hard-coded constructor default. Promotion to `FcConfig.statustext_transition_min_interval_s` is a forward-action contract bump if operator feedback indicates spam at 1 Hz under pathological label-flapping.
|
||||
3. **AZ-394 secondary MAVLink channel does not check signed-flag explicitly** — we assume the constructor of the secondary connection uses `mavlink_version=2.0` without `setup_signing(...)`. The `_SecondaryMavStub` test verifies `setup_signing_calls == []`, but a production wiring that erroneously calls `setup_signing` on the secondary stream would not be caught by unit tests. An IT-tier wire-bytes check (AC-5 of AZ-394's spec — "no MAVLink2 signed-flag") is a forward action.
|
||||
4. **`EstimatorOutput.smoothed` defaults to `False`** — existing C5 callers continue working. C5 must set this to `True` on its smoothed-history output (AZ-387 follow-up); the gate at the C8 boundary is now in place.
|
||||
5. **`current_flight_state()` on `Msp2InavAdapter`** currently returns a default `FlightState.INIT` signal because the inbound iNav decoder (AZ-391) is not wired into this adapter shell — the composition root reads `decoder.state_ring` directly. Wiring the decoder INTO the adapter (so a single `subscribe_telemetry` call serves both producer-side and state-query needs) is the next composition refinement.
|
||||
|
||||
## Verdict
|
||||
|
||||
PASS_WITH_INFO — all 30 ACs (10 + 10 + 10) satisfied; 33 new tests added (476 total, 0 failures); contract surface unchanged at v1.0.0 / composition_root v1.2.0; the five informational findings are forward-action enhancements that do not block the three tasks landing.
|
||||
@@ -8,7 +8,7 @@ status: in_progress
|
||||
sub_step:
|
||||
phase: 6
|
||||
name: implement-tasks
|
||||
detail: "batch 9 of N committed (AZ-391 c8 inbound: MAVLink + MSP2 decoders + rings + bus + warm-start hint)"
|
||||
detail: "batch 10 of N committed (AZ-393 ap outbound + AZ-394 inav outbound + AZ-395 ap mavlink signing)"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
|
||||
@@ -21,7 +21,14 @@ class PoseEstimate:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EstimatorOutput:
|
||||
"""C5 state-estimator output (smoothed pose + uncertainty + source label + health)."""
|
||||
"""C5 state-estimator output (smoothed pose + uncertainty + source label + health).
|
||||
|
||||
``smoothed=True`` indicates the value is post-smoothing (C5's
|
||||
look-back rewrite). Invariant 6 forbids emitting smoothed
|
||||
estimates to the FC — only the real-time (causal) estimate is
|
||||
valid for FC consumption. C8 outbound adapters MUST raise
|
||||
:class:`FcEmitError` on ``smoothed=True``.
|
||||
"""
|
||||
|
||||
frame_id: int
|
||||
timestamp: datetime
|
||||
@@ -29,6 +36,7 @@ class EstimatorOutput:
|
||||
covariance_6x6: Any | None = None
|
||||
source_label: str = "visual_propagated"
|
||||
health: EstimatorHealth | None = None
|
||||
smoothed: bool = False
|
||||
extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""MSP2_SENSOR_GPS wire-format encoder/decoder (AZ-394 / E-C8).
|
||||
|
||||
iNav-specific message ``MSP2_SENSOR_GPS = 0x1F03`` — host-to-FC
|
||||
external GPS injection. YAMSPy's MSP code table is BetaFlight-derived
|
||||
and does NOT include this iNav-only code, so we encode the payload by
|
||||
hand and ship it through the transport's ``send_RAW_msg(code, data)``
|
||||
entry point.
|
||||
|
||||
Wire payload (little-endian, packed; iNav source of truth:
|
||||
``inav/src/main/io/msp.c`` — search ``MSP2_SENSOR_GPS``):
|
||||
|
||||
| Offset | Field | Type | Notes |
|
||||
|--------|-----------------------|--------|------------------------------------|
|
||||
| 0 | instance | u8 | 0 (single external GPS instance) |
|
||||
| 1 | gps_week | u16 | 0 (unused for external injection) |
|
||||
| 3 | ms_tow | u32 | 0 (unused) |
|
||||
| 7 | fix_type | u8 | 3 (3D fix); contract Invariant 5 |
|
||||
| 8 | satellites_visible | u8 | cosmetic; we send 10 |
|
||||
| 9 | h_pos_accuracy_mm | u16 | from CovarianceProjector (clamped) |
|
||||
| 11 | v_pos_accuracy_mm | u16 | 0 (we don't project vertical) |
|
||||
| 13 | h_vel_accuracy_mm_s | u16 | 0 |
|
||||
| 15 | hdop | u16 | 0 |
|
||||
| 17 | longitude_e7 | i32 | wgs84 lon * 1e7 |
|
||||
| 21 | latitude_e7 | i32 | wgs84 lat * 1e7 |
|
||||
| 25 | msl_altitude_cm | i32 | wgs84 alt * 100 |
|
||||
| 29 | ned_vel_north_cm_s | i32 | 0 |
|
||||
| 33 | ned_vel_east_cm_s | i32 | 0 |
|
||||
| 37 | ned_vel_down_cm_s | i32 | 0 |
|
||||
| 41 | ground_course_cdeg | u16 | 0 |
|
||||
| 43 | true_yaw_cdeg | u16 | 0 |
|
||||
| 45 | year | u16 | 0 |
|
||||
| 47 | month | u8 | 0 |
|
||||
| 48 | day | u8 | 0 |
|
||||
| 49 | hour | u8 | 0 |
|
||||
| 50 | min | u8 | 0 |
|
||||
| 51 | sec | u8 | 0 |
|
||||
| 52 | (end) | | total length = 52 bytes |
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
__all__ = [
|
||||
"MSP2_SENSOR_GPS_CODE",
|
||||
"MSP2_SENSOR_GPS_PAYLOAD_LEN",
|
||||
"Msp2SensorGpsPayload",
|
||||
"decode_msp2_sensor_gps",
|
||||
"encode_msp2_sensor_gps",
|
||||
]
|
||||
|
||||
|
||||
MSP2_SENSOR_GPS_CODE: Final[int] = 0x1F03
|
||||
MSP2_SENSOR_GPS_PAYLOAD_LEN: Final[int] = 52
|
||||
|
||||
# little-endian, no padding; matches inav's __packed__ struct layout.
|
||||
_PACK_FMT: Final[str] = "<BHIBBHHHHiiiiiiHHHBBBBB"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Msp2SensorGpsPayload:
|
||||
"""Decoded MSP2_SENSOR_GPS payload (subset we exercise)."""
|
||||
|
||||
instance: int
|
||||
fix_type: int
|
||||
satellites_visible: int
|
||||
h_pos_accuracy_mm: int
|
||||
longitude_e7: int
|
||||
latitude_e7: int
|
||||
msl_altitude_cm: int
|
||||
|
||||
|
||||
def encode_msp2_sensor_gps(
|
||||
*,
|
||||
fix_type: int,
|
||||
satellites_visible: int,
|
||||
h_pos_accuracy_mm: int,
|
||||
longitude_e7: int,
|
||||
latitude_e7: int,
|
||||
msl_altitude_cm: int,
|
||||
ned_vel_north_cm_s: int = 0,
|
||||
ned_vel_east_cm_s: int = 0,
|
||||
ned_vel_down_cm_s: int = 0,
|
||||
) -> bytes:
|
||||
"""Encode an MSP2_SENSOR_GPS payload (52 bytes)."""
|
||||
return struct.pack(
|
||||
_PACK_FMT,
|
||||
0, # instance
|
||||
0, # gps_week
|
||||
0, # ms_tow
|
||||
int(fix_type),
|
||||
int(satellites_visible),
|
||||
int(h_pos_accuracy_mm) & 0xFFFF,
|
||||
0, # v_pos_accuracy_mm
|
||||
0, # h_vel_accuracy_mm_s
|
||||
0, # hdop
|
||||
int(longitude_e7),
|
||||
int(latitude_e7),
|
||||
int(msl_altitude_cm),
|
||||
int(ned_vel_north_cm_s),
|
||||
int(ned_vel_east_cm_s),
|
||||
int(ned_vel_down_cm_s),
|
||||
0, # ground_course_cdeg
|
||||
0, # true_yaw_cdeg
|
||||
0, # year
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, # month/day/hour/min/sec
|
||||
)
|
||||
|
||||
|
||||
def decode_msp2_sensor_gps(buf: bytes) -> Msp2SensorGpsPayload:
|
||||
"""Decode the subset of MSP2_SENSOR_GPS we exercise."""
|
||||
if len(buf) != MSP2_SENSOR_GPS_PAYLOAD_LEN:
|
||||
raise ValueError(
|
||||
f"MSP2_SENSOR_GPS expects {MSP2_SENSOR_GPS_PAYLOAD_LEN} bytes; got {len(buf)}"
|
||||
)
|
||||
unpacked = struct.unpack(_PACK_FMT, buf)
|
||||
(
|
||||
instance,
|
||||
_gps_week,
|
||||
_ms_tow,
|
||||
fix_type,
|
||||
satellites_visible,
|
||||
h_pos_accuracy_mm,
|
||||
_v_pos_acc,
|
||||
_h_vel_acc,
|
||||
_hdop,
|
||||
longitude_e7,
|
||||
latitude_e7,
|
||||
msl_altitude_cm,
|
||||
_vn,
|
||||
_ve,
|
||||
_vd,
|
||||
_gc,
|
||||
_yaw,
|
||||
_year,
|
||||
_mo,
|
||||
_d,
|
||||
_h,
|
||||
_mn,
|
||||
_s,
|
||||
) = unpacked
|
||||
return Msp2SensorGpsPayload(
|
||||
instance=instance,
|
||||
fix_type=fix_type,
|
||||
satellites_visible=satellites_visible,
|
||||
h_pos_accuracy_mm=h_pos_accuracy_mm,
|
||||
longitude_e7=longitude_e7,
|
||||
latitude_e7=latitude_e7,
|
||||
msl_altitude_cm=msl_altitude_cm,
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Outbound provenance side-channel helpers (AZ-393 / AZ-394 / E-C8).
|
||||
|
||||
Two pieces shared by AP and iNav outbound paths:
|
||||
|
||||
1. :func:`source_label_to_float` — deterministic ``source_label`` →
|
||||
``float`` mapping consumed by AP's ``NAMED_VALUE_FLOAT(name="src_lbl")``
|
||||
side-channel. The OPERATOR-side decoder (E-C12) MUST use the SAME
|
||||
mapping; the canonical table lives here.
|
||||
|
||||
2. :class:`StatusTextTransitionRateLimiter` — emits ``STATUSTEXT(...)``
|
||||
exactly once per ``source_label`` transition (AC-4 / AZ-393 AC-3 /
|
||||
AZ-394 AC-3), with a defensive 1-per-second per-severity hard cap
|
||||
against pathological transition spam (constraint § 4 of AZ-393).
|
||||
|
||||
The rate limiter has no I/O; the adapter passes ``send_statustext``
|
||||
in. This keeps the helper pure for unit testing and matches the
|
||||
per-adapter wire dispatch responsibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
from gps_denied_onboard._types.fc import Severity
|
||||
|
||||
__all__ = [
|
||||
"SOURCE_LABEL_TO_FLOAT",
|
||||
"StatusTextTransitionRateLimiter",
|
||||
"source_label_to_float",
|
||||
]
|
||||
|
||||
|
||||
# Canonical source-label-to-float mapping (AZ-393 AC-3 / D-C8-7).
|
||||
# Operator-side decoder in C12 MUST mirror this table.
|
||||
SOURCE_LABEL_TO_FLOAT: Final[dict[str, float]] = {
|
||||
"unknown": 0.0,
|
||||
"visual_propagated": 1.0,
|
||||
"sat_anchored": 2.0,
|
||||
"imu_only": 3.0,
|
||||
"warm_start": 4.0,
|
||||
"smoothed": 5.0,
|
||||
"ac52_fallback": 6.0,
|
||||
}
|
||||
|
||||
|
||||
def source_label_to_float(label: str) -> float:
|
||||
"""Return the canonical float encoding for ``label``; unknowns map to 0.0."""
|
||||
return SOURCE_LABEL_TO_FLOAT.get(label, SOURCE_LABEL_TO_FLOAT["unknown"])
|
||||
|
||||
|
||||
class StatusTextTransitionRateLimiter:
|
||||
"""Emits a STATUSTEXT exactly once per ``source_label`` transition.
|
||||
|
||||
Defensive secondary cap: at most one emit per second per severity,
|
||||
in case a producer flaps source_label every frame at 5 Hz.
|
||||
|
||||
Thread-safe; ``note_label_and_maybe_emit`` may be called from any
|
||||
thread. The send callback is invoked OUTSIDE the lock.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
send_statustext: Callable[[str, Severity], None],
|
||||
*,
|
||||
min_interval_s: float = 1.0,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._send = send_statustext
|
||||
self._min_interval_s = min_interval_s
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._last_label: str | None = None
|
||||
self._last_emit_at_by_sev: dict[Severity, float] = {}
|
||||
|
||||
def note_label_and_maybe_emit(
|
||||
self,
|
||||
new_label: str,
|
||||
*,
|
||||
severity: Severity = Severity.INFO,
|
||||
) -> bool:
|
||||
"""Record ``new_label`` and emit a STATUSTEXT iff this is a transition.
|
||||
|
||||
Returns ``True`` if a STATUSTEXT was emitted, ``False`` otherwise.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._last_label == new_label:
|
||||
return False
|
||||
previous = self._last_label
|
||||
self._last_label = new_label
|
||||
# Hard cap: at most one emit per second per severity.
|
||||
now = self._clock()
|
||||
last_emit = self._last_emit_at_by_sev.get(severity, float("-inf"))
|
||||
if (now - last_emit) < self._min_interval_s:
|
||||
return False
|
||||
self._last_emit_at_by_sev[severity] = now
|
||||
msg = f"src={new_label}" if previous is None else f"src {previous}->{new_label}"
|
||||
# Send OUTSIDE the lock — pymavlink statustext_send may block on UART.
|
||||
self._send(msg, severity)
|
||||
return True
|
||||
|
||||
@property
|
||||
def last_label(self) -> str | None:
|
||||
with self._lock:
|
||||
return self._last_label
|
||||
@@ -0,0 +1,283 @@
|
||||
"""iNav MSP2 + secondary-MAVLink adapter (AZ-394 / E-C8).
|
||||
|
||||
Outbound: encodes ``EstimatorOutput`` to MSP2_SENSOR_GPS and writes
|
||||
through a YAMSPy-style transport (``send_RAW_msg(code, data)``).
|
||||
Side-channel: STATUSTEXT on the SECONDARY MAVLink telemetry link
|
||||
(NOT on the primary MSP2 link) — iNav supports MAVLink for telemetry
|
||||
output but not for primary positioning per RESTRICT-COMM-2. The
|
||||
secondary link is ALWAYS unsigned (Invariant 9).
|
||||
|
||||
Build flag: ``BUILD_FC_INAV``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Final
|
||||
|
||||
from gps_denied_onboard._types.emitted import EmittedExternalPosition
|
||||
from gps_denied_onboard._types.fc import (
|
||||
FcKind,
|
||||
FlightState,
|
||||
FlightStateSignal,
|
||||
PortConfig,
|
||||
Severity,
|
||||
Subscription,
|
||||
TelemetryCallback,
|
||||
)
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._msp2_sensor_gps_encoder import (
|
||||
MSP2_SENSOR_GPS_CODE,
|
||||
encode_msp2_sensor_gps,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
||||
StatusTextTransitionRateLimiter,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._subscription import SubscriptionBus
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
FcEmitError,
|
||||
FcOpenError,
|
||||
SourceSetSwitchNotSupportedError,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
__all__ = ["Msp2InavAdapter"]
|
||||
|
||||
|
||||
_FIX_TYPE_3D: Final[int] = 3
|
||||
_INAV_DEFAULT_SATS: Final[int] = 10
|
||||
|
||||
|
||||
def _mav_severity(sev: Severity) -> int:
|
||||
return int(sev.value)
|
||||
|
||||
|
||||
class Msp2InavAdapter:
|
||||
"""iNav FcAdapter (MSP2 primary + unsigned MAVLink secondary)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: Config,
|
||||
wgs_converter: Any,
|
||||
covariance_projector: CovarianceProjector,
|
||||
fdr_client: FdrClient,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
msp_connect_factory: Callable[[str, int], Any] | None = None,
|
||||
secondary_mavlink_factory: Callable[[], Any] | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._wgs_converter = wgs_converter
|
||||
self._cov_projector = covariance_projector
|
||||
self._fdr_client = fdr_client
|
||||
self._clock = clock
|
||||
self._msp_connect_factory = msp_connect_factory
|
||||
self._secondary_mavlink_factory = secondary_mavlink_factory
|
||||
self._log = get_logger("c8_fc_adapter.inav_adapter")
|
||||
# Wire state ------------------------------------------------------
|
||||
self._msp: Any = None
|
||||
self._secondary_mav: Any = None
|
||||
self._opened = False
|
||||
self._sequence_number = 0
|
||||
self._first_emit_logged = False
|
||||
self._open_emit_thread_ident: int | None = None
|
||||
# Inbound bus stub — AZ-394 doesn't wire the inbound (iNav
|
||||
# polling decoder lands in AZ-391; the per-adapter inbound
|
||||
# composition happens in a follow-up batch).
|
||||
self._bus = SubscriptionBus()
|
||||
# Provenance rate-limiter for the secondary MAVLink STATUSTEXT.
|
||||
self._provenance = StatusTextTransitionRateLimiter(
|
||||
send_statustext=self._send_statustext_secondary,
|
||||
clock=time.monotonic,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FcAdapter Protocol implementation
|
||||
|
||||
def open(self, port: PortConfig, signing_key: bytes | None) -> None:
|
||||
if self._opened:
|
||||
raise FcOpenError("Msp2InavAdapter already opened")
|
||||
if port.fc_kind is not FcKind.INAV:
|
||||
raise FcOpenError(f"Msp2InavAdapter requires FcKind.INAV; got {port.fc_kind!r}")
|
||||
if signing_key is not None:
|
||||
# Invariant 2 / RESTRICT-COMM-2 — iNav has no signing.
|
||||
raise FcAdapterConfigError("iNav does not support MAVLink signing per RESTRICT-COMM-2")
|
||||
try:
|
||||
self._msp = self._connect_msp(port)
|
||||
except Exception as exc:
|
||||
raise FcOpenError(f"MSP2 connect failed: {exc!r}") from exc
|
||||
try:
|
||||
self._secondary_mav = (
|
||||
self._secondary_mavlink_factory()
|
||||
if self._secondary_mavlink_factory is not None
|
||||
else None
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log.warning(
|
||||
f"c8.inav.secondary_mavlink_open_failed: {exc!r}",
|
||||
extra={
|
||||
"kind": "c8.inav.secondary_mavlink_open_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
self._secondary_mav = None
|
||||
self._opened = True
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._opened:
|
||||
return
|
||||
try:
|
||||
for conn in (self._msp, self._secondary_mav):
|
||||
if conn is not None and hasattr(conn, "close"):
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._opened = False
|
||||
self._msp = None
|
||||
self._secondary_mav = None
|
||||
self._open_emit_thread_ident = None
|
||||
self._first_emit_logged = False
|
||||
|
||||
def subscribe_telemetry(self, callback: TelemetryCallback) -> Subscription:
|
||||
return self._bus.subscribe(callback)
|
||||
|
||||
def emit_external_position(self, output: EstimatorOutput) -> EmittedExternalPosition:
|
||||
if not self._opened or self._msp is None:
|
||||
raise FcEmitError("adapter not opened")
|
||||
self._enforce_single_writer()
|
||||
if output.smoothed:
|
||||
self._log_emit_failed("smoothed_rejected", output.frame_id)
|
||||
raise FcEmitError("smoothed output cannot be emitted to FC (Invariant 6)")
|
||||
h_pos_accuracy_mm = self._cov_projector.to_inav_h_pos_accuracy_mm(output)
|
||||
wgs = self._extract_wgs84(output)
|
||||
emitted_at = time.monotonic_ns()
|
||||
self._sequence_number = (self._sequence_number + 1) & 0xFF
|
||||
seq = self._sequence_number
|
||||
payload = encode_msp2_sensor_gps(
|
||||
fix_type=_FIX_TYPE_3D,
|
||||
satellites_visible=_INAV_DEFAULT_SATS,
|
||||
h_pos_accuracy_mm=h_pos_accuracy_mm,
|
||||
longitude_e7=int(wgs.lon_deg * 1e7),
|
||||
latitude_e7=int(wgs.lat_deg * 1e7),
|
||||
msl_altitude_cm=int(wgs.alt_m * 100.0),
|
||||
)
|
||||
try:
|
||||
self._msp.send_RAW_msg(MSP2_SENSOR_GPS_CODE, payload)
|
||||
except Exception as exc:
|
||||
self._log_emit_failed(repr(exc), output.frame_id)
|
||||
raise FcEmitError(f"iNav MSP2 outbound wire emit failed: {exc!r}") from exc
|
||||
self._provenance.note_label_and_maybe_emit(output.source_label, severity=Severity.INFO)
|
||||
if not self._first_emit_logged:
|
||||
self._first_emit_logged = True
|
||||
self._log.info(
|
||||
"c8.inav.first_emit",
|
||||
extra={
|
||||
"kind": "c8.inav.first_emit",
|
||||
"kv": {"frame_id": output.frame_id, "seq": seq},
|
||||
},
|
||||
)
|
||||
self._log.debug(
|
||||
"c8.inav.emit",
|
||||
extra={
|
||||
"kind": "c8.inav.emit",
|
||||
"kv": {
|
||||
"frame_seq": seq,
|
||||
"h_pos_accuracy_mm": h_pos_accuracy_mm,
|
||||
"source_label": output.source_label,
|
||||
},
|
||||
},
|
||||
)
|
||||
return EmittedExternalPosition(
|
||||
fc_kind=FcKind.INAV,
|
||||
horiz_accuracy_m=h_pos_accuracy_mm / 1000.0,
|
||||
source_label=output.source_label,
|
||||
emitted_at=emitted_at,
|
||||
sequence_number=seq,
|
||||
)
|
||||
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None:
|
||||
if not self._opened:
|
||||
raise FcEmitError("adapter not opened")
|
||||
self._enforce_single_writer()
|
||||
self._send_statustext_secondary(msg, severity)
|
||||
|
||||
def request_source_set_switch(self) -> None:
|
||||
raise SourceSetSwitchNotSupportedError("iNav: no MAV_CMD_SET_EKF_SOURCE_SET equivalent")
|
||||
|
||||
def current_flight_state(self) -> FlightStateSignal:
|
||||
# Inbound iNav state ring is not wired into this adapter shell;
|
||||
# the inbound decoder (AZ-391) exposes its own state_ring that
|
||||
# the composition root can read directly. Default to INIT here.
|
||||
return FlightStateSignal(
|
||||
state=FlightState.INIT,
|
||||
last_valid_gps_hint_wgs84=None,
|
||||
last_valid_gps_age_ms=None,
|
||||
captured_at=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
|
||||
def _enforce_single_writer(self) -> None:
|
||||
cur = threading.get_ident()
|
||||
if self._open_emit_thread_ident is None:
|
||||
self._open_emit_thread_ident = cur
|
||||
return
|
||||
if self._open_emit_thread_ident != cur:
|
||||
raise RuntimeError(
|
||||
"Msp2InavAdapter outbound is single-writer; "
|
||||
f"first thread={self._open_emit_thread_ident}, this thread={cur}"
|
||||
)
|
||||
|
||||
def _connect_msp(self, port: PortConfig) -> Any:
|
||||
if self._msp_connect_factory is not None:
|
||||
return self._msp_connect_factory(port.device, port.baud)
|
||||
# Lazy import so the test path with an injected factory does NOT
|
||||
# require yamspy at module-import time.
|
||||
from yamspy import MSPy
|
||||
|
||||
return MSPy(device=port.device, baudrate=port.baud)
|
||||
|
||||
def _send_statustext_secondary(self, msg: str, severity: Severity) -> None:
|
||||
if self._secondary_mav is None:
|
||||
return
|
||||
text = msg.encode("utf-8")[:50]
|
||||
try:
|
||||
self._secondary_mav.mav.statustext_send(_mav_severity(severity), text)
|
||||
except Exception as exc:
|
||||
self._log.debug(
|
||||
f"c8.inav.secondary_statustext_failed: {exc!r}",
|
||||
extra={
|
||||
"kind": "c8.inav.secondary_statustext_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
|
||||
def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
|
||||
wgs = output.extras.get("wgs84") if output.extras else None
|
||||
if not isinstance(wgs, LatLonAlt):
|
||||
raise FcEmitError(
|
||||
"EstimatorOutput.extras['wgs84'] missing or not a LatLonAlt; "
|
||||
"composition root must inject the ENU->WGS84 enricher"
|
||||
)
|
||||
return wgs
|
||||
|
||||
def _log_emit_failed(self, reason: str, frame_id: int) -> None:
|
||||
self._log.error(
|
||||
f"c8.inav.emit_failed: {reason}",
|
||||
extra={
|
||||
"kind": "c8.inav.emit_failed",
|
||||
"kv": {"reason": reason, "frame_id": frame_id},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,499 @@
|
||||
"""ArduPilot MAVLink 2.0 adapter (AZ-393 outbound, AZ-395 signing, E-C8).
|
||||
|
||||
Composes :class:`PymavlinkInboundDecoder` (AZ-391) for the inbound
|
||||
producer side and the local outbound encoder for `emit_external_position` +
|
||||
`emit_status_text`. Implements `FcAdapter` per the contract.
|
||||
|
||||
Production wiring (composition-root, AZ-393 / AZ-395 follow-up):
|
||||
- Constructor receives the WgsConverter (AZ-279), CovarianceProjector
|
||||
(AZ-392), FdrClient (AZ-273), and a clock callable.
|
||||
- `open(port, signing_key)` opens the pymavlink connection on the
|
||||
configured port and, if signing is enabled in config, completes
|
||||
the per-flight signing handshake (AZ-395).
|
||||
- A single composition-root thread is bound for outbound emit via
|
||||
:func:`bind_outbound_emit_thread`; emitting from a second thread
|
||||
raises :class:`RuntimeError`.
|
||||
|
||||
Build flag: ``BUILD_FC_ARDUPILOT_PLANE``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Final
|
||||
|
||||
from gps_denied_onboard._types.emitted import EmittedExternalPosition
|
||||
from gps_denied_onboard._types.fc import (
|
||||
FcKind,
|
||||
FlightState,
|
||||
FlightStateSignal,
|
||||
PortConfig,
|
||||
Severity,
|
||||
Subscription,
|
||||
TelemetryCallback,
|
||||
)
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._inbound_mavlink import (
|
||||
PymavlinkInboundDecoder,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
||||
StatusTextTransitionRateLimiter,
|
||||
source_label_to_float,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._subscription import SubscriptionBus
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcEmitError,
|
||||
FcOpenError,
|
||||
SigningHandshakeError,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
__all__ = ["PymavlinkArdupilotAdapter"]
|
||||
|
||||
|
||||
_GPS_FIX_TYPE_3D: Final[int] = 3
|
||||
_NAMED_VALUE_FLOAT_NAME: Final[str] = "src_lbl"
|
||||
_SIGNING_KEY_LEN: Final[int] = 32
|
||||
_BUILD_DEV_STATIC_KEY_ENV: Final[str] = "BUILD_DEV_STATIC_KEY"
|
||||
|
||||
|
||||
# Maps Severity enum to MAVLink statustext severity numeric value.
|
||||
def _mav_severity(sev: Severity) -> int:
|
||||
return int(sev.value)
|
||||
|
||||
|
||||
class PymavlinkArdupilotAdapter:
|
||||
"""ArduPilot Plane FcAdapter (MAVLink 2.0).
|
||||
|
||||
Threading: outbound (``emit_*``) is bound to a single composition-
|
||||
root thread; inbound (decode loop) runs on its own thread inside
|
||||
``PymavlinkInboundDecoder.run_decode_loop`` (AZ-391).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: Config,
|
||||
wgs_converter: Any,
|
||||
covariance_projector: CovarianceProjector,
|
||||
fdr_client: FdrClient,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
flight_id: str = "",
|
||||
connect_factory: Callable[[str, int], Any] | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._wgs_converter = wgs_converter
|
||||
self._cov_projector = covariance_projector
|
||||
self._fdr_client = fdr_client
|
||||
self._clock = clock
|
||||
self._flight_id = flight_id
|
||||
self._connect_factory = connect_factory
|
||||
self._signing_failure_threshold = max(1, int(config.fc.signing_failure_threshold))
|
||||
self._log = get_logger("c8_fc_adapter.ap_adapter")
|
||||
# Wire state ------------------------------------------------------
|
||||
self._connection: Any = None
|
||||
self._signing_key: bytearray | None = None
|
||||
self._opened = False
|
||||
self._sequence_number = 0
|
||||
self._first_emit_logged = False
|
||||
self._open_emit_thread_ident: int | None = None
|
||||
self._signing_failure_logged_at_count = 0
|
||||
# Inbound bus + decoder (lazily constructed inside ``open``).
|
||||
self._bus = SubscriptionBus()
|
||||
self._inbound: PymavlinkInboundDecoder | None = None
|
||||
self._inbound_thread: threading.Thread | None = None
|
||||
# Outbound provenance rate limiter.
|
||||
self._provenance = StatusTextTransitionRateLimiter(
|
||||
send_statustext=self._send_statustext_internal,
|
||||
clock=time.monotonic,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FcAdapter Protocol implementation
|
||||
|
||||
def open(self, port: PortConfig, signing_key: bytes | None) -> None:
|
||||
if self._opened:
|
||||
raise FcOpenError("PymavlinkArdupilotAdapter already opened")
|
||||
if port.fc_kind is not FcKind.ARDUPILOT_PLANE:
|
||||
raise FcOpenError(
|
||||
f"PymavlinkArdupilotAdapter requires FcKind.ARDUPILOT_PLANE; got {port.fc_kind!r}"
|
||||
)
|
||||
try:
|
||||
self._connection = self._connect(port)
|
||||
except Exception as exc:
|
||||
raise FcOpenError(f"pymavlink connect failed: {exc!r}") from exc
|
||||
# Per-flight signing key handling (AZ-395). ----------------------
|
||||
# In production, `signing_key_source = "ephemeral_per_flight"`; we
|
||||
# generate a fresh 32-byte key. The caller (composition root) is
|
||||
# also free to pass an explicit signing_key for test paths.
|
||||
source = self._config.fc.signing_key_source
|
||||
if source == "none":
|
||||
pass
|
||||
elif source == "ephemeral_per_flight":
|
||||
self._signing_key = self._materialise_signing_key(signing_key)
|
||||
try:
|
||||
self._setup_signing(self._signing_key)
|
||||
except Exception as exc:
|
||||
self._handle_signing_handshake_failure(exc)
|
||||
raise SigningHandshakeError(f"AP signing handshake failed: {exc!r}") from exc
|
||||
self._fdr_signing_event(
|
||||
kind="c8.ap.signing_key_rotated",
|
||||
kv={"flight_id": self._flight_id, "key_age_s": 0},
|
||||
)
|
||||
elif source == "dev_static":
|
||||
if os.environ.get(_BUILD_DEV_STATIC_KEY_ENV) != "ON":
|
||||
raise FcOpenError(
|
||||
"signing_key_source='dev_static' requires "
|
||||
f"{_BUILD_DEV_STATIC_KEY_ENV}=ON at build time"
|
||||
)
|
||||
self._signing_key = self._materialise_dev_static_key()
|
||||
try:
|
||||
self._setup_signing(self._signing_key)
|
||||
except Exception as exc:
|
||||
self._handle_signing_handshake_failure(exc)
|
||||
raise SigningHandshakeError(f"AP signing handshake failed: {exc!r}") from exc
|
||||
self._log.warning(
|
||||
"c8.ap.signing_dev_static_key",
|
||||
extra={"kind": "c8.ap.signing_dev_static_key", "kv": {}},
|
||||
)
|
||||
else:
|
||||
raise FcOpenError(f"unknown signing_key_source={source!r}")
|
||||
# Inbound decoder + thread.
|
||||
self._inbound = PymavlinkInboundDecoder(self._connection, self._bus)
|
||||
thread = threading.Thread(
|
||||
target=self._inbound.run_decode_loop,
|
||||
name="c8.ap.inbound",
|
||||
daemon=True,
|
||||
)
|
||||
self._inbound_thread = thread
|
||||
thread.start()
|
||||
self._open_emit_thread_ident = None
|
||||
self._opened = True
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._opened:
|
||||
return
|
||||
if self._inbound is not None:
|
||||
self._inbound.stop()
|
||||
# Zeroise the signing-key buffer BEFORE we let it be deallocated
|
||||
# (AZ-395 AC-7 / Invariant 10).
|
||||
if self._signing_key is not None:
|
||||
self._zeroise_signing_key(self._signing_key)
|
||||
self._log.info(
|
||||
"c8.ap.signing_key_zeroised",
|
||||
extra={"kind": "c8.ap.signing_key_zeroised", "kv": {}},
|
||||
)
|
||||
self._signing_key = None
|
||||
try:
|
||||
if self._connection is not None and hasattr(self._connection, "close"):
|
||||
self._connection.close()
|
||||
finally:
|
||||
self._opened = False
|
||||
self._connection = None
|
||||
self._inbound = None
|
||||
self._open_emit_thread_ident = None
|
||||
self._first_emit_logged = False
|
||||
|
||||
def subscribe_telemetry(self, callback: TelemetryCallback) -> Subscription:
|
||||
return self._bus.subscribe(callback)
|
||||
|
||||
def emit_external_position(self, output: EstimatorOutput) -> EmittedExternalPosition:
|
||||
if not self._opened or self._connection is None:
|
||||
raise FcEmitError("adapter not opened")
|
||||
self._enforce_single_writer()
|
||||
if output.smoothed:
|
||||
self._log_emit_failed("smoothed_rejected", output.frame_id)
|
||||
raise FcEmitError("smoothed output cannot be emitted to FC (Invariant 6)")
|
||||
horiz_accuracy_m = self._cov_projector.to_ardupilot_horiz_accuracy_m(output)
|
||||
wgs = self._extract_wgs84(output)
|
||||
emitted_at = time.monotonic_ns()
|
||||
self._sequence_number += 1
|
||||
seq = self._sequence_number
|
||||
try:
|
||||
self._connection.mav.gps_input_send(
|
||||
int(self._clock_us()),
|
||||
0, # gps_id (primary)
|
||||
0, # ignore_flags
|
||||
0, # time_week_ms
|
||||
0, # time_week
|
||||
_GPS_FIX_TYPE_3D,
|
||||
int(wgs.lat_deg * 1e7),
|
||||
int(wgs.lon_deg * 1e7),
|
||||
float(wgs.alt_m),
|
||||
0.0, # hdop
|
||||
0.0, # vdop
|
||||
0.0, # vn
|
||||
0.0, # ve
|
||||
0.0, # vd
|
||||
0.0, # speed_accuracy
|
||||
float(horiz_accuracy_m),
|
||||
0.0, # vert_accuracy
|
||||
10, # satellites_visible (synthetic; cosmetic for AP EKF)
|
||||
0, # yaw
|
||||
)
|
||||
self._connection.mav.named_value_float_send(
|
||||
int(self._clock_ms_boot()),
|
||||
_NAMED_VALUE_FLOAT_NAME.encode("utf-8"),
|
||||
source_label_to_float(output.source_label),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log_emit_failed(repr(exc), output.frame_id)
|
||||
raise FcEmitError(f"AP outbound wire emit failed: {exc!r}") from exc
|
||||
# AC-NEW-4 / AZ-395 AC-6: poll signing-failure counter post-emit;
|
||||
# only log on the transition past the configured threshold.
|
||||
self._poll_signing_failure_counter()
|
||||
# Source-label transition STATUSTEXT (AC-4).
|
||||
self._provenance.note_label_and_maybe_emit(output.source_label, severity=Severity.INFO)
|
||||
if not self._first_emit_logged:
|
||||
self._first_emit_logged = True
|
||||
self._log.info(
|
||||
"c8.ap.first_emit",
|
||||
extra={
|
||||
"kind": "c8.ap.first_emit",
|
||||
"kv": {"frame_id": output.frame_id, "seq": seq},
|
||||
},
|
||||
)
|
||||
self._log.debug(
|
||||
"c8.ap.emit",
|
||||
extra={
|
||||
"kind": "c8.ap.emit",
|
||||
"kv": {
|
||||
"frame_seq": seq,
|
||||
"horiz_accuracy_m": horiz_accuracy_m,
|
||||
"source_label": output.source_label,
|
||||
},
|
||||
},
|
||||
)
|
||||
return EmittedExternalPosition(
|
||||
fc_kind=FcKind.ARDUPILOT_PLANE,
|
||||
horiz_accuracy_m=horiz_accuracy_m,
|
||||
source_label=output.source_label,
|
||||
emitted_at=emitted_at,
|
||||
sequence_number=seq,
|
||||
)
|
||||
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None:
|
||||
if not self._opened or self._connection is None:
|
||||
raise FcEmitError("adapter not opened")
|
||||
self._enforce_single_writer()
|
||||
self._send_statustext_internal(msg, severity)
|
||||
|
||||
def request_source_set_switch(self) -> None:
|
||||
raise NotImplementedError("Owned by source-set task; install AZ-396 to enable")
|
||||
|
||||
def current_flight_state(self) -> FlightStateSignal:
|
||||
if self._inbound is None:
|
||||
raise FcEmitError("current_flight_state requires open(); no inbound decoder yet")
|
||||
latest = self._inbound.state_ring.peek_latest()
|
||||
if latest is None:
|
||||
return FlightStateSignal(
|
||||
state=FlightState.INIT,
|
||||
last_valid_gps_hint_wgs84=None,
|
||||
last_valid_gps_age_ms=None,
|
||||
captured_at=time.monotonic_ns(),
|
||||
)
|
||||
payload = latest.payload
|
||||
assert isinstance(payload, FlightStateSignal)
|
||||
return payload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
|
||||
def _enforce_single_writer(self) -> None:
|
||||
cur = threading.get_ident()
|
||||
if self._open_emit_thread_ident is None:
|
||||
self._open_emit_thread_ident = cur
|
||||
return
|
||||
if self._open_emit_thread_ident != cur:
|
||||
raise RuntimeError(
|
||||
"PymavlinkArdupilotAdapter outbound is single-writer; "
|
||||
f"first thread={self._open_emit_thread_ident}, this thread={cur}"
|
||||
)
|
||||
|
||||
def _connect(self, port: PortConfig) -> Any:
|
||||
if self._connect_factory is not None:
|
||||
return self._connect_factory(port.device, port.baud)
|
||||
# Lazy import so the test path with an injected factory does NOT
|
||||
# require pymavlink at module-import time.
|
||||
from pymavlink import mavutil
|
||||
|
||||
return mavutil.mavlink_connection(
|
||||
port.device,
|
||||
baud=port.baud,
|
||||
dialect="common",
|
||||
mavlink_version="2.0",
|
||||
)
|
||||
|
||||
def _setup_signing(self, key: bytearray) -> None:
|
||||
if not hasattr(self._connection, "setup_signing"):
|
||||
return
|
||||
# pymavlink's setup_signing accepts bytes; pass the buffer (it
|
||||
# converts to bytes internally so the buffer here stays the
|
||||
# authoritative copy we zeroise on close).
|
||||
self._connection.setup_signing(bytes(key))
|
||||
|
||||
def _materialise_signing_key(self, supplied: bytes | None) -> bytearray:
|
||||
if supplied is None:
|
||||
return bytearray(secrets.token_bytes(_SIGNING_KEY_LEN))
|
||||
if len(supplied) != _SIGNING_KEY_LEN:
|
||||
raise SigningHandshakeError(
|
||||
f"signing_key must be {_SIGNING_KEY_LEN} bytes; got {len(supplied)}"
|
||||
)
|
||||
return bytearray(supplied)
|
||||
|
||||
def _materialise_dev_static_key(self) -> bytearray:
|
||||
"""Hex-decode FcConfig.dev_static_signing_key into a 32-byte buffer."""
|
||||
hex_str = self._config.fc.dev_static_signing_key.strip()
|
||||
try:
|
||||
raw = bytes.fromhex(hex_str)
|
||||
except ValueError as exc:
|
||||
raise SigningHandshakeError(
|
||||
"FcConfig.dev_static_signing_key must be valid hex"
|
||||
) from exc
|
||||
if len(raw) != _SIGNING_KEY_LEN:
|
||||
raise SigningHandshakeError(
|
||||
f"FcConfig.dev_static_signing_key decodes to {len(raw)} bytes; "
|
||||
f"expected {_SIGNING_KEY_LEN}"
|
||||
)
|
||||
return bytearray(raw)
|
||||
|
||||
@staticmethod
|
||||
def _zeroise_signing_key(buf: bytearray) -> None:
|
||||
for i in range(len(buf)):
|
||||
buf[i] = 0
|
||||
|
||||
def _handle_signing_handshake_failure(self, exc: BaseException) -> None:
|
||||
self._log.error(
|
||||
f"c8.ap.signing_handshake_failed: {exc!r}",
|
||||
extra={
|
||||
"kind": "c8.ap.signing_handshake_failed",
|
||||
"kv": {"error_class": type(exc).__name__},
|
||||
},
|
||||
)
|
||||
self._fdr_signing_event(
|
||||
kind="c8.ap.signing_handshake_failed",
|
||||
kv={
|
||||
"flight_id": self._flight_id,
|
||||
"error_class": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
def _poll_signing_failure_counter(self) -> None:
|
||||
if self._signing_key is None:
|
||||
return
|
||||
count = getattr(self._connection.mav, "signing", None)
|
||||
if count is None:
|
||||
return
|
||||
failure_count = int(getattr(count, "sig_count", 0) or 0)
|
||||
if failure_count <= self._signing_failure_logged_at_count:
|
||||
return
|
||||
if failure_count < self._signing_failure_threshold:
|
||||
return
|
||||
# Transition past the threshold — log + FDR + STATUSTEXT, no raise.
|
||||
self._signing_failure_logged_at_count = failure_count
|
||||
self._log.error(
|
||||
f"c8.ap.signing_failure: count={failure_count}",
|
||||
extra={
|
||||
"kind": "c8.ap.signing_failure",
|
||||
"kv": {"failure_count": failure_count},
|
||||
},
|
||||
)
|
||||
self._fdr_signing_event(
|
||||
kind="c8.ap.signing_failure",
|
||||
kv={
|
||||
"flight_id": self._flight_id,
|
||||
"failure_count": failure_count,
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._send_statustext_internal(
|
||||
f"signing failure count={failure_count}", Severity.WARNING
|
||||
)
|
||||
except Exception:
|
||||
# STATUSTEXT failure on the same UART is non-fatal; the
|
||||
# ERROR log + FDR already captured the event.
|
||||
pass
|
||||
|
||||
def _send_statustext_internal(self, msg: str, severity: Severity) -> None:
|
||||
if self._connection is None:
|
||||
return
|
||||
try:
|
||||
text = msg.encode("utf-8")[:50]
|
||||
self._connection.mav.statustext_send(_mav_severity(severity), text)
|
||||
except Exception as exc:
|
||||
self._log.debug(
|
||||
f"c8.ap.statustext_failed: {exc!r}",
|
||||
extra={
|
||||
"kind": "c8.ap.statustext_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
|
||||
def _log_emit_failed(self, reason: str, frame_id: int) -> None:
|
||||
self._log.error(
|
||||
f"c8.ap.emit_failed: {reason}",
|
||||
extra={
|
||||
"kind": "c8.ap.emit_failed",
|
||||
"kv": {"reason": reason, "frame_id": frame_id},
|
||||
},
|
||||
)
|
||||
|
||||
def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
|
||||
"""Pull the WGS84 fix the composition root pre-attached.
|
||||
|
||||
C5 emits its estimate in the local ENU frame; the composition
|
||||
root injects a WgsConverter-backed enricher that attaches the
|
||||
WGS84 conversion to ``output.extras["wgs84"]`` BEFORE handing
|
||||
the output to C8. If the enricher is missing the wgs84 key,
|
||||
that is a composition bug — fail loudly rather than guess.
|
||||
"""
|
||||
wgs = output.extras.get("wgs84") if output.extras else None
|
||||
if not isinstance(wgs, LatLonAlt):
|
||||
raise FcEmitError(
|
||||
"EstimatorOutput.extras['wgs84'] missing or not a LatLonAlt; "
|
||||
"composition root must inject the ENU->WGS84 enricher"
|
||||
)
|
||||
return wgs
|
||||
|
||||
def _clock_us(self) -> int:
|
||||
return int(self._clock() * 1_000_000)
|
||||
|
||||
def _clock_ms_boot(self) -> int:
|
||||
return int(self._clock() * 1_000)
|
||||
|
||||
def _fdr_signing_event(self, *, kind: str, kv: dict[str, Any]) -> None:
|
||||
record = FdrRecord(
|
||||
schema_version=1,
|
||||
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||
producer_id="c8_fc_adapter",
|
||||
kind="log",
|
||||
payload={
|
||||
"level": "INFO" if kind.endswith("_rotated") else "ERROR",
|
||||
"component": "c8_fc_adapter",
|
||||
"kind": kind,
|
||||
"msg": kind,
|
||||
"kv": kv,
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._fdr_client.enqueue(record)
|
||||
except Exception as exc:
|
||||
self._log.debug(
|
||||
f"c8.ap.fdr_enqueue_failed: {exc!r}",
|
||||
extra={
|
||||
"kind": "c8.ap.fdr_enqueue_failed",
|
||||
"kv": {"error": repr(exc), "downstream_kind": kind},
|
||||
},
|
||||
)
|
||||
@@ -56,6 +56,8 @@ ENV_KEY_MAP: Final[dict[str, tuple[str, str]]] = {
|
||||
"FC_PORT_DEVICE": ("fc", "port_device"),
|
||||
"FC_PORT_BAUD": ("fc", "port_baud"),
|
||||
"FC_SIGNING_KEY_SOURCE": ("fc", "signing_key_source"),
|
||||
"FC_DEV_STATIC_SIGNING_KEY": ("fc", "dev_static_signing_key"),
|
||||
"FC_SIGNING_FAILURE_THRESHOLD": ("fc", "signing_failure_threshold"),
|
||||
"GCS_ADAPTER": ("gcs", "adapter"),
|
||||
"GCS_PORT_DEVICE": ("gcs", "port_device"),
|
||||
"GCS_PORT_BAUD": ("gcs", "port_baud"),
|
||||
@@ -97,6 +99,8 @@ _FIELD_COERCIONS: Final[dict[str, type]] = {
|
||||
"port_device": str,
|
||||
"port_baud": int,
|
||||
"signing_key_source": str,
|
||||
"dev_static_signing_key": str,
|
||||
"signing_failure_threshold": int,
|
||||
"summary_rate_hz": float,
|
||||
}
|
||||
|
||||
|
||||
@@ -207,22 +207,37 @@ class FcConfig:
|
||||
port_device: str = "/dev/ttyTHS1"
|
||||
port_baud: int = 921600
|
||||
signing_key_source: str = "ephemeral_per_flight"
|
||||
dev_static_signing_key: str = ""
|
||||
signing_failure_threshold: int = 3
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.adapter not in KNOWN_FC_STRATEGIES:
|
||||
raise ConfigError(
|
||||
f"FcConfig.adapter={self.adapter!r} not in {sorted(KNOWN_FC_STRATEGIES)}"
|
||||
)
|
||||
if self.signing_key_source not in {"none", "ephemeral_per_flight"}:
|
||||
if self.signing_key_source not in {
|
||||
"none",
|
||||
"ephemeral_per_flight",
|
||||
"dev_static",
|
||||
}:
|
||||
raise ConfigError(
|
||||
f"FcConfig.signing_key_source={self.signing_key_source!r} not in "
|
||||
f"['none', 'ephemeral_per_flight']"
|
||||
f"['none', 'ephemeral_per_flight', 'dev_static']"
|
||||
)
|
||||
if self.adapter == "inav" and self.signing_key_source != "none":
|
||||
raise ConfigError(
|
||||
"FcConfig.signing_key_source must be 'none' when adapter='inav' "
|
||||
"(RESTRICT-COMM-2 — iNav has no signing)"
|
||||
)
|
||||
if self.signing_key_source == "dev_static" and not self.dev_static_signing_key:
|
||||
raise ConfigError(
|
||||
"FcConfig.dev_static_signing_key required when signing_key_source='dev_static'"
|
||||
)
|
||||
if self.signing_failure_threshold < 1:
|
||||
raise ConfigError(
|
||||
"FcConfig.signing_failure_threshold must be >= 1; got "
|
||||
f"{self.signing_failure_threshold}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""AZ-393 — PymavlinkArdupilotAdapter outbound AC tests.
|
||||
|
||||
Covers all 10 ACs of AZ-393. The AP signing path is covered by
|
||||
``test_az395_mavlink_signing.py``; this file uses
|
||||
``signing_key_source="none"`` so the AP open path is signing-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.fc import FcKind, PortConfig
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
||||
source_label_to_float,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
|
||||
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
|
||||
PymavlinkArdupilotAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — pymavlink stand-in
|
||||
|
||||
|
||||
class _MavStub:
|
||||
"""Captures pymavlink ``mav.*_send`` calls for wire-level assertions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.gps_input_calls: list[tuple[Any, ...]] = []
|
||||
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
self.gps_input_calls.append(args)
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
self.statustext_calls.append((severity, text))
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
def __init__(self) -> None:
|
||||
self.mav = _MavStub()
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self.closed = False
|
||||
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
self.setup_signing_calls.append(bytes(key))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn() -> _ConnStub:
|
||||
return _ConnStub()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(conn: _ConnStub, tmp_path) -> PymavlinkArdupilotAdapter:
|
||||
cfg = _config_for_ap(tmp_path, signing_key_source="none")
|
||||
fdr = mock.MagicMock()
|
||||
cov = CovarianceProjector(fdr_client=fdr)
|
||||
adapter = PymavlinkArdupilotAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=cov,
|
||||
fdr_client=fdr,
|
||||
clock=lambda: 1.0,
|
||||
flight_id="flt-test",
|
||||
connect_factory=lambda device, baud: conn,
|
||||
)
|
||||
port = PortConfig(
|
||||
fc_kind=FcKind.ARDUPILOT_PLANE,
|
||||
device="/dev/null",
|
||||
baud=921600,
|
||||
)
|
||||
adapter.open(port, signing_key=None)
|
||||
yield adapter
|
||||
adapter.close()
|
||||
|
||||
|
||||
def _config_for_ap(tmp_path, *, signing_key_source: str = "none"):
|
||||
"""Build a Config with ``fc.adapter='ardupilot_plane'``."""
|
||||
env = {
|
||||
"FC_ADAPTER": "ardupilot_plane",
|
||||
"FC_PORT_DEVICE": "/dev/null",
|
||||
"FC_PORT_BAUD": "921600",
|
||||
"FC_SIGNING_KEY_SOURCE": signing_key_source,
|
||||
}
|
||||
return load_config(env=env, paths=(), require_env=False)
|
||||
|
||||
|
||||
def _make_output(
|
||||
*,
|
||||
source_label: str = "visual_propagated",
|
||||
smoothed: bool = False,
|
||||
cov: np.ndarray | None = None,
|
||||
wgs: LatLonAlt | None = None,
|
||||
frame_id: int = 1,
|
||||
) -> EstimatorOutput:
|
||||
if cov is None:
|
||||
cov = np.eye(6, dtype=np.float64) * 0.25
|
||||
if wgs is None:
|
||||
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
pose_se3=np.eye(4),
|
||||
covariance_6x6=cov,
|
||||
source_label=source_label,
|
||||
health=None,
|
||||
smoothed=smoothed,
|
||||
extras={"wgs84": wgs},
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: GPS_INPUT field fidelity
|
||||
|
||||
|
||||
def test_ac1_gps_input_field_fidelity(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
|
||||
# Arrange
|
||||
cov = np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64)
|
||||
wgs = LatLonAlt(lat_deg=50.4501, lon_deg=30.5234, alt_m=180.0)
|
||||
output = _make_output(cov=cov, wgs=wgs)
|
||||
expected_horiz_m = CovarianceProjector(
|
||||
fdr_client=mock.MagicMock()
|
||||
).to_ardupilot_horiz_accuracy_m(output)
|
||||
|
||||
# Act
|
||||
result = adapter.emit_external_position(output)
|
||||
|
||||
# Assert
|
||||
assert len(conn.mav.gps_input_calls) == 1
|
||||
call = conn.mav.gps_input_calls[0]
|
||||
# gps_input_send signature: (time_us, gps_id, ignore_flags, time_week_ms,
|
||||
# time_week, fix_type, lat_e7, lon_e7, alt, hdop, vdop, vn, ve, vd,
|
||||
# speed_accuracy, horiz_accuracy, vert_accuracy, sat_visible, yaw)
|
||||
assert call[5] == 3 # fix type 3D
|
||||
assert call[6] == int(wgs.lat_deg * 1e7)
|
||||
assert call[7] == int(wgs.lon_deg * 1e7)
|
||||
assert call[8] == pytest.approx(wgs.alt_m)
|
||||
assert call[15] == pytest.approx(expected_horiz_m, abs=1e-3)
|
||||
assert result.horiz_accuracy_m == pytest.approx(expected_horiz_m)
|
||||
assert result.fc_kind is FcKind.ARDUPILOT_PLANE
|
||||
assert result.sequence_number == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: GPS_INPUT every frame
|
||||
|
||||
|
||||
def test_ac2_gps_input_every_frame(adapter: PymavlinkArdupilotAdapter, conn: _ConnStub) -> None:
|
||||
for i in range(100):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert len(conn.mav.gps_input_calls) == 100
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: NAMED_VALUE_FLOAT every frame, correct mapping
|
||||
|
||||
|
||||
def test_ac3_named_value_float_every_frame(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
for i in range(100):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert len(conn.mav.named_value_float_calls) == 100
|
||||
for _, name, value in conn.mav.named_value_float_calls:
|
||||
assert name == b"src_lbl"
|
||||
assert value == pytest.approx(source_label_to_float("visual_propagated"))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: STATUSTEXT rate-limited on transition
|
||||
|
||||
|
||||
def test_ac4_statustext_only_on_transition(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
"""100 frames, source_label toggles every 10; expect 10 transitions."""
|
||||
# Loosen the 1 s per-severity hard cap for the test by patching the
|
||||
# rate-limiter's min_interval_s — AC-4 measures the transition
|
||||
# behaviour, not the secondary 1 Hz spam-defence cap.
|
||||
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
||||
labels = ["visual_propagated", "sat_anchored"]
|
||||
for i in range(100):
|
||||
label = labels[(i // 10) % 2]
|
||||
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
|
||||
# Each block of 10 frames is a single label; that's 10 blocks → 9
|
||||
# transitions between adjacent blocks plus 1 for the initial
|
||||
# None->visual_propagated bootstrap.
|
||||
assert len(conn.mav.statustext_calls) == 10
|
||||
|
||||
|
||||
def test_ac4_statustext_zero_within_state(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
"""100 frames all on the same source label → exactly 1 STATUSTEXT (the bootstrap)."""
|
||||
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
||||
for i in range(100):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert len(conn.mav.statustext_calls) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: Smoothed output rejected
|
||||
|
||||
|
||||
def test_ac5_smoothed_output_rejected(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
output = _make_output(smoothed=True)
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(FcEmitError, match="smoothed"):
|
||||
adapter.emit_external_position(output)
|
||||
assert len(conn.mav.gps_input_calls) == 0
|
||||
assert any(getattr(rec, "kind", None) == "c8.ap.emit_failed" for rec in caplog.records)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: Non-SPD covariance rejected
|
||||
|
||||
|
||||
def test_ac6_non_spd_covariance_rejected(
|
||||
adapter: PymavlinkArdupilotAdapter, conn: _ConnStub
|
||||
) -> None:
|
||||
bad_cov = np.eye(6, dtype=np.float64)
|
||||
bad_cov[0, 0] = -1.0 # not positive-definite
|
||||
output = _make_output(cov=bad_cov)
|
||||
with pytest.raises(FcEmitError):
|
||||
adapter.emit_external_position(output)
|
||||
assert len(conn.mav.gps_input_calls) == 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: Single-writer thread
|
||||
|
||||
|
||||
def test_ac7_single_writer_thread(adapter: PymavlinkArdupilotAdapter) -> None:
|
||||
adapter.emit_external_position(_make_output()) # binds to main thread
|
||||
|
||||
err: list[BaseException] = []
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
adapter.emit_external_position(_make_output(frame_id=2))
|
||||
except RuntimeError as e:
|
||||
err.append(e)
|
||||
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert len(err) == 1
|
||||
assert "single-writer" in str(err[0]).lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: Open without signing key (placeholder; AZ-395 tightens this)
|
||||
|
||||
|
||||
def test_ac8_open_without_signing_key_succeeds(conn: _ConnStub, tmp_path) -> None:
|
||||
cfg = _config_for_ap(tmp_path, signing_key_source="none")
|
||||
fdr = mock.MagicMock()
|
||||
a = PymavlinkArdupilotAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
connect_factory=lambda device, baud: conn,
|
||||
)
|
||||
port = PortConfig(
|
||||
fc_kind=FcKind.ARDUPILOT_PLANE,
|
||||
device="/dev/null",
|
||||
baud=921600,
|
||||
)
|
||||
a.open(port, signing_key=None)
|
||||
try:
|
||||
assert a._opened is True
|
||||
finally:
|
||||
a.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: source-set switch raises NotImplementedError
|
||||
|
||||
|
||||
def test_ac9_source_set_switch_not_implemented(adapter: PymavlinkArdupilotAdapter) -> None:
|
||||
with pytest.raises(NotImplementedError, match="AZ-396"):
|
||||
adapter.request_source_set_switch()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: First emit logged once
|
||||
|
||||
|
||||
def test_ac10_first_emit_logged_once(
|
||||
adapter: PymavlinkArdupilotAdapter, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.INFO):
|
||||
for i in range(5):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
first_emit_records = [
|
||||
rec for rec in caplog.records if getattr(rec, "kind", None) == "c8.ap.first_emit"
|
||||
]
|
||||
assert len(first_emit_records) == 1
|
||||
@@ -0,0 +1,301 @@
|
||||
"""AZ-394 — Msp2InavAdapter outbound AC tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.fc import FcKind, PortConfig
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._msp2_sensor_gps_encoder import (
|
||||
MSP2_SENSOR_GPS_CODE,
|
||||
decode_msp2_sensor_gps,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
FcEmitError,
|
||||
SourceSetSwitchNotSupportedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.msp2_inav_adapter import (
|
||||
Msp2InavAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — MSP / secondary-MAVLink stand-ins
|
||||
|
||||
|
||||
class _MspStub:
|
||||
def __init__(self) -> None:
|
||||
self.raw_msgs: list[tuple[int, bytes]] = []
|
||||
self.closed = False
|
||||
|
||||
def send_RAW_msg(self, code: int, data: bytes) -> None:
|
||||
self.raw_msgs.append((int(code), bytes(data)))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _SecondaryMavStub:
|
||||
def __init__(self) -> None:
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
self.closed = False
|
||||
# Mirror pymavlink connection.mav shape.
|
||||
self.mav = self
|
||||
# Track signing-key state per RESTRICT-COMM-2 (Invariant 9):
|
||||
# the unit-test adapter MUST never call setup_signing on us.
|
||||
self.setup_signing_calls: list[Any] = []
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
self.statustext_calls.append((int(severity), bytes(text)))
|
||||
|
||||
def setup_signing(self, key: Any) -> None:
|
||||
self.setup_signing_calls.append(key)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def msp() -> _MspStub:
|
||||
return _MspStub()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def secondary() -> _SecondaryMavStub:
|
||||
return _SecondaryMavStub()
|
||||
|
||||
|
||||
def _inav_config(tmp_path) -> Any:
|
||||
env = {
|
||||
"FC_ADAPTER": "inav",
|
||||
"FC_PORT_DEVICE": "/dev/null",
|
||||
"FC_PORT_BAUD": "115200",
|
||||
"FC_SIGNING_KEY_SOURCE": "none",
|
||||
}
|
||||
return load_config(env=env, paths=(), require_env=False)
|
||||
|
||||
|
||||
def _make_output(
|
||||
*,
|
||||
source_label: str = "visual_propagated",
|
||||
smoothed: bool = False,
|
||||
cov: np.ndarray | None = None,
|
||||
wgs: LatLonAlt | None = None,
|
||||
frame_id: int = 1,
|
||||
) -> EstimatorOutput:
|
||||
if cov is None:
|
||||
cov = np.eye(6, dtype=np.float64) * 0.25
|
||||
if wgs is None:
|
||||
wgs = LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
pose_se3=np.eye(4),
|
||||
covariance_6x6=cov,
|
||||
source_label=source_label,
|
||||
smoothed=smoothed,
|
||||
extras={"wgs84": wgs},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(msp: _MspStub, secondary: _SecondaryMavStub, tmp_path) -> Msp2InavAdapter:
|
||||
cfg = _inav_config(tmp_path)
|
||||
fdr = mock.MagicMock()
|
||||
a = Msp2InavAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
msp_connect_factory=lambda device, baud: msp,
|
||||
secondary_mavlink_factory=lambda: secondary,
|
||||
)
|
||||
port = PortConfig(fc_kind=FcKind.INAV, device="/dev/null", baud=115200)
|
||||
a.open(port, signing_key=None)
|
||||
yield a
|
||||
a.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: field fidelity
|
||||
|
||||
|
||||
def test_ac1_msp2_field_fidelity(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
# Arrange
|
||||
cov = np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64)
|
||||
wgs = LatLonAlt(lat_deg=50.4501, lon_deg=30.5234, alt_m=180.0)
|
||||
output = _make_output(cov=cov, wgs=wgs)
|
||||
expected_mm = CovarianceProjector(fdr_client=mock.MagicMock()).to_inav_h_pos_accuracy_mm(output)
|
||||
|
||||
# Act
|
||||
result = adapter.emit_external_position(output)
|
||||
|
||||
# Assert
|
||||
assert len(msp.raw_msgs) == 1
|
||||
code, payload = msp.raw_msgs[0]
|
||||
assert code == MSP2_SENSOR_GPS_CODE
|
||||
decoded = decode_msp2_sensor_gps(payload)
|
||||
assert decoded.fix_type == 3
|
||||
assert decoded.latitude_e7 == int(wgs.lat_deg * 1e7)
|
||||
assert decoded.longitude_e7 == int(wgs.lon_deg * 1e7)
|
||||
assert decoded.msl_altitude_cm == int(wgs.alt_m * 100.0)
|
||||
assert decoded.h_pos_accuracy_mm == expected_mm
|
||||
assert result.fc_kind is FcKind.INAV
|
||||
assert result.horiz_accuracy_m == pytest.approx(expected_mm / 1000.0)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: every frame, monotonic seq
|
||||
|
||||
|
||||
def test_ac2_msp2_every_frame_with_seq(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
seqs = []
|
||||
for i in range(100):
|
||||
result = adapter.emit_external_position(_make_output(frame_id=i))
|
||||
seqs.append(result.sequence_number)
|
||||
assert len(msp.raw_msgs) == 100
|
||||
# AC-2: monotonically incrementing (modulo uint8 wrap)
|
||||
assert seqs[0] == 1
|
||||
assert seqs[-1] == 100 & 0xFF
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: STATUSTEXT on secondary channel only, on transitions
|
||||
|
||||
|
||||
def test_ac3_statustext_secondary_only_on_transitions(
|
||||
adapter: Msp2InavAdapter, msp: _MspStub, secondary: _SecondaryMavStub
|
||||
) -> None:
|
||||
adapter._provenance._min_interval_s = 0.0 # type: ignore[attr-defined]
|
||||
labels = ["visual_propagated", "sat_anchored"]
|
||||
for i in range(100):
|
||||
label = labels[(i // 10) % 2]
|
||||
adapter.emit_external_position(_make_output(source_label=label, frame_id=i))
|
||||
# 10 transitions including the initial None->visual_propagated
|
||||
assert len(secondary.statustext_calls) == 10
|
||||
# AC-3: NEVER on primary MSP2 channel
|
||||
for code, _ in msp.raw_msgs:
|
||||
assert code == MSP2_SENSOR_GPS_CODE
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: signing-key rejection
|
||||
|
||||
|
||||
def test_ac4_signing_key_rejected(msp: _MspStub, secondary: _SecondaryMavStub, tmp_path) -> None:
|
||||
cfg = _inav_config(tmp_path)
|
||||
fdr = mock.MagicMock()
|
||||
a = Msp2InavAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
msp_connect_factory=lambda device, baud: msp,
|
||||
secondary_mavlink_factory=lambda: secondary,
|
||||
)
|
||||
port = PortConfig(fc_kind=FcKind.INAV, device="/dev/null", baud=115200)
|
||||
with pytest.raises(FcAdapterConfigError, match="iNav does not support MAVLink signing"):
|
||||
a.open(port, signing_key=b"\x00" * 32)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: signing-asymmetry — adapter never calls setup_signing on secondary
|
||||
|
||||
|
||||
def test_ac5_signing_asymmetry_no_signed_flag(
|
||||
adapter: Msp2InavAdapter, secondary: _SecondaryMavStub
|
||||
) -> None:
|
||||
for i in range(50):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
assert secondary.setup_signing_calls == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: source-set-switch unsupported
|
||||
|
||||
|
||||
def test_ac6_source_set_switch_unsupported(adapter: Msp2InavAdapter) -> None:
|
||||
with pytest.raises(SourceSetSwitchNotSupportedError, match="iNav"):
|
||||
adapter.request_source_set_switch()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: smoothed rejected
|
||||
|
||||
|
||||
def test_ac7_smoothed_rejected(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
with pytest.raises(FcEmitError, match="smoothed"):
|
||||
adapter.emit_external_position(_make_output(smoothed=True))
|
||||
assert msp.raw_msgs == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: non-SPD cov rejected
|
||||
|
||||
|
||||
def test_ac8_non_spd_covariance_rejected(adapter: Msp2InavAdapter, msp: _MspStub) -> None:
|
||||
bad = np.eye(6, dtype=np.float64)
|
||||
bad[0, 0] = -1.0
|
||||
with pytest.raises(FcEmitError):
|
||||
adapter.emit_external_position(_make_output(cov=bad))
|
||||
assert msp.raw_msgs == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: single-writer thread
|
||||
|
||||
|
||||
def test_ac9_single_writer_thread(adapter: Msp2InavAdapter) -> None:
|
||||
adapter.emit_external_position(_make_output())
|
||||
err: list[BaseException] = []
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
adapter.emit_external_position(_make_output(frame_id=2))
|
||||
except RuntimeError as e:
|
||||
err.append(e)
|
||||
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert len(err) == 1
|
||||
assert "single-writer" in str(err[0]).lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: first emit logged once
|
||||
|
||||
|
||||
def test_ac10_first_emit_logged_once(
|
||||
adapter: Msp2InavAdapter, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.INFO):
|
||||
for i in range(5):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
first = [rec for rec in caplog.records if getattr(rec, "kind", None) == "c8.inav.first_emit"]
|
||||
assert len(first) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Invariant 2 cross-check: iNav config rejects ephemeral signing
|
||||
|
||||
|
||||
def test_inav_config_rejects_signing(tmp_path) -> None:
|
||||
env = {
|
||||
"FC_ADAPTER": "inav",
|
||||
"FC_SIGNING_KEY_SOURCE": "ephemeral_per_flight",
|
||||
}
|
||||
with pytest.raises(Exception, match="adapter='inav'"):
|
||||
load_config(env=env, paths=(), require_env=False)
|
||||
@@ -0,0 +1,304 @@
|
||||
"""AZ-395 — AP MAVLink 2.0 per-flight signing AC tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.fc import FcKind, PortConfig, Severity
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcOpenError,
|
||||
SigningHandshakeError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
|
||||
PymavlinkArdupilotAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
_DEV_STATIC_KEY = "00112233445566778899aabbccddeeff" * 2 # 64 hex chars = 32 bytes
|
||||
|
||||
|
||||
class _MavStub:
|
||||
def __init__(self, signing_failure_count: int = 0) -> None:
|
||||
self.gps_input_calls: list[tuple[Any, ...]] = []
|
||||
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
# pymavlink exposes `connection.mav.signing.sig_count` after
|
||||
# setup_signing(...); we simulate that surface here.
|
||||
self.signing = SimpleNamespace(sig_count=signing_failure_count)
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
self.gps_input_calls.append(args)
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
self.statustext_calls.append((severity, text))
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
def __init__(self, *, fail_signing: bool = False, signing_failure_count: int = 0) -> None:
|
||||
self.mav = _MavStub(signing_failure_count=signing_failure_count)
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self._fail_signing = fail_signing
|
||||
self.closed = False
|
||||
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
if self._fail_signing:
|
||||
raise RuntimeError("simulated signing handshake refusal")
|
||||
self.setup_signing_calls.append(bytes(key))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _ap_config(*, signing_key_source: str, dev_static_key: str = "") -> Any:
|
||||
env: dict[str, str] = {
|
||||
"FC_ADAPTER": "ardupilot_plane",
|
||||
"FC_PORT_DEVICE": "/dev/null",
|
||||
"FC_PORT_BAUD": "921600",
|
||||
"FC_SIGNING_KEY_SOURCE": signing_key_source,
|
||||
}
|
||||
if dev_static_key:
|
||||
env["FC_DEV_STATIC_SIGNING_KEY"] = dev_static_key
|
||||
return load_config(env=env, paths=(), require_env=False)
|
||||
|
||||
|
||||
def _build_adapter(
|
||||
conn: _ConnStub, *, signing_key_source: str, dev_static_key: str = ""
|
||||
) -> PymavlinkArdupilotAdapter:
|
||||
cfg = _ap_config(signing_key_source=signing_key_source, dev_static_key=dev_static_key)
|
||||
fdr = mock.MagicMock()
|
||||
return PymavlinkArdupilotAdapter(
|
||||
config=cfg,
|
||||
wgs_converter=mock.MagicMock(),
|
||||
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
||||
fdr_client=fdr,
|
||||
flight_id="flt-az395",
|
||||
connect_factory=lambda device, baud: conn,
|
||||
)
|
||||
|
||||
|
||||
def _port() -> PortConfig:
|
||||
return PortConfig(fc_kind=FcKind.ARDUPILOT_PLANE, device="/dev/null", baud=921600)
|
||||
|
||||
|
||||
def _make_output(frame_id: int = 1) -> EstimatorOutput:
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
pose_se3=np.eye(4),
|
||||
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
|
||||
source_label="visual_propagated",
|
||||
smoothed=False,
|
||||
extras={"wgs84": LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0)},
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1 / AC-10: signing_key=None with ephemeral source generates the key
|
||||
|
||||
|
||||
def test_ac1_ephemeral_generates_key_when_none_passed() -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
assert len(conn.setup_signing_calls) == 1
|
||||
assert len(conn.setup_signing_calls[0]) == 32
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: two opens produce distinct ephemeral keys
|
||||
|
||||
|
||||
def test_ac2_ephemeral_distinct_per_flight() -> None:
|
||||
conn1 = _ConnStub()
|
||||
a1 = _build_adapter(conn1, signing_key_source="ephemeral_per_flight")
|
||||
a1.open(_port(), signing_key=None)
|
||||
a1.close()
|
||||
conn2 = _ConnStub()
|
||||
a2 = _build_adapter(conn2, signing_key_source="ephemeral_per_flight")
|
||||
a2.open(_port(), signing_key=None)
|
||||
a2.close()
|
||||
assert conn1.setup_signing_calls[0] != conn2.setup_signing_calls[0]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: handshake failure raises SigningHandshakeError
|
||||
|
||||
|
||||
def test_ac3_handshake_failure_raises(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub(fail_signing=True)
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(SigningHandshakeError):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
assert any(
|
||||
getattr(rec, "kind", None) == "c8.ap.signing_handshake_failed" for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: handshake success FDR record has NO key bytes
|
||||
|
||||
|
||||
def test_ac4_handshake_success_fdr_has_no_key_bytes() -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
key = bytes(conn.setup_signing_calls[0])
|
||||
rotated_calls = [
|
||||
call
|
||||
for call in adapter._fdr_client.enqueue.mock_calls # type: ignore[attr-defined]
|
||||
if call.args[0].payload.get("kind") == "c8.ap.signing_key_rotated"
|
||||
]
|
||||
assert len(rotated_calls) == 1
|
||||
rec_payload = rotated_calls[0].args[0].payload
|
||||
# AC-4: assert NO key byte sub-sequence appears in the FDR payload.
|
||||
rendered = str(rec_payload)
|
||||
assert key.hex() not in rendered
|
||||
for i in range(0, len(key) - 4):
|
||||
chunk = key[i : i + 4].hex()
|
||||
assert chunk not in rendered, f"4-byte chunk leak at offset {i}"
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: key never in any log line
|
||||
|
||||
|
||||
def test_ac5_key_never_in_logs(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
for i in range(5):
|
||||
adapter.emit_external_position(_make_output(frame_id=i))
|
||||
adapter.close()
|
||||
key = bytes(conn.setup_signing_calls[0])
|
||||
rendered_logs = "\n".join(rec.getMessage() for rec in caplog.records)
|
||||
assert key.hex() not in rendered_logs
|
||||
pattern = re.compile(re.escape(key[:4].hex()))
|
||||
assert pattern.search(rendered_logs) is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: mid-flight signing failure does NOT raise
|
||||
|
||||
|
||||
def test_ac6_mid_flight_signing_failure_no_raise(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub(signing_failure_count=5)
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
adapter.emit_external_position(_make_output())
|
||||
adapter.emit_external_position(_make_output(frame_id=2))
|
||||
assert any(getattr(rec, "kind", None) == "c8.ap.signing_failure" for rec in caplog.records)
|
||||
assert any(sev == int(Severity.WARNING.value) for sev, _ in conn.mav.statustext_calls)
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: key zeroisation on close
|
||||
|
||||
|
||||
def test_ac7_key_zeroisation_on_close(caplog: pytest.LogCaptureFixture) -> None:
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
# Grab a direct reference to the bytearray buffer so we can inspect
|
||||
# it after close() — bytearray is mutable so the zeroisation in
|
||||
# _zeroise_signing_key wipes this same buffer in place.
|
||||
key_buf = adapter._signing_key
|
||||
assert key_buf is not None
|
||||
assert any(b != 0 for b in key_buf) # non-zero before close
|
||||
with caplog.at_level(logging.INFO):
|
||||
adapter.close()
|
||||
assert all(b == 0 for b in key_buf)
|
||||
assert any(getattr(rec, "kind", None) == "c8.ap.signing_key_zeroised" for rec in caplog.records)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: BUILD_DEV_STATIC_KEY=ON enables static repeatable key path
|
||||
|
||||
|
||||
def test_ac8_dev_static_key_repeatable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BUILD_DEV_STATIC_KEY", "ON")
|
||||
conn1 = _ConnStub()
|
||||
a1 = _build_adapter(conn1, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
|
||||
a1.open(_port(), signing_key=None)
|
||||
a1.close()
|
||||
conn2 = _ConnStub()
|
||||
a2 = _build_adapter(conn2, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
|
||||
a2.open(_port(), signing_key=None)
|
||||
a2.close()
|
||||
assert conn1.setup_signing_calls[0] == conn2.setup_signing_calls[0]
|
||||
assert conn1.setup_signing_calls[0].hex() == _DEV_STATIC_KEY
|
||||
|
||||
|
||||
def test_ac8_dev_static_key_blocked_without_build_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("BUILD_DEV_STATIC_KEY", raising=False)
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="dev_static", dev_static_key=_DEV_STATIC_KEY)
|
||||
with pytest.raises(FcOpenError, match="BUILD_DEV_STATIC_KEY"):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9 (indirect): mid-flight signing failure STATUSTEXT severity = WARNING.
|
||||
# AC-3 handshake-failure raises and logs ERROR; the STATUSTEXT severity is
|
||||
# not exercised because handshake-failure aborts open() before any emit.
|
||||
|
||||
|
||||
def test_ac9_statustext_severity_on_mid_flight_failure() -> None:
|
||||
conn = _ConnStub(signing_failure_count=5)
|
||||
adapter = _build_adapter(conn, signing_key_source="ephemeral_per_flight")
|
||||
adapter.open(_port(), signing_key=None)
|
||||
try:
|
||||
adapter.emit_external_position(_make_output())
|
||||
# First STATUSTEXT is the signing-failure WARNING; subsequent
|
||||
# provenance STATUSTEXT may follow at INFO. Assert at least one
|
||||
# WARNING was emitted.
|
||||
assert any(sev == int(Severity.WARNING.value) for sev, _ in conn.mav.statustext_calls)
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: AZ-393 placeholder tightened — `signing_key_source="none"` is the
|
||||
# only path that bypasses signing; `"ephemeral_per_flight"` rejects no key
|
||||
# via an internally generated one (and AC-1 verified that). The matching
|
||||
# tightening surface is that an unknown source is rejected.
|
||||
|
||||
|
||||
def test_ac10_unknown_signing_source_rejected() -> None:
|
||||
# FcConfig enforces the allowed-source list at config-load time, so
|
||||
# an explicit "explicit" source is rejected before the adapter sees
|
||||
# it. We exercise the FcOpenError path by patching the validated
|
||||
# config in place (test seam).
|
||||
conn = _ConnStub()
|
||||
adapter = _build_adapter(conn, signing_key_source="none")
|
||||
# Mutate the validated config in place to simulate an unknown source
|
||||
# slipping past validation (defence-in-depth) — adapter must refuse.
|
||||
object.__setattr__(adapter._config.fc, "signing_key_source", "bogus") # type: ignore[arg-type]
|
||||
with pytest.raises(FcOpenError, match="unknown signing_key_source"):
|
||||
adapter.open(_port(), signing_key=None)
|
||||
Reference in New Issue
Block a user