Files
keita df91b1110e 精简仓库结构并增强任务链路:归档双目可选后端、新增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。
2026-10-21 14:48:41 +08:00

190 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ground_link.py —— 地面站链路(坐标注入 + 控制命令,预定流程.md「地面给坐标」)。
本吊舱无激光 → 表4 目标经纬度永不出(部署文档 §10.0),目标坐标改由地面站注入。
本模块负责接收地面站下发的两类 UDP JSON 消息:
* 目标坐标 {"lat":..,"lon":..,"alt":..} → 维护一份带本地打戳的注入目标缓存,
供 PodBridge 的 L2 地理引导**优先消费**(回退表4);
* 控制命令 {"cmd":"<name>"}(rtl / emergency / pod_*)→ 入命令队列,
由 TaskRunner 主循环 drain_commands() 取走并分派。
设计要点:
* UDP 无连接监听;坐标包可选 "ts" 仅诊断,不用。
* 新鲜度用「本地接收时刻」打戳(now_ms_fn 单调时钟),**不信任地面站时钟**
(两端时钟不同步);超龄由下游按 GroundInjectConfig.timeout_ms 判无效。
* 命令是事件(非状态):队列缓存、满则丢最旧;未知命令名由上层忽略。
* parse_payload / parse_message 为纯函数(可单测);socket / 事件循环拆到
run(),且 socket 延迟导入,模块 import 无副作用(bridge 仅复用 GroundTarget 类型)。
"""
import asyncio
import json
import logging
from collections import deque
from dataclasses import dataclass
from typing import Callable, Optional
from visual_track_control.staleness_checker import is_stale
logger = logging.getLogger(__name__)
def _is_number(value) -> bool:
"""数值(排除 bool)。"""
return isinstance(value, (int, float)) and not isinstance(value, bool)
@dataclass
class GroundTarget:
"""地面注入的目标坐标(带本地接收时刻)。
字段与 protocol.TargetGeo 语义对齐(经纬度 °、绝对高度 m),但不含表4 的
时间字段——那些由吊舱解算生成,地面注入不提供。
"""
latitude_deg: float = 0.0
longitude_deg: float = 0.0
abs_alt_m: float = 0.0
time_ms: int = 0 # 本地接收时刻(单调毫秒),0 = 从未注入
@dataclass
class GroundCommand:
"""地面站控制命令(事件语义)。
name 为小写命令名(rtl / emergency / pod_detect_on / …);time_ms 为本地
接收时刻(单调毫秒,仅日志/诊断用)。合法性(白名单)由上层分派时判。
"""
name: str = ""
time_ms: int = 0
@dataclass
class GroundInjectConfig:
"""地面坐标注入配置(全外置;数值占位,待落地时回填)。"""
enabled: bool = False # 是否启用地面注入(骨架期默认关,走表4 原路径)
host: str = "0.0.0.0" # UDP 监听地址
port: int = 14555 # UDP 监听端口(避开 MAVSDK SITL 14540)
timeout_ms: int = 5000 # 注入坐标超龄门限(超龄判无效 → 下游刹车)
class GroundLink:
"""地面站链路接收器(坐标注入 + 控制命令队列)。
用法(在 runner 外部以 asyncio task 运行):
link = GroundLink(cfg, now_ms_fn)
task = asyncio.create_task(link.run(stop_event))
PodBridge 通过 `ground_target_fn=lambda: link.latest()` 读取注入坐标;
TaskRunner 主循环通过 `drain_commands()` 取控制命令。
"""
def __init__(self, cfg: GroundInjectConfig,
now_ms_fn: Callable[[], int]) -> None:
self._cfg = cfg
self._now_ms = now_ms_fn
self._target = GroundTarget()
self._commands: deque[str] = deque(maxlen=16)
def latest(self) -> GroundTarget:
"""返回当前注入目标(可能 time_ms=0 表示从未注入)。"""
return self._target
def inject(self, target: GroundTarget) -> None:
"""直接注入目标并本地打戳(测试 / 地面站本地注入用,与 UDP run() 等价)。"""
target.time_ms = self._now_ms()
self._target = target
def drain_commands(self) -> list[str]:
"""取走全部待处理命令(FIFO,取后清空)。命令为事件,无新鲜度概念。"""
cmds = list(self._commands)
self._commands.clear()
return cmds
def is_valid(self, now_ms: int) -> bool:
"""注入坐标是否新鲜有效(未注入 / 超龄 → False)。"""
t = self._target
if t.time_ms <= 0:
return False
return not is_stale(t.time_ms, now_ms, self._cfg.timeout_ms)
@staticmethod
def _load_object(payload: bytes) -> Optional[dict]:
"""解 JSON 且要求顶层为对象(纯函数)。非法 JSON / 非对象 → None。"""
try:
obj = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(obj, dict):
return None
return obj
@staticmethod
def _target_from_object(obj: dict) -> Optional[GroundTarget]:
"""从 dict 提取坐标(缺 lat/lon 或类型错 → None)。"""
lat = obj.get("lat")
lon = obj.get("lon")
if not _is_number(lat) or not _is_number(lon):
return None
alt = obj.get("alt", 0.0)
if not _is_number(alt):
return None
return GroundTarget(latitude_deg=float(lat), longitude_deg=float(lon),
abs_alt_m=float(alt))
@staticmethod
def parse_payload(payload: bytes) -> Optional[GroundTarget]:
"""解析坐标包(纯函数,可单测)。返回不含 time_ms 的目标;
非法 JSON / 非对象 / 缺 lat 或 lon → None(静默丢弃)。"""
obj = GroundLink._load_object(payload)
if obj is None:
return None
return GroundLink._target_from_object(obj)
@staticmethod
def parse_message(payload: bytes) -> Optional[GroundTarget | GroundCommand]:
"""解析任意地面站包(纯函数):{"cmd":".."} → GroundCommand(优先),
否则按坐标包解析;非法包 → None(静默丢弃)。"""
obj = GroundLink._load_object(payload)
if obj is None:
return None
cmd = obj.get("cmd")
if isinstance(cmd, str) and cmd.strip():
return GroundCommand(name=cmd.strip().lower())
return GroundLink._target_from_object(obj)
async def run(self, stop_event: asyncio.Event) -> None:
"""UDP 接收循环:每收一包合法 JSON 更新注入目标并本地打戳。"""
import socket # 延迟导入:仅真正跑 socket 时才需要
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self._cfg.host, self._cfg.port))
sock.setblocking(False)
loop = asyncio.get_running_loop()
logger.info("[GroundLink] 监听 UDP %s:%d", self._cfg.host, self._cfg.port)
try:
while not stop_event.is_set():
try:
data, _addr = await loop.sock_recvfrom(sock, 1024)
except asyncio.CancelledError:
raise
except OSError as exc:
logger.warning("[GroundLink] 接收异常: %s", exc)
await asyncio.sleep(0.05)
continue
msg = self.parse_message(data)
if msg is None:
continue # 非法包静默丢弃(热路径不打日志)
msg.time_ms = self._now_ms()
if isinstance(msg, GroundCommand):
self._commands.append(msg.name)
logger.debug("[GroundLink] 收到命令: %s", msg.name)
continue
self._target = msg
logger.debug("[GroundLink] 更新注入目标 (%f, %f, %fm)",
msg.latitude_deg, msg.longitude_deg,
msg.abs_alt_m)
finally:
sock.close()