/** * motor/main.js - 电机控制台页面逻辑(ES Module 装配入口) * * P1 拆分(R4–R7):本文件只做装配与页面共用业务(解码/发送流控/UI), * 大块职责外提: * ./scope.js 示波器 * ./ota.js 电机 OTA + IDB * ./seq.js 命令序列 * ./conn-poll.js 探测/失败计数/轮询调度 * ./transport*.js 链路 I/O + waitVersion / queryNodeIdParam(R5) * ../common/ecat-bus.js ECAT bootstrap 单点(R6) * ../common/toast-stack.js 堆叠 toast + setQuietMode(R7) * 逻辑行为相对拆分前保持不变;不改 CAN 协议解码与流控语义。 */ /* ═══════════════════════════════════════════════════════════════ 电机控制台 — 双传输:WiFi(HTTP) / EtherCAT(过程数据),业务逻辑完全共用 传输由工具栏「链路」下拉框切换(记在 localStorage dcu_motor_transport)。 两种传输的差异全部收敛在 js/motor/transport-*.js 里,本文件只经 state.tp.* 调用抽象接口 —— 解码器、节点列表、STATUS 面板、波形、 命令序列、以及全部流控策略(互斥/退避/冷却/latest-wins)两边共用。 WiFi 传输(原有,共用 OTA 主页设备 IP localStorage dcu_esp_ip): GET /motor/state?bin=1 refreshIntervalMs 驱动(WiFi 默认 250ms,50~5000 可调) POST /can/send_batch 发送入口(CMD / CONFIG / VERSION / OTA) GET /can/feedback 1Hz → 状态栏 RX/TX/ERR/TEC/REC/BUS GET /system/status 进入页自动探测 + 5s 健康检查 / 断线 2s 探测 EtherCAT 传输(经本地页面服务器 /ecat/api/* 代理到 tools/ecat 的 ecat_native.exe): POST /api/motor/state 读后台 PDO 循环发布的快照(不碰总线,默认 50ms 读一次) POST /api/motor/send 多帧塞进同一 PDO 周期的批量区(同一 seq 沿发出) POST /api/motor/rx 消费批量接收区(CONFIG/版本/节点ID 应答走这里) 刷新速度按链路各存一份(state.refreshByTp / REFRESH_DEFAULTS):两条链路的 往返代价差一个数量级,切链路时自动换成该链路自己的值,互不污染。 两者的差异(每通道 TEC/REC 缺失、CONFIG 需前端累积、无 OTA 链路) 见 transport-ecat.js 头部注释。 CAN ID(29-bit 扩展,物理 ID,不含 0x80000000 标志): STATUS 0x000N0100 · CMD 0x000N0200(DLC32) · CMD_CONFIG 0x000N0301(DLC24) GET_VERSION 请求/回报共用 0x000N00FF(同 ID, DLC24) CANFD_BOOT 0x000N0000 · NODE_ID SET/GET 0x000N0400/0401 (扩展格式 DLC4/2:选择器 0x0000=节点ID · 0x0100/0x0101=STATUS/CONFIG 刷新周期 ms) ═══════════════════════════════════════════════════════════════ */ "use strict"; import { DEFAULT_ESP_IP, saveDeviceIp } from "../common/base-url.js"; import { escapeHtml, flashBusyNow, setTextIfChanged, setClassIfChanged, setStyleIfChanged, toggleClsIfChanged as toggleClsIfChangedUtil, setQuietMode, } from "../common/utils.js"; import { createToastStack } from "../common/toast-stack.js?v=20260924a"; import { createTransport, loadTransportId, saveTransportId, LS_TRANSPORT } from "./transport.js?v=20260924a"; import { deviceLink } from "../common/device-link.js?v=20260924a"; import { REFRESH_DEFAULTS, REFRESH_MAX_MS, defaultRefreshMap, normalizeRefreshMap, pickRefresh, minRefreshFor, isUnthrottled } from "./refresh-rate.js"; import { createScopeSys } from "./scope.js?v=20260924a"; import { createSeqSys } from "./seq.js?v=20260924a"; import { createOtaSys } from "./ota.js?v=20260924a"; import { createConnPoll } from "./conn-poll.js?v=20260924a"; /* ─── 控制模式定义 ─── */ const MODES = { 0: { name: "Disable", zh: "关闭", targets: [] }, 1: { name: "Angle", zh: "位置控制", targets: ["angle"] }, 2: { name: "Velocity", zh: "速度控制", targets: ["velocity"] }, 3: { name: "Velocity_Angle", zh: "速度+位置", targets: ["velocity", "angle"] }, 4: { name: "MIT", zh: "MIT Mode", targets: ["current", "velocity", "angle"] }, 5: { name: "Current", zh: "电流/力矩", targets: ["current"] }, 6: { name: "OpenLoop", zh: "开环速度", targets: ["velocity"] }, 7: { name: "OpenLoop2", zh: "开环速度(2)", targets: ["velocity"] }, }; const MODE_DESCS = { 0: "电机关闭输出,进入自由旋转状态", 1: "闭环位置控制,目标角度生效", 2: "闭环速度控制,目标速度生效", 3: "速度+位置环联合控制,目标速度与目标角度同时生效", 4: "MIT 力控模式,电流/速度/角度目标均参与计算", 5: "电流环直接控制(力矩模式),目标电流生效", 6: "开环速度控制,无编码器反馈", 7: "开环速度控制(备用通道)", }; /* ─── 全局状态 ─── */ const LS_MOTOR_UI = "dcu_motor_ui_v2"; const state = { connected: false, connecting: false, ip: DEFAULT_ESP_IP, tp: null, tpId: "wifi", probeErr: null, probeErrFull: null, ch: 0, chEnabled: [true, true, true], chOnline: [0, 0, 0], selectedNode: null, motors: [], bootActive: false, slaveAl: null, slaveOp: false, decoded: new Map(), decodedConfig: new Map(), versions: new Map(), refreshIntervalMs: 250, refreshByTp: defaultRefreshMap(), lastSentMode: new Map(), lastSentTargets: new Map(), sendInFlight: false, pendingCmd: null, busCooldownUntil: 0, txCount: 0, poll: { tick: null, probe: null, status: null, feedback: null }, pollGen: 0, tickInFlight: false, tickStuck: 0, versionWait: null, feedbackInFlight: false, statusInFlight: false, failCount: 0, probing: false, probeGen: 0, lastConnToastAt: 0, offlineSince: 0, tickN: 0, recvTs: [], statusTickN: 0, phaseDebug: { lastSlowReq: null, slowStats: {} }, }; /* 跨模块 late 绑定:连接/OTA 回调需要 stopSeq 等,模块创建后由 main 回填 */ const late = {}; /* 控制台诊断入口(不删) */ window.motorDbg = { state, get tickWorker() { return late.connSys ? late.connSys.getTickWorker() : null; }, get tickUseFallback() { return late.connSys ? late.connSys.getTickUseFallback() : false; }, get transport() { return state.tp && state.tp.id; }, get transportDebug() { return state.tp && state.tp.debug ? state.tp.debug() : null; }, }; /* R7:堆叠 toast(#toastContainer),不再内联实现 */ const toast = createToastStack("#toastContainer"); const TICK_INTERVAL_MS = 250; const MIN_CMD_PERIOD_MS = 80; /* ─── DOM refs ─── */ const $ = (s) => document.querySelector(s); const nodeListEl = $("#nodeList"); const FIXED_MOTOR_NODES = [1, 2, 3, 4, 5, 6, 7, 8, 9]; function getSharedDeviceIp() { return deviceLink.getIp(); } /* ─── 输入持久化 ─── */ const PERSIST_FIELDS = [ "modeSelect", "inVoltage", "inVelocity", "inAngle", "inCurrent", "pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect", "nodeIdInput", "periodStatusInput", "periodConfigInput", ]; const UI_STATE_PATH_LABEL = "data/motor_ui_state.json"; function collectUiSnapshot() { const o = { savedAt: Date.now(), v: 2 }; for (const id of PERSIST_FIELDS) { const el = document.getElementById(id); if (el) o[id] = el.value; } if (state.selectedNode != null) o.selectedNode = state.selectedNode; o.refreshByTp = Object.assign({}, state.refreshByTp); o.tpId = state.tpId; try { o.transport = localStorage.getItem(LS_TRANSPORT) || state.tpId; o.ecat = { adapter: localStorage.getItem("dcu_ecat_adapter") || null, period: localStorage.getItem("dcu_ecat_period_ms") || null, spin: localStorage.getItem("dcu_ecat_spin") || null, }; } catch (_) { o.transport = state.tpId; } if (seq.steps.length > 0 || seq.loops !== 0) { o.seq = { loops: seq.loops, steps: seq.steps }; } if (state.versions && state.versions.size > 0) { const vo = {}; for (const [n, v] of state.versions) { if (v && (v.soft || v.hard || v.boot)) { vo[n] = { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null }; } } o.versions = vo; } return o; } function applyUiSnapshot(o) { if (!o || typeof o !== "object") return; for (const id of PERSIST_FIELDS) { const el = document.getElementById(id); if (el && o[id] != null) el.value = o[id]; } if (o.selectedNode != null) state.selectedNode = o.selectedNode; if (o.seq && Array.isArray(o.seq.steps)) { seq.steps = o.seq.steps .filter((s) => s && typeof s === "object") .map(normStep); seq.loops = clampNum(parseInt(o.seq.loops, 10) || 0, 0, 9999); } if (o.versions && typeof o.versions === "object") { for (const [n, v] of Object.entries(o.versions)) { const node = parseInt(n, 10); if (Number.isFinite(node) && v) { state.versions.set(node, { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null, }); } } } state.refreshByTp = normalizeRefreshMap(o.refreshByTp, o.refreshRate); try { if (o.transport) localStorage.setItem(LS_TRANSPORT, String(o.transport)); if (o.ecat && typeof o.ecat === "object") { if (o.ecat.adapter) localStorage.setItem("dcu_ecat_adapter", String(o.ecat.adapter)); if (o.ecat.period != null && o.ecat.period !== "") { localStorage.setItem("dcu_ecat_period_ms", String(o.ecat.period)); } if (o.ecat.spin === "0" || o.ecat.spin === "1") { localStorage.setItem("dcu_ecat_spin", String(o.ecat.spin)); } } } catch (_) {} syncCmdCommittedFromDom(); const chEl = document.getElementById("chSelect"); if (chEl && chEl.value !== "" && chEl.value != null) { const ch = parseInt(chEl.value, 10); if (Number.isFinite(ch) && ch >= 0 && ch <= 2) state.ch = ch; } } function saveUiState() { const o = collectUiSnapshot(); try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(o)); } catch (_) {} scheduleDiskSave(o); updateUiSaveHint("saved"); } function loadUiState() { try { const raw = localStorage.getItem(LS_MOTOR_UI); if (!raw) return; const o = JSON.parse(raw); applyUiSnapshot(o); } catch (_) {} } let _diskTimer = null; let _diskPayload = null; let _diskInFlight = false; function scheduleDiskSave(snapshot) { _diskPayload = snapshot || collectUiSnapshot(); if (_diskTimer) clearTimeout(_diskTimer); _diskTimer = setTimeout(flushDiskSave, 600); } function flushDiskSave() { if (_diskTimer) { clearTimeout(_diskTimer); _diskTimer = null; } const o = _diskPayload || collectUiSnapshot(); _diskPayload = null; const body = JSON.stringify(o); _diskInFlight = true; fetch("/api/motor/ui-state", { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true, }).then((r) => r.json()).then((j) => { _diskInFlight = false; if (j && j.ok) updateUiSaveHint("disk", j.path || UI_STATE_PATH_LABEL); else updateUiSaveHint("disk-err"); }).catch(() => { _diskInFlight = false; try { const blob = new Blob([body], { type: "application/json" }); if (navigator.sendBeacon && navigator.sendBeacon("/api/motor/ui-state", blob)) { updateUiSaveHint("disk", UI_STATE_PATH_LABEL); return; } } catch (_) {} updateUiSaveHint("disk-err"); }); } function flushUiOnHide() { if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } const o = collectUiSnapshot(); try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(o)); } catch (_) {} const body = JSON.stringify(o); try { fetch("/api/motor/ui-state", { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true, }).catch(() => { try { navigator.sendBeacon("/api/motor/ui-state", new Blob([body], { type: "application/json" })); } catch (_) {} }); } catch (_) { try { navigator.sendBeacon("/api/motor/ui-state", new Blob([body], { type: "application/json" })); } catch (_) {} } } window.addEventListener("pagehide", flushUiOnHide); window.addEventListener("beforeunload", flushUiOnHide); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") flushUiOnHide(); }); async function hydrateUiFromDisk() { let disk = null; try { const r = await fetch("/api/motor/ui-state", { cache: "no-store" }); if (r.ok) { const j = await r.json(); disk = (j && j.ok && j.state) ? j.state : null; } } catch (_) { return; } if (!disk || typeof disk !== "object") { updateUiSaveHint("no-disk"); return; } let local = null; try { local = JSON.parse(localStorage.getItem(LS_MOTOR_UI) || "null"); } catch (_) {} const diskAt = Number(disk.savedAt) || 0; const localAt = local ? (Number(local.savedAt) || 0) : 0; if (local && localAt > diskAt) { updateUiSaveHint("disk", UI_STATE_PATH_LABEL); return; } applyUiSnapshot(disk); try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(disk)); } catch (_) {} applyModeVisibility(); renderSeqList(); if (typeof renderNodeList === "function") renderNodeList(); updateNodeLabels(); state.ch = clampNum(parseInt(($("#chSelect") || {}).value, 10) || 0, 0, 2); const chEl = $("#chSelect"); if (chEl) chEl.value = String(state.ch); applyTransportRefresh(); updateChSelectOptions(); if (typeof refreshChannelViews === "function") refreshChannelViews(); updateUiSaveHint("disk", UI_STATE_PATH_LABEL); } function updateUiSaveHint(mode, path) { const el = $("#uiSaveHint"); if (!el) return; const p = path || UI_STATE_PATH_LABEL; if (mode === "saved") { el.textContent = "配置保存中…"; el.title = "同时写 localStorage 与 " + p; } else if (mode === "disk") { el.textContent = "已存 " + p; el.title = "配置已写入电脑文件,关页/重启后会自动恢复"; } else if (mode === "disk-err") { el.textContent = "落盘失败(仅 localStorage)"; el.title = "本地页面服务可能未运行;配置仍存在浏览器里,但清缓存会丢"; } else if (mode === "no-disk") { el.textContent = "暂无磁盘备份"; el.title = "改动任意配置或关页后会生成 " + p; } } function exportUiState() { const o = collectUiSnapshot(); const blob = new Blob([JSON.stringify(o, null, 2)], { type: "application/json" }); const a = document.createElement("a"); const ts = new Date(); const pad = (n) => String(n).padStart(2, "0"); const name = `motor_ui_${ts.getFullYear()}${pad(ts.getMonth() + 1)}${pad(ts.getDate())}_${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.json`; a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); saveUiState(); } function importUiStateFile(file) { if (!file) return; const rd = new FileReader(); rd.onload = () => { try { const o = JSON.parse(String(rd.result || "")); if (!o || typeof o !== "object") throw new Error("不是配置对象"); applyUiSnapshot(o); applyModeVisibility(); renderSeqList(); applyTransportRefresh(); saveUiState(); toast("配置已导入并保存", "ok"); } catch (e) { toast("导入失败: " + (e && e.message), "err"); } }; rd.readAsText(file); } function bindAutoSave() { for (const id of PERSIST_FIELDS) { const el = document.getElementById(id); if (el) { el.addEventListener("change", saveUiState); if (el.tagName === "INPUT") { el.addEventListener("input", debounceSaveUi); } } } const loops = $("#seqLoops"); if (loops) { loops.addEventListener("change", () => { seq.loops = clampNum(parseInt(loops.value, 10) || 0, 0, 9999); saveUiState(); }); } const exp = $("#uiExportBtn"); if (exp) exp.addEventListener("click", exportUiState); const impBtn = $("#uiImportBtn"); const impInput = $("#uiImportInput"); if (impBtn && impInput) { impBtn.addEventListener("click", () => impInput.click()); impInput.addEventListener("change", () => { importUiStateFile(impInput.files && impInput.files[0]); impInput.value = ""; }); } } let _saveTimer = null; function debounceSaveUi() { if (_saveTimer) clearTimeout(_saveTimer); _saveTimer = setTimeout(saveUiState, 300); } function setSharedDeviceIp(ip) { if (!ip || !String(ip).trim()) return; const v = String(ip).trim(); state.ip = v; try { deviceLink.setIp(v); } catch (_) {} saveDeviceIp(v); const label = $("#ipLabel"); if (label) label.textContent = "IP " + v; } function getBaseUrl() { return "http://" + state.ip; } function pad2(n) { return n.toString(16).padStart(2, "0").toUpperCase(); } function hexId(id) { return "0x" + id.toString(16).toUpperCase().padStart(8, "0"); } function nodeHex(n) { return n.toString(16).toUpperCase(); } function nowMs() { return Date.now(); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function clampNum(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); } const ID_STATUS = (n) => canId(n, 0x0100); const ID_CMD = (n) => canId(n, 0x0200); const ID_CMD_CONFIG = (n) => canId(n, 0x0301); const ID_GET_VERSION_REQ = (n) => canId(n, 0x00FF); const ID_SET_NODE_ID = (n) => canId(n, 0x0400); const ID_GET_NODE_ID = (n) => canId(n, 0x0401); function hexToBytes(hex) { const clean = (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 bytesToHexSpaced(bytes, maxBytes) { const n = maxBytes ? Math.min(bytes.length, maxBytes) : bytes.length; let s = ""; for (let i = 0; i < n; i++) s += pad2(bytes[i]) + " "; return s.trim(); } /* ─── Modal ─── */ let modalResolve = null; function showModal(title, desc) { return new Promise((resolve) => { modalResolve = resolve; $("#modalTitle").textContent = title; $("#modalDesc").textContent = desc; $("#modalMask").classList.add("show"); }); } $("#modalCancel").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(false); }; $("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(true); }; /* ─── STATUS / CONFIG 解码 ─── */ function decodeStatus(bytes) { if (!bytes || bytes.length < 32) return null; const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); const copy = new Uint8Array(36); copy.set(u8.subarray(0, Math.min(36, u8.length))); const dv = new DataView(copy.buffer); const b10 = copy[10]; const f32 = (off) => { const v = dv.getFloat32(off, true); return Number.isFinite(v) ? v : 0; }; return { mcuTemp: dv.getInt16(0, true) * 0.01, motTemp: dv.getInt16(2, true) * 0.01, uCurrent: dv.getInt16(4, true), vCurrent: dv.getInt16(6, true), wCurrent: dv.getInt16(8, true), motStatus: (b10 & 0x01) ? 1 : 0, magStatus: (b10 & 0x02) ? 1 : 0, thermalStatus: (b10 & 0x04) ? 1 : 0, eepromStatus: (b10 >> 3) & 0x07, powerVoltage: dv.getUint16(11, true) * 0.001, rtAngle: f32(13), rtVelocity: f32(17), rtCurrent: f32(21), testFloat: f32(25), testU16: dv.getUint16(29, true), cpuUsage: copy[31], initStatus: u8.length >= 36 ? dv.getUint32(32, true) : null, }; } function decodeConfig(hexStr) { const b = hexToBytes(hexStr); if (!b || b.length < 16) return null; const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); const f32 = (off) => { if (off + 4 > b.length) return 0; const v = dv.getFloat32(off, true); return Number.isFinite(v) ? v : 0; }; return { targetMode: b[0], targetVoltage: f32(4), targetCurrent: f32(8), targetVelocity: f32(12), targetAngle: f32(16), }; } function faultShortParts(d) { if (!d) return []; const p = []; if (d.motStatus) p.push("电机故障"); if (d.magStatus) p.push("磁编码"); if (d.thermalStatus) p.push("过温"); if (d.eepromStatus) p.push("EEPROM"); return p; } function faultShortLabel(d) { return faultShortParts(d).join(" · "); } function faultCountOf(d) { return faultShortParts(d).length; } /* ─── CMD 编码 / 提交制 ─── */ function syncCmdCommittedFromDom() { /* 由 initCmdCommit 注册的实现覆盖/配合 */ } const _cmdCommit = { vals: {} }; function initCmdCommit() { const ids = ["inVoltage", "inCurrent", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP"]; const readOne = (id) => { const el = document.getElementById(id); return el ? el.value : ""; }; const commit = () => { for (const id of ids) _cmdCommit.vals[id] = readOne(id); }; for (const id of ids) { const el = document.getElementById(id); if (!el) continue; el.addEventListener("change", commit); } commit(); } function syncCmdCommittedFromDomImpl() { const ids = ["inVoltage", "inCurrent", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP"]; for (const id of ids) { const el = document.getElementById(id); if (el) _cmdCommit.vals[id] = el.value; } } syncCmdCommittedFromDom = syncCmdCommittedFromDomImpl; function cmdVal(id) { if (_cmdCommit.vals[id] != null && _cmdCommit.vals[id] !== "") return _cmdCommit.vals[id]; const el = document.getElementById(id); return el ? el.value : ""; } function buildCmdBytes(values) { const bytes = new Uint8Array(32); const mode = parseInt(values.mode, 10) || 0; bytes[0] = mode & 0xFF; const dv = new DataView(bytes.buffer); const f32 = (off, v) => { const n = parseFloat(v); dv.setFloat32(off, Number.isFinite(n) ? n : 0, true); }; const u16 = (off, v) => { const n = parseInt(v, 10); dv.setUint16(off, Number.isFinite(n) ? n : 0, true); }; u16(4, values.voltage); f32(8, values.current); f32(12, values.velocity); f32(16, values.angle); f32(20, values.pidCurP); f32(24, values.pidVelP); f32(28, values.pidAngP); return bytes; } function buildFormCmdBytes(opts) { opts = opts || {}; const mode = opts.mode != null ? opts.mode : ($("#modeSelect") ? $("#modeSelect").value : "0"); return buildCmdBytes({ mode, voltage: opts.voltage != null ? opts.voltage : cmdVal("inVoltage"), current: opts.current != null ? opts.current : cmdVal("inCurrent"), velocity: opts.velocity != null ? opts.velocity : cmdVal("inVelocity"), angle: opts.angle != null ? opts.angle : cmdVal("inAngle"), pidCurP: cmdVal("pidCurP"), pidVelP: cmdVal("pidVelP"), pidAngP: cmdVal("pidAngP"), }); } function bytesToHexStr(bytes) { let s = ""; for (let i = 0; i < bytes.length; i++) s += pad2(bytes[i]); return s; } /* ─── 发送核心(流控语义保持不变) ─── */ const GD32_ERR_DESC = { 0xE2: "发送冷却", 0xE3: "总线错误", 0xE4: "邮箱忙", }; function describeResult(result) { if (!result) return "无结果"; if (result.status === "ok") return "ok"; return result.desc || result.status || "error"; } async function sendBatch(frames, opts) { opts = opts || {}; if (!state.connected && !opts.allowOffline) { if (!opts.silent) toast("设备未连接", "err"); return { ok: false, desc: "设备未连接", networkError: true }; } if (state.sendInFlight) { if (frames.length === 1) { state.pendingCmd = frames[0]; } return { ok: false, busy: true, desc: "发送通道忙" }; } if (nowMs() < state.busCooldownUntil) { return { ok: false, busy: true, desc: "总线冷却中" }; } state.sendInFlight = true; try { const results = await state.tp.sendFrames(frames, { ch: state.ch }); const first = results && results[0]; const ok = !!(first && first.status === "ok"); if (ok) { state.txCount += frames.length; } const gerr = first && (first.gd32_err || first.code); if (gerr === 0xE2 || gerr === 0xE3) { state.busCooldownUntil = nowMs() + 3000; if (!opts.silent) { toast("总线异常 (" + (GD32_ERR_DESC[gerr] || gerr) + ") — 停止发送,冷却 3s", "err"); } } return { ok, busy: !!(first && (first.code === 0xE4 || first.status === "busy")), busFault: gerr === 0xE2 || gerr === 0xE3, networkError: false, desc: describeResult(first), results, }; } catch (e) { return { ok: false, networkError: true, desc: (e && e.message) || "网络错误", }; } finally { state.sendInFlight = false; if (!opts.retried && state.pendingCmd && state.connected && nowMs() >= state.busCooldownUntil) { const p = state.pendingCmd; state.pendingCmd = null; sendBatch([{ id: p.id, dlc: p.dlc, data: p.data }], { silent: true }); } } } async function sendOneFrame(id, dlc, dataHex, opts) { return sendBatch([{ id, dlc, data: dataHex }], opts || {}); } async function sendOneFrameWhenFree(id, dlc, dataHex, opts) { opts = opts || {}; const res = await sendOneFrame(id, dlc, dataHex, opts); if (res.busy && opts.retry !== false) { await sleep(50); return sendOneFrame(id, dlc, dataHex, Object.assign({}, opts, { retried: true })); } return res; } async function sendBurst(actions, opts) { opts = opts || {}; if (!state.connected) { return { ok: false, desc: "设备未连接", networkError: true }; } if (state.sendInFlight) { return { ok: false, busy: true, desc: "发送通道忙" }; } if (nowMs() < state.busCooldownUntil) { return { ok: false, busy: true, desc: "总线冷却中" }; } state.sendInFlight = true; try { const frames = actions.map((a) => { const bytes = buildFormCmdBytes(a); return { ch: a.ch, node: a.node, data: bytesToHexStr(bytes) }; }); const results = await state.tp.sendBurst(frames, { suffix: 0x0200, dlc: 32 }); const first = results && results[0]; const ok = !!(first && first.status === "ok"); if (ok) state.txCount += frames.length; return { ok, busy: !!(first && (first.code === 0xE4 || first.status === "busy")), networkError: false, desc: describeResult(first), results, }; } catch (e) { return { ok: false, networkError: true, desc: (e && e.message) || "网络错误" }; } finally { state.sendInFlight = false; if (!opts.retried && state.pendingCmd && state.connected && nowMs() >= state.busCooldownUntil) { const p = state.pendingCmd; state.pendingCmd = null; sendBatch([{ id: p.id, dlc: p.dlc, data: p.data }], { silent: true }); } } } /* ─── 慢请求统计 / 瞬时错误(TRANSPORT_CTX 注入用) ─── */ function recordSlowReq(path, durMs, errMsg) { if (durMs <= 200) return; const s = state.phaseDebug.slowStats; const key = path.split("?")[0]; 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 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; 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; } /* ─── 传输层装配 ─── */ const TRANSPORT_CTX = { getBaseUrl: () => getBaseUrl(), recordSlow: (path, ms, err) => recordSlowReq(path, ms, err), isTransient: (e) => isTransientError(e), /* R5:参数查询/设置发送入口(流控仍留在 main) */ sendFrame: (id, dlc, dataHex, opts) => sendOneFrameWhenFree(id, dlc, dataHex, opts), getCh: () => state.ch, }; function applyTransportRefresh() { const v = pickRefresh(state.refreshByTp, state.tpId); const changed = state.refreshIntervalMs !== v; state.refreshByTp[state.tpId] = v; state.refreshIntervalMs = v; const el = $("#refreshRate"); if (el) { const lo = minRefreshFor(state.tpId); el.min = String(lo); el.step = isUnthrottled(lo) ? "1" : "50"; el.value = v; } if (!changed) return v; updateNodeLabels(); if (state.poll.tick) { clearTimeout(state.poll.tick); state.poll.tick = null; } if (state.connected && !state.probing && late.connSys) { late.connSys.setTickNextAt(0); late.connSys.schedulePollLoop(state.pollGen, 0); } return v; } function useTransport(id) { state.tpId = id; state.tp = createTransport(id, TRANSPORT_CTX); return state.tp; } async function switchTransport(id) { if (id === state.tpId) return; const old = state.tp; stopRepeat(true); stopSeq("切换链路,命令序列已停止", true); stopDataPolling(); stopProbe(); state.connected = false; state.connecting = false; state.failCount = 0; if (old) { try { await old.teardown(); } catch (_) {} } useTransport(id); saveTransportId(id); applyTransportUi(); toast("已切换链路: " + state.tp.label + " · 刷新 " + state.refreshIntervalMs + " ms", "ok"); connectShared(); } function applyTransportUi() { const tp = state.tp; const sel = $("#transportSelect"); if (sel && sel.value !== state.tpId) sel.value = state.tpId; const ad = $("#ecatAdapter"); if (ad) { const show = !!tp.getAdapter; ad.style.display = show ? "" : "none"; if (show) ad.value = tp.getAdapter(); } const otaBtn = document.querySelector('.tab-btn[data-tab="ota"]'); if (otaBtn) { const off = !tp.supportsOta; otaBtn.disabled = off; otaBtn.classList.toggle("tab-disabled", off); otaBtn.title = off ? "电机 OTA 仅 WiFi 链路支持(需 ESP32 固件上传 + GD32 relay)——切回 WiFi 链路后可用" : ""; if (off && otaBtn.classList.contains("active")) { const cfg = document.querySelector('.tab-btn[data-tab="config"]'); if (cfg) cfg.click(); } } applyTransportRefresh(); const rr = document.getElementById("refreshRate"); if (rr) { const other = state.tpId === "ecat" ? "wifi" : "ecat"; const otherName = other === "wifi" ? "WiFi" : "EtherCAT"; const shared = "两条链路各记一份刷新速度,切链路自动换(" + otherName + " 当前记着 " + (state.refreshByTp[other] || REFRESH_DEFAULTS[other]) + " ms)。"; rr.parentElement.title = tp.supportsPeriodSync ? "电机状态刷新间隔(ms," + minRefreshFor(state.tpId) + "~" + REFRESH_MAX_MS + ")。同时驱动前端 HTTP 轮询与 GD32 0xC5 SPI 主动推送周期,端到端节拍一致。" + shared : "界面读取快照的间隔(ms," + minRefreshFor(state.tpId) + "~" + REFRESH_MAX_MS + ")。EtherCAT 的 PDO 总线周期由后台循环独立维持(1000ms 过程数据看门狗要求持续交换),不受此值影响。" + "填 1 表示不限速(实测约 125Hz,受每拍一次 HTTP 往返限制),真实频率看 STATUS 面板右上角。" + shared; } updateConnUi(); } /* 连接接口代理(conn-poll 创建后 late 生效;声明保持提升) */ function updateConnUi() { if (late.connSys) late.connSys.updateConnUi(); } function connectShared() { if (late.connSys) late.connSys.connectShared(); } function stopDataPolling() { if (late.connSys) late.connSys.stopDataPolling(); } function startPolling() { if (late.connSys) late.connSys.startPolling(); } function stopProbe() { if (late.connSys) late.connSys.stopProbe(); } /* ─── /motor/state 处理与 UI ─── */ function motorKey(m) { return m.ch + ":" + m.node; } const UI_PAINT_MIN_MS = 33; let uiPaintPending = false; let lastUiPaintAt = 0; let lastUiSig = ""; let lastConnSig = ""; const _decodedReuse = new Map(); const _decodedConfigReuse = new Map(); const _statusRefs = {}; function statusEl(id) { let el = _statusRefs[id]; if (el === undefined) { el = document.getElementById(id); _statusRefs[id] = el; } return el; } function setClsIfChanged(el, cls) { setClassIfChanged(el, cls); } function toggleClsIfChanged(el, cls, on) { toggleClsIfChangedUtil(el, cls, on); } function paintMotorUi(force) { const now = nowMs(); if (!force && now - lastUiPaintAt < UI_PAINT_MIN_MS) return false; lastUiPaintAt = now; refreshChannelViews(); updateNodeLabels(); return true; } function scheduleUiPaint() { if (uiPaintPending) return; uiPaintPending = true; const run = () => { const wait = UI_PAINT_MIN_MS - (nowMs() - lastUiPaintAt); if (wait > 0) { setTimeout(run, wait); return; } uiPaintPending = false; paintMotorUi(true); }; requestAnimationFrame(run); } const RECV_HZ_WINDOW = 20; function onMotorState(data) { const motors = (data && data.motors) ? data.motors : []; state.motors = motors; state.bootActive = !!(data && data.boot_active); const ts = state.recvTs; ts.push(Date.now()); if (ts.length > RECV_HZ_WINDOW * 2) ts.splice(0, ts.length - RECV_HZ_WINDOW); const decoded = _decodedReuse; const decodedConfig = _decodedConfigReuse; decoded.clear(); decodedConfig.clear(); for (const m of motors) { const bytes = hexToBytes(m.data); const d = decodeStatus(bytes); if (d) decoded.set(motorKey(m), d); if (m.config_data) { const cfg = decodeConfig(m.config_data); if (cfg) decodedConfig.set(motorKey(m), cfg); } } state.decoded = decoded; state.decodedConfig = decodedConfig; const chOnline = [0, 0, 0]; for (const m of motors) { if (m.online && m.node >= 1 && m.node <= 9 && m.ch >= 0 && m.ch <= 2) chOnline[m.ch]++; } const chSig = chOnline.join(",") + "|" + state.chEnabled.join(",") + "|" + (state.tickStuck || 0); const chChanged = chSig !== lastUiSig; state.chOnline = chOnline; if (chChanged) { lastUiSig = chSig; updateChSelectOptions(); } let autoSelected = false; if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) { const online = motors.find((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9) || motors.find((m) => m.online && m.node >= 1 && m.node <= 9); state.selectedNode = online ? online.node : 1; autoSelected = true; } const bootOn = !!state.bootActive; const bootEl = statusEl("bootBadge"); if (bootEl && bootEl._on !== bootOn) { bootEl._on = bootOn; bootEl.classList.toggle("show", bootOn); bootEl.style.display = bootOn ? "flex" : "none"; bootEl.setAttribute("aria-hidden", bootOn ? "false" : "true"); const sub = statusEl("bootBadgeSub"); if (sub) sub.textContent = bootOn ? "0x103 心跳" : "0x103"; } const onlineCnt = chOnline[state.ch] || 0; const connSig = [state.connected, state.connecting, state.probing, state.tpId, state.slaveAl, state.slaveOp, onlineCnt, state.ch, state.probeErr].join("|"); if (connSig !== lastConnSig) { lastConnSig = connSig; updateConnUi(); } if (autoSelected) { restoreVersionUi(state.selectedNode); paintMotorUi(true); return; } scheduleUiPaint(); } const _nodeCards = new Map(); function getDisplayMotors() { const motors = state.motors || []; return FIXED_MOTOR_NODES.map((node) => { const entries = motors.filter((m) => m && m.node === node); const cur = entries.find((m) => m.ch === state.ch); if (cur && cur.online) return cur; const others = entries.filter((m) => m.online && m.ch !== state.ch); if (others.length) { const chs = others.map((o) => o.ch).sort().join("/"); return { node, ch: others[0].ch, online: false, otherCh: chs, dlc: 0, rx_count: 0, seq: 0, data: "" }; } if (cur) return cur; return { node, ch: state.ch || 0, online: false, dlc: 0, rx_count: 0, seq: 0, data: "" }; }); } function refreshChannelViews() { renderNodeList(); renderStatusPanel(); const onlineCount = (state.motors || []) .filter((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9).length; const summary = `${onlineCount}/9 在线 · CH${state.ch}`; const sumEl = statusEl("nodeSummary") || $("#nodeSummary"); if (sumEl && sumEl.textContent !== summary) sumEl.textContent = summary; } function modeLabelOf(m) { const cfg = state.decodedConfig.get(motorKey(m)); const mode = cfg ? cfg.targetMode : state.lastSentMode.get(m.node); if (mode == null) return "--"; const def = MODES[mode]; return def ? def.zh : ("模式" + mode); } function renderNodeList() { const motors = getDisplayMotors(); let needRebuild = _nodeCards.size !== FIXED_MOTOR_NODES.length; if (!needRebuild) { for (const n of FIXED_MOTOR_NODES) { if (!_nodeCards.has(n)) { needRebuild = true; break; } } } if (needRebuild) { _nodeCards.clear(); nodeListEl.innerHTML = motors.map(buildNodeCardHtml).join(""); for (const m of motors) { const card = nodeListEl.querySelector(`.node-card[data-node="${m.node}"]`); if (!card) continue; _nodeCards.set(m.node, { card, led: card.querySelector(".led"), modeEl: card.querySelector(".nc-mode"), stTxt: card.querySelector(".nc-status-text"), angleVal: card.querySelector(".nc-reading:nth-child(1) .val"), velVal: card.querySelector(".nc-reading:nth-child(2) .val"), curVal: card.querySelector(".nc-reading:nth-child(3) .val"), faultsEl: card.querySelector(".nc-faults"), }); } return; } for (const m of motors) { const ref = _nodeCards.get(m.node); if (!ref) continue; const d = m.otherCh != null ? null : state.decoded.get(motorKey(m)); const faultCount = faultCountOf(d); const faultLabel = faultShortLabel(d); const isSel = m.node === state.selectedNode; const cls = [ "node-card", isSel ? "active" : "", faultCount > 0 ? "fault" : "", !m.online ? "offline" : "", ].join(" ").trim(); if (ref.card.className !== cls) ref.card.className = cls; ref.card.setAttribute("aria-selected", String(isSel)); if (faultLabel) ref.card.title = "故障: " + faultLabel; else if (m.otherCh != null) ref.card.title = `节点在 CH${m.otherCh} 在线,切换顶部通道查看`; else if (!m.online) ref.card.title = "离线也可选中刷写"; else ref.card.title = "在线"; const ledCls = m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : ""); const ledFull = "led " + ledCls; if (ref.led.className !== ledFull) ref.led.className = ledFull; const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m)); if (ref.modeEl.textContent !== modeLabel) ref.modeEl.textContent = modeLabel; const modeCls = m.otherCh != null ? "nc-mode mode-other-ch" : (!m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode")); if (ref.modeEl.className !== modeCls) ref.modeEl.className = modeCls; const stTxt = !m.online ? "--" : (faultLabel || "OK"); if (ref.stTxt.textContent !== stTxt) ref.stTxt.textContent = stTxt; if (ref.stTxt) ref.stTxt.title = faultLabel ? ("故障: " + faultLabel) : (m.online ? "正常" : ""); if (d && m.online && m.otherCh == null) { if (ref.angleVal) setTextIfChanged(ref.angleVal, d.rtAngle.toFixed(1)); if (ref.velVal) setTextIfChanged(ref.velVal, d.rtVelocity.toFixed(0)); if (ref.curVal) setTextIfChanged(ref.curVal, d.rtCurrent.toFixed(0)); } else { if (ref.angleVal) setTextIfChanged(ref.angleVal, "--"); if (ref.velVal) setTextIfChanged(ref.velVal, "--"); if (ref.curVal) setTextIfChanged(ref.curVal, "--"); } if (ref.faultsEl) setTextIfChanged(ref.faultsEl, faultLabel || ""); } } function buildNodeCardHtml(m) { const d = m.online ? state.decoded.get(motorKey(m)) : null; const faultCount = faultCountOf(d); const faultLabel = faultShortLabel(d); const isSel = m.node === state.selectedNode; const cls = [ "node-card", isSel ? "active" : "", faultCount > 0 ? "fault" : "", !m.online ? "offline" : "", ].join(" ").trim(); const ledCls = m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : ""); return `
N${m.node} 0 ? "mode-fault" : ""))}">${m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m))}
${!m.online ? "--" : (faultLabel || "OK")}
角度${d ? d.rtAngle.toFixed(1) : "--"}
速度${d ? d.rtVelocity.toFixed(0) : "--"}
电流${d ? d.rtCurrent.toFixed(0) : "--"}
${faultLabel || ""}
`; } nodeListEl.addEventListener("click", (e) => { const card = e.target.closest(".node-card"); if (!card) return; const n = parseInt(card.dataset.node, 10); if (!Number.isFinite(n)) return; state.selectedNode = n; if (scopeSys) scopeSys.reset(); renderNodeList(); updateNodeLabels(); restoreVersionUi(n); renderStatusPanel(); saveUiState(); }); function hzText(hz) { if (!Number.isFinite(hz) || hz <= 0) return "--"; return hz >= 10 ? hz.toFixed(0) + " Hz" : hz.toFixed(1) + " Hz"; } function statusHzText() { const ts = state.recvTs || []; if (ts.length < 2) return "--"; const iv = ts[ts.length - 1] - ts[Math.max(0, ts.length - RECV_HZ_WINDOW)]; if (!(iv > 0)) return "--"; return hzText((ts.length - 1) * 1000 / Math.max(iv, 1)); } function updateNodeLabels() { const n = state.selectedNode; const el = $("#nodeTitle"); if (el) el.textContent = n != null ? `节点 N${n}` : "未选择节点"; const hzEl = statusEl("statusHz") || $("#statusHz"); if (hzEl) hzEl.textContent = statusHzText(); } /* ─── STATUS 面板 ─── */ function flashEl(el) { if (!el) return; el.classList.remove("flash"); void el.offsetWidth; el.classList.add("flash"); } function renderStatusPanel() { const n = state.selectedNode; const motor = n != null ? state.motors.find((m) => m.node === n && m.ch === state.ch) || state.motors.find((m) => m.node === n) : null; const d = motor ? state.decoded.get(motorKey(motor)) : null; const cfg = motor ? state.decodedConfig.get(motorKey(motor)) : null; const offlineLayer = $("#statusOffline"); const panelLayer = $("#statusPanel"); if (!state.connected || !motor || !motor.online || !d) { if (offlineLayer) offlineLayer.style.display = ""; if (panelLayer) panelLayer.style.display = "none"; if (offlineLayer) { const chs = (state.motors || []).filter((m) => m.node === n && m.online) .map((m) => "CH" + m.ch).join("/"); if (!state.connected) { setTextIfChanged($("#offlineTitle"), "设备未连接"); setTextIfChanged($("#offlineSub"), "本页共用 OTA 主页 IP,请到主页配置并测试连接"); } else if (chs && state.chEnabled[state.ch] === false) { setTextIfChanged($("#offlineTitle"), `节点 ${n} 在 ${chs} 有信号`); setTextIfChanged($("#offlineSub"), `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`); } else if (!motor) { setTextIfChanged($("#offlineTitle"), `节点 ${n} 离线(仍可刷写)`); setTextIfChanged($("#offlineSub"), "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写"); } else { setTextIfChanged($("#offlineTitle"), `节点 ${n} 暂无解码数据`); setTextIfChanged($("#offlineSub"), "等待 STATUS 帧;OTA 刷写不受影响"); } } return; } if (offlineLayer) offlineLayer.style.display = "none"; if (panelLayer) panelLayer.style.display = ""; setTextIfChanged(statusEl("mcuTemp"), d.mcuTemp.toFixed(2) + " °C"); setTextIfChanged(statusEl("motTemp"), d.motTemp.toFixed(2) + " °C"); const mcuBar = statusEl("mcuTempBar"), motBar = statusEl("motTempBar"); if (mcuBar) mcuBar.style.width = Math.min(100, Math.max(0, d.mcuTemp)) + "%"; if (motBar) motBar.style.width = Math.min(100, Math.max(0, d.motTemp)) + "%"; if (cfg) { setTextIfChanged($("#tgtAngle"), cfg.targetAngle.toFixed(1)); setTextIfChanged($("#tgtVelocity"), cfg.targetVelocity.toFixed(0)); setTextIfChanged($("#tgtCurrent"), cfg.targetCurrent.toFixed(0)); } else { setTextIfChanged($("#tgtAngle"), "--"); setTextIfChanged($("#tgtVelocity"), "--"); setTextIfChanged($("#tgtCurrent"), "--"); } setTextIfChanged(statusEl("valU"), `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`); setTextIfChanged(statusEl("valV"), `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`); setTextIfChanged(statusEl("valW"), `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`); setTextIfChanged(statusEl("pwrVolt"), d.powerVoltage.toFixed(3) + " V"); setTextIfChanged(statusEl("testFloat"), d.testFloat.toFixed(3)); setTextIfChanged(statusEl("testU16"), String(d.testU16)); setTextIfChanged(statusEl("cpuPct"), Math.round(d.cpuUsage) + "%"); if (motor) setTextIfChanged(statusEl("cpuSub"), `seq ${motor.seq} · rx ${motor.rx_count}`); const flags = [ ["flagMot", d.motStatus, "电机故障", "正常"], ["flagMag", d.magStatus, "磁编码故障", "正常"], ["flagThermal", d.thermalStatus, "过温", "正常"], ]; for (const [id, active, faultText, okText] of flags) { const el = statusEl(id); if (!el) continue; setTextIfChanged(el, active ? faultText : okText); } } function busStateText(bs) { const map = { 0: "OK", 1: "BUSOFF", 2: "PASSIVE", 3: "WARN", 8: "OK" }; return map[bs] != null ? map[bs] : ("0x" + Number(bs || 0).toString(16)); } function updateTickStuckUi() { const stuck = state.tickStuck > 0; const el = $("#chSelect"); if (el) el.classList.toggle("tick-stuck", stuck); } function updateChSelectOptions() { const sel = $("#chSelect"); if (!sel) return; for (let i = 0; i < sel.options.length; i++) { const opt = sel.options[i]; const ch = parseInt(opt.value, 10); const en = state.chEnabled[ch] !== false; const cnt = state.chOnline[ch] || 0; const base = "CH" + ch; setTextIfChanged(opt, !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base)); } } function onFeedback(data) { const channels = (data && data.channels) || []; for (const c of channels) { if (!c || c.ch == null) continue; state.chEnabled[c.ch] = c.enabled !== false; } updateChSelectOptions(); const ch = channels[state.ch]; if (!ch) return; setTextIfChanged($("#sbRx"), "RX " + (ch.rx != null ? ch.rx : "--")); setTextIfChanged($("#sbTx"), "TX " + (ch.tx != null ? ch.tx : "--")); setTextIfChanged($("#sbErr"), "ERR " + (ch.err != null ? ch.err : "--")); setTextIfChanged($("#sbTec"), "TEC " + (ch.tec != null ? ch.tec : "--")); setTextIfChanged($("#sbRec"), "REC " + (ch.rec != null ? ch.rec : "--")); setTextIfChanged($("#sbBus"), "BUS " + (ch.bus_state != null ? busStateText(ch.bus_state) : "--")); } /* ─── 控制模式联动 / CMD ─── */ function applyModeVisibility() { const mode = parseInt(($("#modeSelect") || {}).value, 10) || 0; const def = MODES[mode]; const targets = def ? def.targets : []; for (const id of ["f_angle", "f_velocity", "f_current", "f_voltage"]) { const el = document.getElementById(id); if (!el) continue; const key = id.replace("f_", ""); const show = key === "voltage" ? true : targets.includes(key); el.style.display = show ? "" : "none"; } updateQuickHint(); } function requireNode() { if (state.selectedNode == null) { toast("请先在左侧选择一个节点", "warn"); return false; } return true; } function inBusCooldown() { if (nowMs() < state.busCooldownUntil) { toast("总线冷却中,请稍候…", "warn"); return true; } return false; } async function doSendCmd(silent) { if (!requireNode()) return; if (inBusCooldown()) return; if (!state.connected) { if (!silent) toast("设备未连接", "err"); return; } if (!state.chEnabled[state.ch]) { if (!silent) toast("CH" + state.ch + " 已禁用(GD32 固件未启用此通道)", "err"); return; } const node = state.selectedNode; const mode = parseInt($("#modeSelect").value, 10) || 0; const bytes = buildFormCmdBytes({ mode }); const id = ID_CMD(node); const res = await sendOneFrameWhenFree(id, 32, bytesToHexStr(bytes)); if (res.ok) { state.lastSentMode.set(node, mode); state.lastSentTargets.set(node, { mode, angle: parseFloat(cmdVal("inAngle")) || 0, velocity: parseFloat(cmdVal("inVelocity")) || 0, current: parseFloat(cmdVal("inCurrent")) || 0, voltage: parseFloat(cmdVal("inVoltage")) || 0, }); if (!silent) toast(`CMD → Node ${node} · ${MODES[mode].zh} 发送成功`, "ok"); } else if (!res.networkError && !res.busy) { toast(`CMD 发送失败: ${res.desc}`, "err"); } } function quickFieldOfMode(mode) { const def = MODES[mode]; if (!def || !def.targets.length) return null; return def.targets[def.targets.length - 1]; } function updateQuickHint() { const mode = parseInt(($("#modeSelect") || {}).value, 10) || 0; const field = quickFieldOfMode(mode); const el = $("#quickHint"); if (!el) return; el.textContent = field ? `快捷发送:${field}` : "当前模式无快捷目标值"; } function getPeriod() { const v = parseInt(($("#repeatPeriod") || {}).value, 10); return Number.isFinite(v) && v >= MIN_CMD_PERIOD_MS ? v : MIN_CMD_PERIOD_MS; } let _repeatTimer = null; function startRepeat() { stopRepeat(true); const period = getPeriod(); _repeatTimer = setInterval(() => { doSendCmd(true); }, period); const hz = 1000 / period; toast(`周期发送已启动 · ${period} ms(${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok"); } function stopRepeat(silent) { if (_repeatTimer) { clearInterval(_repeatTimer); _repeatTimer = null; } if (!silent) toast("周期发送已停止", "warn"); } /* ─── 配置操作(CMD_CONFIG) ─── */ const CONFIG_ACTIONS = { motReset: { title: "电机复位", bit: 0, desc: "将向节点发送 motReset 脉冲,电机立即停止并清除故障标志。运行中的电机将失去力矩输出,请确认机械安全。" }, saveConfig: { title: "保存配置", bit: 2, desc: "将当前控制模式和 PID 参数写入 EEPROM。写入期间约 50ms 不可发送新指令。" }, angleReset: { title: "角度置零", bit: 3, desc: "将当前机械位置设为电气零点。置零后目标角度将以新零点为参考,请确认电机处于期望的零位。" }, zeroCalib: { title: "零点校准", bit: 5, desc: "发送 zero_calibration 脉冲,对电机进行零点校准。请确认电机已处于期望的机械零位且无负载。" }, sleep: { title: "休眠", bit: 56, desc: "电机进入低功耗休眠模式,关闭驱动输出。需发送 CANFD_BOOT 或重新上电才能唤醒。" }, }; function buildConfigBytes(bit) { const bytes = new Uint8Array(24); bytes[Math.floor(bit / 8)] = 1 << (bit % 8); return bytes; } document.querySelectorAll(".config-btn").forEach((btn) => { btn.addEventListener("click", async () => { const action = CONFIG_ACTIONS[btn.dataset.action]; if (!action) return; if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; if (inBusCooldown()) return; const ok = await showModal(action.title, `目标节点 ${state.selectedNode}。` + action.desc); if (!ok) return; const node = state.selectedNode; const id = ID_CMD_CONFIG(node); const bytes = buildConfigBytes(action.bit); const res = await sendOneFrameWhenFree(id, 24, bytesToHexStr(bytes)); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); $("#sbLastSend").textContent = `最近发送: ${action.title} → ${hexId(id)} [24B] ${bytesToHexSpaced(bytes, 8)} … ${time}`; if (res.ok) { state.txCount++; toast(`${action.title} → Node ${node} 已发送 (CMD_CONFIG)`, "ok"); } else if (!res.networkError && !res.busy) { toast(`${action.title} 发送失败: ${res.desc}`, "err"); } }); }); /* ─── 版本查询(R5:等待下沉 transport.waitVersion) ─── */ const VERSION_FULL_BYTES = 24; function readVersionQuadAt(dv, n, off) { if (off + 8 > n) return null; const major = dv.getUint16(off, true); const minor = dv.getUint16(off + 2, true); const patch = dv.getUint16(off + 4, true); const build = dv.getUint16(off + 6, true); return `${major}.${minor}.${patch}.${build}`; } function parseVersionPayload(hex) { const rb = hexToBytes(hex); if (!rb || rb.length < 8) return null; const n = Math.min(rb.length, 24); const copy = new Uint8Array(24); copy.set(rb.subarray(0, n)); const dv = new DataView(copy.buffer); const soft = readVersionQuadAt(dv, n, 0); const hard = readVersionQuadAt(dv, n, 8); const boot = readVersionQuadAt(dv, n, 16); return { soft, hard, boot, byteLen: rb.length, complete: rb.length >= VERSION_FULL_BYTES }; } function setVer(id, text) { const el = $("#" + id); if (!el) return; if (!text || text === "--") { el.textContent = "--"; el.classList.add("ver-empty"); } else { el.textContent = text; el.classList.remove("ver-empty"); } } function restoreVersionUi(node) { const v = node != null ? state.versions.get(node) : null; if (!v) { setVer("verSoft", null); setVer("verHard", null); setVer("verBoot", null); return; } const softEl = $("#verSoft"), hardEl = $("#verHard"), bootEl = $("#verBoot"); const isStale = (el) => !el || el.textContent === "--" || el.textContent.includes("."); if (isStale(softEl)) setVer("verSoft", v.soft || null); if (isStale(hardEl)) setVer("verHard", v.hard || null); if (isStale(bootEl)) setVer("verBoot", v.boot || null); } function setVerWaiting(label) { ["verSoft", "verHard", "verBoot"].forEach((k) => { const el = $("#" + k); if (el) { el.textContent = label; el.classList.add("ver-empty"); } }); } $("#queryVerBtn").addEventListener("click", async () => { if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; if (inBusCooldown()) return; const node = state.selectedNode; const reqId = ID_GET_VERSION_REQ(node); const bytes = new Uint8Array(24); const isEcat = !!(state.tp && state.tp.id === "ecat"); const maxAttempts = isEcat ? 3 : 1; setVerWaiting("查询中…"); if (state.tp.drainPendingRx) { try { await state.tp.drainPendingRx(); } catch (_) {} } let v = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { let prevSeq = null, sentAt = nowMs(); try { const before = await state.tp.fetchVersionNode(node); prevSeq = before ? before.seq : null; sentAt = nowMs(); } catch (_) {} const res = await sendOneFrameWhenFree(reqId, 24, bytesToHexStr(bytes)); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); $("#sbLastSend").textContent = isEcat ? `最近发送: GET_VERSION ${hexId(reqId)} · ECAT 第${attempt}/${maxAttempts}次 … ${time}` : `最近发送: GET_VERSION 请求 ${hexId(reqId)} → 等应答 … ${time}`; if (!res.ok) { if (res.busy || res.networkError) { if (attempt < maxAttempts) { await sleep(200); continue; } setVerWaiting("--"); restoreVersionUi(node); toast(res.busy ? "发送通道仍忙,请稍后重试" : "网络错误,版本查询未发出", "warn"); return; } setVerWaiting("失败"); toast(`GET_VERSION 发送失败: ${res.desc}`, "err"); return; } state.txCount++; const timeoutMs = (attempt < maxAttempts) ? 2000 : 3000; v = await state.tp.waitVersion(node, { prevSeq, sentAt, timeoutMs }); if (v && v.version) break; if (attempt < maxAttempts) await sleep(100); } if (v && v.version) { const parsed = parseVersionPayload(v.version); if (parsed && (parsed.soft || parsed.hard || parsed.boot)) { 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(); const bl = parsed.byteLen || 0; const full = parsed.complete; toast( full ? `版本已更新 · Node ${node} · ${bl}B · seq ${v.seq}` : `版本部分更新 · 仅 ${bl}B(需≥${VERSION_FULL_BYTES}B 才有 hard/boot)`, full ? "ok" : "warn" ); return; } } setVerWaiting("无回报"); toast( isEcat ? `已发 ${hexId(reqId)}×${maxAttempts},Node ${node} 版本未进 xrx/motor/rx` : `已发 ${hexId(reqId)},5s 内未收到电机新版本应答`, "warn" ); }); /* ─── 节点参数(R5:queryNodeIdParam / setNodeIdParam 下沉 transport) ─── */ const PARAM_SEL_NODE_ID = 0x0000; const PARAM_SEL_STATUS = 0x0100; const PARAM_SEL_CONFIG = 0x0101; async function queryParam(node, selector) { if (!state.tp || typeof state.tp.queryNodeIdParam !== "function") { return { ok: false, desc: "当前链路不支持节点参数查询" }; } return state.tp.queryNodeIdParam(node, selector); } async function setParam(node, selector, value) { if (!state.tp || typeof state.tp.setNodeIdParam !== "function") { return { ok: false, desc: "当前链路不支持节点参数设置" }; } return state.tp.setNodeIdParam(node, selector, value); } const PERIOD_MAX_HZ = 1000; function periodToHzText(ms) { if (ms === 0) return "停发"; const hz = 1000 / ms; return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz"; } function periodToHzInput(ms) { if (ms === 0) return 0; return Math.round((1000 / ms) * 100) / 100; } function periodRowEls(kind) { return kind === "status" ? { sel: PARAM_SEL_STATUS, name: "STATUS", input: $("#periodStatusInput") } : { sel: PARAM_SEL_CONFIG, name: "CONFIG", input: $("#periodConfigInput") }; } async function readPeriodRow(kind) { if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; if (inBusCooldown()) return; const node = state.selectedNode; const row = periodRowEls(kind); const result = $("#periodResult"); const id = ID_GET_NODE_ID(node); result.textContent = `${row.name} 读取中…`; const r = await queryParam(node, row.sel); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); $("#sbLastSend").textContent = `最近发送: GET 刷新率(${row.name}) → ${hexId(id)} [2B] … ${time}`; if (r.ok && !r.oldFw) { row.input.value = periodToHzInput(r.val); result.textContent = `${row.name} 刷新率 = ${periodToHzText(r.val)}(周期 ${r.val} ms) · 节点 ${node}`; toast(`${row.name} 刷新率已读取: ${periodToHzText(r.val)}`, "ok"); } else if (r.ok && r.oldFw) { result.textContent = `${row.name} 读取失败:电机固件过旧(回报的是节点 ID),不支持刷新率参数`; toast("电机固件不支持刷新率参数(需 v0.4.1+)", "warn"); } else if (!r.networkError && !r.busy) { result.textContent = `${row.name} 读取失败: ${r.desc}`; toast(`${row.name} 读取失败: ${r.desc}`, "err"); } } async function writePeriodRow(kind) { if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; if (inBusCooldown()) return; const node = state.selectedNode; const row = periodRowEls(kind); const result = $("#periodResult"); const hz = parseFloat(row.input.value); if (!Number.isFinite(hz) || hz < 0 || hz > PERIOD_MAX_HZ) { toast(`${row.name} 刷新率范围 0~${PERIOD_MAX_HZ} Hz(0=停发)`, "err"); return; } const period = hz === 0 ? 0 : Math.round(1000 / hz); if (hz > 0 && (period < 1 || period > 0xFFFF)) { toast(`${row.name} 刷新率过低(周期超出 1~65535 ms)`, "err"); return; } const warn = (hz === 0 && kind === "status") ? "STATUS 帧将停发,右侧状态面板会显示离线(电机仍可控可刷写)。" : (hz === 0 ? "CONFIG 帧将停发,目标值回显将不可用。" : ""); const ok = await showModal("设置反馈刷新率", `将节点 ${node} 的 ${row.name} 帧刷新率设为 ${row.input.value} Hz(周期 ${period} ms),立即生效并写入电机 Flash。${warn}`); if (!ok) return; const id = ID_SET_NODE_ID(node); result.textContent = `${row.name} 写入中…`; const r = await setParam(node, row.sel, period); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); $("#sbLastSend").textContent = `最近发送: SET 刷新率(${row.name}) → ${hexId(id)} [4B] ${period}ms(${row.input.value}Hz) … ${time}`; if (r.ok) { state.txCount++; result.textContent = `${row.name} 刷新率 = ${periodToHzText(period)}(周期 ${period} ms)已生效并写入 Flash · 节点 ${node}`; toast(`${row.name} 刷新率已设为 ${periodToHzText(period)}`, "ok"); saveUiState(); } else if (!r.networkError && !r.busy) { result.textContent = `${row.name} 写入失败: ${r.desc}`; toast(`${row.name} 写入失败: ${r.desc}`, "err"); } } $("#getPeriodStatusBtn").addEventListener("click", () => readPeriodRow("status")); $("#setPeriodStatusBtn").addEventListener("click", () => writePeriodRow("status")); $("#getPeriodConfigBtn").addEventListener("click", () => readPeriodRow("config")); $("#setPeriodConfigBtn").addEventListener("click", () => writePeriodRow("config")); $("#setNodeIdBtn").addEventListener("click", async () => { if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; if (inBusCooldown()) return; const newId = parseInt($("#nodeIdInput").value, 10); if (!Number.isFinite(newId) || newId < 1 || newId > 9) { toast("Node ID 范围 1-9", "err"); return; } const cur = state.selectedNode; const ok = await showModal("设置节点 ID", `将节点 ${cur} 的地址修改为 ${newId},CAN ID 前缀将变为 0x000${nodeHex(newId)}xxxx。修改立即生效并写入 EEPROM。`); if (!ok) return; /* 旧格式 DLC2 保持不变:NodeID_Set 0|16@1+ = 新节点号 u16 LE */ const bytes = new Uint8Array(2); bytes[0] = newId & 0xFF; bytes[1] = (newId >> 8) & 0xFF; const id = ID_SET_NODE_ID(cur); const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes)); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); $("#sbLastSend").textContent = `最近发送: SET_NODE_ID → ${hexId(id)} [2B] new=${newId} … ${time}`; if (res.ok) { state.txCount++; state.selectedNode = newId; $("#nodeIdResult").textContent = `设置成功: Node ID = ${newId} · 新 CMD ID: ${hexId(ID_CMD(newId))}`; toast(`节点 ID 已更新为 ${newId}`, "ok"); updateNodeLabels(); } else if (!res.networkError && !res.busy) { $("#nodeIdResult").textContent = `设置失败: ${res.desc}`; toast(`SET_NODE_ID 失败: ${res.desc}`, "err"); } }); $("#getNodeIdBtn").addEventListener("click", async () => { if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; if (inBusCooldown()) return; const cur = state.selectedNode; const id = ID_GET_NODE_ID(cur); const res = await queryParam(cur, PARAM_SEL_NODE_ID); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); $("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] sel=0x0000 … ${time}`; if (res.ok) { state.txCount++; $("#nodeIdResult").textContent = `节点 ${cur} 回报 ID: ${res.val} · ${hexId(id)}`; toast(`GET_NODE_ID → Node ${cur} 已读取: ${res.val}`, "ok"); } else if (!res.networkError && !res.busy) { $("#nodeIdResult").textContent = `读取失败: ${res.desc}`; toast(`GET_NODE_ID 失败: ${res.desc}`, "err"); } }); /* ─── Tab 切换 ─── */ document.querySelectorAll(".tab-btn").forEach((btn) => { btn.addEventListener("click", () => { if (btn.disabled || btn.classList.contains("tab-disabled")) return; document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active")); document.querySelectorAll(".tab-page").forEach((p) => p.classList.remove("active")); btn.classList.add("active"); $("#tab-" + btn.dataset.tab).classList.add("active"); }); }); /* ─── 通道切换 / 刷新速度 ─── */ $("#chSelect").addEventListener("change", () => { state.ch = parseInt($("#chSelect").value, 10) || 0; if (scopeSys) scopeSys.reset(); updateConnUi(); updateChSelectOptions(); refreshChannelViews(); saveUiState(); }); const refreshRateInput = $("#refreshRate"); async function sendMotorPeriod(ms) { if (!state.tp.supportsPeriodSync) return; try { await state.tp.setPeriod(ms); } catch (e) { /* 周期下发失败静默 */ } } function applyRefreshRate() { const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS; const lo = minRefreshFor(state.tpId); const v = clampNum(parseInt(refreshRateInput.value, 10) || dflt, lo, REFRESH_MAX_MS); refreshRateInput.value = v; state.refreshIntervalMs = v; state.refreshByTp[state.tpId] = v; saveUiState(); updateNodeLabels(); if (state.poll.tick) { clearTimeout(state.poll.tick); state.poll.tick = null; } if (state.connected && !state.probing && late.connSys) { late.connSys.setTickNextAt(0); late.connSys.schedulePollLoop(state.pollGen, 0); sendMotorPeriod(v); } const hz = 1000 / v; toast(isUnthrottled(v) ? `${state.tp.label} 刷新速度 ${v} ms(不限速,实际频率见 STATUS 面板)` : `${state.tp.label} 刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok"); } refreshRateInput.addEventListener("change", applyRefreshRate); refreshRateInput.addEventListener("input", () => { const raw = parseInt(refreshRateInput.value, 10); const lo = minRefreshFor(state.tpId); if (Number.isFinite(raw) && raw < lo) refreshRateInput.value = lo; }); /* ═══════════════════════ 模块装配(P1 R4) ═══════════════════════ */ let scopeSys = null; let otaSys = null; const seqSys = createSeqSys({ MODES, FIXED_MOTOR_NODES, state, clampNum, escapeHtml, $, cmdVal, syncCmdCommittedFromDom, saveUiState, debounceSaveUi, toast, showModal, inBusCooldown, sendBurst, stopRepeat, renderNodeList, nowMs, sleep, late, }); const seq = seqSys.seq; const renderSeqList = seqSys.renderSeqList; const updateSeqUi = seqSys.updateSeqUi; const startSeq = seqSys.startSeq; const stopSeq = seqSys.stopSeq; const normStep = seqSys.normStep; late.stopSeq = stopSeq; late.stopRepeat = stopRepeat; late.sendMotorPeriod = sendMotorPeriod; late.recordSlowReq = recordSlowReq; late.isTransientError = isTransientError; otaSys = createOtaSys({ state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr, sendBatch, sendOneFrameWhenFree, getBaseUrl, ID_GET_VERSION_REQ, parseVersionPayload, setVer, saveUiState, late, }); const otaRestorePersistedFirmware = otaSys.otaRestorePersistedFirmware; const connSys = createConnPoll({ state, $, toast, deviceLink, flashBusyNow, isUnthrottled, getSharedDeviceIp, setSharedDeviceIp, onMotorState, onFeedback, updateTickStuckUi, setQuietMode, late, }); late.connSys = connSys; late.stopDataPolling = connSys.stopDataPolling; late.startPolling = connSys.startPolling; scopeSys = createScopeSys({ state, motorKey, clampNum, $ }); /* ═══════════════════════ 初始化 ═══════════════════════ */ useTransport(loadTransportId()); state.ip = getSharedDeviceIp(); deviceLink.subscribe(function (snap) { if (!snap || !snap.ip) return; if (snap.ip !== state.ip) { state.ip = snap.ip; const label = $("#ipLabel"); if (label) label.textContent = "IP " + state.ip; } }); loadUiState(); applyTransportRefresh(); if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) { state.selectedNode = 1; } applyModeVisibility(); updateNodeLabels(); updateConnUi(); renderNodeList(); renderStatusPanel(); restoreVersionUi(state.selectedNode); renderSeqList(); const seqLoopsInput = $("#seqLoops"); if (seqLoopsInput) seqLoopsInput.value = seq.loops; bindAutoSave(); initCmdCommit(); otaRestorePersistedFirmware(); const transportSelect = $("#transportSelect"); if (transportSelect) { transportSelect.value = state.tpId; transportSelect.addEventListener("change", () => { switchTransport(transportSelect.value); }); } const ecatAdapterInput = $("#ecatAdapter"); if (ecatAdapterInput) { ecatAdapterInput.addEventListener("change", async () => { const tp = state.tp; if (!tp.setAdapter) return; const changed = await tp.setAdapter(ecatAdapterInput.value); if (!changed) { ecatAdapterInput.value = tp.getAdapter(); return; } toast("网卡已切换到 " + tp.getAdapter() + ",正在重连…", "ok"); stopDataPolling(); state.connected = false; connectShared(); }); } applyTransportUi(); /* CMD / 周期发送按钮(依赖 doSendCmd / startSeq 等) */ const sendCmdBtn = $("#sendCmdBtn"); if (sendCmdBtn) sendCmdBtn.addEventListener("click", () => doSendCmd(false)); const quickSendBtn = $("#quickSendBtn"); if (quickSendBtn) quickSendBtn.addEventListener("click", () => doSendCmd(false)); const modeSelect = $("#modeSelect"); if (modeSelect) modeSelect.addEventListener("change", applyModeVisibility); const repeatStartBtn = $("#repeatStartBtn"); if (repeatStartBtn) repeatStartBtn.addEventListener("click", startRepeat); const repeatStopBtn = $("#repeatStopBtn"); if (repeatStopBtn) repeatStopBtn.addEventListener("click", () => stopRepeat(false)); /* 暴露诊断对象到 window,供控制台排查慢请求 */ window.phaseDebug = { get lastSlowReq() { return state.phaseDebug && state.phaseDebug.lastSlowReq; }, get slowStats() { return state.phaseDebug && state.phaseDebug.slowStats; }, get lastHardFail() { return state.phaseDebug && state.phaseDebug.lastHardFail; }, reset() { if (state.phaseDebug) { state.phaseDebug.lastSlowReq = null; state.phaseDebug.slowStats = {}; state.phaseDebug.lastHardFail = null; } console.log("[phaseDebug] 慢请求统计已清空"); }, }; (async () => { try { await hydrateUiFromDisk(); } catch (_) {} connectShared(); })();