实现预定流程全链路: 任务状态机 + 编排器 + 地面站 + 真机/SITL入口

- utils/task_runner: 6态状态机(WAIT→ARM_TAKEOFF→GOTO(L2)→TRACK(L1)→APPROACH→RTL)
  + 编排器(快照采集/状态动作) + 双层日志(事件JSONL/遥测CSV) + 吊舱控制指令(0x07/0x0F/0x0E/0x08)
- pod_bridge: ground_link 地面坐标UDP注入(超龄判无效,无激光替代表4);
  set_heading_servo_enabled() 运行时L1/L2切换(切换即reset清积分防输出跳变);
  pod_locked 提为公共门控; config 新增 ground_inject 段
- virtual_pod 模拟吊舱(无电机语义) + verify_px4 增量④仅飞控验证 +
  pod_sitl_test L1 SITL全流程(飞行编排不塞进 run_bridge, 保持纯offboard闭环入口)
- 入口: task_runner_sitl(VirtualPod+虚拟目标, SITL端到端跑通约1m24s) /
  task_runner_real(真串口+UDP注入+吊舱控制)
- 地面站 ground_station: 坐标注入 + 状态可视化(Flask)
- S1标定回填(2026-10-09): beta_sign=+1 / alpha_sign=-1, 加标定回归测试守护
- 新增测试: task_runner x4 + ground_link/virtual_pod*/bridge/px4_gateway 增量; 全量 483 passed
- 文档: 预定流程(需求) + 预定流程-操作指南(操作/待办) + L1方案补S1结论与实现状态
This commit is contained in:
keita
2026-10-13 15:50:32 +08:00
parent 5a944af4f5
commit ded17d7995
30 changed files with 3552 additions and 27 deletions
+135
View File
@@ -0,0 +1,135 @@
"""ground_link.py —— 地面坐标注入通道(预定流程.md「地面给坐标」)。
本吊舱无激光 → 表4 目标经纬度永不出(部署文档 §10.0),目标坐标改由地面站注入。
本模块负责接收地面站下发的目标坐标(UDP JSON),维护一份带本地打戳的注入目标
缓存,供 PodBridge 的 L2 地理引导**优先消费**(回退表4)。
设计要点:
* UDP 无连接监听,每包 JSON {"lat":..,"lon":..,"alt":..}(可选 "ts" 仅诊断,不用)。
* 新鲜度用「本地接收时刻」打戳(now_ms_fn 单调时钟),**不信任地面站时钟**
(两端时钟不同步);超龄由下游按 GroundInjectConfig.timeout_ms 判无效。
* parse_payload 为纯函数(可单测);socket / 事件循环拆到 run(),且 socket
延迟导入,模块 import 无副作用(bridge 仅复用 GroundTarget 类型)。
"""
import asyncio
import json
import logging
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 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()` 读取注入坐标。
"""
def __init__(self, cfg: GroundInjectConfig,
now_ms_fn: Callable[[], int]) -> None:
self._cfg = cfg
self._now_ms = now_ms_fn
self._target = GroundTarget()
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 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 parse_payload(payload: bytes) -> Optional[GroundTarget]:
"""解析地面站 JSON 包(纯函数,可单测)。返回不含 time_ms 的目标;
非法 JSON / 非对象 / 缺 lat 或 lon → None(静默丢弃)。"""
try:
obj = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(obj, dict):
return 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))
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
target = self.parse_payload(data)
if target is None:
continue # 非法包静默丢弃(热路径不打日志)
target.time_ms = self._now_ms()
self._target = target
logger.debug("[GroundLink] 更新注入目标 (%f, %f, %fm)",
target.latitude_deg, target.longitude_deg,
target.abs_alt_m)
finally:
sock.close()