实现预定流程全链路: 任务状态机 + 编排器 + 地面站 + 真机/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
+139
View File
@@ -0,0 +1,139 @@
"""ground_station/app.py —— 地面站页面(Flask,临时测试用)。
预定流程 C 阶段:地面坐标输入 + 状态可视化配套页面。
功能:
* GET / 地面站页面(坐标输入 + 状态面板 + 模拟视野)
* POST /api/inject 接收 {lat, lon, alt},UDP 发到吊舱桥接的 ground_inject 端口
(默认 14555,GroundLink.run() 接收)
* GET /api/state 返回最新任务状态(读任务日志:事件最后状态 + 遥测最后一行)
与无人机 runner 解耦:本页面只做「发坐标(UDP)+ 读日志(只读)」,不侵入 runner。
用法:
python ground_station/app.py --udp-port 14555 --log-dir logs
"""
import argparse
import csv
import json
import os
import socket
from pathlib import Path
from flask import Flask, jsonify, render_template, request
_GROUND_UDP_PORT = 14555
_LOG_DIR = "logs"
def _read_last_line(path: Path):
"""读文件最后一行(tail,避免读全文)。"""
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
if size == 0:
return None
f.seek(max(0, size - 8192))
lines = f.read().decode("utf-8", errors="replace").splitlines()
return lines[-1] if lines else None
except OSError:
return None
def _latest_state(log_dir: str) -> dict:
"""从任务日志读出当前状态 + 最新遥测(只读,供 /api/state)。"""
log_path = Path(log_dir)
state = "unknown"
events = []
# 事件日志:找最后一个 state 事件(当前任务状态)
events_path = log_path / "task_events.jsonl"
if events_path.exists():
try:
with open(events_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
events.append(ev)
if ev.get("type") == "state":
state = ev.get("to_state", state)
except OSError:
pass
# 遥测最后一行
telemetry = None
tel_path = log_path / "task_telemetry.csv"
last = _read_last_line(tel_path)
if last:
try:
with open(tel_path, encoding="utf-8") as f:
header = f.readline().strip().split(",")
values = last.split(",")
telemetry = dict(zip(header, values))
except OSError:
pass
return {"state": state, "telemetry": telemetry,
"events": events[-20:], "log_dir": str(log_path)}
def create_app(udp_port: int, log_dir: str) -> Flask:
app = Flask(__name__)
@app.route("/")
def index():
return render_template("index.html", udp_port=udp_port)
@app.route("/api/inject", methods=["POST"])
def inject():
data = request.get_json(silent=True) or {}
lat = data.get("lat")
lon = data.get("lon")
if lat is None or lon is None:
return jsonify({"ok": False, "error": "需要 lat 和 lon"}), 400
try:
lat = float(lat)
lon = float(lon)
except (TypeError, ValueError):
return jsonify({"ok": False, "error": "lat/lon 必须是数值"}), 400
alt = float(data.get("alt", 0.0) or 0.0)
payload = json.dumps({"lat": lat, "lon": lon, "alt": alt})
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.sendto(payload.encode("utf-8"), ("127.0.0.1", udp_port))
except OSError as exc:
return jsonify({"ok": False, "error": f"UDP 发送失败: {exc}"}), 500
return jsonify({"ok": True, "sent": {"lat": lat, "lon": lon, "alt": alt}})
@app.route("/api/state")
def state():
return jsonify(_latest_state(log_dir))
return app
def main() -> int:
ap = argparse.ArgumentParser(description="地面站页面(Flask)")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=5000, help="Flask 监听端口")
ap.add_argument("--udp-port", type=int, default=_GROUND_UDP_PORT,
help="坐标注入 UDP 端口(GroundLink 监听)")
ap.add_argument("--log-dir", default=_LOG_DIR, help="任务日志目录")
args = ap.parse_args()
app = create_app(args.udp_port, args.log_dir)
print(f"[地面站] http://{args.host}:{args.port} | UDP 注入 → 127.0.0.1:{args.udp_port}"
f" | 日志 → {args.log_dir}")
app.run(host=args.host, port=args.port, debug=False)
return 0
if __name__ == "__main__":
main()