[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,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(