"""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()