feat(motor): 新增 EtherCAT 周期速率扫描分析工具链,支持 sweep 脚本、指标校准、绘图与 HTML 报告生成

This commit is contained in:
cbb
2026-09-10 03:02:14 +08:00
parent 4e018af168
commit 81096d0425
20 changed files with 4544 additions and 219 deletions
+150 -28
View File
@@ -97,6 +97,7 @@ const state = {
selectedNode: null, // 当前选中节点号
motors: [], // 最近一次 /motor/state 的 motors 数组
decoded: new Map(), // node -> 解码后的 STATUS 对象
decodedConfig: new Map(),// node -> 解码后的 CONFIG 对象(电机实时回报的目标模式/目标值)
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。
@@ -1456,6 +1457,17 @@ function refreshChannelViews() {
$("#nodeSummary").textContent = `${onlineCount}/9 在线 · CH${state.ch}`;
}
/** 节点卡片模式标签:电机 CONFIG 帧 B0(= FOC.control_mode)每周期实时回报,是唯一权威来源;
* 仅在这条链路还没回过 CONFIG 时,退到前端记录的最近发送 CMD。
* 都不到就用 "--",不要写"待机"—— 电机没有这个状态,写了就是假仪表。 */
function modeLabelOf(m) {
const cfg = state.decodedConfig.get(motorKey(m));
const mode = cfg ? cfg.targetMode : state.lastSentMode.get(m.node);
if (mode == null) return "--";
const def = MODES[mode];
return def ? def.zh : ("模式" + mode);
}
function renderNodeList() {
const motors = getDisplayMotors();
@@ -1513,11 +1525,9 @@ function renderNodeList() {
const ledFull = "led " + ledCls;
if (ref.led.className !== ledFull) ref.led.className = ledFull;
const sentMode = state.lastSentMode.get(m.node);
const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
/* motStatus 是报错位(1=故障),不是运行标志;无最近 CMD 时用 待机。
/* motStatus 是报错位(1=故障),不是运行标志;模式取电机 CONFIG 帧实时回报。
* otherCh:节点在其他通道在线,标注实际通道供用户切换。 */
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m));
if (ref.modeEl.textContent !== modeLabel) ref.modeEl.textContent = modeLabel;
const modeCls = m.otherCh != null ? "nc-mode mode-other-ch"
: (!m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode"));
@@ -1556,9 +1566,7 @@ function buildNodeCardHtml(m) {
faultCount > 0 ? "fault" : "",
!m.online ? "offline" : "",
].join(" ").trim();
const sentMode = state.lastSentMode.get(m.node);
const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m));
const modeCls = m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : ""));
const stTxt = !m.online ? "--" : (faultLabel || "OK");
const cardTitle = faultLabel ? ("故障: " + faultLabel)
@@ -2015,6 +2023,36 @@ function seqNodeOptions(selected) {
return FIXED_MOTOR_NODES.map((n) =>
`<option value="${n}"${parseInt(selected, 10) === n ? " selected" : ""}>N${n}</option>`).join("");
}
/* 步骤序号中文读法:1 → 一、11 → 十一、20 → 二十;超出 99 退回阿拉伯数字避免读错 */
const CN_DIGITS = "零一二三四五六七八九";
function cnNum(n) {
if (n < 10) return CN_DIGITS[n];
if (n < 20) return "十" + (n % 10 ? CN_DIGITS[n % 10] : "");
return CN_DIGITS[Math.floor(n / 10)] + "十" + (n % 10 ? CN_DIGITS[n % 10] : "");
}
function seqStepName(i) {
const n = i + 1;
return n <= 99 ? "步骤" + cnNum(n) : "步骤 " + n;
}
/* 折叠态摘要:一个 action 压成 "CH0·N3 速度+位置 速度100°/s 角度0°" */
const SEQ_TARGET_ZH = { voltage: "电压", current: "电流", velocity: "速度", angle: "角度" };
const SEQ_TARGET_UNIT = { voltage: "V", current: "mA", velocity: "°/s", angle: "°" };
const SEQ_SUMMARY_MAX = 3; /* 一行最多列 3 台电机,其余折叠成 "+N 台" */
function seqActionBrief(a) {
const m = MODES[parseInt(a.mode, 10)] || null;
const vals = (m ? m.targets : []).map((t) =>
`${SEQ_TARGET_ZH[t] || t}${a[t] != null ? a[t] : "0"}${SEQ_TARGET_UNIT[t] || ""}`).join(" ");
return `CH${a.ch}·N${a.node} ${(m && m.zh) || "模式" + a.mode}${vals ? " " + vals : ""}`;
}
function seqStepSummaryHtml(s) {
const acts = s.actions || [];
if (!acts.length) return "无动作";
const html = acts.slice(0, SEQ_SUMMARY_MAX).map((a) => escapeHtml(seqActionBrief(a)))
.join('<span class="seq-sum-sep">|</span>');
return acts.length > SEQ_SUMMARY_MAX
? `${html}<span class="seq-sum-more">+${acts.length - SEQ_SUMMARY_MAX} 台</span>`
: html;
}
/* 归一单个 action:补齐 ch/node/各参数默认值(ch/node 缺失时回退当前通道/选中节点) */
function actionFromAny(a) {
a = a || {};
@@ -2031,14 +2069,16 @@ function actionFromAny(a) {
pidAngP: a.pidAngP != null ? a.pidAngP : "100",
};
}
/* 归一步骤:新结构 {holdMs, actions[]};旧扁平步骤(含 mode 等顶层字段)→ 单动作组 */
/* 归一步骤:新结构 {holdMs, actions[], collapsed?};旧扁平步骤(含 mode 等顶层字段)→ 单动作组。
* collapsed 是 UI 态但挂在步骤对象上:拖拽换位时要跟着这一步走,按索引记会错位。 */
function normStep(s) {
s = s || {};
const holdMs = clampNum(parseInt(s.holdMs, 10) || 1000, 0, 600000);
const collapsed = !!s.collapsed;
if (Array.isArray(s.actions) && s.actions.length) {
return { holdMs: holdMs, actions: s.actions.map(actionFromAny) };
return { holdMs: holdMs, collapsed: collapsed, actions: s.actions.map(actionFromAny) };
}
return { holdMs: holdMs, actions: [actionFromAny(s)] };
return { holdMs: holdMs, collapsed: collapsed, actions: [actionFromAny(s)] };
}
function cloneAction(a) { return Object.assign({}, actionFromAny(a)); }
/* 读取当前 CMD 表单已提交值 + 当前通道/选中节点,作为一个新 action(与 buildFormCmdBytes 同源) */
@@ -2062,12 +2102,15 @@ function renderSeqList() {
seqListEl.innerHTML = '<div class="seq-empty">尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发</div>';
} else {
seqListEl.innerHTML = seq.steps.map((s, i) => `
<div class="seq-step" data-idx="${i}">
<div class="seq-step${s.collapsed ? " collapsed" : ""}" data-idx="${i}">
<div class="seq-step-head">
<span class="seq-idx">步骤 ${i + 1}</span>
<span class="seq-grip" tabindex="0" role="button" title="按住拖动上下排序(聚焦后方向键同样可移)" aria-label="${seqStepName(i)} 拖动排序">
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true"><circle cx="3" cy="3" r="1.25"/><circle cx="7" cy="3" r="1.25"/><circle cx="3" cy="7" r="1.25"/><circle cx="7" cy="7" r="1.25"/><circle cx="3" cy="11" r="1.25"/><circle cx="7" cy="11" r="1.25"/></svg>
</span>
<span class="seq-idx">${seqStepName(i)}</span>
<span class="seq-count">${s.actions.length} 电机</span>
<span class="seq-hold-label">保持</span>
<input class="seq-num seq-hold" data-f="holdMs" type="number" value="${s.holdMs}" min="0" max="600000" step="100" aria-label="步骤 ${i + 1} 保持时间 ms" />
<input class="seq-num seq-hold" data-f="holdMs" type="number" value="${s.holdMs}" min="0" max="600000" step="100" aria-label="${seqStepName(i)} 保持时间 ms" />
<span class="seq-hold-unit">ms</span>
<div class="seq-ops">
<button data-op="sendOne" title="单发本步(仅发送这一步的全部电机动作,不推进序列)" class="seq-send-one">
@@ -2076,11 +2119,8 @@ function renderSeqList() {
<button data-op="addAction" title="本步添加电机动作(复制最后一个动作)">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button data-op="up" title="上移" ${i === 0 ? "disabled" : ""}>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M3.5 8.5 7 5l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button data-op="down" title="下移" ${i === seq.steps.length - 1 ? "disabled" : ""}>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M3.5 5.5 7 9l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<button data-op="collapse" title="${s.collapsed ? "展开本步" : "折叠本步(只留功能摘要)"}" class="seq-fold" aria-expanded="${s.collapsed ? "false" : "true"}">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M4.5 3 8.5 7l-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button data-op="dup" title="复制本步(含全部动作)">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><rect x="4.5" y="4.5" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/><rect x="2.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2" opacity=".4"/></svg>
@@ -2090,14 +2130,15 @@ function renderSeqList() {
</button>
</div>
</div>
<div class="seq-summary">${seqStepSummaryHtml(s)}</div>
<div class="seq-actions">
${s.actions.map((a, j) => `
<div class="seq-action" data-aidx="${j}">
<div class="seq-action-head">
<span class="seq-action-tag">电机 ${j + 1}</span>
<select class="seq-ch" aria-label="步骤 ${i + 1} 电机 ${j + 1} 通道" title="发送通道">${seqChOptions(a.ch)}</select>
<select class="seq-node" aria-label="步骤 ${i + 1} 电机 ${j + 1} 节点" title="目标节点">${seqNodeOptions(a.node)}</select>
<select class="seq-mode" aria-label="步骤 ${i + 1} 电机 ${j + 1} 控制模式">${seqModeOptions(a.mode)}</select>
<select class="seq-ch" aria-label="${seqStepName(i)} 电机 ${j + 1} 通道" title="发送通道">${seqChOptions(a.ch)}</select>
<select class="seq-node" aria-label="${seqStepName(i)} 电机 ${j + 1} 节点" title="目标节点">${seqNodeOptions(a.node)}</select>
<select class="seq-mode" aria-label="${seqStepName(i)} 电机 ${j + 1} 控制模式">${seqModeOptions(a.mode)}</select>
<div class="seq-ops">
<button data-op="delAction" title="删除此电机动作" class="seq-del">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M2.5 4h9M5.5 4V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3.5 4l.55 6.9A1 1 0 0 0 5.04 12h3.92a1 1 0 0 0 .99-.9L10.5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
@@ -2180,7 +2221,7 @@ seqListEl.addEventListener("input", (e) => {
debounceSaveUi();
});
/* 步骤操作:上移/下移/复制/删除 + 动作级增删(运行中锁定,指针事件被 .seq-locked 禁用) */
/* 步骤操作:折叠/复制/删除 + 动作级增删(运行中锁定,指针事件被 .seq-locked 禁用) */
seqListEl.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-op]");
if (!btn || seq.running) return;
@@ -2194,12 +2235,17 @@ seqListEl.addEventListener("click", (e) => {
sendSeqStepOnce(i);
return;
}
if (op === "up" && i > 0) {
const t = seq.steps[i]; seq.steps[i] = seq.steps[i - 1]; seq.steps[i - 1] = t;
} else if (op === "down" && i < seq.steps.length - 1) {
const t = seq.steps[i]; seq.steps[i] = seq.steps[i + 1]; seq.steps[i + 1] = t;
} else if (op === "dup") {
seq.steps.splice(i + 1, 0, { holdMs: step.holdMs, actions: step.actions.map(cloneAction) });
if (op === "collapse") {
/* 就地折叠:整表重绘会把焦点与滚动位置一起丢掉,摘要已随渲染算好,不必重绘 */
step.collapsed = !step.collapsed;
row.classList.toggle("collapsed", step.collapsed);
btn.setAttribute("aria-expanded", step.collapsed ? "false" : "true");
btn.title = step.collapsed ? "展开本步" : "折叠本步(只留功能摘要)";
saveUiState();
return;
}
if (op === "dup") {
seq.steps.splice(i + 1, 0, { holdMs: step.holdMs, collapsed: false, actions: step.actions.map(cloneAction) });
} else if (op === "del") {
seq.steps.splice(i, 1);
} else if (op === "addAction") {
@@ -2219,6 +2265,82 @@ seqListEl.addEventListener("click", (e) => {
saveUiState();
});
/* ── 步骤排序:按住把手拖动;把手聚焦后 ↑/↓ 等价移动一步 ──
* 拖拽全程只移动 DOM 与切 .dragging 类,绝不调 renderSeqList —— 重绘会替换掉被拖节点,
* 原生拖拽当场中止。最终顺序在 dragend 里按视觉次序回填 seq.steps 后统一重绘 + 保存。 */
let seqDragRow = null;
function seqMoveStep(from, to) {
if (to < 0 || to >= seq.steps.length || from === to) return false;
seq.steps.splice(to, 0, seq.steps.splice(from, 1)[0]);
return true;
}
function clearSeqDraggable() {
seqListEl.querySelectorAll('.seq-step[draggable="true"]').forEach((r) => { r.draggable = false; });
}
/* 只有把手按下才把该行标记为可拖,行内输入框的文字选择不会被误判成拖拽起点 */
seqListEl.addEventListener("pointerdown", (e) => {
const grip = e.target.closest(".seq-grip");
const row = grip && grip.closest(".seq-step");
if (!row || seq.running) return;
clearSeqDraggable();
row.draggable = true;
});
seqListEl.addEventListener("pointerup", clearSeqDraggable);
seqListEl.addEventListener("pointercancel", clearSeqDraggable);
seqListEl.addEventListener("dragstart", (e) => {
const row = e.target.closest ? e.target.closest(".seq-step") : null;
if (!row || !row.draggable || seq.running) return;
seqDragRow = row;
row.classList.add("drag-src");
seqListEl.classList.add("dragging"); /* 拖拽期整表临时收成单行,含被拖的这行 */
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", row.dataset.idx); } catch (_) { /* 个别内核要求先 setData 才放行 */ }
});
seqListEl.addEventListener("dragover", (e) => {
if (!seqDragRow) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const over = e.target.closest ? e.target.closest(".seq-step") : null;
if (!over || over === seqDragRow) return;
const r = over.getBoundingClientRect();
seqListEl.insertBefore(seqDragRow, e.clientY < r.top + r.height / 2 ? over : over.nextSibling);
});
seqListEl.addEventListener("drop", (e) => { if (seqDragRow) e.preventDefault(); });
seqListEl.addEventListener("dragend", () => {
if (!seqDragRow) return;
const order = Array.from(seqListEl.querySelectorAll(".seq-step"))
.map((el) => parseInt(el.dataset.idx, 10));
seqDragRow.classList.remove("drag-src");
seqDragRow = null;
clearSeqDraggable();
seqListEl.classList.remove("dragging");
const moved = order.some((idx, pos) => idx !== pos);
/* 按视觉次序重排数据;未拖动或按 Esc 取消时这次重绘即把顺序还原 */
if (moved) seq.steps = order.map((idx) => seq.steps[idx]);
renderSeqList();
if (moved) saveUiState();
});
seqListEl.addEventListener("keydown", (e) => {
const grip = e.target.closest(".seq-grip");
if (!grip || seq.running) return;
const dir = e.key === "ArrowUp" ? -1 : (e.key === "ArrowDown" ? 1 : 0);
if (!dir) return;
e.preventDefault();
const i = parseInt(grip.closest(".seq-step").dataset.idx, 10);
if (!seqMoveStep(i, i + dir)) return;
renderSeqList();
saveUiState();
const g = seqListEl.querySelectorAll(".seq-grip")[i + dir];
if (g) g.focus(); /* 重绘后把焦点跟到这一步的新行,可连按方向键连续移动 */
});
$("#seqAddBtn").addEventListener("click", () => {
if (seq.running) return;
seq.steps.push(seqReadStepFromForm());