fix(ui): 电机前端回滚到 P1 拆分前,恢复 STATUS 离线层契约
根因:拆分后 renderStatusPanel 操作 #statusOffline/#statusPanel, HTML 实际 id 是 offlineOverlay/statusLive,遮罩永不摘掉。 CSS/HTML 结构本就未重写;本提交恢复完整 main.js 与 dual-link 传输层。
This commit is contained in:
@@ -1,751 +0,0 @@
|
||||
/**
|
||||
* conn-poll.js — 电机页连接稳健性:硬失败计数 / 断线探测 / 三链轮询调度
|
||||
* 从 main.js 外提(探测 / 失败计数 / pollLoop Worker 节拍),逻辑行为不变。
|
||||
* createConnPoll(ctx) 注入 state/toast/deviceLink/onMotorState 等。
|
||||
* late.stopSeq / late.stopRepeat / late.sendMotorPeriod 由 main 装配后回填。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
export function createConnPoll(ctx) {
|
||||
const {
|
||||
state, $, toast, deviceLink, flashBusyNow, isUnthrottled,
|
||||
getSharedDeviceIp, setSharedDeviceIp,
|
||||
onMotorState, onFeedback, updateTickStuckUi,
|
||||
setQuietMode, late,
|
||||
} = ctx;
|
||||
|
||||
const FAIL_THRESHOLD = 5;
|
||||
const FAIL_THRESHOLD_ECAT = 12;
|
||||
function failThresholdFor(tpId) {
|
||||
return tpId === "ecat" ? FAIL_THRESHOLD_ECAT : FAIL_THRESHOLD;
|
||||
}
|
||||
const CONN_TOAST_GAP_MS = 6000;
|
||||
const RECOVER_TOAST_MIN_OFFLINE_MS = 8000;
|
||||
const TICK_INTERVAL_MS = 250;
|
||||
const TICK_STUCK_WARN = 2;
|
||||
const PROBE_INTERVAL_MS = 2000;
|
||||
let tickNextAt = 0;
|
||||
let tickWorker = null;
|
||||
let tickUseFallback = false;
|
||||
const TICK_WORKER_URL = new URL("./tick.worker.js?v=20260802g", import.meta.url);
|
||||
|
||||
const stopRepeat = (...a) => late.stopRepeat(...a);
|
||||
const stopSeq = (...a) => late.stopSeq(...a);
|
||||
const sendMotorPeriod = (...a) => late.sendMotorPeriod(...a);
|
||||
|
||||
function connToast(msg, type) {
|
||||
const now = Date.now();
|
||||
if (now - state.lastConnToastAt < CONN_TOAST_GAP_MS) return;
|
||||
state.lastConnToastAt = now;
|
||||
toast(msg, type);
|
||||
}
|
||||
|
||||
/** 500/503 busy、超时:设备忙,不是链路掉线 */
|
||||
function isTransientError(e) {
|
||||
if (!e) return false;
|
||||
if (e.transient === true) return true;
|
||||
const st = Number(e.status);
|
||||
if (st === 500 || st === 503 || st === 429) return true;
|
||||
const name = e.name || "";
|
||||
if (name === "TimeoutError" || name === "AbortError") return true;
|
||||
const msg = String(e.message || e);
|
||||
if (/timed?\s*out|aborted|AbortError|signal is aborted/i.test(msg)) return true;
|
||||
if (/^HTTP\s+(500|503|429)\b/.test(msg)) return true;
|
||||
/* 网络毛刺:高刷 HTTP 下偶发 Failed to fetch / 连接被重置很常见,
|
||||
* 算硬失败会在几拍内把整页打成「设备未连接」。真拔线会持续超时/拒连,
|
||||
* 由 failCount 在更高阈值上兜住(见 noteHardFailure 的网络类加权)。 */
|
||||
if (/failed to fetch|networkerror|load failed|err_connection|econnreset|econnrefused|socket hang up|net::/i.test(msg)) {
|
||||
e.transient = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 GET JSON(设备直连)。失败抛 Error,附带 status / transient。
|
||||
*
|
||||
* 常规数据链(状态/反馈/发送/版本)已全部走 state.tp 传输层,
|
||||
* 这里保留给 OTA 那条 WiFi 专属链路复用同一套错误语义。
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function fetchDeviceJson(path, timeoutMs) {
|
||||
let resp;
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
resp = await fetch(String(state.ip && state.ip.length ? ('http://' + state.ip) : 'http://') + path, {
|
||||
signal: AbortSignal.timeout(timeoutMs || 3000),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
err.transient = isTransientError(err);
|
||||
recordSlowReq(path, performance.now() - t0, err.message);
|
||||
throw err;
|
||||
}
|
||||
recordSlowReq(path, performance.now() - t0);
|
||||
if (!resp.ok) {
|
||||
const err = new Error("HTTP " + resp.status);
|
||||
err.status = resp.status;
|
||||
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/* 慢请求统计:>200ms 记入 phaseDebug.slowStats[path] = {count, maxMs, lastMs, lastAt}。
|
||||
* 用于诊断"卡在哪个 endpoint"——不是看单次 lastSlowReq,而是看各 path 的卡顿分布。
|
||||
* 控制台 phaseDebug.slowStats 直接看哪个 path 累计最多。 */
|
||||
function recordSlowReq(path, durMs, errMsg) {
|
||||
if (durMs <= 200) return;
|
||||
const s = state.phaseDebug.slowStats;
|
||||
const key = path.split("?")[0]; /* /motor/state?bin=1 → /motor/state */
|
||||
if (!s[key]) s[key] = { count: 0, maxMs: 0, lastMs: 0, lastAt: 0, lastErr: null };
|
||||
s[key].count++;
|
||||
s[key].maxMs = Math.max(s[key].maxMs, Math.round(durMs));
|
||||
s[key].lastMs = Math.round(durMs);
|
||||
s[key].lastAt = Date.now();
|
||||
if (errMsg) s[key].lastErr = errMsg;
|
||||
state.phaseDebug.lastSlowReq = {
|
||||
path, dur: Math.round(durMs), at: Date.now(), err: errMsg
|
||||
};
|
||||
}
|
||||
|
||||
/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */
|
||||
function noteHardSuccess() { state.failCount = 0; }
|
||||
|
||||
function noteHardFailure(e, source) {
|
||||
if (e && e.transient) return; /* 500/503/超时/网络毛刺等瞬时错误不计入 */
|
||||
state.failCount++;
|
||||
/* ECAT:status 1Hz 健康读失败不该和 motor 热路径同等累计 ——
|
||||
* 从站 AL 抖一下就断电机页会误停周期发送/命令序列。 */
|
||||
if (state.tpId === "ecat" && source === "status" && state.connected) {
|
||||
if (state.failCount < failThresholdFor("ecat") * 2) return;
|
||||
}
|
||||
const thr = failThresholdFor(state.tpId);
|
||||
if (state.failCount >= thr && state.connected) {
|
||||
try {
|
||||
state.phaseDebug.lastHardFail = {
|
||||
source, msg: e && e.message, at: Date.now(), failCount: state.failCount, thr,
|
||||
};
|
||||
} catch (_) {}
|
||||
enterDisconnected(e && e.message, source);
|
||||
}
|
||||
}
|
||||
|
||||
function enterDisconnected(msg, source) {
|
||||
if (state.probing) return;
|
||||
const wasConnected = state.connected;
|
||||
state.connected = false;
|
||||
state.connecting = false;
|
||||
state.probing = true;
|
||||
state.failCount = 0;
|
||||
state.offlineSince = Date.now();
|
||||
state.bootActive = false;
|
||||
state.slaveAl = null;
|
||||
state.slaveOp = false;
|
||||
stopRepeat(true);
|
||||
stopSeq("连接中断,命令序列已停止", true);
|
||||
stopDataPolling();
|
||||
updateConnUi();
|
||||
$("#netBanner").classList.add("show");
|
||||
$("#netBannerText").textContent =
|
||||
"连接中断" + (source ? " (" + source + ")" : "") +
|
||||
" — 使用主页共享 IP (" + state.ip + ") 自动重连中…";
|
||||
if (wasConnected) {
|
||||
connToast("网络异常:" + (msg || "请求失败") + " — 已停止周期发送,自动重连中", "err");
|
||||
}
|
||||
startProbe();
|
||||
}
|
||||
|
||||
function startProbe() {
|
||||
stopTimer("probe");
|
||||
state.probing = true;
|
||||
if (!state.offlineSince) state.offlineSince = Date.now();
|
||||
scheduleProbe(0);
|
||||
}
|
||||
function stopProbe() {
|
||||
stopTimer("probe");
|
||||
state.probing = false;
|
||||
}
|
||||
function scheduleProbe(delayMs) {
|
||||
stopTimer("probe");
|
||||
state.poll.probe = setTimeout(function () {
|
||||
state.poll.probe = null;
|
||||
probeOnce();
|
||||
}, delayMs == null ? PROBE_INTERVAL_MS : delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把冗长的链路错误压成一句人能看的话。
|
||||
*
|
||||
* 为什么需要:EtherCAT 后端会把整串诊断塞进 log[],SSC 邮箱楔死那条会带
|
||||
* **64 个一模一样的 MailboxError** 加一段 SM 寄存器现场,原样显示等于没显示。
|
||||
* 这里做两件事:把重复项折叠成"×N",并对已知故障给出**该怎么办**
|
||||
* —— 楔死是软件恢复不了的(ecat_native.c 里同样注明要断电复位 MCU)。
|
||||
*/
|
||||
function summarizeLinkError(msg) {
|
||||
let s = String(msg || "未知错误");
|
||||
/* 折叠重复的 MailboxError(…),只留计数 */
|
||||
const mb = s.match(/MailboxError\([^)]*\)/g);
|
||||
if (mb && mb.length > 2) {
|
||||
s = s.replace(/\[?MailboxError\([^)]*\)(,\s*MailboxError\([^)]*\))*\]?/,
|
||||
`MailboxError ×${mb.length}`);
|
||||
}
|
||||
/* 已知故障 → 给可执行的处置建议 */
|
||||
if (/邮箱地址错位楔死|ConfigMapError/.test(s)) {
|
||||
return "SSC 邮箱楔死(config_map 失败)—— 需复位 DCU 板 MCU 才能恢复(软件无法自愈)";
|
||||
}
|
||||
if (/没有找到描述包含/.test(s)) {
|
||||
return "找不到匹配的网卡 —— 请在工具栏「链路」旁的输入框改网卡匹配子串";
|
||||
}
|
||||
if (/没有发现从站/.test(s)) {
|
||||
return "总线上没有从站 —— 检查 DCU 板供电与网线是否接在该网口";
|
||||
}
|
||||
/* 环境类错误原样给,它本身就是指引。token 对齐 native 代理 autostart 的实际文案:
|
||||
* "找不到...守护进程" / "请先运行 build_native.bat" / "Npcap 未安装或 8090..8095 全被占"。 */
|
||||
if (/Npcap|build_native|守护进程|全被占/.test(s)) return s;
|
||||
/* 兜底:截断,完整内容留在 title 里 */
|
||||
return s.length > 140 ? s.slice(0, 140) + "…" : s;
|
||||
}
|
||||
|
||||
/** 探测成功收尾:置在线、清横幅、开轮询。代际不符时丢弃(防晚到结果覆盖)。 */
|
||||
function finishProbeOk(gen, data) {
|
||||
if (gen !== state.probeGen) return;
|
||||
const offlineMs = state.offlineSince ? (Date.now() - state.offlineSince) : 0;
|
||||
stopProbe();
|
||||
state.connected = true;
|
||||
state.connecting = false;
|
||||
state.offlineSince = 0;
|
||||
state.failCount = 0;
|
||||
state.probeErr = null;
|
||||
state.probeErrFull = null;
|
||||
$("#netBanner").classList.remove("show");
|
||||
applySystemStatus(data);
|
||||
updateConnUi();
|
||||
/* 短离线恢复不弹 toast,避免毛刺「闪一下」 */
|
||||
if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) {
|
||||
connToast("连接已恢复 — " + transportTargetLabel(), "ok");
|
||||
}
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function probeOnce() {
|
||||
if (!state.probing) return;
|
||||
const gen = ++state.probeGen;
|
||||
state.ip = getSharedDeviceIp();
|
||||
const label = $("#ipLabel");
|
||||
if (label) label.textContent = transportTargetLabel();
|
||||
try {
|
||||
const data = await state.tp.probe();
|
||||
if (gen !== state.probeGen || !state.probing) return; /* 已有更新的探测在跑 */
|
||||
finishProbeOk(gen, data);
|
||||
} catch (e) {
|
||||
if (gen !== state.probeGen) return; /* 晚到的失败:别把后来的成功打成横幅 */
|
||||
/* 把失败原因显示出来。
|
||||
* 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP),
|
||||
* 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作**
|
||||
* (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / exe 没编译或 Npcap 未装),
|
||||
* 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */
|
||||
const raw = (e && e.message) ? String(e.message) : "未知错误";
|
||||
/* EtherCAT:motor/start 超时 ≠ 总线没起来。
|
||||
* 外壳 Owner / 上一轮 start 可能已把 PDO 循环跑着,probe 里那记
|
||||
* motor/start 因 loop_stop+重进 OP 拖过 30s 才报 signal timed out。
|
||||
* 此时用只读健康位复核:在跑就当连上,别把「实际已连」标成失败。 */
|
||||
if (state.tpId === "ecat" && state.tp.healthCheck &&
|
||||
/timed?\s*out|signal|超时/i.test(raw)) {
|
||||
try {
|
||||
const data = await state.tp.healthCheck();
|
||||
if (gen === state.probeGen && state.probing) {
|
||||
finishProbeOk(gen, data);
|
||||
return;
|
||||
}
|
||||
return; /* 代际已变:结果交给新一轮 */
|
||||
} catch (_) { /* 真没起来 → 走下面的失败展示 */ }
|
||||
}
|
||||
if (gen !== state.probeGen || !state.probing) return;
|
||||
state.probeErrFull = raw;
|
||||
state.probeErr = summarizeLinkError(raw);
|
||||
const bt = $("#netBannerText");
|
||||
if (bt) {
|
||||
bt.textContent = (state.tpId === "ecat" ? "EtherCAT 接入失败: " : "连接失败: ")
|
||||
+ state.probeErr + "(每 2 秒自动重试)";
|
||||
bt.title = raw; /* 完整诊断留在悬浮提示里 */
|
||||
}
|
||||
$("#netBanner").classList.add("show");
|
||||
updateConnUi(); /* 同步 #devStatus,别让它一直写"探测中…" */
|
||||
if (state.probing) scheduleProbe(PROBE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 连接目标的显示文本:WiFi 是设备 IP,EtherCAT 是本地服务 + 从站。 */
|
||||
function transportTargetLabel() {
|
||||
return (state.tpId === "ecat") ? "EtherCAT 从站" : ("IP " + state.ip);
|
||||
}
|
||||
|
||||
/** 当前通道上在线的电机数(1–9)。 */
|
||||
function onlineMotorCount() {
|
||||
return (state.motors || []).filter(function (m) {
|
||||
return m && m.online && m.node >= 1 && m.node <= 9;
|
||||
}).length;
|
||||
}
|
||||
|
||||
const AL_NAMES = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
|
||||
|
||||
/**
|
||||
* 目标是否在线 —— 工具栏指示灯的唯一判据。
|
||||
*
|
||||
* 灯的语义保持不变:就是「是否在线」。
|
||||
* - WiFi:ESP32 HTTP 可达即在线(标签是设备 IP)。
|
||||
* - EtherCAT:标签是「EtherCAT 从站」,在线 = 从站 AL 处于 OP。
|
||||
* 卡在 PREOP / INIT / SAFEOP 时,即使主站服务+网卡+PDO 循环在跑、
|
||||
* 读得到 AL、界面还有残留槽位,灯也不绿。
|
||||
*/
|
||||
function isTargetOnline() {
|
||||
if (!state.connected) return false;
|
||||
if (state.tpId !== "ecat") return true;
|
||||
/* AL 已读到:只有 OP 算在线。PREOP 等一律不绿。 */
|
||||
if (state.slaveAl != null) return state.slaveAl === 8;
|
||||
/* AL 还没读到:仅当 native health 明确报 op 时才绿 */
|
||||
return state.slaveOp === true;
|
||||
}
|
||||
|
||||
/** 状态栏追加的在线状态提示(灯黄时把原因写出来)。 */
|
||||
function onlineStatusParts(data) {
|
||||
const parts = [];
|
||||
if (state.tpId === "ecat") {
|
||||
const al = state.slaveAl;
|
||||
if (al != null && al !== 8) {
|
||||
parts.push("从站 " + (AL_NAMES[al] || ("0x" + al.toString(16))) + " · 未在线");
|
||||
}
|
||||
}
|
||||
if (state.connected && !isTargetOnline()) {
|
||||
if (!parts.length) parts.push("未在线");
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动连接当前链路(本页无连接按钮)。
|
||||
* WiFi 用主页共享 IP,EtherCAT 打本地 EtherCAT 服务。
|
||||
* 失败不阻断页面,进入探测直到链路可用。
|
||||
*/
|
||||
function connectShared() {
|
||||
state.ip = getSharedDeviceIp();
|
||||
state.connecting = true;
|
||||
state.probing = false;
|
||||
state.failCount = 0;
|
||||
state.probeErr = null; /* 新一轮连接:清掉上次的失败原因 */
|
||||
state.probeErrFull = null;
|
||||
state.offlineSince = Date.now();
|
||||
state.bootActive = false;
|
||||
state.slaveAl = null;
|
||||
state.slaveOp = false;
|
||||
/* 新一轮探测:递增代际,作废在途的旧 probeOnce(其晚到失败不得再写横幅) */
|
||||
state.probeGen += 1;
|
||||
state.ch = parseInt($("#chSelect").value, 10) || 0;
|
||||
updateConnUi();
|
||||
$("#netBanner").classList.add("show");
|
||||
$("#netBannerText").textContent = (state.tpId === "ecat")
|
||||
? "正在自动接入 EtherCAT … 按需拉起本地服务 → 打开网卡 → 进 OP → 启动 PDO 循环"
|
||||
: ("自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP");
|
||||
startProbe();
|
||||
}
|
||||
|
||||
/** 传输回报的状态 → #devStatus。字段差异由各传输的 describeStatus 消化。 */
|
||||
function applySystemStatus(data) {
|
||||
const ip = state.tp.reportedIp(data);
|
||||
if (ip) setSharedDeviceIp(ip);
|
||||
/* 在线判定用的从站状态:AL 优先,native health 的 op 作补充 */
|
||||
if (data && data.slave && data.slave.state != null) {
|
||||
state.slaveAl = Number(data.slave.state) & 0x0F;
|
||||
state.slaveOp = state.slaveAl === 8;
|
||||
} else if (data && data.__op != null) {
|
||||
state.slaveOp = !!data.__op;
|
||||
}
|
||||
const parts = (state.tp.describeStatus(data) || []).concat(onlineStatusParts(data));
|
||||
const el = $("#devStatus");
|
||||
if (el) el.textContent = parts.length ? parts.join(" · ") : "在线";
|
||||
updateConnUi();
|
||||
}
|
||||
|
||||
/* 无设备静默态:常驻动画在 160Hz 屏上每帧重算+重绘,实测未接设备时
|
||||
renderer+GPU 各占约一核的七成。规则见 css/responsive.css 的
|
||||
html[data-dcu-calm="1"] 段;本页不 import connection.js,故就地写同一标记。 */
|
||||
function setQuietMode(quiet) {
|
||||
const v = quiet ? "1" : "0";
|
||||
if (document.documentElement.dataset.dcuCalm !== v) {
|
||||
document.documentElement.dataset.dcuCalm = v;
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnUi() {
|
||||
const led = $("#connLed");
|
||||
const ipLabel = $("#ipLabel");
|
||||
setQuietMode(!state.connected);
|
||||
if (ipLabel) ipLabel.textContent = transportTargetLabel();
|
||||
/* 指示灯 = 是否在线(语义不变)。
|
||||
* 绿 = 在线(isTargetOnline)
|
||||
* 黄 = 主站/链路可达,但目标未在线(从站没进 OP / 收不到帧)
|
||||
* 黄闪 = 探测中
|
||||
* 红 = 未连接
|
||||
* 以前 connected 就涂绿:EtherCAT 只要服务+网卡+PDO 循环在就算在线,
|
||||
* 即使从站卡 PREOP、收不到任何 STATUS 也绿着 —— 灯起不到指示作用。 */
|
||||
if (state.connecting || state.probing) {
|
||||
if (led) {
|
||||
led.className = "led led-warn led-pulse";
|
||||
led.title = "正在探测链路…";
|
||||
}
|
||||
if (!state.connected) {
|
||||
const ds = $("#devStatus");
|
||||
/* 有失败原因就显示原因摘要,别一直"探测中…"(那等于什么都没说)。
|
||||
* 摘要截断:SSC 邮箱楔死那类错误会带 64 个 MailboxError + SM 现场,
|
||||
* 全塞进工具栏会把布局挤爆,完整内容留在横幅和 title 里。 */
|
||||
if (ds) {
|
||||
if (state.probeErr) {
|
||||
const brief = state.probeErr.length > 44
|
||||
? state.probeErr.slice(0, 44) + "…" : state.probeErr;
|
||||
ds.textContent = brief;
|
||||
ds.title = state.probeErrFull || state.probeErr;
|
||||
} else {
|
||||
ds.textContent = "探测中…";
|
||||
ds.title = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (state.connected) {
|
||||
if (led) {
|
||||
if (isTargetOnline()) {
|
||||
led.className = "led led-on";
|
||||
led.title = state.tpId === "ecat"
|
||||
? "EtherCAT 从站在线(AL OP)"
|
||||
: ("设备在线 · " + (state.ip || ""));
|
||||
} else {
|
||||
led.className = "led led-warn";
|
||||
const alTxt = state.slaveAl != null
|
||||
? ("AL " + (AL_NAMES[state.slaveAl] || state.slaveAl)) : "AL 未知";
|
||||
led.title = state.tpId === "ecat"
|
||||
? ("EtherCAT 从站未在线(" + alTxt + ")— 卡在 PREOP/非 OP 时不亮绿灯;查供电/网线/从站状态")
|
||||
: "链路可达但设备未就绪";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (led) {
|
||||
led.className = "led led-err";
|
||||
led.title = "未连接";
|
||||
}
|
||||
const ds = $("#devStatus");
|
||||
if (ds) ds.textContent = "未连接";
|
||||
}
|
||||
const sendHint = $("#sendHint");
|
||||
if (sendHint) sendHint.textContent = state.tp.sendHint(state.ch);
|
||||
const sbCh = $("#sbCh");
|
||||
if (sbCh) sbCh.textContent = "CH" + state.ch;
|
||||
}
|
||||
|
||||
/* ═══════════════════════ 轮询调度(单飞递归,永不并发) ═══════════════════════ */
|
||||
function stopTimer(key) {
|
||||
if (state.poll[key]) {
|
||||
clearTimeout(state.poll[key]);
|
||||
clearInterval(state.poll[key]);
|
||||
state.poll[key] = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 双链路轮询:motor 和 feedback 各自独立定时器,互不阻塞。
|
||||
*
|
||||
* 历史问题:原来 motor → feedback 串行,任一挂起(ESP32 端 mutex 阻塞 /
|
||||
* WiFi 抖动)会拖累另一个,表现为数据卡顿。
|
||||
* 现在:motor 和 feedback 各走独立固定节拍定时器,
|
||||
* - motor 挂起 3s 超时期间,feedback 仍正常刷
|
||||
* - feedback 挂起不影响 motor
|
||||
* 两者共用 ESP32 端 httpJsonWorkMutex,ESP32 串行处理即可。
|
||||
*/
|
||||
function stopDataPolling() {
|
||||
state.pollGen += 1;
|
||||
stopTimer("tick");
|
||||
stopTimer("feedback");
|
||||
stopTimer("status");
|
||||
state.tickInFlight = false;
|
||||
state.feedbackInFlight = false;
|
||||
state.tickStuck = 0; /* 停止轮询 → 清除卡顿标记 */
|
||||
/* 清理挂起的版本等待:主 tick 已停,resolve(null) 让调用方走超时分支,避免 Promise 悬挂 */
|
||||
if (state.versionWait) {
|
||||
const w = state.versionWait;
|
||||
state.versionWait = null;
|
||||
w.resolve(null);
|
||||
}
|
||||
updateTickStuckUi();
|
||||
/* 停止 Worker 节拍(后台节流兜底) */
|
||||
if (tickWorker) {
|
||||
tickWorker.postMessage({ type: "stop" });
|
||||
try { tickWorker.terminate(); } catch (e) { /* ignore */ }
|
||||
tickWorker = null;
|
||||
tickUseFallback = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动数据链路:HTTP 三链(motor tick / feedback 1Hz / status 1Hz)立即开车。
|
||||
*/
|
||||
function startPolling() {
|
||||
stopDataPolling(); /* 递增 pollGen,清残留定时器/在途链 */
|
||||
state.failCount = 0;
|
||||
state.tickN = 0;
|
||||
state.statusTickN = 0;
|
||||
state.recvTs = []; /* 清除旧的到达时间窗口,Hz 从新数据重新累积 */
|
||||
tickNextAt = 0; /* 节拍基准重置:首个 tick 立即执行后按新间隔对齐 */
|
||||
const gen = state.pollGen;
|
||||
schedulePollLoop(gen, 0);
|
||||
scheduleFeedbackPoll(gen, 0);
|
||||
scheduleStatusPoll(gen, 1000);
|
||||
/* 连上后把持久化的刷新速度同步给 GD32,使 0xC5 推送周期与本地轮询一致(无需用户重新拨动) */
|
||||
sendMotorPeriod(state.refreshIntervalMs || 250);
|
||||
}
|
||||
|
||||
function scheduleStatusPoll(gen, delayMs) {
|
||||
if (gen !== state.pollGen) return;
|
||||
stopTimer("status");
|
||||
state.poll.status = setTimeout(function () {
|
||||
state.poll.status = null;
|
||||
if (gen !== state.pollGen || !state.connected || state.probing) return;
|
||||
/* lite/full 交替:4 次 lite 后 1 次 full,循环。
|
||||
* lite ~150B 不调重活;full ~3KB 含 OTA/heap/SPI,5s 一次。 */
|
||||
const wantFull = (state.statusTickN % 5) === 0;
|
||||
state.statusTickN = (state.statusTickN || 0) + 1;
|
||||
fetchSystemStatus(gen, wantFull).finally(() => {
|
||||
if (gen === state.pollGen && state.connected && !state.probing) {
|
||||
scheduleStatusPoll(gen, 1000);
|
||||
}
|
||||
});
|
||||
}, delayMs == null ? 1000 : delayMs);
|
||||
}
|
||||
/* feedback 独立定时器:固定 1000ms(与 GD32 0xC1 CAN 反馈 1Hz 推送对齐)。
|
||||
* feedback 是 CAN 错误计数/总线状态快照,GD32 1Hz 推送,前端 1Hz 拉即可,
|
||||
* 加快只会拿到陈旧数据还跟 motor 抢 ESP32 端 mutex。
|
||||
* 与 motor 解耦后,motor 挂起不拖 feedback,feedback 挂起不拖 motor。 */
|
||||
function scheduleFeedbackPoll(gen, delayMs) {
|
||||
if (gen !== state.pollGen) return;
|
||||
stopTimer("feedback");
|
||||
state.poll.feedback = setTimeout(function () {
|
||||
state.poll.feedback = null;
|
||||
if (gen !== state.pollGen || !state.connected || state.probing) return;
|
||||
if (state.feedbackInFlight) {
|
||||
scheduleFeedbackPoll(gen, 1000);
|
||||
return;
|
||||
}
|
||||
state.feedbackInFlight = true;
|
||||
fetchFeedback(gen).finally(() => {
|
||||
state.feedbackInFlight = false;
|
||||
if (gen === state.pollGen && state.connected && !state.probing) {
|
||||
scheduleFeedbackPoll(gen, 1000);
|
||||
}
|
||||
});
|
||||
}, delayMs == null ? 1000 : delayMs);
|
||||
}
|
||||
/* ═══════ Web Worker 节拍(后台标签页不节流) ═══════
|
||||
* 浏览器对后台(隐藏)标签页的 setTimeout/setInterval 强制 ≥1s 节流,
|
||||
* 使刷新速度在切后台后降到 ~1Hz。Dedicated Worker 的定时器不受此限制,
|
||||
* 故把 motor 轮询的节拍源放到 tick.worker.js:Worker 按设定间隔定期发
|
||||
* 'tick' 消息,主线程收到后才真正发起一次 fetchMotorState。
|
||||
* 前台行为与原来一致;后台也能守住设定的刷新速度。 */
|
||||
/* 用 import.meta.url 解析: 本模块在 js/motor/ 下, 与引用它的页面在哪层目录无关
|
||||
* (页面已移入 pages/, 文档相对路径会解析成 /pages/js/... 而 404)。 */
|
||||
const TICK_WORKER_URL = new URL("./tick.worker.js?v=20260802g", import.meta.url);
|
||||
let tickWorker = null; /* Web Worker 节拍源(null = 未创建/失败) */
|
||||
let tickUseFallback = false; /* Worker 创建/加载失败 → 回退到 setTimeout 兜底 */
|
||||
|
||||
/**
|
||||
* tick 统一入口:Worker 每 tick、或 setTimeout 兜底触达时调用。
|
||||
* 负责单飞互斥 + 卡顿标记(是否跳过由 tickInFlight 决定)。
|
||||
*/
|
||||
function tickHandler() {
|
||||
if (!state.connected || state.probing) return;
|
||||
if (state.tickInFlight) {
|
||||
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
|
||||
* 「不限速」档(如 ECAT 的 6ms 下限档)下这是**预期行为**:设定间隔贴着
|
||||
* 单次往返耗时(均值 ≈1.8ms,但尾部会越过 6ms),节拍源撞上在途请求是常态。
|
||||
* 此时不计卡顿,否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",
|
||||
* 而用户要的就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
|
||||
if (!isUnthrottled(state.refreshIntervalMs)) {
|
||||
state.tickStuck = (state.tickStuck || 0) + 1;
|
||||
updateTickStuckUi();
|
||||
}
|
||||
return;
|
||||
}
|
||||
pollLoop(state.pollGen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 Worker 就绪并按 interval 校时。Worker 已存在时只更新间隔。
|
||||
* Worker 加载失败(如 404 或不被服务端支持)→ 回退到 setTimeout 兜底。
|
||||
*/
|
||||
function ensureTickWorker(interval) {
|
||||
if (tickUseFallback) return;
|
||||
if (tickWorker) {
|
||||
tickWorker.postMessage({ type: "update", interval });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
tickWorker = new Worker(TICK_WORKER_URL);
|
||||
} catch (err) {
|
||||
tickWorker = null;
|
||||
tickUseFallback = true;
|
||||
startTickFallback(state.pollGen);
|
||||
return;
|
||||
}
|
||||
tickWorker.onmessage = (e) => { if (e.data === "tick") tickHandler(); };
|
||||
tickWorker.onerror = () => {
|
||||
try { tickWorker.terminate(); } catch (e) { /* ignore */ }
|
||||
tickWorker = null;
|
||||
tickUseFallback = true;
|
||||
startTickFallback(state.pollGen);
|
||||
};
|
||||
tickWorker.postMessage({ type: "start", interval });
|
||||
}
|
||||
|
||||
/* Worker 不可用时的兜底:留用原 setTimeout 固定节拍(前台正常,后台受节流)。 */
|
||||
function startTickFallback(gen) {
|
||||
if (gen !== state.pollGen) return;
|
||||
stopTimer("tick");
|
||||
tickNextAt = 0;
|
||||
scheduleTickFallbackFrame(gen, 0);
|
||||
}
|
||||
function scheduleTickFallbackFrame(gen, delayMs) {
|
||||
if (gen !== state.pollGen) return;
|
||||
stopTimer("tick");
|
||||
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
|
||||
let delay = delayMs != null ? delayMs : iv;
|
||||
if (delayMs == null && tickNextAt > 0) {
|
||||
/* 固定节拍对齐:按绝对时间调度,请求耗时不再拖慢周期 */
|
||||
const remain = tickNextAt - Date.now();
|
||||
if (remain <= 0) delay = (tickNextAt + iv < Date.now()) ? iv : 10;
|
||||
else delay = remain;
|
||||
}
|
||||
state.poll.tick = setTimeout(function () {
|
||||
state.poll.tick = null;
|
||||
if (gen !== state.pollGen) return;
|
||||
tickHandler();
|
||||
tickNextAt = (tickNextAt > 0 ? tickNextAt : Date.now()) + (state.refreshIntervalMs || TICK_INTERVAL_MS);
|
||||
scheduleTickFallbackFrame(gen, null);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* motor 轮询主链:只跑 fetchMotorState,feedback/status 已剥离独立定时器。
|
||||
* 节拍由 Web Worker(或兜底 setTimeout)驱动;pollLoop 自身不重排,
|
||||
* 单飞互斥和卡顿标记在 tickHandler 侧完成。失败/断线由上层停止调度。
|
||||
*/
|
||||
async function pollLoop(gen) {
|
||||
if (gen !== state.pollGen) return;
|
||||
state.tickInFlight = true;
|
||||
try {
|
||||
await fetchMotorState(gen);
|
||||
if (gen !== state.pollGen) return;
|
||||
state.tickN += 1;
|
||||
} finally {
|
||||
state.tickInFlight = false;
|
||||
if (state.tickStuck) { state.tickStuck = 0; updateTickStuckUi(); } /* 请求结束 → 卡顿解除 */
|
||||
}
|
||||
/* R5:版本等待已下沉 transport.waitVersion(各自独立轮询/采样),
|
||||
* 主 tick 不再挂 state.versionWait 钩子。 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度 motor 节拍。delayMs === 0 表示立即触发首拍并启动 Worker;
|
||||
* 之后每拍由 Worker 自然驱动,不再从这里重排。
|
||||
*/
|
||||
function schedulePollLoop(gen, delayMs) {
|
||||
if (gen !== state.pollGen) return;
|
||||
if (tickUseFallback) {
|
||||
if (delayMs === 0) startTickFallback(gen);
|
||||
return;
|
||||
}
|
||||
ensureTickWorker(state.refreshIntervalMs || TICK_INTERVAL_MS);
|
||||
if (delayMs === 0) setTimeout(tickHandler, 0); /* 立即首拍,后续靠 Worker tick */
|
||||
}
|
||||
|
||||
async function fetchSystemStatus(gen, wantFull) {
|
||||
if (gen !== state.pollGen || !state.connected || state.probing) return;
|
||||
try {
|
||||
/* WiFi:wantFull=false 走 ?lite=1(~150B),不调 ESP32 端
|
||||
* heap_caps_get_info / 任务栈遍历 / OTA snapshot,避免 httpd 单线程被
|
||||
* 重 handler 阻塞几十~上百 ms;wantFull=true 5s 一次,含 OTA/heap/SPI。
|
||||
* EtherCAT:读从站身份/AL 状态,无 lite/full 之分。 */
|
||||
const data = await state.tp.fetchStatus(wantFull);
|
||||
if (gen !== state.pollGen) return;
|
||||
applySystemStatus(data);
|
||||
noteHardSuccess();
|
||||
} catch (e) {
|
||||
if (gen !== state.pollGen) return;
|
||||
noteHardFailure(e, "status");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMotorState(gen) {
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
if (!state.connected || state.probing) return false;
|
||||
try {
|
||||
/* WiFi 走二进制 /motor/state?bin=1(~408B vs JSON ~1200B,省 66%);
|
||||
* EtherCAT 读后台 PDO 循环的快照。两者都归一成同一份 motors[] 契约,
|
||||
* 所以 onMotorState 及其下游全部不用区分链路。 */
|
||||
const data = await state.tp.fetchMotorState();
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
onMotorState(data);
|
||||
noteHardSuccess();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
noteHardFailure(e, "motor");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFeedback(gen) {
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
if (!state.connected || state.probing) return false;
|
||||
/* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,CAN 反馈注定拿不到
|
||||
* (WiFi 链上每次 0xC1 都被 ESP32 链路层拒并打日志)。跨 iframe 标记
|
||||
* 见 common/utils.js flashBusyNow,随刷写收尾自撤,下轮自动恢复。 */
|
||||
if (flashBusyNow()) return false;
|
||||
try {
|
||||
/* WiFi:/can/feedback?lite=1 —— 跳过 entries 数组(~3KB → ~200B),
|
||||
* 本页只用 ch/stats 字段,不需要 CAN 帧快照(那是监控页用的)。
|
||||
* EtherCAT:从最近一次过程数据快照的 bridge 段取,不额外打一次后端。 */
|
||||
const data = await state.tp.fetchFeedback();
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
onFeedback(data);
|
||||
noteHardSuccess();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
noteHardFailure(e, "feedback");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
failThresholdFor,
|
||||
isTransientError,
|
||||
recordSlowReq,
|
||||
connToast,
|
||||
noteHardSuccess,
|
||||
noteHardFailure,
|
||||
enterDisconnected,
|
||||
startProbe,
|
||||
stopProbe,
|
||||
probeOnce,
|
||||
connectShared,
|
||||
applySystemStatus,
|
||||
updateConnUi,
|
||||
isTargetOnline,
|
||||
transportTargetLabel,
|
||||
stopTimer,
|
||||
stopDataPolling,
|
||||
startPolling,
|
||||
schedulePollLoop,
|
||||
pollLoop,
|
||||
setTickNextAt(v) { tickNextAt = v; },
|
||||
getTickWorker: () => tickWorker,
|
||||
getTickUseFallback: () => tickUseFallback,
|
||||
};
|
||||
}
|
||||
+3282
-529
File diff suppressed because it is too large
Load Diff
-669
@@ -1,669 +0,0 @@
|
||||
/**
|
||||
* ota.js — 电机 OTA(relay 模式:上传 → BOOT → GD32 转发 → 轮询/看门狗)
|
||||
* 从 main.js 外提(约 L3559–4211 的 OTA + IDB 持久化),逻辑行为不变。
|
||||
* createOtaSys(ctx) 注入 sendOneFrame/sendBatch/state/toast 等。
|
||||
* 版本等待统一走 state.tp.waitVersion(R5),不再依赖 main 内双实现。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
export function createOtaSys(ctx) {
|
||||
const {
|
||||
state, $, toast, showModal, escapeHtml, sleep,
|
||||
canId, hexId, nodeHex, bytesToHexStr,
|
||||
sendBatch, sendOneFrameWhenFree, getBaseUrl,
|
||||
ID_GET_VERSION_REQ, parseVersionPayload, setVer, saveUiState,
|
||||
late,
|
||||
} = ctx;
|
||||
|
||||
const stopRepeat = (...a) => late.stopRepeat(...a);
|
||||
const stopSeq = (...a) => late.stopSeq(...a);
|
||||
const stopDataPolling = (...a) => late.stopDataPolling(...a);
|
||||
const startPolling = (...a) => late.startPolling(...a);
|
||||
/** R5:版本等待只经 transport 契约 */
|
||||
async function waitMotorVersion(node, opts) {
|
||||
if (!state.tp || typeof state.tp.waitVersion !== "function") return null;
|
||||
return state.tp.waitVersion(node, opts || {});
|
||||
}
|
||||
|
||||
/* ═══════════════════════ 电机 OTA(relay 模式)═══════════════════════
|
||||
* 两段式(与 BLE 刷 GD32 对齐):
|
||||
* 步1 POST /motor/ota 收固件写 ESP32 flash(不碰 SPI,避免与 GD32 按铃竞态)
|
||||
* 步2 POST /motor/ota/relay 触发 SPI START + feedTask 喂 GD32 → GD32 本地 AES 握手 + CAN stop-and-wait
|
||||
* 前端负责:选文件 → 上传 → 进BOOT → 触发转发 → 轮询进度 → 显示结果。
|
||||
* ★ 顺序(2026-07-27 调整):先收完固件再让电机进 BOOT——上传期间电机仍在 APP
|
||||
* 正常运行,上传失败/固件损坏时电机不会被白白踢进 BOOT 空等。 */
|
||||
const OTA_MAX_FW = 0xC000; // 48 KB 上限
|
||||
const OTA_POLL_MS = 500; // 进度轮询间隔
|
||||
const OTA_TOTAL_TIMEOUT_MS = 120000; // OTA 整体超时(含进 BOOT + 上传 + 传输),超时自动取消
|
||||
const ota = {
|
||||
running: false,
|
||||
file: null,
|
||||
bytes: null,
|
||||
pollTimer: null,
|
||||
watchdogTimer: null, /* 整体超时看门狗 */
|
||||
startTime: 0, /* OTA 开始时间戳 */
|
||||
fileHandle: null, /* FileSystemFileHandle(FSAPI),用于刷新后恢复固件 */
|
||||
pendingHandle: null, /* 待重新授权的 handle(权限丢失时暂存,点上传区再授权) */
|
||||
};
|
||||
|
||||
/* ─── 固件文件持久化(仿主页面 file-handle.js 模式)───
|
||||
* localStorage 存文件元数据(名/大小),IndexedDB 存 FileSystemFileHandle。
|
||||
* 刷新后:权限仍 granted → 静默恢复 bytes;否则展示文件名,点"选择固件"区重新授权。
|
||||
* File 对象本身不可序列化,bytes 不入 localStorage;IndexedDB 仅存 handle(可结构化克隆)。 */
|
||||
const LS_OTA_FW_META = "dcu_motor_ota_fw_meta";
|
||||
const IDB_OTA_NAME = "dcu_motor_ota_fw_v1";
|
||||
const IDB_OTA_STORE = "file_handle";
|
||||
let _otaIdbDb = null;
|
||||
|
||||
function otaIdbOpen() {
|
||||
if (_otaIdbDb) return Promise.resolve(_otaIdbDb);
|
||||
if (!window.indexedDB) return Promise.reject(new Error("no IDB"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(IDB_OTA_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(IDB_OTA_STORE)) req.result.createObjectStore(IDB_OTA_STORE);
|
||||
};
|
||||
req.onsuccess = () => { _otaIdbDb = req.result; resolve(_otaIdbDb); };
|
||||
req.onerror = () => { _otaIdbDb = null; reject(req.error); };
|
||||
});
|
||||
}
|
||||
function otaIdbPutHandle(handle) {
|
||||
return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(IDB_OTA_STORE, "readwrite");
|
||||
if (handle) tx.objectStore(IDB_OTA_STORE).put(handle, "motor");
|
||||
else tx.objectStore(IDB_OTA_STORE).delete("motor");
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
})).catch(() => {});
|
||||
}
|
||||
function otaIdbGetHandle() {
|
||||
return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(IDB_OTA_STORE, "readonly");
|
||||
const r = tx.objectStore(IDB_OTA_STORE).get("motor");
|
||||
r.onsuccess = () => resolve(r.result || null);
|
||||
r.onerror = () => reject(r.error);
|
||||
})).catch(() => null);
|
||||
}
|
||||
|
||||
function otaSaveFwMeta(file) {
|
||||
try {
|
||||
if (file && file.name) {
|
||||
localStorage.setItem(LS_OTA_FW_META, JSON.stringify({ name: file.name, size: file.size || 0, lastModified: file.lastModified || 0 }));
|
||||
} else {
|
||||
localStorage.removeItem(LS_OTA_FW_META);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
function otaLoadFwMeta() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_OTA_FW_META);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
/** 查询 handle 权限(不 request,刷新无手势时 request 会失败) */
|
||||
async function otaQueryPermission(handle) {
|
||||
if (!handle || typeof handle.getFile !== "function") return "denied";
|
||||
try {
|
||||
if (typeof handle.queryPermission === "function") return await handle.queryPermission({ mode: "read" });
|
||||
await handle.getFile();
|
||||
return "granted";
|
||||
} catch (_) { return "denied"; }
|
||||
}
|
||||
|
||||
/** 把恢复的 File 应用到 ota 对象 + UI */
|
||||
async function otaApplyRestoredFile(handle, file, opts) {
|
||||
opts = opts || {};
|
||||
ota.fileHandle = handle || null;
|
||||
ota.file = file;
|
||||
ota.bytes = new Uint8Array(await file.arrayBuffer());
|
||||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||||
otaSaveFwMeta(file);
|
||||
if (!opts.silent) otaLog(`<span class="log-info">[固件]</span> ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
|
||||
}
|
||||
|
||||
/** 无权限时展示文件名,提示点上传区重新授权 */
|
||||
function otaShowPendingReauth(handle, meta) {
|
||||
ota.fileHandle = handle || null;
|
||||
ota.pendingHandle = handle || null;
|
||||
const name = (meta && meta.name) || (handle && handle.name) || "上次固件";
|
||||
$("#otaFileName").textContent = `${name} — 点击「选择固件」重新授权恢复`;
|
||||
if (meta && meta.size) $("#otaPktTotal").textContent = String(Math.ceil(meta.size / 48));
|
||||
otaLog(`<span class="log-info">[固件]</span> 上次固件「${escapeHtml(name)}」需点击「选择固件」重新授权(浏览器安全限制)`);
|
||||
}
|
||||
|
||||
/** 页面加载后恢复上次固件(权限 granted → 静默恢复;否则提示重新授权) */
|
||||
async function otaRestorePersistedFirmware() {
|
||||
let handle;
|
||||
try { handle = await otaIdbGetHandle(); } catch (_) { handle = null; }
|
||||
const meta = otaLoadFwMeta();
|
||||
if (!handle) {
|
||||
/* 无 handle(非 FSAPI 选择的旧文件或首次):仅展示元数据提示 */
|
||||
if (meta && meta.name) {
|
||||
$("#otaFileName").textContent = `${meta.name} — ${meta.size ? meta.size.toLocaleString() + " B · " : ""}请重新选择`;
|
||||
otaLog(`<span class="log-info">[固件]</span> 上次固件「${escapeHtml(meta.name)}」需重新选择(无 FSAPI handle)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const perm = await otaQueryPermission(handle);
|
||||
if (perm === "granted") {
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
await otaApplyRestoredFile(handle, file, { silent: true });
|
||||
otaLog(`<span class="log-ok">[固件]</span> 已恢复刷新前的固件: ${escapeHtml(file.name)}`);
|
||||
} catch (e) {
|
||||
otaShowPendingReauth(handle, meta);
|
||||
}
|
||||
} else {
|
||||
otaShowPendingReauth(handle, meta);
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户手势中:对 pending handle 重新授权并恢复(点"选择固件"时先尝试此路径) */
|
||||
async function otaResumeWithGesture() {
|
||||
const handle = ota.pendingHandle;
|
||||
if (!handle || typeof handle.getFile !== "function") return false;
|
||||
try {
|
||||
let perm = "granted";
|
||||
if (typeof handle.queryPermission === "function") perm = await handle.queryPermission({ mode: "read" });
|
||||
if (perm !== "granted" && typeof handle.requestPermission === "function") perm = await handle.requestPermission({ mode: "read" });
|
||||
if (perm !== "granted") { toast("未授予读取权限,请在弹窗中允许或重新选择", "warn"); return false; }
|
||||
const file = await handle.getFile();
|
||||
if (file.size > OTA_MAX_FW) { toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err"); return false; }
|
||||
await otaApplyRestoredFile(handle, file, {});
|
||||
ota.pendingHandle = null;
|
||||
toast(`已恢复上次固件: ${file.name}`, "ok");
|
||||
return true;
|
||||
} catch (e) {
|
||||
otaLog(`<span class="log-err">[固件]</span> 恢复失败: ${escapeHtml(e.message || e)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function otaLog(html) {
|
||||
const el = $("#otaLog");
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
el.appendChild(div);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
/* OTA 日志工具栏:清空 / 复制 */
|
||||
$("#otaLogClearBtn").addEventListener("click", function () {
|
||||
const el = $("#otaLog");
|
||||
el.innerHTML = '<div class="log-info">[已清空] 日志已清除</div>';
|
||||
});
|
||||
$("#otaLogCopyBtn").addEventListener("click", function () {
|
||||
const el = $("#otaLog");
|
||||
const text = el.innerText.trim();
|
||||
if (!text) { toast("日志为空", "warn"); return; }
|
||||
const btn = this;
|
||||
function onDone() {
|
||||
btn.classList.add("copied");
|
||||
setTimeout(function () { btn.classList.remove("copied"); }, 1200);
|
||||
toast("已复制日志", "ok");
|
||||
}
|
||||
function fallback() {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); onDone(); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
});
|
||||
function setOtaStage(stageIdx) {
|
||||
document.querySelectorAll(".ota-stage").forEach((el, i) => {
|
||||
el.classList.toggle("done", i < stageIdx);
|
||||
el.classList.toggle("current", i === stageIdx);
|
||||
});
|
||||
}
|
||||
function resetOtaUi() {
|
||||
setOtaStage(-1);
|
||||
$("#otaProgressFill").style.width = "0%";
|
||||
$("#otaPktNum").textContent = "0";
|
||||
}
|
||||
|
||||
/* 状态名 → 阶段索引映射(6 阶段:上传固件 → 进入BOOT → 转发握手 → 擦除 → 传输 → 完成)
|
||||
* 后端 motor_ota_state_t 不含 upload/boot 阶段(纯前端驱动),后端状态从 handshake_req 起。
|
||||
* 顺序:上传固件(stage0) → 进BOOT(stage1) → 触发转发 → GD32 握手(stage2)/擦除(stage3)/传输(stage4)/完成(stage5)。 */
|
||||
const OTA_STATE_STAGE = {
|
||||
idle: -1, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2,
|
||||
erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finished: 5, error: -1,
|
||||
};
|
||||
const OTA_STATE_ZH = {
|
||||
idle: "空闲", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
|
||||
handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输",
|
||||
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误",
|
||||
};
|
||||
|
||||
$("#otaFileBtn").addEventListener("click", async () => {
|
||||
/* 优先尝试恢复待授权的 handle(FSAPI),成功则不再弹文件选择框 */
|
||||
if (ota.pendingHandle && await otaResumeWithGesture()) return;
|
||||
/* FSAPI:showOpenFilePicker 可拿到 handle 用于刷新后恢复 */
|
||||
if (window.showOpenFilePicker) {
|
||||
try {
|
||||
const [handle] = await window.showOpenFilePicker({
|
||||
types: [{ description: "固件 (.bin)", accept: { "application/octet-stream": [".bin"] } }],
|
||||
multiple: false,
|
||||
});
|
||||
const file = await handle.getFile();
|
||||
if (file.size > OTA_MAX_FW) {
|
||||
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
|
||||
return;
|
||||
}
|
||||
ota.fileHandle = handle;
|
||||
ota.pendingHandle = null;
|
||||
ota.file = file;
|
||||
ota.bytes = new Uint8Array(await file.arrayBuffer());
|
||||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||||
$("#otaCrc").textContent = "—";
|
||||
otaSaveFwMeta(file);
|
||||
otaIdbPutHandle(handle);
|
||||
otaLog(`<span class="log-info">[固件]</span> ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
|
||||
toast(`固件已加载: ${file.name}`, "ok");
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e && e.name === "AbortError") return; /* 用户取消,静默 */
|
||||
/* FSAPI 失败则回退到 input[type=file] */
|
||||
}
|
||||
}
|
||||
$("#otaFileInput").click();
|
||||
});
|
||||
$("#otaFileInput").addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > OTA_MAX_FW) {
|
||||
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
const buf = await file.arrayBuffer();
|
||||
ota.file = file;
|
||||
ota.bytes = new Uint8Array(buf);
|
||||
ota.fileHandle = null; /* input 选择无 handle,刷新后只能展示文件名 */
|
||||
ota.pendingHandle = null;
|
||||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||||
$("#otaCrc").textContent = "—";
|
||||
otaSaveFwMeta(file);
|
||||
/* input 选择无 FSAPI handle,清掉旧 handle 避免恢复到旧文件 */
|
||||
otaIdbPutHandle(null);
|
||||
otaLog(`<span class="log-info">[固件]</span> ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
|
||||
toast(`固件已加载: ${file.name}`, "ok");
|
||||
});
|
||||
|
||||
$("#otaStartBtn").addEventListener("click", startOta);
|
||||
$("#otaCancelBtn").addEventListener("click", cancelOta);
|
||||
|
||||
async function startOta() {
|
||||
if (ota.running) return;
|
||||
if (!state.connected) { toast("设备未连接", "err"); return; }
|
||||
/* 电机 OTA 依赖 ESP32 固件的 /motor/ota* 上传 + GD32 本地 relay,
|
||||
* EtherCAT 链路上没有这条通路(过程数据只跑 CAN 帧)。这里必须硬拦:
|
||||
* 否则会去打设备 IP 的 HTTP,在只连了网线的场合表现为莫名超时。 */
|
||||
if (!state.tp.supportsOta) {
|
||||
toast("当前链路(" + state.tp.label + ")不支持电机 OTA —— 请切回 WiFi 链路", "err");
|
||||
return;
|
||||
}
|
||||
if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
|
||||
/* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
|
||||
* node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
|
||||
const node = state.selectedNode;
|
||||
if (node == null || node < 1) {
|
||||
toast("请先在左侧节点列表选中目标电机(node ≥ 1)", "warn");
|
||||
return;
|
||||
}
|
||||
const ch = state.ch || 0;
|
||||
|
||||
/* ★ 每次刷写都重新从磁盘读取固件,确保用最新文件内容,不依赖内存缓存。
|
||||
* 之前只读一次存 ota.bytes,之后多次刷写复用缓存——若磁盘文件被重新编译
|
||||
* 覆盖(改代码 rebuild),前端仍发旧数据,导致刷进电机的不是最新固件。
|
||||
* 有 FSAPI handle 时从 handle 重新读(handle 总是指向同一文件路径);
|
||||
* 无 handle(input 选择)时从 ota.file 重新读 File 对象。
|
||||
* 读取失败提示用户重新选择固件,绝不带着旧/坏缓存刷写。 */
|
||||
try {
|
||||
let file = ota.file;
|
||||
if (ota.fileHandle && typeof ota.fileHandle.getFile === "function") {
|
||||
file = await ota.fileHandle.getFile();
|
||||
}
|
||||
if (file.size > OTA_MAX_FW) {
|
||||
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
|
||||
return;
|
||||
}
|
||||
ota.file = file;
|
||||
ota.bytes = new Uint8Array(await file.arrayBuffer());
|
||||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||||
} catch (e) {
|
||||
otaLog(`<span class="log-err">[固件]</span> 重新读取失败:${escapeHtml(e.message || e)},请重新选择固件`);
|
||||
toast("固件读取失败,请重新选择", "err");
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await showModal("开始电机 OTA",
|
||||
`将先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备并校验 SHA-256,` +
|
||||
`再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` +
|
||||
`由 GD32 本地完成 AES 握手与 CAN 传输。OTA 期间请勿操作电机。`);
|
||||
if (!ok) return;
|
||||
|
||||
stopRepeat(true);
|
||||
stopSeq("OTA 开始,命令序列已停止", true);
|
||||
ota.running = true;
|
||||
ota.node = node; /* 记录目标节点:finished 后 verifyNewApp 查版本用 */
|
||||
ota.startTime = performance.now();
|
||||
$("#otaStartBtn").disabled = true;
|
||||
$("#otaCancelBtn").disabled = false;
|
||||
resetOtaUi();
|
||||
setOtaStage(0); /* 上传固件阶段 */
|
||||
|
||||
/* 整体超时看门狗:上传 + 进 BOOT + 传输 + 跳转 总计不应超过 OTA_TOTAL_TIMEOUT_MS。
|
||||
* 超时后自动取消,避免卡死时前端无限轮询 + ESP32 feedTask 残留。 */
|
||||
if (ota.watchdogTimer) clearTimeout(ota.watchdogTimer);
|
||||
ota.watchdogTimer = setTimeout(() => {
|
||||
if (!ota.running) return;
|
||||
otaLog(`<span class="log-err">[超时]</span> OTA 整体超时 (${OTA_TOTAL_TIMEOUT_MS / 1000}s),自动取消…`);
|
||||
cancelOta();
|
||||
}, OTA_TOTAL_TIMEOUT_MS);
|
||||
|
||||
/* OTA 期间暂停 motor/feedback/status 三链路轮询:
|
||||
* 三条链各占一个 HTTP socket + httpd 线程时间,叠加固件上传 + OTA poll(500ms)
|
||||
* + cancel 需要的新连接,6 个 max_open_sockets 会紧张,
|
||||
* 导致 cancel "Failed to fetch"(连接建立失败/被 lru_purge 踢掉);
|
||||
* 且电机随后将跳 BOOT,motor 状态轮询无意义。
|
||||
* OTA 完成后 otaDone→startPolling 恢复。 */
|
||||
stopDataPolling();
|
||||
|
||||
/* ── 步骤 1:前端先算本地固件 SHA-256(只算一次,重传不变)── */
|
||||
const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes);
|
||||
const fwShaHex = Array.from(new Uint8Array(fwSha256))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
otaLog(`<span class="log-info">[校验]</span> 本地固件 SHA-256: ${fwShaHex}`);
|
||||
|
||||
try {
|
||||
/* ── 步骤 2:上传 + SHA 比对,不通过重传(最多 3 次尝试 = 首发 + 重发 2 次)──
|
||||
* POST /motor/ota 只收固件写 flash(不触发 SPI),ESP32 收尾从 flash 读回算 SHA 随响应返回;
|
||||
* 前端比对不一致 = flash 写坏 → 重传(每次上传都全盘擦除重写);
|
||||
* 连续 3 次不过才中断——此时电机仍在 APP 正常运行,重试/中断都不影响电机。 */
|
||||
const kUploadMaxAttempts = 3;
|
||||
let uploadOk = false;
|
||||
for (let attempt = 1; attempt <= kUploadMaxAttempts && ota.running; attempt++) {
|
||||
otaLog(`<span class="log-info">[上传]</span> 上传固件到 ESP32 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`);
|
||||
let upJson = {};
|
||||
try {
|
||||
const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: ota.bytes,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
upJson = await upResp.json().catch(() => ({}));
|
||||
if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`);
|
||||
} catch (e) {
|
||||
otaLog(`<span class="log-err">[上传]</span> 第 ${attempt} 次上传失败:${escapeHtml(e.message || e)}`);
|
||||
if (attempt < kUploadMaxAttempts) await sleep(300);
|
||||
continue;
|
||||
}
|
||||
otaLog(`<span class="log-ok">[上传]</span> 固件已存储 (${escapeHtml(String(upJson.size || ota.bytes.length))} B)`);
|
||||
|
||||
/* 比对:本地 SHA vs ESP32 从 flash 读回算的 SHA(upJson.sha256) */
|
||||
if (!upJson.sha256 || upJson.sha256.length !== 64) {
|
||||
/* 旧 ESP32 固件无 sha256 字段:无法本地比对,且旧固件 relay 还要求 body(新前端已不发),
|
||||
* 需刷新 ESP32 固件;继续尝试,若 relay 被拒会报错中止。 */
|
||||
otaLog(`<span class="log-err">[校验]</span> 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`);
|
||||
uploadOk = true;
|
||||
break;
|
||||
}
|
||||
if (upJson.sha256.toLowerCase() === fwShaHex) {
|
||||
otaLog(`<span class="log-ok">[校验]</span> flash SHA 比对一致,允许进 BOOT`);
|
||||
uploadOk = true;
|
||||
break;
|
||||
}
|
||||
otaLog(`<span class="log-err">[校验]</span> 第 ${attempt} 次 flash SHA 不一致(device=${escapeHtml(String(upJson.sha256.slice(0, 16)))}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`);
|
||||
if (attempt < kUploadMaxAttempts) await sleep(300);
|
||||
}
|
||||
if (!uploadOk) {
|
||||
throw new Error(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`);
|
||||
}
|
||||
|
||||
/* ── 步骤 3:发 0x000N0000 进 BOOT 帧(DLC=0,fire-and-forget)──
|
||||
* 固件已收完且 flash SHA 比对通过,此刻才让电机离开 APP。
|
||||
* APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。
|
||||
* 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK,
|
||||
* 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败——
|
||||
* 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。
|
||||
* 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */
|
||||
setOtaStage(1); /* 进入 BOOT 阶段 */
|
||||
const bootId = canId(node, 0x0000); /* 0x000N0000 */
|
||||
otaLog(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
|
||||
try {
|
||||
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
|
||||
if (r.ok) {
|
||||
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
|
||||
} else {
|
||||
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
|
||||
}
|
||||
} catch (e) {
|
||||
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
|
||||
}
|
||||
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出,立即触发转发…`);
|
||||
|
||||
/* ── 步骤 4:POST /motor/ota/relay 触发 SPI START + 喂数据(纯启动扳机,无 body)──
|
||||
* SHA 校验已在步骤 2 闭环(比对+重传),relay 不再传/比 SHA。
|
||||
* 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。
|
||||
* SPI 0xC0(进 BOOT) 与 0xD0(START) 经 SPI worker 串行,顺序有保证。 */
|
||||
const rResp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${ch}`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const rJson = await rResp.json().catch(() => ({}));
|
||||
if (!rResp.ok) throw new Error(rJson.error || `HTTP ${rResp.status}`);
|
||||
otaLog(`<span class="log-ok">[转发]</span> GD32 开始握手…`);
|
||||
startOtaPoll();
|
||||
} catch (e) {
|
||||
otaLog(`<span class="log-err">[失败]</span> ${escapeHtml(e.message || e)}`);
|
||||
toast("OTA 启动失败:" + (e.message || e), "err");
|
||||
otaDone();
|
||||
}
|
||||
}
|
||||
|
||||
function startOtaPoll() {
|
||||
const startTime = performance.now();
|
||||
let lastState = "";
|
||||
let lastLoggedPkt = -1;
|
||||
ota.pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(getBaseUrl() + "/motor/ota/status", {
|
||||
signal: AbortSignal.timeout(2000), cache: "no-store",
|
||||
});
|
||||
const st = await resp.json();
|
||||
updateOtaUi(st, startTime);
|
||||
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
|
||||
if (st.state && st.state !== lastState) {
|
||||
const zh = OTA_STATE_ZH[st.state] || escapeHtml(String(st.state));
|
||||
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
|
||||
* 不显示 x/y 进度——
|
||||
* 握手未成时 packets_acked 是 ESP32 预填充字节数,不是电机真实 ACK,
|
||||
* 显示 "0/912" 无意义且让用户误以为在传输。transfer 起才显示进度。 */
|
||||
const showPkt = (st.state === "transfer" || st.state === "wait_app_run" ||
|
||||
st.state === "finish_ota" || st.state === "finished");
|
||||
otaLog(`<span class="log-info">[状态]</span> ${zh}` +
|
||||
(showPkt && st.total_packets ? ` · ${Number(st.packets_acked) || 0}/${Number(st.total_packets) || 0}` : ""));
|
||||
lastState = st.state;
|
||||
} else if (st.state === "transfer" && st.total_packets > 0) {
|
||||
const acked = Number(st.packets_acked) || 0;
|
||||
/* 传输期每约 64 包补一条进度,避免只有握手日志 */
|
||||
if (acked - lastLoggedPkt >= 64 || acked >= st.total_packets) {
|
||||
otaLog(`<span class="log-info">[传输]</span> ${acked}/${Number(st.total_packets) || 0}`);
|
||||
lastLoggedPkt = acked;
|
||||
}
|
||||
}
|
||||
if (st.state === "finished" || st.state === "error" || st.state === "idle") {
|
||||
clearInterval(ota.pollTimer);
|
||||
ota.pollTimer = null;
|
||||
if (st.state === "finished") {
|
||||
otaLog(`<span class="log-ok">[完成]</span> 传输完成 · ${Number(st.packets_acked) || 0} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
|
||||
} else if (st.state === "error") {
|
||||
const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
|
||||
otaLog(`<span class="log-err">[错误]</span> 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${errHint}`);
|
||||
toast("电机 OTA 失败 (0x" + Number(st.error || 0).toString(16) + ")", "err");
|
||||
}
|
||||
/* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel
|
||||
* 会让 GD32 发 0x00000000(kOtaCancelId),被刚启动的电机 APP 当作
|
||||
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s → APP 看似卡死。
|
||||
* 只有 error/idle/异常退出才发 cancel 兜底清理设备端残留状态。 */
|
||||
otaDone(st.state !== "finished");
|
||||
/* ★ finished = GD32 已收到电机新 APP 上电自主发的 [0A AC] 运行确认
|
||||
* (kWaitAppRun → kFinished,无确认 3s 超时报 0x25)——APP 已确认在跑。
|
||||
* verifyNewApp 只做最后一步:查版本号核对并回填 UI。 */
|
||||
if (st.state === "finished") verifyNewApp(ota.node);
|
||||
}
|
||||
} catch (e) { /* 轮询瞬时失败静默 */ }
|
||||
}, OTA_POLL_MS);
|
||||
}
|
||||
|
||||
/* ── OTA 终验:查版本号核对新 APP ──
|
||||
* 运行确认已由设备端闭环:电机新 APP 上电自主发 [0A AC],GD32 kWaitAppRun
|
||||
* 收到才报 finished(无确认 3s → error 0x25)——走到这里 APP 必然在跑且 CAN 已就绪。
|
||||
* 本函数只补最后一步:发 GET_VERSION(0x000N00FF) 拿新版本号展示/核对,
|
||||
* 无应答仅提醒(不推翻设备端的运行确认结论)。 */
|
||||
async function verifyNewApp(node) {
|
||||
if (node == null || node < 1) return;
|
||||
otaLog(`<span class="log-info">[验证]</span> 查询新 APP 版本号 (${hexId(ID_GET_VERSION_REQ(node))})…`);
|
||||
await sleep(200); /* APP 已发过 0AAC,CAN 已就绪,留小间隔避开启动期杂帧 */
|
||||
const kVerifyAttempts = 2;
|
||||
for (let attempt = 1; attempt <= kVerifyAttempts; attempt++) {
|
||||
try {
|
||||
const before = await fetchMotorVersionNodeViaTp(node);
|
||||
const prevSeq = before ? before.seq : null;
|
||||
const res = await sendOneFrameWhenFree(ID_GET_VERSION_REQ(node), 24, bytesToHexStr(new Uint8Array(24)));
|
||||
if (res.ok) {
|
||||
const v = await waitMotorVersion(node, { prevSeq, timeoutMs: 4000 });
|
||||
if (v && v.version) {
|
||||
const parsed = parseVersionPayload(v.version);
|
||||
if (parsed && parsed.soft) {
|
||||
/* 同步版本卡片与缓存,用户无需再手动点查版本 */
|
||||
setVer("verSoft", parsed.soft || "--");
|
||||
setVer("verHard", parsed.hard || "--");
|
||||
setVer("verBoot", parsed.boot || "--");
|
||||
state.versions.set(node, { soft: parsed.soft || null, hard: parsed.hard || null, boot: parsed.boot || null });
|
||||
saveUiState();
|
||||
otaLog(`<span class="log-ok">[成功]</span> OTA 成功 · 新 APP v${parsed.soft} 运行中(0AAC 确认 + 版本应答)`);
|
||||
toast(`电机 OTA 成功 · APP v${parsed.soft}`, "ok");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 单轮异常按未应答处理,进入重试 */ }
|
||||
if (attempt < kVerifyAttempts) {
|
||||
otaLog(`<span class="log-info">[验证]</span> 第 ${attempt} 次无应答,重试…`);
|
||||
}
|
||||
}
|
||||
otaLog(`<span class="log-err">[提醒]</span> APP 已发 0AAC 确认运行,但版本查询无应答,请手动点「查版本」核对`);
|
||||
toast("OTA 完成(版本查询无应答,请手动核对)", "warn");
|
||||
}
|
||||
|
||||
function updateOtaUi(st, startTime) {
|
||||
const stage = OTA_STATE_STAGE[st.state] ?? -1;
|
||||
setOtaStage(stage);
|
||||
const acked = Number(st.packets_acked) || 0;
|
||||
const total = st.total_packets || 1;
|
||||
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
|
||||
* 不更新进度条和包数——
|
||||
* 此时 packets_acked 是 ESP32 预填充字节数不是电机真实 ACK,
|
||||
* 显示 0/912 会让人误以为在传输。transfer 起才显示进度。 */
|
||||
const inTransfer = (st.state === "transfer" || st.state === "wait_app_run" ||
|
||||
st.state === "finish_ota" || st.state === "finished");
|
||||
if (inTransfer) {
|
||||
$("#otaPktNum").textContent = String(acked);
|
||||
$("#otaPktTotal").textContent = String(total);
|
||||
$("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
|
||||
} else {
|
||||
/* 握手阶段进度条归零,避免残留上次进度 */
|
||||
$("#otaPktNum").textContent = "0";
|
||||
$("#otaProgressFill").style.width = "0%";
|
||||
}
|
||||
const elapsed = (performance.now() - startTime) / 1000;
|
||||
if (elapsed > 0.5 && acked > 0 && inTransfer) {
|
||||
$("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
|
||||
}
|
||||
$("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
|
||||
}
|
||||
|
||||
async function cancelOta() {
|
||||
if (!ota.running) return;
|
||||
otaLog(`<span class="log-err">[取消]</span> 正在取消…`);
|
||||
/* 先停 OTA 进度轮询,腾出 HTTP socket 给 cancel 请求本身。
|
||||
* motor/feedback/status 三链路在 startOta 时已 stopDataPolling,
|
||||
* 这里只剩 OTA poll(500ms)可能正占用一个 socket。
|
||||
* 先停 poll 再发 cancel,避免 cancel 被挤导致 "Failed to fetch"。 */
|
||||
if (ota.pollTimer) { clearInterval(ota.pollTimer); ota.pollTimer = null; }
|
||||
/* 取消请求重试 3 次:单次失败(网络抖动/ESP32 忙)时不能静默吞掉,
|
||||
* 否则 ESP32 侧 feedTask 不会退出,motorOtaIsActive() 残留为 true,
|
||||
* 导致后续 ESP32/GD32 OTA 全被"电机 OTA 进行中"拒绝。 */
|
||||
let ok = false;
|
||||
for (let i = 1; i <= 3 && !ok; i++) {
|
||||
try {
|
||||
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(5000) });
|
||||
ok = r.ok;
|
||||
} catch (e) {
|
||||
otaLog(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(e.message || e)}`);
|
||||
}
|
||||
if (!ok && i < 3) await sleep(500);
|
||||
}
|
||||
if (!ok) otaLog(`<span class="log-err">[取消]</span> 3 次均失败,设备端 feedTask 可能残留,建议刷新页面或等待自动超时`);
|
||||
/* 成功时不再经 otaDone 兜底重发 cancel:设备端已在取消流程中,
|
||||
* 双发 CANCEL 会再次触发 motorOtaCancel(GD32 重复 resetToIdle),
|
||||
* 徒占 SPI 且延长 cancel task 存活窗口。仅 3 次全失败时保留兜底。 */
|
||||
otaDone(!ok);
|
||||
}
|
||||
|
||||
/* otaDone(sendCancel): 清理前端 OTA 状态。
|
||||
* sendCancel=true 时发 POST /motor/ota/cancel 兜底清理设备端残留状态。
|
||||
* sendCancel=false(正常 finished)时不发 cancel——电机 APP 已跳转启动,
|
||||
* 此时发 cancel 会让 GD32 发 0x00000000(kOtaCancelId),被电机 APP 当作
|
||||
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s,APP 看似卡死。
|
||||
* 调用方:
|
||||
* - startOtaPoll 正常完成 → otaDone(false)
|
||||
* - startOtaPoll error/idle → otaDone(true)
|
||||
* - cancelOta(用户主动取消)→ otaDone(true)
|
||||
* - startOta 启动失败 → otaDone(true) */
|
||||
function otaDone(sendCancel = true) {
|
||||
ota.running = false;
|
||||
$("#otaStartBtn").disabled = false;
|
||||
$("#otaCancelBtn").disabled = true;
|
||||
if (ota.watchdogTimer) { clearTimeout(ota.watchdogTimer); ota.watchdogTimer = null; }
|
||||
/* OTA 期间暂停了 motor/feedback/status 三链路轮询,完成后恢复。
|
||||
* startPolling 内部先 stopDataPolling 再重建,安全可重复调用。 */
|
||||
if (state.connected && !state.probing) {
|
||||
startPolling();
|
||||
}
|
||||
/* 仅在需要时发 cancel 兜底清理设备端 motorOtaIsActive。
|
||||
* 正常完成(finished)时 feedTask 已自行退出 + GD32 已进 kFinished,
|
||||
* 不需要 cancel;发了反而干扰刚跳转的电机 APP(见上注释)。 */
|
||||
if (sendCancel && state.connected) {
|
||||
fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(3000) })
|
||||
.catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMotorVersionNodeViaTp(node) {
|
||||
try { return await state.tp.fetchVersionNode(node); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
return {
|
||||
ota,
|
||||
otaRestorePersistedFirmware,
|
||||
startOta,
|
||||
cancelOta,
|
||||
otaDone,
|
||||
};
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
/**
|
||||
* scope.js — 电机控制台示波器(波形缓冲 + canvas 绘制)
|
||||
* 从 main.js 外提的示波器 IIFE,逻辑行为不变。
|
||||
* createScopeSys(ctx) 注入 state / motorKey / clampNum / $ 依赖。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @param {{ state: object, motorKey: (m:any)=>string, clampNum:(v:number,lo:number,hi:number)=>number, $:(s:string)=>Element|null }} ctx
|
||||
* @returns {{ reset: () => void }}
|
||||
*/
|
||||
export function createScopeSys(ctx) {
|
||||
const { state, motorKey, clampNum, $ } = ctx;
|
||||
|
||||
const BUF = 300;
|
||||
const dprOf = () => window.devicePixelRatio || 1;
|
||||
const hist = {
|
||||
u: new Float32Array(BUF), v: new Float32Array(BUF), w: new Float32Array(BUF),
|
||||
angle: new Float32Array(BUF), velocity: new Float32Array(BUF), current: new Float32Array(BUF),
|
||||
mcuTemp: new Float32Array(BUF), motTemp: new Float32Array(BUF),
|
||||
};
|
||||
let head = 0;
|
||||
/* 样本有效性:1 = 来自在线电机;0 = 离线补零(绘制为虚线段) */
|
||||
const valid = new Uint8Array(BUF);
|
||||
|
||||
function colors() {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
u: cs.getPropertyValue("--seed-primary").trim() || "#22d3ee",
|
||||
v: "#34d399",
|
||||
w: cs.getPropertyValue("--seed-accent").trim() || "#f59e0b",
|
||||
};
|
||||
}
|
||||
let C = colors();
|
||||
setInterval(() => { C = colors(); }, 2000);
|
||||
|
||||
/* 尺寸缓存:fit 每帧调一次 getBoundingClientRect = 每秒 160 次强制同步布局。
|
||||
* 改成 ResizeObserver 只在真变化时量一次;borderBoxSize 与原来的
|
||||
* getBoundingClientRect 同口径(迷你波形的 canvas 有 1px 边框,
|
||||
* 用 contentRect 会让几何悄悄缩 2px)。 */
|
||||
const sizes = new WeakMap();
|
||||
let sizeObs = null;
|
||||
function cssSize(canvas) {
|
||||
let s = sizes.get(canvas);
|
||||
if (s) return s;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
s = { w: r.width, h: r.height };
|
||||
sizes.set(canvas, s);
|
||||
if (!sizeObs) {
|
||||
sizeObs = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const t = sizes.get(e.target);
|
||||
if (!t) continue;
|
||||
const b = e.borderBoxSize && e.borderBoxSize[0];
|
||||
t.w = b ? b.inlineSize : e.contentRect.width;
|
||||
t.h = b ? b.blockSize : e.contentRect.height;
|
||||
}
|
||||
});
|
||||
}
|
||||
sizeObs.observe(canvas);
|
||||
return s;
|
||||
}
|
||||
/** 对齐 backing store 与 CSS 尺寸;返回 CSS 尺寸,宽度为 0(未显示)时返回 null。 */
|
||||
function fit(canvas, ctx) {
|
||||
const dpr = dprOf();
|
||||
const s = cssSize(canvas);
|
||||
if (s.w === 0) return null;
|
||||
const cw = Math.round(s.w * dpr), ch = Math.round(s.h * dpr);
|
||||
if (canvas.width !== cw || canvas.height !== ch) {
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return s;
|
||||
}
|
||||
function grid(ctx, w, h) {
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 4; i++) { const y = h / 4 * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
for (let i = 1; i < 6; i++) { const x = w / 6 * i; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.13)";
|
||||
ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
|
||||
}
|
||||
/* 分段描线:valid 样本连成实线(发光 + 可选基线填充);无数据样本(离线
|
||||
* 补零段)画虚线、无发光无填充 —— 数据停更一眼可辨。相邻样本任一无效,
|
||||
* 该连线即按虚线处理;边界点同时属于两段,段间不脱节。 */
|
||||
/* 每帧 new 300 个点对象是 160Hz 下最大的分配源(一次全量重绘 ≈2400 个对象,
|
||||
* 也正是工作集一路涨到几 GB 的原因)。改成把 y 写进复用的 scratch 数组,
|
||||
* x 因为等间距直接算出来,不再存。 */
|
||||
const scratchY = new Float32Array(BUF);
|
||||
const okAt = (i) => valid[(head + i) % BUF] === 1;
|
||||
function drawTrace(ctx, buf, color, yOf, base, w, lw, fill) {
|
||||
const xOf = (i) => i / (BUF - 1) * w;
|
||||
for (let i = 0; i < BUF; i++) scratchY[i] = yOf(buf[(head + i) % BUF]);
|
||||
const linePath = (a, b) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xOf(a), scratchY[a]);
|
||||
for (let i = a + 1; i <= b; i++) ctx.lineTo(xOf(i), scratchY[i]);
|
||||
};
|
||||
ctx.lineJoin = "round"; ctx.strokeStyle = color;
|
||||
let start = 0;
|
||||
while (start < BUF - 1) {
|
||||
const solid = okAt(start) && okAt(start + 1);
|
||||
let end = start + 1;
|
||||
while (end + 1 < BUF && (okAt(end) && okAt(end + 1)) === solid) end++;
|
||||
if (solid && fill) {
|
||||
linePath(start, end);
|
||||
ctx.lineTo(xOf(end), base); ctx.lineTo(xOf(start), base); ctx.closePath();
|
||||
ctx.fillStyle = color + "16";
|
||||
ctx.fill();
|
||||
}
|
||||
linePath(start, end);
|
||||
ctx.setLineDash(solid ? [] : [5, 4]);
|
||||
/* 发光:同一条路径先描一道宽而淡的底。原来的 shadowBlur 是逐像素模糊,
|
||||
* 视觉上两者等价,成本差一个量级。 */
|
||||
if (solid) {
|
||||
ctx.globalAlpha = 0.16; ctx.lineWidth = lw * 3.2; ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.lineWidth = lw; ctx.stroke();
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
function trace(ctx, buf, color, range, w, h, lw, fill) {
|
||||
const mid = h / 2, gain = (h / 2 - 4) / range;
|
||||
drawTrace(ctx, buf, color, (v) => mid - Math.max(-range, Math.min(range, v)) * gain, mid, w, lw, fill);
|
||||
}
|
||||
function traceUni(ctx, buf, color, min, max, w, h, lw, fill) {
|
||||
const span = max - min;
|
||||
drawTrace(ctx, buf, color, (v) => h - 4 - Math.max(0, Math.min(1, (v - min) / span)) * (h - 8), h, w, lw, fill);
|
||||
}
|
||||
|
||||
/* 大三相示波器 */
|
||||
const big = $("#scopeCanvas");
|
||||
const bigCtx = big.getContext("2d");
|
||||
function drawBig() {
|
||||
const s = fit(big, bigCtx);
|
||||
if (!s) return;
|
||||
const w = s.w, h = s.h;
|
||||
bigCtx.clearRect(0, 0, w, h);
|
||||
grid(bigCtx, w, h);
|
||||
trace(bigCtx, hist.u, C.u, 5000, w, h, 1.6, false);
|
||||
trace(bigCtx, hist.v, C.v, 5000, w, h, 1.6, false);
|
||||
trace(bigCtx, hist.w, C.w, 5000, w, h, 1.6, false);
|
||||
}
|
||||
|
||||
/* 迷你示波器(每卡片独立)
|
||||
* angle 用 auto: true 自适应 Y 轴(角度可能 >360 多圈累计或为负,
|
||||
* 固定 0~360 会让波形贴顶/贴底或越界不可见)。
|
||||
* 其余卡片量纲固定(velocity/current 对称范围,temp 单极性),保留 uni/range。 */
|
||||
const MINIS = {
|
||||
angle: { el: $("#miniAngle"), buf: hist.angle, auto: true, unit: "\u00B0", color: () => C.u },
|
||||
velocity: { el: $("#miniVelocity"), buf: hist.velocity, auto: true, unit: "deg/s", color: () => C.v },
|
||||
current: { el: $("#miniCurrent"), buf: hist.current, auto: true, unit: "mA", color: () => C.w },
|
||||
mcuTemp: { el: $("#miniMcuTemp"), buf: hist.mcuTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.v },
|
||||
motTemp: { el: $("#miniMotTemp"), buf: hist.motTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.w },
|
||||
};
|
||||
/* ctx 与宿主卡片引用都在初始化时取一次:卡片 DOM 是静态的,
|
||||
* 原来每帧每个 mini 都要 querySelector 一遍(6 个查询 × 30 次/s)。 */
|
||||
const MINI_LIST = Object.entries(MINIS).map(([sig, m]) => {
|
||||
m.ctx = m.el.getContext("2d");
|
||||
m.card = document.querySelector(`[data-signal="${sig}"]`);
|
||||
return m;
|
||||
});
|
||||
|
||||
const MONO = '"Cascadia Code","Fira Code",Consolas,monospace';
|
||||
const fmtTick = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "k" : String(v));
|
||||
|
||||
/* 自适应 Y 轴:扫 buf 取实际数据范围,加 10% padding,生成 3 个 nice ticks。
|
||||
* 用于 angle/velocity/current(各自范围不确定,固定轴会让波形拟合成直线或越界)。
|
||||
* 空缓冲/全零时用小范围居中显示,避免空白卡片无刻度。 */
|
||||
function autoScale(buf) {
|
||||
let lo = Infinity, hi = -Infinity, hasNonZero = false;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const v = buf[i];
|
||||
if (!Number.isFinite(v)) continue;
|
||||
if (v < lo) lo = v;
|
||||
if (v > hi) hi = v;
|
||||
if (v !== 0) hasNonZero = true;
|
||||
}
|
||||
/* 无有效数据或全零:小范围居中,等有数据后自然扩展 */
|
||||
if (!hasNonZero || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
||||
return { min: -1, max: 1, ticks: [1, 0, -1] };
|
||||
}
|
||||
/* 平坦线(lo===hi):围绕该值拓展小范围 */
|
||||
if (lo === hi) {
|
||||
const c = lo;
|
||||
const pad = Math.abs(c) * 0.2 || 1;
|
||||
return { min: c - pad, max: c + pad, ticks: [c + pad, c, c - pad] };
|
||||
}
|
||||
/* 留 10% padding,避免波形贴顶/贴底 */
|
||||
const span = hi - lo;
|
||||
const pad = span * 0.1 || 1;
|
||||
let min = lo - pad, max = hi + pad;
|
||||
/* nice 化:把范围对齐到 1/2/5 整数倍刻度,ticks 取三等分点 */
|
||||
const niceStep = niceStepSize(max - min, 3);
|
||||
min = Math.floor(min / niceStep) * niceStep;
|
||||
max = Math.ceil(max / niceStep) * niceStep;
|
||||
const ticks = [max, (max + min) / 2, min];
|
||||
return { min, max, ticks };
|
||||
}
|
||||
|
||||
/* 计算 nice 刻度步长:让 (max-min)/step ≈ targetTicks,并对齐到 1/2/5×10^k。 */
|
||||
function niceStepSize(range, targetTicks) {
|
||||
const rawStep = range / targetTicks;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const norm = rawStep / mag;
|
||||
let step;
|
||||
if (norm < 1.5) step = 1;
|
||||
else if (norm < 3) step = 2;
|
||||
else if (norm < 7) step = 5;
|
||||
else step = 10;
|
||||
return step * mag;
|
||||
}
|
||||
|
||||
function drawYScale(ctx, m, w, h) {
|
||||
/* auto 模式:drawMini 已把 m.uni/ticks 算好填入;其余模式用静态配置 */
|
||||
const yOf = m.uni
|
||||
? (v) => h - 4 - Math.max(0, Math.min(1, (v - m.uni.min) / (m.uni.max - m.uni.min))) * (h - 8)
|
||||
: (v) => h / 2 - Math.max(-m.range, Math.min(m.range, v)) * ((h / 2 - 4) / m.range);
|
||||
ctx.save();
|
||||
ctx.font = "8px " + MONO;
|
||||
ctx.textBaseline = "middle";
|
||||
for (const t of m.ticks) {
|
||||
const y = yOf(t);
|
||||
ctx.strokeStyle = "rgba(226,233,242,0.35)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(5, y); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(226,233,242,0.5)";
|
||||
ctx.fillText(fmtTick(Math.round(t)), 7, y);
|
||||
}
|
||||
if (m.unit) {
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = "rgba(226,233,242,0.35)";
|
||||
ctx.fillText(m.unit, w - ctx.measureText(m.unit).width - 4, 3);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMini(m) {
|
||||
const s = fit(m.el, m.ctx);
|
||||
if (!s) return;
|
||||
const w = s.w, h = s.h;
|
||||
/* auto 模式:每帧根据 buf 实际数据重算 Y 轴范围 + ticks */
|
||||
if (m.auto) {
|
||||
const sc = autoScale(m.buf);
|
||||
m.uni = { min: sc.min, max: sc.max };
|
||||
m.ticks = sc.ticks;
|
||||
}
|
||||
m.ctx.clearRect(0, 0, w, h);
|
||||
grid(m.ctx, w, h);
|
||||
if (m.uni) traceUni(m.ctx, m.buf, m.color(), m.uni.min, m.uni.max, w, h, 1.8, true);
|
||||
else trace(m.ctx, m.buf, m.color(), m.range, w, h, 1.8, true);
|
||||
drawYScale(m.ctx, m, w, h);
|
||||
}
|
||||
|
||||
/* 点击卡片 → 只切换自己的波形 */
|
||||
document.querySelectorAll(".big-reading, .temp-block").forEach((card) => {
|
||||
card.addEventListener("click", () => card.classList.toggle("open"));
|
||||
});
|
||||
|
||||
/* 采样:用真实解码的 STATUS 数据填充历史缓冲(1Hz 数据 → 平滑插值)。
|
||||
* node×channel 双键:只采当前通道的记录,避免波形混入其他通道同号电机数据。 */
|
||||
function sample() {
|
||||
const n = state.selectedNode;
|
||||
const motor = n != null ? state.motors.find((m) => m.node === n && m.ch === state.ch) : null;
|
||||
const d = motor ? state.decoded.get(motorKey(motor)) : null;
|
||||
const online = d && motor && motor.online;
|
||||
valid[head] = online ? 1 : 0;
|
||||
if (!online) {
|
||||
hist.u[head] = hist.v[head] = hist.w[head] = 0;
|
||||
hist.angle[head] = hist.velocity[head] = hist.current[head] = 0;
|
||||
hist.mcuTemp[head] = hist.motTemp[head] = 0;
|
||||
} else {
|
||||
/* 三相电流:用解码的 U/V/W 瞬时值(mA) */
|
||||
const targets = { u: d.uCurrent, v: d.vCurrent, w: d.wCurrent };
|
||||
for (const ch of ["u", "v", "w"]) {
|
||||
const prev = hist[ch][(head + BUF - 1) % BUF];
|
||||
hist[ch][head] = prev + (clampNum(targets[ch], -5000, 5000) - prev) * 0.5;
|
||||
}
|
||||
const pA = hist.angle[(head + BUF - 1) % BUF];
|
||||
hist.angle[head] = pA + (d.rtAngle - pA) * 0.4;
|
||||
const pV = hist.velocity[(head + BUF - 1) % BUF];
|
||||
hist.velocity[head] = pV + (d.rtVelocity - pV) * 0.4;
|
||||
const pC = hist.current[(head + BUF - 1) % BUF];
|
||||
hist.current[head] = pC + (d.rtCurrent - pC) * 0.4;
|
||||
const pMcu = hist.mcuTemp[(head + BUF - 1) % BUF];
|
||||
hist.mcuTemp[head] = pMcu + (d.mcuTemp - pMcu) * 0.1;
|
||||
const pMot = hist.motTemp[(head + BUF - 1) % BUF];
|
||||
hist.motTemp[head] = pMot + (d.motTemp - pMot) * 0.1;
|
||||
}
|
||||
head = (head + 1) % BUF;
|
||||
}
|
||||
|
||||
let lastSample = 0;
|
||||
/* 只有写入了新样本才重绘:采样节流在 33ms(≈30Hz),而 rAF 跟着显示器跑到
|
||||
* 160Hz —— 中间那 ~130 帧画的是同一份数据,像素一模一样,纯属白烧 GPU 进程。 */
|
||||
let dirty = true;
|
||||
function loop(ts) {
|
||||
if (ts - lastSample > 33) { sample(); lastSample = ts; dirty = true; }
|
||||
if (dirty) {
|
||||
dirty = false;
|
||||
drawBig();
|
||||
for (const m of MINI_LIST) {
|
||||
if (m.card && m.card.classList.contains("open")) drawMini(m);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
/* 清空历史(切电机/切通道时调用):缓冲清零 + 全部标记无效,波形回到
|
||||
* “无数据”虚线态,避免新旧电机的数据混在同一条曲线里慢慢冲刷。 */
|
||||
function reset() {
|
||||
for (const key of Object.keys(hist)) hist[key].fill(0);
|
||||
valid.fill(0);
|
||||
}
|
||||
return { reset };
|
||||
|
||||
return { reset };
|
||||
}
|
||||
-538
@@ -1,538 +0,0 @@
|
||||
/**
|
||||
* seq.js — 电机控制台命令序列(循环步骤 / 同步动作组 / sendBurst)
|
||||
* 从 main.js 外提,逻辑行为不变。
|
||||
* createSeqSys(ctx) 注入 MODES/state/sendBurst/toast 等。
|
||||
* late 绑定字段由 main.js 在装配后回填(stopSeq 等被连接断线回调使用)。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @param {object} ctx 见 main.js 装配处
|
||||
*/
|
||||
export function createSeqSys(ctx) {
|
||||
const {
|
||||
MODES, FIXED_MOTOR_NODES, state, clampNum, escapeHtml, $,
|
||||
cmdVal, syncCmdCommittedFromDom, saveUiState, debounceSaveUi,
|
||||
toast, showModal, inBusCooldown, sendBurst, stopRepeat,
|
||||
renderNodeList, nowMs, sleep, late,
|
||||
} = 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,
|
||||
renderSeqList,
|
||||
updateSeqUi,
|
||||
startSeq,
|
||||
stopSeq,
|
||||
sendSeqStepOnce,
|
||||
normStep,
|
||||
actionFromAny,
|
||||
};
|
||||
}
|
||||
+93
-175
@@ -42,33 +42,10 @@
|
||||
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
|
||||
import { staleLimitMs } from "./refresh-rate.js";
|
||||
import { deviceLink } from "../common/device-link.js";
|
||||
import {
|
||||
openEcatAdapter, startEcatMotorLoop, repairEcatDc,
|
||||
resolveEcatStartBody, readStoredEcatPeriod,
|
||||
ECAT_DEFAULT_ADAPTER,
|
||||
} from "../common/ecat-bus.js";
|
||||
import {
|
||||
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
|
||||
desktopEcatSendRaw, hasDesktopFastSend,
|
||||
} from "../common/desktop-bridge.js?v=20260924a";
|
||||
|
||||
function bytesToHexStr(bytes) {
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
s += bytes[i].toString(16).padStart(2, "0").toUpperCase();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function hexToBytes(hex) {
|
||||
const clean = String(hex || "").replace(/[^0-9a-fA-F]/g, "");
|
||||
const out = new Uint8Array(Math.floor(clean.length / 2));
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16);
|
||||
return out;
|
||||
}
|
||||
function u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); }
|
||||
function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); }
|
||||
const ID_GET_NODE_ID = (n) => canId(n, 0x0401);
|
||||
const ID_SET_NODE_ID = (n) => canId(n, 0x0400);
|
||||
} from "../common/desktop-bridge.js?v=20260923i";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数。
|
||||
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
|
||||
@@ -315,22 +292,39 @@ export function createEcatTransport(ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保网卡已打开 —— R6:策略实现在 js/common/ecat-bus.js 单点。
|
||||
* 服务刚被自动拉起时 g_master=None,任何 /api/motor/* 会回「未连接」。
|
||||
* 确保网卡已打开(从站已被发现)。
|
||||
*
|
||||
* 服务刚被自动拉起时是"没连接"状态(g_master=None),此时任何
|
||||
* /api/motor/* 都会回 {ok:false, log:["未连接"]}。这里代替用户去点
|
||||
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
|
||||
* 用户还是得开另一个页面点两下才能用。
|
||||
*
|
||||
* 幂等:后端的 open 路由会先 close 掉旧 master 再重开,
|
||||
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
|
||||
*/
|
||||
async function ensureOpen() {
|
||||
try {
|
||||
return await openEcatAdapter(api, adapterSubstr);
|
||||
} catch (e) {
|
||||
/* open 失败(网线/网卡名)保持非瞬时,让探测横幅给出可操作原因 */
|
||||
if (e && e.transient === undefined) e.transient = false;
|
||||
throw e;
|
||||
const st = await api("status", {}, 6000);
|
||||
if (st && st.ok === true) return st; // 已连接
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I210);
|
||||
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
|
||||
const r = await api("open", { substr: adapterSubstr }, 20000);
|
||||
if (!r || r.ok !== true) {
|
||||
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败";
|
||||
const err = new Error(msg);
|
||||
/* 网线没插/板子没供电属"外部条件不满足",不是瞬时抖动:
|
||||
* 标成 transient 会让页面一直转圈却不给红灯提示。
|
||||
* 但"适配器没找到"往往是网卡名不同,值得把候选列表原样冒泡上去。 */
|
||||
err.transient = false;
|
||||
throw err;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量健康:native/health 只读缓存(不抢 g_bus_lock、不 loop_stop)。
|
||||
* R6:是否健康的判定与 ecat-bus.isEcatBusHealthy 同源。
|
||||
* bus_open + loop=1 即后台 PDO 循环在跑,从站看门狗正被喂着 —— 这时候
|
||||
* 再打 motor/start 反而有害:它会先 loop_stop 再核实/重进 OP,热路径上
|
||||
* 既慢又可能撞前端 AbortSignal 假超时,文档也写明「已连接只复用」。
|
||||
*/
|
||||
async function readHealth() {
|
||||
const h = await api("native/health", {}, 2500);
|
||||
@@ -343,6 +337,16 @@ export function createEcatTransport(ctx) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 */
|
||||
async function isBusHealthy() {
|
||||
try {
|
||||
const h = await readHealth();
|
||||
return !!(h && h.busOpen && h.loop);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 motor/state 快照同步周期/自旋/钉核,供状态栏在跳过 start 后仍显示正确。 */
|
||||
function syncFromSnapshot(snap) {
|
||||
if (!snap) return;
|
||||
@@ -353,19 +357,12 @@ export function createEcatTransport(ctx) {
|
||||
lastSnapshot = snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保已进 OP 且后台 PDO 循环在跑。
|
||||
* R6:health 复用 + DC 250µs + motor/start 的策略在 ecat-bus.startEcatMotorLoop。
|
||||
*/
|
||||
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
||||
async function ensureStarted(reqPeriodMs) {
|
||||
/* 日常接入:循环还在跑就别动总线(start 会先 loop_stop) */
|
||||
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
|
||||
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
|
||||
if (!reqPeriodMs) {
|
||||
const startedR = await startEcatMotorLoop(api, {
|
||||
force: false,
|
||||
fromLocalStorage: true,
|
||||
period_ms: readStoredEcatPeriod(),
|
||||
});
|
||||
if (startedR.reused) {
|
||||
if (await isBusHealthy()) {
|
||||
started = true;
|
||||
try {
|
||||
const snap = unwrap(await api("motor/state", {}, 2500), "读电机快照");
|
||||
@@ -374,24 +371,52 @@ export function createEcatTransport(ctx) {
|
||||
} catch (_) { /* 健康位说在跑但快照暂不可读:仍按已启动处理 */ }
|
||||
return { ok: true, period_ms: periodMs, spin: spinOn };
|
||||
}
|
||||
const body = startedR.body || resolveEcatStartBody({
|
||||
fromLocalStorage: true, period_ms: readStoredEcatPeriod(),
|
||||
});
|
||||
periodMs = (startedR.result && startedR.result.period_ms) || body.period_ms || periodMs;
|
||||
if (startedR.result && typeof startedR.result.spin === "boolean") spinOn = startedR.result.spin;
|
||||
started = true;
|
||||
return startedR.result || {};
|
||||
}
|
||||
/* 显式周期覆盖(setPdoPeriod 等):强制 start,仍走 ecat-bus 的 DC 钳制 */
|
||||
const startedR = await startEcatMotorLoop(api, {
|
||||
force: true,
|
||||
period_ms: reqPeriodMs,
|
||||
});
|
||||
const body = startedR.body || resolveEcatStartBody({ period_ms: reqPeriodMs });
|
||||
periodMs = (startedR.result && startedR.result.period_ms) || body.period_ms || periodMs;
|
||||
if (startedR.result && typeof startedR.result.spin === "boolean") spinOn = startedR.result.spin;
|
||||
const body = {};
|
||||
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
|
||||
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。
|
||||
* 没显式给时带上 localStorage 里的上次取值(内核级 250us 档位能跨重启存活)。 */
|
||||
if (reqPeriodMs) body.period_ms = reqPeriodMs;
|
||||
else {
|
||||
try {
|
||||
if (!localStorage.getItem(LS_PERIOD_MIG)) {
|
||||
if (parseFloat(localStorage.getItem(LS_PERIOD)) === 4) {
|
||||
localStorage.removeItem(LS_PERIOD);
|
||||
}
|
||||
localStorage.setItem(LS_PERIOD_MIG, "1");
|
||||
}
|
||||
const v = parseFloat(localStorage.getItem(LS_PERIOD));
|
||||
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
|
||||
if (v > 0 && v !== 1) body.period_ms = v;
|
||||
else body.period_ms = 0.25;
|
||||
} catch (_) { body.period_ms = 0.25; }
|
||||
}
|
||||
/* EtherCAT 控制路径必须 DC SYNC0=250µs:
|
||||
* free-run 或 DC=1ms 时,GD32 processOutput 消费输出 PDO 会拖到 ~1s;
|
||||
* DC=250µs 时 motor/send → CAN 出口约 2ms。 */
|
||||
{
|
||||
let per = Number(body.period_ms) || 0.25;
|
||||
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
|
||||
body.period_ms = per;
|
||||
body.dc_cycle_us = Math.round(per * 1000);
|
||||
if (body.dc_cycle_us < 250) body.dc_cycle_us = 250;
|
||||
body.dc_shift_us = 0;
|
||||
}
|
||||
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
||||
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
|
||||
let spinPref = null;
|
||||
try {
|
||||
const v = localStorage.getItem(LS_SPIN);
|
||||
if (v === "0" || v === "1") spinPref = (v === "1");
|
||||
} catch (_) { /* 隐私模式下 localStorage 会抛,忽略即可 */ }
|
||||
if (spinPref !== null) body.spin = spinPref;
|
||||
/* motor/start 内部会在未进 OP 时自己跑 PREOP→SAFEOP→OP(还会自动重试一次),
|
||||
* 最坏约 24s,所以超时给 30s —— 给小了会在正常进 OP 的过程中假失败。 */
|
||||
const r = unwrap(await api("motor/start", body, 30000), "启动 PDO 循环");
|
||||
periodMs = r.period_ms || periodMs;
|
||||
if (typeof r.spin === "boolean") spinOn = r.spin;
|
||||
started = true;
|
||||
return startedR.result || {};
|
||||
return r;
|
||||
}
|
||||
|
||||
function xrxChangeKey(x) {
|
||||
@@ -589,9 +614,15 @@ export function createEcatTransport(ctx) {
|
||||
const h = await readHealth();
|
||||
if (!h || !h.busOpen) throw new Error("EtherCAT 服务未打开网卡");
|
||||
if (!h.loop) throw new Error("PDO 循环未运行");
|
||||
/* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc(250µs) */
|
||||
/* 自愈:DC/周期被配错(free-run 或 ≥1ms)时发送出口会到秒级 —— 拉回 250µs */
|
||||
try {
|
||||
await repairEcatDc(api);
|
||||
const raw = await api("native/health", {}, 2000);
|
||||
const dc = raw && raw.dc;
|
||||
const dcs = raw && raw.dc_cycle_us;
|
||||
const per = raw && raw.period_ms;
|
||||
if (dc !== 1 || dcs !== 250 || (per != null && Math.abs(per - 0.25) > 0.001)) {
|
||||
await api("motor/start", { period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0 }, 30000);
|
||||
}
|
||||
} catch (_) { /* 自愈失败仍按 health 结果返回 */ }
|
||||
const st = await api("status", {}, 2500);
|
||||
if (!st || st.ok !== true) throw new Error("从站状态不可读");
|
||||
@@ -757,94 +788,6 @@ export function createEcatTransport(ctx) {
|
||||
return versions.get(node) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* R5:ECAT 版本等待 —— drain + 采样循环(不绑主 tick)。
|
||||
* 旧 main.waitVersionEcat 逻辑迁到此处,行为不变。
|
||||
*/
|
||||
async waitVersion(node, opts) {
|
||||
const prevSeq = opts && opts.prevSeq;
|
||||
const sentAt = (opts && opts.sentAt) || 0;
|
||||
const timeoutMs = (opts && opts.timeoutMs) || 2500;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const sampleOnce = async () => {
|
||||
try { await drainRx(); } catch (_) {}
|
||||
const mapV = versions.get(node) || null;
|
||||
if (mapV && mapV.version &&
|
||||
(prevSeq == null || mapV.seq > prevSeq || (mapV.at && mapV.at >= sentAt))) {
|
||||
return mapV;
|
||||
}
|
||||
try {
|
||||
const x = await this.peekVersionXrx(node);
|
||||
if (x && x.version) return x;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
};
|
||||
while (Date.now() < deadline) {
|
||||
const v = await sampleOnce();
|
||||
if (v && v.version) return v;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* R5:节点参数 GET —— 发送走注入 sendFrame,回执走 motor/rx(takeNodeIdReport)。
|
||||
*/
|
||||
async queryNodeIdParam(node, selector) {
|
||||
const sendFrame = ctx.sendFrame;
|
||||
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
|
||||
const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]);
|
||||
const id = ID_GET_NODE_ID(node);
|
||||
const res = await sendFrame(id, 2, bytesToHexStr(bytes));
|
||||
if (!res.ok) {
|
||||
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
|
||||
}
|
||||
let rb = null;
|
||||
const r = res.results && res.results[0];
|
||||
if (r && r.recv && r.recv_len >= 2) {
|
||||
const b = hexToBytes(r.recv);
|
||||
if (b.length === 2 || b.length === 4) rb = b;
|
||||
}
|
||||
if (!rb) {
|
||||
const deadline = Date.now() + 1500;
|
||||
while (Date.now() < deadline && !rb) {
|
||||
await new Promise((r2) => setTimeout(r2, 120));
|
||||
const hex = nodeIdReports.get(node);
|
||||
if (hex) { nodeIdReports.delete(node); rb = hexToBytes(hex); }
|
||||
else { try { await drainRx(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
return parseParamReplyEc(rb, selector);
|
||||
},
|
||||
|
||||
/** R5:节点参数 SET —— 回执走 motor/rx(5B ACK / 旧格式)。 */
|
||||
async setNodeIdParam(node, selector, value) {
|
||||
const sendFrame = ctx.sendFrame;
|
||||
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
|
||||
const bytes = new Uint8Array([
|
||||
selector & 0xFF, (selector >> 8) & 0xFF,
|
||||
value & 0xFF, (value >> 8) & 0xFF,
|
||||
]);
|
||||
const id = ID_SET_NODE_ID(node);
|
||||
const res = await sendFrame(id, 4, bytesToHexStr(bytes));
|
||||
if (!res.ok) {
|
||||
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
|
||||
}
|
||||
let rb = null;
|
||||
const r = res.results && res.results[0];
|
||||
if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv);
|
||||
if (!rb) {
|
||||
const deadline = Date.now() + 1500;
|
||||
while (Date.now() < deadline && !rb) {
|
||||
await new Promise((r2) => setTimeout(r2, 120));
|
||||
const hex = nodeIdReports.get(node);
|
||||
if (hex) { nodeIdReports.delete(node); rb = hexToBytes(hex); }
|
||||
else { try { await drainRx(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
return parseSetReplyEc(rb, selector);
|
||||
},
|
||||
|
||||
/** 版本查询等待期强制消费 motor/rx(不依赖 rx_batch.count)。 */
|
||||
drainPendingRx() {
|
||||
return drainRx();
|
||||
@@ -973,28 +916,3 @@ export function createEcatTransport(ctx) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseParamReplyEc(rb, selector) {
|
||||
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
|
||||
if (rb.length >= 4) {
|
||||
const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2);
|
||||
if (sel !== selector) {
|
||||
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
|
||||
}
|
||||
return { ok: true, val };
|
||||
}
|
||||
if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) };
|
||||
return { ok: false, desc: "应答格式异常" };
|
||||
}
|
||||
|
||||
function parseSetReplyEc(rb, selector) {
|
||||
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
|
||||
if (rb.length >= 5) {
|
||||
const st = rb[0], sel = u16LeAt(rb, 1);
|
||||
if (sel !== selector) {
|
||||
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
|
||||
}
|
||||
return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" };
|
||||
}
|
||||
return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" };
|
||||
}
|
||||
|
||||
@@ -22,29 +22,9 @@
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
function bytesToHexStr(bytes) {
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
s += bytes[i].toString(16).padStart(2, "0").toUpperCase();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function hexToBytes(hex) {
|
||||
const clean = String(hex || "").replace(/[^0-9a-fA-F]/g, "");
|
||||
const out = new Uint8Array(Math.floor(clean.length / 2));
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16);
|
||||
return out;
|
||||
}
|
||||
function u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); }
|
||||
function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); }
|
||||
const ID_GET_NODE_ID = (n) => canId(n, 0x0401);
|
||||
const ID_SET_NODE_ID = (n) => canId(n, 0x0400);
|
||||
|
||||
/** @description 创建 WiFi/HTTP 传输实例。 */
|
||||
export function createWifiTransport(ctx) {
|
||||
const { getBaseUrl, recordSlow, isTransient } = ctx;
|
||||
const sendFrame = ctx.sendFrame;
|
||||
const getCh = typeof ctx.getCh === "function" ? ctx.getCh : () => 0;
|
||||
|
||||
/** 统一 GET JSON。失败抛 Error,附 status / transient(与原 fetchDeviceJson 一致)。 */
|
||||
async function getJson(path, timeoutMs) {
|
||||
@@ -249,99 +229,6 @@ export function createWifiTransport(ctx) {
|
||||
return data.nodes.find((n) => n.node === node) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* R5:版本等待下沉传输层。
|
||||
* WiFi 走 GD32 缓存 /motor/version,按 seq 增长或 at>=sentAt 判新应答。
|
||||
* 超时返回 null(与旧 waitMotorVersionOnTick 语义一致)。
|
||||
*/
|
||||
async waitVersion(node, opts) {
|
||||
const prevSeq = opts && opts.prevSeq;
|
||||
const sentAt = (opts && opts.sentAt) || 0;
|
||||
const timeoutMs = (opts && opts.timeoutMs) || 5000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const sampleOnce = async () => {
|
||||
try {
|
||||
const v = await getJson("/motor/version", 2000);
|
||||
if (!v || !v.have_data || !Array.isArray(v.nodes)) return null;
|
||||
const hit = v.nodes.find((n) => n.node === node) || null;
|
||||
if (hit && hit.version &&
|
||||
(prevSeq == null || hit.seq > prevSeq || (hit.at && hit.at >= sentAt))) {
|
||||
return hit;
|
||||
}
|
||||
} catch (_) { /* 瞬时 busy 忽略 */ }
|
||||
return null;
|
||||
};
|
||||
let v = await sampleOnce();
|
||||
if (v) return v;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
v = await sampleOnce();
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* R5:节点参数 GET(0x000N0401 扩展格式)。
|
||||
* 发送走注入的 sendOneFrameWhenFree(保持 main 流控);回执走 GD32 per-ID 快照。
|
||||
*/
|
||||
async queryNodeIdParam(node, selector) {
|
||||
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
|
||||
const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]);
|
||||
const id = ID_GET_NODE_ID(node);
|
||||
const res = await sendFrame(id, 2, bytesToHexStr(bytes));
|
||||
if (!res.ok) {
|
||||
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
|
||||
}
|
||||
let rb = null;
|
||||
const r = res.results && res.results[0];
|
||||
if (r && r.recv && r.recv_len >= 2) {
|
||||
const b = hexToBytes(r.recv);
|
||||
if (b.length === 2 || b.length === 4) rb = b;
|
||||
}
|
||||
if (!rb) {
|
||||
await new Promise((r2) => setTimeout(r2, 80));
|
||||
try {
|
||||
const fb = await getJson("/can/feedback", 2500);
|
||||
const ch = (fb && fb.channels) ? fb.channels[getCh()] : null;
|
||||
if (ch && ch.valid && Array.isArray(ch.entries)) {
|
||||
const hit = ch.entries.find((e) => e && Number(e.id) === id);
|
||||
if (hit) rb = hexToBytes(hit.data);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return parseParamReply(rb, selector);
|
||||
},
|
||||
|
||||
/** R5:节点参数 SET(0x000N0400 扩展格式 DLC4)。 */
|
||||
async setNodeIdParam(node, selector, value) {
|
||||
if (typeof sendFrame !== "function") return { ok: false, desc: "sendFrame 未注入" };
|
||||
const bytes = new Uint8Array([
|
||||
selector & 0xFF, (selector >> 8) & 0xFF,
|
||||
value & 0xFF, (value >> 8) & 0xFF,
|
||||
]);
|
||||
const id = ID_SET_NODE_ID(node);
|
||||
const res = await sendFrame(id, 4, bytesToHexStr(bytes));
|
||||
if (!res.ok) {
|
||||
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
|
||||
}
|
||||
let rb = null;
|
||||
const r = res.results && res.results[0];
|
||||
if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv);
|
||||
if (!rb) {
|
||||
await new Promise((r2) => setTimeout(r2, 80));
|
||||
try {
|
||||
const fb = await getJson("/can/feedback", 2500);
|
||||
const ch = (fb && fb.channels) ? fb.channels[getCh()] : null;
|
||||
if (ch && ch.valid && Array.isArray(ch.entries)) {
|
||||
const hit = ch.entries.find((e) => e && Number(e.id) === id);
|
||||
if (hit) rb = hexToBytes(hit.data);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return parseSetReply(rb, selector);
|
||||
},
|
||||
|
||||
/** 把前端刷新周期同步给 GD32(0xC5 主动推送周期),失败静默。 */
|
||||
async setPeriod(ms) {
|
||||
const v = Math.max(20, Math.min(65535, Math.round(ms)));
|
||||
@@ -359,28 +246,3 @@ export function createWifiTransport(ctx) {
|
||||
async teardown() { /* no-op */ },
|
||||
};
|
||||
}
|
||||
|
||||
function parseParamReply(rb, selector) {
|
||||
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
|
||||
if (rb.length >= 4) {
|
||||
const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2);
|
||||
if (sel !== selector) {
|
||||
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
|
||||
}
|
||||
return { ok: true, val };
|
||||
}
|
||||
if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) };
|
||||
return { ok: false, desc: "应答格式异常" };
|
||||
}
|
||||
|
||||
function parseSetReply(rb, selector) {
|
||||
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
|
||||
if (rb.length >= 5) {
|
||||
const st = rb[0], sel = u16LeAt(rb, 1);
|
||||
if (sel !== selector) {
|
||||
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
|
||||
}
|
||||
return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" };
|
||||
}
|
||||
return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" };
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
|
||||
* sendBurst(frames, {suffix,dlc}) -> results[]
|
||||
* fetchVersionNode(node) -> {node,seq,version,at?}|null
|
||||
* waitVersion(node, {prevSeq,sentAt,timeoutMs}) -> version|null
|
||||
* R5:版本等待下沉传输层,main 不再区分 wifi/ecat 双实现
|
||||
* queryNodeIdParam(node, selector) -> {ok,val?,oldFw?,desc?}
|
||||
* setNodeIdParam(node, selector, value) -> {ok,desc?}
|
||||
* R5:节点参数回执通道(WiFi 快照 / ECAT motor/rx)各在传输内消化
|
||||
* 可选:drainPendingRx() -> Promise 版本等待期强制消费批量接收区(ECAT)
|
||||
* setPeriod(ms) -> void 不支持则空实现
|
||||
* teardown() -> void 切走时收尾
|
||||
@@ -36,7 +31,7 @@
|
||||
"use strict";
|
||||
|
||||
import { createWifiTransport } from "./transport-wifi.js";
|
||||
import { createEcatTransport } from "./transport-ecat.js?v=20260924a";
|
||||
import { createEcatTransport } from "./transport-ecat.js?v=20260923i";
|
||||
|
||||
/** localStorage key:记住上次选的传输,刷新页面后保持 */
|
||||
export const LS_TRANSPORT = "dcu_motor_transport";
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
<title>电机控制台 — Motor CAN FD Console</title>
|
||||
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="../css/motor.css?v=20260924a">
|
||||
<link rel="stylesheet" href="../css/motor.css?v=20260924c">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="../js/motor/main.js?v=20260924a"></script>
|
||||
<script type="module" src="../js/motor/main.js?v=20260924c"></script>
|
||||
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
|
||||
<script src="../js/common/app-lease.js"></script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user