精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航
归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。 目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。 自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。 任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。 入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。 新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
"""app_test.py —— 地面站 Flask 端点测试(test_client + 真实 UDP 回环监听)。
|
||||
|
||||
覆盖:POST /api/inject 合法/非法(UDP 实收断言)、局部坐标注入换算与拒绝、
|
||||
POST /api/command 白名单校验与 UDP 下发、POST /api/continuous 服务端持续注入启停、
|
||||
GET /api/link 链路目标读写、GET /api/state 空日志目录兜底与 continuous 标志。
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3] # utils/tests/ground_station → 仓库根
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from ground_station.app import create_app # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udp_listener():
|
||||
"""真实 UDP 回环监听:返回 (port, recv_one)。"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.settimeout(1.5)
|
||||
|
||||
def recv_one():
|
||||
data, _addr = s.recvfrom(2048)
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
yield s.getsockname()[1], recv_one
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, udp_listener):
|
||||
port, _recv = udp_listener
|
||||
app = create_app(udp_port=port, log_dir=str(tmp_path),
|
||||
inject_interval_s=0.05) # 测试用短周期
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# --- /api/inject ------------------------------------------------------------
|
||||
def test_inject_ok_sends_udp(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/inject", json={"lat": 1.5, "lon": 2.5, "alt": 10})
|
||||
assert r.get_json()["ok"] is True
|
||||
assert recv_one() == {"lat": 1.5, "lon": 2.5, "alt": 10.0}
|
||||
|
||||
|
||||
def test_inject_missing_lat_400(client):
|
||||
r = client.post("/api/inject", json={"lon": 2.5})
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()["ok"] is False
|
||||
|
||||
|
||||
def test_inject_non_numeric_400(client):
|
||||
r = client.post("/api/inject", json={"lat": "x", "lon": 2.5})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# --- 局部坐标注入 -------------------------------------------------------------
|
||||
_TEL_HEADER = ("t_s,state,pos_valid,fix_type,num_satellites,latitude_deg,"
|
||||
"longitude_deg,relative_altitude_m,yaw_deg,target_injected,"
|
||||
"distance_to_target_m,target_locked,off_target_x_deg,"
|
||||
"off_target_y_deg,mode,yaw_rate_dps,vx_body_mps,vz_mps\n")
|
||||
|
||||
|
||||
def _write_telemetry(log_dir, lat=47.0, lon=8.0, pos_valid="True"):
|
||||
with open(Path(log_dir) / "task_telemetry.csv", "w", encoding="utf-8") as f:
|
||||
f.write(_TEL_HEADER)
|
||||
f.write(f"1.0,wait,{pos_valid},3,10,{lat},{lon},0,0,False,inf,False,"
|
||||
"0,0,,0,0,0\n")
|
||||
|
||||
|
||||
def test_inject_local_converts_from_vehicle_position(client, udp_listener, tmp_path):
|
||||
_write_telemetry(tmp_path, lat=47.0, lon=8.0)
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15, "alt": 0})
|
||||
j = r.get_json()
|
||||
assert j["ok"] is True
|
||||
exp_lat = 47.0 + 30 / 111320.0
|
||||
exp_lon = 8.0 + 15 / (111320.0 * math.cos(math.radians(47.0)))
|
||||
msg = recv_one()
|
||||
assert msg["lat"] == pytest.approx(exp_lat, abs=1e-9)
|
||||
assert msg["lon"] == pytest.approx(exp_lon, abs=1e-9)
|
||||
assert j["local"]["north_m"] == 30 and j["local"]["east_m"] == 15
|
||||
assert j["local"]["origin_lat"] == pytest.approx(47.0)
|
||||
|
||||
|
||||
def test_inject_local_rejected_without_telemetry(client):
|
||||
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
|
||||
assert r.status_code == 400
|
||||
assert "无法换算" in r.get_json()["error"]
|
||||
|
||||
|
||||
def test_inject_local_rejected_when_pos_invalid(client, tmp_path):
|
||||
_write_telemetry(tmp_path, pos_valid="False")
|
||||
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_inject_local_bad_numbers_400(client, tmp_path):
|
||||
_write_telemetry(tmp_path)
|
||||
assert client.post("/api/inject",
|
||||
json={"north_m": "x", "east_m": 15}).status_code == 400
|
||||
|
||||
|
||||
def test_inject_local_home_origin_uses_session_start(client, udp_listener, tmp_path):
|
||||
# 同一段:起始有效定位 47.0/8.0(≈起飞点),之后移动到 47.5/8.5
|
||||
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
|
||||
f.write(_TEL_HEADER)
|
||||
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
f.write("60.0,goto,True,3,10,47.5,8.5,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
_port, recv_one = udp_listener
|
||||
|
||||
j = client.post("/api/inject",
|
||||
json={"north_m": 0, "east_m": 0, "origin": "home"}).get_json()
|
||||
assert j["ok"] is True and j["local"]["origin"] == "home"
|
||||
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 段首而非最后行
|
||||
assert j["local"]["origin_lon"] == pytest.approx(8.0)
|
||||
recv_one()
|
||||
|
||||
j2 = client.post("/api/inject",
|
||||
json={"north_m": 0, "east_m": 0}).get_json() # current
|
||||
assert j2["local"]["origin"] == "current"
|
||||
assert j2["local"]["origin_lat"] == pytest.approx(47.5) # 当前位置
|
||||
recv_one()
|
||||
|
||||
|
||||
def test_inject_local_home_origin_skips_invalid_rows(client, tmp_path):
|
||||
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
|
||||
f.write(_TEL_HEADER)
|
||||
f.write("0.5,wait,False,0,0,0.0,0.0,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
|
||||
j = client.post("/api/inject",
|
||||
json={"north_m": 30, "east_m": 15,
|
||||
"origin": "home"}).get_json()
|
||||
assert j["ok"] is True
|
||||
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 取首个有效定位
|
||||
|
||||
|
||||
def test_inject_local_bad_origin_400(client, tmp_path):
|
||||
_write_telemetry(tmp_path)
|
||||
r = client.post("/api/inject",
|
||||
json={"north_m": 1, "east_m": 1, "origin": "mars"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_inject_local_home_origin_rejected_without_telemetry(client):
|
||||
r = client.post("/api/inject",
|
||||
json={"north_m": 30, "east_m": 15, "origin": "home"})
|
||||
assert r.status_code == 400
|
||||
assert "起飞点" in r.get_json()["error"]
|
||||
|
||||
|
||||
# --- /api/command -----------------------------------------------------------
|
||||
def test_command_whitelist_ok_sends_udp(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/command", json={"cmd": "rtl"})
|
||||
assert r.get_json() == {"ok": True, "sent": {"cmd": "rtl"}}
|
||||
assert recv_one() == {"cmd": "rtl"}
|
||||
|
||||
|
||||
def test_command_all_whitelist_entries_accepted(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
for cmd in ("rtl", "emergency", "pod_detect_on", "pod_auto_lock",
|
||||
"pod_stop_track", "pod_detect_off", "pod_center"):
|
||||
r = client.post("/api/command", json={"cmd": cmd})
|
||||
assert r.get_json()["ok"] is True
|
||||
assert recv_one() == {"cmd": cmd}
|
||||
|
||||
|
||||
def test_command_unknown_rejected_no_udp(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
r = client.post("/api/command", json={"cmd": "reboot"})
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()["ok"] is False
|
||||
with pytest.raises(socket.timeout):
|
||||
recv_one() # 未下发任何 UDP → 监听超时即证明
|
||||
|
||||
|
||||
def test_command_non_string_rejected(client):
|
||||
r = client.post("/api/command", json={"cmd": 1})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# --- /api/continuous --------------------------------------------------------
|
||||
def test_continuous_requires_prior_inject(client):
|
||||
r = client.post("/api/continuous", json={"on": True})
|
||||
assert r.status_code == 400
|
||||
assert r.get_json()["ok"] is False
|
||||
|
||||
|
||||
def test_continuous_non_bool_rejected(client):
|
||||
r = client.post("/api/continuous", json={"on": "yes"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_continuous_sends_repeatedly_and_stops(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 手动单发
|
||||
|
||||
r = client.post("/api/continuous", json={"on": True})
|
||||
assert r.get_json() == {"ok": True, "continuous": True}
|
||||
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 服务端自动重发
|
||||
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0}
|
||||
|
||||
r = client.post("/api/continuous", json={"on": False})
|
||||
assert r.get_json() == {"ok": True, "continuous": False}
|
||||
time.sleep(0.2)
|
||||
while True: # 排空在途包
|
||||
try:
|
||||
recv_one()
|
||||
except socket.timeout:
|
||||
break
|
||||
with pytest.raises(socket.timeout):
|
||||
recv_one() # 停止后不再发
|
||||
|
||||
|
||||
def test_continuous_uses_latest_inject(client, udp_listener):
|
||||
_port, recv_one = udp_listener
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
recv_one()
|
||||
client.post("/api/inject", json={"lat": 3.0, "lon": 4.0}) # 中途改坐标
|
||||
recv_one()
|
||||
|
||||
client.post("/api/continuous", json={"on": True})
|
||||
assert recv_one() == {"lat": 3.0, "lon": 4.0, "alt": 0.0}
|
||||
client.post("/api/continuous", json={"on": False})
|
||||
|
||||
|
||||
# --- /api/link --------------------------------------------------------------
|
||||
def test_link_get_default_and_options(client, udp_listener):
|
||||
port, _recv = udp_listener
|
||||
j = client.get("/api/link").get_json()
|
||||
assert j["host"] == "127.0.0.1"
|
||||
assert j["port"] == port
|
||||
assert any(o["host"] == "127.0.0.1" for o in j["options"])
|
||||
|
||||
|
||||
def test_link_set_bad_host_or_port_400(client):
|
||||
assert client.post("/api/link", json={"host": " "}).status_code == 400
|
||||
assert client.post("/api/link",
|
||||
json={"host": "1.2.3.4", "port": "x"}).status_code == 400
|
||||
assert client.post("/api/link",
|
||||
json={"host": "1.2.3.4", "port": 70000}).status_code == 400
|
||||
|
||||
|
||||
def test_link_switch_redirects_inject_and_command(client, udp_listener):
|
||||
_port_a, recv_a = udp_listener
|
||||
s_b = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s_b.bind(("127.0.0.1", 0))
|
||||
s_b.settimeout(1.5)
|
||||
port_b = s_b.getsockname()[1]
|
||||
try:
|
||||
r = client.post("/api/link", json={"host": "127.0.0.1", "port": port_b})
|
||||
assert r.get_json() == {"ok": True, "host": "127.0.0.1", "port": port_b}
|
||||
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
data, _addr = s_b.recvfrom(2048)
|
||||
assert json.loads(data.decode("utf-8")) == {"lat": 1.0, "lon": 2.0,
|
||||
"alt": 0.0}
|
||||
client.post("/api/command", json={"cmd": "rtl"})
|
||||
data, _addr = s_b.recvfrom(2048)
|
||||
assert json.loads(data.decode("utf-8")) == {"cmd": "rtl"}
|
||||
|
||||
with pytest.raises(socket.timeout):
|
||||
recv_a() # 旧目标不再收到任何包
|
||||
finally:
|
||||
s_b.close()
|
||||
|
||||
|
||||
# --- /api/state -------------------------------------------------------------
|
||||
def test_state_empty_log_dir(client):
|
||||
j = client.get("/api/state").get_json()
|
||||
assert j["state"] == "unknown"
|
||||
assert j["telemetry"] is None
|
||||
assert j["continuous"] is False
|
||||
|
||||
|
||||
def test_video_disabled_by_default(client):
|
||||
assert client.get("/api/state").get_json()["video"] == {
|
||||
"enabled": False, "online": False}
|
||||
assert client.get("/video_feed").status_code == 503
|
||||
|
||||
|
||||
def test_state_reports_continuous(client):
|
||||
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
|
||||
client.post("/api/continuous", json={"on": True})
|
||||
assert client.get("/api/state").get_json()["continuous"] is True
|
||||
client.post("/api/continuous", json={"on": False})
|
||||
assert client.get("/api/state").get_json()["continuous"] is False
|
||||
@@ -0,0 +1,138 @@
|
||||
"""video_source_test.py —— RTSP→MJPEG 视频源单元测试(注入假采集器,不依赖真流)。
|
||||
|
||||
覆盖:拉帧→JPEG 编码缓存、宽度限制(等比缩放)、断线重连(重新调用工厂)、
|
||||
打开失败退避重试、online 判定、MJPEG 分块格式。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3] # utils/tests/ground_station → 仓库根
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from ground_station.video_source import RtspVideoSource # noqa: E402
|
||||
|
||||
|
||||
class FakeCapture:
|
||||
"""假采集器:可注入读失败次数;帧为纯色小图。"""
|
||||
|
||||
def __init__(self, fail_reads=0, height=60, width=120):
|
||||
self.fail_reads = fail_reads
|
||||
self._frame = np.full((height, width, 3), 128, dtype=np.uint8)
|
||||
self.released = False
|
||||
|
||||
def isOpened(self):
|
||||
return True
|
||||
|
||||
def read(self):
|
||||
if self.fail_reads > 0:
|
||||
self.fail_reads -= 1
|
||||
return False, None
|
||||
return True, self._frame
|
||||
|
||||
def release(self):
|
||||
self.released = True
|
||||
|
||||
|
||||
def _wait(pred, timeout_s=4.0, step=0.02):
|
||||
t0 = time.monotonic()
|
||||
while time.monotonic() - t0 < timeout_s:
|
||||
if pred():
|
||||
return True
|
||||
time.sleep(step)
|
||||
return False
|
||||
|
||||
|
||||
def _decode(jpeg):
|
||||
import cv2
|
||||
return cv2.imdecode(np.frombuffer(jpeg, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
|
||||
|
||||
def test_frames_encoded_and_online():
|
||||
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
|
||||
capture_factory=lambda: FakeCapture())
|
||||
assert src.online is False # 尚无帧 → 离线
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None)
|
||||
seq, jpeg = src.latest()
|
||||
assert seq >= 1
|
||||
assert jpeg[:2] == b"\xff\xd8" # JPEG SOI 头
|
||||
assert _decode(jpeg).shape[1] == 120 # 宽度不超限
|
||||
assert src.online is True
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_downscale_to_max_width():
|
||||
src = RtspVideoSource("rtsp://fake", width=60, fps=50,
|
||||
capture_factory=lambda: FakeCapture(height=60, width=120))
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None)
|
||||
img = _decode(src.latest()[1])
|
||||
assert img.shape[1] == 60 and img.shape[0] == 30 # 等比缩到 60 宽
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_reconnect_after_read_failure():
|
||||
opened = []
|
||||
|
||||
def factory():
|
||||
cap = FakeCapture(fail_reads=(10 ** 6 if not opened else 0))
|
||||
opened.append(cap)
|
||||
return cap
|
||||
|
||||
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
|
||||
capture_factory=factory)
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None, timeout_s=5.0)
|
||||
assert len(opened) >= 2 # 首采集器读失败 → 重新打开
|
||||
assert opened[0].released is True
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_open_failure_keeps_retrying():
|
||||
calls = []
|
||||
|
||||
class ClosedCapture:
|
||||
def isOpened(self):
|
||||
return False
|
||||
|
||||
def release(self):
|
||||
pass
|
||||
|
||||
def factory():
|
||||
calls.append(1)
|
||||
return ClosedCapture()
|
||||
|
||||
src = RtspVideoSource("rtsp://fake", capture_factory=factory)
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: len(calls) >= 2, timeout_s=4.0) # 1s 退避后重试
|
||||
assert src.latest()[1] is None
|
||||
finally:
|
||||
src.stop()
|
||||
|
||||
|
||||
def test_mjpeg_stream_chunks():
|
||||
src = RtspVideoSource("rtsp://fake", width=120, fps=50,
|
||||
capture_factory=lambda: FakeCapture())
|
||||
src.start()
|
||||
try:
|
||||
assert _wait(lambda: src.latest()[1] is not None)
|
||||
gen = src.mjpeg_stream()
|
||||
chunk = next(gen)
|
||||
assert chunk.startswith(b"--frame\r\nContent-Type: image/jpeg\r\n")
|
||||
assert b"Content-Length: " in chunk
|
||||
assert chunk.endswith(b"\r\n")
|
||||
gen.close()
|
||||
finally:
|
||||
src.stop()
|
||||
Reference in New Issue
Block a user