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:
+2
-2
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user