Files
DCU_ECAT_Master/web/motor.js
T
cbb beb33ffe50 refactor: 按角色分 web/ diag/ launch/ 三层, 根目录只留主程序
18 个文件平铺根目录太乱, 按角色归拢:
  web/     ecat.html/css + motor.html/js —— 前端资源
  diag/    ecat_scan / ecat_sniff / ecat_esc_regs —— 独立诊断脚本, 互不依赖
  launch/  5 个 .bat/.vbs 双击入口
根目录只留 ecat_server / ecat_tool / ecat_flash_eeprom / test_motor_backend:
这几个互相 import(tool→flash_eeprom, test→server), 挪进子目录就得改 import
或加包, 收益不抵成本。

配套改动:
  _STATIC_MAP 的值加 web/ 前缀 —— URL 路径刻意保持不变(/、/ecat.css、
  /motor.html), 页面内相对引用、书签、外部链接一律不用改。
  launch/ 里 %~dp0 现在指向 launch\, 五个启动器统一改为先取上一层作 ROOT
  (bat 用 %~dp0.., vbs 用两次 GetParentFolderName), run_scan 顺带指向
  diag\ecat_scan.py。

验证: 7 个 py 全编译通过; test_motor_backend 8 项全通过; 服务实起后 6 条
静态路由全 200 且 MIME 正确(js 是 application/javascript)、404 兜底正常;
Edge headless 渲染主页与 motor 页, CSS 294 规则加载、9 个节点样式生效、
零加载失败零 JS 异常; bat/vbs 的 ROOT 解析实测指到项目根, 五个目标文件
全部命中; ecat_tool adapters 与 diag/ecat_scan.py 实跑正常。
2026-09-03 22:08:38 +08:00

1099 lines
45 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.
/**
* 电机控制台(EtherCAT 传输)— tools/ecat/motor.js
*
* 与 tools/ESP32/js/motor/main.js 的关系:
* **解码器与编码器逐字节相同**(decodeStatus / decodeConfig / buildCmdBytes /
* parseVersionPayload 等),因为那是 DBC 定义的报文格式,与传输无关,改一处
* 就必须改两处 —— 所以这里是刻意的原样复制,不做"优化"。
* 不同的只有传输层:ESP32 页走 HTTP /can/send_batch + /motor/state,
* 本页走 EtherCAT 过程数据 /api/motor/{start,stop,state,send,rx}。
*
* 传输层要点:
* 1. 状态不是"请求-应答",而是后台 PDO 循环持续把从站输入 PDO 解出来存快照,
* 本页只是按 #refreshRate 读那个快照。因此"界面刷新"与"总线节拍(PDO 周期)"
* 是两个独立概念,不要混为一谈。
* 2. 后台循环必须一直跑:从站 SM2 有 1000ms 过程数据看门狗,停超 1 秒就会
* OP→SAFEOP 掉线报 AL 0x001B。所以进页面即自动启动循环。
* 3. 发送走批量区:一次提交的多帧会被服务端塞进同一个 PDO 周期的批量发送区,
* 在**同一个 seq 沿**由从站一次性发出,这是多轴同步下发的前提。
*
* 经典脚本(非 module):与 motor.html 里的 <script src> 一致,不用 type=module。
*/
'use strict';
/* ═══════════════════════ 常量(与 DBC Motor_CAN_FD v0.70 对齐) ═══════════════════════ */
const MODE_INFO = {
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_UI = 'dcu_ecat_motor_ui_v1';
const MAX_NODES = 9;
/* CAN ID 构造(物理 ID,node 位于 bits[19:16])— 与 ESP32 页一致 */
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);
/* 配置操作位(CMD_CONFIG,DLC 24,脉冲式:只置一个 bit) */
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 或重新上电才能唤醒。' },
};
const TX_RESULT_DESC = {
0x00: 'OK', 0xE0: 'payload 过短', 0xE1: '通道号非法',
0xE2: 'BUS_OFF(总线离线)', 0xE3: 'TEC 过高',
0xE4: '邮箱满(对端不 ACK)', 0xE5: '参数无效',
};
/* ═══════════════════════ 全局状态 ═══════════════════════ */
const state = {
connected: false, // 后台 PDO 循环在跑且 WKC 正常
looping: false, // 后台 PDO 循环是否在运行
ch: 0,
selectedNode: null,
motors: [], // 快照里的 motors 数组(27 槽聚合)
decoded: new Map(), // node -> decodeStatus 结果(仅当前通道)
configs: new Map(), // node -> decodeConfig 结果(目标值回报)
versions: new Map(), // node -> {soft,hard,boot}
chOnline: [0, 0, 0],
bootActive: false,
refreshMs: 100,
periodMs: 4,
repeatTimer: null,
pollTimer: null,
polling: false, // 单飞标志:上一次快照还没回来就不再发
txCount: 0,
rxCount: 0,
cycles: 0,
lastCycles: 0,
lastHzAt: 0,
hz: 0,
winBase: 0,
scanCells: 4,
lastSentMode: new Map(),
};
/* ═══════════════════════ DOM / 小工具 ═══════════════════════ */
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
function pad2(n) { return n.toString(16).padStart(2, '0').toUpperCase(); }
function hexId(id) { return '0x' + id.toString(16).toUpperCase().padStart(8, '0'); }
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 fmt(v, d) { return (v === null || v === undefined || !Number.isFinite(v)) ? '--' : v.toFixed(d); }
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 bytesToHexStr(bytes) {
let s = '';
for (let i = 0; i < bytes.length; i++) s += pad2(bytes[i]);
return s;
}
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();
}
/* ═══════════════════════ 传输层:/api/motor/* ═══════════════════════ */
/**
* POST JSON 到本地 EtherCAT 服务。
* 带超时:后台循环持锁时个别路由可能排队,没有超时会让轮询无限堆积。
*/
async function api(path, body, timeoutMs = 4000) {
try {
const r = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs),
});
if (!r.ok) {
// 404 常见于"改了 ecat_server.py 但没重启服务",服务端已在响应里写明原因
const txt = await r.text().catch(() => '');
let hint = `HTTP ${r.status}`;
try {
const j = JSON.parse(txt);
if (j.log && j.log.length) hint = j.log.join(' / ');
} catch (_) { /* 非 JSON,用 HTTP 状态 */ }
return { ok: false, log: [hint], networkError: r.status >= 500 };
}
return await r.json();
} catch (e) {
return { ok: false, log: [`请求失败: ${e.name === 'TimeoutError' ? '超时' : e.message}`], networkError: true };
}
}
/* ═══════════════════════ 解码器(与 ESP32 页逐字节一致,勿改) ═══════════════════════ */
/**
* 解码 STATUS 帧 (0x000N0100, DLC 32)。
* DBC 信号布局(Intel LE,注意多处**非字节对齐**):
* MCU_temp 0|16@1- ×0.01 °C → B0-1
* Motor_temp 16|16@1- ×0.01 °C → B2-3
* U/V/W_Current 32/48/64|16@1- mA → B4-5 / B6-7 / B8-9
* motStatus 80|1@1+ 0正常/1报错 → B10.0
* magStatus 81|1@1+ → B10.1
* thermal 82|1@1+ → B10.2
* EEPROMStatus 85|3@0+ Motorola → B10 bits 85,84,83 → (B10>>3)&7
* Power_Voltage 88|16@1+ ×0.001 V → B11-12 ★非对齐
* RT_Angle 104|32@1- float ° → B13-16 ★非对齐
* RT_Velocity 136|32@1- float °/s → B17-20
* RT_Current 168|32@1- float mA → B21-24
* test_float 200|32@1- float → B25-28
* test_U16 232|16@1+ → B29-30
* CPU_Usage 248|8@1+ % → B31
*/
function decodeStatus(bytes) {
if (!bytes || bytes.length < 32) return null;
/* 拷到独立 ArrayBuffer,避免子视图偏移干扰 DataView */
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const copy = new Uint8Array(32);
copy.set(u8.subarray(0, 32));
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],
};
}
/**
* 解码 CONFIG 帧 (0x000N0101, DLC 24, Motor→Host 目标值回报)。
* Target_Control_Mode 0|8@1+ → B0
* Limit_Voltage 8|16@1+ ×0.001 → B1-2
* Target_Current 24|32@1- float32 → B3-6
* Target_Veloctiy 56|32@1- float32 → B7-10
* Target_Angle 88|32@1- float32 → B11-14
*/
function decodeConfig(hexStr) {
if (!hexStr || hexStr.length < 48) return null;
const bytes = hexToBytes(hexStr);
if (!bytes || bytes.length < 24) return null;
const dv = new DataView(bytes.buffer, bytes.byteOffset, 24);
const f32 = (off) => {
const v = dv.getFloat32(off, true);
return Number.isFinite(v) ? v : 0;
};
return {
targetMode: bytes[0],
limitVoltage: dv.getUint16(1, true) * 0.001,
targetCurrent: f32(3),
targetVelocity: f32(7),
targetAngle: f32(11),
};
}
/** 版本帧 payload → {soft,hard,boot}:ASCII 文本,按分隔符切。 */
function parseVersionPayload(hexStr) {
const bytes = hexToBytes(hexStr);
if (!bytes.length) return null;
let end = bytes.length;
while (end > 0 && (bytes[end - 1] === 0 || bytes[end - 1] === 0xFF)) end--;
const txt = new TextDecoder('ascii').decode(bytes.subarray(0, end)).replace(/\0/g, '').trim();
if (!txt) return null;
const parts = txt.split(/[\s,;|/]+/).filter(Boolean);
return {
soft: parts[0] || null,
hard: parts[1] || null,
boot: parts[2] || null,
byteLen: end,
raw: txt,
};
}
/** STATUS 故障位简写(节点卡片右上角)。 */
function faultShortParts(d) {
if (!d) return [];
const parts = [];
if (d.motStatus) parts.push('电机');
if (d.magStatus) parts.push('磁编');
if (d.thermalStatus) parts.push('过温');
if (d.eepromStatus) parts.push('EE' + d.eepromStatus);
return parts;
}
function faultShortLabel(d) {
const parts = faultShortParts(d);
return parts.length ? parts.join('·') : '';
}
/* ═══════════════════════ CMD 编码(DLC 32) ═══════════════════════
* Control_Mode 0|8@1+
* Limit_Voltage_cmd 8|16@1+ ×0.001 → B1-2
* Target_Current_cmd 24|32@1- float → B3-6
* Target_Veloctiy_cmd 56|32@1- float → B7-10
* Target_Angle_cmd 88|32@1- float → B11-14
* PID_current_loop_P 120|16@1- → B15-16
* PID_vel_loop_P 136|16@1- → B17-18
* PID_angle_loop_P 152|16@1- → B19-20
*/
/* ── CMD 数值输入提交制 ──
* 周期发送每 20~100ms 读一次输入框:若直接读 .value,键入 "200" 的中间态
* "2"/"20" 会被当目标值发给电机。改为:输入过程不生效,按回车或点击别处
* (blur 触发 change)才提交到 cmdCommitted,buildCmdBytes 只读已提交值。 */
const CMD_VALUE_IDS = ['inVoltage', 'inCurrent', 'inVelocity', 'inAngle', 'pidCurP', 'pidVelP', 'pidAngP'];
const cmdCommitted = {};
function initCmdCommit() {
for (const id of CMD_VALUE_IDS) {
const el = document.getElementById(id);
if (!el) continue;
cmdCommitted[id] = el.value;
el.addEventListener('change', () => { cmdCommitted[id] = el.value; saveUiState(); });
el.addEventListener('keydown', (e) => { if (e.key === 'Enter') el.blur(); });
}
}
function cmdVal(id) {
return cmdCommitted[id] !== undefined ? cmdCommitted[id] : $('#' + id).value;
}
function buildCmdBytes(values) {
const buf = new ArrayBuffer(32);
const dv = new DataView(buf);
dv.setUint8(0, parseInt(values.mode, 10) || 0);
dv.setUint16(1, Math.round((parseFloat(values.voltage) || 0) * 1000) & 0xFFFF, true);
dv.setFloat32(3, parseFloat(values.current) || 0, true);
dv.setFloat32(7, parseFloat(values.velocity) || 0, true);
dv.setFloat32(11, parseFloat(values.angle) || 0, true);
dv.setInt16(15, parseInt(values.pidCurP, 10) || 0, true);
dv.setInt16(17, parseInt(values.pidVelP, 10) || 0, true);
dv.setInt16(19, parseInt(values.pidAngP, 10) || 0, true);
return new Uint8Array(buf);
}
function buildFormCmdBytes() {
return buildCmdBytes({
mode: $('#modeSelect').value,
voltage: cmdVal('inVoltage'),
current: cmdVal('inCurrent'),
velocity: cmdVal('inVelocity'),
angle: cmdVal('inAngle'),
pidCurP: cmdVal('pidCurP'),
pidVelP: cmdVal('pidVelP'),
pidAngP: cmdVal('pidAngP'),
});
}
/** CMD_CONFIG 24 字节脉冲帧:仅置位对应 bit。 */
function buildConfigBytes(bit) {
const bytes = new Uint8Array(24);
bytes[Math.floor(bit / 8)] = 1 << (bit % 8);
return bytes;
}
/* ═══════════════════════ 发送 ═══════════════════════ */
/**
* 发一帧或多帧。多帧会被服务端塞进同一个 PDO 周期的批量发送区,
* 在同一个 seq 沿由从站一次性发出 —— 这是"多轴同步下发"的关键,
* 逐帧调用本函数会退化成一帧一个周期。
*/
async function sendFrames(frames) {
const r = await api('/api/motor/send', { frames });
if (r.ok) {
state.txCount += frames.length;
} else if (r.log && r.log.length) {
logLine(r.log.join(' / '), 'err');
}
return r;
}
function frameOf(id, dlc, bytes) {
return { ch: state.ch, id: '0x' + id.toString(16), dlc, data: bytesToHexStr(bytes) };
}
async function sendOneFrame(id, dlc, bytes) {
return sendFrames([frameOf(id, dlc, bytes)]);
}
/* ═══════════════════════ 日志 / Toast / Modal ═══════════════════════ */
function logLine(msg, kind) {
const box = $('#opLog');
if (!box) return;
const div = document.createElement('div');
div.className = 'log-' + (kind || 'info');
const t = new Date().toLocaleTimeString('zh-CN', { hour12: false });
div.textContent = `[${t}] ${msg}`;
box.appendChild(div);
while (box.childElementCount > 400) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
let toastTimer = null;
function toast(msg, kind) {
const box = $('#toastContainer');
if (!box) return;
const el = document.createElement('div');
el.className = 'toast toast-' + (kind || 'info');
el.textContent = msg;
box.appendChild(el);
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 300); }, 2600);
if (kind === 'err') logLine(msg, 'err');
}
let modalResolve = null;
function showModal(title, desc) {
return new Promise((resolve) => {
modalResolve = resolve;
$('#modalTitle').textContent = title;
$('#modalDesc').textContent = desc;
$('#modalMask').classList.add('show');
});
}
function closeModal(ok) {
$('#modalMask').classList.remove('show');
if (modalResolve) { modalResolve(ok); modalResolve = null; }
}
/* ═══════════════════════ 持久化 ═══════════════════════ */
function saveUiState() {
try {
const o = {
mode: $('#modeSelect').value, ch: state.ch, node: state.selectedNode,
period: $('#periodMs').value, refresh: $('#refreshRate').value,
repeat: $('#repeatPeriod').value, nodeId: $('#nodeIdInput').value,
vals: {}, versions: {},
};
for (const id of CMD_VALUE_IDS) o.vals[id] = cmdVal(id);
for (const [n, v] of state.versions) o.versions[n] = v;
localStorage.setItem(LS_UI, JSON.stringify(o));
} catch (_) { /* 隐私模式下 localStorage 可能不可写,忽略 */ }
}
function loadUiState() {
let o = null;
try { o = JSON.parse(localStorage.getItem(LS_UI) || 'null'); } catch (_) { return; }
if (!o) return;
if (o.mode) $('#modeSelect').value = o.mode;
if (o.period) $('#periodMs').value = o.period;
if (o.refresh) $('#refreshRate').value = o.refresh;
if (o.repeat) $('#repeatPeriod').value = o.repeat;
if (o.nodeId) $('#nodeIdInput').value = o.nodeId;
if (o.vals) for (const id of CMD_VALUE_IDS) if (o.vals[id] !== undefined) {
const el = document.getElementById(id);
if (el) el.value = o.vals[id];
}
if (typeof o.ch === 'number') { state.ch = o.ch; $('#chSelect').value = String(o.ch); }
if (o.node) state.selectedNode = o.node;
if (o.versions) for (const k of Object.keys(o.versions)) state.versions.set(+k, o.versions[k]);
}
/* ═══════════════════════ 波形缓冲 ═══════════════════════ */
const SCOPE_LEN = 240;
const scope = {
u: [], v: [], w: [],
angle: [], velocity: [], current: [], mcuTemp: [], motTemp: [],
};
function pushScope(d) {
const put = (arr, v) => { arr.push(v); if (arr.length > SCOPE_LEN) arr.shift(); };
put(scope.u, d.uCurrent); put(scope.v, d.vCurrent); put(scope.w, d.wCurrent);
put(scope.angle, d.rtAngle); put(scope.velocity, d.rtVelocity);
put(scope.current, d.rtCurrent);
put(scope.mcuTemp, d.mcuTemp); put(scope.motTemp, d.motTemp);
}
function clearScope() {
for (const k of Object.keys(scope)) scope[k].length = 0;
}
function drawMini(canvas, data, color) {
if (!canvas || !canvas.clientWidth) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight || 40;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr; canvas.height = h * dpr;
}
const g = canvas.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
if (data.length < 2) return;
let lo = Infinity, hi = -Infinity;
for (const v of data) { if (v < lo) lo = v; if (v > hi) hi = v; }
if (hi - lo < 1e-6) { hi = lo + 1; lo -= 1; }
const pad = (hi - lo) * 0.1;
lo -= pad; hi += pad;
g.strokeStyle = color; g.lineWidth = 1.4; g.beginPath();
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * w;
const y = h - ((data[i] - lo) / (hi - lo)) * h;
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.stroke();
}
function drawScope() {
const cv = $('#scopeCanvas');
if (!cv || !cv.clientWidth) return;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight || 120;
if (cv.width !== w * dpr || cv.height !== h * dpr) {
cv.width = w * dpr; cv.height = h * dpr;
}
const g = cv.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
const css = getComputedStyle(document.documentElement);
g.strokeStyle = css.getPropertyValue('--border').trim() || '#333';
g.lineWidth = 1;
for (const f of [0.5]) {
g.beginPath(); g.moveTo(0, h * f); g.lineTo(w, h * f); g.stroke();
}
const RANGE = 5000; // ±5A 满量程,与 HTML 上的刻度标签一致
const series = [
[scope.u, css.getPropertyValue('--primary').trim() || '#4a9eff'],
[scope.v, css.getPropertyValue('--ok').trim() || '#3fb950'],
[scope.w, css.getPropertyValue('--warn').trim() || '#d29922'],
];
for (const [data, color] of series) {
if (data.length < 2) continue;
g.strokeStyle = color; g.lineWidth = 1.5; g.beginPath();
for (let i = 0; i < data.length; i++) {
const x = (i / (SCOPE_LEN - 1)) * w;
const y = h / 2 - (clampNum(data[i], -RANGE, RANGE) / RANGE) * (h / 2);
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.stroke();
}
}
/* ═══════════════════════ 节点列表 ═══════════════════════ */
function renderNodeList() {
const box = $('#nodeList');
if (!box) return;
if (box.childElementCount !== MAX_NODES) {
box.innerHTML = '';
for (let n = 1; n <= MAX_NODES; n++) {
const el = document.createElement('div');
el.className = 'node-item';
el.dataset.node = String(n);
el.setAttribute('role', 'option');
el.tabIndex = 0;
el.innerHTML =
`<span class="ni-led"></span>` +
`<span class="ni-name">N${n}</span>` +
`<span class="ni-fault"></span>` +
`<span class="ni-sub"></span>`;
el.addEventListener('click', () => selectNode(n));
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectNode(n); }
});
box.appendChild(el);
}
}
let online = 0;
for (let n = 1; n <= MAX_NODES; n++) {
const el = box.querySelector(`.node-item[data-node="${n}"]`);
if (!el) continue;
const m = state.motors.find((x) => x.node === n && x.ch === state.ch);
const isOn = !!(m && m.online);
if (isOn) online++;
el.classList.toggle('online', isOn);
el.classList.toggle('active', state.selectedNode === n);
const d = state.decoded.get(n);
const fault = isOn ? faultShortLabel(d) : '';
el.querySelector('.ni-fault').textContent = fault;
el.querySelector('.ni-fault').classList.toggle('has', !!fault);
el.querySelector('.ni-sub').textContent = isOn && d
? `${fmt(d.rtVelocity, 0)}°/s`
: (isOn ? '在线' : '离线');
}
$('#nodeSummary').textContent = `${online}/${MAX_NODES} 在线 · CH${state.ch} · PDO`;
const bb = $('#bootBadge');
if (bb) {
bb.classList.toggle('show', state.bootActive);
bb.setAttribute('aria-hidden', state.bootActive ? 'false' : 'true');
}
}
function selectNode(n) {
if (state.selectedNode === n) return;
state.selectedNode = n;
clearScope();
$('#nodeBadge').textContent = `NODE ${n}`;
$('#cmdIdLabel').textContent = `ID ${hexId(ID_CMD(n))} · DLC 32`;
$('#statusIdLabel').textContent = `${hexId(ID_STATUS(n))} · PDO`;
const v = state.versions.get(n);
setVerFields(v);
renderNodeList();
renderStatus();
saveUiState();
}
function requireNode() {
if (!state.selectedNode) { toast('请先在左侧选择一个节点', 'warn'); return false; }
return true;
}
/* ═══════════════════════ STATUS 面板 ═══════════════════════ */
function renderStatus() {
const n = state.selectedNode;
const m = n ? state.motors.find((x) => x.node === n && x.ch === state.ch) : null;
const d = n ? state.decoded.get(n) : null;
const live = !!(m && m.online && d);
$('#statusLive').classList.toggle('hide', !live);
$('#offlineOverlay').classList.toggle('show', !live);
if (!live) {
$('#offlineTitle').textContent = n
? `节点 N${n} 离线 — CH${state.ch} 未收到 STATUS 帧`
: '请选择一个节点';
$('#offlineSub').textContent = state.looping
? '检查电机供电与 CAN 总线连接'
: 'PDO 循环未运行 —— 在「链路诊断」里启动';
return;
}
$('#rtAngle').textContent = fmt(d.rtAngle, 2);
$('#rtVelocity').textContent = fmt(d.rtVelocity, 1);
$('#rtCurrent').textContent = fmt(d.rtCurrent, 0);
const cfg = state.configs.get(n);
$('#tgtAngle').textContent = cfg ? fmt(cfg.targetAngle, 2) : '--';
$('#tgtVelocity').textContent = cfg ? fmt(cfg.targetVelocity, 1) : '--';
$('#tgtCurrent').textContent = cfg ? fmt(cfg.targetCurrent, 0) : '--';
$('#mcuTemp').textContent = fmt(d.mcuTemp, 1) + ' °C';
$('#motTemp').textContent = fmt(d.motTemp, 1) + ' °C';
$('#mcuTempBar').style.width = clampNum(d.mcuTemp / 100 * 100, 0, 100) + '%';
$('#motTempBar').style.width = clampNum(d.motTemp / 100 * 100, 0, 100) + '%';
const sgn = (v) => (v >= 0 ? '+' : '') + v;
$('#valU').textContent = sgn(d.uCurrent);
$('#valV').textContent = sgn(d.vCurrent);
$('#valW').textContent = sgn(d.wCurrent);
$('#pwrVolt').textContent = fmt(d.powerVoltage, 3) + ' V';
$('#testFloat').textContent = fmt(d.testFloat, 3);
$('#testU16').textContent = String(d.testU16);
const pct = clampNum(d.cpuUsage, 0, 100);
$('#cpuPct').textContent = pct + '%';
$('#cpuSub').textContent = pct >= 90 ? '偏高' : (pct >= 70 ? '较高' : '正常');
const C = 131.9;
$('#cpuRingFg').setAttribute('stroke-dashoffset', String(C * (1 - pct / 100)));
/* 故障灯:DBC 语义 0=正常 1=报错,所以"亮红"= 有故障 */
const setFlag = (led, st, bad, okTxt, badTxt) => {
const l = $('#' + led), s = $('#' + st);
if (!l || !s) return;
l.classList.toggle('led-err', !!bad);
l.classList.toggle('led-ok', !bad);
s.textContent = bad ? badTxt : okTxt;
};
setFlag('flagMot', 'flagMotState', d.motStatus, '正常', '报错');
setFlag('flagMag', 'flagMagState', d.magStatus, '正常', '报错');
setFlag('flagThermal', 'flagThermalState', d.thermalStatus, '正常', '过温');
setFlag('flagEeprom', 'flagEepromState', d.eepromStatus, '正常', 'E' + d.eepromStatus);
drawScope();
drawMini($('#miniAngle'), scope.angle, getComputedStyle(document.documentElement).getPropertyValue('--primary').trim());
drawMini($('#miniVelocity'), scope.velocity, getComputedStyle(document.documentElement).getPropertyValue('--ok').trim());
drawMini($('#miniCurrent'), scope.current, getComputedStyle(document.documentElement).getPropertyValue('--warn').trim());
drawMini($('#miniMcuTemp'), scope.mcuTemp, getComputedStyle(document.documentElement).getPropertyValue('--primary').trim());
drawMini($('#miniMotTemp'), scope.motTemp, getComputedStyle(document.documentElement).getPropertyValue('--warn').trim());
}
/* ═══════════════════════ 链路诊断 ═══════════════════════ */
function renderScanBar(winBase, cells) {
const box = $('#scanBar');
if (!box) return;
if (box.childElementCount !== cells) {
box.innerHTML = '';
for (let i = 0; i < cells; i++) {
const c = document.createElement('div');
c.className = 'scan-cell';
box.appendChild(c);
}
}
const active = Math.floor(winBase / 7) % cells;
[...box.children].forEach((c, i) => c.classList.toggle('hit', i === active));
}
function renderLink(snap) {
const set = (id, v) => { const el = $('#' + id); if (el) el.textContent = String(v); };
const b = (snap && snap.bridge) || {};
const rb = (snap && snap.rx_batch) || {};
set('dgWkc', snap ? `${snap.wkc}/${snap.expected_wkc}` : '—');
set('dgPdo', '384/640B');
set('dgCycles', snap ? snap.cycles : 0);
set('dgLoop', state.looping ? `运行 ${state.periodMs}ms` : '停止');
set('dgAck', b.ack !== undefined ? b.ack : '—');
set('dgTx', b.tx_count !== undefined ? b.tx_count : 0);
set('dgRx', b.rx_count !== undefined ? b.rx_count : 0);
set('dgDrop', b.drop_count !== undefined ? b.drop_count : 0);
set('dgBacklog', b.backlog !== undefined ? b.backlog : 0);
const res = b.tx_result;
set('dgTxRes', res === undefined ? '—' : (TX_RESULT_DESC[res] || ('0x' + pad2(res))));
set('dgSentN', snap && snap.sent_frames ? snap.sent_frames : 0);
set('dgQueue', snap && snap.queue_depth ? snap.queue_depth : 0);
set('dgRxN', rb.count || 0);
set('dgRxPend', rb.pending || 0);
set('dgRxLost', rb.lost || 0);
/* WKC 不等于期望值 = 从站没参与本次交换,这是"通信错"而不是"没数据" */
const wkcOk = snap && snap.wkc === snap.expected_wkc;
const chip = (id, ok) => {
const el = $('#' + id);
if (el && el.parentElement) {
el.parentElement.classList.toggle('ok', !!ok);
el.parentElement.classList.toggle('err', ok === false);
}
};
chip('dgWkc', snap ? wkcOk : undefined);
chip('dgRxLost', rb.lost ? false : undefined);
chip('dgRxPend', rb.pending ? false : undefined);
const al = snap && snap.head ? '' : '';
set('dgSlave', snap ? '1 (LAN9252)' : '—');
set('dgAl', wkcOk ? 'OP' : (snap ? '非 OP?' : '—'));
$('#sbWkc').textContent = snap ? `${snap.wkc}/${snap.expected_wkc}` : '--';
$('#sbTx').textContent = String(b.tx_count !== undefined ? b.tx_count : state.txCount);
$('#sbRx').textContent = 'RX ' + (b.rx_count !== undefined ? b.rx_count : 0);
$('#sbCh').textContent = 'CH' + state.ch;
$('#sbLed').className = 'led ' + (state.connected ? 'led-ok' : 'led-err');
}
/* ═══════════════════════ 轮询:读快照 ═══════════════════════ */
async function pollOnce() {
if (state.polling) return; // 单飞:慢响应时不堆积请求
state.polling = true;
try {
const snap = await api('/api/motor/state', {}, 3000);
if (!snap.ok) {
setConnected(false, (snap.log && snap.log[0]) || '读快照失败');
return;
}
setConnected(snap.wkc === snap.expected_wkc, '');
state.cycles = snap.cycles || 0;
state.periodMs = snap.period_ms || state.periodMs;
state.motors = snap.motors || [];
if (snap.head) {
state.bootActive = !!snap.head.boot;
if (typeof snap.head.win_base === 'number') state.winBase = snap.head.win_base;
}
/* 解码当前通道各节点的 STATUS。data 是 64 字符 hex(32B),
* 直接喂给与 ESP32 页共用的 decodeStatus。 */
state.decoded.clear();
for (const m of state.motors) {
if (m.ch !== state.ch || !m.online) continue;
const d = decodeStatus(hexToBytes(m.data));
if (d) state.decoded.set(m.node, d);
}
const cur = state.selectedNode ? state.decoded.get(state.selectedNode) : null;
if (cur) pushScope(cur);
/* 段3 长帧快照:CONFIG(0x0101)/版本(0x00FF) 都是 DLC24,靠它取回目标值 */
if (snap.xrx && snap.xrx.valid) applyXrx(snap.xrx);
/* 段4 批量接收:一周期可能回多帧,逐帧按 ID 分派 */
if (snap.rx_batch && snap.rx_batch.count) await drainRxBatch();
/* 刷新率统计:用后台 cycles 增量算真实总线节拍,而不是界面帧率 */
const t = nowMs();
if (state.lastHzAt && t > state.lastHzAt) {
const dc = state.cycles - state.lastCycles;
if (dc >= 0) state.hz = Math.round(dc / ((t - state.lastHzAt) / 1000));
}
state.lastHzAt = t; state.lastCycles = state.cycles;
$('#sbHz').textContent = state.hz ? `${state.hz} 周期/s` : '--';
renderNodeList();
renderStatus();
renderLink(snap);
renderScanBar(state.winBase, state.scanCells);
} finally {
state.polling = false;
}
}
/** 把一帧非 STATUS 报文按 ID 分派(CONFIG / 版本 / 节点ID)。 */
function dispatchFrame(id, dlc, dataHex) {
const node = (id >> 16) & 0xF;
const sub = id & 0xFFFF;
if (sub === 0x0101) { // CONFIG 回报:目标值
const c = decodeConfig(dataHex);
if (c) state.configs.set(node, c);
} else if (sub === 0x00FF) { // 版本应答
const v = parseVersionPayload(dataHex);
if (v && (v.soft || v.hard || v.boot)) {
state.versions.set(node, { soft: v.soft, hard: v.hard, boot: v.boot });
if (node === state.selectedNode) setVerFields(state.versions.get(node));
saveUiState();
logLine(`Node ${node} 版本: ${v.raw}`, 'ok');
}
} else if (sub === 0x0401 || sub === 0x0400) { // 节点 ID 应答
const b = hexToBytes(dataHex);
if (b.length >= 1) {
$('#nodeIdResult').textContent =
`节点 ID 应答: ${b[0]} · 新 CMD ID ${hexId(ID_CMD(b[0]))}`;
}
}
}
function applyXrx(x) {
if (!x || !x.data) return;
dispatchFrame(x.id, x.dlc, x.data);
}
async function drainRxBatch() {
const r = await api('/api/motor/rx', {}, 3000);
if (!r.ok || !r.frames) return;
for (const f of r.frames) {
if (!f.valid) continue;
const sub = f.id & 0xFFFF;
if (sub === 0x0100) continue; // STATUS 已由段2 状态窗口聚合,跳过
dispatchFrame(f.id, f.dlc, f.data);
}
}
function setConnected(ok, why) {
const changed = state.connected !== ok;
state.connected = ok;
$('#connLed').className = 'led ' + (ok ? 'led-ok' : 'led-err led-pulse');
$('#ipLabel').textContent = ok ? 'EtherCAT OP' : '未就绪';
$('#devStatus').textContent = ok
? `PDO ${state.periodMs}ms · ${state.cycles} 周期`
: (why || '未连接');
const banner = $('#netBanner');
banner.classList.toggle('show', !ok);
if (!ok) {
$('#netBannerText').textContent = why && why.includes('未启动')
? '后台 PDO 循环未运行 —— 点「链路诊断 → 启动 PDO 循环」'
: (why || '未连接 — 请先在 EtherCAT 主页点「连接」,再回到本页启动 PDO 循环');
}
if (changed) logLine(ok ? 'PDO 循环就绪,状态开始刷新' : `链路异常: ${why || '未知'}`, ok ? 'ok' : 'warn');
}
function startPolling() {
stopPolling();
state.refreshMs = clampNum(parseInt($('#refreshRate').value, 10) || 100, 30, 2000);
state.pollTimer = setInterval(pollOnce, state.refreshMs);
pollOnce();
}
function stopPolling() {
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
}
/* ═══════════════════════ PDO 循环控制 ═══════════════════════ */
async function startLoop() {
const period = clampNum(parseInt($('#periodMs').value, 10) || 4, 1, 100);
const r = await api('/api/motor/start', { period_ms: period }, 20000);
if (r.log) r.log.forEach((l) => logLine(l, r.ok ? 'info' : 'err'));
state.looping = !!r.ok;
if (r.ok) {
state.periodMs = r.period_ms || period;
toast(`PDO 循环已启动 (${state.periodMs}ms)`, 'ok');
} else {
toast('启动 PDO 循环失败', 'err');
}
return r.ok;
}
async function stopLoop() {
const r = await api('/api/motor/stop', {}, 8000);
if (r.log) r.log.forEach((l) => logLine(l, 'info'));
state.looping = false;
toast('PDO 循环已停止 —— 超过 1 秒不喂看门狗,从站会掉出 OP', 'warn');
}
/* ═══════════════════════ 版本字段 ═══════════════════════ */
function setVerFields(v) {
const put = (id, val) => {
const el = $('#' + id);
if (!el) return;
el.textContent = val || '--';
el.classList.toggle('ver-empty', !val);
};
put('verSoft', v && v.soft); put('verHard', v && v.hard); put('verBoot', v && v.boot);
}
/* ═══════════════════════ 事件绑定 ═══════════════════════ */
function updateModeUi() {
const mode = parseInt($('#modeSelect').value, 10) || 0;
$('#modeDesc').textContent = MODE_DESCS[mode] || '';
const info = MODE_INFO[mode] || { targets: [] };
const map = { current: 'fldCurrent', velocity: 'fldVelocity', angle: 'fldAngle' };
for (const k of Object.keys(map)) {
const el = $('#' + map[k]);
if (el) el.classList.toggle('dim', !info.targets.includes(k));
}
const primary = info.targets[info.targets.length - 1];
const unit = { angle: '目标角度 deg', velocity: '目标速度 deg/s', current: '目标电流 mA' };
$('#qrTargetHint').textContent = primary ? '→ ' + unit[primary] : '→ 当前模式无目标值';
}
function quickRunTarget() {
const mode = parseInt($('#modeSelect').value, 10) || 0;
const t = (MODE_INFO[mode] || { targets: [] }).targets;
const primary = t[t.length - 1];
return { angle: 'inAngle', velocity: 'inVelocity', current: 'inCurrent' }[primary] || null;
}
function bindEvents() {
$('#chSelect').addEventListener('change', () => {
state.ch = parseInt($('#chSelect').value, 10) || 0;
clearScope();
state.decoded.clear();
$('#sendHint').textContent = `POST /api/motor/send · CH${state.ch}`;
renderNodeList(); renderStatus(); saveUiState();
});
$('#modeSelect').addEventListener('change', () => { updateModeUi(); saveUiState(); });
$('#refreshRate').addEventListener('change', () => { startPolling(); saveUiState(); });
$('#periodMs').addEventListener('change', async () => {
saveUiState();
if (state.looping) {
/* 周期变了要重启循环才生效。先停再起,而不是发个"改周期"接口 ——
服务端的周期是线程启动参数,重启是唯一真实生效路径。 */
await stopLoop();
await startLoop();
}
});
/* 发送 CMD */
$('#sendCmdBtn').addEventListener('click', async () => {
if (!requireNode()) return;
const node = state.selectedNode;
const bytes = buildFormCmdBytes();
const r = await sendOneFrame(ID_CMD(node), 32, bytes);
const t = new Date().toLocaleTimeString('zh-CN', { hour12: false });
$('#sbLastSend').textContent =
`最近发送: CMD → ${hexId(ID_CMD(node))} [32B] ${bytesToHexSpaced(bytes, 8)} … ${t}`;
if (r.ok) {
state.lastSentMode.set(node, parseInt($('#modeSelect').value, 10) || 0);
toast(`CMD → Node ${node} 已下发`, 'ok');
}
});
/* 周期发送 */
$('#sendRepeatBtn').addEventListener('click', () => {
if (state.repeatTimer) {
clearInterval(state.repeatTimer); state.repeatTimer = null;
$('#sendRepeatBtn').classList.remove('active');
$('#sendRepeatBtn').textContent = '周期发送';
logLine('周期发送已停止', 'info');
return;
}
if (!requireNode()) return;
const period = clampNum(parseInt($('#repeatPeriod').value, 10) || 50, 20, 10000);
$('#sendRepeatBtn').classList.add('active');
$('#sendRepeatBtn').textContent = '停止周期';
logLine(`周期发送启动: 每 ${period}ms 一帧 CMD(读已提交值,不受输入中间态影响)`, 'ok');
state.repeatTimer = setInterval(() => {
if (!state.selectedNode) return;
sendOneFrame(ID_CMD(state.selectedNode), 32, buildFormCmdBytes());
}, period);
});
/* 快捷运行:写入当前模式主目标字段并立即发送 */
$$('#quickRun .qr-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!requireNode()) return;
const id = quickRunTarget();
if (!id) { toast('当前模式没有目标值可设', 'warn'); return; }
const el = document.getElementById(id);
el.value = btn.dataset.val;
cmdCommitted[id] = el.value; // 直接提交:这是显式点击,不是键入中间态
saveUiState();
await sendOneFrame(ID_CMD(state.selectedNode), 32, buildFormCmdBytes());
toast(`${btn.dataset.val} → ${el.previousElementSibling ? '目标值' : id} 已下发`, 'ok');
});
});
/* 配置操作(危险动作先确认) */
$$('.config-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const action = CONFIG_ACTIONS[btn.dataset.action];
if (!action || !requireNode()) return;
const ok = await showModal(action.title, `目标节点 ${state.selectedNode}。` + action.desc);
if (!ok) return;
const node = state.selectedNode;
const bytes = buildConfigBytes(action.bit);
const r = await sendOneFrame(ID_CMD_CONFIG(node), 24, bytes);
const t = new Date().toLocaleTimeString('zh-CN', { hour12: false });
$('#sbLastSend').textContent =
`最近发送: ${action.title} → ${hexId(ID_CMD_CONFIG(node))} [24B] ${bytesToHexSpaced(bytes, 8)} … ${t}`;
if (r.ok) toast(`${action.title} → Node ${node} 已发送`, 'ok');
});
});
/* 版本查询:发请求,应答经段3/段4 回来后由 dispatchFrame 填字段 */
$('#queryVerBtn').addEventListener('click', async () => {
if (!requireNode()) return;
setVerFields(null);
['verSoft', 'verHard', 'verBoot'].forEach((k) => { $('#' + k).textContent = '查询中…'; });
const node = state.selectedNode;
const r = await sendOneFrame(ID_GET_VERSION_REQ(node), 24, new Uint8Array(24));
if (!r.ok) { setVerFields(null); toast('版本查询发送失败', 'err'); return; }
logLine(`GET_VERSION → ${hexId(ID_GET_VERSION_REQ(node))},等电机应答…`, 'info');
/* 等应答:应答由轮询解码填入 state.versions,这里只做超时提示 */
const deadline = nowMs() + 5000;
while (nowMs() < deadline) {
await sleep(200);
if (state.versions.has(node)) return;
}
if (!state.versions.has(node)) {
setVerFields(null);
toast('版本查询超时 — 电机未应答', 'warn');
}
});
/* 节点 ID 读写 */
$('#getNodeIdBtn').addEventListener('click', async () => {
if (!requireNode()) return;
const r = await sendOneFrame(ID_GET_NODE_ID(state.selectedNode), 2, new Uint8Array(2));
if (r.ok) $('#nodeIdResult').textContent = '已发送读取请求,等应答…';
});
$('#setNodeIdBtn').addEventListener('click', async () => {
if (!requireNode()) return;
const nv = parseInt($('#nodeIdInput').value, 10);
if (!(nv >= 1 && nv <= 9)) { toast('节点 ID 必须是 1~9', 'err'); return; }
const ok = await showModal('修改节点 ID',
`将节点 ${state.selectedNode} 的 ID 改为 ${nv}。改完后 CMD ID 变为 ${hexId(ID_CMD(nv))},` +
`需要重新选中新节点号才能继续控制。`);
if (!ok) return;
const b = new Uint8Array(2); b[0] = nv;
const r = await sendOneFrame(ID_SET_NODE_ID(state.selectedNode), 2, b);
if (r.ok) {
$('#nodeIdResult').textContent = `已下发: Node ID → ${nv}(新 CMD ID ${hexId(ID_CMD(nv))})`;
saveUiState();
}
});
/* 链路诊断:循环启停 */
$('#loopStartBtn').addEventListener('click', async () => {
if (await startLoop()) startPolling();
});
$('#loopStopBtn').addEventListener('click', stopLoop);
/* 标签页 */
$$('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
$$('.tab-btn').forEach((b) => b.classList.toggle('active', b === btn));
const name = btn.dataset.tab;
$$('.tab-page').forEach((p) => p.classList.toggle('active', p.id === 'tab-' + name));
});
});
/* 波形展开/收起 */
$$('[data-signal]').forEach((el) => {
el.addEventListener('click', () => el.classList.toggle('expanded'));
});
/* 日志工具 */
$('#logClearBtn').addEventListener('click', () => { $('#opLog').innerHTML = ''; });
$('#logCopyBtn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText($('#opLog').innerText);
toast('日志已复制', 'ok');
} catch (_) { toast('复制失败(浏览器拒绝剪贴板权限)', 'err'); }
});
/* Modal */
$('#modalCancel').addEventListener('click', () => closeModal(false));
$('#modalConfirm').addEventListener('click', () => closeModal(true));
$('#modalMask').addEventListener('click', (e) => { if (e.target === $('#modalMask')) closeModal(false); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && $('#modalMask').classList.contains('show')) closeModal(false);
});
window.addEventListener('resize', () => { drawScope(); renderStatus(); });
/* 离开页面时停周期发送。刻意**不停** PDO 循环:它要一直喂看门狗,
停了从站就掉出 OP —— 由用户显式停止或服务退出时清理。 */
window.addEventListener('beforeunload', () => {
if (state.repeatTimer) clearInterval(state.repeatTimer);
});
}
/* ═══════════════════════ 启动 ═══════════════════════ */
async function boot() {
loadUiState();
initCmdCommit();
bindEvents();
updateModeUi();
renderNodeList();
if (state.selectedNode) {
$('#nodeBadge').textContent = `NODE ${state.selectedNode}`;
$('#cmdIdLabel').textContent = `ID ${hexId(ID_CMD(state.selectedNode))} · DLC 32`;
setVerFields(state.versions.get(state.selectedNode));
}
$('#sendHint').textContent = `POST /api/motor/send · CH${state.ch}`;
renderScanBar(0, state.scanCells);
/* 进页面即启动后台 PDO 循环:从站 SM2 的 1000ms 过程数据看门狗要求
持续交换,否则 OP→SAFEOP 报 AL 0x001B。这不是"性能优化"而是正确性。 */
logLine('启动后台 PDO 循环(持续喂 1000ms 过程数据看门狗)…', 'info');
const ok = await startLoop();
if (!ok) logLine('未能进 OP —— 请先在 EtherCAT 主页完成「连接」', 'warn');
startPolling();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}