精简仓库结构并增强任务链路:归档双目可选后端、新增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。
This commit is contained in:
+359
-26
@@ -1,30 +1,193 @@
|
||||
"""ground_station/app.py —— 地面站页面(Flask,临时测试用)。
|
||||
|
||||
预定流程 C 阶段:地面坐标输入 + 状态可视化配套页面。
|
||||
预定流程 C 阶段:地面坐标输入 + 任务/吊舱控制 + 状态可视化配套页面。
|
||||
|
||||
功能:
|
||||
* GET / 地面站页面(坐标输入 + 状态面板 + 模拟视野)
|
||||
* POST /api/inject 接收 {lat, lon, alt},UDP 发到吊舱桥接的 ground_inject 端口
|
||||
(默认 14555,GroundLink.run() 接收)
|
||||
* GET /api/state 返回最新任务状态(读任务日志:事件最后状态 + 遥测最后一行)
|
||||
* GET / 地面站页面(坐标注入 + 任务控制 + 吊舱控制 + 状态面板 + 模拟视野)
|
||||
* GET /px4 PX4 信息子页面(实时遥测 + 任务日志解读分页)
|
||||
* POST /api/inject 接收 {lat, lon, alt} 或局部 {north_m, east_m, alt}
|
||||
(局部按注入时刻无人机位置换算为经纬度),UDP 发到 ground_inject
|
||||
端口(默认 14555,GroundLink.run() 接收);同时刷新持续注入内容
|
||||
* POST /api/continuous 接收 {on: bool},启停**服务端**持续注入(后台线程每 0.5s 重发)
|
||||
* POST /api/command 接收 {cmd}(白名单:rtl/emergency/pod_*),同端口 UDP 下发,
|
||||
由 TaskRunner 主循环 drain_commands() 分派
|
||||
* GET /api/link 返回链路目标(坐标/命令发往的地址)+ 本机可选地址列表
|
||||
* POST /api/link {"host":.., "port":..} 切换链路目标(默认 127.0.0.1:14555)
|
||||
* GET /video_feed MJPEG 实时视频(服务端 RTSP 拉流转推;未配置 --rtsp-url 时 503)
|
||||
* GET /api/state 返回最新任务状态(读任务日志:事件最后状态 + 遥测最后一行,
|
||||
附 continuous 标志与 video 状态)
|
||||
* GET /api/logs/telemetry 整段遥测 CSV → JSON(子页面图表用;只读)
|
||||
* GET /api/logs/events 整段事件 JSONL → JSON(子页面解读用;只读)
|
||||
|
||||
与无人机 runner 解耦:本页面只做「发坐标(UDP)+ 读日志(只读)」,不侵入 runner。
|
||||
与无人机 runner 解耦:本页面只做「发 UDP(坐标/命令)+ 读日志(只读)」,不侵入 runner。
|
||||
持续注入放服务端的原因:浏览器会节流不可见标签页的 JS 定时器(实测 0.5s 被降到约
|
||||
1 次/分钟,注入超龄致任务误返航),服务端线程不受页面可见性影响。
|
||||
用法:
|
||||
python ground_station/app.py --udp-port 14555 --log-dir logs
|
||||
python ground_station/app.py --rtsp-url rtsp://192.168.144.119:8554/video
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
from flask import (Flask, Response, jsonify, render_template, request,
|
||||
stream_with_context)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent # 兼容「脚本直跑 / 包导入」双模式
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from ground_station.video_source import RtspVideoSource # noqa: E402
|
||||
|
||||
_GROUND_UDP_PORT = 14555
|
||||
_LOG_DIR = "logs"
|
||||
|
||||
# 日志解读接口的返回上限(超限返回最近 N 条,防内存/流量失控)
|
||||
_LOG_MAX_ROWS = 60000
|
||||
_LOG_MAX_EVENTS = 5000
|
||||
|
||||
# 允许页面下发的控制命令(与 task_runner/runner.py 的 _POD_COMMAND_METHODS 对齐)
|
||||
_COMMAND_WHITELIST = frozenset({
|
||||
"rtl", "emergency",
|
||||
"pod_detect_on", "pod_auto_lock", "pod_stop_track",
|
||||
"pod_detect_off", "pod_center",
|
||||
})
|
||||
|
||||
|
||||
class _LinkTarget:
|
||||
"""链路目标:坐标/命令 UDP 发往的地址(页面可运行时切换)。"""
|
||||
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
|
||||
|
||||
def _local_ipv4_list() -> list:
|
||||
"""本机 IPv4 地址列表(供页面下拉选择;解析失败返回空列表)。"""
|
||||
ips = []
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None,
|
||||
socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip not in ips:
|
||||
ips.append(ip)
|
||||
except OSError:
|
||||
pass
|
||||
return ips
|
||||
|
||||
|
||||
def _is_number(value) -> bool:
|
||||
"""数值(排除 bool)。"""
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
_DEG_LAT_M = 111320.0 # 局部平面近似的纬度米/度(与 task_runner_sitl 同口径)
|
||||
|
||||
|
||||
def _offset_to_latlon(lat0: float, lon0: float, north_m: float,
|
||||
east_m: float) -> tuple:
|
||||
"""北东偏移(米)→ 经纬度(局部平面近似,小范围够用)。"""
|
||||
lat = lat0 + north_m / _DEG_LAT_M
|
||||
lon = lon0 + east_m / (_DEG_LAT_M * math.cos(math.radians(lat0)))
|
||||
return lat, lon
|
||||
|
||||
|
||||
def _session_start_position(log_dir: str):
|
||||
"""本次任务运行段(遥测最后一段)的首个有效定位 ≈ 起飞点/home。
|
||||
|
||||
运行段按 t_s 回退切分;取最后一段里第一条 pos_valid=True 的经纬度。
|
||||
无日志 / 无有效定位 → (None, None)。
|
||||
"""
|
||||
path = Path(log_dir) / "task_telemetry.csv"
|
||||
try:
|
||||
with open(path, encoding="utf-8", newline="") as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
except OSError:
|
||||
return None, None
|
||||
start = 0
|
||||
for i in range(1, len(rows)):
|
||||
try:
|
||||
if float(rows[i].get("t_s") or 0) < float(rows[i - 1].get("t_s") or 0):
|
||||
start = i
|
||||
except ValueError:
|
||||
continue
|
||||
for row in rows[start:]:
|
||||
if (row.get("pos_valid") == "True" and row.get("latitude_deg")
|
||||
and row.get("longitude_deg")):
|
||||
try:
|
||||
return float(row["latitude_deg"]), float(row["longitude_deg"])
|
||||
except ValueError:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def _send_udp(target: _LinkTarget, payload: dict) -> None:
|
||||
"""把 JSON 发到链路目标(坐标注入 / 持续注入 / 控制命令共用)。"""
|
||||
data = json.dumps(payload, ensure_ascii=False)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.sendto(data.encode("utf-8"), (target.host, target.port))
|
||||
|
||||
|
||||
class _ContinuousInjector:
|
||||
"""服务端持续注入:后台线程按固定周期重发最近一次坐标。
|
||||
|
||||
页面 JS 定时器在标签页不可见时被浏览器节流(实测 0.5s → 约 1 次/分钟,
|
||||
注入超龄致任务误返航),故持续注入放服务端;手动「注入坐标」同步刷新内容。
|
||||
"""
|
||||
|
||||
def __init__(self, target: _LinkTarget, interval_s: float = 0.5) -> None:
|
||||
self._target = target
|
||||
self._interval_s = max(0.05, float(interval_s))
|
||||
self._lock = threading.Lock()
|
||||
self._payload = None
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def update(self, payload: dict) -> None:
|
||||
with self._lock:
|
||||
self._payload = payload
|
||||
|
||||
def start(self) -> bool:
|
||||
"""开始持续注入;尚无坐标(未手动注入过)→ False。"""
|
||||
with self._lock:
|
||||
if self._payload is None:
|
||||
return False
|
||||
if self.running:
|
||||
return True
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._loop,
|
||||
name="continuous-inject", daemon=True)
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
thread, self._thread = self._thread, None
|
||||
if thread is not None:
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
with self._lock:
|
||||
payload = self._payload
|
||||
if payload is not None:
|
||||
try:
|
||||
_send_udp(self._target, payload)
|
||||
except OSError:
|
||||
pass # 端口未监听等:静默重试
|
||||
self._stop.wait(self._interval_s)
|
||||
|
||||
|
||||
def _read_last_line(path: Path):
|
||||
"""读文件最后一行(tail,避免读全文)。"""
|
||||
@@ -80,41 +243,197 @@ def _latest_state(log_dir: str) -> dict:
|
||||
pass
|
||||
|
||||
return {"state": state, "telemetry": telemetry,
|
||||
"events": events[-20:], "log_dir": str(log_path)}
|
||||
"events": events[-40:], "log_dir": str(log_path)}
|
||||
|
||||
|
||||
def create_app(udp_port: int, log_dir: str) -> Flask:
|
||||
def create_app(udp_port: int, log_dir: str,
|
||||
udp_host: str = "127.0.0.1",
|
||||
inject_interval_s: float = 0.5,
|
||||
rtsp_url: str = "",
|
||||
video_width: int = 960,
|
||||
video_fps: float = 15.0) -> Flask:
|
||||
app = Flask(__name__)
|
||||
target = _LinkTarget(udp_host, udp_port)
|
||||
injector = _ContinuousInjector(target, inject_interval_s)
|
||||
video = None
|
||||
if rtsp_url:
|
||||
video = RtspVideoSource(rtsp_url, width=video_width, fps=video_fps)
|
||||
video.start()
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html", udp_port=udp_port)
|
||||
|
||||
@app.route("/px4")
|
||||
def px4_page():
|
||||
return render_template("px4.html")
|
||||
|
||||
@app.route("/api/logs/telemetry")
|
||||
def logs_telemetry():
|
||||
"""整段遥测 CSV → JSON(fields + 行数组;前端负责分段/绘图)。只读。"""
|
||||
path = Path(log_dir) / "task_telemetry.csv"
|
||||
fields, rows = [], []
|
||||
try:
|
||||
with open(path, encoding="utf-8", newline="") as f:
|
||||
for i, row in enumerate(csv.reader(f)):
|
||||
if i == 0:
|
||||
fields = row
|
||||
elif row:
|
||||
rows.append(row)
|
||||
except OSError:
|
||||
pass
|
||||
total = len(rows)
|
||||
if total > _LOG_MAX_ROWS:
|
||||
rows = rows[-_LOG_MAX_ROWS:]
|
||||
return jsonify({"fields": fields, "rows": rows,
|
||||
"total": total, "returned": len(rows)})
|
||||
|
||||
@app.route("/api/logs/events")
|
||||
def logs_events():
|
||||
"""整段事件 JSONL → JSON 列表(前端负责分段/解读)。只读。"""
|
||||
path = Path(log_dir) / "task_events.jsonl"
|
||||
events = []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
total = len(events)
|
||||
if total > _LOG_MAX_EVENTS:
|
||||
events = events[-_LOG_MAX_EVENTS:]
|
||||
return jsonify({"events": events, "total": total})
|
||||
|
||||
@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)
|
||||
alt = float(data.get("alt", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "error": "lat/lon 必须是数值"}), 400
|
||||
alt = float(data.get("alt", 0.0) or 0.0)
|
||||
return jsonify({"ok": False, "error": "alt 必须是数值"}), 400
|
||||
|
||||
local = None
|
||||
if "north_m" in data or "east_m" in data:
|
||||
# 局部坐标:按选定原点换算为经纬度
|
||||
# current(默认)= 注入时刻无人机位置;home = 本次运行段起始位置(≈起飞点)
|
||||
north, east = data.get("north_m"), data.get("east_m")
|
||||
if not (_is_number(north) and _is_number(east)):
|
||||
return jsonify({"ok": False,
|
||||
"error": "north_m/east_m 必须是数值"}), 400
|
||||
origin_mode = data.get("origin", "current")
|
||||
if origin_mode not in ("current", "home"):
|
||||
return jsonify({"ok": False,
|
||||
"error": "origin 必须是 current 或 home"}), 400
|
||||
if origin_mode == "home":
|
||||
lat0, lon0 = _session_start_position(log_dir)
|
||||
if lat0 is None:
|
||||
return jsonify({"ok": False,
|
||||
"error": "无本次运行的有效起始位置(遥测缺失/"
|
||||
"定位无效),无法按起飞点换算"}), 400
|
||||
else:
|
||||
tel = (_latest_state(log_dir).get("telemetry") or {})
|
||||
lat0, lon0 = tel.get("latitude_deg"), tel.get("longitude_deg")
|
||||
if tel.get("pos_valid") != "True" or not lat0 or not lon0:
|
||||
return jsonify({"ok": False,
|
||||
"error": "无人机位置无效(无遥测/无 GPS),"
|
||||
"无法换算局部坐标"}), 400
|
||||
lat0, lon0 = float(lat0), float(lon0)
|
||||
lat, lon = _offset_to_latlon(lat0, lon0, float(north), float(east))
|
||||
local = {"north_m": float(north), "east_m": float(east),
|
||||
"origin": origin_mode,
|
||||
"origin_lat": lat0, "origin_lon": lon0}
|
||||
else:
|
||||
lat, lon = data.get("lat"), data.get("lon")
|
||||
if lat is None or lon is None:
|
||||
return jsonify({"ok": False,
|
||||
"error": "需要 lat/lon 或 north_m/east_m"}), 400
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False,
|
||||
"error": "lat/lon 必须是数值"}), 400
|
||||
|
||||
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))
|
||||
_send_udp(target, {"lat": lat, "lon": lon, "alt": alt})
|
||||
except OSError as exc:
|
||||
return jsonify({"ok": False, "error": f"UDP 发送失败: {exc}"}), 500
|
||||
return jsonify({"ok": True, "sent": {"lat": lat, "lon": lon, "alt": alt}})
|
||||
injector.update({"lat": lat, "lon": lon, "alt": alt}) # 刷新持续注入内容
|
||||
resp = {"ok": True, "sent": {"lat": lat, "lon": lon, "alt": alt}}
|
||||
if local is not None:
|
||||
resp["local"] = local
|
||||
return jsonify(resp)
|
||||
|
||||
@app.route("/api/continuous", methods=["POST"])
|
||||
def continuous():
|
||||
data = request.get_json(silent=True) or {}
|
||||
on = data.get("on")
|
||||
if not isinstance(on, bool):
|
||||
return jsonify({"ok": False, "error": "on 必须是布尔值"}), 400
|
||||
if on and not injector.start():
|
||||
return jsonify({"ok": False,
|
||||
"error": "尚未注入过坐标,请先点「注入坐标」"}), 400
|
||||
if not on:
|
||||
injector.stop()
|
||||
return jsonify({"ok": True, "continuous": injector.running})
|
||||
|
||||
@app.route("/api/command", methods=["POST"])
|
||||
def command():
|
||||
data = request.get_json(silent=True) or {}
|
||||
cmd = data.get("cmd")
|
||||
if not isinstance(cmd, str) or cmd not in _COMMAND_WHITELIST:
|
||||
return jsonify({"ok": False, "error": f"未知命令: {cmd!r}"}), 400
|
||||
try:
|
||||
_send_udp(target, {"cmd": cmd})
|
||||
except OSError as exc:
|
||||
return jsonify({"ok": False, "error": f"UDP 发送失败: {exc}"}), 500
|
||||
return jsonify({"ok": True, "sent": {"cmd": cmd}})
|
||||
|
||||
@app.route("/api/link")
|
||||
def link_get():
|
||||
options = [{"label": "本地回环(仿真)", "host": "127.0.0.1"}]
|
||||
for ip in _local_ipv4_list():
|
||||
if not ip.startswith("127."):
|
||||
options.append({"label": f"本机网卡 {ip}", "host": ip})
|
||||
return jsonify({"host": target.host, "port": target.port,
|
||||
"options": options})
|
||||
|
||||
@app.route("/api/link", methods=["POST"])
|
||||
def link_set():
|
||||
data = request.get_json(silent=True) or {}
|
||||
host = str(data.get("host") or "").strip()
|
||||
port = data.get("port", target.port)
|
||||
if not host or len(host) > 64 or any(c.isspace() for c in host):
|
||||
return jsonify({"ok": False, "error": "host 不合法"}), 400
|
||||
try:
|
||||
port = int(port)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "error": "port 必须是整数"}), 400
|
||||
if not (1 <= port <= 65535):
|
||||
return jsonify({"ok": False, "error": "port 超出范围"}), 400
|
||||
target.host, target.port = host, port
|
||||
return jsonify({"ok": True, "host": target.host, "port": target.port})
|
||||
|
||||
@app.route("/api/state")
|
||||
def state():
|
||||
return jsonify(_latest_state(log_dir))
|
||||
j = _latest_state(log_dir)
|
||||
j["continuous"] = injector.running
|
||||
j["video"] = {"enabled": video is not None,
|
||||
"online": bool(video and video.online)}
|
||||
return jsonify(j)
|
||||
|
||||
@app.route("/video_feed")
|
||||
def video_feed():
|
||||
if video is None:
|
||||
return "未配置视频源(启动参数 --rtsp-url)", 503
|
||||
return Response(stream_with_context(video.mjpeg_stream()),
|
||||
mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||
|
||||
return app
|
||||
|
||||
@@ -124,13 +443,27 @@ def main() -> int:
|
||||
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 监听)")
|
||||
help="坐标/命令注入 UDP 端口(GroundLink 监听)")
|
||||
ap.add_argument("--udp-host", default="127.0.0.1",
|
||||
help="坐标/命令注入目标地址(默认本机;真机可填机载/伴侣机 IP,"
|
||||
"页面也可运行时切换)")
|
||||
ap.add_argument("--log-dir", default=_LOG_DIR, help="任务日志目录")
|
||||
ap.add_argument("--rtsp-url", default="",
|
||||
help="吊舱 RTSP 视频地址(如 rtsp://192.168.144.119:8554/video);"
|
||||
"缺省则视频面板关闭")
|
||||
ap.add_argument("--video-width", type=int, default=960,
|
||||
help="MJPEG 输出最大宽度(默认 960)")
|
||||
ap.add_argument("--video-fps", type=float, default=15.0,
|
||||
help="MJPEG 编码帧率上限(默认 15)")
|
||||
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 = create_app(args.udp_port, args.log_dir, udp_host=args.udp_host,
|
||||
rtsp_url=args.rtsp_url,
|
||||
video_width=args.video_width, video_fps=args.video_fps)
|
||||
video_note = f" | 视频 ← {args.rtsp_url}" if args.rtsp_url else " | 视频未配置"
|
||||
print(f"[地面站] http://{args.host}:{args.port} | UDP 注入 → "
|
||||
f"{args.udp_host}:{args.udp_port}"
|
||||
f" | 日志 → {args.log_dir}{video_note}")
|
||||
app.run(host=args.host, port=args.port, debug=False)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
button { margin-top:12px; padding:9px 16px; border:none; border-radius:6px;
|
||||
background:var(--green); color:var(--green-ink); font-size:14px; cursor:pointer; }
|
||||
button:active { opacity:.85; }
|
||||
button.sec { background:#5b6a5e; }
|
||||
button.danger { background:var(--red); color:#fff; }
|
||||
.btn-row { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.btn-row button { margin-top:0; flex:1 1 auto; }
|
||||
input[type=checkbox] { width:auto; }
|
||||
.kv { display:flex; justify-content:space-between; padding:5px 0;
|
||||
border-bottom:1px dashed var(--line); font-size:13px; }
|
||||
.kv:last-child { border-bottom:none; }
|
||||
@@ -33,27 +38,97 @@
|
||||
.badge.warn { background:var(--red); color:#fff; }
|
||||
canvas { background:#0e1410; border-radius:8px; display:block; }
|
||||
.hint { color:var(--mut); font-size:12px; margin-top:8px; }
|
||||
.nav { margin-top:8px; font-size:13px; }
|
||||
.nav a { color:var(--green); text-decoration:none; margin-right:14px; }
|
||||
.nav a.active { color:var(--ink); font-weight:600; text-decoration:underline; }
|
||||
select { width:100%; padding:8px; border:1px solid var(--line); border-radius:6px;
|
||||
font-size:14px; background:#fff; color:var(--ink); }
|
||||
.badge.mut { background:#c9c3b2; color:#4a514a; }
|
||||
.log-list { max-height:300px; overflow:auto; font-size:12.5px;
|
||||
font-family:Consolas,"Courier New",monospace; border:1px solid var(--line);
|
||||
border-radius:8px; padding:8px 10px; }
|
||||
.log-line { padding:2px 0; border-bottom:1px dashed var(--line); word-break:break-all; }
|
||||
.log-line:last-child { border-bottom:none; }
|
||||
.log-t { color:var(--mut); margin-right:6px; }
|
||||
.lv-state { color:var(--green); }
|
||||
.lv-info { color:#3a6ea5; }
|
||||
.lv-warn { color:#b06a2a; }
|
||||
.lv-err { color:var(--red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>地面站 · 预定流程</h1>
|
||||
<div class="sub">地面坐标注入 + 状态可视化(临时测试用)</div>
|
||||
<div class="sub">坐标注入 + 任务/吊舱控制 + 状态可视化(临时测试用)</div>
|
||||
<nav class="nav"><a class="active" href="/">主页面</a><a href="/px4">PX4 信息</a></nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="col">
|
||||
<h2>链路目标 <span class="badge" id="link-badge">-</span></h2>
|
||||
<label>坐标与命令经 UDP 发往此地址</label>
|
||||
<select id="link-sel"></select>
|
||||
<input id="link-custom" type="text" placeholder="自定义 IP,如 192.168.144.10" style="display:none;margin-top:8px">
|
||||
<button onclick="applyLink()">切换目标</button>
|
||||
<div class="hint" id="link-msg">默认「本地回环」= 本机跑仿真/runner;真机可切到机载或伴侣机 IP。</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>坐标注入</h2>
|
||||
<label>纬度 lat</label>
|
||||
<label>坐标形式</label>
|
||||
<select id="coord-mode" onchange="applyCoordMode()">
|
||||
<option value="geo">经纬度(绝对)</option>
|
||||
<option value="local">局部坐标(相对原点,北/东 米)</option>
|
||||
</select>
|
||||
<div id="origin-row" style="display:none">
|
||||
<label>局部原点</label>
|
||||
<select id="origin-mode">
|
||||
<option value="current">以当前坐标为原点(注入时刻无人机位置)</option>
|
||||
<option value="home">以起飞点(home)为原点(本次运行起始位置)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label id="lbl-a">纬度 lat</label>
|
||||
<input id="lat" type="text" value="47.398241" placeholder="如 47.398241">
|
||||
<label>经度 lon</label>
|
||||
<label id="lbl-b">经度 lon</label>
|
||||
<input id="lon" type="text" value="8.546363" placeholder="如 8.546363">
|
||||
<label>高度 alt (m)</label>
|
||||
<input id="alt" type="text" value="0">
|
||||
<button onclick="sendInject()">注入坐标</button>
|
||||
<button id="inject-btn" onclick="sendInject()">注入坐标</button>
|
||||
<div class="hint">局部坐标按选定原点换算(起飞前注入即为起飞点;「当前坐标」模式随注入时刻无人机位置变化)。</div>
|
||||
<label style="display:flex;align-items:center;gap:6px;margin-top:10px">
|
||||
<input type="checkbox" id="cont" onchange="toggleContinuous()">
|
||||
持续注入(服务端每 0.5s 自动重发)
|
||||
</label>
|
||||
<div class="hint">坐标须持续注入:断流超过 5s 判失效(任务将返航)。持续注入由服务端执行,
|
||||
切窗口/最小化不受影响;取消勾选或关闭地面站程序才停止。</div>
|
||||
<div class="hint" id="inject-msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>任务控制</h2>
|
||||
<div class="btn-row">
|
||||
<button onclick="sendCmd('rtl', '确认请求返航(RTL)?')">请求返航 RTL</button>
|
||||
<button class="danger" onclick="sendCmd('emergency', '确认紧急终止(立即返航)?')">紧急终止</button>
|
||||
</div>
|
||||
<div class="hint">RTL = 人工返航请求;紧急 = 最高优先级立即返航(均经 UDP 下发到 runner)。</div>
|
||||
<div class="hint" id="cmd-msg-t"></div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>吊舱控制</h2>
|
||||
<div class="btn-row">
|
||||
<button class="sec" onclick="sendCmd('pod_detect_on')">开识别</button>
|
||||
<button class="sec" onclick="sendCmd('pod_auto_lock')">自动锁定</button>
|
||||
<button class="sec" onclick="sendCmd('pod_stop_track')">停跟踪</button>
|
||||
<button class="sec" onclick="sendCmd('pod_detect_off')">关识别</button>
|
||||
<button class="sec" onclick="sendCmd('pod_center')">归中</button>
|
||||
</div>
|
||||
<div class="hint">对齐 pod_console 按键:开识别 d(0x07+0x0F) / 自动锁定 a(0x0F+0x10) /
|
||||
停跟踪 u(0x0E,仅解当前锁,0x0F 自动锁定仍常驻) / 关识别 s(0x0E+0x08,彻底退出) / 归中 c(0x2B)</div>
|
||||
<div class="hint">真机经 runner → 吊舱串口下发;SITL 无吊舱控制口,命令将被忽略。</div>
|
||||
<div class="hint" id="cmd-msg-p"></div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>任务状态 <span class="badge" id="state">-</span></h2>
|
||||
<div class="kv"><span class="k">位置</span><span class="v" id="pos">-</span></div>
|
||||
@@ -65,28 +140,108 @@
|
||||
<div class="hint" id="log-dir"></div>
|
||||
</div>
|
||||
|
||||
<div class="col" style="flex:1 1 400px">
|
||||
<h2>任务日志 <span class="badge mut" id="log-count">0</span></h2>
|
||||
<div class="log-list" id="log-list"><div class="log-line">加载中…</div></div>
|
||||
<div class="hint">阶段切换 / 地面站命令 / 飞控状态(STATUSTEXT 警告)实时滚动</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>模拟视野(脱靶量)</h2>
|
||||
<canvas id="view" width="300" height="200"></canvas>
|
||||
<div class="hint">十字线 = 视野中心,红点 = 目标(按吊舱脱靶量映射)</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>吊舱视频 <span class="badge" id="vid-badge">-</span></h2>
|
||||
<img id="vid" alt="吊舱视频流" style="display:none; width:320px; border-radius:8px; background:#0e1410">
|
||||
<div class="hint" id="vid-hint">检测中…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const FOV_H = 60, FOV_V = 35;
|
||||
let vidReloadAt = 0;
|
||||
|
||||
function $(id){ return document.getElementById(id); }
|
||||
|
||||
function applyCoordMode(){
|
||||
const local = $("coord-mode").value === "local";
|
||||
$("origin-row").style.display = local ? "block" : "none";
|
||||
$("lbl-a").textContent = local ? "北向偏移 north (m)" : "纬度 lat";
|
||||
$("lbl-b").textContent = local ? "东向偏移 east (m)" : "经度 lon";
|
||||
$("lat").value = local ? "30" : "47.398241";
|
||||
$("lon").value = local ? "15" : "8.546363";
|
||||
$("lat").placeholder = local ? "如 30(北为正)" : "如 47.398241";
|
||||
$("lon").placeholder = local ? "如 15(东为正)" : "如 8.546363";
|
||||
$("inject-btn").textContent = local ? "注入局部坐标" : "注入坐标";
|
||||
}
|
||||
|
||||
async function sendInject(){
|
||||
const lat = $("lat").value, lon = $("lon").value, alt = $("alt").value || "0";
|
||||
const local = $("coord-mode").value === "local";
|
||||
const a = $("lat").value, b = $("lon").value, alt = $("alt").value || "0";
|
||||
const msg = $("inject-msg");
|
||||
const body = local
|
||||
? {north_m: Number(a), east_m: Number(b), alt: Number(alt),
|
||||
origin: $("origin-mode").value}
|
||||
: {lat: Number(a), lon: Number(b), alt: Number(alt)};
|
||||
try {
|
||||
const r = await fetch("/api/inject", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({lat:Number(lat), lon:Number(lon), alt:Number(alt)})
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const j = await r.json();
|
||||
msg.textContent = j.ok ? `已注入 (${j.sent.lat}, ${j.sent.lon})` : `失败: ${j.error}`;
|
||||
if (j.ok) {
|
||||
if (local && j.local){
|
||||
const orgLabel = j.local.origin === "home" ? "起飞点" : "当前坐标";
|
||||
msg.textContent = `已注入(局部 北${j.local.north_m}m/东${j.local.east_m}m → `
|
||||
+ `${j.sent.lat.toFixed(6)}, ${j.sent.lon.toFixed(6)};`
|
||||
+ `原点(${orgLabel}) ${j.local.origin_lat.toFixed(6)}, ${j.local.origin_lon.toFixed(6)})`;
|
||||
} else {
|
||||
msg.textContent = `已注入 (${j.sent.lat}, ${j.sent.lon})`;
|
||||
}
|
||||
msg.style.color = "var(--green)";
|
||||
} else {
|
||||
msg.textContent = `失败: ${j.error}`;
|
||||
msg.style.color = "var(--red)";
|
||||
}
|
||||
} catch(e){ msg.textContent = "请求失败: "+e; msg.style.color = "var(--red)"; }
|
||||
}
|
||||
|
||||
async function toggleContinuous(){
|
||||
const on = $("cont").checked;
|
||||
const msg = $("inject-msg");
|
||||
try {
|
||||
const r = await fetch("/api/continuous", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({on})
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
$("cont").checked = !on; // 失败回滚勾选
|
||||
msg.textContent = `失败: ${j.error}`;
|
||||
msg.style.color = "var(--red)";
|
||||
return;
|
||||
}
|
||||
msg.textContent = j.continuous ? "持续注入中(服务端发送,每 0.5s)" : "已停止持续注入";
|
||||
msg.style.color = j.continuous ? "var(--green)" : "var(--mut)";
|
||||
} catch(e){
|
||||
$("cont").checked = !on;
|
||||
msg.textContent = "请求失败: "+e;
|
||||
msg.style.color = "var(--red)";
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCmd(cmd, ask){
|
||||
if (ask && !confirm(ask)) return;
|
||||
const msg = (cmd==="rtl" || cmd==="emergency") ? $("cmd-msg-t") : $("cmd-msg-p");
|
||||
try {
|
||||
const r = await fetch("/api/command", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({cmd})
|
||||
});
|
||||
const j = await r.json();
|
||||
msg.textContent = j.ok ? `已发送: ${j.sent.cmd}` : `失败: ${j.error}`;
|
||||
msg.style.color = j.ok ? "var(--green)" : "var(--red)";
|
||||
} catch(e){ msg.textContent = "请求失败: "+e; msg.style.color = "var(--red)"; }
|
||||
}
|
||||
@@ -126,10 +281,142 @@ async function poll(){
|
||||
$("mode").textContent = t.mode || "-";
|
||||
$("cmd").textContent = t.yaw_rate_dps ? `${fmt(t.yaw_rate_dps,1)}°/s / ${fmt(t.vx_body_mps,2)} m/s` : "-";
|
||||
$("log-dir").textContent = "日志: " + (j.log_dir || "-");
|
||||
// 持续注入开关与服务端同步(刷新/多标签时不失真)
|
||||
if (typeof j.continuous === "boolean" && $("cont").checked !== j.continuous) {
|
||||
$("cont").checked = j.continuous;
|
||||
const m = $("inject-msg");
|
||||
m.textContent = j.continuous ? "持续注入中(服务端发送,每 0.5s)" : "";
|
||||
m.style.color = j.continuous ? "var(--green)" : "var(--mut)";
|
||||
}
|
||||
// 吊舱视频面板:状态徽标 + 断流自动重挂(限频 5s,避免打断重连)
|
||||
const v = j.video || {};
|
||||
const img = $("vid"), badge = $("vid-badge");
|
||||
if (!v.enabled) {
|
||||
img.style.display = "none";
|
||||
badge.textContent = "未配置"; badge.className = "badge warn";
|
||||
$("vid-hint").textContent = "服务端未配置视频源(--rtsp-url)";
|
||||
} else {
|
||||
img.style.display = "block";
|
||||
badge.textContent = v.online ? "在线" : "断流";
|
||||
badge.className = "badge" + (v.online ? "" : " warn");
|
||||
$("vid-hint").textContent = v.online
|
||||
? "服务端 RTSP → MJPEG 实时画面"
|
||||
: "视频流中断,自动重连中…";
|
||||
const now = Date.now(), src = img.getAttribute("src");
|
||||
if (!v.online) {
|
||||
if (!src || now - vidReloadAt > 5000) { img.src = "/video_feed?t=" + now; vidReloadAt = now; }
|
||||
} else if (!src) {
|
||||
img.src = "/video_feed";
|
||||
}
|
||||
}
|
||||
// 任务日志面板(阶段切换 / 地面站命令 / 飞控状态)
|
||||
renderTaskLog(j.events || []);
|
||||
drawView(parseFloat(t.off_target_x_deg), parseFloat(t.off_target_y_deg));
|
||||
} catch(e){ /* 后端未起时静默 */ }
|
||||
}
|
||||
|
||||
// ============================================================ 链路目标
|
||||
let linkPopulated = false;
|
||||
|
||||
async function loadLink(){
|
||||
try {
|
||||
const r = await fetch("/api/link");
|
||||
const j = await r.json();
|
||||
$("link-badge").textContent = `${j.host}:${j.port}`;
|
||||
if (linkPopulated) return;
|
||||
const sel = $("link-sel");
|
||||
sel.innerHTML = "";
|
||||
for (const o of j.options){
|
||||
const opt = document.createElement("option");
|
||||
opt.value = o.host;
|
||||
opt.textContent = `${o.label}(${o.host})`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
const c = document.createElement("option");
|
||||
c.value = "__custom__"; c.textContent = "自定义…";
|
||||
sel.appendChild(c);
|
||||
sel.value = j.host;
|
||||
if (sel.value !== j.host){ // 当前目标不在列表 → 自定义项承载
|
||||
sel.value = "__custom__";
|
||||
$("link-custom").value = j.host;
|
||||
$("link-custom").style.display = "block";
|
||||
}
|
||||
sel.onchange = () => {
|
||||
$("link-custom").style.display = sel.value === "__custom__" ? "block" : "none";
|
||||
};
|
||||
linkPopulated = true;
|
||||
} catch(e){ /* 后端未起时静默 */ }
|
||||
}
|
||||
|
||||
async function applyLink(){
|
||||
const sel = $("link-sel");
|
||||
const host = sel.value === "__custom__" ? $("link-custom").value.trim() : sel.value;
|
||||
const msg = $("link-msg");
|
||||
try {
|
||||
const r = await fetch("/api/link", {
|
||||
method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({host})
|
||||
});
|
||||
const j = await r.json();
|
||||
msg.textContent = j.ok ? `已切换到 ${j.host}:${j.port}` : `失败: ${j.error}`;
|
||||
msg.style.color = j.ok ? "var(--green)" : "var(--red)";
|
||||
if (j.ok) $("link-badge").textContent = `${j.host}:${j.port}`;
|
||||
} catch(e){ msg.textContent = "请求失败: " + e; msg.style.color = "var(--red)"; }
|
||||
}
|
||||
|
||||
// ============================================================ 任务日志
|
||||
const STATE_CN = {
|
||||
wait: "待命", arm_takeoff: "起飞", goto: "坐标引导", track: "追踪",
|
||||
approach: "接近悬停", rtl: "返航"
|
||||
};
|
||||
let lastLogKey = "";
|
||||
|
||||
function fmtT(t){
|
||||
if (t === null || t === undefined) return "--:--.-";
|
||||
const m = Math.floor(t / 60), s = t % 60;
|
||||
return String(m).padStart(2, "0") + ":" + s.toFixed(1).padStart(4, "0");
|
||||
}
|
||||
|
||||
function evLine(e){
|
||||
if (e.type === "state"){
|
||||
const a = STATE_CN[e.from_state] || e.from_state || "?";
|
||||
const b = STATE_CN[e.to_state] || e.to_state || "?";
|
||||
return { cls: "lv-state", txt: `阶段切换: ${a} → ${b}(${e.reason || ""})` };
|
||||
}
|
||||
if (e.type === "command") return { cls: "lv-info", txt: `地面站命令: ${e.name}` };
|
||||
if (e.type === "fc_status"){
|
||||
const sev = String(e.severity || "").toUpperCase();
|
||||
const cls = sev.includes("ERR") ? "lv-err" : (sev.includes("WARN") ? "lv-warn" : "lv-info");
|
||||
return { cls, txt: `飞控 ${sev || "STATUS"}: ${e.text || ""}` };
|
||||
}
|
||||
if (e.type === "takeoff") return { cls: "lv-state", txt: `起飞至 ${e.altitude_m} m` };
|
||||
if (e.type === "takeoff_failed") return { cls: "lv-err", txt: `起飞失败: ${e.error || ""}` };
|
||||
if (e.type === "rtl") return { cls: "lv-state", txt: "开始返航" };
|
||||
const parts = [];
|
||||
for (const [k, v] of Object.entries(e)){
|
||||
if (["type", "ts_ms", "t_s"].includes(k)) continue;
|
||||
parts.push(`${k}=${v}`);
|
||||
}
|
||||
return { cls: "", txt: `${e.type || "事件"}: ${parts.join(", ")}` };
|
||||
}
|
||||
|
||||
function renderTaskLog(evs){
|
||||
const tail = evs.length ? evs[evs.length - 1] : null;
|
||||
const key = evs.length + "|" + (tail ? `${tail.ts_ms}|${tail.type}` : "");
|
||||
if (key === lastLogKey) return; // 无变化不重建,避免打断滚动
|
||||
lastLogKey = key;
|
||||
const box = $("log-list");
|
||||
box.innerHTML = evs.length
|
||||
? evs.map(e => {
|
||||
const l = evLine(e);
|
||||
return `<div class="log-line ${l.cls}"><span class="log-t">${fmtT(e.t_s)}</span>${l.txt}</div>`;
|
||||
}).join("")
|
||||
: '<div class="log-line">暂无事件(等待任务运行)</div>';
|
||||
box.scrollTop = box.scrollHeight;
|
||||
$("log-count").textContent = evs.length;
|
||||
}
|
||||
|
||||
loadLink();
|
||||
poll();
|
||||
setInterval(poll, 1000);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>地面站 · PX4 信息</title>
|
||||
<style>
|
||||
:root { --bg:#f7f4ec; --card:#fffdf7; --ink:#2f3b2f; --mut:#6b756b;
|
||||
--green:#2f5d46; --green-ink:#eaf3de; --line:#e2ddcf; --red:#b04a3a; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
|
||||
background:var(--bg); color:var(--ink); }
|
||||
header { padding:16px 24px; border-bottom:1px solid var(--line); background:var(--card); }
|
||||
header h1 { margin:0; font-size:18px; font-weight:600; }
|
||||
header .sub { color:var(--mut); font-size:12px; margin-top:2px; }
|
||||
.nav { margin-top:8px; font-size:13px; }
|
||||
.nav a { color:var(--green); text-decoration:none; margin-right:14px; }
|
||||
.nav a.active { color:var(--ink); font-weight:600; text-decoration:underline; }
|
||||
.wrap { padding:16px 24px; }
|
||||
.tabs { display:flex; gap:8px; margin-bottom:14px; }
|
||||
.tab { padding:8px 18px; border:1px solid var(--line); border-radius:8px 8px 0 0;
|
||||
background:var(--card); color:var(--mut); font-size:14px; cursor:pointer; }
|
||||
.tab.active { background:var(--green); color:var(--green-ink); border-color:var(--green);
|
||||
font-weight:600; }
|
||||
.panel { display:block; }
|
||||
.hidden { display:none !important; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:10px;
|
||||
padding:16px; margin-bottom:14px; }
|
||||
.card h2 { margin:0 0 12px; font-size:14px; font-weight:600; color:var(--green); }
|
||||
.badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:12px;
|
||||
background:var(--green); color:var(--green-ink); }
|
||||
.badge.warn { background:var(--red); color:#fff; }
|
||||
.badge.mut { background:#c9c3b2; color:#4a514a; }
|
||||
.hint { color:var(--mut); font-size:12px; margin-top:8px; }
|
||||
table.kv-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.kv-table td { padding:6px 4px; border-bottom:1px dashed var(--line);
|
||||
font-variant-numeric:tabular-nums; }
|
||||
.kv-table td:first-child { color:var(--mut); width:150px; }
|
||||
.kv-table tr:last-child td { border-bottom:none; }
|
||||
.toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
select, button.btn { padding:7px 12px; border:1px solid var(--line); border-radius:6px;
|
||||
background:#fff; font-size:13px; color:var(--ink); }
|
||||
button.btn { background:var(--green); color:var(--green-ink); border:none; cursor:pointer; }
|
||||
button.btn.sec { background:#5b6a5e; }
|
||||
button.btn:active { opacity:.85; }
|
||||
.chips { display:flex; gap:8px; flex-wrap:wrap; margin:10px 0 4px; }
|
||||
.chip { background:#efeadc; border:1px solid var(--line); border-radius:14px;
|
||||
padding:4px 12px; font-size:12px; color:var(--ink);
|
||||
font-variant-numeric:tabular-nums; }
|
||||
.chip b { color:var(--green); }
|
||||
.chart-grid { display:flex; flex-wrap:wrap; gap:12px; }
|
||||
.chart-card { flex:1 1 340px; min-width:300px; background:#fffdf7;
|
||||
border:1px solid var(--line); border-radius:8px; padding:6px; }
|
||||
.chart-card canvas { width:100%; height:200px; display:block; }
|
||||
canvas#timeline { width:100%; height:64px; display:block; background:#fffdf7;
|
||||
border:1px solid var(--line); border-radius:8px; }
|
||||
.ev-wrap { max-height:320px; overflow:auto; border:1px solid var(--line);
|
||||
border-radius:8px; }
|
||||
table.ev-table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||
.ev-table th { position:sticky; top:0; background:#efeadc; text-align:left;
|
||||
padding:7px 8px; border-bottom:1px solid var(--line); z-index:1; }
|
||||
.ev-table td { padding:6px 8px; border-bottom:1px dashed var(--line);
|
||||
font-variant-numeric:tabular-nums; }
|
||||
.ev-table tr:last-child td { border-bottom:none; }
|
||||
.tl-legend { display:flex; gap:10px; flex-wrap:wrap; font-size:12px; color:var(--mut);
|
||||
margin-top:6px; }
|
||||
.tl-legend span { display:inline-flex; align-items:center; gap:4px; }
|
||||
.dot { width:10px; height:10px; border-radius:3px; display:inline-block; }
|
||||
.loading { color:var(--mut); font-size:13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>地面站 · PX4 信息</h1>
|
||||
<div class="sub">PX4 输出实时视图 + 任务日志解读</div>
|
||||
<nav class="nav">
|
||||
<a href="/">主页面</a><a class="active" href="/px4">PX4 信息</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="tabs">
|
||||
<button class="tab active" id="tabbtn-live" onclick="switchTab('live')">实时状态</button>
|
||||
<button class="tab" id="tabbtn-logs" onclick="switchTab('logs')">日志解读</button>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 实时状态 ===================== -->
|
||||
<section id="tab-live" class="panel">
|
||||
<div class="card">
|
||||
<h2>PX4 遥测 <span class="badge" id="live-state">-</span>
|
||||
<span class="badge mut" id="live-inject">注入 -</span>
|
||||
<span class="badge mut" id="live-video">视频 -</span>
|
||||
</h2>
|
||||
<table class="kv-table"><tbody id="live-body">
|
||||
<tr><td colspan="2" class="loading">加载中…</td></tr>
|
||||
</tbody></table>
|
||||
<div class="hint" id="live-meta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 日志解读 ===================== -->
|
||||
<section id="tab-logs" class="panel hidden">
|
||||
<div class="card">
|
||||
<h2>运行段选择</h2>
|
||||
<div class="toolbar">
|
||||
<select id="run-sel" onchange="renderRun()"></select>
|
||||
<button class="btn sec" onclick="loadLogs(true)">刷新日志</button>
|
||||
<span class="hint" id="logs-note">日志文件为多次运行追加,已按 t_s 重置自动分段。</span>
|
||||
</div>
|
||||
<div class="chips" id="kpis"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>曲线</h2>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card"><canvas id="c-alt"></canvas></div>
|
||||
<div class="chart-card"><canvas id="c-dist"></canvas></div>
|
||||
<div class="chart-card"><canvas id="c-vel"></canvas></div>
|
||||
<div class="chart-card"><canvas id="c-yaw"></canvas></div>
|
||||
<div class="chart-card"><canvas id="c-off"></canvas></div>
|
||||
<div class="chart-card"><canvas id="c-yawrate"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>任务状态时间线</h2>
|
||||
<canvas id="timeline"></canvas>
|
||||
<div class="tl-legend" id="tl-legend"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>事件表 <span class="badge mut" id="ev-count">0</span></h2>
|
||||
<div class="ev-wrap">
|
||||
<table class="ev-table">
|
||||
<thead><tr><th>t_s</th><th>类型</th><th>详情</th></tr></thead>
|
||||
<tbody id="ev-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ============================================================ 通用
|
||||
function $(id){ return document.getElementById(id); }
|
||||
|
||||
const STATE_COLORS = {
|
||||
wait: "#8a8f8a", arm_takeoff: "#c9a227", goto: "#3a6ea5",
|
||||
track: "#2f7d4f", approach: "#7a4fa3", rtl: "#b04a3a"
|
||||
};
|
||||
const STATE_LABELS = {
|
||||
wait: "待命", arm_takeoff: "起飞", goto: "坐标引导", track: "追踪",
|
||||
approach: "接近悬停", rtl: "返航"
|
||||
};
|
||||
const SERIES_COLORS = ["#2f6f4f", "#b04a3a", "#3a6ea5", "#c9a227", "#7a4fa3", "#0f766e"];
|
||||
|
||||
// ============================================================ 分页
|
||||
let liveTimer = null;
|
||||
|
||||
function switchTab(name){
|
||||
for (const t of ["live", "logs"]){
|
||||
$("tabbtn-" + t).classList.toggle("active", t === name);
|
||||
$("tab-" + t).classList.toggle("hidden", t !== name);
|
||||
}
|
||||
if (name === "live"){ startLive(); }
|
||||
else { stopLive(); if (!logsLoaded) loadLogs(false); }
|
||||
}
|
||||
|
||||
// ============================================================ 实时状态
|
||||
const LIVE_FIELDS = [
|
||||
["state", "任务状态", v => v],
|
||||
["pos_valid", "位置有效", v => v === true ? "是" : (v === false ? "否" : "-")],
|
||||
["fix_type", "定位类型 fix_type", v => v],
|
||||
["num_satellites", "卫星数", v => v],
|
||||
["latitude_deg", "纬度", v => num(v, 6)],
|
||||
["longitude_deg", "经度", v => num(v, 6)],
|
||||
["relative_altitude_m", "相对高度 (m)", v => num(v, 2)],
|
||||
["yaw_deg", "航向 yaw (°)", v => num(v, 1)],
|
||||
["mode", "飞行模式", v => v || "-"],
|
||||
["target_injected", "目标已注入", v => v === true ? "是" : (v === false ? "否" : "-")],
|
||||
["distance_to_target_m", "距目标 (m)", v => v === "inf" ? "∞" : num(v, 1)],
|
||||
["target_locked", "吊舱锁定", v => v === true ? "锁定" : (v === false ? "未锁定" : "-")],
|
||||
["off_target_x_deg", "脱靶 X (°)", v => num(v, 2)],
|
||||
["off_target_y_deg", "脱靶 Y (°)", v => num(v, 2)],
|
||||
["yaw_rate_dps", "yaw_rate (°/s)", v => num(v, 1)],
|
||||
["vx_body_mps", "vx 前向 (m/s)", v => num(v, 2)],
|
||||
["vz_mps", "vz 垂速 (m/s)", v => num(v, 2)],
|
||||
];
|
||||
|
||||
function num(v, d){
|
||||
if (v === null || v === undefined || v === "" || v === "nan" || v === "inf") return v === "inf" ? "∞" : "-";
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? String(v) : n.toFixed(d);
|
||||
}
|
||||
|
||||
async function pollLive(){
|
||||
try {
|
||||
const r = await fetch("/api/state");
|
||||
const j = await r.json();
|
||||
const t = j.telemetry || {};
|
||||
const s = $("live-state");
|
||||
const st = j.state || "unknown";
|
||||
s.textContent = st + "(" + (STATE_LABELS[st] || "") + ")";
|
||||
s.className = "badge" + (st === "rtl" || st === "wait" ? " warn" : "");
|
||||
$("live-inject").textContent = "注入 " + (j.continuous ? "持续中" : "未持续");
|
||||
$("live-inject").className = "badge" + (j.continuous ? "" : " mut");
|
||||
const v = j.video || {};
|
||||
$("live-video").textContent = "视频 " + (!v.enabled ? "未配置" : (v.online ? "在线" : "断流"));
|
||||
$("live-video").className = "badge" + (!v.enabled || !v.online ? " mut" : "");
|
||||
|
||||
$("live-body").innerHTML = LIVE_FIELDS.map(([k, label, fmt]) => {
|
||||
const raw = t[k];
|
||||
// /api/state 的遥测值是 CSV 原始字符串:"True"/"False" 归一化为布尔
|
||||
const val0 = raw === "True" ? true : (raw === "False" ? false : raw);
|
||||
const val = fmt(val0);
|
||||
return `<tr><td>${label}</td><td>${val === undefined || val === null ? "-" : val}</td></tr>`;
|
||||
}).join("");
|
||||
$("live-meta").textContent =
|
||||
`日志源: ${j.log_dir || "-"} · 刷新 ${new Date().toLocaleTimeString()}`
|
||||
+ `(数据为任务日志最新一行;runner 未运行时显示上次任务数据)`;
|
||||
} catch(e){
|
||||
$("live-meta").textContent = "后端未连接: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
function startLive(){
|
||||
pollLive();
|
||||
if (liveTimer === null) liveTimer = setInterval(pollLive, 1000);
|
||||
}
|
||||
|
||||
function stopLive(){
|
||||
if (liveTimer !== null){ clearInterval(liveTimer); liveTimer = null; }
|
||||
}
|
||||
|
||||
// ============================================================ 日志加载与分段
|
||||
let logsLoaded = false;
|
||||
let telSegs = []; // 遥测分段(每段是对象数组)
|
||||
let evSegs = []; // 事件分段
|
||||
let curTel = null, curEv = null;
|
||||
|
||||
function parseVal(s){
|
||||
if (s === "" || s === null || s === undefined) return null;
|
||||
if (s === "True") return true;
|
||||
if (s === "False") return false;
|
||||
if (s === "inf") return "inf";
|
||||
if (s === "-inf" || s === "nan") return null;
|
||||
const n = Number(s);
|
||||
return Number.isNaN(n) ? s : n;
|
||||
}
|
||||
|
||||
function segmentize(arr, getT){
|
||||
const segs = []; let cur = [];
|
||||
for (const item of arr){
|
||||
const t = getT(item);
|
||||
if (cur.length && t < getT(cur[cur.length-1])){ segs.push(cur); cur = []; }
|
||||
cur.push(item);
|
||||
}
|
||||
if (cur.length) segs.push(cur);
|
||||
return segs;
|
||||
}
|
||||
|
||||
// 事件分段:三重判据——t_s 回退(新会话)/「任务启动」标记(from==to)/
|
||||
// 上一段以 rtl 收尾(每次运行以返航事件结束;旧日志没有启动标记时靠这条切分)
|
||||
function segmentEvents(evs){
|
||||
const segs = []; let cur = [];
|
||||
for (const e of evs){
|
||||
const prev = cur[cur.length - 1];
|
||||
const boundary = !!prev && (
|
||||
(e.t_s || 0) < (prev.t_s || 0)
|
||||
|| (e.type === "state" && e.from_state === e.to_state)
|
||||
|| prev.type === "rtl");
|
||||
if (boundary){ segs.push(cur); cur = []; }
|
||||
cur.push(e);
|
||||
}
|
||||
if (cur.length) segs.push(cur);
|
||||
return segs;
|
||||
}
|
||||
|
||||
async function loadLogs(force){
|
||||
const note = $("logs-note");
|
||||
if (logsLoaded && !force) return;
|
||||
note.textContent = "加载中…";
|
||||
try {
|
||||
const [rt, re] = await Promise.all([
|
||||
fetch("/api/logs/telemetry").then(r => r.json()),
|
||||
fetch("/api/logs/events").then(r => r.json()),
|
||||
]);
|
||||
const fields = rt.fields || [];
|
||||
const rows = (rt.rows || []).map(r => {
|
||||
const o = {};
|
||||
fields.forEach((f, i) => { o[f] = parseVal(r[i]); });
|
||||
return o;
|
||||
});
|
||||
telSegs = segmentize(rows, o => o.t_s || 0);
|
||||
evSegs = segmentEvents(re.events || []);
|
||||
logsLoaded = true;
|
||||
|
||||
const sel = $("run-sel");
|
||||
sel.innerHTML = "";
|
||||
for (let k = 1; k <= telSegs.length; k++){ // 最近在前(默认选中)
|
||||
const seg = segFromEnd(telSegs, k);
|
||||
const dur = seg.length ? seg[seg.length-1].t_s || 0 : 0;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = k; // k = 从最近数第 k 段
|
||||
opt.textContent = (k === 1 ? "最近运行" : `倒数第 ${k} 次`)
|
||||
+ ` · ${dur.toFixed(1)}s · ${seg.length} 行`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (!telSegs.length){
|
||||
note.textContent = "未找到遥测日志(logs/task_telemetry.csv)。";
|
||||
return;
|
||||
}
|
||||
note.textContent = `共 ${telSegs.length} 段运行(文件追加 ${rt.total} 行);`
|
||||
+ "事件按运行次序从最近配对,较旧段可能无事件。";
|
||||
renderRun();
|
||||
} catch(e){
|
||||
note.textContent = "日志加载失败: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
function segFromEnd(segs, k){ return segs[segs.length - k] || null; }
|
||||
|
||||
function renderRun(){
|
||||
const k = Number($("run-sel").value || 1);
|
||||
curTel = segFromEnd(telSegs, k);
|
||||
curEv = segFromEnd(evSegs, k);
|
||||
if (!curTel) return;
|
||||
renderKpis();
|
||||
renderCharts();
|
||||
renderTimeline();
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
// ============================================================ KPI
|
||||
function renderKpis(){
|
||||
const seg = curTel;
|
||||
const dur = seg.length ? seg[seg.length-1].t_s || 0 : 0;
|
||||
const alts = seg.map(r => r.relative_altitude_m).filter(v => typeof v === "number" && isFinite(v));
|
||||
const dists = seg.map(r => r.distance_to_target_m)
|
||||
.filter(v => typeof v === "number" && isFinite(v) && v > 0);
|
||||
const maxAlt = alts.length ? Math.max(...alts) : null;
|
||||
const minDist = dists.length ? Math.min(...dists) : null;
|
||||
|
||||
const states = [];
|
||||
for (const r of seg){ const s = r.state; if (s && states[states.length-1] !== s) states.push(s); }
|
||||
const seq = states.map(s => STATE_LABELS[s] || s).join(" → ");
|
||||
|
||||
$("kpis").innerHTML = [
|
||||
`时长 <b>${dur.toFixed(1)}s</b>`,
|
||||
`最大高度 <b>${maxAlt === null ? "-" : maxAlt.toFixed(1) + "m"}</b>`,
|
||||
`最近距目标 <b>${minDist === null ? "-" : minDist.toFixed(1) + "m"}</b>`,
|
||||
`遥测 <b>${seg.length}</b> 行`,
|
||||
`事件 <b>${curEv ? curEv.length : 0}</b> 条`,
|
||||
`状态链 <b>${seq || "-"}</b>`,
|
||||
].map(c => `<span class="chip">${c}</span>`).join("");
|
||||
}
|
||||
|
||||
// ============================================================ 曲线图(纯 canvas)
|
||||
function niceTicks(lo, hi, n){
|
||||
const span = hi - lo;
|
||||
if (!(span > 0)) return [lo];
|
||||
const step0 = span / n;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(step0)));
|
||||
const norm = step0 / mag;
|
||||
let step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
||||
step *= mag;
|
||||
const out = [];
|
||||
for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
function fmtTick(v){
|
||||
const a = Math.abs(v);
|
||||
if (a >= 10) return String(Math.round(v * 10) / 10);
|
||||
if (a >= 1) return String(Math.round(v * 100) / 100);
|
||||
return String(Math.round(v * 1000) / 1000);
|
||||
}
|
||||
|
||||
function drawChart(cv, spec){
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = cv.clientWidth || 340, H = cv.clientHeight || 200;
|
||||
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
|
||||
const ctx = cv.getContext("2d");
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const L = 50, R = 12, T = 24, B = 24;
|
||||
const pw = W - L - R, ph = H - T - B;
|
||||
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.fillStyle = "#2f3b2f";
|
||||
ctx.fillText(spec.title || "", 8, 15);
|
||||
|
||||
// 图例(右上,从右往左排)
|
||||
let lx = W - R;
|
||||
ctx.textAlign = "right";
|
||||
for (let i = spec.series.length - 1; i >= 0; i--){
|
||||
const s = spec.series[i];
|
||||
const w = ctx.measureText(s.name).width;
|
||||
ctx.fillStyle = s.color; ctx.fillRect(lx - w - 16, 7, 8, 8);
|
||||
ctx.fillStyle = "#6b756b"; ctx.fillText(s.name, lx, 15);
|
||||
lx -= w + 26;
|
||||
}
|
||||
ctx.textAlign = "left";
|
||||
|
||||
let xs = [], ys = [];
|
||||
for (const s of spec.series) for (const p of s.points){
|
||||
if (isFinite(p[0]) && isFinite(p[1])){ xs.push(p[0]); ys.push(p[1]); }
|
||||
}
|
||||
if (!xs.length){
|
||||
ctx.fillStyle = "#6b756b";
|
||||
ctx.fillText("无数据", L, T + ph / 2);
|
||||
return;
|
||||
}
|
||||
let x0 = Math.min(...xs), x1 = Math.max(...xs);
|
||||
if (x1 <= x0) x1 = x0 + 1;
|
||||
let y0 = Math.min(...ys), y1 = Math.max(...ys);
|
||||
if (y1 - y0 < 1e-9){ const m = Math.abs(y1) || 1; y0 -= m * 0.1; y1 += m * 0.1; }
|
||||
else { const pad = (y1 - y0) * 0.08; y0 -= pad; y1 += pad; }
|
||||
|
||||
const xTo = x => L + (x - x0) / (x1 - x0) * pw;
|
||||
const yTo = y => T + (1 - (y - y0) / (y1 - y0)) * ph;
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = "#e2ddcf";
|
||||
ctx.fillStyle = "#6b756b";
|
||||
ctx.font = "11px sans-serif";
|
||||
for (const yv of niceTicks(y0, y1, 5)){
|
||||
const yy = yTo(yv);
|
||||
ctx.beginPath(); ctx.moveTo(L, yy); ctx.lineTo(L + pw, yy); ctx.stroke();
|
||||
ctx.fillText(fmtTick(yv), 6, yy + 3.5);
|
||||
}
|
||||
for (const xv of niceTicks(x0, x1, 4)){
|
||||
const xx = xTo(xv);
|
||||
ctx.beginPath(); ctx.moveTo(xx, T); ctx.lineTo(xx, T + ph); ctx.stroke();
|
||||
const lbl = fmtTick(xv) + "s";
|
||||
ctx.fillText(lbl, Math.min(Math.max(xx - 8, 2), W - R - ctx.measureText(lbl).width), H - 8);
|
||||
}
|
||||
ctx.strokeStyle = "#b9b4a3";
|
||||
ctx.strokeRect(L, T, pw, ph);
|
||||
|
||||
for (const s of spec.series){
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
for (const p of s.points){
|
||||
if (!isFinite(p[0]) || !isFinite(p[1])){ started = false; continue; }
|
||||
const xx = xTo(p[0]), yy = yTo(p[1]);
|
||||
if (!started){ ctx.moveTo(xx, yy); started = true; }
|
||||
else ctx.lineTo(xx, yy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function seriesFrom(seg, key, label, colorIdx){
|
||||
return { name: label, color: SERIES_COLORS[colorIdx % SERIES_COLORS.length],
|
||||
points: seg.map(r => [r.t_s, r[key]]) };
|
||||
}
|
||||
|
||||
function renderCharts(){
|
||||
const seg = curTel;
|
||||
drawChart($("c-alt"), { title: "相对高度 (m)", series: [seriesFrom(seg, "relative_altitude_m", "alt", 0)] });
|
||||
drawChart($("c-dist"), { title: "距目标 (m)", series: [seriesFrom(seg, "distance_to_target_m", "dist", 1)] });
|
||||
drawChart($("c-vel"), { title: "机体系速度 (m/s)", series: [
|
||||
seriesFrom(seg, "vx_body_mps", "vx", 0), seriesFrom(seg, "vz_mps", "vz", 1)] });
|
||||
drawChart($("c-yaw"), { title: "航向 yaw (°)", series: [seriesFrom(seg, "yaw_deg", "yaw", 2)] });
|
||||
drawChart($("c-off"), { title: "脱靶量 (°)", series: [
|
||||
seriesFrom(seg, "off_target_x_deg", "off_x", 3), seriesFrom(seg, "off_target_y_deg", "off_y", 4)] });
|
||||
drawChart($("c-yawrate"), { title: "yaw_rate (°/s)", series: [seriesFrom(seg, "yaw_rate_dps", "yaw_rate", 5)] });
|
||||
}
|
||||
|
||||
// ============================================================ 状态时间线
|
||||
function transitionsOf(evSeg){
|
||||
const out = [];
|
||||
for (const e of (evSeg || [])){
|
||||
if (e.type === "state" && e.to_state){
|
||||
out.push({ t: e.t_s || 0, from: e.from_state, to: e.to_state, reason: e.reason || "" });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTimeline(){
|
||||
const cv = $("timeline");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = cv.clientWidth || 800, H = cv.clientHeight || 64;
|
||||
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
|
||||
const ctx = cv.getContext("2d");
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const seg = curTel;
|
||||
const tEnd = seg.length ? (seg[seg.length-1].t_s || 0) : 0;
|
||||
const trs = transitionsOf(curEv);
|
||||
const blocks = [];
|
||||
let curFrom = 0;
|
||||
let curState = trs.length ? (trs[0].from || "wait") : (seg[0] ? seg[0].state : "wait");
|
||||
for (const tr of trs){
|
||||
blocks.push({ from: curFrom, to: tr.t, state: curState });
|
||||
curFrom = tr.t;
|
||||
curState = tr.to;
|
||||
}
|
||||
blocks.push({ from: curFrom, to: Math.max(tEnd, curFrom + 0.1), state: curState });
|
||||
|
||||
const L = 8, R = 8, T = 6, ph = H - 2 * T;
|
||||
const pw = W - L - R;
|
||||
ctx.font = "11px sans-serif";
|
||||
for (const b of blocks){
|
||||
const x0 = L + (b.from / Math.max(tEnd, 0.1)) * pw;
|
||||
const x1 = L + (b.to / Math.max(tEnd, 0.1)) * pw;
|
||||
const w = Math.max(x1 - x0, 1);
|
||||
ctx.fillStyle = STATE_COLORS[b.state] || "#555";
|
||||
ctx.fillRect(x0, T, w, ph);
|
||||
const label = STATE_LABELS[b.state] || b.state;
|
||||
if (w > ctx.measureText(label).width + 10){
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillText(label, x0 + (w - ctx.measureText(label).width) / 2, T + ph / 2 + 4);
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = "#6b756b";
|
||||
ctx.fillText(`0s`, L, H - 1);
|
||||
const endLbl = `${tEnd.toFixed(1)}s`;
|
||||
ctx.fillText(endLbl, W - R - ctx.measureText(endLbl).width, H - 1);
|
||||
|
||||
// 图例
|
||||
const used = [...new Set(blocks.map(b => b.state))];
|
||||
$("tl-legend").innerHTML = used.map(s =>
|
||||
`<span><span class="dot" style="background:${STATE_COLORS[s] || "#555"}"></span>${STATE_LABELS[s] || s}</span>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
// ============================================================ 事件表
|
||||
function evSummary(e){
|
||||
if (e.type === "state") return `${e.from_state} → ${e.to_state}(${e.reason || ""})`;
|
||||
if (e.type === "command") return `地面站命令: ${e.name}`;
|
||||
if (e.type === "fc_status") return `飞控 ${e.severity || ""}: ${e.text || ""}`;
|
||||
if (e.type === "takeoff") return `起飞至 ${e.altitude_m} m`;
|
||||
if (e.type === "takeoff_failed") return `起飞失败: ${e.error || ""}`;
|
||||
if (e.type === "rtl") return "开始返航";
|
||||
const parts = [];
|
||||
for (const [k, v] of Object.entries(e)){
|
||||
if (["type", "ts_ms", "t_s"].includes(k)) continue;
|
||||
parts.push(`${k}=${v}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function renderEvents(){
|
||||
const evs = curEv || [];
|
||||
$("ev-count").textContent = String(evs.length);
|
||||
const rows = evs.slice(-300).map(e =>
|
||||
`<tr><td>${(e.t_s ?? 0).toFixed(2)}</td><td>${e.type || "-"}</td><td>${evSummary(e)}</td></tr>`
|
||||
);
|
||||
$("ev-body").innerHTML = rows.length ? rows.join("")
|
||||
: `<tr><td colspan="3" class="loading">本段无事件</td></tr>`;
|
||||
}
|
||||
|
||||
// ============================================================ 启动
|
||||
startLive();
|
||||
window.addEventListener("resize", () => { if (logsLoaded && !$("tab-logs").classList.contains("hidden")) renderCharts(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,152 @@
|
||||
"""video_source.py —— 吊舱 RTSP 视频源(服务端拉流 → MJPEG 推流)。
|
||||
|
||||
浏览器不能直连 RTSP,由本模块在服务端用 OpenCV 拉流、编码 JPEG 帧缓存,
|
||||
页面经 `/video_feed`(multipart/x-mixed-replace)显示实时画面。
|
||||
|
||||
设计要点:
|
||||
* cv2 延迟导入:模块 import 无副作用,无 OpenCV 环境也能 import;
|
||||
capture_factory 可注入假采集器 → 单测不依赖真实摄像头/网络;
|
||||
* 断线自动重连(读帧失败 → 释放 → 退避重试);
|
||||
* 抽帧节流 + 降分辨率:1080p60 全量转 JPEG 带宽/CPU 吃不消(默认 960 宽、≤15fps)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IDLE_RESEND_S = 2.0 # 无新帧时重发当前帧作保活(浏览器挂住连接)
|
||||
|
||||
|
||||
class RtspVideoSource:
|
||||
"""后台线程拉 RTSP,维护最新 JPEG 帧供 MJPEG 消费。"""
|
||||
|
||||
def __init__(self, url: str, width: int = 960, fps: float = 15.0,
|
||||
quality: int = 75, capture_factory=None) -> None:
|
||||
self._url = url
|
||||
self._width = int(width)
|
||||
self._min_interval = 1.0 / max(1.0, float(fps))
|
||||
self._quality = int(quality)
|
||||
self._capture_factory = capture_factory
|
||||
self._lock = threading.Lock()
|
||||
self._frame = None # 最新 JPEG bytes
|
||||
self._frame_seq = 0
|
||||
self._frame_at = 0.0 # 最新帧时间(monotonic)
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
# --- 生命周期 -----------------------------------------------------------
|
||||
def start(self) -> None:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="rtsp-video",
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
thread, self._thread = self._thread, None
|
||||
if thread is not None:
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
"""最近 2s 内有新帧 → 在线(用于页面状态显示)。"""
|
||||
with self._lock:
|
||||
return (self._frame is not None
|
||||
and (time.monotonic() - self._frame_at) < 2.0)
|
||||
|
||||
def latest(self) -> tuple[int, bytes | None]:
|
||||
"""返回 (seq, jpeg);尚无帧时 (0, None)。"""
|
||||
with self._lock:
|
||||
return self._frame_seq, self._frame
|
||||
|
||||
def mjpeg_stream(self):
|
||||
"""MJPEG 生成器(multipart 分块);客户端断开时随 GeneratorExit 终止。"""
|
||||
last_seq = -1
|
||||
last_send = 0.0
|
||||
while not self._stop.is_set():
|
||||
seq, jpeg = self.latest()
|
||||
now = time.monotonic()
|
||||
if jpeg is not None and (seq != last_seq
|
||||
or now - last_send > _IDLE_RESEND_S):
|
||||
last_seq = seq
|
||||
last_send = now
|
||||
yield (b"--frame\r\nContent-Type: image/jpeg\r\n"
|
||||
+ f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii")
|
||||
+ jpeg + b"\r\n")
|
||||
time.sleep(1.0 / 30.0)
|
||||
|
||||
# --- 拉流线程 -----------------------------------------------------------
|
||||
def _open(self):
|
||||
if self._capture_factory is not None:
|
||||
return self._capture_factory()
|
||||
import cv2 # 延迟导入:仅真正拉流时依赖 OpenCV
|
||||
return cv2.VideoCapture(self._url, cv2.CAP_FFMPEG)
|
||||
|
||||
def _run(self) -> None:
|
||||
import os
|
||||
# stimeout:RTSP socket 超时(µs)。不设的话断网时单次 open 会阻塞 ~30s,
|
||||
# 影响重连节奏(实测 2026-10-14)。
|
||||
os.environ.setdefault("OPENCV_FFMPEG_CAPTURE_OPTIONS",
|
||||
"rtsp_transport;tcp|stimeout;5000000")
|
||||
import cv2
|
||||
|
||||
cap = None
|
||||
last_encode = 0.0
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
if cap is None or not cap.isOpened():
|
||||
if cap is not None:
|
||||
try:
|
||||
cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
cap = None
|
||||
cap = self._open()
|
||||
if not cap.isOpened():
|
||||
logger.warning("[Video] RTSP 打开失败,1s 后重试: %s",
|
||||
self._url)
|
||||
self._stop.wait(1.0)
|
||||
continue
|
||||
logger.info("[Video] RTSP 已连接: %s", self._url)
|
||||
last_encode = 0.0
|
||||
|
||||
ok, frame = cap.read()
|
||||
if not ok or frame is None:
|
||||
logger.warning("[Video] 读帧失败,重连")
|
||||
try:
|
||||
cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
cap = None
|
||||
self._stop.wait(0.5)
|
||||
continue
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_encode < self._min_interval:
|
||||
continue # 抽帧节流:60fps 源 → ≤fps 编码
|
||||
last_encode = now
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
if w > self._width:
|
||||
scale = self._width / w
|
||||
frame = cv2.resize(
|
||||
frame, (self._width, max(1, int(h * scale))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(
|
||||
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self._quality])
|
||||
if not ok:
|
||||
continue
|
||||
with self._lock:
|
||||
self._frame = buf.tobytes()
|
||||
self._frame_seq += 1
|
||||
self._frame_at = time.monotonic()
|
||||
finally:
|
||||
if cap is not None:
|
||||
try:
|
||||
cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user