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) => ` -
-
- 电机 ${j + 1} - - - -
- -
-
-
- - - - - - - -
-
`).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) => ` +
+
+ 电机 ${j + 1} + + + +
+ +
+
+
+ + + + + + + +
+
`).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 @@ - +