- pod_bridge: 协议G版编解码/地理位置引导律/PX4网关/串口链路/闭环主循环 - 含三项安全修复: position_time_ms拆分、表2-2锁定门控、run()异常护栏 - 测试92条全绿; 附树莓派4B(Ubuntu 24.04)部署注意事项与最小依赖清单
215 lines
7.0 KiB
Python
215 lines
7.0 KiB
Python
"""px4_gateway_test.py —— Px4Gateway 单元测试(mock 遥测流 + mock offboard)。
|
|
|
|
覆盖:遥测缓存更新(position/attitude/gps/velocity)、position_valid 判据、
|
|
快照副本隔离、时间戳注入、枚举转换、offboard 指令委托、异步订阅启停。
|
|
"""
|
|
|
|
import asyncio
|
|
from enum import Enum
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from pod_bridge.px4_gateway import OwnState, Px4Gateway, _enum_int
|
|
|
|
|
|
class FakeOffboard:
|
|
def __init__(self):
|
|
self.body = []
|
|
self.ned = []
|
|
self.start_count = 0
|
|
self.stop_count = 0
|
|
|
|
async def set_velocity_body(self, sp):
|
|
self.body.append(sp)
|
|
|
|
async def set_velocity_ned(self, sp):
|
|
self.ned.append(sp)
|
|
|
|
async def start(self):
|
|
self.start_count += 1
|
|
|
|
async def stop(self):
|
|
self.stop_count += 1
|
|
|
|
|
|
class FakeTelemetry:
|
|
"""各遥测流:先吐预设消息,再挂起(由 stop_telemetry 取消)。"""
|
|
|
|
def __init__(self, position=None, attitude=None, gps=None, velocity=None):
|
|
self._position = position or []
|
|
self._attitude = attitude or []
|
|
self._gps = gps or []
|
|
self._velocity = velocity or []
|
|
|
|
async def _stream(self, msgs):
|
|
for m in msgs:
|
|
yield m
|
|
await asyncio.sleep(3600)
|
|
|
|
def position(self):
|
|
return self._stream(self._position)
|
|
|
|
def attitude_euler(self):
|
|
return self._stream(self._attitude)
|
|
|
|
def gps_info(self):
|
|
return self._stream(self._gps)
|
|
|
|
def velocity_body(self):
|
|
return self._stream(self._velocity)
|
|
|
|
|
|
class FakeDrone:
|
|
def __init__(self, telemetry=None):
|
|
self.offboard = FakeOffboard()
|
|
self.telemetry = telemetry or FakeTelemetry()
|
|
|
|
|
|
def _pos(lat=31.0, lon=121.0, aalt=100.0, ralt=50.0):
|
|
return SimpleNamespace(latitude_deg=lat, longitude_deg=lon,
|
|
absolute_altitude_m=aalt, relative_altitude_m=ralt)
|
|
|
|
|
|
def _att(roll=0.1, pitch=0.2, yaw=1.57):
|
|
return SimpleNamespace(roll_rad=roll, pitch_rad=pitch, yaw_rad=yaw)
|
|
|
|
|
|
# --- 枚举转换 ---------------------------------------------------------------
|
|
def test_enum_int_handles_enum_and_raw():
|
|
class Fix(Enum):
|
|
NO_FIX = 0
|
|
THREE_D = 3
|
|
|
|
assert _enum_int(Fix.THREE_D) == 3
|
|
assert _enum_int(3) == 3
|
|
assert _enum_int(None) == 0
|
|
|
|
|
|
# --- 缓存更新 ---------------------------------------------------------------
|
|
def test_update_position_sets_fields_but_not_valid_without_gps():
|
|
gw = Px4Gateway(FakeDrone())
|
|
gw.update_position(_pos())
|
|
s = gw.own_state()
|
|
assert s.latitude_deg == 31.0 and s.longitude_deg == 121.0
|
|
assert s.absolute_altitude_m == 100.0 and s.relative_altitude_m == 50.0
|
|
assert s.has_position is True
|
|
assert s.position_valid is False # 尚无 GPS 定位
|
|
|
|
|
|
def test_position_valid_requires_3d_fix():
|
|
gw = Px4Gateway(FakeDrone())
|
|
gw.update_position(_pos())
|
|
gw.update_gps(SimpleNamespace(fix_type=3, num_satellites=12))
|
|
s = gw.own_state()
|
|
assert s.position_valid is True
|
|
assert s.num_satellites == 12
|
|
# 定位降级 → 立即失效
|
|
gw.update_gps(SimpleNamespace(fix_type=2, num_satellites=6))
|
|
assert gw.own_state().position_valid is False
|
|
|
|
|
|
def test_update_attitude_sets_radians():
|
|
gw = Px4Gateway(FakeDrone())
|
|
gw.update_attitude(_att())
|
|
s = gw.own_state()
|
|
assert s.has_attitude is True
|
|
assert s.yaw_rad == pytest.approx(1.57)
|
|
|
|
|
|
def test_update_velocity_body_ground_speed_is_horizontal_magnitude():
|
|
gw = Px4Gateway(FakeDrone())
|
|
gw.update_velocity_body(SimpleNamespace(forward_m_s=3.0, right_m_s=4.0,
|
|
down_m_s=-9.9))
|
|
assert gw.own_state().ground_speed_mps == pytest.approx(5.0) # 不含 down
|
|
|
|
|
|
def test_timestamp_uses_injected_clock():
|
|
clock = [1000]
|
|
gw = Px4Gateway(FakeDrone(), now_ms_fn=lambda: clock[0])
|
|
gw.update_position(_pos())
|
|
assert gw.own_state().time_ms == 1000
|
|
assert gw.own_state().position_time_ms == 1000
|
|
clock[0] = 1500
|
|
gw.update_attitude(_att())
|
|
assert gw.own_state().time_ms == 1500
|
|
assert gw.own_state().position_time_ms == 1000 # 姿态流不得代刷位置时间戳
|
|
|
|
|
|
def test_own_state_returns_isolated_copy():
|
|
gw = Px4Gateway(FakeDrone())
|
|
gw.update_position(_pos(lat=10.0))
|
|
snap = gw.own_state()
|
|
snap.latitude_deg = 999.0
|
|
assert gw.own_state().latitude_deg == 10.0 # 内部状态未被外部副本修改
|
|
|
|
|
|
# --- offboard 指令委托 ------------------------------------------------------
|
|
def test_command_delegation_reaches_offboard():
|
|
drone = FakeDrone()
|
|
gw = Px4Gateway(drone)
|
|
|
|
async def scenario():
|
|
assert await gw.start_offboard()
|
|
out = SimpleNamespace(yaw_rate_dps=5.0, vx_body_mps=1.5, vz_mps=0.0,
|
|
vx_mps=0.0, vy_mps=0.0, yaw_deg=0.0)
|
|
assert await gw.send_velocity_heading(out, 1)
|
|
assert await gw.send_brake_hover(2)
|
|
await gw.stop_offboard()
|
|
|
|
asyncio.run(scenario())
|
|
assert drone.offboard.start_count == 1
|
|
assert len(drone.offboard.body) >= 2
|
|
assert drone.offboard.stop_count == 1
|
|
|
|
|
|
# --- 异步订阅启停 -----------------------------------------------------------
|
|
def test_start_telemetry_populates_cache_then_stop():
|
|
telemetry = FakeTelemetry(
|
|
position=[_pos(lat=22.0, lon=114.0)],
|
|
attitude=[_att(yaw=0.5)],
|
|
gps=[SimpleNamespace(fix_type=3, num_satellites=10)],
|
|
velocity=[SimpleNamespace(forward_m_s=2.0, right_m_s=0.0, down_m_s=0.0)],
|
|
)
|
|
gw = Px4Gateway(FakeDrone(telemetry))
|
|
|
|
async def scenario():
|
|
await gw.start_telemetry()
|
|
await asyncio.sleep(0.05) # 让后台任务消费预设消息
|
|
s = gw.own_state()
|
|
assert s.latitude_deg == 22.0 and s.longitude_deg == 114.0
|
|
assert s.yaw_rad == pytest.approx(0.5)
|
|
assert s.position_valid is True
|
|
assert s.ground_speed_mps == pytest.approx(2.0)
|
|
await gw.stop_telemetry()
|
|
assert gw._tasks == []
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_default_own_state_is_invalid():
|
|
s = OwnState()
|
|
assert s.has_position is False and s.position_valid is False
|
|
assert s.time_ms == 0 and s.position_time_ms == 0
|
|
|
|
|
|
|
|
def test_position_timestamp_not_refreshed_by_other_streams():
|
|
"""缺陷①回归:position 流断更后,attitude/gps/velocity 高频流继续刷新
|
|
time_ms 时,position_time_ms 必须保持原值(位置超龄判定才有效)。"""
|
|
clock = [1000]
|
|
gw = Px4Gateway(FakeDrone(), now_ms_fn=lambda: clock[0])
|
|
gw.update_position(_pos())
|
|
assert gw.own_state().position_time_ms == 1000
|
|
clock[0] = 2000
|
|
gw.update_attitude(_att())
|
|
gw.update_gps(SimpleNamespace(fix_type=3, num_satellites=9))
|
|
gw.update_velocity_body(SimpleNamespace(forward_m_s=1.0, right_m_s=0.0,
|
|
down_m_s=0.0))
|
|
s = gw.own_state()
|
|
assert s.time_ms == 2000 # 诊断时间戳照常刷新
|
|
assert s.position_time_ms == 1000 # 位置时间戳停留 → 超龄判定可触发
|
|
clock[0] = 2500
|
|
gw.update_position(_pos(lat=32.0)) # position 流恢复 → 时间戳跟进
|
|
assert gw.own_state().position_time_ms == 2500
|