[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:
Oleksandr Bezdieniezhnykh
2026-05-16 05:33:45 +03:00
parent d7e6b0959e
commit 2b19b8b90b
18 changed files with 1106 additions and 90 deletions
@@ -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)
)