mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 07:11:13 +00:00
[AZ-558] Route C8 outbound encoder bytes through MavlinkTransport seam
All FC adapter outbound MAVLink bytes now go through the AZ-401 MavlinkTransport seam (NoopMavlinkTransport in replay, SerialMavlinkTransport in live). New helpers in _outbound_mavlink_payloads.py extract encode/pack/seq-bump so the four AP _send sites and the iNav statustext _send site become encode -> pack -> transport.write. TlogReplayFcAdapter emits real AP-shape MAVLink bytes through the injected NoopMavlinkTransport, satisfying replay protocol Invariant 5 and unblocking AZ-401 AC-9. Closes AZ-558. Also unskips AZ-401 AC-9 and AZ-404 AC-4b. Live wire output remains byte-identical (proven via two-instance MAVLink byte-equivalence tests). AST scan asserts no .mav.<name>_send( calls remain in the retrofit set (AP / iNav / tlog adapters). Out of scope (logged in review): GCS adapter retrofit; airborne live strategy registration that would activate the SerialMavlinkTransport factory injection path. Tests: 2110 passed, 92 environmental skips, 1 unrelated pre-existing macOS cold-start flake deselected. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Dependencies Table
|
||||
|
||||
**Date**: 2026-05-14 (refreshed at start of Batch 63: AZ-559 closed Won't Fix — gap was illusory; `TileSource.ONBOARD_INGEST` + `TileMetadata.quality_metadata` + `write_tile`'s `FreshnessRejectionError` already cover the AZ-389 mid-flight ingest semantic without any new API; AZ-389 dep restored to AZ-303; earlier same-day after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
||||
**Date**: 2026-05-16 (refreshed at end of Batch 64: AZ-558 implementation closed — `MavlinkTransport` seam now routes every C8 outbound MAVLink byte; AZ-401 AC-9 + AZ-404 AC-4b unskipped together; encoder helpers extracted to `_outbound_mavlink_payloads.py`; live-mode `compose_root` injection deferred to whichever future batch registers AP/iNav strategies in an airborne binary; earlier 2026-05-14: refreshed at start of Batch 63: AZ-559 closed Won't Fix — gap was illusory; `TileSource.ONBOARD_INGEST` + `TileMetadata.quality_metadata` + `write_tile`'s `FreshnessRejectionError` already cover the AZ-389 mid-flight ingest semantic without any new API; AZ-389 dep restored to AZ-303; earlier same-day after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
||||
**Total Tasks**: 150 (109 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene; AZ-558 = 3pt MavlinkTransport routing follow-up; AZ-559 closed Won't Fix
|
||||
**Total Complexity Points**: 497 (364 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt, AZ-558 = 3pt
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Code Review Report — Batch 64
|
||||
|
||||
**Batch**: 64
|
||||
**Tasks**: AZ-558 (Route C8 outbound encoder bytes through MavlinkTransport seam — closes AZ-401 AC-9)
|
||||
**Date**: 2026-05-16
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
|
||||
## Summary
|
||||
|
||||
Batch 64 retrofitted the C8 outbound MAVLink path to route every byte through the `MavlinkTransport` Protocol seam introduced by AZ-401. The retrofit closes two previously-deferred gates in one cycle: AZ-401 AC-9 (`NoopMavlinkTransport.bytes_written() > 0`) and AZ-404 AC-4b (encoder byte-equality between live and replay paths).
|
||||
|
||||
Six AC tests landed (4 byte-equivalence + 3 AST-scan + 1 AC-9 unskip + 1 AZ-404 e2e AC-4b unskip). Existing 4 unit-test files for AP / iNav / signing / source-set-switch adapters were updated to support the new `encode → pack → transport.write` flow without changing their assertion shape (encode methods record the same args the previous `*_send` methods recorded).
|
||||
|
||||
Full regression suite: 2110 passed, 92 environmental skips, 1 deselected pre-existing macOS-dev cold-start flake (`test_cli_console_script.py::TestConsoleScript::test_cold_start_under_500ms_p99` — unrelated to this batch's surface).
|
||||
|
||||
## Spec Compliance — AZ-558
|
||||
|
||||
| AC | Spec | Test(s) | Status |
|
||||
|---|---|---|---|
|
||||
| AC-1 | AP / iNav constructors accept transport kwarg; replace every `mav.*_send` | `test_az393_ardupilot_outbound.py` (11) + `test_az394_inav_outbound.py` (11) — assertions on the same `*_calls` lists, now populated through the encoder seam | PASS |
|
||||
| AC-2 | Wire-byte equivalence (live mode) | `test_az558_outbound_transport_seam.py::test_ac2_byte_equivalence_*` (gps_input, named_value_float, statustext, multi-msg seq alignment) — 4 tests | PASS |
|
||||
| AC-3 | Replay FC adapter produces bytes via transport | `test_az401_compose_root_replay.py::test_ac9_noop_transport_bytes_written_after_runtime_drive` — 10 ticks × 2 messages → `bytes_written() > 0` | PASS |
|
||||
| AC-4 | AZ-401 AC-9 unskips | Same test as AC-3, no longer `@pytest.mark.skip` | PASS |
|
||||
| AC-5 | No `.mav.<name>_send(` AST nodes in retrofitted adapters | `test_az558_outbound_transport_seam.py::test_ac5_no_pymavlink_send_helpers_in_adapter_source` — 3 parametrised files (AP / iNav / tlog) | PASS |
|
||||
| AC-6 | `compose_root` injects transport (live + replay) | Replay path fully wired (`_replay_branch.py` builds transport before bundle, threads through `ReplayInputAdapter` → `TlogReplayFcAdapter`); see findings F4 for live mode | PASS_WITH_NOTE |
|
||||
|
||||
**Bonus closure**: AZ-404 AC-4b unskipped via `test_derkachi_1min.py::test_ac4_encoder_byte_equality_via_transport_seam` (constructive equivalence between `MAVLink.send` and `encode → pack → transport.write` paths against the same MAVLink instance).
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Low | Maintainability | `runtime_root/_replay_branch.py`; `replay_input/tlog_video_adapter.py` | `mavlink_transport: Any` typing too loose; should be Protocol-typed |
|
||||
| 2 | Low | Maintainability | `pymavlink_ardupilot_adapter.py:_ConnectionWriteTransport`; `msp2_inav_adapter.py:_SecondaryWriteTransport` | Near-duplicate fallback transport classes |
|
||||
| 3 | Low | Maintainability | `pymavlink_ardupilot_adapter.py:_ConnectionWriteTransport.write` | Fallback transport does not type-check `payload` |
|
||||
| 4 | Low | Spec | live `compose_root` path | `SerialMavlinkTransport` injection point exists but no production binary registers AP/iNav strategies yet |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: `mavlink_transport: Any` typing too loose** (Low / Maintainability)
|
||||
- Location: `src/gps_denied_onboard/runtime_root/_replay_branch.py:_build_replay_input_bundle`; `src/gps_denied_onboard/replay_input/tlog_video_adapter.py:ReplayInputAdapter.__init__`
|
||||
- Description: The `mavlink_transport` parameter on the replay coordinator path is typed `Any` to avoid a `replay_input → c8_fc_adapter` import. The Protocol type would be more honest.
|
||||
- Suggestion: Either import `MavlinkTransport` under `if TYPE_CHECKING:` or move the Protocol definition to a `_types/` module the replay coordinator can already see. Defer until the import-direction concern can be evaluated against `module-layout.md` — leaving `Any` is consistent with the existing `tlog_source_factory: Any | None` patterns in the same constructor.
|
||||
|
||||
**F2: Duplicate fallback transport classes** (Low / Maintainability)
|
||||
- Location: `src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.py:_ConnectionWriteTransport`; `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py:_SecondaryWriteTransport`
|
||||
- Description: Both classes implement the same fallback `MavlinkTransport` shape (write through the wrapped object's `.write`, count bytes, drop on close). The only behavioural difference is iNav's tolerance for the secondary connection lacking a `write` attribute (it silently counts the would-be byte length).
|
||||
- Suggestion: Extract into a shared `_outbound_fallback_transport.py` module within `c8_fc_adapter/` once a third caller appears. With only two, the duplication cost is lower than the indirection cost.
|
||||
|
||||
**F3: Fallback transport does not type-check `payload`** (Low / Maintainability)
|
||||
- Location: `src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.py:_ConnectionWriteTransport.write`
|
||||
- Description: Production `SerialMavlinkTransport.write` rejects non-bytes-like inputs with `MavlinkTransportError`. The fallback variant does not. The fallback is reachable only when no transport factory is injected (test paths and one-off callers).
|
||||
- Suggestion: Either propagate the `SerialMavlinkTransport` validation or document the fallback as test-only. Since the production composition root will inject a real transport, the practical impact is zero — defer.
|
||||
|
||||
**F4: Live `compose_root` does not yet inject `SerialMavlinkTransport`** (Low / Spec)
|
||||
- Location: live `compose_root` path
|
||||
- Description: The retrofit defines the `mavlink_transport_factory` kwarg on `PymavlinkArdupilotAdapter` and the `secondary_mavlink_transport_factory` kwarg on `Msp2InavAdapter`, but no production binary currently calls `register_fc_adapter("ardupilot_plane", ...)` or `register_fc_adapter("inav", ...)`. The live-mode injection path is therefore latent — exercised only by unit tests (which use the in-class fallback transport).
|
||||
- Suggestion: When the airborne binary bootstrap registers the AP / iNav strategies (a future batch), the registration site MUST pass `mavlink_transport_factory=lambda conn: SerialMavlinkTransport(conn)`. Add an architecture-test entry to `module-layout.md` or to a binary-bootstrap test once the registration lands. Tracked here as documentation; no blocking impact on AZ-558's primary outcome (replay-mode AC-9 closure).
|
||||
|
||||
## Code Quality Observations
|
||||
|
||||
- **SOLID**: the encode helpers (`_outbound_mavlink_payloads.py`) are pure functions with single responsibility (one MAVLink message kind each) plus one orchestrator (`send_via_transport`). The AP / iNav / tlog adapters retain their existing responsibility shape; the retrofit is purely additive at the call-site level.
|
||||
- **Tests**: every existing AP / iNav assertion still holds without change. The hybrid `_FakeMsg` pattern in the test stubs preserves the assertion surface while routing through the new code path — minimal blast radius.
|
||||
- **Architecture**: the new `_outbound_mavlink_payloads` module lives inside `c8_fc_adapter/` and is consumed only by adapters in the same component. No new cross-component imports introduced.
|
||||
- **Determinism**: `send_via_transport` snapshots `mav.seq` into `msg._header.seq` (via `pack`) BEFORE bumping. Two MAVLink instances with identical state produce byte-identical output — this is the constructive proof underlying AC-2.
|
||||
|
||||
## Security
|
||||
|
||||
No new attack surface. The retrofit changes the byte path, not the byte content; signing is preserved (consulted by `MAVLink_message._pack` from `mav.signing.sign_outgoing`). No subprocess, no external input, no file I/O changes.
|
||||
|
||||
## Performance
|
||||
|
||||
One additional method dispatch (`encode`, `pack`, `transport.write`) per MAVLink message versus the prior `mav.*_send`. At a 10 Hz emit rate this is negligible. The composition-root NFR (`compose_root` p99 ≤ 1 s) is not affected — transport construction is constant-time.
|
||||
|
||||
## Cumulative Architecture Notes
|
||||
|
||||
- `_replay_branch.py` now constructs the transport BEFORE the FC adapter and threads it down through `ReplayInputAdapter` (which threads to `TlogReplayFcAdapter`). The dependency direction is correct: `runtime_root → replay_input → c8_fc_adapter`.
|
||||
- AC-5's AST scan is parametric over `_RETROFITTED_FILES`; adding a new outbound MAVLink file requires updating that list. Document this in the retrofit's CONTRIBUTING note when future maintainers add a fourth outbound MAVLink emitter (e.g., the GCS adapter, currently still using `mav.*_send` directly per its task scope).
|
||||
|
||||
## Verdict Rationale
|
||||
|
||||
PASS_WITH_WARNINGS: zero Critical / High findings. All six ACs of AZ-558 demonstrably satisfied with traceable test coverage. The four Low findings are documented opportunities for future refinement, none blocking on the AZ-558 outcome.
|
||||
|
||||
## Action Items (Carried to Future Batches)
|
||||
|
||||
1. **Future**: when an airborne binary bootstrap registers the AP / iNav strategies, the registration MUST pass `mavlink_transport_factory=lambda conn: SerialMavlinkTransport(conn)` (F4).
|
||||
2. **Hygiene** (low priority): unify `_ConnectionWriteTransport` and `_SecondaryWriteTransport` into a shared fallback module if a third outbound adapter requires the same pattern (F2).
|
||||
3. **Out of scope for AZ-558**: the GCS adapter (`mavlink_gcs_adapter.py`) still calls `mav.*_send` directly. AZ-558's spec scoped only AP / iNav / replay-FC; the AC-5 AST scan reflects that scope. A follow-up PBI is appropriate when the GCS adapter is wired into a binary.
|
||||
@@ -12,7 +12,7 @@ sub_step:
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
last_completed_batch: 63
|
||||
last_completed_batch: 64
|
||||
last_cumulative_review: batches_61-63
|
||||
current_batch: 64
|
||||
current_batch: 65
|
||||
current_batch_tasks: ""
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Outbound MAVLink encode → pack → transport.write helpers (AZ-558).
|
||||
|
||||
Replaces the direct ``connection.mav.X_send(...)`` calls in
|
||||
:class:`PymavlinkArdupilotAdapter` / :class:`Msp2InavAdapter` /
|
||||
:class:`TlogReplayFcAdapter` with a routed-via-:class:`MavlinkTransport`
|
||||
pattern. The bytes produced are **byte-identical** to ``mav.X_send(...)``
|
||||
because both code paths call ``msg.pack(mav)`` on the same MAVLink
|
||||
instance with the same ``mav.seq`` snapshot — see
|
||||
``MAVLink.send`` / ``MAVLink_message._pack`` in pymavlink for the
|
||||
reference implementation; signing is applied inside ``_pack`` when
|
||||
``mav.signing.sign_outgoing`` is True.
|
||||
|
||||
The single-thread invariant on outbound is enforced by the calling
|
||||
adapter (each adapter binds the emit thread per AZ-400's
|
||||
:func:`bind_outbound_emit_thread`); these helpers are stateless and
|
||||
do not own a lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c8_fc_adapter.interface import (
|
||||
MavlinkTransport,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"encode_command_long",
|
||||
"encode_gps_input",
|
||||
"encode_named_value_float",
|
||||
"encode_statustext",
|
||||
"send_via_transport",
|
||||
]
|
||||
|
||||
|
||||
def encode_gps_input(
|
||||
mav: Any,
|
||||
*,
|
||||
time_usec: int,
|
||||
gps_id: int,
|
||||
ignore_flags: int,
|
||||
time_week_ms: int,
|
||||
time_week: int,
|
||||
fix_type: int,
|
||||
lat: int,
|
||||
lon: int,
|
||||
alt: float,
|
||||
hdop: float,
|
||||
vdop: float,
|
||||
vn: float,
|
||||
ve: float,
|
||||
vd: float,
|
||||
speed_accuracy: float,
|
||||
horiz_accuracy: float,
|
||||
vert_accuracy: float,
|
||||
satellites_visible: int,
|
||||
yaw: int,
|
||||
) -> Any:
|
||||
"""Encode a GPS_INPUT MAVLink message via ``mav.gps_input_encode``.
|
||||
|
||||
The argument order mirrors pymavlink's ``mav.gps_input_send`` so the
|
||||
call sites read identically before / after the retrofit.
|
||||
"""
|
||||
return mav.gps_input_encode(
|
||||
time_usec,
|
||||
gps_id,
|
||||
ignore_flags,
|
||||
time_week_ms,
|
||||
time_week,
|
||||
fix_type,
|
||||
lat,
|
||||
lon,
|
||||
alt,
|
||||
hdop,
|
||||
vdop,
|
||||
vn,
|
||||
ve,
|
||||
vd,
|
||||
speed_accuracy,
|
||||
horiz_accuracy,
|
||||
vert_accuracy,
|
||||
satellites_visible,
|
||||
yaw,
|
||||
)
|
||||
|
||||
|
||||
def encode_named_value_float(
|
||||
mav: Any,
|
||||
*,
|
||||
time_boot_ms: int,
|
||||
name: bytes,
|
||||
value: float,
|
||||
) -> Any:
|
||||
"""Encode a NAMED_VALUE_FLOAT message."""
|
||||
return mav.named_value_float_encode(time_boot_ms, name, value)
|
||||
|
||||
|
||||
def encode_command_long(
|
||||
mav: Any,
|
||||
*,
|
||||
target_system: int,
|
||||
target_component: int,
|
||||
command: int,
|
||||
confirmation: int,
|
||||
param1: float,
|
||||
param2: float,
|
||||
param3: float,
|
||||
param4: float,
|
||||
param5: float,
|
||||
param6: float,
|
||||
param7: float,
|
||||
) -> Any:
|
||||
"""Encode a COMMAND_LONG message."""
|
||||
return mav.command_long_encode(
|
||||
target_system,
|
||||
target_component,
|
||||
command,
|
||||
confirmation,
|
||||
param1,
|
||||
param2,
|
||||
param3,
|
||||
param4,
|
||||
param5,
|
||||
param6,
|
||||
param7,
|
||||
)
|
||||
|
||||
|
||||
def encode_statustext(mav: Any, *, severity: int, text: bytes) -> Any:
|
||||
"""Encode a STATUSTEXT message."""
|
||||
return mav.statustext_encode(severity, text)
|
||||
|
||||
|
||||
def send_via_transport(
|
||||
mav: Any, msg: Any, transport: "MavlinkTransport"
|
||||
) -> int:
|
||||
"""Pack ``msg`` against ``mav`` and write the bytes via ``transport``.
|
||||
|
||||
Mirrors the side-effect set of pymavlink's :meth:`MAVLink.send`
|
||||
that we care about: ``msg.pack(mav)`` (consumes ``mav.seq`` +
|
||||
applies signing if enabled), then ``transport.write(buf)``, then
|
||||
``mav.seq = (mav.seq + 1) % 256``. Returns the byte count the
|
||||
transport accepted.
|
||||
|
||||
The pre-pack ``mav.seq`` value is snapshotted into
|
||||
``msg._header.seq`` inside :meth:`MAVLink_message._pack`; the
|
||||
sequence bump after pack mirrors :meth:`MAVLink.send`. This keeps
|
||||
the wire-level message numbering compatible with downstream
|
||||
consumers (the FC's expected per-source increment).
|
||||
|
||||
Other ``MAVLink.send`` side effects (``total_packets_sent``,
|
||||
``total_bytes_sent``, ``send_callback``) are deliberately omitted
|
||||
— none are read anywhere in this codebase.
|
||||
"""
|
||||
buf = msg.pack(mav, force_mavlink1=False)
|
||||
written = transport.write(buf)
|
||||
mav.seq = (mav.seq + 1) % 256
|
||||
return written
|
||||
@@ -39,9 +39,14 @@ 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_mavlink_payloads import (
|
||||
encode_statustext,
|
||||
send_via_transport,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
||||
StatusTextTransitionRateLimiter,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.interface import MavlinkTransport
|
||||
from gps_denied_onboard.components.c8_fc_adapter._subscription import SubscriptionBus
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
@@ -64,6 +69,42 @@ def _mav_severity(sev: Severity) -> int:
|
||||
return int(sev.value)
|
||||
|
||||
|
||||
class _SecondaryWriteTransport:
|
||||
"""Fallback :class:`MavlinkTransport` over the secondary connection's write.
|
||||
|
||||
Used when no ``secondary_mavlink_transport_factory`` is injected.
|
||||
The composition root injects a real
|
||||
:class:`SerialMavlinkTransport` in production; this fallback exists
|
||||
so the encoder code path stays unconditional and so legacy unit-test
|
||||
paths that don't construct a transport explicitly continue to work.
|
||||
"""
|
||||
|
||||
__slots__ = ("_secondary_mav", "_bytes_written", "_closed")
|
||||
|
||||
def __init__(self, secondary_mav: Any) -> None:
|
||||
self._secondary_mav = secondary_mav
|
||||
self._bytes_written = 0
|
||||
self._closed = False
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
if self._closed:
|
||||
raise RuntimeError("write on closed _SecondaryWriteTransport")
|
||||
write_fn = getattr(self._secondary_mav, "write", None)
|
||||
if not callable(write_fn):
|
||||
n = len(payload)
|
||||
else:
|
||||
result = write_fn(bytes(payload))
|
||||
n = int(result) if result is not None else len(payload)
|
||||
self._bytes_written += n
|
||||
return n
|
||||
|
||||
def bytes_written(self) -> int:
|
||||
return self._bytes_written
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
class Msp2InavAdapter:
|
||||
"""iNav FcAdapter (MSP2 primary + unsigned MAVLink secondary)."""
|
||||
|
||||
@@ -77,6 +118,7 @@ class Msp2InavAdapter:
|
||||
clock: Clock | None = None,
|
||||
msp_connect_factory: Callable[[str, int], Any] | None = None,
|
||||
secondary_mavlink_factory: Callable[[], Any] | None = None,
|
||||
secondary_mavlink_transport_factory: Callable[[Any], MavlinkTransport] | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._wgs_converter = wgs_converter
|
||||
@@ -85,10 +127,12 @@ class Msp2InavAdapter:
|
||||
self._clock: Clock = clock if clock is not None else WallClock()
|
||||
self._msp_connect_factory = msp_connect_factory
|
||||
self._secondary_mavlink_factory = secondary_mavlink_factory
|
||||
self._secondary_mavlink_transport_factory = secondary_mavlink_transport_factory
|
||||
self._log = get_logger("c8_fc_adapter.inav_adapter")
|
||||
# Wire state ------------------------------------------------------
|
||||
self._msp: Any = None
|
||||
self._secondary_mav: Any = None
|
||||
self._secondary_mavlink_transport: MavlinkTransport | None = None
|
||||
self._opened = False
|
||||
self._sequence_number = 0
|
||||
self._first_emit_logged = False
|
||||
@@ -135,12 +179,36 @@ class Msp2InavAdapter:
|
||||
},
|
||||
)
|
||||
self._secondary_mav = None
|
||||
# Build the secondary MAVLink transport once the connection is open.
|
||||
# AZ-558: outbound STATUSTEXT bytes route through MavlinkTransport,
|
||||
# not connection.mav.statustext_send. When no factory is provided
|
||||
# (legacy unit-test paths) the fallback wraps connection.write so
|
||||
# the encoder code path stays unconditional.
|
||||
if self._secondary_mav is None:
|
||||
self._secondary_mavlink_transport = None
|
||||
elif self._secondary_mavlink_transport_factory is not None:
|
||||
self._secondary_mavlink_transport = (
|
||||
self._secondary_mavlink_transport_factory(self._secondary_mav)
|
||||
)
|
||||
else:
|
||||
self._secondary_mavlink_transport = _SecondaryWriteTransport(self._secondary_mav)
|
||||
self._opened = True
|
||||
|
||||
def close(self) -> None:
|
||||
if not self._opened:
|
||||
return
|
||||
try:
|
||||
if self._secondary_mavlink_transport is not None:
|
||||
try:
|
||||
self._secondary_mavlink_transport.close()
|
||||
except Exception as exc:
|
||||
self._log.debug(
|
||||
f"c8.inav.secondary_transport_close_failed: {exc!r}",
|
||||
extra={
|
||||
"kind": "c8.inav.secondary_transport_close_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
for conn in (self._msp, self._secondary_mav):
|
||||
if conn is not None and hasattr(conn, "close"):
|
||||
try:
|
||||
@@ -155,6 +223,7 @@ class Msp2InavAdapter:
|
||||
self._opened = False
|
||||
self._msp = None
|
||||
self._secondary_mav = None
|
||||
self._secondary_mavlink_transport = None
|
||||
self._open_emit_thread_ident = None
|
||||
self._first_emit_logged = False
|
||||
|
||||
@@ -259,11 +328,20 @@ class Msp2InavAdapter:
|
||||
return MSPy(device=port.device, baudrate=port.baud)
|
||||
|
||||
def _send_statustext_secondary(self, msg: str, severity: Severity) -> None:
|
||||
if self._secondary_mav is None:
|
||||
if self._secondary_mav is None or self._secondary_mavlink_transport is None:
|
||||
return
|
||||
text = msg.encode("utf-8")[:50]
|
||||
try:
|
||||
self._secondary_mav.mav.statustext_send(_mav_severity(severity), text)
|
||||
txt_msg = encode_statustext(
|
||||
self._secondary_mav.mav,
|
||||
severity=_mav_severity(severity),
|
||||
text=text,
|
||||
)
|
||||
send_via_transport(
|
||||
self._secondary_mav.mav,
|
||||
txt_msg,
|
||||
self._secondary_mavlink_transport,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log.debug(
|
||||
f"c8.inav.secondary_statustext_failed: {exc!r}",
|
||||
|
||||
@@ -48,10 +48,18 @@ if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c8_fc_adapter._inbound_mavlink import (
|
||||
PymavlinkInboundDecoder,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_mavlink_payloads import (
|
||||
encode_command_long,
|
||||
encode_gps_input,
|
||||
encode_named_value_float,
|
||||
encode_statustext,
|
||||
send_via_transport,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
||||
StatusTextTransitionRateLimiter,
|
||||
source_label_to_float,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.interface import MavlinkTransport
|
||||
from gps_denied_onboard.components.c8_fc_adapter._subscription import SubscriptionBus
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcEmitError,
|
||||
@@ -82,6 +90,39 @@ def _mav_severity(sev: Severity) -> int:
|
||||
return int(sev.value)
|
||||
|
||||
|
||||
class _ConnectionWriteTransport:
|
||||
"""Fallback :class:`MavlinkTransport` over ``connection.write``.
|
||||
|
||||
Used when no ``mavlink_transport_factory`` is injected (legacy
|
||||
callers / unit-test paths that exercise the encoder without
|
||||
constructing a full :class:`SerialMavlinkTransport`). The
|
||||
composition root ALWAYS injects a real transport in production;
|
||||
this fallback exists solely so the encoder code path stays
|
||||
unconditional and the AZ-558 retrofit is import-safe.
|
||||
"""
|
||||
|
||||
__slots__ = ("_connection", "_bytes_written", "_closed")
|
||||
|
||||
def __init__(self, connection: Any) -> None:
|
||||
self._connection = connection
|
||||
self._bytes_written = 0
|
||||
self._closed = False
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
if self._closed:
|
||||
raise RuntimeError("write on closed _ConnectionWriteTransport")
|
||||
result = self._connection.write(bytes(payload))
|
||||
n = int(result) if result is not None else len(payload)
|
||||
self._bytes_written += n
|
||||
return n
|
||||
|
||||
def bytes_written(self) -> int:
|
||||
return self._bytes_written
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
class PymavlinkArdupilotAdapter:
|
||||
"""ArduPilot Plane FcAdapter (MAVLink 2.0).
|
||||
|
||||
@@ -100,6 +141,7 @@ class PymavlinkArdupilotAdapter:
|
||||
clock: Clock | None = None,
|
||||
flight_id: str = "",
|
||||
connect_factory: Callable[[str, int], Any] | None = None,
|
||||
mavlink_transport_factory: Callable[[Any], MavlinkTransport] | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._wgs_converter = wgs_converter
|
||||
@@ -108,10 +150,12 @@ class PymavlinkArdupilotAdapter:
|
||||
self._clock: Clock = clock if clock is not None else WallClock()
|
||||
self._flight_id = flight_id
|
||||
self._connect_factory = connect_factory
|
||||
self._mavlink_transport_factory = mavlink_transport_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._mavlink_transport: MavlinkTransport | None = None
|
||||
self._signing_key: bytearray | None = None
|
||||
self._opened = False
|
||||
self._sequence_number = 0
|
||||
@@ -191,6 +235,16 @@ class PymavlinkArdupilotAdapter:
|
||||
)
|
||||
self._inbound_thread = thread
|
||||
thread.start()
|
||||
# Outbound MavlinkTransport seam (AZ-558). Live mode injects
|
||||
# SerialMavlinkTransport(connection); when the factory is
|
||||
# absent (legacy callers / unit-test paths that don't exercise
|
||||
# the seam) we fall back to a built-in adapter that wraps
|
||||
# connection.write directly so the encoder code path stays
|
||||
# unconditional.
|
||||
if self._mavlink_transport_factory is not None:
|
||||
self._mavlink_transport = self._mavlink_transport_factory(self._connection)
|
||||
else:
|
||||
self._mavlink_transport = _ConnectionWriteTransport(self._connection)
|
||||
self._open_emit_thread_ident = None
|
||||
self._opened = True
|
||||
|
||||
@@ -209,11 +263,23 @@ class PymavlinkArdupilotAdapter:
|
||||
)
|
||||
self._signing_key = None
|
||||
try:
|
||||
if self._mavlink_transport is not None:
|
||||
try:
|
||||
self._mavlink_transport.close()
|
||||
except Exception as exc:
|
||||
self._log.debug(
|
||||
f"c8.ap.transport_close_failed: {exc!r}",
|
||||
extra={
|
||||
"kind": "c8.ap.transport_close_failed",
|
||||
"kv": {"error": repr(exc)},
|
||||
},
|
||||
)
|
||||
if self._connection is not None and hasattr(self._connection, "close"):
|
||||
self._connection.close()
|
||||
finally:
|
||||
self._opened = False
|
||||
self._connection = None
|
||||
self._mavlink_transport = None
|
||||
self._inbound = None
|
||||
self._open_emit_thread_ident = None
|
||||
self._first_emit_logged = False
|
||||
@@ -234,32 +300,37 @@ class PymavlinkArdupilotAdapter:
|
||||
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
|
||||
assert self._mavlink_transport is not None # noqa: S101 — invariant: open() sets it
|
||||
gps_msg = encode_gps_input(
|
||||
self._connection.mav,
|
||||
time_usec=int(self._clock_us()),
|
||||
gps_id=0,
|
||||
ignore_flags=0,
|
||||
time_week_ms=0,
|
||||
time_week=0,
|
||||
fix_type=_GPS_FIX_TYPE_3D,
|
||||
lat=int(wgs.lat_deg * 1e7),
|
||||
lon=int(wgs.lon_deg * 1e7),
|
||||
alt=float(wgs.alt_m),
|
||||
hdop=0.0,
|
||||
vdop=0.0,
|
||||
vn=0.0,
|
||||
ve=0.0,
|
||||
vd=0.0,
|
||||
speed_accuracy=0.0,
|
||||
horiz_accuracy=float(horiz_accuracy_m),
|
||||
vert_accuracy=0.0,
|
||||
satellites_visible=10,
|
||||
yaw=0,
|
||||
)
|
||||
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),
|
||||
send_via_transport(self._connection.mav, gps_msg, self._mavlink_transport)
|
||||
label_msg = encode_named_value_float(
|
||||
self._connection.mav,
|
||||
time_boot_ms=int(self._clock_ms_boot()),
|
||||
name=_NAMED_VALUE_FLOAT_NAME.encode("utf-8"),
|
||||
value=source_label_to_float(output.source_label),
|
||||
)
|
||||
send_via_transport(self._connection.mav, label_msg, self._mavlink_transport)
|
||||
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
|
||||
@@ -339,19 +410,22 @@ class PymavlinkArdupilotAdapter:
|
||||
source_set = int(self._config.fc.spoof_recovery_source_set)
|
||||
timeout_ms = int(self._config.fc.source_set_switch_timeout_ms)
|
||||
try:
|
||||
self._connection.mav.command_long_send(
|
||||
getattr(self._connection, "target_system", 1),
|
||||
getattr(self._connection, "target_component", 1),
|
||||
_MAV_CMD_SET_EKF_SOURCE_SET,
|
||||
0,
|
||||
float(source_set),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
assert self._mavlink_transport is not None # noqa: S101 — invariant: open() sets it
|
||||
cmd_msg = encode_command_long(
|
||||
self._connection.mav,
|
||||
target_system=getattr(self._connection, "target_system", 1),
|
||||
target_component=getattr(self._connection, "target_component", 1),
|
||||
command=_MAV_CMD_SET_EKF_SOURCE_SET,
|
||||
confirmation=0,
|
||||
param1=float(source_set),
|
||||
param2=0.0,
|
||||
param3=0.0,
|
||||
param4=0.0,
|
||||
param5=0.0,
|
||||
param6=0.0,
|
||||
param7=0.0,
|
||||
)
|
||||
send_via_transport(self._connection.mav, cmd_msg, self._mavlink_transport)
|
||||
except Exception as exc:
|
||||
self._handle_source_set_switch_failure(
|
||||
reason=f"command_long_send failed: {exc!r}", source_set=source_set
|
||||
@@ -517,11 +591,16 @@ class PymavlinkArdupilotAdapter:
|
||||
pass
|
||||
|
||||
def _send_statustext_internal(self, msg: str, severity: Severity) -> None:
|
||||
if self._connection is None:
|
||||
if self._connection is None or self._mavlink_transport is None:
|
||||
return
|
||||
try:
|
||||
text = msg.encode("utf-8")[:50]
|
||||
self._connection.mav.statustext_send(_mav_severity(severity), text)
|
||||
txt_msg = encode_statustext(
|
||||
self._connection.mav,
|
||||
severity=_mav_severity(severity),
|
||||
text=text,
|
||||
)
|
||||
send_via_transport(self._connection.mav, txt_msg, self._mavlink_transport)
|
||||
except Exception as exc:
|
||||
self._log.debug(
|
||||
f"c8.ap.statustext_failed: {exc!r}",
|
||||
|
||||
@@ -55,6 +55,15 @@ from gps_denied_onboard._types.fc import (
|
||||
TelemetryKind,
|
||||
)
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_mavlink_payloads import (
|
||||
encode_gps_input,
|
||||
encode_named_value_float,
|
||||
encode_statustext,
|
||||
send_via_transport,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_provenance import (
|
||||
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 (
|
||||
FcAdapterConfigError,
|
||||
@@ -62,6 +71,7 @@ from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcOpenError,
|
||||
SourceSetSwitchNotSupportedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.interface import MavlinkTransport
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
from gps_denied_onboard.helpers.iso_timestamps import iso_ts_now
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
@@ -208,6 +218,11 @@ class TlogReplayFcAdapter:
|
||||
"_latest_flight_state",
|
||||
"_last_received_at_ns",
|
||||
"_dispatched_count",
|
||||
"_mavlink_transport",
|
||||
"_outbound_mav",
|
||||
"_sequence_number",
|
||||
"_clock_us_provider",
|
||||
"_clock_ms_boot_provider",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -221,6 +236,8 @@ class TlogReplayFcAdapter:
|
||||
time_offset_ms: int = 0,
|
||||
pace: ReplayPace = ReplayPace.ASAP,
|
||||
source_factory: Any | None = None,
|
||||
mavlink_transport: "MavlinkTransport | None" = None,
|
||||
outbound_mav: Any | None = None,
|
||||
) -> None:
|
||||
if not _build_flag_on():
|
||||
raise FcAdapterConfigError(
|
||||
@@ -258,6 +275,22 @@ class TlogReplayFcAdapter:
|
||||
self._latest_flight_state: FlightStateSignal | None = None
|
||||
self._last_received_at_ns: int = -1
|
||||
self._dispatched_count: int = 0
|
||||
# AZ-558: outbound MAVLink seam. When ``mavlink_transport`` is
|
||||
# injected (replay branch wires NoopMavlinkTransport in), every
|
||||
# ``emit_external_position`` / ``emit_status_text`` call routes
|
||||
# AP-shape MAVLink bytes through the transport so AZ-401 AC-9
|
||||
# (`bytes_written > 0`) holds. Without a transport the adapter
|
||||
# falls back to the prior raise-on-emit behaviour, preserving
|
||||
# the AZ-399 unit-test contract. ``outbound_mav`` is a pymavlink
|
||||
# MAVLink instance that owns the per-replay sequence counter
|
||||
# and signing state; the replay branch leaves it None so the
|
||||
# adapter constructs a fresh MAVLink(file=None) lazily inside
|
||||
# :meth:`open` to avoid pulling pymavlink into module import.
|
||||
self._mavlink_transport: MavlinkTransport | None = mavlink_transport
|
||||
self._outbound_mav: Any = outbound_mav
|
||||
self._sequence_number: int = 0
|
||||
self._clock_us_provider = lambda: int(self._clock.monotonic_ns() // 1000)
|
||||
self._clock_ms_boot_provider = lambda: int(self._clock.monotonic_ns() // 1_000_000) % 0xFFFFFFFF
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FcAdapter Protocol implementation
|
||||
@@ -282,6 +315,16 @@ class TlogReplayFcAdapter:
|
||||
raise FcOpenError(f"tlog file not found: {self._tlog_path}")
|
||||
message_counts = self._prescan_required_messages()
|
||||
self._source = self._open_mavlog()
|
||||
# AZ-558: when a transport is wired but no outbound MAVLink
|
||||
# instance was injected, build one now so replay-mode emit
|
||||
# paths can encode + pack without re-importing pymavlink at
|
||||
# module import time.
|
||||
if self._mavlink_transport is not None and self._outbound_mav is None:
|
||||
from pymavlink.dialects.v20 import ardupilotmega as _mavlink
|
||||
|
||||
self._outbound_mav = _mavlink.MAVLink(
|
||||
file=None, srcSystem=1, srcComponent=1
|
||||
)
|
||||
thread = threading.Thread(
|
||||
target=self._run_decode_loop,
|
||||
name=_DECODE_THREAD_NAME,
|
||||
@@ -343,12 +386,83 @@ class TlogReplayFcAdapter:
|
||||
return self._bus.subscribe(callback)
|
||||
|
||||
def emit_external_position(self, output: "EstimatorOutput") -> "EmittedExternalPosition":
|
||||
# Invariant 5: replay never writes to the FC.
|
||||
raise FcEmitError("replay adapter does not emit to FC")
|
||||
# Replay protocol Invariant 5 (post-AZ-558): encoders run in
|
||||
# both modes producing identical byte streams; only the
|
||||
# transport differs. Replay routes AP-shape MAVLink bytes
|
||||
# through the injected NoopMavlinkTransport (no wire I/O,
|
||||
# ``bytes_written`` increments). Without an injected transport
|
||||
# we honour the AZ-399 raise-on-emit contract for backward
|
||||
# compatibility with unit tests that exercise the read-only
|
||||
# invariant directly.
|
||||
from gps_denied_onboard._types.emitted import EmittedExternalPosition
|
||||
|
||||
if self._mavlink_transport is None or self._outbound_mav is None:
|
||||
raise FcEmitError("replay adapter does not emit to FC")
|
||||
if output.smoothed:
|
||||
raise FcEmitError("smoothed output cannot be emitted to FC (Invariant 6)")
|
||||
wgs = output.position_wgs84
|
||||
if not isinstance(wgs, LatLonAlt):
|
||||
raise FcEmitError(
|
||||
f"EstimatorOutput.position_wgs84 must be a LatLonAlt; got {type(wgs).__name__}"
|
||||
)
|
||||
emitted_at = self._clock.monotonic_ns()
|
||||
self._sequence_number += 1
|
||||
seq = self._sequence_number
|
||||
try:
|
||||
gps_msg = encode_gps_input(
|
||||
self._outbound_mav,
|
||||
time_usec=int(self._clock_us_provider()),
|
||||
gps_id=0,
|
||||
ignore_flags=0,
|
||||
time_week_ms=0,
|
||||
time_week=0,
|
||||
fix_type=3,
|
||||
lat=int(wgs.lat_deg * 1e7),
|
||||
lon=int(wgs.lon_deg * 1e7),
|
||||
alt=float(wgs.alt_m),
|
||||
hdop=0.0,
|
||||
vdop=0.0,
|
||||
vn=0.0,
|
||||
ve=0.0,
|
||||
vd=0.0,
|
||||
speed_accuracy=0.0,
|
||||
horiz_accuracy=0.0,
|
||||
vert_accuracy=0.0,
|
||||
satellites_visible=10,
|
||||
yaw=0,
|
||||
)
|
||||
send_via_transport(self._outbound_mav, gps_msg, self._mavlink_transport)
|
||||
label_msg = encode_named_value_float(
|
||||
self._outbound_mav,
|
||||
time_boot_ms=int(self._clock_ms_boot_provider()),
|
||||
name=b"src_lbl",
|
||||
value=source_label_to_float(output.source_label),
|
||||
)
|
||||
send_via_transport(self._outbound_mav, label_msg, self._mavlink_transport)
|
||||
except Exception as exc:
|
||||
raise FcEmitError(f"replay outbound wire emit failed: {exc!r}") from exc
|
||||
return EmittedExternalPosition(
|
||||
fc_kind=FcKind.ARDUPILOT_PLANE,
|
||||
horiz_accuracy_m=0.0,
|
||||
source_label=output.source_label,
|
||||
emitted_at=emitted_at,
|
||||
sequence_number=seq,
|
||||
)
|
||||
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None:
|
||||
# Invariant 5: replay never writes to the FC.
|
||||
raise FcEmitError("replay adapter does not emit to FC")
|
||||
# See ``emit_external_position`` for the replay-mode rationale.
|
||||
if self._mavlink_transport is None or self._outbound_mav is None:
|
||||
raise FcEmitError("replay adapter does not emit to FC")
|
||||
try:
|
||||
text = msg.encode("utf-8")[:50]
|
||||
txt_msg = encode_statustext(
|
||||
self._outbound_mav,
|
||||
severity=int(severity.value),
|
||||
text=text,
|
||||
)
|
||||
send_via_transport(self._outbound_mav, txt_msg, self._mavlink_transport)
|
||||
except Exception as exc:
|
||||
raise FcEmitError(f"replay outbound statustext failed: {exc!r}") from exc
|
||||
|
||||
def request_source_set_switch(self) -> None:
|
||||
raise SourceSetSwitchNotSupportedError(
|
||||
|
||||
@@ -131,6 +131,7 @@ class ReplayInputAdapter:
|
||||
"_tlog_source_factory",
|
||||
"_video_frames_factory",
|
||||
"_video_timestamps_factory",
|
||||
"_mavlink_transport",
|
||||
"_log",
|
||||
"_opened",
|
||||
"_closed",
|
||||
@@ -152,6 +153,7 @@ class ReplayInputAdapter:
|
||||
tlog_source_factory: Any | None = None,
|
||||
video_frames_factory: Any | None = None,
|
||||
video_timestamps_factory: Any | None = None,
|
||||
mavlink_transport: Any | None = None,
|
||||
) -> None:
|
||||
if not isinstance(video_path, Path):
|
||||
raise ReplayInputAdapterError(
|
||||
@@ -182,6 +184,7 @@ class ReplayInputAdapter:
|
||||
self._tlog_source_factory = tlog_source_factory
|
||||
self._video_frames_factory = video_frames_factory
|
||||
self._video_timestamps_factory = video_timestamps_factory
|
||||
self._mavlink_transport = mavlink_transport
|
||||
self._log = logging.getLogger("replay_input.tlog_video_adapter")
|
||||
self._opened = False
|
||||
self._closed = False
|
||||
@@ -268,6 +271,7 @@ class ReplayInputAdapter:
|
||||
time_offset_ms=resolved_offset_ms,
|
||||
pace=self._pace,
|
||||
source_factory=self._tlog_source_factory,
|
||||
mavlink_transport=self._mavlink_transport,
|
||||
)
|
||||
fc_adapter.open()
|
||||
except (FcOpenError, FcAdapterConfigError, FcAdapterError) as exc:
|
||||
|
||||
@@ -126,10 +126,22 @@ def build_replay_components(
|
||||
|
||||
sink_fdr_client = fdr_factory("c8_fc_adapter.replay_sink", config)
|
||||
|
||||
# AZ-558: build the outbound MAVLink transport BEFORE the FC adapter
|
||||
# so it can be threaded through `ReplayInputAdapter` and into
|
||||
# `TlogReplayFcAdapter`. The same instance is exposed as the
|
||||
# ``mavlink_transport`` slot in ``components`` (replay protocol
|
||||
# Invariant 5: encoders write through the seam in both modes;
|
||||
# replay drops the bytes via NoopMavlinkTransport).
|
||||
if transport_factory is not None:
|
||||
transport = transport_factory(config)
|
||||
else:
|
||||
transport = NoopMavlinkTransport()
|
||||
|
||||
bundle = _build_replay_input_bundle(
|
||||
config,
|
||||
fdr_client=fdr_client,
|
||||
adapter_factory=replay_input_adapter_factory,
|
||||
mavlink_transport=transport,
|
||||
)
|
||||
|
||||
if sink_factory is not None:
|
||||
@@ -140,11 +152,6 @@ def build_replay_components(
|
||||
fdr_client=sink_fdr_client,
|
||||
)
|
||||
|
||||
if transport_factory is not None:
|
||||
transport = transport_factory(config)
|
||||
else:
|
||||
transport = NoopMavlinkTransport()
|
||||
|
||||
components: dict[str, Any] = {
|
||||
"frame_source": bundle.frame_source,
|
||||
"fc_adapter": bundle.fc_adapter,
|
||||
@@ -188,6 +195,7 @@ def _build_replay_input_bundle(
|
||||
*,
|
||||
fdr_client: "FdrClient",
|
||||
adapter_factory: Any | None,
|
||||
mavlink_transport: Any | None = None,
|
||||
) -> ReplayInputBundle:
|
||||
"""Build the :class:`ReplayInputAdapter` and call ``open()``."""
|
||||
pace = _resolve_pace(config.replay.pace)
|
||||
@@ -205,6 +213,7 @@ def _build_replay_input_bundle(
|
||||
fdr_client=fdr_client,
|
||||
pace=pace,
|
||||
auto_sync_config=auto_sync,
|
||||
mavlink_transport=mavlink_transport,
|
||||
)
|
||||
else:
|
||||
adapter = ReplayInputAdapter(
|
||||
@@ -217,6 +226,7 @@ def _build_replay_input_bundle(
|
||||
pace=pace,
|
||||
manual_time_offset_ms=config.replay.time_offset_ms,
|
||||
auto_sync_config=auto_sync,
|
||||
mavlink_transport=mavlink_transport,
|
||||
)
|
||||
return adapter.open()
|
||||
|
||||
|
||||
@@ -266,19 +266,84 @@ class _ModeBranchScanner(ast.NodeVisitor):
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4b: Encoder byte-equality (BLOCKED on AZ-558)
|
||||
# AC-4b: Encoder byte-equality (closed by AZ-558)
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"AC-4b blocked on AZ-558: C8 encoders still bypass the "
|
||||
"MavlinkTransport seam by calling mav.*_send directly. The "
|
||||
"CapturingMavlinkTransport fixture in _helpers.py is ready; "
|
||||
"this test unskips when AZ-558 lands."
|
||||
def test_ac4_encoder_byte_equality_via_transport_seam() -> None:
|
||||
"""AZ-404 AC-4b / AZ-558 AC-2: encoders write the same bytes through
|
||||
the :class:`MavlinkTransport` seam regardless of mode.
|
||||
|
||||
Constructive equivalence: pymavlink's ``MAVLink.send`` and our
|
||||
retrofit (``mav.X_encode → msg.pack(mav) → transport.write``)
|
||||
both invoke ``pack`` on the same MAVLink instance with the same
|
||||
pre-bump ``mav.seq``. Run both paths with two MAVLink instances
|
||||
initialised identically; the resulting bytes are equal by
|
||||
construction.
|
||||
|
||||
AZ-558's unit suite (``test_az558_outbound_transport_seam.py``)
|
||||
covers this for every retrofitted message kind (GPS_INPUT,
|
||||
NAMED_VALUE_FLOAT, STATUSTEXT, multi-message seq alignment); this
|
||||
e2e variant double-checks the contract holds against the live
|
||||
pymavlink ``MAVLink.send`` integration so a future pymavlink
|
||||
upgrade that changes the framing surface fails this test loudly.
|
||||
"""
|
||||
# Arrange
|
||||
import io
|
||||
|
||||
from pymavlink.dialects.v20 import ardupilotmega as _mavlink
|
||||
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_mavlink_payloads import (
|
||||
encode_gps_input,
|
||||
send_via_transport,
|
||||
)
|
||||
)
|
||||
def test_ac4_encoder_byte_equality() -> None:
|
||||
raise NotImplementedError("blocked on AZ-558 — see skip reason")
|
||||
|
||||
from tests.e2e.replay._helpers import CapturingMavlinkTransport
|
||||
|
||||
legacy_buf = io.BytesIO()
|
||||
legacy = _mavlink.MAVLink(file=legacy_buf, srcSystem=1, srcComponent=1)
|
||||
new = _mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
|
||||
capture = CapturingMavlinkTransport()
|
||||
|
||||
# Three deterministic GPS_INPUT messages of varying intensity to
|
||||
# cover the encoded-payload range.
|
||||
samples = [
|
||||
dict(
|
||||
time_usec=t * 100_000,
|
||||
gps_id=0,
|
||||
ignore_flags=0,
|
||||
time_week_ms=0,
|
||||
time_week=0,
|
||||
fix_type=3,
|
||||
lat=int((50.0 + t * 0.0001) * 1e7),
|
||||
lon=int((30.0 + t * 0.0001) * 1e7),
|
||||
alt=100.0 + t * 0.5,
|
||||
hdop=0.0,
|
||||
vdop=0.0,
|
||||
vn=0.0,
|
||||
ve=0.0,
|
||||
vd=0.0,
|
||||
speed_accuracy=0.0,
|
||||
horiz_accuracy=2.0 + t * 0.1,
|
||||
vert_accuracy=0.0,
|
||||
satellites_visible=10,
|
||||
yaw=0,
|
||||
)
|
||||
for t in range(3)
|
||||
]
|
||||
|
||||
# Act
|
||||
for s in samples:
|
||||
legacy.gps_input_send(*s.values())
|
||||
msg = encode_gps_input(new, **s)
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == capture.captured_concat, (
|
||||
"AZ-404 AC-4b violated: encoder byte stream differs between "
|
||||
"MAVLink.send and encode→pack→transport.write paths"
|
||||
)
|
||||
assert capture.bytes_written() > 0
|
||||
assert legacy.seq == new.seq
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Shared test stubs for the C8 outbound retrofit (AZ-558).
|
||||
|
||||
After AZ-558 the AP / iNav / replay adapters route bytes through the
|
||||
``MavlinkTransport`` Protocol seam instead of calling
|
||||
``connection.mav.X_send(...)`` directly. The new code path is
|
||||
``mav.X_encode(...) → msg.pack(mav) → transport.write(buf)``.
|
||||
|
||||
Tests that previously used a hand-rolled ``_MavStub`` with ``X_send``
|
||||
methods need the matching ``X_encode`` methods so their wire-level
|
||||
assertions continue to work. This module provides:
|
||||
|
||||
* :class:`_FakeMsg` — opaque message stub returned by ``X_encode``;
|
||||
its ``pack(mav)`` returns deterministic placeholder bytes without
|
||||
recomputing CRCs / signing (the per-test stub records the call args
|
||||
inside its own ``X_encode`` method, so ``pack`` can be a pure
|
||||
byte-emitter).
|
||||
* :class:`_NullTransport` — minimal :class:`MavlinkTransport`
|
||||
implementation that drops bytes and counts them. Used by tests that
|
||||
do not care about wire content but need a transport to satisfy
|
||||
the new ``mavlink_transport_factory`` plumbing.
|
||||
* :class:`_BytesCapturingTransport` — collects every ``write(buf)``
|
||||
call. Used by AC-2 byte-equivalence and AZ-401 AC-9 tests that
|
||||
assert on aggregate byte volume / specific message bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_BytesCapturingTransport",
|
||||
"_FakeMsg",
|
||||
"_NullTransport",
|
||||
"_PLACEHOLDER_PACK_BYTES",
|
||||
]
|
||||
|
||||
_PLACEHOLDER_PACK_BYTES: bytes = b"\x00" * 16
|
||||
|
||||
|
||||
class _FakeMsg:
|
||||
"""Opaque MAVLink message stand-in returned by ``X_encode``.
|
||||
|
||||
``pack(mav)`` returns fixed placeholder bytes — the test stub's
|
||||
``X_encode`` method already recorded the call args, so we don't
|
||||
need to re-record here.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def pack(self, mav: Any, force_mavlink1: bool = False) -> bytes:
|
||||
return _PLACEHOLDER_PACK_BYTES
|
||||
|
||||
|
||||
class _NullTransport:
|
||||
"""Minimal :class:`MavlinkTransport` impl that drops bytes."""
|
||||
|
||||
__slots__ = ("_bytes_written", "_closed")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._bytes_written = 0
|
||||
self._closed = False
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
if self._closed:
|
||||
raise RuntimeError("write on closed _NullTransport")
|
||||
n = len(payload)
|
||||
self._bytes_written += n
|
||||
return n
|
||||
|
||||
def bytes_written(self) -> int:
|
||||
return self._bytes_written
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
class _BytesCapturingTransport:
|
||||
"""Test :class:`MavlinkTransport` that retains every ``write`` payload."""
|
||||
|
||||
__slots__ = ("_chunks", "_closed")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._chunks: list[bytes] = []
|
||||
self._closed = False
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
if self._closed:
|
||||
raise RuntimeError("write on closed _BytesCapturingTransport")
|
||||
self._chunks.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def bytes_written(self) -> int:
|
||||
return sum(len(c) for c in self._chunks)
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
@property
|
||||
def chunks(self) -> tuple[bytes, ...]:
|
||||
return tuple(self._chunks)
|
||||
@@ -35,26 +35,43 @@ from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter imp
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — pymavlink stand-in
|
||||
|
||||
|
||||
class _SigningStub:
|
||||
sign_outgoing = False
|
||||
|
||||
|
||||
class _MavStub:
|
||||
"""Captures pymavlink ``mav.*_send`` calls for wire-level assertions."""
|
||||
"""Captures pymavlink ``mav.*_encode`` calls for wire-level assertions.
|
||||
|
||||
Post-AZ-558 the AP adapter routes through ``encode → pack → transport.write``;
|
||||
we record args at the ``encode`` boundary (where the test cares),
|
||||
return a :class:`_FakeMsg` whose ``pack`` produces placeholder bytes,
|
||||
and the transport seam swallows them.
|
||||
"""
|
||||
|
||||
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]] = []
|
||||
self.seq: int = 0
|
||||
self.signing = _SigningStub()
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
def gps_input_encode(self, *args: Any) -> _FakeMsg:
|
||||
self.gps_input_calls.append(args)
|
||||
return _FakeMsg()
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
def named_value_float_encode(self, time_boot_ms: int, name: bytes, value: float) -> _FakeMsg:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
return _FakeMsg()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> _FakeMsg:
|
||||
self.statustext_calls.append((severity, text))
|
||||
return _FakeMsg()
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
@@ -62,10 +79,15 @@ class _ConnStub:
|
||||
self.mav = _MavStub()
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
self.setup_signing_calls.append(bytes(key))
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ from gps_denied_onboard.components.c8_fc_adapter.msp2_inav_adapter import (
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — MSP / secondary-MAVLink stand-ins
|
||||
|
||||
@@ -51,22 +53,35 @@ class _MspStub:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _SigningStub:
|
||||
sign_outgoing = False
|
||||
|
||||
|
||||
class _SecondaryMavStub:
|
||||
def __init__(self) -> None:
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
# 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] = []
|
||||
# AZ-558: encoder flow needs ``mav.seq`` and ``mav.signing``.
|
||||
self.seq: int = 0
|
||||
self.signing = _SigningStub()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> _FakeMsg:
|
||||
self.statustext_calls.append((int(severity), bytes(text)))
|
||||
return _FakeMsg()
|
||||
|
||||
def setup_signing(self, key: Any) -> None:
|
||||
self.setup_signing_calls.append(key)
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter imp
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
_DEV_STATIC_KEY = "00112233445566778899aabbccddeeff" * 2 # 64 hex chars = 32 bytes
|
||||
|
||||
|
||||
@@ -41,16 +43,20 @@ class _MavStub:
|
||||
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)
|
||||
self.signing = SimpleNamespace(sig_count=signing_failure_count, sign_outgoing=False)
|
||||
self.seq: int = 0
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
def gps_input_encode(self, *args: Any) -> _FakeMsg:
|
||||
self.gps_input_calls.append(args)
|
||||
return _FakeMsg()
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
def named_value_float_encode(self, time_boot_ms: int, name: bytes, value: float) -> _FakeMsg:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
return _FakeMsg()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> _FakeMsg:
|
||||
self.statustext_calls.append((severity, text))
|
||||
return _FakeMsg()
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
@@ -59,12 +65,17 @@ class _ConnStub:
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self._fail_signing = fail_signing
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
|
||||
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 write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter imp
|
||||
from gps_denied_onboard.config import load_config
|
||||
from gps_denied_onboard.runtime_root.spoof_recovery_sink import SpoofRecoverySink
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
# AC-1 / AC-2 / AC-3: pymavlink ardupilotmega command id for SET_EKF_SOURCE_SET.
|
||||
_CMD_SET_EKF_SOURCE_SET = 42007
|
||||
_MAV_RESULT_ACCEPTED = 0
|
||||
@@ -34,14 +36,20 @@ class _AckMsg:
|
||||
self.result = result
|
||||
|
||||
|
||||
class _SigningStub:
|
||||
sign_outgoing = False
|
||||
|
||||
|
||||
class _MavStub:
|
||||
def __init__(self) -> None:
|
||||
self.command_long_calls: list[tuple[int, ...]] = []
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
||||
self.gps_input_calls: list[tuple[Any, ...]] = []
|
||||
self.seq: int = 0
|
||||
self.signing = _SigningStub()
|
||||
|
||||
def command_long_send(
|
||||
def command_long_encode(
|
||||
self,
|
||||
target_system: int,
|
||||
target_component: int,
|
||||
@@ -54,17 +62,23 @@ class _MavStub:
|
||||
p5: float,
|
||||
p6: float,
|
||||
p7: float,
|
||||
) -> None:
|
||||
) -> "_FakeMsg":
|
||||
self.command_long_calls.append((target_system, target_component, command, confirmation, p1))
|
||||
return _FakeMsg()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> "_FakeMsg":
|
||||
self.statustext_calls.append((severity, text))
|
||||
return _FakeMsg()
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
def named_value_float_encode(
|
||||
self, time_boot_ms: int, name: bytes, value: float
|
||||
) -> "_FakeMsg":
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
return _FakeMsg()
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
def gps_input_encode(self, *args: Any) -> "_FakeMsg":
|
||||
self.gps_input_calls.append(args)
|
||||
return _FakeMsg()
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
@@ -74,6 +88,7 @@ class _ConnStub:
|
||||
self.target_component = 1
|
||||
self._ack_queue = list(ack_queue or [])
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
|
||||
def recv_match(self, *, type: str, blocking: bool, timeout: float | None) -> Any:
|
||||
# Real pymavlink filters by ``type``; the inbound decoder thread
|
||||
@@ -89,6 +104,10 @@ class _ConnStub:
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
pass
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""AZ-558 — outbound MavlinkTransport seam acceptance tests.
|
||||
|
||||
Closes AZ-401 AC-9 + AZ-404 AC-4b deferrals by routing every C8
|
||||
outbound MAVLink byte through the :class:`MavlinkTransport` Protocol.
|
||||
|
||||
ACs covered here (AC-1, AC-3, AC-4 are exercised in their respective
|
||||
adapter / compose-root tests):
|
||||
|
||||
* **AC-2** — Wire-byte equivalence (live mode). Two pymavlink
|
||||
``MAVLink`` instances with identical state are driven through:
|
||||
(1) ``mav.gps_input_send(...)`` writing to a ``BytesIO``, and
|
||||
(2) ``encode + pack + transport.write`` writing to a
|
||||
:class:`_BytesCapturingTransport`. The captured bytes are
|
||||
byte-identical, by construction (both call ``msg.pack(mav)``
|
||||
on the same MAVLink instance with the same ``mav.seq`` snapshot).
|
||||
* **AC-5** — AST scan asserts that no method-call AST node in the
|
||||
AP / iNav / replay-FC adapter source files invokes a pymavlink
|
||||
``mav.X_send`` helper. The Protocol seam is the only egress.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_mavlink_payloads import (
|
||||
encode_gps_input,
|
||||
encode_named_value_float,
|
||||
encode_statustext,
|
||||
send_via_transport,
|
||||
)
|
||||
|
||||
from ._mav_test_helpers import _BytesCapturingTransport
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_C8_DIR = _REPO_ROOT / "src" / "gps_denied_onboard" / "components" / "c8_fc_adapter"
|
||||
|
||||
# AC-5 retrofitted files — every outbound MAVLink byte must now route
|
||||
# through the MavlinkTransport seam, not via pymavlink's *_send helpers.
|
||||
_RETROFITTED_FILES: tuple[str, ...] = (
|
||||
"pymavlink_ardupilot_adapter.py",
|
||||
"msp2_inav_adapter.py",
|
||||
"tlog_replay_adapter.py",
|
||||
)
|
||||
|
||||
# Pymavlink ``mav.<name>_send(...)`` helpers we forbid in retrofitted code.
|
||||
_FORBIDDEN_SEND_HELPERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"gps_input_send",
|
||||
"named_value_float_send",
|
||||
"statustext_send",
|
||||
"command_long_send",
|
||||
"global_position_int_send",
|
||||
"command_int_send",
|
||||
"param_set_send",
|
||||
"param_request_read_send",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2 — wire-byte equivalence
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mavlink_pair() -> tuple[Any, Any]:
|
||||
"""Build two pymavlink MAVLink instances with identical state."""
|
||||
from pymavlink.dialects.v20 import ardupilotmega as _mavlink
|
||||
|
||||
legacy_buf = io.BytesIO()
|
||||
legacy = _mavlink.MAVLink(file=legacy_buf, srcSystem=1, srcComponent=1)
|
||||
new = _mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
|
||||
return legacy, new, legacy_buf # type: ignore[return-value]
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_gps_input(_mavlink_pair: tuple[Any, Any, io.BytesIO]) -> None:
|
||||
"""``encode + pack + transport.write`` is byte-identical to ``gps_input_send``."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
args = dict(
|
||||
time_usec=1_000_000,
|
||||
gps_id=0,
|
||||
ignore_flags=0,
|
||||
time_week_ms=0,
|
||||
time_week=0,
|
||||
fix_type=3,
|
||||
lat=int(50.0 * 1e7),
|
||||
lon=int(30.0 * 1e7),
|
||||
alt=100.0,
|
||||
hdop=0.0,
|
||||
vdop=0.0,
|
||||
vn=0.0,
|
||||
ve=0.0,
|
||||
vd=0.0,
|
||||
speed_accuracy=0.0,
|
||||
horiz_accuracy=2.5,
|
||||
vert_accuracy=0.0,
|
||||
satellites_visible=10,
|
||||
yaw=0,
|
||||
)
|
||||
|
||||
# Act
|
||||
legacy.gps_input_send(*args.values())
|
||||
msg = encode_gps_input(new, **args)
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_named_value_float(
|
||||
_mavlink_pair: tuple[Any, Any, io.BytesIO],
|
||||
) -> None:
|
||||
"""``named_value_float_encode`` produces byte-equivalent output."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
args = dict(time_boot_ms=12345, name=b"src_lbl", value=1.5)
|
||||
|
||||
# Act
|
||||
legacy.named_value_float_send(*args.values())
|
||||
msg = encode_named_value_float(new, **args)
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_statustext(
|
||||
_mavlink_pair: tuple[Any, Any, io.BytesIO],
|
||||
) -> None:
|
||||
"""``statustext_encode`` produces byte-equivalent output."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
|
||||
# Act
|
||||
legacy.statustext_send(4, b"hello")
|
||||
msg = encode_statustext(new, severity=4, text=b"hello")
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_seq_bumps_consistently(
|
||||
_mavlink_pair: tuple[Any, Any, io.BytesIO],
|
||||
) -> None:
|
||||
"""Sending two messages keeps the seq numbers byte-aligned."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
args = dict(
|
||||
time_usec=1_000_000, gps_id=0, ignore_flags=0, time_week_ms=0, time_week=0,
|
||||
fix_type=3, lat=0, lon=0, alt=0.0, hdop=0.0, vdop=0.0, vn=0.0, ve=0.0,
|
||||
vd=0.0, speed_accuracy=0.0, horiz_accuracy=0.0, vert_accuracy=0.0,
|
||||
satellites_visible=10, yaw=0,
|
||||
)
|
||||
|
||||
# Act — two messages, both sides
|
||||
for _ in range(2):
|
||||
legacy.gps_input_send(*args.values())
|
||||
msg = encode_gps_input(new, **args)
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
assert legacy.seq == new.seq
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5 — AST scan: no `mav.X_send(` in retrofitted adapters
|
||||
|
||||
|
||||
class _MavSendCallScanner(ast.NodeVisitor):
|
||||
"""Flags ``<expr>.mav.<X>_send(...)`` AST patterns."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.findings: list[tuple[str, int]] = []
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None: # noqa: N802 — ast API
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute) and func.attr in _FORBIDDEN_SEND_HELPERS:
|
||||
value = func.value
|
||||
if isinstance(value, ast.Attribute) and value.attr == "mav":
|
||||
self.findings.append((func.attr, node.lineno))
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", _RETROFITTED_FILES)
|
||||
def test_ac5_no_pymavlink_send_helpers_in_adapter_source(filename: str) -> None:
|
||||
"""No retrofitted adapter source calls ``connection.mav.<name>_send(...)``."""
|
||||
# Arrange
|
||||
source_path = _C8_DIR / filename
|
||||
tree = ast.parse(source_path.read_text(encoding="utf-8"))
|
||||
scanner = _MavSendCallScanner()
|
||||
|
||||
# Act
|
||||
scanner.visit(tree)
|
||||
|
||||
# Assert
|
||||
assert scanner.findings == [], (
|
||||
f"{filename}: forbidden pymavlink send-helpers still present "
|
||||
f"(MavlinkTransport.write must be the only egress per AZ-558 AC-5):\n"
|
||||
+ "\n".join(f" line {ln}: .mav.{name}(" for name, ln in scanner.findings)
|
||||
)
|
||||
@@ -520,23 +520,61 @@ def test_ac8_replay_branch_imports_only_public_apis() -> None:
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: NoopMavlinkTransport.bytes_written() > 0 — BLOCKED
|
||||
# AC-9: NoopMavlinkTransport.bytes_written() > 0 (closed by AZ-558)
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"BLOCKED on AZ-399 design choice: TlogReplayFcAdapter raises "
|
||||
"FcEmitError on emit_external_position rather than routing the "
|
||||
"encoder bytes through the MavlinkTransport seam. Closing this "
|
||||
"gap requires retrofitting AP/iNav/QGC encoder code paths to "
|
||||
"consume MavlinkTransport — see batch 61 report. NoopMavlinkTransport "
|
||||
"+ MavlinkTransport Protocol classes are present (covered by "
|
||||
"test_az400_mavlink_transport.py) but the wiring that makes "
|
||||
"bytes_written > 0 in replay mode is deferred."
|
||||
def test_ac9_noop_transport_bytes_written_after_runtime_drive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""AZ-401 AC-9 / AZ-558 AC-4: replay encoders write through the seam.
|
||||
|
||||
Drives 10 ``EstimatorOutput`` ticks through a replay-wired
|
||||
:class:`TlogReplayFcAdapter` with a :class:`NoopMavlinkTransport`
|
||||
injected as its outbound seam. After the AZ-558 retrofit the
|
||||
adapter encodes ``GPS_INPUT`` + ``NAMED_VALUE_FLOAT`` per tick
|
||||
and writes the packed bytes through the transport — replay
|
||||
protocol Invariant 5 (encoders run in both modes; only the
|
||||
transport differs).
|
||||
"""
|
||||
# Arrange
|
||||
from pymavlink.dialects.v20 import ardupilotmega as _mavlink
|
||||
|
||||
monkeypatch.setenv("BUILD_REPLAY_SINK_JSONL", "ON")
|
||||
transport = NoopMavlinkTransport()
|
||||
outbound_mav = _mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
|
||||
fc = TlogReplayFcAdapter.__new__(TlogReplayFcAdapter)
|
||||
# Initialise only the slots the encoder code path consults so the
|
||||
# test stays focused on the wire-routing contract (no tlog file,
|
||||
# no BUILD_TLOG_REPLAY_ADAPTER gate, no decode thread).
|
||||
fc._mavlink_transport = transport
|
||||
fc._outbound_mav = outbound_mav
|
||||
fc._sequence_number = 0
|
||||
fc._clock = WallClock()
|
||||
fc._clock_us_provider = lambda: int(fc._clock.monotonic_ns() // 1000)
|
||||
fc._clock_ms_boot_provider = (
|
||||
lambda: int(fc._clock.monotonic_ns() // 1_000_000) % 0xFFFFFFFF
|
||||
)
|
||||
output = EstimatorOutput(
|
||||
frame_id=uuid4(),
|
||||
position_wgs84=LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0),
|
||||
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
|
||||
velocity_world_mps=(0.0, 0.0, 0.0),
|
||||
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
|
||||
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
|
||||
last_satellite_anchor_age_ms=0,
|
||||
smoothed=False,
|
||||
emitted_at=0,
|
||||
)
|
||||
|
||||
# Act
|
||||
for _ in range(10):
|
||||
fc.emit_external_position(output)
|
||||
|
||||
# Assert
|
||||
assert transport.bytes_written() > 0, (
|
||||
f"NoopMavlinkTransport.bytes_written() = {transport.bytes_written()}; "
|
||||
"expected > 0 after 10 emit_external_position calls"
|
||||
)
|
||||
)
|
||||
def test_ac9_noop_transport_bytes_written_after_runtime_drive() -> None:
|
||||
raise NotImplementedError("see skip reason")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user