From aab526ffa870df5378a44f50b146e336b38d1acf Mon Sep 17 00:00:00 2001
From: cbb <2625274797@qq.com>
Date: Mon, 21 Sep 2026 03:10:47 +0800
Subject: [PATCH] =?UTF-8?q?refactor(motor):=20=E5=91=BD=E4=BB=A4=E5=BA=8F?=
=?UTF-8?q?=E5=88=97=E5=A4=96=E6=8F=90=20seq.js=EF=BC=88P1=20=E9=87=8D?=
=?UTF-8?q?=E5=81=9A=E7=AC=AC=202=20=E6=AD=A5=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
createSeqSys 注入依赖;main 保留 seq/renderSeqList/stopSeq 绑定。
STATUS offlineOverlay 契约未改。UI 验收通过(绿灯+数据+无报错)。
---
js/motor/main.js | 516 +---------------------------------------------
js/motor/seq.js | 524 +++++++++++++++++++++++++++++++++++++++++++++++
pages/motor.html | 2 +-
3 files changed, 536 insertions(+), 506 deletions(-)
create mode 100644 js/motor/seq.js
diff --git a/js/motor/main.js b/js/motor/main.js
index 6d46413..5cd221d 100644
--- a/js/motor/main.js
+++ b/js/motor/main.js
@@ -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]) =>
- ``).join("");
-}
-function seqChOptions(selected) {
- return [0, 1, 2].map((c) =>
- ``).join("");
-}
-function seqNodeOptions(selected) {
- return FIXED_MOTOR_NODES.map((n) =>
- ``).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('|');
- return acts.length > SEQ_SUMMARY_MAX
- ? `${html}+${acts.length - SEQ_SUMMARY_MAX} 台`
- : 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 = '
尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发
';
- } else {
- seqListEl.innerHTML = seq.steps.map((s, i) => `
-
-
-
-
-
-
${seqStepName(i)}
-
${s.actions.length} 电机
-
保持
-
-
ms
-
-
-
-
-
-
-
-
-
${seqStepSummaryHtml(s)}
-
- ${s.actions.map((a, j) => `
-
`).join("")}
-
-
`).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 = {
diff --git a/js/motor/seq.js b/js/motor/seq.js
new file mode 100644
index 0000000..407df44
--- /dev/null
+++ b/js/motor/seq.js
@@ -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]) =>
+ ``).join("");
+}
+function seqChOptions(selected) {
+ return [0, 1, 2].map((c) =>
+ ``).join("");
+}
+function seqNodeOptions(selected) {
+ return FIXED_MOTOR_NODES.map((n) =>
+ ``).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('|');
+ return acts.length > SEQ_SUMMARY_MAX
+ ? `${html}+${acts.length - SEQ_SUMMARY_MAX} 台`
+ : 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 = '尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发
';
+ } else {
+ seqListEl.innerHTML = seq.steps.map((s, i) => `
+
+
+
+
+
+
${seqStepName(i)}
+
${s.actions.length} 电机
+
保持
+
+
ms
+
+
+
+
+
+
+
+
+
${seqStepSummaryHtml(s)}
+
+ ${s.actions.map((a, j) => `
+
`).join("")}
+
+
`).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,
+ };
+}
diff --git a/pages/motor.html b/pages/motor.html
index 124e9c7..c91647d 100644
--- a/pages/motor.html
+++ b/pages/motor.html
@@ -430,7 +430,7 @@
-
+