Files
gps-denied-onboard/tests/unit/c8_fc_adapter/test_az391_inbound_subscription.py
T
Oleksandr Bezdieniezhnykh a61d2d3f4b [AZ-391] C8 inbound: MAVLink + MSP2 decoders + rings + bus + warm-start
Adds the C8 inbound producer side:
- TelemetryRing[T]: bounded drop-oldest ring; first-overflow INFO log
  + monotonic dropped_count.
- SubscriptionBus + SubscriptionHandle: synchronous fan-out, lock-
  released-before-callback to avoid deadlock; subscriber crash caught
  + DEBUG-logged so one bad subscriber cannot kill the decode loop.
- PymavlinkInboundDecoder: pymavlink-based AP decoder for RAW_IMU,
  SCALED_IMU2, ATTITUDE, GPS_RAW_INT, GPS2_RAW, HEARTBEAT, STATUSTEXT.
  Out-of-order drop (Invariant 7) per-kind WARN. STATUSTEXT spoofing
  sentinel promotes subsequent GPS to GpsStatus.SPOOFED within 5 s.
  AC-5.1 warm-start hint cached on first 3D+ fix; embedded into
  every FlightStateSignal.
- Msp2InavInboundDecoder: YAMSPy-based iNav polling decoder for IMU /
  attitude / GPS / flight-state. signed=False always (RESTRICT-COMM-2);
  GpsStatus.SPOOFED is unreachable on iNav.

Adds yamspy>=0.3.3 + pyserial>=3.5 to pyproject.toml.

Tests: 443 pass / 2 skip / 0 fail (+33 in batch 9).

Contract: no drift on fc_adapter_protocol.md v1.0.0; this batch
implements the inbound producer side without changing signatures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 04:28:14 +03:00

714 lines
20 KiB
Python

"""AZ-391 — C8 inbound subscription / decoder / rings / fan-out unit tests.
Covers all 10 ACs of AZ-391 + the named NFRs that can be verified
inside a unit test (the ≥ 200 Hz sustain budget belongs in C8-PT-01).
"""
from __future__ import annotations
import logging
import threading
import time
from types import SimpleNamespace
from typing import Any
from unittest import mock
import pytest
from gps_denied_onboard._types.fc import (
AttitudeSample,
FcTelemetryFrame,
FlightState,
FlightStateSignal,
GpsHealth,
GpsStatus,
ImuTelemetrySample,
TelemetryKind,
)
from gps_denied_onboard.components.c8_fc_adapter._inbound_mavlink import (
PymavlinkInboundDecoder,
)
from gps_denied_onboard.components.c8_fc_adapter._inbound_msp2 import (
Msp2InavInboundDecoder,
)
from gps_denied_onboard.components.c8_fc_adapter._subscription import (
SubscriptionBus,
)
from gps_denied_onboard.components.c8_fc_adapter._telemetry_rings import (
TelemetryRing,
)
# ----------------------------------------------------------------------
# Helpers
def _ap_msg(msg_type: str, **fields: Any) -> Any:
"""Build a pymavlink-style stub message with `get_type()` + fields."""
ns = SimpleNamespace(**fields)
ns.get_type = lambda: msg_type
return ns
def _make_decoder(bus: SubscriptionBus | None = None) -> PymavlinkInboundDecoder:
if bus is None:
bus = SubscriptionBus()
src = mock.MagicMock()
return PymavlinkInboundDecoder(src, bus)
# ----------------------------------------------------------------------
# AC-1: AP RAW_IMU decode
def test_ac1_ap_raw_imu_decode() -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
msg = _ap_msg(
"RAW_IMU",
time_usec=12345,
xacc=10,
yacc=20,
zacc=-981,
xgyro=1,
ygyro=2,
zgyro=3,
)
# Act
dispatched = decoder.feed_one_message(msg)
# Assert
assert dispatched is True
assert len(received) == 1
frame = received[0]
assert frame.kind is TelemetryKind.IMU_SAMPLE
assert isinstance(frame.payload, ImuTelemetrySample)
assert frame.payload.accel_xyz == (10.0, 20.0, -981.0)
assert frame.payload.gyro_xyz == (1.0, 2.0, 3.0)
# received_at is monotonic_ns at decode boundary (positive, non-zero)
assert frame.received_at > 0
# ----------------------------------------------------------------------
# AC-2: AP ATTITUDE decode
def test_ac2_ap_attitude_decode() -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
msg = _ap_msg(
"ATTITUDE",
time_boot_ms=5000,
roll=0.1,
pitch=-0.2,
yaw=1.5,
rollspeed=0.0,
pitchspeed=0.0,
yawspeed=0.0,
)
# Act
decoder.feed_one_message(msg)
# Assert
assert len(received) == 1
assert received[0].kind is TelemetryKind.ATTITUDE
payload = received[0].payload
assert isinstance(payload, AttitudeSample)
assert payload.roll_rad == pytest.approx(0.1)
assert payload.pitch_rad == pytest.approx(-0.2)
assert payload.yaw_rad == pytest.approx(1.5)
# ----------------------------------------------------------------------
# AC-3: AP GPS_RAW_INT -> GpsHealth fix-type mapping
@pytest.mark.parametrize(
"fix_type, expected_status",
[
(0, GpsStatus.NO_FIX),
(1, GpsStatus.NO_FIX),
(2, GpsStatus.DEGRADED),
(3, GpsStatus.STABLE),
(4, GpsStatus.STABLE), # DGPS
(5, GpsStatus.STABLE), # RTK_FLOAT
(6, GpsStatus.STABLE), # RTK_FIXED
],
)
def test_ac3_ap_gps_fix_type_mapping(fix_type: int, expected_status: GpsStatus) -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
msg = _ap_msg(
"GPS_RAW_INT",
time_usec=0,
fix_type=fix_type,
lat=505000000,
lon=300000000,
alt=100000,
eph=0,
epv=0,
vel=0,
cog=0,
satellites_visible=8,
alt_ellipsoid=0,
h_acc=0,
v_acc=0,
vel_acc=0,
hdg_acc=0,
yaw=0,
)
# Act
decoder.feed_one_message(msg)
# Assert
assert isinstance(received[0].payload, GpsHealth)
assert received[0].payload.status is expected_status
def test_ac3_spoofing_sentinel_promotes_to_spoofed() -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
statustext = _ap_msg("STATUSTEXT", severity=4, text="GPS spoofing detected!", id=0, chunk_seq=0)
gps_msg = _ap_msg(
"GPS_RAW_INT",
time_usec=0,
fix_type=3,
lat=505000000,
lon=300000000,
alt=100000,
eph=0,
epv=0,
vel=0,
cog=0,
satellites_visible=8,
alt_ellipsoid=0,
h_acc=0,
v_acc=0,
vel_acc=0,
hdg_acc=0,
yaw=0,
)
# Act
decoder.feed_one_message(statustext)
decoder.feed_one_message(gps_msg)
# Assert — the GPS frame after the sentinel is promoted to SPOOFED
gps_frames = [f for f in received if f.kind is TelemetryKind.GPS_HEALTH]
assert len(gps_frames) == 1
assert gps_frames[0].payload.status is GpsStatus.SPOOFED
# ----------------------------------------------------------------------
# AC-4: AP HEARTBEAT -> FlightState mapping
@pytest.mark.parametrize(
"system_status, base_mode, expected_state",
[
(0, 0, FlightState.INIT), # UNINIT
(1, 0, FlightState.INIT), # BOOT
(2, 0, FlightState.INIT), # CALIBRATING
(3, 0x00, FlightState.ON_GROUND), # STANDBY, disarmed
(3, 0x80, FlightState.ARMED), # STANDBY, armed
(4, 0x80, FlightState.IN_FLIGHT), # ACTIVE
(5, 0, FlightState.FAILED), # CRITICAL
(6, 0, FlightState.FAILED), # EMERGENCY
(7, 0, FlightState.ON_GROUND), # POWEROFF
(8, 0, FlightState.FAILED), # FLIGHT_TERMINATION
],
)
def test_ac4_heartbeat_to_flight_state(
system_status: int, base_mode: int, expected_state: FlightState
) -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
msg = _ap_msg(
"HEARTBEAT",
type=0,
autopilot=3,
base_mode=base_mode,
custom_mode=0,
system_status=system_status,
mavlink_version=2,
)
# Act
decoder.feed_one_message(msg)
# Assert
assert received[0].kind is TelemetryKind.MAV_STATE
payload = received[0].payload
assert isinstance(payload, FlightStateSignal)
assert payload.state is expected_state
# ----------------------------------------------------------------------
# AC-5: iNav MSP2 decode (same DTOs as AP)
class _FakeMspSource:
def __init__(
self,
imu: dict | None = None,
attitude: dict | None = None,
gps: dict | None = None,
state: dict | None = None,
) -> None:
self._imu = imu
self._attitude = attitude
self._gps = gps
self._state = state
def read_imu(self) -> dict | None:
return self._imu
def read_attitude(self) -> dict | None:
return self._attitude
def read_gps(self) -> dict | None:
return self._gps
def read_flight_state(self) -> dict | None:
return self._state
def test_ac5_inav_msp2_imu_decode() -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
source = _FakeMspSource(
imu={"accelerometer": [11, 22, -33], "gyroscope": [1, 2, 3]},
)
decoder = Msp2InavInboundDecoder(source, bus)
# Act
decoder.feed_one_tick()
# Assert
imu_frames = [f for f in received if f.kind is TelemetryKind.IMU_SAMPLE]
assert len(imu_frames) == 1
payload = imu_frames[0].payload
assert isinstance(payload, ImuTelemetrySample)
assert payload.accel_xyz == (11.0, 22.0, -33.0)
assert payload.gyro_xyz == (1.0, 2.0, 3.0)
# iNav is unsigned per RESTRICT-COMM-2
assert imu_frames[0].signed is False
def test_ac5_inav_msp2_attitude_decode() -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
source = _FakeMspSource(attitude={"angx": 180.0, "angy": 0.0, "heading": 0.0})
decoder = Msp2InavInboundDecoder(source, bus)
# Act
decoder.feed_one_tick()
# Assert
att = [f for f in received if f.kind is TelemetryKind.ATTITUDE]
assert len(att) == 1
payload = att[0].payload
assert isinstance(payload, AttitudeSample)
# 180 deg -> pi rad
assert payload.roll_rad == pytest.approx(3.141592653589793)
def test_ac5_inav_spoofed_status_unreachable() -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
source = _FakeMspSource(gps={"fix": 2, "lat": 505000000, "lon": 300000000, "alt": 100.0})
decoder = Msp2InavInboundDecoder(source, bus)
# Act
decoder.feed_one_tick()
# Assert — iNav with valid fix is STABLE; SPOOFED is unreachable
gps = [f for f in received if f.kind is TelemetryKind.GPS_HEALTH]
assert len(gps) == 1
assert gps[0].payload.status is GpsStatus.STABLE
# ----------------------------------------------------------------------
# AC-6: Bounded ring drop-oldest
def test_ac6_ring_drop_oldest_and_logs_first_overflow(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
ring: TelemetryRing[int] = TelemetryRing(capacity=100, kind_name="imu")
caplog.set_level(logging.INFO, logger="c8_fc_adapter.telemetry_ring")
# Act
for i in range(1000):
ring.push(i)
# Assert — ring contains the latest 100
items = ring.snapshot()
assert len(items) == 100
assert items == list(range(900, 1000))
# Dropped 900 items (the first 900 entered before any drops; once full,
# each subsequent push drops one).
assert ring.dropped_count == 900
# Exactly ONE overflow record (first overflow), not 900 of them.
overflow_logs = [
r for r in caplog.records if getattr(r, "kind", None) == "c8.inbound.ring_overflow"
]
assert len(overflow_logs) == 1
assert overflow_logs[0].kv["ring_kind"] == "imu"
def test_ac6_ring_capacity_zero_rejected() -> None:
# Act + Assert
with pytest.raises(ValueError, match=r"capacity must be > 0"):
TelemetryRing(capacity=0, kind_name="imu")
# ----------------------------------------------------------------------
# AC-7: Multi-subscriber fan-out
def test_ac7_multi_subscriber_fan_out_and_cancel() -> None:
# Arrange
bus = SubscriptionBus()
counters = [0, 0, 0]
def make_cb(idx: int):
def _cb(_f: FcTelemetryFrame) -> None:
counters[idx] += 1
return _cb
handles = [bus.subscribe(make_cb(i)) for i in range(3)]
frame = _make_frame(TelemetryKind.IMU_SAMPLE)
# Act 1 — all 3 receive
bus.dispatch(frame)
# Assert 1
assert counters == [1, 1, 1]
# Act 2 — cancel one, dispatch again
handles[1].cancel()
bus.dispatch(frame)
# Assert 2
assert counters == [2, 1, 2]
def test_ac7_subscriber_exception_does_not_kill_dispatch(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda _f: (_ for _ in ()).throw(RuntimeError("boom")))
bus.subscribe(lambda f: received.append(f))
caplog.set_level(logging.DEBUG, logger="c8_fc_adapter.subscription_bus")
frame = _make_frame(TelemetryKind.IMU_SAMPLE)
# Act
bus.dispatch(frame)
# Assert — second subscriber STILL fired
assert len(received) == 1
# And the bad subscriber's exception was DEBUG-logged
err_logs = [
r for r in caplog.records if getattr(r, "kind", None) == "c8.inbound.callback_error"
]
assert len(err_logs) == 1
# ----------------------------------------------------------------------
# AC-8: Warm-start hint surfaces within 1 s of first GPS_RAW_INT
def test_ac8_warm_start_hint_present_after_first_gps() -> None:
# Arrange
bus = SubscriptionBus()
decoder = _make_decoder(bus)
# Before any GPS: hint is None
assert decoder.warm_start_hint is None
# Act
msg = _ap_msg(
"GPS_RAW_INT",
time_usec=0,
fix_type=3,
lat=505000000,
lon=300000000,
alt=125000, # 50.5 / 30.0 / 125 m
eph=0,
epv=0,
vel=0,
cog=0,
satellites_visible=8,
alt_ellipsoid=0,
h_acc=0,
v_acc=0,
vel_acc=0,
hdg_acc=0,
yaw=0,
)
decoder.feed_one_message(msg)
# Assert
hint = decoder.warm_start_hint
assert hint is not None
assert hint.lat_deg == pytest.approx(50.5)
assert hint.lon_deg == pytest.approx(30.0)
assert hint.alt_m == pytest.approx(125.0)
def test_ac8_warm_start_hint_propagates_to_flight_state_signal() -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
gps = _ap_msg(
"GPS_RAW_INT",
time_usec=0,
fix_type=3,
lat=505000000,
lon=300000000,
alt=125000,
eph=0,
epv=0,
vel=0,
cog=0,
satellites_visible=8,
alt_ellipsoid=0,
h_acc=0,
v_acc=0,
vel_acc=0,
hdg_acc=0,
yaw=0,
)
heartbeat = _ap_msg(
"HEARTBEAT",
type=0,
autopilot=3,
base_mode=0,
custom_mode=0,
system_status=3,
mavlink_version=2,
)
# Act
decoder.feed_one_message(gps)
decoder.feed_one_message(heartbeat)
# Assert
state_frames = [f for f in received if f.kind is TelemetryKind.MAV_STATE]
assert len(state_frames) == 1
payload = state_frames[0].payload
assert payload.last_valid_gps_hint_wgs84 is not None
assert payload.last_valid_gps_age_ms is not None
# ----------------------------------------------------------------------
# AC-9: Out-of-order drop + WARN
def test_ac9_out_of_order_dropped_with_warn(caplog: pytest.LogCaptureFixture) -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
caplog.set_level(logging.WARNING, logger="c8_fc_adapter.inbound_mavlink")
# First message
msg1 = _ap_msg(
"ATTITUDE",
time_boot_ms=1000,
roll=0.0,
pitch=0.0,
yaw=0.0,
rollspeed=0.0,
pitchspeed=0.0,
yawspeed=0.0,
)
decoder.feed_one_message(msg1)
# Capture the first frame's received_at; we'll force the next frame to be
# apparently older by injecting a manipulated `_last_ts_ns` entry.
# Force the decoder's per-kind last-ts way into the future so any next
# frame is "before" it.
decoder._last_ts_ns[TelemetryKind.ATTITUDE] = time.monotonic_ns() + 10_000_000_000
# Act
msg2 = _ap_msg(
"ATTITUDE",
time_boot_ms=2000,
roll=0.1,
pitch=0.1,
yaw=0.1,
rollspeed=0.0,
pitchspeed=0.0,
yawspeed=0.0,
)
decoder.feed_one_message(msg2)
# Assert
att_frames = [f for f in received if f.kind is TelemetryKind.ATTITUDE]
assert len(att_frames) == 1 # second frame dropped
warn_logs = [
r
for r in caplog.records
if getattr(r, "kind", None) == "c8.inbound.out_of_order_frame_dropped"
]
assert len(warn_logs) == 1
assert warn_logs[0].kv["telemetry_kind"] == "ATTITUDE"
# ----------------------------------------------------------------------
# AC-10: Decode-error isolation
def test_ac10_corrupt_frame_does_not_kill_decoder(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
bus = SubscriptionBus()
received: list[FcTelemetryFrame] = []
bus.subscribe(lambda f: received.append(f))
decoder = _make_decoder(bus)
caplog.set_level(logging.DEBUG, logger="c8_fc_adapter.inbound_mavlink")
# Corrupt RAW_IMU — `xacc` field missing (AttributeError) on access
corrupt = SimpleNamespace()
corrupt.get_type = lambda: "RAW_IMU"
# Good RAW_IMU immediately after
good = _ap_msg(
"RAW_IMU",
time_usec=0,
xacc=1,
yacc=2,
zacc=3,
xgyro=4,
ygyro=5,
zgyro=6,
)
# Act
decoder.feed_one_message(corrupt)
decoder.feed_one_message(good)
# Assert — bad frame logged, good frame dispatched
err_logs = [r for r in caplog.records if getattr(r, "kind", None) == "c8.inbound.decode_error"]
assert len(err_logs) == 1
imu_frames = [f for f in received if f.kind is TelemetryKind.IMU_SAMPLE]
assert len(imu_frames) == 1
assert imu_frames[0].payload.accel_xyz == (1.0, 2.0, 3.0)
# ----------------------------------------------------------------------
# NFR: IMU callback latency under 1 ms (loose budget for unit test)
def test_nfr_perf_imu_callback_under_1ms() -> None:
# Arrange
bus = SubscriptionBus()
bus.subscribe(lambda _f: None)
decoder = _make_decoder(bus)
iters = 200
msgs = [
_ap_msg(
"RAW_IMU",
time_usec=i,
xacc=i,
yacc=i,
zacc=i,
xgyro=i,
ygyro=i,
zgyro=i,
)
for i in range(iters)
]
# Act
start = time.perf_counter()
for m in msgs:
decoder.feed_one_message(m)
avg_s = (time.perf_counter() - start) / iters
# Assert
assert avg_s < 1e-3, f"avg {avg_s * 1e3:.3f}ms > 1ms budget"
# ----------------------------------------------------------------------
# NFR: Thread-safety smoke (single-producer / single-consumer over a ring)
def test_nfr_ring_thread_safety_smoke() -> None:
# Arrange
ring: TelemetryRing[int] = TelemetryRing(capacity=1024, kind_name="imu")
n = 5000
def producer() -> None:
for i in range(n):
ring.push(i)
def consumer_snapshots() -> None:
for _ in range(n):
ring.snapshot()
# Act
threads = [
threading.Thread(target=producer),
threading.Thread(target=consumer_snapshots),
]
for t in threads:
t.start()
for t in threads:
t.join()
# Assert — no crash; ring still has bounded size
assert len(ring) <= 1024
# ----------------------------------------------------------------------
# Helpers (factories for test frames)
def _make_frame(kind: TelemetryKind) -> FcTelemetryFrame:
if kind is TelemetryKind.IMU_SAMPLE:
payload: Any = ImuTelemetrySample(
ts_ns=0, accel_xyz=(0.0, 0.0, 0.0), gyro_xyz=(0.0, 0.0, 0.0)
)
elif kind is TelemetryKind.ATTITUDE:
payload = AttitudeSample(ts_ns=0, roll_rad=0.0, pitch_rad=0.0, yaw_rad=0.0)
elif kind is TelemetryKind.GPS_HEALTH:
payload = GpsHealth(status=GpsStatus.STABLE, fix_age_ms=0, captured_at=0)
else:
payload = FlightStateSignal(
state=FlightState.INIT,
last_valid_gps_hint_wgs84=None,
last_valid_gps_age_ms=None,
captured_at=0,
)
return FcTelemetryFrame(kind=kind, payload=payload, received_at=0, signed=False)