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 实跑正常。
1099 lines
45 KiB
JavaScript
1099 lines
45 KiB
JavaScript
/**
|
||
* 电机控制台(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();
|
||
}
|