Files
DCU_DesktopController/js/motor/main.js
T
cbb 261caf50b7 refactor(motor): P1 拆分 main.js + 收敛连接/总线状态机
R4 外提 scope/ota/seq/conn-poll;R5 waitVersion/queryNodeIdParam 下沉
transport;R6 ecat-bus 单点 bootstrap;R7 toast-stack + utils 去重。
main.js 4647→~1894 行。?v= 已 bump 20260924a。
2026-09-21 02:26:39 +08:00

1895 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `<div class="${cls}" data-node="${m.node}" role="option" aria-selected="${isSel}">
<div class="nc-head">
<span class="led ${ledCls}"></span>
<span class="nc-node">N${m.node}</span>
<span class="nc-mode ${m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : ""))}">${m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m))}</span>
</div>
<div class="nc-status-text">${!m.online ? "--" : (faultLabel || "OK")}</div>
<div class="nc-reading"><span class="k">角度</span><span class="val">${d ? d.rtAngle.toFixed(1) : "--"}</span></div>
<div class="nc-reading"><span class="k">速度</span><span class="val">${d ? d.rtVelocity.toFixed(0) : "--"}</span></div>
<div class="nc-reading"><span class="k">电流</span><span class="val">${d ? d.rtCurrent.toFixed(0) : "--"}</span></div>
<div class="nc-faults">${faultLabel || ""}</div>
</div>`;
}
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();
})();