[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
@@ -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()