精简仓库结构并增强任务链路:归档双目可选后端、新增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:
keita
2026-10-21 14:48:41 +08:00
parent 53fdd3b13f
commit df91b1110e
159 changed files with 6121 additions and 1617 deletions
+564
View File
@@ -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>