[AZ-401] [AZ-400] Replay — compose_root replay-mode branch + transport seam

Wires the airborne composition root for replay-as-configuration (ADR-011):

- compose_root(config) branches on config.mode in {"live", "replay"}.
  Live behaviour is unchanged; replay builds ReplayInputAdapter,
  attaches JsonlReplaySink, and injects NoopMavlinkTransport.
- New private module runtime_root/_replay_branch.py holds the
  replay-only strategy graph + build-flag gate + calibration loader.
- Config gains Config.mode (Literal["live","replay"]) plus
  Config.replay sub-block with nested ReplayAutoSyncConfig that mirrors
  the AZ-405 AutoSyncConfig DTO; YAML loader + ENV map updated.

Absorbs the AZ-400 transport-seam retrofit that AZ-401 strictly
required but AZ-400 had not delivered:

- New MavlinkTransport Protocol (write/bytes_written/close).
- NoopMavlinkTransport (replay; build-flag gated, idempotent close,
  thread-safe byte counter).
- SerialMavlinkTransport (live, no-op restructure of existing pymavlink
  byte path; encoder retrofit to actually USE it is the AZ-558
  follow-up).

AZ-401 AC-9 (NoopMavlinkTransport.bytes_written > 0 after C8 encoders
run) is BLOCKED on AZ-558 — the encoder routing retrofit is out of
the AZ-401 task envelope (FORBIDDEN files: pymavlink_ardupilot_adapter,
msp2_inav_adapter). AZ-558 spec, batch_61_review.md, and the test's
@pytest.mark.skip rationale all carry the deferral reason.

Tests: 22 compose_root replay-branch tests + 17 transport tests.
Full regression: 2063 passed, 86 environment-skips, 1 documented
skip (AC-9 / AZ-558), 1 pre-existing flaky perf test deselected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 11:55:33 +03:00
parent 8149083cac
commit 17a0d074af
19 changed files with 2156 additions and 45 deletions
@@ -10,7 +10,14 @@ from gps_denied_onboard._types.emitted import EmittedExternalPosition
from gps_denied_onboard.components.c8_fc_adapter.interface import (
FcAdapter,
GcsAdapter,
MavlinkTransport,
)
from gps_denied_onboard.components.c8_fc_adapter.replay_sink import ReplaySink
__all__ = ["EmittedExternalPosition", "FcAdapter", "GcsAdapter", "ReplaySink"]
__all__ = [
"EmittedExternalPosition",
"FcAdapter",
"GcsAdapter",
"MavlinkTransport",
"ReplaySink",
]
@@ -18,6 +18,8 @@ __all__ = [
"GcsAdapterConfigError",
"GcsAdapterError",
"GcsEmitError",
"MavlinkTransportConfigError",
"MavlinkTransportError",
"SigningHandshakeError",
"SigningKeyExpiredError",
"SourceSetSwitchError",
@@ -96,3 +98,15 @@ class GcsAdapterConfigError(GcsAdapterError):
Raised at config-load for unknown strategy names and at factory
build for build-flag-OFF strategies.
"""
# ---------------------------------------------------------------------
# MavlinkTransport tree (AZ-400 Protocol seam)
class MavlinkTransportError(Exception):
"""Base class for every `MavlinkTransport` failure."""
class MavlinkTransportConfigError(MavlinkTransportError):
"""Construction-time / build-flag failure for a transport strategy."""
@@ -31,7 +31,56 @@ from gps_denied_onboard._types.fc import (
)
from gps_denied_onboard._types.state import EstimatorOutput
__all__ = ["FcAdapter", "GcsAdapter"]
__all__ = ["FcAdapter", "GcsAdapter", "MavlinkTransport"]
@runtime_checkable
class MavlinkTransport(Protocol):
"""Outbound MAVLink byte-stream destination (AZ-400 Protocol seam).
The contract (replay_protocol.md v2.0.0 Invariant 5) splits the C8
outbound code path into two halves: an *encoder* half (per-message
`gps_input_send` / `statustext_send` / `command_long_send` calls
that produce MAVLink 2.0 byte streams) and a *transport* half that
decides where those bytes go (a real serial UART in live mode, a
drop-on-the-floor sink in replay).
Concrete strategies:
* :class:`SerialMavlinkTransport` — wraps a ``pymavlink``
``mavutil.mavlink_connection`` open on the FC's UART (live mode).
* :class:`NoopMavlinkTransport` — counts the bytes the encoders
try to send and discards them (replay mode + Invariant 5
verification + AC-9 byte-count check).
Only :func:`gps_denied_onboard.runtime_root.compose_root` may
instantiate transports; component code consumes them via
constructor injection so the strategy is mode-agnostic from the
encoder's point of view.
"""
def write(self, payload: bytes) -> int:
"""Write ``payload`` to the transport; return the byte count consumed.
Must accept any byte length (encoders may issue zero-length
flushes during the MAVLink 2.0 signing handshake). Implementors
that fail mid-write must raise (do NOT return a short count) so
the caller can decide whether the link is dead.
"""
...
def bytes_written(self) -> int:
"""Cumulative byte count the transport has accepted since open.
Used by AC-9 of AZ-401 to verify the encoder code path actually
ran in replay mode (and by live-side health checks to detect a
completely silent UART).
"""
...
def close(self) -> None:
"""Close the underlying transport; idempotent."""
...
@runtime_checkable
@@ -0,0 +1,106 @@
"""``NoopMavlinkTransport`` — replay-mode outbound byte sink (AZ-400).
Replay-mode strategy for the :class:`MavlinkTransport` Protocol. Counts
every byte the C8 outbound encoders try to send and discards the
payload. Used by ``compose_root`` in ``config.mode == "replay"`` so the
encoders' code path can be exercised in replay tests without opening a
real serial UART.
Build-time gating: the transport refuses construction unless
``BUILD_REPLAY_SINK_JSONL`` is ON. The flag is shared with the
``JsonlReplaySink`` because both answer the same question — "where do
the airborne binary's outbound side-effects go in replay?" — and the
replay binary always wants both ON together.
Thread-safety: ``write`` and ``bytes_written`` are guarded by a lock so
concurrent encoder threads (the live binary's outbound thread + a
diagnostic emit thread) do not race the counter. Replay's runtime loop
is single-threaded, but the lock costs ~100 ns and prevents test-side
surprises (mirrors :class:`JsonlReplaySink`).
"""
from __future__ import annotations
import os
import threading
from typing import Final
from gps_denied_onboard.components.c8_fc_adapter.errors import (
MavlinkTransportConfigError,
MavlinkTransportError,
)
from gps_denied_onboard.logging import get_logger
__all__ = ["NoopMavlinkTransport"]
_BUILD_FLAG: Final[str] = "BUILD_REPLAY_SINK_JSONL"
_LOG_KIND_OPENED: Final[str] = "replay.transport.noop_opened"
_LOG_KIND_CLOSED: Final[str] = "replay.transport.noop_closed"
_LOG_KIND_DOUBLE_CLOSE: Final[str] = "replay.transport.noop_double_close"
def _build_flag_on() -> bool:
raw = os.environ.get(_BUILD_FLAG, "")
return raw.strip().lower() in {"on", "1", "true", "yes"}
class NoopMavlinkTransport:
"""Drop-on-the-floor :class:`MavlinkTransport` for replay mode.
Counts the bytes the C8 outbound encoders attempt to write; never
raises on the write path. Idempotent close.
"""
__slots__ = ("_log", "_lock", "_bytes_written", "_closed")
def __init__(self) -> None:
if not _build_flag_on():
raise MavlinkTransportConfigError(
f"{_BUILD_FLAG} is OFF in this binary; NoopMavlinkTransport is "
"unavailable. Rebuild with the flag set to ON in the airborne "
"Dockerfile."
)
self._log = get_logger("c8_fc_adapter.noop_mavlink_transport")
self._lock = threading.Lock()
self._bytes_written = 0
self._closed = False
self._log.info(
_LOG_KIND_OPENED,
extra={"kind": _LOG_KIND_OPENED, "kv": {}},
)
def write(self, payload: bytes) -> int:
if not isinstance(payload, (bytes, bytearray, memoryview)):
raise MavlinkTransportError(
"NoopMavlinkTransport.write expects bytes-like; got "
f"{type(payload).__name__}"
)
with self._lock:
if self._closed:
raise MavlinkTransportError("write on closed NoopMavlinkTransport")
n = len(payload)
self._bytes_written += n
return n
def bytes_written(self) -> int:
with self._lock:
return self._bytes_written
def close(self) -> None:
with self._lock:
if self._closed:
self._log.debug(
_LOG_KIND_DOUBLE_CLOSE,
extra={"kind": _LOG_KIND_DOUBLE_CLOSE, "kv": {}},
)
return
self._closed = True
total = self._bytes_written
self._log.info(
_LOG_KIND_CLOSED,
extra={
"kind": _LOG_KIND_CLOSED,
"kv": {"bytes_written": total},
},
)
@@ -360,8 +360,8 @@ def create(*, output_path: Path, fdr_client: "FdrClient") -> JsonlReplaySink:
"""Module-level factory entrypoint per project convention.
Mirrors the ``create`` factories used by the C2/C3 strategies so
the AZ-401 ``compose_replay`` wiring resolves the sink through a
single named-symbol contract instead of poking at the class
constructor directly.
the AZ-401 replay-mode branch of ``compose_root`` resolves the
sink through a single named-symbol contract instead of poking at
the class constructor directly.
"""
return JsonlReplaySink(output_path=output_path, fdr_client=fdr_client)
@@ -0,0 +1,124 @@
"""``SerialMavlinkTransport`` — live-mode outbound byte sink (AZ-400).
Live-mode strategy for the :class:`MavlinkTransport` Protocol. Wraps a
``pymavlink`` ``mavutil.mavlink_connection`` so the C8 outbound
encoders can write through a typed transport seam instead of poking the
connection directly.
The existing :class:`PymavlinkArdupilotAdapter` / :class:`Msp2InavAdapter`
encoders still call ``self._connection.mav.gps_input_send(...)`` etc.
directly; the full retrofit that routes those calls through this
transport is tracked separately (see the AZ-401 batch report — the
encoder rewrite is deferred to keep this commit's blast radius
bounded). This module ships the typed surface so
* :func:`gps_denied_onboard.runtime_root.compose_root` in live mode can
construct it under the same registry slot the replay branch uses for
:class:`NoopMavlinkTransport` (replay protocol Invariant 5 surface
parity); and
* future AP/iNav/QGC encoder edits route their per-message ``write``
calls through here without touching the composition root.
The class is intentionally minimal — it forwards ``write(payload)`` to
the underlying pymavlink connection's ``write`` method (every
``mavlink_connection`` returned by ``mavutil.mavlink_connection`` is a
file-like object exposing ``.write(bytes) -> int``) and tracks a
running byte count for parity with :class:`NoopMavlinkTransport`.
"""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING, Any, Final
from gps_denied_onboard.components.c8_fc_adapter.errors import (
MavlinkTransportError,
)
from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
pass
__all__ = ["SerialMavlinkTransport"]
_LOG_KIND_OPENED: Final[str] = "live.transport.serial_opened"
_LOG_KIND_CLOSED: Final[str] = "live.transport.serial_closed"
_LOG_KIND_DOUBLE_CLOSE: Final[str] = "live.transport.serial_double_close"
class SerialMavlinkTransport:
""":class:`MavlinkTransport` over a pymavlink serial connection.
Constructor injects an already-open ``mavutil.mavlink_connection``
object so the connection lifecycle (port open, signing handshake,
reconnection on disconnect) stays owned by the existing
:class:`PymavlinkArdupilotAdapter` / :class:`Msp2InavAdapter`. The
transport itself does not open the connection — that ownership
boundary keeps this commit a no-op restructure for live wiring.
"""
__slots__ = ("_connection", "_log", "_lock", "_bytes_written", "_closed")
def __init__(self, connection: Any) -> None:
if connection is None:
raise MavlinkTransportError(
"SerialMavlinkTransport requires an open pymavlink connection"
)
write = getattr(connection, "write", None)
if not callable(write):
raise MavlinkTransportError(
"SerialMavlinkTransport.connection must expose a callable "
".write(bytes) -> int; got "
f"{type(connection).__name__}"
)
self._connection = connection
self._log = get_logger("c8_fc_adapter.serial_mavlink_transport")
self._lock = threading.Lock()
self._bytes_written = 0
self._closed = False
self._log.info(
_LOG_KIND_OPENED,
extra={"kind": _LOG_KIND_OPENED, "kv": {}},
)
def write(self, payload: bytes) -> int:
if not isinstance(payload, (bytes, bytearray, memoryview)):
raise MavlinkTransportError(
"SerialMavlinkTransport.write expects bytes-like; got "
f"{type(payload).__name__}"
)
with self._lock:
if self._closed:
raise MavlinkTransportError("write on closed SerialMavlinkTransport")
try:
returned = self._connection.write(bytes(payload))
except OSError as exc:
raise MavlinkTransportError(
f"SerialMavlinkTransport underlying write failed: {exc!r}"
) from exc
n = int(returned) if returned is not None else len(payload)
self._bytes_written += n
return n
def bytes_written(self) -> int:
with self._lock:
return self._bytes_written
def close(self) -> None:
with self._lock:
if self._closed:
self._log.debug(
_LOG_KIND_DOUBLE_CLOSE,
extra={"kind": _LOG_KIND_DOUBLE_CLOSE, "kv": {}},
)
return
self._closed = True
total = self._bytes_written
self._log.info(
_LOG_KIND_CLOSED,
extra={
"kind": _LOG_KIND_CLOSED,
"kv": {"bytes_written": total},
},
)