新增pod_bridge吊舱桥接中间件(树莓派4B部署)

- pod_bridge: 协议G版编解码/地理位置引导律/PX4网关/串口链路/闭环主循环
- 含三项安全修复: position_time_ms拆分、表2-2锁定门控、run()异常护栏
- 测试92条全绿; 附树莓派4B(Ubuntu 24.04)部署注意事项与最小依赖清单
This commit is contained in:
keita
2026-09-23 14:38:23 +08:00
parent 3a8e2c7f4b
commit 477ad5f5de
17 changed files with 2758 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
"""protocol_test.py —— 光电吊舱协议编解码单元测试(规格:docs/光电吊舱控制协议G版-PB.docx)。
覆盖:文档样例校验和、四帧 encode/decode 回环、量纲换算、遥测位域属性、
分帧器(粘包/半帧/噪声前缀/坏校验恢复)、异常帧拒绝。
"""
import pytest
from pod_bridge.protocol import (
CarrierAttitude,
ControlCode,
ControlCommand,
FrameParser,
Telemetry,
TargetGeo,
checksum,
decode_attitude,
decode_control,
decode_target,
decode_telemetry,
encode_attitude,
encode_control,
encode_target,
encode_telemetry,
)
# --- 校验和:文档第 589 行样例 ---------------------------------------------
def test_checksum_matches_doc_example():
# 文档样例 eb 90 2b 00..00(15 字节 body)累加取低 8 位应为 0xa6
body = bytes.fromhex("eb902b000000000000000000000000")
assert len(body) == 15
assert checksum(body) == 0xA6
def test_encode_control_center_matches_doc_bytes():
frame = encode_control(ControlCommand(code=ControlCode.CENTER))
assert frame == bytes.fromhex("eb902b000000000000000000000000a6")
assert len(frame) == 16
# --- 表2-1 控制指令回环 -----------------------------------------------------
def test_control_roundtrip_box_track():
cmd = ControlCommand(code=ControlCode.BOX_TRACK, param_x=-120, param_y=340,
param3=2, file_save_mode=1, param_x2=500, param_y2=-600)
decoded = decode_control(encode_control(cmd))
assert decoded == cmd
def test_control_signed_params_preserved():
cmd = ControlCommand(code=ControlCode.POINT_TRACK, param_x=-32768, param_y=32767)
decoded = decode_control(encode_control(cmd))
assert decoded.param_x == -32768
assert decoded.param_y == 32767
# --- 表3 载机姿态回环(含量纲换算)----------------------------------------
def test_attitude_roundtrip():
att = CarrierAttitude(
roll_deg=1.23, pitch_deg=-4.56, yaw_deg=179.99,
latitude_deg=31.2345678, longitude_deg=121.4737012,
abs_alt_m=123.4, rel_alt_m=56.8,
year=2026, month=9, day=22, hour=14, minute=30, second=15, centisecond=42,
airspeed_mps=15.5, ground_speed_mps=12.0,
)
d = decode_attitude(encode_attitude(att))
assert d.roll_deg == pytest.approx(1.23, abs=0.01)
assert d.pitch_deg == pytest.approx(-4.56, abs=0.01)
assert d.yaw_deg == pytest.approx(179.99, abs=0.01)
assert d.latitude_deg == pytest.approx(31.2345678, abs=1e-7)
assert d.longitude_deg == pytest.approx(121.4737012, abs=1e-7)
assert d.abs_alt_m == pytest.approx(123.4, abs=0.2)
assert d.rel_alt_m == pytest.approx(56.8, abs=0.2)
assert (d.year, d.month, d.day) == (2026, 9, 22)
assert (d.hour, d.minute, d.second, d.centisecond) == (14, 30, 15, 42)
assert d.airspeed_mps == pytest.approx(15.5, abs=0.5)
assert d.ground_speed_mps == pytest.approx(12.0, abs=0.5)
def test_attitude_sync_and_length():
frame = encode_attitude(CarrierAttitude())
assert len(frame) == 32
assert frame[0] == 0xEB and frame[1] == 0x91
# --- 表4 目标经纬度回环 -----------------------------------------------------
def test_target_roundtrip():
tgt = TargetGeo(latitude_deg=31.2345678, longitude_deg=121.4737012,
abs_alt_m=100.2, rel_alt_m=33.4,
year=2026, month=9, day=22, hour=8, minute=5, second=30,
centisecond=99)
d = decode_target(encode_target(tgt))
assert d.latitude_deg == pytest.approx(31.2345678, abs=1e-7)
assert d.longitude_deg == pytest.approx(121.4737012, abs=1e-7)
assert d.abs_alt_m == pytest.approx(100.2, abs=0.2)
assert d.rel_alt_m == pytest.approx(33.4, abs=0.2)
assert d.year == 2026 and d.centisecond == 99
def test_target_negative_longitude():
tgt = TargetGeo(latitude_deg=-33.8688, longitude_deg=-70.6693)
d = decode_target(encode_target(tgt))
assert d.longitude_deg == pytest.approx(-70.6693, abs=1e-7)
assert d.latitude_deg == pytest.approx(-33.8688, abs=1e-7)
# --- 表2-2 遥测:物理量 + 位域 ---------------------------------------------
def test_telemetry_roundtrip_physical_values():
t = Telemetry(status1=0b10000100, status2=0b00101010, status3=0b01000000,
off_target_x_deg=1.23, off_target_y_deg=-4.56,
frame_roll_deg=10.0, frame_pitch_deg=-20.0, frame_yaw_deg=90.5,
att_roll_deg=2.0, att_pitch_deg=-3.0,
roll_rate_dps=1.5, pitch_rate_dps=-2.5, yaw_rate_dps=30.0,
laser_range_m=123.4, self_check=0)
d = decode_telemetry(encode_telemetry(t))
assert d.off_target_x_deg == pytest.approx(1.23, abs=0.01)
assert d.off_target_y_deg == pytest.approx(-4.56, abs=0.01)
assert d.frame_yaw_deg == pytest.approx(90.5, abs=0.01)
assert d.yaw_rate_dps == pytest.approx(30.0, abs=0.01)
assert d.laser_range_m == pytest.approx(123.4, abs=0.1)
def test_telemetry_laser_zero_is_none():
d = decode_telemetry(encode_telemetry(Telemetry(laser_range_m=None)))
assert d.laser_range_m is None
def test_telemetry_bitfield_properties():
t = Telemetry(status1=0b11000100, status2=0b00101110, status3=0b01100000)
d = decode_telemetry(encode_telemetry(t))
assert d.track_source == 0b11 # 红外2
assert d.is_target_locked is True # status1 Bit2 = 1
assert d.motor_on is True # status2 Bit3 = 1
assert d.follow_on is True # status2 Bit2 = 1
assert d.big_picture == 0b01
assert d.small_picture == 0b10
# --- 异常帧拒绝 ------------------------------------------------------------
def test_decode_rejects_bad_sync():
frame = bytearray(encode_control(ControlCommand(code=ControlCode.CENTER)))
frame[1] = 0x91 # 破坏同步字
with pytest.raises(ValueError, match="同步字"):
decode_control(bytes(frame))
def test_decode_rejects_bad_checksum():
frame = bytearray(encode_target(TargetGeo(latitude_deg=10.0)))
frame[31] ^= 0xFF
with pytest.raises(ValueError, match="校验和"):
decode_target(bytes(frame))
def test_decode_rejects_wrong_length():
with pytest.raises(ValueError, match="帧长"):
decode_telemetry(b"\xee\x16" + b"\x00" * 10)
# --- 分帧器 ----------------------------------------------------------------
def test_parser_single_frame():
parser = FrameParser()
frames = parser.feed(encode_target(TargetGeo(latitude_deg=31.0, longitude_deg=121.0)))
assert len(frames) == 1
assert isinstance(frames[0], TargetGeo)
assert frames[0].latitude_deg == pytest.approx(31.0, abs=1e-7)
def test_parser_byte_by_byte_half_frame():
parser = FrameParser()
raw = encode_telemetry(Telemetry(laser_range_m=50.0))
collected = []
for i in range(len(raw)):
collected.extend(parser.feed(raw[i:i + 1]))
assert len(collected) == 1
assert collected[0].laser_range_m == pytest.approx(50.0, abs=0.1)
def test_parser_concatenated_and_mixed_frames():
parser = FrameParser()
blob = (encode_telemetry(Telemetry(laser_range_m=10.0))
+ encode_target(TargetGeo(latitude_deg=1.0))
+ encode_telemetry(Telemetry(laser_range_m=20.0)))
frames = parser.feed(blob)
assert [type(f) for f in frames] == [Telemetry, TargetGeo, Telemetry]
assert frames[0].laser_range_m == pytest.approx(10.0, abs=0.1)
assert frames[2].laser_range_m == pytest.approx(20.0, abs=0.1)
def test_parser_skips_noise_prefix():
parser = FrameParser()
blob = b"\x00\xff\x12\x34" + encode_target(TargetGeo(latitude_deg=5.0))
frames = parser.feed(blob)
assert len(frames) == 1
assert frames[0].latitude_deg == pytest.approx(5.0, abs=1e-7)
def test_parser_recovers_from_bad_checksum():
parser = FrameParser()
bad = bytearray(encode_telemetry(Telemetry(laser_range_m=1.0)))
bad[31] ^= 0xFF # 坏校验
good = encode_target(TargetGeo(latitude_deg=7.0))
frames = parser.feed(bytes(bad) + good)
# 坏帧被丢弃,紧随的好帧仍能解析
assert len(frames) == 1
assert isinstance(frames[0], TargetGeo)
def test_parser_split_across_feeds():
parser = FrameParser()
raw = encode_target(TargetGeo(latitude_deg=42.0))
assert parser.feed(raw[:5]) == [] # 半帧,未输出
frames = parser.feed(raw[5:])
assert len(frames) == 1
assert frames[0].latitude_deg == pytest.approx(42.0, abs=1e-7)
def test_parser_accepts_configured_kinds():
parser = FrameParser(kinds=((0xEB, 0x90),)) # 只识别控制帧(模拟器侧)
frames = parser.feed(encode_control(ControlCommand(code=ControlCode.CENTER)))
assert len(frames) == 1
assert isinstance(frames[0], ControlCommand)
assert frames[0].code == ControlCode.CENTER