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