feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本

变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
This commit is contained in:
cbb
2026-09-20 22:58:00 +08:00
parent e91d53e0fc
commit 383535bd1d
23 changed files with 2060 additions and 283 deletions
+2 -2
View File
@@ -28,8 +28,8 @@
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<link rel="stylesheet" href="../css/components.css?v=quiet20260919a">
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
<style>
/* 本页专属少量补充(令牌/组件全走 base/layout/components) */
.pm-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:14px; }
+163 -30
View File
@@ -720,11 +720,23 @@ function ls(key, val) { // localStorage 读写包装(隐私模式会
try { if (val === undefined) return window.localStorage.getItem(key); window.localStorage.setItem(key, val); }
catch (_) { return null; }
}
/* 面板开关持久化:刷新后恢复。冻结默认不恢复(冻住空面板没意义),其余全存。 */
/* 面板开关持久化:localStorage 快路径 + 磁盘 data/spi_test_ui_state.json。
* 保存:实时观测 / 冻结 / 原始窗口 / hex·帧重建 / 逐拍环 / 采样率 / PDO 档位 / SPI 测参。
* PDO 档位同时写共享键 dcu_ecat_period_ms,电机页与 EtherCAT 后端启动时同源。 */
const UI_OPT_KEY = "spi_test_ui_opts";
function saveUiOpts() {
const LAB_UI_API = "/api/lab/ui-state";
const LAB_UI_PATH_LABEL = "data/spi_test_ui_state.json";
const LS_ECAT_PERIOD = "dcu_ecat_period_ms";
let labOptsHadSave = false;
let _labDiskTimer = null, _labDiskPayload = null;
function collectUiOpts() {
const gv = (id) => { const n = document.getElementById(id); return n && n.value !== undefined ? n.value : ""; };
ls(UI_OPT_KEY, JSON.stringify({
return {
savedAt: Date.now(),
v: 2,
live: !!(E.wantLive || E.live),
frozen: !!E.frozen,
rxraw: !!E.rxraw,
heavyHex: !!E.heavyHex,
heavyFrames: !!E.heavyFrames,
@@ -736,26 +748,20 @@ function saveUiOpts() {
frames: gv("frames"),
payload: gv("payload"),
spiTimeout: gv("spiTimeout"),
}));
};
}
function applyToggleBtn(id, on, label) {
const n = document.getElementById(id);
if (!n) return;
n.classList.toggle("on", !!on);
n.setAttribute("aria-pressed", on ? "true" : "false");
const em = n.querySelector("em");
if (em) em.textContent = label;
else n.textContent = label + (on ? ":开" : ":关");
}
function restoreUiOpts() {
let o = null;
try { o = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) { o = null; }
if (!o) return;
/** 把快照应用到 E + DOM(localStorage 恢复 / 磁盘 hydrate 共用)。 */
function applyUiOpts(o) {
if (!o || typeof o !== "object") return;
labOptsHadSave = true;
E.rxraw = !!o.rxraw;
E.heavyHex = !!o.heavyHex;
E.heavyFrames = !!o.heavyFrames;
E.ring = !!o.ring;
E.frozen = false; /* 刷新后解冻,避免冻在「等待」占位 */
/* 冻结也恢复:用户明确要求按钮状态保存。空面板冻住无妨,解冻一键即可。 */
E.frozen = !!o.frozen;
E.wantLive = o.live !== undefined ? !!o.live : E.wantLive;
E.chartOpt = E.chartOpt || { max: false, skip: false };
E.chartOpt.max = !!o.chartMax;
E.chartOpt.skip = !!o.chartSkip;
@@ -765,10 +771,22 @@ function restoreUiOpts() {
applyToggleBtn("btnRing", E.ring, "逐拍环");
applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max");
applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍");
applyToggleBtn("btnLive", !!(E.live || E.wantLive), "实时观测");
setFreezeUi();
const hz = document.getElementById("hz");
if (hz && o.hz !== undefined && o.hz !== "") hz.value = o.hz;
const pick = document.getElementById("periodPick");
if (pick && o.periodPick !== undefined) pick.value = o.periodPick;
if (pick && o.periodPick !== undefined && o.periodPick !== null) pick.value = o.periodPick;
/* PDO 档位与电机页/后端共享:没写 periodPick 时用共享键兜底 */
if (pick && (pick.value === "" || pick.value == null)) {
try {
const shared = window.localStorage.getItem(LS_ECAT_PERIOD);
if (shared) pick.value = shared;
} catch (_) {}
}
if (pick && pick.value) {
try { window.localStorage.setItem(LS_ECAT_PERIOD, String(pick.value)); } catch (_) {}
}
const frames = document.getElementById("frames");
if (frames && o.frames) frames.value = o.frames;
const payload = document.getElementById("payload");
@@ -781,6 +799,92 @@ function restoreUiOpts() {
}
}
function writePeriodShared(val) {
if (!val) return;
try { window.localStorage.setItem(LS_ECAT_PERIOD, String(val)); } catch (_) {}
}
function scheduleLabDiskSave(snapshot) {
_labDiskPayload = snapshot || collectUiOpts();
if (_labDiskTimer) clearTimeout(_labDiskTimer);
_labDiskTimer = setTimeout(flushLabDiskSave, 400);
}
function flushLabDiskSave() {
if (_labDiskTimer) { clearTimeout(_labDiskTimer); _labDiskTimer = null; }
const o = _labDiskPayload || collectUiOpts();
_labDiskPayload = null;
const body = JSON.stringify(o);
try {
fetch(LAB_UI_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {
try {
const blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon) navigator.sendBeacon(LAB_UI_API, blob);
} catch (_) {}
});
} catch (_) {
try {
if (navigator.sendBeacon) navigator.sendBeacon(LAB_UI_API, new Blob([body], { type: "application/json" }));
} catch (_) {}
}
}
function saveUiOpts() {
const o = collectUiOpts();
ls(UI_OPT_KEY, JSON.stringify(o));
if (o.periodPick) writePeriodShared(o.periodPick);
scheduleLabDiskSave(o);
}
function restoreUiOpts() {
let o = null;
try { o = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) { o = null; }
if (!o) {
/* localStorage 空(WebView 清存储)→ 先用共享 PDO 档位兜底 */
const p = ls(LS_ECAT_PERIOD);
if (p) o = { periodPick: p, hz: "10", live: true };
}
if (!o) return;
applyUiOpts(o);
}
/** 磁盘优先 hydrate:比 savedAt,新者覆盖 localStorage。 */
async function hydrateLabUiFromDisk() {
let disk = null;
try {
const r = await fetch(LAB_UI_API, { cache: "no-store" });
if (r.ok) {
const j = await r.json();
disk = (j && j.ok && j.state) ? j.state : null;
}
} catch (_) { return; }
if (!disk || typeof disk !== "object") return;
let local = null;
try { local = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) {}
const diskAt = Number(disk.savedAt) || 0;
const localAt = local ? (Number(local.savedAt) || 0) : 0;
if (local && localAt > diskAt) {
applyUiOpts(local);
return;
}
applyUiOpts(disk);
try { ls(UI_OPT_KEY, JSON.stringify(disk)); } catch (_) {}
}
function applyToggleBtn(id, on, label) {
const n = document.getElementById(id);
if (!n) return;
n.classList.toggle("on", !!on);
n.setAttribute("aria-pressed", on ? "true" : "false");
const em = n.querySelector("em");
if (em) em.textContent = label;
else n.textContent = label + (on ? ":开" : ":关");
}
/* ═══════════════════ 面板切换 ═══════════════════ */
const PANES = { spi: $("#pane-spi"), ecat: $("#pane-ecat") };
function showPane(name) {
@@ -789,13 +893,16 @@ function showPane(name) {
ls("speedtest_pane", name);
if (name === "ecat") {
layoutChart();
/* 首次进来自动开;之后只在"被切走时本来是开着"的场合恢复 */
if (!E.everStarted || E.wantLive) startEcatIfIdle();
/* 有保存 → 只按 wantLive 恢复;无保存 → 保持原「首次自动开观测」习惯 */
if (E.wantLive || (!labOptsHadSave && !E.everStarted)) startEcatIfIdle();
} else if (E.live) {
/* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 N Hz×M 条代理请求
* 会把自己的测量抬高 —— 先排除自己造的假象。 */
const was = E.live; stopEcat(); E.wantLive = was;
/* 切到 SPI:暂停轮询但记住 wantLive,回来自动恢复;不要走 stopEcat
* (会把 wantLive 清成 false 并落盘,刷新后就再也起不来了)。 */
E.live = false; E.pausedByHidden = false;
ticker.stop();
applyToggleBtn("btnLive", false, "实时观测");
setPill("#ecatState", "", "已暂停(在 SPI 页)");
E.wantLive = true;
}
}
for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
@@ -1243,12 +1350,14 @@ function startEcatIfIdle() {
eapi("native/health", {}, 6000).then((h) => { E.health = h; renderBus(E.lastState); }).catch(() => { E.health = null; });
ticker.start(1000 / pollHz());
requestTick();
saveUiOpts();
}
function stopEcat() {
E.live = false; E.wantLive = false; E.pausedByHidden = false;
ticker.stop();
applyToggleBtn("btnLive", false, "实时观测");
setPill("#ecatState", "", "已暂停");
saveUiOpts();
}
/* 后台标签页:Worker 定时器不受节流,live 着=持续打代理。暂停但记住 wantLive。 */
function pauseEcatHidden() {
@@ -1322,6 +1431,7 @@ $("#btnFreeze").onclick = () => {
else renderRaw(E.lastState || null, null);
}
setFreezeUi();
saveUiOpts();
};
$("#btnHeavyFrames").onclick = () => {
E.heavyFrames = !E.heavyFrames;
@@ -1345,10 +1455,14 @@ $("#hz").onchange = () => {
if (E.live) ticker.update(1000 / pollHz());
saveUiOpts();
};
/* PDO 档位 / SPI 测参变更即落盘 */
/* PDO 档位 / SPI 测参变更即落盘;档位同时写共享键供电机页/后端启动使用 */
["periodPick", "frames", "payload", "spiTimeout"].forEach((id) => {
const n = document.getElementById(id);
if (n) n.onchange = saveUiOpts;
if (!n) return;
n.onchange = () => {
if (id === "periodPick") writePeriodShared(n.value);
saveUiOpts();
};
});
/* ═══════════════════ 渲染:指标卡 ═══════════════════ */
@@ -2161,6 +2275,10 @@ $("#btnPeriod").onclick = async () => {
if (isNum(eff) && Math.abs(eff - v) < 1e-6) break;
}
const ok = isNum(eff) && Math.abs(eff - v) < 1e-6;
if (ok) {
writePeriodShared(eff);
saveUiOpts();
}
runBusy(ok ? "ok" : "bad", ok ? ("已生效 " + eff + "ms(" + f0(1000 / eff) + "Hz 档)")
: ("下发 " + v + "ms 但回读 " + eff + "ms —— 有他端在设周期(localStorage 劫持)"));
} catch (e) { runBusy("bad", "失败:" + e.message); }
@@ -2178,14 +2296,29 @@ $("#btnRxbClear").onclick = () => {
};
/* ═══════════════════ 初始化 ═══════════════════ */
showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
/* 先恢复配置(采样率/PDO 档位/开关/冻结/是否观测),再进面板 ——
* 否则 showPane 会用默认 10Hz 起观测,再被恢复值改掉却没重排节拍。 */
restoreUiOpts();
layoutChart();
restoreUiOpts(); /* 恢复上次的开关/采样率/曲线选项 */
showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
renderRaw(null, null); /* 面板③先画「未开启」占位,避免两块空白看起来像坏了 */
setFreezeUi();
window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); saveUiOpts(); });
/* 磁盘更新则覆盖(WebView 清 localStorage 后仍能找回上次档位/开关) */
hydrateLabUiFromDisk().then(() => {
if (PANES.ecat && !PANES.ecat.hidden && E.wantLive && !E.live) startEcatIfIdle();
setFreezeUi();
}).catch(() => {});
window.addEventListener("beforeunload", () => {
E.live = false; ticker.stop();
saveUiOpts();
flushLabDiskSave();
});
window.addEventListener("pagehide", () => { saveUiOpts(); flushLabDiskSave(); });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") { saveUiOpts(); flushLabDiskSave(); }
});
/* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */
window.__speedtest = { E, eapi, RateWindow, decodeStatus, renderRaw, renderRawDecode, saveUiOpts, restoreUiOpts };
window.__speedtest = { E, eapi, RateWindow, decodeStatus, renderRaw, renderRawDecode, saveUiOpts, restoreUiOpts, collectUiOpts, applyUiOpts };
})();
</script>
</body>