实现预定流程全链路: 任务状态机 + 编排器 + 地面站 + 真机/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:
@@ -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()
|
||||
Reference in New Issue
Block a user