Files
code-test-drone/ground_station/templates/index.html
T
keita df91b1110e 精简仓库结构并增强任务链路:归档双目可选后端、新增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。
2026-10-21 14:48:41 +08:00

425 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>地面站 · 预定流程</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; }
.wrap { display:flex; gap:16px; padding:16px 24px; flex-wrap:wrap; align-items:flex-start; }
.col { background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:16px; min-width:280px; }
.col h2 { margin:0 0 12px; font-size:14px; font-weight:600; color:var(--green); }
label { display:block; font-size:12px; color:var(--mut); margin:8px 0 4px; }
input { width:100%; padding:8px; border:1px solid var(--line); border-radius:6px;
font-size:14px; background:#fff; color:var(--ink); }
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; }
.kv .k { color:var(--mut); }
.kv .v { font-variant-numeric:tabular-nums; }
.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; }
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>
<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>坐标形式</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 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 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>
<div class="kv"><span class="k">距目标</span><span class="v" id="dist">-</span></div>
<div class="kv"><span class="k">锁定</span><span class="v" id="locked">-</span></div>
<div class="kv"><span class="k">脱靶量 (x/y)</span><span class="v" id="off">-</span></div>
<div class="kv"><span class="k">控制</span><span class="v" id="mode">-</span></div>
<div class="kv"><span class="k">yaw_rate / vx</span><span class="v" id="cmd">-</span></div>
<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 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(body)
});
const j = await r.json();
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)"; }
}
function fmt(v, d){ return (v===null||v===undefined||v==="") ? "-" : Number(v).toFixed(d); }
function drawView(offX, offY){
const cv = $("view"), ctx = cv.getContext("2d");
const w = cv.width, h = cv.height, cx = w/2, cy = h/2;
ctx.clearRect(0,0,w,h);
ctx.strokeStyle = "#3d5343"; ctx.lineWidth = 1;
ctx.strokeRect(0,0,w,h);
ctx.strokeStyle = "#5f7a68"; ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.moveTo(cx,0); ctx.lineTo(cx,h);
ctx.moveTo(0,cy); ctx.lineTo(w,cy); ctx.stroke();
ctx.setLineDash([]);
if(offX===null||offX===undefined){ return; }
const x = cx + (offX / FOV_H) * w;
const y = cy - (offY / FOV_V) * h; // 上正 → 屏幕减
ctx.fillStyle = "#e0574a";
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI*2); ctx.fill();
}
async function poll(){
try {
const r = await fetch("/api/state");
const j = await r.json();
const t = j.telemetry || {};
const state = j.state || "unknown";
const s = $("state");
s.textContent = state;
s.className = "badge" + (state==="rtl"||state==="wait" ? " warn" : "");
$("pos").textContent = t.latitude_deg ? `${fmt(t.latitude_deg,6)}, ${fmt(t.longitude_deg,6)}` : "-";
$("dist").textContent = t.distance_to_target_m ? fmt(t.distance_to_target_m,1)+" m" : "-";
$("locked").textContent = t.target_locked==="True" ? "锁定" : "未锁定";
$("off").textContent = t.off_target_x_deg ? `${fmt(t.off_target_x_deg,2)}° / ${fmt(t.off_target_y_deg,2)}°` : "-";
$("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>
</body>
</html>