refactor(motor): 命令序列外提 seq.js(P1 重做第 2 步)

createSeqSys 注入依赖;main 保留 seq/renderSeqList/stopSeq 绑定。
STATUS offlineOverlay 契约未改。UI 验收通过(绿灯+数据+无报错)。
This commit is contained in:
cbb
2026-09-21 03:10:47 +08:00
parent aa1e14d4cb
commit aab526ffa8
3 changed files with 536 additions and 506 deletions
+11 -505
View File
@@ -51,6 +51,7 @@ import {
isUnthrottled
} from "./refresh-rate.js";
import { createScopeSys } from "./scope.js?v=20260924d";
import { createSeqSys } from "./seq.js?v=20260924d";
/* ─── 控制模式定义 ─── */
const MODES = {
@@ -2480,512 +2481,17 @@ periodInput.addEventListener("change", () => {
});
/* ═══════════════════════ 命令序列发送(循环发送设定步骤) ═══════════════════════
* 每步 = 一个「同步动作组」:组内每个 action = 一个电机目标(ch/node/模式/电压/电流/
* 速度/角度/PID)。执行本步时把组内全部 action 经一次 POST /can/send_burst 下发
* (ESP32 转 0xC7 突发,GD32 背靠背入各通道 CAN 邮箱),组内多电机近同时发送;
* 发完本步再等 holdMs 进下一步。循环次数 0 = 无限循环。与周期发送互斥。
* 目标完全独立于左侧选中节点:每个 action 自带 ch/node,可多通道、多电机混合搭配。
* 兼容旧数据:无 actions 的扁平旧步骤自动归一为单动作步骤(normStep)。
* 步骤持久化在 saveUiState/loadUiState(序列化到 o.seq)。 */
const seq = {
running: false,
stopRequested: false,
steps: [], /* [{holdMs, actions:[{ch,node,mode,voltage,current,velocity,angle,pidCurP,pidVelP,pidAngP}]}] */
loops: 0, /* 循环次数,0 = 无限 */
round: 1, /* 当前轮(1 起) */
stepIdx: 0, /* 当前步索引 */
statusText: "未运行",
singleIdx: null, /* 正在单发的步骤索引,null = 无单发在途 */
};
const seqListEl = $("#seqList");
const seqStartBtn = $("#seqStartBtn");
const seqLoopsInput = $("#seqLoops");
function seqModeOptions(selected) {
return Object.entries(MODES).map(([v, m]) =>
`<option value="${v}"${String(v) === String(selected) ? " selected" : ""}>${v} — ${m.zh}</option>`).join("");
}
function seqChOptions(selected) {
return [0, 1, 2].map((c) =>
`<option value="${c}"${parseInt(selected, 10) === c ? " selected" : ""}>CH${c}</option>`).join("");
}
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 || {};
return {
ch: Number.isInteger(a.ch) ? clampNum(a.ch, 0, 2) : clampNum(state.ch || 0, 0, 2),
node: (Number.isInteger(a.node) && a.node >= 1 && a.node <= 9) ? a.node : (state.selectedNode || 1),
mode: a.mode != null ? String(a.mode) : "3",
voltage: a.voltage != null ? a.voltage : "24.000",
current: a.current != null ? a.current : "0",
velocity: a.velocity != null ? a.velocity : "100",
angle: a.angle != null ? a.angle : "0",
pidCurP: a.pidCurP != null ? a.pidCurP : "100",
pidVelP: a.pidVelP != null ? a.pidVelP : "100",
pidAngP: a.pidAngP != null ? a.pidAngP : "100",
};
}
/* 归一步骤:新结构 {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, collapsed: collapsed, actions: s.actions.map(actionFromAny) };
}
return { holdMs: holdMs, collapsed: collapsed, actions: [actionFromAny(s)] };
}
function cloneAction(a) { return Object.assign({}, actionFromAny(a)); }
/* 读取当前 CMD 表单输入框值 + 当前通道/选中节点,作为一个新 action(与一帧发送同源) */
function defaultActionFromForm() {
syncCmdCommittedFromDom();
return {
ch: clampNum(state.ch || 0, 0, 2),
node: (state.selectedNode >= 1 && state.selectedNode <= 9) ? state.selectedNode : 1,
mode: $("#modeSelect").value,
voltage: cmdVal("inVoltage"),
current: cmdVal("inCurrent"),
velocity: cmdVal("inVelocity"),
angle: cmdVal("inAngle"),
pidCurP: cmdVal("pidCurP"),
pidVelP: cmdVal("pidVelP"),
pidAngP: cmdVal("pidAngP"),
};
}
function renderSeqList() {
if (!seq.steps.length) {
seqListEl.innerHTML = '<div class="seq-empty">尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发</div>';
} else {
seqListEl.innerHTML = seq.steps.map((s, i) => `
<div class="seq-step${s.collapsed ? " collapsed" : ""}" data-idx="${i}">
<div class="seq-step-head">
<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="${seqStepName(i)} 保持时间 ms" />
<span class="seq-hold-unit">ms</span>
<div class="seq-ops">
<button data-op="sendOne" title="单发本步(仅发送这一步的全部电机动作,不推进序列)" class="seq-send-one">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M4.5 2.8 11 7l-6.5 4.2z" fill="currentColor"/></svg>
</button>
<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="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>
</button>
<button data-op="del" 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>
</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="${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>
</button>
</div>
</div>
<div class="seq-step-fields">
<label class="field"><span class="field-label">电压 <span class="field-unit">V</span></span><input class="seq-num" data-f="voltage" type="number" value="${a.voltage}" step="0.5" /></label>
<label class="field"><span class="field-label">电流 <span class="field-unit">mA</span></span><input class="seq-num" data-f="current" type="number" value="${a.current}" step="100" /></label>
<label class="field"><span class="field-label">速度 <span class="field-unit">deg/s</span></span><input class="seq-num" data-f="velocity" type="number" value="${a.velocity}" step="10" /></label>
<label class="field"><span class="field-label">角度 <span class="field-unit">deg</span></span><input class="seq-num" data-f="angle" type="number" value="${a.angle}" step="1" /></label>
<label class="field"><span class="field-label">电流环P</span><input class="seq-num" data-f="pidCurP" type="number" value="${a.pidCurP}" step="1" /></label>
<label class="field"><span class="field-label">速度环P</span><input class="seq-num" data-f="pidVelP" type="number" value="${a.pidVelP}" step="1" /></label>
<label class="field"><span class="field-label">角度环P</span><input class="seq-num" data-f="pidAngP" type="number" value="${a.pidAngP}" step="1" /></label>
</div>
</div>`).join("")}
</div>
</div>`).join("");
}
updateSeqUi();
}
function updateSeqUi() {
const running = seq.running;
const sendingIdx = seq.singleIdx;
seqStartBtn.textContent = running ? "■ 停止序列" : "▶ 开始序列";
seqStartBtn.classList.toggle("btn-primary", running);
seqStartBtn.disabled = !running && sendingIdx != null;
const st = $("#seqStatus");
if (st) {
if (running) {
const cs = seq.steps[seq.stepIdx];
const cnt = cs ? cs.actions.length : 0;
st.textContent = `运行中 · 步骤 ${seq.stepIdx + 1}/${seq.steps.length} (${cnt} 电机) · 第 ${seq.round} 轮`;
st.classList.add("on");
} else if (sendingIdx != null) {
st.textContent = `单发步骤 ${sendingIdx + 1}…`;
st.classList.add("on");
} else {
st.textContent = seq.statusText || `未运行 · ${seq.steps.length} 步`;
st.classList.remove("on");
}
}
seqListEl.classList.toggle("seq-locked", running);
seqListEl.querySelectorAll(".seq-step").forEach((row) => {
const idx = parseInt(row.dataset.idx, 10);
row.classList.toggle("current", running && idx === seq.stepIdx);
row.classList.toggle("sending", !running && sendingIdx === idx);
});
seqLoopsInput.disabled = running;
$("#seqAddBtn").disabled = running;
$("#seqClearBtn").disabled = running;
}
/* 读取当前 CMD 表单已提交值作为新步骤:单动作组(用户可再「+ 电机」扩展) */
function seqReadStepFromForm() {
return { holdMs: 1000, actions: [defaultActionFromForm()] };
}
/* 步骤行编辑(input 事件同时覆盖 number 输入与 select 选择):
* holdMs 归属步骤本身;ch/node/mode/各参数归属 .seq-action(data-aidx)对应的 action */
seqListEl.addEventListener("input", (e) => {
const row = e.target.closest(".seq-step");
if (!row) return;
const step = seq.steps[parseInt(row.dataset.idx, 10)];
if (!step) return;
const t = e.target;
if (t.dataset.f === "holdMs") {
step.holdMs = clampNum(parseInt(t.value, 10) || 0, 0, 600000);
} else {
const actRow = t.closest(".seq-action");
const action = actRow ? step.actions[parseInt(actRow.dataset.aidx, 10)] : null;
if (!action) return;
if (t.classList.contains("seq-ch")) action.ch = clampNum(parseInt(t.value, 10) || 0, 0, 2);
else if (t.classList.contains("seq-node")) action.node = clampNum(parseInt(t.value, 10) || 1, 1, 9);
else if (t.classList.contains("seq-mode")) action.mode = t.value;
else if (t.dataset.f) action[t.dataset.f] = t.value;
else return;
}
debounceSaveUi();
* 实现见 js/motor/seq.js(P1 重做:机械外提,契约不变)。 */
const seqSys = createSeqSys({
MODES, FIXED_MOTOR_NODES, state, clampNum, escapeHtml, $,
cmdVal, debounceSaveUi, saveUiState, inBusCooldown,
renderNodeList, nowMs, sendBurst, showModal, sleep,
stopRepeat, syncCmdCommittedFromDom, toast,
});
/* 步骤操作:折叠/复制/删除 + 动作级增删(运行中锁定,指针事件被 .seq-locked 禁用) */
seqListEl.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-op]");
if (!btn || seq.running) return;
const row = btn.closest(".seq-step");
if (!row) return;
const i = parseInt(row.dataset.idx, 10);
const step = seq.steps[i];
if (!step) return;
const op = btn.dataset.op;
if (op === "sendOne") {
sendSeqStepOnce(i);
return;
}
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") {
/* 默认复制本步最后一个动作(改个节点即成另一台电机);空步骤则取自 CMD 表单 */
step.actions.push(step.actions.length
? cloneAction(step.actions[step.actions.length - 1])
: defaultActionFromForm());
} else if (op === "delAction") {
const actRow = btn.closest(".seq-action");
if (!actRow) return;
step.actions.splice(parseInt(actRow.dataset.aidx, 10), 1);
if (!step.actions.length) seq.steps.splice(i, 1); /* 无动作则整步删除 */
} else {
return;
}
renderSeqList();
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());
renderSeqList();
saveUiState();
});
$("#seqClearBtn").addEventListener("click", async () => {
if (seq.running || seq.steps.length === 0) return;
const ok = await showModal("清空命令序列", `将删除全部 ${seq.steps.length} 个步骤。`);
if (!ok) return;
seq.steps = [];
renderSeqList();
saveUiState();
});
seqLoopsInput.addEventListener("change", () => {
seq.loops = clampNum(parseInt(seqLoopsInput.value, 10) || 0, 0, 9999);
seqLoopsInput.value = seq.loops;
saveUiState();
});
seqStartBtn.addEventListener("click", () => {
if (seq.running) { stopSeq("已手动停止"); return; }
startSeq();
});
/* 单发一步:只把该步的全部动作经一次 sendBurst 近同时下发,不循环、不推进步骤、不等 holdMs。
* 与序列运行互斥(运行中按钮被 .seq-locked 屏蔽),同一时刻只允许一个单发在途。 */
async function sendSeqStepOnce(i) {
if (seq.running || seq.singleIdx != null) return;
const step = seq.steps[i];
if (!step || !step.actions.length) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
if (inBusCooldown()) return;
const chBad = step.actions.find((a) => !state.chEnabled[a.ch]);
if (chBad) { toast(`步骤 ${i + 1} 的通道 CH${chBad.ch} 已禁用`, "err"); return; }
seq.singleIdx = i;
updateSeqUi();
const targets = step.actions.map((a) => `CH${a.ch}·N${a.node}`).join(" ");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: 单发步${i + 1} → ${step.actions.length} 电机 [${targets}] ${time}`;
try {
const res = await sendBurst(step.actions);
if (res.busy) { toast("发送通道忙,请稍后重试", "warn"); return; }
if (!res.ok) { toast(`步骤 ${i + 1} 发送失败:${res.desc || "未知错误"}`, "err"); return; }
for (const a of step.actions) {
state.lastSentMode.set(a.node, parseInt(a.mode, 10) || 0);
state.lastSentTargets.set(a.node, {
mode: parseInt(a.mode, 10) || 0,
angle: parseFloat(a.angle) || 0,
velocity: parseFloat(a.velocity) || 0,
current: parseFloat(a.current) || 0,
voltage: parseFloat(a.voltage) || 0,
});
}
state.txCount++;
renderNodeList();
seq.statusText = `已单发步骤 ${i + 1}`;
toast(`步骤 ${i + 1} 已单发 · ${step.actions.length} 电机 [${targets}]`, "ok");
} finally {
seq.singleIdx = null;
updateSeqUi();
}
}
function startSeq() {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (seq.steps.length === 0) { toast("请先添加至少一个步骤", "warn"); return; }
if (inBusCooldown()) return;
stopRepeat(true); /* 与周期发送互斥 */
seq.loops = clampNum(parseInt(seqLoopsInput.value, 10) || 0, 0, 9999);
seqLoopsInput.value = seq.loops;
seq.running = true;
seq.stopRequested = false;
seq.round = 1;
seq.stepIdx = 0;
seq.statusText = "";
updateSeqUi();
const totalActions = seq.steps.reduce((n, s) => n + s.actions.length, 0);
toast(`命令序列已启动 · ${seq.steps.length} 步 / ${totalActions} 电机动作 · 循环 ${seq.loops === 0 ? "无限" : seq.loops + " 次"}(同步动作组近同时下发)`, "ok");
seqRunLoop();
}
function stopSeq(reason, silent) {
seq.running = false;
seq.stopRequested = true;
seq.statusText = reason || "已停止";
updateSeqUi();
if (!silent) toast(reason || "命令序列已停止", "warn");
}
/* 序列主循环:stop-and-wait 逐步发送。每步把组内全部动作经一次 sendBurst
* (/can/send_burst → 0xC7 突发)近同时下发,发完等 holdMs 推进;
* 循环次数用尽或异常停止。 */
async function seqRunLoop() {
while (seq.running && !seq.stopRequested) {
if (!state.connected || state.probing) { stopSeq("连接断开,序列已停止", true); return; }
if (nowMs() < state.busCooldownUntil) { await sleep(200); continue; } /* 总线冷却等待 */
const step = seq.steps[seq.stepIdx];
if (!step || !Array.isArray(step.actions) || !step.actions.length) { stopSeq("序列数据无效,已停止"); return; }
let chBad = null;
for (const a of step.actions) {
if (!state.chEnabled[a.ch]) { chBad = a.ch; break; }
}
if (chBad != null) { stopSeq(`步骤 ${seq.stepIdx + 1} 的通道 CH${chBad} 已禁用,序列已停止`); return; }
updateSeqUi();
const targets = step.actions.map((a) => `CH${a.ch}·N${a.node}`).join(" ");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: 序列步${seq.stepIdx + 1} → ${step.actions.length} 电机 [${targets}] ${time}`;
/* 互斥发送:通道忙(手动 CMD 等在途)时小退避重试本步,不推进步骤 */
let res;
let busyTries = 0;
do {
res = await sendBurst(step.actions);
if (!seq.running) return;
if (res.busy && busyTries < 20) { busyTries++; await sleep(50); }
} while (res.busy && busyTries < 20);
if (res.busFault) { stopSeq("总线异常,序列已停止"); return; }
if (res.networkError) { stopSeq("网络错误,序列已停止"); return; }
if (!res.ok) { stopSeq("发送失败:" + (res.desc || "未知错误")); return; }
for (const a of step.actions) {
state.lastSentMode.set(a.node, parseInt(a.mode, 10) || 0);
state.lastSentTargets.set(a.node, {
mode: parseInt(a.mode, 10) || 0,
angle: parseFloat(a.angle) || 0,
velocity: parseFloat(a.velocity) || 0,
current: parseFloat(a.current) || 0,
voltage: parseFloat(a.voltage) || 0,
});
}
state.txCount++;
renderNodeList();
if (!seq.running) return;
const holdMs = clampNum(parseInt(step.holdMs, 10) || 0, 0, 600000);
if (holdMs > 0) await sleep(holdMs);
if (!seq.running) return;
if (seq.stepIdx + 1 >= seq.steps.length) {
if (seq.loops > 0 && seq.round >= seq.loops) {
stopSeq(`已完成 ${seq.loops} 轮`);
return;
}
seq.round++;
seq.stepIdx = 0;
} else {
seq.stepIdx++;
}
}
}
const {
seq, seqListEl, seqStartBtn, seqLoopsInput,
normStep, renderSeqList, startSeq, stopSeq, updateSeqUi,
} = seqSys;
/* ═══════════════════════ 配置操作(CMD_CONFIG,DLC 24) ═══════════════════════ */
const CONFIG_ACTIONS = {
+524
View File
@@ -0,0 +1,524 @@
/**
* seq.js — 电机控制台命令序列(同步动作组 / 循环 / 拖拽排序)
* 从 main.js 机械外提;行为不变。STATUS 离线层契约不在此文件。
* createSeqSys(ctx) 注入依赖;返回 seq 状态与 render/stop 等。
*/
"use strict";
export function createSeqSys(ctx) {
const { MODES, FIXED_MOTOR_NODES, state, clampNum, escapeHtml, $, cmdVal, debounceSaveUi, saveUiState, inBusCooldown, renderNodeList, nowMs, sendBurst, showModal, sleep, stopRepeat, syncCmdCommittedFromDom, toast } = ctx;
/* ═══════════════════════ 命令序列发送(循环发送设定步骤) ═══════════════════════
* 每步 = 一个「同步动作组」:组内每个 action = 一个电机目标(ch/node/模式/电压/电流/
* 速度/角度/PID)。执行本步时把组内全部 action 经一次 POST /can/send_burst 下发
* (ESP32 转 0xC7 突发,GD32 背靠背入各通道 CAN 邮箱),组内多电机近同时发送;
* 发完本步再等 holdMs 进下一步。循环次数 0 = 无限循环。与周期发送互斥。
* 目标完全独立于左侧选中节点:每个 action 自带 ch/node,可多通道、多电机混合搭配。
* 兼容旧数据:无 actions 的扁平旧步骤自动归一为单动作步骤(normStep)。
* 步骤持久化在 saveUiState/loadUiState(序列化到 o.seq)。 */
const seq = {
running: false,
stopRequested: false,
steps: [], /* [{holdMs, actions:[{ch,node,mode,voltage,current,velocity,angle,pidCurP,pidVelP,pidAngP}]}] */
loops: 0, /* 循环次数,0 = 无限 */
round: 1, /* 当前轮(1 起) */
stepIdx: 0, /* 当前步索引 */
statusText: "未运行",
singleIdx: null, /* 正在单发的步骤索引,null = 无单发在途 */
};
const seqListEl = $("#seqList");
const seqStartBtn = $("#seqStartBtn");
const seqLoopsInput = $("#seqLoops");
function seqModeOptions(selected) {
return Object.entries(MODES).map(([v, m]) =>
`<option value="${v}"${String(v) === String(selected) ? " selected" : ""}>${v} — ${m.zh}</option>`).join("");
}
function seqChOptions(selected) {
return [0, 1, 2].map((c) =>
`<option value="${c}"${parseInt(selected, 10) === c ? " selected" : ""}>CH${c}</option>`).join("");
}
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 || {};
return {
ch: Number.isInteger(a.ch) ? clampNum(a.ch, 0, 2) : clampNum(state.ch || 0, 0, 2),
node: (Number.isInteger(a.node) && a.node >= 1 && a.node <= 9) ? a.node : (state.selectedNode || 1),
mode: a.mode != null ? String(a.mode) : "3",
voltage: a.voltage != null ? a.voltage : "24.000",
current: a.current != null ? a.current : "0",
velocity: a.velocity != null ? a.velocity : "100",
angle: a.angle != null ? a.angle : "0",
pidCurP: a.pidCurP != null ? a.pidCurP : "100",
pidVelP: a.pidVelP != null ? a.pidVelP : "100",
pidAngP: a.pidAngP != null ? a.pidAngP : "100",
};
}
/* 归一步骤:新结构 {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, collapsed: collapsed, actions: s.actions.map(actionFromAny) };
}
return { holdMs: holdMs, collapsed: collapsed, actions: [actionFromAny(s)] };
}
function cloneAction(a) { return Object.assign({}, actionFromAny(a)); }
/* 读取当前 CMD 表单输入框值 + 当前通道/选中节点,作为一个新 action(与一帧发送同源) */
function defaultActionFromForm() {
syncCmdCommittedFromDom();
return {
ch: clampNum(state.ch || 0, 0, 2),
node: (state.selectedNode >= 1 && state.selectedNode <= 9) ? state.selectedNode : 1,
mode: $("#modeSelect").value,
voltage: cmdVal("inVoltage"),
current: cmdVal("inCurrent"),
velocity: cmdVal("inVelocity"),
angle: cmdVal("inAngle"),
pidCurP: cmdVal("pidCurP"),
pidVelP: cmdVal("pidVelP"),
pidAngP: cmdVal("pidAngP"),
};
}
function renderSeqList() {
if (!seq.steps.length) {
seqListEl.innerHTML = '<div class="seq-empty">尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发</div>';
} else {
seqListEl.innerHTML = seq.steps.map((s, i) => `
<div class="seq-step${s.collapsed ? " collapsed" : ""}" data-idx="${i}">
<div class="seq-step-head">
<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="${seqStepName(i)} 保持时间 ms" />
<span class="seq-hold-unit">ms</span>
<div class="seq-ops">
<button data-op="sendOne" title="单发本步(仅发送这一步的全部电机动作,不推进序列)" class="seq-send-one">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M4.5 2.8 11 7l-6.5 4.2z" fill="currentColor"/></svg>
</button>
<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="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>
</button>
<button data-op="del" 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>
</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="${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>
</button>
</div>
</div>
<div class="seq-step-fields">
<label class="field"><span class="field-label">电压 <span class="field-unit">V</span></span><input class="seq-num" data-f="voltage" type="number" value="${a.voltage}" step="0.5" /></label>
<label class="field"><span class="field-label">电流 <span class="field-unit">mA</span></span><input class="seq-num" data-f="current" type="number" value="${a.current}" step="100" /></label>
<label class="field"><span class="field-label">速度 <span class="field-unit">deg/s</span></span><input class="seq-num" data-f="velocity" type="number" value="${a.velocity}" step="10" /></label>
<label class="field"><span class="field-label">角度 <span class="field-unit">deg</span></span><input class="seq-num" data-f="angle" type="number" value="${a.angle}" step="1" /></label>
<label class="field"><span class="field-label">电流环P</span><input class="seq-num" data-f="pidCurP" type="number" value="${a.pidCurP}" step="1" /></label>
<label class="field"><span class="field-label">速度环P</span><input class="seq-num" data-f="pidVelP" type="number" value="${a.pidVelP}" step="1" /></label>
<label class="field"><span class="field-label">角度环P</span><input class="seq-num" data-f="pidAngP" type="number" value="${a.pidAngP}" step="1" /></label>
</div>
</div>`).join("")}
</div>
</div>`).join("");
}
updateSeqUi();
}
function updateSeqUi() {
const running = seq.running;
const sendingIdx = seq.singleIdx;
seqStartBtn.textContent = running ? "■ 停止序列" : "▶ 开始序列";
seqStartBtn.classList.toggle("btn-primary", running);
seqStartBtn.disabled = !running && sendingIdx != null;
const st = $("#seqStatus");
if (st) {
if (running) {
const cs = seq.steps[seq.stepIdx];
const cnt = cs ? cs.actions.length : 0;
st.textContent = `运行中 · 步骤 ${seq.stepIdx + 1}/${seq.steps.length} (${cnt} 电机) · 第 ${seq.round} 轮`;
st.classList.add("on");
} else if (sendingIdx != null) {
st.textContent = `单发步骤 ${sendingIdx + 1}…`;
st.classList.add("on");
} else {
st.textContent = seq.statusText || `未运行 · ${seq.steps.length} 步`;
st.classList.remove("on");
}
}
seqListEl.classList.toggle("seq-locked", running);
seqListEl.querySelectorAll(".seq-step").forEach((row) => {
const idx = parseInt(row.dataset.idx, 10);
row.classList.toggle("current", running && idx === seq.stepIdx);
row.classList.toggle("sending", !running && sendingIdx === idx);
});
seqLoopsInput.disabled = running;
$("#seqAddBtn").disabled = running;
$("#seqClearBtn").disabled = running;
}
/* 读取当前 CMD 表单已提交值作为新步骤:单动作组(用户可再「+ 电机」扩展) */
function seqReadStepFromForm() {
return { holdMs: 1000, actions: [defaultActionFromForm()] };
}
/* 步骤行编辑(input 事件同时覆盖 number 输入与 select 选择):
* holdMs 归属步骤本身;ch/node/mode/各参数归属 .seq-action(data-aidx)对应的 action */
seqListEl.addEventListener("input", (e) => {
const row = e.target.closest(".seq-step");
if (!row) return;
const step = seq.steps[parseInt(row.dataset.idx, 10)];
if (!step) return;
const t = e.target;
if (t.dataset.f === "holdMs") {
step.holdMs = clampNum(parseInt(t.value, 10) || 0, 0, 600000);
} else {
const actRow = t.closest(".seq-action");
const action = actRow ? step.actions[parseInt(actRow.dataset.aidx, 10)] : null;
if (!action) return;
if (t.classList.contains("seq-ch")) action.ch = clampNum(parseInt(t.value, 10) || 0, 0, 2);
else if (t.classList.contains("seq-node")) action.node = clampNum(parseInt(t.value, 10) || 1, 1, 9);
else if (t.classList.contains("seq-mode")) action.mode = t.value;
else if (t.dataset.f) action[t.dataset.f] = t.value;
else return;
}
debounceSaveUi();
});
/* 步骤操作:折叠/复制/删除 + 动作级增删(运行中锁定,指针事件被 .seq-locked 禁用) */
seqListEl.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-op]");
if (!btn || seq.running) return;
const row = btn.closest(".seq-step");
if (!row) return;
const i = parseInt(row.dataset.idx, 10);
const step = seq.steps[i];
if (!step) return;
const op = btn.dataset.op;
if (op === "sendOne") {
sendSeqStepOnce(i);
return;
}
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") {
/* 默认复制本步最后一个动作(改个节点即成另一台电机);空步骤则取自 CMD 表单 */
step.actions.push(step.actions.length
? cloneAction(step.actions[step.actions.length - 1])
: defaultActionFromForm());
} else if (op === "delAction") {
const actRow = btn.closest(".seq-action");
if (!actRow) return;
step.actions.splice(parseInt(actRow.dataset.aidx, 10), 1);
if (!step.actions.length) seq.steps.splice(i, 1); /* 无动作则整步删除 */
} else {
return;
}
renderSeqList();
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());
renderSeqList();
saveUiState();
});
$("#seqClearBtn").addEventListener("click", async () => {
if (seq.running || seq.steps.length === 0) return;
const ok = await showModal("清空命令序列", `将删除全部 ${seq.steps.length} 个步骤。`);
if (!ok) return;
seq.steps = [];
renderSeqList();
saveUiState();
});
seqLoopsInput.addEventListener("change", () => {
seq.loops = clampNum(parseInt(seqLoopsInput.value, 10) || 0, 0, 9999);
seqLoopsInput.value = seq.loops;
saveUiState();
});
seqStartBtn.addEventListener("click", () => {
if (seq.running) { stopSeq("已手动停止"); return; }
startSeq();
});
/* 单发一步:只把该步的全部动作经一次 sendBurst 近同时下发,不循环、不推进步骤、不等 holdMs。
* 与序列运行互斥(运行中按钮被 .seq-locked 屏蔽),同一时刻只允许一个单发在途。 */
async function sendSeqStepOnce(i) {
if (seq.running || seq.singleIdx != null) return;
const step = seq.steps[i];
if (!step || !step.actions.length) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
if (inBusCooldown()) return;
const chBad = step.actions.find((a) => !state.chEnabled[a.ch]);
if (chBad) { toast(`步骤 ${i + 1} 的通道 CH${chBad.ch} 已禁用`, "err"); return; }
seq.singleIdx = i;
updateSeqUi();
const targets = step.actions.map((a) => `CH${a.ch}·N${a.node}`).join(" ");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: 单发步${i + 1} → ${step.actions.length} 电机 [${targets}] ${time}`;
try {
const res = await sendBurst(step.actions);
if (res.busy) { toast("发送通道忙,请稍后重试", "warn"); return; }
if (!res.ok) { toast(`步骤 ${i + 1} 发送失败:${res.desc || "未知错误"}`, "err"); return; }
for (const a of step.actions) {
state.lastSentMode.set(a.node, parseInt(a.mode, 10) || 0);
state.lastSentTargets.set(a.node, {
mode: parseInt(a.mode, 10) || 0,
angle: parseFloat(a.angle) || 0,
velocity: parseFloat(a.velocity) || 0,
current: parseFloat(a.current) || 0,
voltage: parseFloat(a.voltage) || 0,
});
}
state.txCount++;
renderNodeList();
seq.statusText = `已单发步骤 ${i + 1}`;
toast(`步骤 ${i + 1} 已单发 · ${step.actions.length} 电机 [${targets}]`, "ok");
} finally {
seq.singleIdx = null;
updateSeqUi();
}
}
function startSeq() {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (seq.steps.length === 0) { toast("请先添加至少一个步骤", "warn"); return; }
if (inBusCooldown()) return;
stopRepeat(true); /* 与周期发送互斥 */
seq.loops = clampNum(parseInt(seqLoopsInput.value, 10) || 0, 0, 9999);
seqLoopsInput.value = seq.loops;
seq.running = true;
seq.stopRequested = false;
seq.round = 1;
seq.stepIdx = 0;
seq.statusText = "";
updateSeqUi();
const totalActions = seq.steps.reduce((n, s) => n + s.actions.length, 0);
toast(`命令序列已启动 · ${seq.steps.length} 步 / ${totalActions} 电机动作 · 循环 ${seq.loops === 0 ? "无限" : seq.loops + " 次"}(同步动作组近同时下发)`, "ok");
seqRunLoop();
}
function stopSeq(reason, silent) {
seq.running = false;
seq.stopRequested = true;
seq.statusText = reason || "已停止";
updateSeqUi();
if (!silent) toast(reason || "命令序列已停止", "warn");
}
/* 序列主循环:stop-and-wait 逐步发送。每步把组内全部动作经一次 sendBurst
* (/can/send_burst → 0xC7 突发)近同时下发,发完等 holdMs 推进;
* 循环次数用尽或异常停止。 */
async function seqRunLoop() {
while (seq.running && !seq.stopRequested) {
if (!state.connected || state.probing) { stopSeq("连接断开,序列已停止", true); return; }
if (nowMs() < state.busCooldownUntil) { await sleep(200); continue; } /* 总线冷却等待 */
const step = seq.steps[seq.stepIdx];
if (!step || !Array.isArray(step.actions) || !step.actions.length) { stopSeq("序列数据无效,已停止"); return; }
let chBad = null;
for (const a of step.actions) {
if (!state.chEnabled[a.ch]) { chBad = a.ch; break; }
}
if (chBad != null) { stopSeq(`步骤 ${seq.stepIdx + 1} 的通道 CH${chBad} 已禁用,序列已停止`); return; }
updateSeqUi();
const targets = step.actions.map((a) => `CH${a.ch}·N${a.node}`).join(" ");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: 序列步${seq.stepIdx + 1} → ${step.actions.length} 电机 [${targets}] ${time}`;
/* 互斥发送:通道忙(手动 CMD 等在途)时小退避重试本步,不推进步骤 */
let res;
let busyTries = 0;
do {
res = await sendBurst(step.actions);
if (!seq.running) return;
if (res.busy && busyTries < 20) { busyTries++; await sleep(50); }
} while (res.busy && busyTries < 20);
if (res.busFault) { stopSeq("总线异常,序列已停止"); return; }
if (res.networkError) { stopSeq("网络错误,序列已停止"); return; }
if (!res.ok) { stopSeq("发送失败:" + (res.desc || "未知错误")); return; }
for (const a of step.actions) {
state.lastSentMode.set(a.node, parseInt(a.mode, 10) || 0);
state.lastSentTargets.set(a.node, {
mode: parseInt(a.mode, 10) || 0,
angle: parseFloat(a.angle) || 0,
velocity: parseFloat(a.velocity) || 0,
current: parseFloat(a.current) || 0,
voltage: parseFloat(a.voltage) || 0,
});
}
state.txCount++;
renderNodeList();
if (!seq.running) return;
const holdMs = clampNum(parseInt(step.holdMs, 10) || 0, 0, 600000);
if (holdMs > 0) await sleep(holdMs);
if (!seq.running) return;
if (seq.stepIdx + 1 >= seq.steps.length) {
if (seq.loops > 0 && seq.round >= seq.loops) {
stopSeq(`已完成 ${seq.loops} 轮`);
return;
}
seq.round++;
seq.stepIdx = 0;
} else {
seq.stepIdx++;
}
}
}
return {
seq, seqListEl, seqStartBtn, seqLoopsInput,
normStep, renderSeqList, startSeq, stopSeq, updateSeqUi,
};
}