mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:51:14 +00:00
[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:
@@ -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},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user