+
-
- layout:dense=1.6.21 密排整轮 / win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,
- 解析器必须按 layout 选,否则拿变长游标解定长窗口会整片错位。
-
-
xrx 长帧完整载荷(kv 里那行是截断的,这里不截)
-
—
-
-
逐拍环未开启。开启后实测每次约 160KB(后端 RING=2048 拍),
- 只在需要看抖动分布时开,别常年挂着 —— 仪表本身也是负载。
+
逐拍环:关
-
-
接收区原始字节 —— dense:8B 整轮头 + count×56B 槽;win:44B/槽 覆盖窗口
-
—
+
+
+
原始字节 —— 整包 PDO(有 pdoin)/ 段2 rxraw;16B/行 多列
+
—
+
+
@@ -546,10 +657,15 @@ function kv(boxId, rows) {
box.appendChild(d);
}
}
-/** 状态胶囊:SPI / ECAT / 热改周期共用同一套 led + 文案结构 */
+/** 状态胶囊:与 header-bar .pill 同构(.indicator);ok/warn/bad 映射 online/live/offline */
function setPill(sel, cls, text) {
- const n = $(sel); n.className = "pill" + (cls ? " " + cls : "");
- n.innerHTML = '
'; n.appendChild(el("span", null, text));
+ const n = $(sel);
+ if (!n) return;
+ const map = { ok: "online", warn: "live", bad: "offline", good: "online", live: "live" };
+ const k = map[cls] || cls || "";
+ n.className = "pill" + (k ? " " + k : "");
+ n.innerHTML = '
';
+ n.appendChild(el("span", null, text));
}
const isNum = (v) => typeof v === "number" && isFinite(v);
const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—");
@@ -604,6 +720,66 @@ function ls(key, val) { // localStorage 读写包装(隐私模式会
try { if (val === undefined) return window.localStorage.getItem(key); window.localStorage.setItem(key, val); }
catch (_) { return null; }
}
+/* 面板开关持久化:刷新后恢复。冻结默认不恢复(冻住空面板没意义),其余全存。 */
+const UI_OPT_KEY = "spi_test_ui_opts";
+function saveUiOpts() {
+ const gv = (id) => { const n = document.getElementById(id); return n && n.value !== undefined ? n.value : ""; };
+ ls(UI_OPT_KEY, JSON.stringify({
+ rxraw: !!E.rxraw,
+ heavyHex: !!E.heavyHex,
+ heavyFrames: !!E.heavyFrames,
+ ring: !!E.ring,
+ chartMax: !!(E.chartOpt && E.chartOpt.max),
+ chartSkip: !!(E.chartOpt && E.chartOpt.skip),
+ hz: gv("hz"),
+ periodPick: gv("periodPick"),
+ 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.rxraw = !!o.rxraw;
+ E.heavyHex = !!o.heavyHex;
+ E.heavyFrames = !!o.heavyFrames;
+ E.ring = !!o.ring;
+ E.frozen = false; /* 刷新后解冻,避免冻在「等待」占位 */
+ E.chartOpt = E.chartOpt || { max: false, skip: false };
+ E.chartOpt.max = !!o.chartMax;
+ E.chartOpt.skip = !!o.chartSkip;
+ applyToggleBtn("btnRxraw", E.rxraw, "原始窗口");
+ applyToggleBtn("btnHeavyHex", E.heavyHex, "hex每拍重建");
+ applyToggleBtn("btnHeavyFrames", E.heavyFrames, "每拍重建");
+ applyToggleBtn("btnRing", E.ring, "逐拍环");
+ applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max");
+ applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍");
+ 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;
+ const frames = document.getElementById("frames");
+ if (frames && o.frames) frames.value = o.frames;
+ const payload = document.getElementById("payload");
+ if (payload && o.payload !== undefined && o.payload !== "") payload.value = o.payload;
+ const stmo = document.getElementById("spiTimeout");
+ if (stmo && o.spiTimeout) stmo.value = o.spiTimeout;
+ if (E.rxraw) {
+ const b = document.getElementById("btnRxraw");
+ if (b) b.title = "每拍额外读 /api/motor/rxraw + /api/motor/pdoin(段2 与整包 600B)。对 EtherCAT 有负载,看完建议关掉";
+ }
+}
/* ═══════════════════ 面板切换 ═══════════════════ */
const PANES = { spi: $("#pane-spi"), ecat: $("#pane-ecat") };
@@ -910,16 +1086,17 @@ const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs"
/* ═══════════════════ EtherCAT:实时观测状态 ═══════════════════ */
const E = {
- live: false, ring: false, rxraw: false,
+ live: false, ring: false, rxraw: false, frozen: false,
+ frozenSnap: null, /* 冻结瞬间的 {st,raw,pdoin},解冻前不再重画 */
heavyFrames: false, heavyHex: false,
wantLive: false, everStarted: false, pausedByHidden: false,
win: new RateWindow(3000),
pollDts: [], pollHz: null, intervalMs: null, lastRates: null,
- lastState: null, status: null, health: null, load: null, raw: null, dbg: null,
+ lastState: null, status: null, health: null, load: null, raw: null, dbg: null, pdoin: null,
tornPrev: null, reentryPrev: null, tornCap: false, reentryCap: false,
overPrev: null, rejPrev: null, overAccum: 0, rejAccum: 0,
lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(),
- lastSeq: {}, seqAt: {}, densLast: null,
+ lastSeq: {}, seqAt: {}, densLast: null, lastRenderedRawCycle: null,
chart: { t: [], hz: [], us: [], usMax: [], lateHz: [] },
chartOpt: { max: false, skip: false },
errors: 0, errText: "",
@@ -1005,10 +1182,14 @@ async function handleTick(st) {
load = done[0]; dbg = done[1];
rtt = Math.max(rtt, (load && load.__rtt) || 0, (dbg && dbg.__rtt) || 0);
} catch (e) { E.errors++; E.errText = (e && e.message) || String(e); }
- /* rxraw 响应体最大(约 600B hex + parsed),默认关。开启时才拉,并计入 rtt。 */
+ /* rxraw 只含段2(dense~176B / win~232B);pdoin 是整包 PDO 输入镜像(~600B)。 */
if (E.rxraw) {
try { raw = await eapi("motor/rxraw", {}, 4000); if (raw) rtt = Math.max(rtt, raw.__rtt || 0); }
catch (_) { raw = null; }
+ try {
+ const pin = await eapi("motor/pdoin", {}, 4000);
+ if (pin && pin.ok) { E.pdoin = pin; rtt = Math.max(rtt, pin.__rtt || 0); }
+ } catch (_) { /* 旧 ecat_native 无此路由时静默 */ }
}
const t = performance.now(); // 与计数同一瞬间,不能事后再取
const b = st.bridge || {}, head = st.head || {};
@@ -1024,18 +1205,25 @@ async function handleTick(st) {
rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
});
E.lastState = st; E.load = load; E.dbg = dbg; E.raw = raw;
+ if (!E.rxraw) E.pdoin = null;
const rates = E.win.calc();
E.lastRates = rates;
renderMetrics(st, load, dbg, rates, rtt);
renderBus(st);
renderBridge(st);
renderMotors(st, rates);
- /* 帧表 / hex 网格:各自模块的「每拍重建」开关(默认关)。关着时 DOM 冻结。 */
- if (E.rxraw && raw) {
+ /* 帧表 / hex 网格:各自模块的「每拍重建」开关(默认关)。关着时 DOM 冻结。
+ * 原始窗口开着时:cycle 变了就刷一次完整解码(本拍内容真正变了才值得重画)。 */
+ if (E.frozen) {
+ /* 冻结:只更新内存里的最新快照,不重画面板③ */
+ E.frozenSnap = { st, raw, pdoin: E.pdoin };
+ } else if (E.rxraw && raw) {
if (E.heavyFrames) renderFrames(st, raw);
- else countSeenFrames(raw); /* 计数照走,只是不重画表 */
- if (E.heavyHex) renderRaw(st, raw);
- } else if (E.heavyHex) {
+ else countSeenFrames(raw);
+ /* 完整解码必须跟上 raw:hex 网格也可每拍重建(dense 仅 ~176B,开销可忽略) */
+ renderRaw(st, raw);
+ E.lastRenderedRawCycle = raw.cycle;
+ } else {
renderRaw(st, null);
}
if (dbg) renderBeats(dbg);
@@ -1048,7 +1236,7 @@ function startEcatIfIdle() {
E.live = true; E.everStarted = true; E.wantLive = true; E.pausedByHidden = false;
tickPrev = null; E.pollDts = [];
E.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null;
- $("#btnLive").textContent = "暂停观测";
+ applyToggleBtn("btnLive", true, "实时观测");
setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : ""));
eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); })
.catch((e) => { E.status = null; E.errText = e.message; });
@@ -1059,7 +1247,7 @@ function startEcatIfIdle() {
function stopEcat() {
E.live = false; E.wantLive = false; E.pausedByHidden = false;
ticker.stop();
- $("#btnLive").textContent = "开始实时观测";
+ applyToggleBtn("btnLive", false, "实时观测");
setPill("#ecatState", "", "已暂停");
}
/* 后台标签页:Worker 定时器不受节流,live 着=持续打代理。暂停但记住 wantLive。 */
@@ -1068,7 +1256,7 @@ function pauseEcatHidden() {
E.pausedByHidden = true;
E.live = false;
ticker.stop();
- $("#btnLive").textContent = "后台已暂停 · 点此恢复";
+ applyToggleBtn("btnLive", false, "后台已暂停");
setPill("#ecatState", "", "后台已暂停");
}
function resumeEcatShown() {
@@ -1083,39 +1271,85 @@ document.addEventListener("visibilitychange", () => {
$("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); };
$("#btnRing").onclick = () => {
E.ring = !E.ring;
- $("#btnRing").textContent = "逐拍环:" + (E.ring ? "开" : "关");
+ applyToggleBtn("btnRing", E.ring, "逐拍环");
if (!E.ring) { tblBeatsBody.textContent = ""; $("#beatStat").textContent = "逐拍环未开启。"; }
+ saveUiOpts();
};
$("#btnRxraw").onclick = () => {
E.rxraw = !E.rxraw;
- $("#btnRxraw").textContent = "原始窗口:" + (E.rxraw ? "开" : "关");
+ applyToggleBtn("btnRxraw", E.rxraw, "原始窗口");
$("#btnRxraw").title = E.rxraw
- ? "每拍额外读 /api/motor/rxraw(约 600B hex)。对 EtherCAT 有负载,看完建议关掉"
- : "每拍多打 /api/motor/rxraw(约 600B hex)。默认关";
+ ? "每拍额外读 /api/motor/rxraw + /api/motor/pdoin(段2 与整包 600B)。对 EtherCAT 有负载,看完建议关掉"
+ : "每拍多打 /api/motor/rxraw + pdoin。默认关";
if (E.live) setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : ""));
- if (!E.rxraw) E.raw = null;
- if (E.rxraw && E.lastState && E.raw) {
- if (E.heavyFrames) renderFrames(E.lastState, E.raw);
- if (E.heavyHex) renderRaw(E.lastState, E.raw);
+ if (!E.rxraw) {
+ E.raw = null;
+ E.lastRenderedRawCycle = null;
+ if (!E.frozen) renderRaw(E.lastState, null);
+ } else if (!E.frozen) {
+ if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw);
+ renderRaw(E.lastState || null, E.raw || null);
+ if (E.raw) E.lastRenderedRawCycle = E.raw.cycle;
}
+ saveUiOpts();
+};
+function setFreezeUi() {
+ const b = document.getElementById("btnFreeze");
+ const capL = document.getElementById("rawDecodeCap");
+ const capR = document.getElementById("rawDecodeCapR");
+ if (b) {
+ applyToggleBtn("btnFreeze", E.frozen, "冻结");
+ b.title = E.frozen
+ ? "已冻结:面板③定格在冻结瞬间那一帧,轮询不会改写。再点恢复实时。"
+ : "冻结后本面板定格在当前帧,不再随轮询刷新;再点恢复实时";
+ }
+ if (capL) capL.textContent = E.frozen
+ ? "【已冻结】原始字节"
+ : "原始字节 —— 整包 PDO(有 pdoin)/ 段2 rxraw;16B/行 多列";
+ if (capR) capR.textContent = E.frozen
+ ? "【已冻结】解析摘要"
+ : "解析摘要(不重复贴 raw)";
+}
+$("#btnFreeze").onclick = () => {
+ E.frozen = !E.frozen;
+ if (E.frozen) {
+ /* 冻结:锁住当前 DOM;同时记住快照(解冻重画用最新内存值,不回放冻结帧) */
+ E.frozenSnap = { st: E.lastState, raw: E.raw, pdoin: E.pdoin };
+ } else {
+ E.frozenSnap = null;
+ /* 解冻:立刻用最新内存数据重画,不等下一拍 */
+ if (E.rxraw) renderRaw(E.lastState || null, E.raw || null);
+ else renderRaw(E.lastState || null, null);
+ }
+ setFreezeUi();
};
$("#btnHeavyFrames").onclick = () => {
E.heavyFrames = !E.heavyFrames;
- $("#btnHeavyFrames").textContent = "每拍重建:" + (E.heavyFrames ? "开" : "关");
+ applyToggleBtn("btnHeavyFrames", E.heavyFrames, "每拍重建");
if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw);
+ saveUiOpts();
};
$("#btnHeavyHex").onclick = () => {
E.heavyHex = !E.heavyHex;
- $("#btnHeavyHex").textContent = "hex每拍重建:" + (E.heavyHex ? "开" : "关");
- if (E.heavyHex && E.lastState) {
- if (E.rxraw && E.raw) renderRaw(E.lastState, E.raw);
- else renderRaw(E.lastState, null);
+ applyToggleBtn("btnHeavyHex", E.heavyHex, "hex每拍重建");
+ if (E.heavyHex && E.lastState && !E.frozen) {
+ if (E.rxraw && E.raw) {
+ renderRaw(E.lastState, E.raw);
+ E.lastRenderedRawCycle = E.raw.cycle;
+ } else renderRaw(E.lastState, null);
}
+ saveUiOpts();
};
$("#hz").onchange = () => {
E.win.reset();
if (E.live) ticker.update(1000 / pollHz());
+ saveUiOpts();
};
+/* PDO 档位 / SPI 测参变更即落盘 */
+["periodPick", "frames", "payload", "spiTimeout"].forEach((id) => {
+ const n = document.getElementById(id);
+ if (n) n.onchange = saveUiOpts;
+});
/* ═══════════════════ 渲染:指标卡 ═══════════════════ */
function renderMetrics(st, load, dbg, r, rtt) {
@@ -1393,7 +1627,16 @@ function renderFrames(st, raw) {
const src = best.get((f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase());
const tr = el("tr");
const td = (txt, cls) => tr.appendChild(el("td", cls, txt));
- const tdHex = (v) => { const c = el("td", "hex", hexShort(v, 32)); if (String(v).length > 32) { c.title = v; c.classList.add("trunc"); } tr.appendChild(c); };
+ const tdHex = (v) => {
+ const s = String(v || "");
+ const c = el("td", "hex", s ? s.replace(/(.{16})/g, "$1 ").trim() : "—");
+ c.style.whiteSpace = "normal";
+ c.style.wordBreak = "break-all";
+ c.style.maxWidth = "22em";
+ c.style.fontSize = "11px";
+ if (s) c.title = s;
+ tr.appendChild(c);
+ };
td(clock(new Date()), "num");
td(src ? src.ch : (isNum(f.ch) ? f.ch : "—"), "num");
td("0x" + (f.id >>> 0).toString(16).toUpperCase().padStart(8, "0"), "num");
@@ -1554,22 +1797,172 @@ function renderDenseRound(raw, body) {
td(s.dlc, "num");
td("0x" + (0x180 + s.node).toString(16).toUpperCase(), "num");
td("N" + s.node + " · STATUS");
- td(hexShort(s.data, 16), "hex");
+ /* 48B STATUS 全量:112 hex 字符过长会撑破表,折行显示 + title 原文 */
+ const fullHex = s.full || s.data || "";
+ const cFull = el("td", "hex", fullHex ? fullHex.replace(/(.{16})/g, "$1 ").trim() : "—");
+ cFull.style.whiteSpace = "normal";
+ cFull.style.wordBreak = "break-all";
+ cFull.style.fontFamily = "var(--mono)";
+ cFull.style.fontSize = "11px";
+ cFull.style.maxWidth = "28em";
+ cFull.style.lineHeight = "1.45";
+ if (fullHex) cFull.title = fullHex;
+ tr.appendChild(cFull);
td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"),
isNew || prev === undefined ? "good" : "dim");
- tr.title = "48B STATUS: " + (s.full || s.data);
body.appendChild(tr);
}
}
+/** 把 hex 串按 offset 切成「字段名 + 全量字节」行(dense 完整解码用) */
+function fmtHexBytes(hex, off, len) {
+ const h = String(hex || "").toUpperCase();
+ const s = h.substr(off * 2, len * 2);
+ return s ? s.replace(/(.{2})/g, "$1 ").trim() : "—";
+}
+/** xrx 长帧完整文本:ch/dlc/id + 全 payload(不截断) */
+function xrxFullText(x) {
+ if (!x || !x.data) return "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里";
+ return "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() +
+ " flags=" + (x.flags ?? "—") +
+ "\n" + String(x.data).replace(/(.{8})/g, "$1 ").trim();
+}
+/** 600B PDO 输入分区图(1.6.21 dense)—— 只读路由拼不齐整包镜像,这里按段展示能拿到的 */
+const PDO_SEG = [
+ { name: "段1 桥状态", off: 0, len: 32, src: "state.bridge" },
+ { name: "段2 头+密排槽", off: 32, len: 512, src: "/api/motor/rxraw" },
+ { name: "段3 长帧", off: 544, len: 40, src: "state.xrx" },
+ { name: "段5 诊断", off: 584, len: 12, src: "app/load" },
+ { name: "保留", off: 596, len: 4, src: "—" },
+];
+function pushDec(box, text, cls) {
+ const n = document.createElement("i");
+ n.className = cls || "";
+ n.textContent = text;
+ box.appendChild(n);
+}
+/** 左=raw 多列 hex(主);右=紧凑摘要(不重复贴 STATUS raw) */
+function renderRawDecode(st, raw) {
+ const L = document.getElementById("rawDecodeL");
+ const R = document.getElementById("rawDecodeR");
+ if (!L) return;
+ L.textContent = "";
+ if (R) R.textContent = "";
+ const x = (st && st.xrx) || null;
+ const load = E.load || {};
+
+ if (!raw || !raw.raw) {
+ pushDec(L, E.rxraw
+ ? "等待本拍 /api/motor/rxraw…(保持「开始实时观测」)"
+ : "原始窗口未开启 —— 点「原始窗口:开」", "hdr");
+ if (R) pushDec(R, "—", "hdr");
+ return;
+ }
+
+ const hex = String(raw.raw).toUpperCase();
+ const b = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16);
+ const dv = new DataView(b.buffer);
+ const pin = E.pdoin && E.pdoin.ok && E.pdoin.raw ? E.pdoin : null;
+
+ /* ── 左:原始字节(多列铺满)── */
+ const dumpHex = (label, h, baseOff) => {
+ if (!h) return;
+ pushDec(L, label, "blk");
+ for (let i = 0; i < h.length; i += 32) {
+ const offB = baseOff + i / 2;
+ pushDec(L, String(offB).padStart(4, " ") + " " + fmtHexBytes(h, i / 2, 16),
+ (offB === 0 || offB === 32 || offB === 544 || offB === 584) ? "slot" : null);
+ }
+ };
+ if (pin) {
+ dumpHex("PDO " + pin.len + "B · cycle " + (pin.cycle || "—"), String(pin.raw).toUpperCase(), 0);
+ } else {
+ dumpHex("rxraw 段2 " + raw.len + "B · " + raw.layout, hex, raw.layout === "dense" ? 32 : 0);
+ }
+ if (x && x.data) {
+ pushDec(L, "", "dim");
+ dumpHex("xrx dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase(),
+ String(x.data).toUpperCase(), pin ? 552 : 0);
+ }
+
+ /* ── 右:摘要(不重复 raw)── */
+ if (!R) return;
+ if (raw.layout === "dense") {
+ const count = b[0], active = b[1], boot = b[2], magic = b[3];
+ const gseq = hex.length >= 16 ? dv.getUint32(4, true) : null;
+ pushDec(R, "头 count=" + count + " active=" + active + " boot=" + boot +
+ " magic=0x" + magic.toString(16).toUpperCase().padStart(2, "0") +
+ " gseq=" + (isNum(gseq) ? gseq.toLocaleString("en-US") : "—"), "blk");
+ const n = Math.max(0, Math.min(count | 0, Math.floor((hex.length / 2 - DENSE_HDR) / DENSE_SLOT) || 0));
+ for (let s = 0; s < n; s++) {
+ const o = DENSE_HDR + s * DENSE_SLOT;
+ const node = b[o], ch = b[o + 1], online = b[o + 2], dlc = b[o + 3];
+ const seq = dv.getUint32(o + 4, true);
+ pushDec(R, "槽" + s + " n" + node + " ch" + ch + (online ? " on" : " off") +
+ " dlc" + dlc + " seq" + seq.toLocaleString("en-US"), "slot");
+ const d = decodeStatus(fmtHexBytes(hex, o + 8, Math.min(dlc || 0, DENSE_STATUS)).replace(/ /g, ""));
+ if (d) {
+ const stBits = [d.motStatus ? "err" : null, d.magStatus ? "mag" : null,
+ d.thermal ? "hot" : null, d.eepromStatus ? "ee" + d.eepromStatus : null]
+ .filter(Boolean).join(" ") || "ok";
+ pushDec(R, " " + d.mcuTemp.toFixed(1) + "/" + d.motTemp.toFixed(1) + "°C" +
+ " " + d.powerVoltage.toFixed(1) + "V" +
+ " ang" + d.rtAngle.toFixed(1) +
+ " " + d.cpuUsage + "%" +
+ " " + stBits, "dec");
+ }
+ }
+ } else if (raw.layout === "win") {
+ pushDec(R, "win slots=" + raw.count + " wr=" + raw.lost + " ovf=" + raw.pending, "blk");
+ const n = Math.min(WIN_SLOT_COUNT, Math.floor((hex.length / 2) / WIN_SLOT_SIZE));
+ for (let s = 0; s < n; s++) {
+ const o = s * WIN_SLOT_SIZE;
+ const seq = dv.getUint32(o + 8, true);
+ if (!seq) { pushDec(R, "槽" + s + " 空", "dim"); continue; }
+ pushDec(R, "槽" + s + " ch" + b[o] + " dlc" + b[o + 1] +
+ " id0x" + dv.getUint32(o + 4, true).toString(16).toUpperCase() +
+ " seq" + seq + ((seq & 1) ? " 写入中" : ""), "slot");
+ }
+ } else {
+ pushDec(R, raw.layout + " len=" + raw.len + " count=" + raw.count, "blk");
+ }
+ if (x && x.data) {
+ pushDec(R, "", "dim");
+ pushDec(R, "xrx ch" + x.ch + " dlc" + x.dlc +
+ " id0x" + (x.id >>> 0).toString(16).toUpperCase(), "slot");
+ }
+ const frames = (raw && raw.parsed) || [];
+ pushDec(R, "", "dim");
+ pushDec(R, "parsed 新帧 " + frames.length, "blk");
+ frames.forEach((f, i) => {
+ const sub = f.id & 0xFFFF;
+ pushDec(R, "#" + i + " 0x" + (f.id >>> 0).toString(16).toUpperCase() +
+ " dlc" + f.dlc + " " + (SUB_NAME[sub] || ""), "slot");
+ });
+ pushDec(R, "", "dim");
+ pushDec(R, "torn=" + (isNum(load.rx_torn) ? load.rx_torn : "—") +
+ " relto=" + (isNum(load.reltimeout) ? load.reltimeout : "—") +
+ " ovr=" + (isNum(load.overfull) ? load.overfull : "—"), "dim");
+}
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
+function setXrxFull(x) {
+ const xf = document.getElementById("xrxFull");
+ if (xf) xf.textContent = xrxFullText(x);
+}
+function setRawDecodeMsg(msg) {
+ const L = document.getElementById("rawDecodeL");
+ const R = document.getElementById("rawDecodeR");
+ if (L) { L.textContent = ""; pushDec(L, msg, "hdr"); }
+ if (R) { R.textContent = ""; pushDec(R, "—", "hdr"); }
+}
function renderRaw(st, raw) {
const x = (st && st.xrx) || null;
- if (!E.rxraw && !raw) {
- /* 原始窗口关:只刷 kv/xrx(来自 state,零额外请求),hex 区写提示不重建网格 */
+ /* rxraw 开着但本拍还没拿到 raw:不能继续解 raw.layout(会抛错,后面解码/hex 全不画) */
+ if (!raw) {
kv("rawKv", [
["rxraw cycle", null],
- ["layout", "—(原始窗口未开)"],
+ ["layout", E.rxraw ? "—(等待 /api/motor/rxraw)" : "—(原始窗口未开)"],
["count / lost / pending", null],
["gseq", null],
["len (字节)", null],
@@ -1578,18 +1971,10 @@ function renderRaw(st, raw) {
["xrx data", x ? x.data : null],
["wr(楔死恢复)", isNum(st && st.wr) ? st.wr : null, (st && st.wr) ? "warn" : "good"],
]);
- const xf = document.getElementById("xrxFull");
- if (x && x.data) {
- xf.textContent = "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() +
- "\n" + x.data.replace(/(.{8})/g, "$1 ").trim();
- } else {
- xf.textContent = "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里";
- }
- const grid = document.getElementById("rawHex");
- if (grid.childNodes.length !== 1 || !grid.firstChild.classList || !grid.firstChild.classList.contains("hdr")) {
- grid.textContent = "";
- grid.appendChild(el("i", "hdr", "原始窗口未开启 —— 点「原始窗口:开」才会每拍读 /api/motor/rxraw(对 EtherCAT 有负载)"));
- }
+ setXrxFull(x);
+ setRawDecodeMsg(E.rxraw
+ ? "原始窗口已开,等待本拍 /api/motor/rxraw…(需保持「开始实时观测」)"
+ : "原始窗口未开启 —— 点上方「原始窗口:开」,并保持「开始实时观测」。");
return;
}
/* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf;
@@ -1606,55 +1991,15 @@ function renderRaw(st, raw) {
[hLbl[1], raw ? raw.lost : null],
[hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null],
["gseq", isDense && isNum(raw.gseq) ? raw.gseq.toLocaleString("en-US") : null],
- ["len (字节)", raw ? raw.len : null],
+ ["len 段2(rxraw)", raw ? raw.len : null],
+ ["pdoin 整包", E.pdoin && E.pdoin.len ? E.pdoin.len + "B" : "—(旧 native 或未开窗)"],
["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null],
["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null],
["xrx data", x ? x.data : null],
["wr(楔死恢复)", isNum(st && st.wr) ? st.wr : null, (st && st.wr) ? "warn" : "good"],
]);
- /* xrx 完整载荷:kv 里那行是截断显示的,这里给全量 */
- const xf = document.getElementById("xrxFull");
- if (x && x.data) {
- xf.textContent = "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() +
- "\n" + x.data.replace(/(.{8})/g, "$1 ").trim();
- } else {
- xf.textContent = "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里";
- }
- const grid = document.getElementById("rawHex");
- grid.textContent = "";
- const cell = (t, cls) => grid.appendChild(el("i", cls, t));
- if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; }
- if (raw.layout === "dense") {
- /* 密排:8B 头 + count×56B 槽。头单独一行,每槽 56B=112 hex 切成 2 行 */
- const hex = raw.raw;
- if (hex.length >= 16) cell("头 " + hexGrp(hex.substr(0, 16)), "hdr");
- for (let s = 0; s * DENSE_SLOT * 2 < hex.length - 16; s++) {
- const off = 16 + s * DENSE_SLOT * 2, slotHex = hex.substr(off, DENSE_SLOT * 2);
- if (!slotHex) break;
- cell("槽" + s + " " + hexGrp(slotHex.substr(0, 16)), "slot");
- cell(" " + hexGrp(slotHex.substr(16, 32)));
- if (slotHex.length > 48) cell(" " + hexGrp(slotHex.substr(48, 32)));
- if (slotHex.length > 80) cell(" " + hexGrp(slotHex.substr(80)));
- }
- return;
- }
- if (raw.layout !== "win") {
- for (let i = 0; i < raw.raw.length; i += 32)
- cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32)));
- return;
- }
- /* win 布局:5 槽 × 44B(头 12B + 数据 32B)。按槽切行、行宽固定,所以能排成多列网格
- * —— 原来单列 pre 每行只用掉面板四成宽度。快照长 232B,5 槽只占 220B,
- * 剩下 12B 是段 4 之后的邻接字节:单独一行标"尾",不能当"槽5"(会被读成窗口多一格)。 */
- for (let s = 0; s < WIN_SLOT_COUNT && s * 88 < raw.raw.length; s++) {
- const hdr = raw.raw.substr(s * 88, 24), dat = raw.raw.substr(s * 88 + 24, 64);
- if (!hdr) break;
- cell("槽" + s + " " + hexGrp(hdr), "slot");
- cell(" " + hexGrp(dat.substr(0, 32)));
- if (dat.length > 32) cell(" " + hexGrp(dat.substr(32, 32)));
- }
- const tail = raw.raw.substr(WIN_SLOT_COUNT * 88);
- if (tail) cell("尾 " + hexGrp(tail), "hdr");
+ setXrxFull(x);
+ renderRawDecode(st, raw);
}
/* ═══════════════════ 渲染:逐拍节拍环 ═══════════════════ */
@@ -1787,13 +2132,15 @@ function drawChart() {
}
$("#btnChartMax").onclick = () => {
E.chartOpt.max = !E.chartOpt.max;
- $("#btnChartMax").textContent = "单拍 max:" + (E.chartOpt.max ? "开" : "关");
+ applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max");
drawChart();
+ saveUiOpts();
};
$("#btnChartSkip").onclick = () => {
E.chartOpt.skip = !E.chartOpt.skip;
- $("#btnChartSkip").textContent = "跳拍:" + (E.chartOpt.skip ? "开" : "关");
+ applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍");
drawChart();
+ saveUiOpts();
};
window.addEventListener("resize", layoutChart);
@@ -1833,9 +2180,12 @@ $("#btnRxbClear").onclick = () => {
/* ═══════════════════ 初始化 ═══════════════════ */
showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
layoutChart();
-window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); });
+restoreUiOpts(); /* 恢复上次的开关/采样率/曲线选项 */
+renderRaw(null, null); /* 面板③先画「未开启」占位,避免两块空白看起来像坏了 */
+setFreezeUi();
+window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); saveUiOpts(); });
/* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */
-window.__speedtest = { E, eapi, RateWindow, decodeStatus };
+window.__speedtest = { E, eapi, RateWindow, decodeStatus, renderRaw, renderRawDecode, saveUiOpts, restoreUiOpts };
})();
diff --git a/pages/index.html b/pages/index.html
index 0b4c8ff..a841fb5 100644
--- a/pages/index.html
+++ b/pages/index.html
@@ -13,7 +13,7 @@
-
+
@@ -59,9 +59,19 @@
🔗 设备连接
-
-
-
+
+
+
+
+
+
+
+ 主站经引导程序邮箱刷写 GD32 APP,ESP32 离线不影响
+
+
总线对面
+
+
+ 未探测
+
+
+
+
+
+
主站已缓存0 B
+
从站已写 flash--
+
+
+ 设备名(0x1008)—
+
+
+ 软件版本(0x100A)—
+
+
@@ -143,9 +182,15 @@
🎯 升级目标、固件文件与执行操作
-
-
-
+
+
+
@@ -233,18 +278,28 @@
+
+
+
+
+
-
-
+
+
⚡ 刷写进度与流程阶段
📦ESP32 OTA
📤GD32 OTA
+
⚡EtherCAT
+
+
待机
@@ -344,7 +409,7 @@
-
+
@@ -500,8 +565,12 @@
操作成功
-
-
+
+
+
+