Files
DCU_DesktopController/js/motor/main.js
T
cbb 9925009a92 refactor(motor): 电机 OTA 外提 ota.js(P1 重做第 3 步)
createOtaSys 注入 \$ 与轮询/版本依赖;STATUS offlineOverlay 契约保留。
连接/轮询/STATUS 渲染仍留在 main.js,本轮不再动连接状态机。
UI 验收:绿灯 + 节点数据 + 遮罩 display:none + 控制台无报错。
2026-09-21 03:13:04 +08:00

3234 lines
146 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 入口)
* 由 motor.html 内联 <script>(原 L1014-L3660)机械外提,逻辑未改。
* 原脚本已是 "use strict",转 module 行为中性;唯一对外暴露 window.phaseDebug。
*/
/* ═══════════════════════════════════════════════════════════════
电机控制台 — 双传输: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";
/* IP 存取公共实现(localStorage dcu_esp_ip,与 OTA/CAN 页共用) */
import {
DEFAULT_ESP_IP, saveDeviceIp
} from "../common/base-url.js";
import { escapeHtml, flashBusyNow } from "../common/utils.js";
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
import {
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
} from "./transport.js?v=20260923i";
import { deviceLink } from "../common/device-link.js";
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
import {
REFRESH_DEFAULTS, REFRESH_MAX_MS,
defaultRefreshMap, normalizeRefreshMap, pickRefresh, minRefreshFor,
isUnthrottled
} from "./refresh-rate.js";
import { createScopeSys } from "./scope.js?v=20260924f";
import { createSeqSys } from "./seq.js?v=20260924f";
import { createOtaSys } from "./ota.js?v=20260924g";
/* ─── 控制模式定义 ─── */
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: "开环速度控制(备用通道)",
};
/* ─── 全局状态 ─── */
/* LS_ESP_IP / DEFAULT_ESP_IP 由 common/base-url.js 导入(与 OTA / CAN 页共用) */
/* 本页输入持久化 key:v2 扩展了 nodeIdInput + 按节点版本缓存 + OTA 固件元数据。
* 保存内容:modeSelect / inVoltage / inVelocity / inAngle / pidCurP / pidVelP /
* pidAngP / repeatPeriod / chSelect / selectedNode / nodeIdInput /
* versions(按 node 缓存 {soft,hard,boot})/ otaFwMeta(固件名+大小)。
* 不保存:实时状态(motor 数组、解码、波形缓冲、连接态)、固件 bytes(用 IndexedDB handle 恢复)。 */
const LS_MOTOR_UI = "dcu_motor_ui_v2";
const state = {
connected: false, // 连续成功探测后视为在线
connecting: false,
ip: DEFAULT_ESP_IP,
/* 当前传输实例(wifi / ecat)。所有链路调用都经它走,
* 见文件头注释与 js/motor/transport.js 的接口定义。 */
tp: null,
tpId: "wifi",
/* 最近一次探测失败原因(EtherCAT 自动接入四级里任一级的报错)。
* null = 没失败过。显示到横幅 + #devStatus,别让页面只写"探测中…"。 */
probeErr: null,
probeErrFull: null, // 未压缩的完整诊断(放 title,供复制排查)
ch: 0,
chEnabled: [true, true, true], // 各通道启用状态(由 onFeedback 更新)
chOnline: [0, 0, 0], // 各通道在线电机数(由 onMotorState 按 m.ch 统计,驱动下拉框信号标记)
selectedNode: null, // 当前选中节点号
motors: [], // 最近一次 /motor/state 的 motors 数组
bootActive: false, // 总线上是否有 0x103 BOOT 心跳
slaveAl: null, // EtherCAT 从站 AL state(8=OP),来自 /api/status
slaveOp: false, // native/health 的 op 位:从站是否在 OP
decoded: new Map(), // node -> 解码后的 STATUS 对象
decodedConfig: new Map(),// node -> 解码后的 CONFIG 对象(电机实时回报的目标模式/目标值)
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。
* 这是**当前链路**的生效值,等于 refreshByTp[tpId];全页仍只读这一个字段。 */
refreshIntervalMs: 250,
/* 每条链路各存一份刷新速度,切链路时自动换(见 refresh-rate.js)。 */
refreshByTp: defaultRefreshMap(),
lastSentMode: new Map(), // node -> 最近发送的 Control_Mode
lastSentTargets: new Map(), // node -> {mode, angle, velocity, current, voltage} 最近发送的 CMD 目标值
sendInFlight: false, // send_batch 互斥
pendingCmd: null, // latest-wins CMD 合并槽:发送在途时新 CMD 覆盖此帧,完成后立即发出
busCooldownUntil: 0, // 0xE2/0xE3 冷却截止时间戳
txCount: 0,
poll: { tick: null, probe: null, status: null, feedback: null },
pollGen: 0, // 递增使在途 tick 失效
tickInFlight: false,
tickStuck: 0, // 连续未按时完成的 motor 轮询拍数(>0 = 卡顿,通道下拉框变黄)
versionWait: null, // 挂载到主 tick 的版本等待 {node,prevSeq,deadline,resolve,reject};命中/超时由 pollLoop 检查
feedbackInFlight: false, // /can/feedback 独立链路占用标记
statusInFlight: false, // /system/status 独立链路占用标记
failCount: 0, // 连续硬失败计数,达阈值才断线
probing: false, // 已在断线探测,避免重复 startProbe/toast
probeGen: 0, // 探测代际:晚到的失败不得覆盖更新的成功
lastConnToastAt: 0, // toast 去抖
offlineSince: 0, // 进入离线时刻,短离线恢复不 toast
tickN: 0, // 轮询序号(每 5 轮附带 status)
recvTs: [], // 最近 N 次数据到达时间戳滑动窗口(用于计算实际接收 Hz)
statusTickN: 0, // status lite/full 交替计数(4 lite + 1 full 循环)
phaseDebug: { lastSlowReq: null, slowStats: {} }, // 慢请求诊断(>200ms)
};
/* 控制台诊断入口:main.js 以 ES module 加载,state 等顶层变量不会挂到 window,
* 控制台直接敲 state 会报 "state is not defined"。这里显式暴露只读诊断快照。
* 用法:控制台敲 motorDbg.state 。 */
window.motorDbg = {
state, // 完整 state 引用,方便深挖
get tickWorker() { return tickWorker; }, // Worker 节拍源对象(null=未创建)
get tickUseFallback() { return tickUseFallback; }, // true=已回退到 setTimeout 兜底
get transport() { return state.tp && state.tp.id; }, // 当前链路 id(wifi/ecat)
/* EtherCAT 传输内部:PDO 周期 / 累积的 config·版本数 / 最近一次原始快照。
* 排"界面没数据"时先看这里的 snapshot.wkc 是否等于 expected_wkc。 */
get transportDebug() { return state.tp && state.tp.debug ? state.tp.debug() : null; },
};
/**
* 连续「硬失败」多少次才判离线。
* HTTP 500/503 busy、超时 一律视为瞬时,不计入、不改 LED。
* EtherCAT 桥/代理路径毛刺更多(WebView2 调度、代理端口探测、
* 从站短暂 busy),阈值放宽,避免高压下 5 拍误断线并停掉命令序列。
*/
const FAIL_THRESHOLD = 5;
const FAIL_THRESHOLD_ECAT = 12;
function failThresholdFor(tpId) {
return tpId === "ecat" ? FAIL_THRESHOLD_ECAT : FAIL_THRESHOLD;
}
const CONN_TOAST_GAP_MS = 6000;
/** 离线不足此时长就恢复:不弹「连接已恢复」(过滤毛刺) */
const RECOVER_TOAST_MIN_OFFLINE_MS = 8000;
/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。 */
const TICK_INTERVAL_MS = 250;
/* 通道下拉框变黄的卡顿阈值:连续 N 拍未按时完成才亮黄。
* 设 2 而非 1:单拍延迟属正常网络抖动(请求略超一拍时长),
* 不应闪黄;仅持续卡顿(>2 拍)才提示链路饱和。 */
const TICK_STUCK_WARN = 2;
/* 固定节拍调度基准:pollLoop 完成后按绝对时间对齐下一个 tick,
* 使实际刷新周期 ≈ 设定值(单次请求耗时不再累加进周期)。 */
let tickNextAt = 0;
const PROBE_INTERVAL_MS = 2000;
const MIN_CMD_PERIOD_MS = 80;
/* ─── DOM refs ─── */
const $ = (s) => document.querySelector(s);
const nodeListEl = $("#nodeList");
/* ─── 工具函数 ─── */
/** 读取 ConnectionHub / base-url 共享设备 IP(dcu_esp_ip) */
function getSharedDeviceIp() {
return deviceLink.getIp();
}
/* ─── 输入持久化:刷新/重开/关掉后恢复上次配置 ───
* 双写:
* 1) localStorage —— 同步快路径,页面秒开就能回填
* 2) 磁盘文件 data/motor_ui_state.json(经本地服务器 /api/motor/ui-state)
* —— WebView2 清存储/换浏览器后仍在电脑上,满足「关掉之后还在」
* 恢复:先用 localStorage 渲染,再异步拉磁盘;两者比 savedAt,新的覆盖。
* 页隐藏/关闭时 pagehide + keepalive/sendBeacon 强制刷盘,避免 debounce 丢尾。 */
/* 注意:#refreshRate **不在** PERSIST_FIELDS —— 它按链路各存一份(refreshByTp),
* 走通用 DOM 回填会把上一条链路的值套到当前链路上。见 applyTransportRefresh。 */
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";
/** 打包完整配置快照(localStorage + 磁盘共用同一形状)。 */
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;
}
/** 把快照应用到 DOM + state(loadUiState / 磁盘 hydrate / 导入共用)。 */
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);
/* EtherCAT / 链路偏好写回 localStorage,transport-ecat 与 device-link 启动时读 */
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 (_) {}
/* DOM 已回填 → 同步 cmdCommitted。否则磁盘 hydrate/导入后,
* 界面显示新值,发送仍用旧默认,出现「输入框和发出的数据不一致」。 */
syncCmdCommittedFromDom();
/* 通道:PERSIST_FIELDS 只回填了 #chSelect,state.ch 必须跟着变。
* 否则 hydrate/发送仍用旧值(默认 0),看起来像「通道没保存」。 */
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;
/* keepalive:关页/切走时浏览器仍会把请求发完(≤64KB;序列过大时回落 sendBeacon) */
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;
/* keepalive fetch 失败(例如服务重启瞬间)再用 sendBeacon 兜底一次 */
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");
});
}
/** 关页/隐藏:停 debounce,立刻落盘(避免最后几次按键没写上)。 */
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);
/* 优先 keepalive fetch;失败/过大用 sendBeacon(无 CORS 预检,关页可靠) */
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();
});
/** 磁盘优先 hydrate:比 savedAt,新者生效;应用后重绘相关 UI。 */
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); /* 含 chSelect DOM + state.ch + cmdCommitted */
try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(disk)); } catch (_) {}
applyModeVisibility();
renderSeqList();
if (typeof renderNodeList === "function") renderNodeList();
updateNodeLabels();
/* 不要用 state.ch 反写 #chSelect —— 旧逻辑会在 applyUiSnapshot 恢复后
* 用仍是默认 0 的 state.ch 把通道框打回 CH0,保存的通道看起来丢了。 */
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;
}
}
/** 导出:下载完整配置 JSON(人工备份/拷到别的机器)。 */
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();
}
/** 导入:选择 JSON → 应用 → 双写保存。 */
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();
const t = typeof toast === "function" ? toast : null;
if (t) t("配置已导入并保存", "ok");
else updateUiSaveHint("saved");
} catch (e) {
if (typeof toast === "function") toast("导入失败: " + (e && e.message), "err");
else updateUiSaveHint("disk-err");
}
};
rd.readAsText(file);
}
/* 在 PERSIST_FIELDS 上挂 change/input 监听,自动保存。 */
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);
}
}
}
/* 序列循环次数不在 PERSIST_FIELDS(值随 seq 一起存),单独监听,改完立刻落盘 */
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 = "";
});
}
}
/* input 事件高频触发,debounce 300ms 避免每次按键都写 localStorage。 */
let _saveTimer = null;
function debounceSaveUi() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(saveUiState, 300);
}
/** status 回报的设备 IP 写回共享存储(与 CAN/OTA 页一致) */
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)); }
/* CAN ID 构造(物理 ID,node 位于 bits[19:16])— 对齐 DBC Motor_CAN_FD v0.70 */
function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); }
const ID_STATUS = (n) => canId(n, 0x0100); /* 0x000N0100 */
const ID_CMD = (n) => canId(n, 0x0200); /* 0x000N0200 */
const ID_CMD_CONFIG = (n) => canId(n, 0x0301); /* 0x000N0301 */
/* 版本:主机与电机共用 0x000N00FF(同 ID 应答,DBC CANFD_GET_VERSION,发送方 Motor)。
* 电机 APP 仅在收到 base_id=0x00FF 时回报(CANFD_VERSION_ID),DBC 无 0x05FF 消息。
* GD32 为解析核心:捕获电机应答后缓存,经 0xC6 同步给 ESP32,前端读 /motor/version。 */
const ID_GET_VERSION_REQ = (n) => canId(n, 0x00FF); /* 请求(与应答同 ID) */
const ID_SET_NODE_ID = (n) => canId(n, 0x0400); /* 0x000N0400 DLC 2 */
const ID_GET_NODE_ID = (n) => canId(n, 0x0401); /* 0x000N0401 DLC 2 */
/* hex 字符串 → Uint8Array */
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;
}
/* Uint8Array → 空格分隔 hex(用于状态栏预览) */
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();
}
/* ─── Toast ─── */
function toast(msg, type = "ok") {
const el = document.createElement("div");
el.className = `toast ${type}`;
const ledCls = type === "ok" ? "led-on" : type === "warn" ? "led-warn" : "led-err";
el.innerHTML = `<span class="led ${ledCls}" style="width:7px;height:7px"></span><span></span>`;
el.lastElementChild.textContent = msg;
$("#toastContainer").appendChild(el);
setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 220); }, 3000);
}
/* ─── 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 帧解码(对齐 DBC Motor_CAN_FD v0.74) ═══════════════════════
* BO_ STATUS DLC=48,信号起止与字节(Intel @1,除非注明 Motorola @0):
* MCU_temp 0|16@1- ×0.01 → B0-1
* Motor_temp 16|16@1- ×0.01 → B2-3
* U/V/W_cur 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
* InitStatus 256|32@1+ → B32-35 初始化错误码位图(0=全部正常)
*
* 旧实现误把 Power 当 B12、float 当 B16/20/24/28,导致角度/速度/电流整体错位乱码。
*/
function decodeStatus(bytes) {
if (!bytes || bytes.length < 32) return null;
/* 拷到独立 ArrayBuffer,避免 SharedArrayBuffer/子视图偏移干扰 DataView;
* 36B 覆盖 v0.74 新增的 InitStatus(B32-35)。载荷不足 36B 说明这条链路根本没
* 送这个字段 —— 必须返回 null 让 UI 显示"—",补 0 会把"无数据"显示成"初始化正常"。 */
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, /* 0=正常 1=报错(DBC VAL_) */
magStatus: (b10 & 0x02) ? 1 : 0,
thermalStatus: (b10 & 0x04) ? 1 : 0,
/* Motorola 3bit start=85 → 位序 85,84,83 = B10[5:3] */
eepromStatus: (b10 >> 3) & 0x07,
powerVoltage: dv.getUint16(11, true) * 0.001, /* bit88 → B11 */
rtAngle: f32(13), /* bit104 → B13 */
rtVelocity: f32(17), /* bit136 → B17 */
rtCurrent: f32(21), /* bit168 → B21 */
testFloat: f32(25), /* bit200 → B25 */
testU16: dv.getUint16(29, true), /* bit232 → B29 */
cpuUsage: copy[31], /* bit248 → B31 */
initStatus: u8.length >= 36 ? dv.getUint32(32, true) : null, /* bit256 → B32-35,0=全部正常;null=这条链路没送 */
};
}
/**
* 解码 CONFIG 帧 (0x000N0101, DLC 24, Motor→Host 目标值回报)。
* DBC 信号布局(Intel LE, 32 位目标值为 IEEE754 float32):
* 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
* B15-23: 未使用
*/
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),
};
}
/**
* STATUS 故障位简写(节点卡片右上角,空间紧凑)。
* 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit),及 InitStatus(B32-35)。
* 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3" / "初始化"。
*/
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);
if (d.initStatus) parts.push("初始化");
return parts;
}
function faultShortLabel(d) {
const parts = faultShortParts(d);
return parts.length ? parts.join("·") : "";
}
function faultCountOf(d) {
return faultShortParts(d).length;
}
/* ═══════════════════════ CMD 帧编码(对齐 DBC Motor_CAN_FD v0.70,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 数值输入提交制 ──
* 一帧发送(点「发送 CMD」/快捷按钮/加序列步骤)以**输入框当前值**为准:
* 构帧前 syncCmdCommittedFromDom(),所见即所发。
* 周期发送(doSendCmd(true))仍读 cmdCommitted:键入 "200" 的中间态
* "2"/"20" 不会被当成目标值打给电机;周期启动时会先按输入框提交一次。
* 配置恢复/导入(applyUiSnapshot)也会同步,避免界面与发出数据脱节。 */
const CMD_VALUE_IDS = ["inVoltage", "inCurrent", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP"];
const cmdCommitted = {};
/** 把输入框当前值写入 cmdCommitted(发送/恢复/启动周期前调用)。 */
function syncCmdCommittedFromDom() {
for (const id of CMD_VALUE_IDS) {
const el = document.getElementById(id);
if (el) cmdCommitted[id] = el.value;
}
}
function initCmdCommit() {
for (const id of CMD_VALUE_IDS) {
const el = document.getElementById(id);
if (!el) continue;
cmdCommitted[id] = el.value; /* 初始提交:含 loadUiState 回填值(本函数在其之后调用) */
el.addEventListener("change", () => { cmdCommitted[id] = el.value; }); /* blur/回车后值变化才触发 */
el.addEventListener("keydown", (e) => { if (e.key === "Enter") el.blur(); }); /* 回车 = 失焦提交 */
}
}
/* 读已提交值(未 init 时回退读当前框值,保险) */
function cmdVal(id) {
return cmdCommitted[id] !== undefined ? cmdCommitted[id] : $("#" + id).value;
}
/* 编码一条 CMD 帧(DLC 32)。values = {mode, voltage, current, velocity, angle,
* pidCurP, pidVelP, pidAngP},字段值接受字符串/数字(与输入框值同构)。
* 表单版与命令序列步骤共用此编码,保证两种发送路径帧格式一致。 */
function buildCmdBytes(values) {
const buf = new ArrayBuffer(32);
const dv = new DataView(buf);
const mode = parseInt(values.mode, 10) || 0;
dv.setUint8(0, mode);
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);
}
/* 表单版:默认以输入框当前值构帧(所见即所发)。
* opts.fromCommitted=true(周期发送)只读 cmdCommitted,避免键入中间态。 */
function buildFormCmdBytes(opts) {
if (!(opts && opts.fromCommitted)) syncCmdCommittedFromDom();
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"),
});
}
function bytesToHexStr(bytes) {
let s = "";
for (let i = 0; i < bytes.length; i++) s += pad2(bytes[i]);
return s;
}
/* ═══════════════════════ 发送核心 /can/send_batch(互斥 + 流控) ═══════════════════════ */
const GD32_ERR_DESC = {
0xE0: "payload 过短", 0xE1: "通道号非法",
0xE2: "BUS_OFF(总线离线)", 0xE3: "TEC 过高",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xEE: "SPI 帧解析失败",
0xFF: "无有效 SPI 响应(陈旧/空帧,非总线故障)",
};
/* -3 仅表示“GD32 未回 ACK”;是否真为总线故障看 gd32_err:0xE2/0xE3 才是总线,0xFF 是陈旧 SPI 帧 */
const CAN_SEND_ERR_DESC = { "-1": "参数错误", "-2": "SPI 通信失败", "-3": "GD32 未确认发送" };
function describeResult(result) {
if (result && result.status === "ok") {
return result.recv ? ("recv 0x" + result.recv + " (" + result.recv_len + "B)") : "ok";
}
const code = result ? result.code : -99;
let desc = CAN_SEND_ERR_DESC[String(code)] || ("code=" + code);
if (result && result.gd32_err != null && result.gd32_err !== 0) {
desc += " · " + (GD32_ERR_DESC[result.gd32_err] || ("gd32_err=0x" + result.gd32_err.toString(16).toUpperCase()));
}
return desc;
}
/**
* 发送一批帧(唯一入口)。互斥:同一时刻只允许一个请求在途。
* @returns {Promise<{ok:boolean, results?:object[], networkError?:boolean, busFault?:number, desc?:string}>}
*/
async function sendBatch(frames, opts) {
opts = opts || {};
if (state.sendInFlight) {
return { ok: false, desc: "上一次发送仍在进行(已跳过)", busy: true };
}
if (!state.connected && !opts.allowOffline) {
return { ok: false, desc: "设备未连接", networkError: true };
}
state.sendInFlight = true;
const n = frames.length;
/* 通道:默认用顶部选中通道;调用方可经 opts.ch 指定(命令序列每步独立通道) */
const txCh = (opts && Number.isInteger(opts.ch)) ? opts.ch : state.ch;
try {
/* 传输层只负责"把帧送出去 + 把响应补齐成 results[]";
* 下面的流控(退避/冷却/latest-wins)与传输无关,两种链路共用。 */
let results = await state.tp.sendFrames(frames, { ch: txCh });
/* 流控:检查首帧 gd32_err */
const first = results[0] || {};
const gerr = first.gd32_err;
if (gerr === 0xE4 && !opts.retried) {
/* 邮箱满:随机退避 50~150ms 重试一次 */
state.sendInFlight = false;
const backoff = 50 + Math.floor(Math.random() * 101);
await sleep(backoff);
return sendBatch(frames, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
stopRepeat(true);
stopSeq("总线异常,命令序列已停止", true);
toast("总线异常 (" + GD32_ERR_DESC[gerr] + ") — 停止发送,冷却 3s", "err");
return { ok: false, results: results, busFault: gerr, desc: GD32_ERR_DESC[gerr] };
}
const allOk = results.every((r) => r.status === "ok");
return { ok: allOk, results: results, desc: describeResult(first) };
} catch (e) {
/* 发送失败:瞬时错误静默;真断线累计,达阈值才离线 */
noteHardFailure(e, "send");
return { ok: false, networkError: true, desc: e.message || "网络错误" };
} finally {
state.sendInFlight = false;
/* latest-wins 排空:发送期间新到的 CMD 已合并到 pendingCmd(仅保留最新帧),
* 此处立即发出,保证周期指令不因互斥而丢失。E4 重试路径(已提前清锁)不触发。 */
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: id, dlc: dlc, data: dataHex }], opts);
}
/** 手动一次性发送(CONFIG / VERSION / 节点 ID 等):先等通道空闲再发。
* 周期 CMD 及其排空链通常 <100ms 完成,短暂等待即可避免 busy 拒绝;
* 超过 1.5s 仍忙则照发(sendBatch 返回 busy 并 toast 提示)。 */
async function sendOneFrameWhenFree(id, dlc, dataHex, opts) {
const deadline = nowMs() + 1500;
while (state.sendInFlight && nowMs() < deadline) await sleep(25);
const res = await sendOneFrame(id, dlc, dataHex, opts);
if (res.busy) toast("发送通道仍忙(上一包未完成),请稍后重试", "warn");
return res;
}
/**
* 突发(近同时)发送多个电机动作:POST /can/send_burst。
* ESP32 转 0xC7 SPI 突发,GD32 背靠背装载各通道 CAN 邮箱,组内偏差 µs 级;
* 超过单次突发容量时 ESP32 内部分块背靠背连发(组间 ~1-2ms)。
* 与 sendBatch 共享 state.sendInFlight 互斥;流控策略与 sendBatch 一致。
* @param {Array} actions [{ch,node,mode,voltage,current,velocity,angle,pid*}]
* @returns {Promise<{ok,results?,networkError?,busFault?,busy?,desc?}>}
*/
async function sendBurst(actions, opts) {
opts = opts || {};
if (state.sendInFlight) {
return { ok: false, desc: "上一次发送仍在进行(已跳过)", busy: true };
}
if (!state.connected && !opts.allowOffline) {
return { ok: false, desc: "设备未连接", networkError: true };
}
state.sendInFlight = true;
const frames = actions.map((a) => ({
ch: a.ch, node: a.node, data: bytesToHexStr(buildCmdBytes(a)),
}));
try {
/* WiFi:ESP32 转 0xC7 突发;EtherCAT:批量发送区天然同一个 seq 沿。
* 两边都保证"组内近同时",语义一致,故上层不必区分。 */
const results = await state.tp.sendBurst(frames, { suffix: 0x0200, dlc: 32 });
/* 流控:取第一个错误帧的状态(同 sendBatch 逻辑) */
const firstErr = results.find((r) => r.status !== "ok") || {};
const gerr = firstErr.gd32_err;
if (gerr === 0xE4 && !opts.retried) {
/* 邮箱满:随机退避 50~150ms 重试一次 */
state.sendInFlight = false;
const backoff = 50 + Math.floor(Math.random() * 101);
await sleep(backoff);
return sendBurst(actions, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
stopRepeat(true);
stopSeq("总线异常,命令序列已停止", true);
toast("总线异常 (" + GD32_ERR_DESC[gerr] + ") — 停止发送,冷却 3s", "err");
return { ok: false, results: results, busFault: gerr, desc: GD32_ERR_DESC[gerr] };
}
const allOk = results.every((r) => r.status === "ok");
return { ok: allOk, results: results, desc: allOk ? "ok" : describeResult(firstErr) };
} catch (e) {
noteHardFailure(e, "send");
return { ok: false, networkError: true, desc: e.message || "网络错误" };
} finally {
state.sendInFlight = false;
/* latest-wins 排空(与 sendBatch 一致) */
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 });
}
}
}
/* ═══════════════════════ 连接稳健性 ═══════════════════════
* 设备端 /system/status、/motor/state、/can/feedback 共用 httpJsonWorkMutex。
* 任一并发 → 500 busy;旧逻辑还把瞬时失败变成 LED 黄灯/断线 toast → 「偶尔闪一下」。
*
* 现策略:
* 1) 全链路单飞:递归 setTimeout,同一时刻只有 1 个 HTTP
* 2) 顺序:motor → feedback →(每 5 轮)status
* 3) 500/503/超时 = 瞬时,静默跳过,不改 LED、不计数
* 4) 仅「真断线」类错误累计,达阈值才 banner/toast
* 5) 短离线(<8s)恢复不弹「连接已恢复」
*/
function connToast(msg, type) {
const now = nowMs();
if (now - state.lastConnToastAt < CONN_TOAST_GAP_MS) return;
state.lastConnToastAt = now;
toast(msg, type);
}
/** 500/503 busy、超时:设备忙,不是链路掉线 */
function isTransientError(e) {
if (!e) return false;
if (e.transient === true) return true;
const st = Number(e.status);
if (st === 500 || st === 503 || st === 429) return true;
const name = e.name || "";
if (name === "TimeoutError" || name === "AbortError") return true;
const msg = String(e.message || e);
if (/timed?\s*out|aborted|AbortError|signal is aborted/i.test(msg)) return true;
if (/^HTTP\s+(500|503|429)\b/.test(msg)) return true;
/* 网络毛刺:高刷 HTTP 下偶发 Failed to fetch / 连接被重置很常见,
* 算硬失败会在几拍内把整页打成「设备未连接」。真拔线会持续超时/拒连,
* 由 failCount 在更高阈值上兜住(见 noteHardFailure 的网络类加权)。 */
if (/failed to fetch|networkerror|load failed|err_connection|econnreset|econnrefused|socket hang up|net::/i.test(msg)) {
e.transient = true;
return true;
}
return false;
}
/**
* 统一 GET JSON(设备直连)。失败抛 Error,附带 status / transient。
*
* 常规数据链(状态/反馈/发送/版本)已全部走 state.tp 传输层,
* 这里保留给 OTA 那条 WiFi 专属链路复用同一套错误语义。
* @returns {Promise<object>}
*/
async function fetchDeviceJson(path, timeoutMs) {
let resp;
const t0 = performance.now();
try {
resp = await fetch(getBaseUrl() + path, {
signal: AbortSignal.timeout(timeoutMs || 3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransientError(err);
recordSlowReq(path, performance.now() - t0, err.message);
throw err;
}
recordSlowReq(path, performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return resp.json();
}
/* 慢请求统计:>200ms 记入 phaseDebug.slowStats[path] = {count, maxMs, lastMs, lastAt}。
* 用于诊断"卡在哪个 endpoint"——不是看单次 lastSlowReq,而是看各 path 的卡顿分布。
* 控制台 phaseDebug.slowStats 直接看哪个 path 累计最多。 */
function recordSlowReq(path, durMs, errMsg) {
if (durMs <= 200) return;
const s = state.phaseDebug.slowStats;
const key = path.split("?")[0]; /* /motor/state?bin=1 → /motor/state */
if (!s[key]) s[key] = { count: 0, maxMs: 0, lastMs: 0, lastAt: 0, lastErr: null };
s[key].count++;
s[key].maxMs = Math.max(s[key].maxMs, Math.round(durMs));
s[key].lastMs = Math.round(durMs);
s[key].lastAt = Date.now();
if (errMsg) s[key].lastErr = errMsg;
state.phaseDebug.lastSlowReq = {
path, dur: Math.round(durMs), at: Date.now(), err: errMsg
};
}
/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */
function noteHardSuccess() { state.failCount = 0; }
/* ═══════════════════════ 传输层装配 ═══════════════════════
* 传输实例需要 recordSlowReq / isTransientError / getBaseUrl,
* 这些都定义在本文件里,因此用注入而非反向 import —— 否则
* transport-*.js 与 main.js 会互相 import 形成循环。 */
const TRANSPORT_CTX = {
getBaseUrl: () => getBaseUrl(),
recordSlow: (path, ms, err) => recordSlowReq(path, ms, err),
isTransient: (e) => isTransientError(e),
};
/**
* 把**当前链路**的刷新速度装载为生效值:写 state.refreshIntervalMs、回填输入框、
* 更新 Hz 标签,必要时重排节拍。
*
* 为什么刷新速度不能两条链路共用一个值:见 refresh-rate.js 的头注释。
* 这里是"链路 → 刷新速度"的唯一入口,applyRefreshRate 则是反方向
* (用户改输入框 → 存回当前链路那一份),两者共同维持 refreshByTp 与
* refreshIntervalMs 一致。
*/
function applyTransportRefresh() {
const v = pickRefresh(state.refreshByTp, state.tpId);
const changed = state.refreshIntervalMs !== v;
state.refreshByTp[state.tpId] = v;
state.refreshIntervalMs = v;
/* 输入框无条件回填:初始化时 state 恰好与 HTML 默认值相同也要写,
* 否则输入框会停在 HTML 里的 250 而实际链路用的是别的值。
* min/step 也随链路走:ECAT 允许到 6ms(读本机快照,见 refresh-rate.js),
* WiFi 仍是 50ms 起。不同步 min 的话,浏览器自带的数字校验会把
* 合法值标成 :invalid,微调箭头也会停在错误的档位上。 */
const el = $("#refreshRate");
if (el) {
const lo = minRefreshFor(state.tpId);
el.min = String(lo);
el.step = isUnthrottled(lo) ? "1" : "50"; /* 不限速档要能逐 ms 微调 */
el.value = v;
}
if (!changed) return v; /* 值没变就别白重排节拍 */
updateNodeLabels(); /* STATUS 面板右上角 Hz 跟随 */
/* 兜底模式下清掉按旧间隔排的 setTimeout,避免双链 */
if (state.poll.tick) {
clearTimeout(state.poll.tick);
state.poll.tick = null;
}
if (state.connected && !state.probing) {
tickNextAt = 0;
schedulePollLoop(state.pollGen, 0);
}
return v;
}
/** 建立(或切换到)指定传输。返回新实例。 */
function useTransport(id) {
state.tpId = id;
state.tp = createTransport(id, TRANSPORT_CTX);
return state.tp;
}
/**
* 切换传输:停链路 → 旧传输收尾 → 建新传输 → 重新探测连接。
* OTA 页签在不支持的传输下禁用(EtherCAT 没有固件上传 + relay 链路)。
* 刷新速度按链路各存一份,由 applyTransportUi → applyTransportRefresh 自动换。
*/
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();
}
/** 按当前传输能力刷新 UI:OTA 页签可用性 + 链路下拉框 + 发送提示。 */
function applyTransportUi() {
const tp = state.tp;
const sel = $("#transportSelect");
if (sel && sel.value !== state.tpId) sel.value = state.tpId;
/* 网卡匹配子串只对 EtherCAT 有意义,WiFi 下隐藏(而不是禁用):
* 它不是"暂时不能用",而是这条链路根本没有网卡的概念。 */
const ad = $("#ecatAdapter");
if (ad) {
const show = !!tp.getAdapter;
ad.style.display = show ? "" : "none";
if (show) ad.value = tp.getAdapter();
}
/* OTA 只有 WiFi 链路有(ESP32 固件上传 + GD32 本地 relay)。
* 不支持时禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清"换回 WiFi 链路即可用"。 */
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 链路后可用"
: "";
/* 当前正停在 OTA 页而链路不支持 → 退回配置页,避免面对一个点不动的界面 */
if (off && otaBtn.classList.contains("active")) {
const cfg = document.querySelector('.tab-btn[data-tab="config"]');
if (cfg) cfg.click();
}
}
/* 刷新速度:先装载本链路那一份(回填输入框 + 重排节拍),再更新提示文案。
* EtherCAT 的 PDO 周期由后台循环独立维持(看门狗要求),与界面刷新率无关,
* 这点必须说清楚否则会被当成"改了没生效"。 */
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();
}
function noteHardFailure(e, source) {
if (e && e.transient) return; /* 500/503/超时/网络毛刺等瞬时错误不计入 */
state.failCount++;
/* ECAT:status 1Hz 健康读失败不该和 motor 热路径同等累计 ——
* 从站 AL 抖一下就断电机页会误停周期发送/命令序列。 */
if (state.tpId === "ecat" && source === "status" && state.connected) {
if (state.failCount < failThresholdFor("ecat") * 2) return;
}
const thr = failThresholdFor(state.tpId);
if (state.failCount >= thr && state.connected) {
try {
state.phaseDebug.lastHardFail = {
source, msg: e && e.message, at: Date.now(), failCount: state.failCount, thr,
};
} catch (_) {}
enterDisconnected(e && e.message, source);
}
}
function enterDisconnected(msg, source) {
if (state.probing) return;
const wasConnected = state.connected;
state.connected = false;
state.connecting = false;
state.probing = true;
state.failCount = 0;
state.offlineSince = nowMs();
state.bootActive = false;
state.slaveAl = null;
state.slaveOp = false;
stopRepeat(true);
stopSeq("连接中断,命令序列已停止", true);
stopDataPolling();
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent =
"连接中断" + (source ? " (" + source + ")" : "") +
" — 使用主页共享 IP (" + state.ip + ") 自动重连中…";
if (wasConnected) {
connToast("网络异常:" + (msg || "请求失败") + " — 已停止周期发送,自动重连中", "err");
}
startProbe();
}
function startProbe() {
stopTimer("probe");
state.probing = true;
if (!state.offlineSince) state.offlineSince = nowMs();
scheduleProbe(0);
}
function stopProbe() {
stopTimer("probe");
state.probing = false;
}
function scheduleProbe(delayMs) {
stopTimer("probe");
state.poll.probe = setTimeout(function () {
state.poll.probe = null;
probeOnce();
}, delayMs == null ? PROBE_INTERVAL_MS : delayMs);
}
/**
* 把冗长的链路错误压成一句人能看的话。
*
* 为什么需要:EtherCAT 后端会把整串诊断塞进 log[],SSC 邮箱楔死那条会带
* **64 个一模一样的 MailboxError** 加一段 SM 寄存器现场,原样显示等于没显示。
* 这里做两件事:把重复项折叠成"×N",并对已知故障给出**该怎么办**
* —— 楔死是软件恢复不了的(ecat_native.c 里同样注明要断电复位 MCU)。
*/
function summarizeLinkError(msg) {
let s = String(msg || "未知错误");
/* 折叠重复的 MailboxError(…),只留计数 */
const mb = s.match(/MailboxError\([^)]*\)/g);
if (mb && mb.length > 2) {
s = s.replace(/\[?MailboxError\([^)]*\)(,\s*MailboxError\([^)]*\))*\]?/,
`MailboxError ×${mb.length}`);
}
/* 已知故障 → 给可执行的处置建议 */
if (/邮箱地址错位楔死|ConfigMapError/.test(s)) {
return "SSC 邮箱楔死(config_map 失败)—— 需复位 DCU 板 MCU 才能恢复(软件无法自愈)";
}
if (/没有找到描述包含/.test(s)) {
return "找不到匹配的网卡 —— 请在工具栏「链路」旁的输入框改网卡匹配子串";
}
if (/没有发现从站/.test(s)) {
return "总线上没有从站 —— 检查 DCU 板供电与网线是否接在该网口";
}
/* 环境类错误原样给,它本身就是指引。token 对齐 native 代理 autostart 的实际文案:
* "找不到...守护进程" / "请先运行 build_native.bat" / "Npcap 未安装或 8090..8095 全被占"。 */
if (/Npcap|build_native|守护进程|全被占/.test(s)) return s;
/* 兜底:截断,完整内容留在 title 里 */
return s.length > 140 ? s.slice(0, 140) + "…" : s;
}
/** 探测成功收尾:置在线、清横幅、开轮询。代际不符时丢弃(防晚到结果覆盖)。 */
function finishProbeOk(gen, data) {
if (gen !== state.probeGen) return;
const offlineMs = state.offlineSince ? (nowMs() - state.offlineSince) : 0;
stopProbe();
state.connected = true;
state.connecting = false;
state.offlineSince = 0;
state.failCount = 0;
state.probeErr = null;
state.probeErrFull = null;
$("#netBanner").classList.remove("show");
applySystemStatus(data);
updateConnUi();
/* 短离线恢复不弹 toast,避免毛刺「闪一下」 */
if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) {
connToast("连接已恢复 — " + transportTargetLabel(), "ok");
}
startPolling();
}
async function probeOnce() {
if (!state.probing) return;
const gen = ++state.probeGen;
state.ip = getSharedDeviceIp();
const label = $("#ipLabel");
if (label) label.textContent = transportTargetLabel();
try {
const data = await state.tp.probe();
if (gen !== state.probeGen || !state.probing) return; /* 已有更新的探测在跑 */
finishProbeOk(gen, data);
} catch (e) {
if (gen !== state.probeGen) return; /* 晚到的失败:别把后来的成功打成横幅 */
/* 把失败原因显示出来。
* 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP),
* 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作**
* (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / exe 没编译或 Npcap 未装),
* 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */
const raw = (e && e.message) ? String(e.message) : "未知错误";
/* EtherCAT:motor/start 超时 ≠ 总线没起来。
* 外壳 Owner / 上一轮 start 可能已把 PDO 循环跑着,probe 里那记
* motor/start 因 loop_stop+重进 OP 拖过 30s 才报 signal timed out。
* 此时用只读健康位复核:在跑就当连上,别把「实际已连」标成失败。 */
if (state.tpId === "ecat" && state.tp.healthCheck &&
/timed?\s*out|signal|超时/i.test(raw)) {
try {
const data = await state.tp.healthCheck();
if (gen === state.probeGen && state.probing) {
finishProbeOk(gen, data);
return;
}
return; /* 代际已变:结果交给新一轮 */
} catch (_) { /* 真没起来 → 走下面的失败展示 */ }
}
if (gen !== state.probeGen || !state.probing) return;
state.probeErrFull = raw;
state.probeErr = summarizeLinkError(raw);
const bt = $("#netBannerText");
if (bt) {
bt.textContent = (state.tpId === "ecat" ? "EtherCAT 接入失败: " : "连接失败: ")
+ state.probeErr + "(每 2 秒自动重试)";
bt.title = raw; /* 完整诊断留在悬浮提示里 */
}
$("#netBanner").classList.add("show");
updateConnUi(); /* 同步 #devStatus,别让它一直写"探测中…" */
if (state.probing) scheduleProbe(PROBE_INTERVAL_MS);
}
}
/** 连接目标的显示文本:WiFi 是设备 IP,EtherCAT 是本地服务 + 从站。 */
function transportTargetLabel() {
return (state.tpId === "ecat") ? "EtherCAT 从站" : ("IP " + state.ip);
}
/** 当前通道上在线的电机数(1–9)。 */
function onlineMotorCount() {
return (state.motors || []).filter(function (m) {
return m && m.online && m.node >= 1 && m.node <= 9;
}).length;
}
const AL_NAMES = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
/**
* 目标是否在线 —— 工具栏指示灯的唯一判据。
*
* 灯的语义保持不变:就是「是否在线」。
* - WiFi:ESP32 HTTP 可达即在线(标签是设备 IP)。
* - EtherCAT:标签是「EtherCAT 从站」,在线 = 从站 AL 处于 OP。
* 卡在 PREOP / INIT / SAFEOP 时,即使主站服务+网卡+PDO 循环在跑、
* 读得到 AL、界面还有残留槽位,灯也不绿。
*/
function isTargetOnline() {
if (!state.connected) return false;
if (state.tpId !== "ecat") return true;
/* AL 已读到:只有 OP 算在线。PREOP 等一律不绿。 */
if (state.slaveAl != null) return state.slaveAl === 8;
/* AL 还没读到:仅当 native health 明确报 op 时才绿 */
return state.slaveOp === true;
}
/** 状态栏追加的在线状态提示(灯黄时把原因写出来)。 */
function onlineStatusParts(data) {
const parts = [];
if (state.tpId === "ecat") {
const al = state.slaveAl;
if (al != null && al !== 8) {
parts.push("从站 " + (AL_NAMES[al] || ("0x" + al.toString(16))) + " · 未在线");
}
}
if (state.connected && !isTargetOnline()) {
if (!parts.length) parts.push("未在线");
}
return parts;
}
/**
* 自动连接当前链路(本页无连接按钮)。
* WiFi 用主页共享 IP,EtherCAT 打本地 EtherCAT 服务。
* 失败不阻断页面,进入探测直到链路可用。
*/
function connectShared() {
state.ip = getSharedDeviceIp();
state.connecting = true;
state.probing = false;
state.failCount = 0;
state.probeErr = null; /* 新一轮连接:清掉上次的失败原因 */
state.probeErrFull = null;
state.offlineSince = nowMs();
state.bootActive = false;
state.slaveAl = null;
state.slaveOp = false;
/* 新一轮探测:递增代际,作废在途的旧 probeOnce(其晚到失败不得再写横幅) */
state.probeGen += 1;
state.ch = parseInt($("#chSelect").value, 10) || 0;
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent = (state.tpId === "ecat")
? "正在自动接入 EtherCAT … 按需拉起本地服务 → 打开网卡 → 进 OP → 启动 PDO 循环"
: ("自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP");
startProbe();
}
/** 传输回报的状态 → #devStatus。字段差异由各传输的 describeStatus 消化。 */
function applySystemStatus(data) {
const ip = state.tp.reportedIp(data);
if (ip) setSharedDeviceIp(ip);
/* 在线判定用的从站状态:AL 优先,native health 的 op 作补充 */
if (data && data.slave && data.slave.state != null) {
state.slaveAl = Number(data.slave.state) & 0x0F;
state.slaveOp = state.slaveAl === 8;
} else if (data && data.__op != null) {
state.slaveOp = !!data.__op;
}
const parts = (state.tp.describeStatus(data) || []).concat(onlineStatusParts(data));
const el = $("#devStatus");
if (el) el.textContent = parts.length ? parts.join(" · ") : "在线";
updateConnUi();
}
/* 无设备静默态:常驻动画在 160Hz 屏上每帧重算+重绘,实测未接设备时
renderer+GPU 各占约一核的七成。规则见 css/responsive.css 的
html[data-dcu-calm="1"] 段;本页不 import connection.js,故就地写同一标记。 */
function setQuietMode(quiet) {
const v = quiet ? "1" : "0";
if (document.documentElement.dataset.dcuCalm !== v) {
document.documentElement.dataset.dcuCalm = v;
}
}
function updateConnUi() {
const led = $("#connLed");
const ipLabel = $("#ipLabel");
setQuietMode(!state.connected);
if (ipLabel) ipLabel.textContent = transportTargetLabel();
/* 指示灯 = 是否在线(语义不变)。
* 绿 = 在线(isTargetOnline)
* 黄 = 主站/链路可达,但目标未在线(从站没进 OP / 收不到帧)
* 黄闪 = 探测中
* 红 = 未连接
* 以前 connected 就涂绿:EtherCAT 只要服务+网卡+PDO 循环在就算在线,
* 即使从站卡 PREOP、收不到任何 STATUS 也绿着 —— 灯起不到指示作用。 */
if (state.connecting || state.probing) {
if (led) {
led.className = "led led-warn led-pulse";
led.title = "正在探测链路…";
}
if (!state.connected) {
const ds = $("#devStatus");
/* 有失败原因就显示原因摘要,别一直"探测中…"(那等于什么都没说)。
* 摘要截断:SSC 邮箱楔死那类错误会带 64 个 MailboxError + SM 现场,
* 全塞进工具栏会把布局挤爆,完整内容留在横幅和 title 里。 */
if (ds) {
if (state.probeErr) {
const brief = state.probeErr.length > 44
? state.probeErr.slice(0, 44) + "…" : state.probeErr;
ds.textContent = brief;
ds.title = state.probeErrFull || state.probeErr;
} else {
ds.textContent = "探测中…";
ds.title = "";
}
}
}
} else if (state.connected) {
if (led) {
if (isTargetOnline()) {
led.className = "led led-on";
led.title = state.tpId === "ecat"
? "EtherCAT 从站在线(AL OP)"
: ("设备在线 · " + (state.ip || ""));
} else {
led.className = "led led-warn";
const alTxt = state.slaveAl != null
? ("AL " + (AL_NAMES[state.slaveAl] || state.slaveAl)) : "AL 未知";
led.title = state.tpId === "ecat"
? ("EtherCAT 从站未在线(" + alTxt + ")— 卡在 PREOP/非 OP 时不亮绿灯;查供电/网线/从站状态")
: "链路可达但设备未就绪";
}
}
} else {
if (led) {
led.className = "led led-err";
led.title = "未连接";
}
const ds = $("#devStatus");
if (ds) ds.textContent = "未连接";
}
const sendHint = $("#sendHint");
if (sendHint) sendHint.textContent = state.tp.sendHint(state.ch);
const sbCh = $("#sbCh");
if (sbCh) sbCh.textContent = "CH" + state.ch;
}
/* ═══════════════════════ 轮询调度(单飞递归,永不并发) ═══════════════════════ */
function stopTimer(key) {
if (state.poll[key]) {
clearTimeout(state.poll[key]);
clearInterval(state.poll[key]);
state.poll[key] = null;
}
}
/**
* 双链路轮询:motor 和 feedback 各自独立定时器,互不阻塞。
*
* 历史问题:原来 motor → feedback 串行,任一挂起(ESP32 端 mutex 阻塞 /
* WiFi 抖动)会拖累另一个,表现为数据卡顿。
* 现在:motor 和 feedback 各走独立固定节拍定时器,
* - motor 挂起 3s 超时期间,feedback 仍正常刷
* - feedback 挂起不影响 motor
* 两者共用 ESP32 端 httpJsonWorkMutex,ESP32 串行处理即可。
*/
function stopDataPolling() {
state.pollGen += 1;
stopTimer("tick");
stopTimer("feedback");
stopTimer("status");
state.tickInFlight = false;
state.feedbackInFlight = false;
state.tickStuck = 0; /* 停止轮询 → 清除卡顿标记 */
/* 清理挂起的版本等待:主 tick 已停,resolve(null) 让调用方走超时分支,避免 Promise 悬挂 */
if (state.versionWait) {
const w = state.versionWait;
state.versionWait = null;
w.resolve(null);
}
updateTickStuckUi();
/* 停止 Worker 节拍(后台节流兜底) */
if (tickWorker) {
tickWorker.postMessage({ type: "stop" });
try { tickWorker.terminate(); } catch (e) { /* ignore */ }
tickWorker = null;
tickUseFallback = false;
}
}
/**
* 启动数据链路:HTTP 三链(motor tick / feedback 1Hz / status 1Hz)立即开车。
*/
function startPolling() {
stopDataPolling(); /* 递增 pollGen,清残留定时器/在途链 */
state.failCount = 0;
state.tickN = 0;
state.statusTickN = 0;
state.recvTs = []; /* 清除旧的到达时间窗口,Hz 从新数据重新累积 */
tickNextAt = 0; /* 节拍基准重置:首个 tick 立即执行后按新间隔对齐 */
const gen = state.pollGen;
schedulePollLoop(gen, 0);
scheduleFeedbackPoll(gen, 0);
scheduleStatusPoll(gen, 1000);
/* 连上后把持久化的刷新速度同步给 GD32,使 0xC5 推送周期与本地轮询一致(无需用户重新拨动) */
sendMotorPeriod(state.refreshIntervalMs || 250);
}
function scheduleStatusPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("status");
state.poll.status = setTimeout(function () {
state.poll.status = null;
if (gen !== state.pollGen || !state.connected || state.probing) return;
/* lite/full 交替:4 次 lite 后 1 次 full,循环。
* lite ~150B 不调重活;full ~3KB 含 OTA/heap/SPI,5s 一次。 */
const wantFull = (state.statusTickN % 5) === 0;
state.statusTickN = (state.statusTickN || 0) + 1;
fetchSystemStatus(gen, wantFull).finally(() => {
if (gen === state.pollGen && state.connected && !state.probing) {
scheduleStatusPoll(gen, 1000);
}
});
}, delayMs == null ? 1000 : delayMs);
}
/* feedback 独立定时器:固定 1000ms(与 GD32 0xC1 CAN 反馈 1Hz 推送对齐)。
* feedback 是 CAN 错误计数/总线状态快照,GD32 1Hz 推送,前端 1Hz 拉即可,
* 加快只会拿到陈旧数据还跟 motor 抢 ESP32 端 mutex。
* 与 motor 解耦后,motor 挂起不拖 feedback,feedback 挂起不拖 motor。 */
function scheduleFeedbackPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("feedback");
state.poll.feedback = setTimeout(function () {
state.poll.feedback = null;
if (gen !== state.pollGen || !state.connected || state.probing) return;
if (state.feedbackInFlight) {
scheduleFeedbackPoll(gen, 1000);
return;
}
state.feedbackInFlight = true;
fetchFeedback(gen).finally(() => {
state.feedbackInFlight = false;
if (gen === state.pollGen && state.connected && !state.probing) {
scheduleFeedbackPoll(gen, 1000);
}
});
}, delayMs == null ? 1000 : delayMs);
}
/* ═══════ Web Worker 节拍(后台标签页不节流) ═══════
* 浏览器对后台(隐藏)标签页的 setTimeout/setInterval 强制 ≥1s 节流,
* 使刷新速度在切后台后降到 ~1Hz。Dedicated Worker 的定时器不受此限制,
* 故把 motor 轮询的节拍源放到 tick.worker.js:Worker 按设定间隔定期发
* 'tick' 消息,主线程收到后才真正发起一次 fetchMotorState。
* 前台行为与原来一致;后台也能守住设定的刷新速度。 */
/* 用 import.meta.url 解析: 本模块在 js/motor/ 下, 与引用它的页面在哪层目录无关
* (页面已移入 pages/, 文档相对路径会解析成 /pages/js/... 而 404)。 */
const TICK_WORKER_URL = new URL("./tick.worker.js?v=20260802g", import.meta.url);
let tickWorker = null; /* Web Worker 节拍源(null = 未创建/失败) */
let tickUseFallback = false; /* Worker 创建/加载失败 → 回退到 setTimeout 兜底 */
/**
* tick 统一入口:Worker 每 tick、或 setTimeout 兜底触达时调用。
* 负责单飞互斥 + 卡顿标记(是否跳过由 tickInFlight 决定)。
*/
function tickHandler() {
if (!state.connected || state.probing) return;
if (state.tickInFlight) {
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
* 「不限速」档(如 ECAT 的 6ms 下限档)下这是**预期行为**:设定间隔贴着
* 单次往返耗时(均值 ≈1.8ms,但尾部会越过 6ms),节拍源撞上在途请求是常态。
* 此时不计卡顿,否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",
* 而用户要的就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
if (!isUnthrottled(state.refreshIntervalMs)) {
state.tickStuck = (state.tickStuck || 0) + 1;
updateTickStuckUi();
}
return;
}
pollLoop(state.pollGen);
}
/**
* 确保 Worker 就绪并按 interval 校时。Worker 已存在时只更新间隔。
* Worker 加载失败(如 404 或不被服务端支持)→ 回退到 setTimeout 兜底。
*/
function ensureTickWorker(interval) {
if (tickUseFallback) return;
if (tickWorker) {
tickWorker.postMessage({ type: "update", interval });
return;
}
try {
tickWorker = new Worker(TICK_WORKER_URL);
} catch (err) {
tickWorker = null;
tickUseFallback = true;
startTickFallback(state.pollGen);
return;
}
tickWorker.onmessage = (e) => { if (e.data === "tick") tickHandler(); };
tickWorker.onerror = () => {
try { tickWorker.terminate(); } catch (e) { /* ignore */ }
tickWorker = null;
tickUseFallback = true;
startTickFallback(state.pollGen);
};
tickWorker.postMessage({ type: "start", interval });
}
/* Worker 不可用时的兜底:留用原 setTimeout 固定节拍(前台正常,后台受节流)。 */
function startTickFallback(gen) {
if (gen !== state.pollGen) return;
stopTimer("tick");
tickNextAt = 0;
scheduleTickFallbackFrame(gen, 0);
}
function scheduleTickFallbackFrame(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("tick");
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
let delay = delayMs != null ? delayMs : iv;
if (delayMs == null && tickNextAt > 0) {
/* 固定节拍对齐:按绝对时间调度,请求耗时不再拖慢周期 */
const remain = tickNextAt - nowMs();
if (remain <= 0) delay = (tickNextAt + iv < nowMs()) ? iv : 10;
else delay = remain;
}
state.poll.tick = setTimeout(function () {
state.poll.tick = null;
if (gen !== state.pollGen) return;
tickHandler();
tickNextAt = (tickNextAt > 0 ? tickNextAt : nowMs()) + (state.refreshIntervalMs || TICK_INTERVAL_MS);
scheduleTickFallbackFrame(gen, null);
}, delay);
}
/**
* motor 轮询主链:只跑 fetchMotorState,feedback/status 已剥离独立定时器。
* 节拍由 Web Worker(或兜底 setTimeout)驱动;pollLoop 自身不重排,
* 单飞互斥和卡顿标记在 tickHandler 侧完成。失败/断线由上层停止调度。
*/
async function pollLoop(gen) {
if (gen !== state.pollGen) return;
state.tickInFlight = true;
try {
await fetchMotorState(gen);
if (gen !== state.pollGen) return;
state.tickN += 1;
} finally {
state.tickInFlight = false;
if (state.tickStuck) { state.tickStuck = 0; updateTickStuckUi(); } /* 请求结束 → 卡顿解除 */
}
/* 版本查询搭便车主 tick 节拍:tickInFlight 已释放,checkVersionWait 异步触发,
* 不阻塞下一拍 tickHandler(/motor/version 即使慢也不会触发 tickStuck 误报)。 */
if (state.versionWait) {
checkVersionWait();
}
}
/**
* 调度 motor 节拍。delayMs === 0 表示立即触发首拍并启动 Worker;
* 之后每拍由 Worker 自然驱动,不再从这里重排。
*/
function schedulePollLoop(gen, delayMs) {
if (gen !== state.pollGen) return;
if (tickUseFallback) {
if (delayMs === 0) startTickFallback(gen);
return;
}
ensureTickWorker(state.refreshIntervalMs || TICK_INTERVAL_MS);
if (delayMs === 0) setTimeout(tickHandler, 0); /* 立即首拍,后续靠 Worker tick */
}
async function fetchSystemStatus(gen, wantFull) {
if (gen !== state.pollGen || !state.connected || state.probing) return;
try {
/* WiFi:wantFull=false 走 ?lite=1(~150B),不调 ESP32 端
* heap_caps_get_info / 任务栈遍历 / OTA snapshot,避免 httpd 单线程被
* 重 handler 阻塞几十~上百 ms;wantFull=true 5s 一次,含 OTA/heap/SPI。
* EtherCAT:读从站身份/AL 状态,无 lite/full 之分。 */
const data = await state.tp.fetchStatus(wantFull);
if (gen !== state.pollGen) return;
applySystemStatus(data);
noteHardSuccess();
} catch (e) {
if (gen !== state.pollGen) return;
noteHardFailure(e, "status");
}
}
async function fetchMotorState(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
try {
/* WiFi 走二进制 /motor/state?bin=1(~408B vs JSON ~1200B,省 66%);
* EtherCAT 读后台 PDO 循环的快照。两者都归一成同一份 motors[] 契约,
* 所以 onMotorState 及其下游全部不用区分链路。 */
const data = await state.tp.fetchMotorState();
if (gen != null && gen !== state.pollGen) return false;
onMotorState(data);
noteHardSuccess();
return true;
} catch (e) {
if (gen != null && gen !== state.pollGen) return false;
noteHardFailure(e, "motor");
return false;
}
}
async function fetchFeedback(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
/* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,CAN 反馈注定拿不到
* (WiFi 链上每次 0xC1 都被 ESP32 链路层拒并打日志)。跨 iframe 标记
* 见 common/utils.js flashBusyNow,随刷写收尾自撤,下轮自动恢复。 */
if (flashBusyNow()) return false;
try {
/* WiFi:/can/feedback?lite=1 —— 跳过 entries 数组(~3KB → ~200B),
* 本页只用 ch/stats 字段,不需要 CAN 帧快照(那是监控页用的)。
* EtherCAT:从最近一次过程数据快照的 bridge 段取,不额外打一次后端。 */
const data = await state.tp.fetchFeedback();
if (gen != null && gen !== state.pollGen) return false;
onFeedback(data);
noteHardSuccess();
return true;
} catch (e) {
if (gen != null && gen !== state.pollGen) return false;
noteHardFailure(e, "feedback");
return false;
}
}
/* ═══════════════════════ /motor/state 处理 ═══════════════════════ */
/* 后端表已改 node×channel 双键(同节点号可同时在多通道在线),
* motors[] 可能含同 node 不同 ch 的多条记录,decoded 缓存键也必须带通道。 */
function motorKey(m) { return m.ch + ":" + m.node; }
/* ── UI 绘制合流 ────────────────────────────────────────────────
* 热路径 HTTP 可到 100–300Hz,但 DOM/样式跟不上:每拍全量
* renderNodeList + renderStatusPanel + flash 会把主线程打满,
* 表现为界面掉帧、Hz 反而上不去(rAF 被饿死)。
* 策略:数据照常每拍解码进 state;绘制合并到 rAF,且最密 30Hz。
* 波形 canvas 自有 33ms 采样环,不受此限。 */
const UI_PAINT_MIN_MS = 33;
let uiPaintPending = false;
let lastUiPaintAt = 0;
let lastUiSig = ""; /* 连接灯/通道摘要签名,变了才写那几处 DOM */
let lastConnSig = "";
const _decodedReuse = new Map();
const _decodedConfigReuse = new Map();
const _statusRefs = {}; /* STATUS 面板静态节点缓存 */
function statusEl(id) {
let el = _statusRefs[id];
if (el === undefined) {
el = document.getElementById(id);
_statusRefs[id] = el;
}
return el;
}
function setClsIfChanged(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function toggleClsIfChanged(el, cls, on) {
if (!el) return;
const has = el.classList.contains(cls);
if (has !== !!on) el.classList.toggle(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);
}
function onMotorState(data) {
const motors = (data && data.motors) ? data.motors : [];
state.motors = motors;
state.bootActive = !!(data && data.boot_active);
/* 记录实际数据到达时间戳滑动窗口,用于 Hz 计算 */
const ts = state.recvTs;
ts.push(Date.now());
if (ts.length > RECV_HZ_WINDOW * 2) ts.splice(0, ts.length - RECV_HZ_WINDOW);
/* 解码复用 Map:每拍 new 两个 Map 是 200Hz 下的稳定分配源 */
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();
}
/* 固定节点 1–9:始终可选(含离线),便于电机已进 BOOT / 无 STATUS 时仍能刷写。 */
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;
}
/* BOOT 徽章:内容不变不碰 DOM */
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";
}
/* 连接灯/状态栏:签名不变则跳过(sendHint/LED 每拍重写是纯浪费) */
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();
}
/* ─── 节点列表渲染 ───
* ★ 固定显示 N1–N9:即使离线/无 0xC5 推送也可见可选,方便电机在 BOOT 时刷写。
* 性能:首次全量重建 DOM;之后只增量更新文本/class。 */
const FIXED_MOTOR_NODES = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const _nodeCards = new Map(); // node -> { card, led, modeEl, stTxt, angleVal, velVal, curVal, faultsEl }
/** 把后端 motors[] 叠到固定 1–9 槽位;无数据的槽位记为 offline。
* ★ node×channel 双键:同节点号可能在多个通道各有一条记录。
* 优先级:当前通道在线 > 其他通道在线(otherCh 标注,如 "0/2")>
* 当前通道离线记录 > 空槽位。 */
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: "" };
});
}
/** 通道切换或电机数据更新后,按当前通道刷新节点列表 / STATUS 面板 / 在线汇总 */
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;
}
/** 节点卡片模式标签:电机 CONFIG 帧 B0(= FOC.control_mode)每周期实时回报,是唯一权威来源;
* 仅在这条链路还没回过 CONFIG 时,退到前端记录的最近发送 CMD。
* 都不到就用 "--",不要写"待机"—— 电机没有这个状态,写了就是假仪表。 */
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();
/* 固定 9 槽:仅首次或 DOM 被清空时全量重建 */
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;
}
/* 槽位稳定 → 增量更新文本/class */
for (const m of motors) {
const ref = _nodeCards.get(m.node);
if (!ref) continue;
/* otherCh 卡片不取解码数据:其 STATUS 属于其他通道,本通道视图不展示/不标故障 */
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;
/* motStatus 是报错位(1=故障),不是运行标志;模式取电机 CONFIG 帧实时回报。
* otherCh:节点在其他通道在线,标注实际通道供用户切换。 */
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 ? "正常" : "");
const angle = d && m.online ? d.rtAngle.toFixed(1) + "°" : "--";
const vel = d && m.online ? d.rtVelocity.toFixed(0) : "--";
const cur = d && m.online ? d.rtCurrent.toFixed(0) : "--";
if (ref.angleVal.textContent !== angle) ref.angleVal.textContent = angle;
if (ref.velVal.textContent !== vel) ref.velVal.textContent = vel;
if (ref.curVal.textContent !== cur) ref.curVal.textContent = cur;
if (ref.faultsEl) {
const dots = ref.faultsEl.querySelectorAll(".fault-dot").length;
if (dots !== faultCount) {
ref.faultsEl.innerHTML = faultCount > 0 ? "<span class='fault-dot'></span>".repeat(faultCount) : "";
}
}
}
}
/* 构建单节点卡片的 HTML(仅集合变化时调用) */
function buildNodeCardHtml(m) {
/* otherCh 卡片不取解码数据(同 renderNodeList 增量路径) */
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();
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m));
const modeCls = m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : ""));
const stTxt = !m.online ? "--" : (faultLabel || "OK");
const cardTitle = faultLabel ? ("故障: " + faultLabel)
: m.otherCh != null ? `节点在 CH${m.otherCh} 在线,切换顶部通道查看`
: (m.online ? "在线" : "离线也可选中刷写");
const angle = d && m.online ? d.rtAngle.toFixed(1) + "°" : "--";
const vel = d && m.online ? d.rtVelocity.toFixed(0) : "--";
const cur = d && m.online ? d.rtCurrent.toFixed(0) : "--";
return `<div class="${cls}" role="option" tabindex="0" data-node="${m.node}" aria-selected="${isSel}" title="${cardTitle}">
${faultCount > 0 ? `<div class="nc-faults">${"<span class='fault-dot'></span>".repeat(faultCount)}</div>` : ""}
<div class="nc-top">
<span class="led ${m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : "")}"></span>
<span class="nc-id">N${m.node}</span>
<span class="nc-mode ${modeCls}">${modeLabel}</span>
<span class="nc-status-text" title="${faultLabel ? ("故障: " + faultLabel) : ""}">${stTxt}</span>
</div>
<div class="nc-readings">
<div class="nc-reading"><span class="lbl">角度</span><span class="val">${angle}</span></div>
<div class="nc-reading"><span class="lbl">速度</span><span class="val">${vel}</span></div>
<div class="nc-reading"><span class="lbl">电流</span><span class="val">${cur}</span></div>
</div>
</div>`;
}
nodeListEl.addEventListener("click", (e) => {
const card = e.target.closest(".node-card");
if (!card) return;
state.selectedNode = parseInt(card.dataset.node, 10);
scopeSys.reset(); /* 换电机:清波形历史,避免两台电机数据混流 */
saveUiState(); /* 选中节点变化时持久化,刷新后仍选中此节点 */
paintMotorUi(true);
restoreVersionUi(state.selectedNode); /* 回填该节点缓存的版本号 */
});
nodeListEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { const card = e.target.closest(".node-card"); if (card) card.click(); }
});
/* ─── 节点相关标签更新 ─── */
/* 数据到达记录:滑动窗口长度(足够在 50ms~5s 间隔下都平滑) */
const RECV_HZ_WINDOW = 10;
/* STATUS 面板右上角频率:基于实际数据到达时间计算,不用用户设定的数值做死算。
* 要显示的就是**网页实际刷新率**(数据每秒真到几次):电机 CAN 回报率可能高于
* 链路承载(WiFi 慢于电机时界面拿不到中间帧),按电机回报率显示会谎报;
* 按到达时间实测则快链路自动跑出高值、慢链路如实反映卡顿。
* 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz;
* 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */
function hzText(hz) {
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
function statusHzText() {
const ts = state.recvTs;
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
* 「不限速」档要按实测往返而非设定值算这个窗口:6ms × 2 × 10 = 120ms,
* 一次尾部抖动(>120ms)就把刚到的样本全判成"旧数据"清空,
* Hz 会永远退回理论值(谎报 166Hz)。
* 这里给不限速档一个固定的 2s 窗口,足够攒满 10 个样本算出真实频率。 */
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
const maxAge = isUnthrottled(iv) ? 2000 : iv * 2 * RECV_HZ_WINDOW;
const cutoff = Date.now() - maxAge;
while (ts.length > 0 && ts[0] < cutoff) ts.shift();
if (ts.length >= 2) {
const span = ts[ts.length - 1] - ts[0];
const avg = span / (ts.length - 1);
if (avg > 0 && avg < 60000) return hzText(1000 / avg);
}
/* 窗口数据不足时回退到理论值,不让面板空着。
* 不限速档没有有意义的理论值(6ms≠166Hz),显示 "…" 等实测数据到齐。 */
if (isUnthrottled(iv)) return "\u2026 Hz";
return hzText(1000 / iv);
}
function updateNodeLabels() {
const n = state.selectedNode;
if (n == null) {
$("#nodeBadge").textContent = "NODE -";
$("#cmdIdLabel").textContent = "ID 0x000-0200 · DLC 32";
$("#statusIdLabel").textContent = "0x000-0100 · " + statusHzText();
document.querySelectorAll(".cfgNodeNibble").forEach((el) => (el.textContent = "-"));
return;
}
const hex = nodeHex(n);
$("#nodeBadge").textContent = `NODE ${n}`;
$("#cmdIdLabel").textContent = `ID ${hexId(ID_CMD(n))} · DLC 32`;
$("#statusIdLabel").textContent = `${hexId(ID_STATUS(n))} · ${statusHzText()}`;
document.querySelectorAll(".cfgNodeNibble").forEach((el) => (el.textContent = hex));
}
/* ═══════════════════════ STATUS 面板渲染 ═══════════════════════ */
/* 值变了才写 DOM。轮询下限 6ms(≈160Hz)下,无条件的 textContent/className/style
* 覆写每一拍都会把整片面板标脏,样式重算和重绘全程满载 —— 而其中绝大多数拍
* 写的值和上一拍一模一样。左侧节点列表早就是这个写法,面板对齐它。 */
function setTextIfChanged(el, s) { if (el && el.textContent !== s) el.textContent = s; }
function setClassIfChanged(el, c) { if (el && el.className !== c) el.className = c; }
function setStyleIfChanged(el, prop, v) { if (el && el.style[prop] !== v) el.style[prop] = v; }
/* 高亮"续期"而不是每拍重排:160Hz 轮询下每拍都会调这里,若每次都 add + 排一个
* 350ms 的 remove,就是每秒 ~480 个定时器,而且摘与下一拍重新加上之间会留
* 一个可见的空档。改成每次只把 _flashUntil 往后推,收尾定时器发现还在续期
* 窗口内就自己改期重排 —— 数据在流期间 class 一直挂着,停流 350ms 后才摘。 */
function flashEl(el) {
if (!el) return;
el._flashUntil = nowMs() + 350;
if (el.classList.contains("flash")) return;
el.classList.add("flash");
setTimeout(function done() {
const left = el._flashUntil - nowMs();
if (left > 0) { setTimeout(done, left); return; } /* 期间被续期,改期再摘 */
el.classList.remove("flash");
}, 350);
}
function renderStatusPanel() {
const n = state.selectedNode;
/* ★ node×channel 双键:只展示当前通道的记录;节点在其他通道在线时给出切换提示 */
const entries = n != null ? state.motors.filter((m) => m.node === n) : [];
const cur = entries.find((m) => m.ch === state.ch);
const others = entries.filter((m) => m.online && m.ch !== state.ch);
const onOtherCh = !(cur && cur.online) && others.length > 0;
const motor = onOtherCh ? null : cur;
const d = (motor && motor.online) ? state.decoded.get(motorKey(motor)) : null;
const offline = !motor || !motor.online || !d;
toggleClsIfChanged(statusEl("offlineOverlay"), "show", offline);
toggleClsIfChanged(statusEl("statusLive"), "hide", offline);
if (offline) {
if (!state.connected) {
setTextIfChanged($("#offlineTitle"), "设备未连接");
setTextIfChanged($("#offlineSub"), "本页共用 OTA 主页 IP,请到主页配置并测试连接");
} else if (onOtherCh) {
const chs = others.map((o) => "CH" + o.ch).sort().join(" / ");
setTextIfChanged($("#offlineTitle"), `节点 ${n} 在 ${chs} 有信号`);
setTextIfChanged($("#offlineSub"), `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`);
} else if (!motor || !motor.online) {
setTextIfChanged($("#offlineTitle"), n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)");
setTextIfChanged($("#offlineSub"), "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写");
} else {
setTextIfChanged($("#offlineTitle"), `节点 ${n} 暂无解码数据`);
setTextIfChanged($("#offlineSub"), "等待 STATUS 帧;OTA 刷写不受影响");
}
return;
}
/* flash 只在数值真变化时打:30Hz 绘制下无条件 flash 等于每秒 90 个定时器 */
const flashNum = (id, txt) => {
const el = statusEl(id);
if (!el) return;
if (el.textContent !== txt) {
el.textContent = txt;
flashEl(el);
}
};
flashNum("rtAngle", d.rtAngle.toFixed(2));
flashNum("rtVelocity", d.rtVelocity.toFixed(1));
flashNum("rtCurrent", d.rtCurrent.toFixed(0));
setTextIfChanged(statusEl("mcuTemp"), d.mcuTemp.toFixed(2) + " °C");
setTextIfChanged(statusEl("motTemp"), d.motTemp.toFixed(2) + " °C");
/* 目标值:优先使用电机 CONFIG 帧回报(0x000N0101, Motor→Host),
* 无 CONFIG 帧时回退到前端 lastSentTargets 记录 */
const cfg = state.decodedConfig.get(motorKey(motor));
if (cfg) {
setTextIfChanged($("#tgtAngle"), cfg.targetAngle.toFixed(1));
setTextIfChanged($("#tgtVelocity"), cfg.targetVelocity.toFixed(0));
setTextIfChanged($("#tgtCurrent"), cfg.targetCurrent.toFixed(0));
} else {
const tgt = state.lastSentTargets.get(n);
if (tgt) {
const MODES_TARGETS = { 1: ["angle"], 2: ["velocity"], 3: ["velocity", "angle"], 4: ["current", "velocity", "angle"], 5: ["current"], 6: ["velocity"], 7: ["velocity"] };
const active = MODES_TARGETS[tgt.mode] || [];
const fmtTgt = (id, val, active) => setTextIfChanged($("#" + id), active ? String(val) : "--");
fmtTgt("tgtAngle", tgt.angle.toFixed(1), active.includes("angle"));
fmtTgt("tgtVelocity", tgt.velocity.toFixed(0), active.includes("velocity"));
fmtTgt("tgtCurrent", tgt.current.toFixed(0), active.includes("current"));
} else {
setTextIfChanged($("#tgtAngle"), "--");
setTextIfChanged($("#tgtVelocity"), "--");
setTextIfChanged($("#tgtCurrent"), "--");
}
}
const mcuPct = clampNum(d.mcuTemp, 0, 100);
const motPct = clampNum(d.motTemp, 0, 100);
setStyleIfChanged($("#mcuTempBar"), "width", mcuPct + "%");
setClassIfChanged($("#mcuTempBar"), "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : ""));
setStyleIfChanged($("#motTempBar"), "width", motPct + "%");
setClassIfChanged($("#motTempBar"), "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : ""));
setTextIfChanged($("#valU"), `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`);
setTextIfChanged($("#valV"), `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`);
setTextIfChanged($("#valW"), `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`);
setTextIfChanged($("#pwrVolt"), d.powerVoltage.toFixed(3) + " V");
setTextIfChanged($("#testFloat"), d.testFloat.toFixed(3));
setTextIfChanged($("#testU16"), String(d.testU16));
setTextIfChanged($("#cpuPct"), Math.round(d.cpuUsage) + "%");
setTextIfChanged($("#cpuSub"), `seq ${motor.seq} · rx ${motor.rx_count}`);
const circumference = 131.9;
setStyleIfChanged($("#cpuRingFg"), "strokeDashoffset",
(circumference * (1 - clampNum(d.cpuUsage, 0, 100) / 100)).toFixed(1));
/* 状态标志位 */
const setFlag = (itemId, ledId, stateId, active, isFault, okText, faultText) => {
setClassIfChanged($("#" + itemId), "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : ""));
setClassIfChanged($("#" + ledId), "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : ""));
setTextIfChanged($("#" + stateId), active ? (isFault ? faultText : okText) : "—");
};
/* motStatus:DBC 0=正常 / 1=报错(不是使能位) */
setFlag("flagItemMot", "flagMot", "flagMotState", true, d.motStatus === 1, "NORMAL", "FAULT");
setFlag("flagItemMag", "flagMag", "flagMagState", true, d.magStatus === 1, "NORMAL", "FAULT");
setFlag("flagItemThermal", "flagThermal", "flagThermalState", true, d.thermalStatus === 1, "NORMAL", "过温!");
setFlag("flagItemEeprom", "flagEeprom", "flagEepromState", true, d.eepromStatus !== 0, "NORMAL", "ERR=" + d.eepromStatus);
/* InitStatus(v0.74 STATUS B32-35):初始化错误码位图,0=全部正常;
* 含电流检测配平超限/无采样帧等,详见 config.h INIT_ERR_* */
const INIT_ERR_NAMES = ["角度传感器", "ADC", "CAN", "角度滤波", "速度滤波", "FOC", "电流配平", "电流无帧", "门驱动"];
let initFaultTxt = "";
if (d.initStatus) {
const names = [];
for (let i = 0; i < INIT_ERR_NAMES.length; i++) {
if (d.initStatus & (1 << i)) names.push(INIT_ERR_NAMES[i]);
}
initFaultTxt = names.length ? names.join("·") : "0x" + (d.initStatus >>> 0).toString(16);
}
setFlag("flagItemInit", "flagInit", "flagInitState", d.initStatus !== null,
d.initStatus !== 0, "NORMAL", initFaultTxt);
}
/* ═══════════════════════ /can/feedback 处理(状态栏) ═══════════════════════ */
function busStateText(bs) {
if (bs == null) return "--";
if (bs === 0) return "ACTIVE";
const parts = [];
if (bs & 1) parts.push("INIT");
if (bs & 2) parts.push("SLEEP");
if (bs & 4) parts.push("ERR_PASSIVE");
if (bs & 8) parts.push("BUS_OFF");
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
/* ─── 轮询卡顿指示(fixed-tick 下某拍未按时完成 = 上一次请求仍占用链路) ───
* 通道下拉框变黄 + 悬浮提示显示连续卡顿拍数;请求结束自动清除。 */
function updateTickStuckUi() {
const sel = $("#chSelect");
if (!sel) return;
const stuck = (state.tickStuck || 0) >= TICK_STUCK_WARN;
sel.classList.toggle("stuck", stuck);
sel.title = stuck
? `电机状态轮询卡顿:连续 ${state.tickStuck} 拍未按时完成(上一次请求仍占用链路)`
: "";
updateChSelectOptions(); /* 同步选框字色:卡顿黄色优先于在线绿色 */
}
/** 根据通道启用/信号状态更新下拉框选项:
* - 禁用通道贴 "(禁用)" 标签并置灰
* - 有信号通道(存在在线电机)贴 "●N" 标记并着色 */
function updateChSelectOptions() {
const sel = $("#chSelect");
if (!sel) return;
for (let i = 0; i < sel.options.length; i++) {
const opt = sel.options[i];
const en = state.chEnabled[i];
const cnt = state.chOnline[i] || 0;
const base = "CH" + i;
setTextIfChanged(opt, !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base));
setStyleIfChanged(opt, "color", !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : ""));
}
/* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效),
* 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白。
* 卡顿优先:轮询卡顿时显示黄色,覆盖在线绿色(updateTickStuckUi 触发本函数同步) */
const curEn = state.chEnabled[state.ch];
const curCnt = state.chOnline[state.ch] || 0;
setStyleIfChanged(sel, "color", (state.tickStuck || 0) >= TICK_STUCK_WARN ? "var(--warn,#f59e0b)"
: (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "")));
}
function onFeedback(data) {
const channels = (data && data.channels) || [];
/* 更新各通道启用状态,禁用通道在下拉框标灰 + 阻止发送 */
for (let i = 0; i < 3; i++) {
const c = channels.find((x) => x.ch === i) || channels[i];
state.chEnabled[i] = !c || c.enabled === undefined || c.enabled === null || !!c.enabled;
}
updateChSelectOptions();
const ch = channels.find((c) => c.ch === state.ch) || channels[state.ch] || {};
const valid = ch.valid && ch.enabled !== false;
$("#sbRx").textContent = "RX " + (valid && ch.rx != null ? ch.rx.toLocaleString() : "--");
$("#sbTx").textContent = valid && ch.tx != null ? ch.tx.toLocaleString() : "--";
$("#sbErr").textContent = valid && ch.err != null ? ch.err.toLocaleString() : "--";
$("#sbTec").textContent = valid && ch.tec != null ? ch.tec : "--";
$("#sbRec").textContent = valid && ch.rec != null ? ch.rec : "--";
const bsText = valid ? busStateText(ch.bus_state) : "--";
const busEl = $("#sbBus");
busEl.textContent = bsText;
const isBusOff = valid && ch.bus_state != null && (ch.bus_state & 8);
const isPassive = valid && ch.bus_state != null && (ch.bus_state & 4);
busEl.style.color = isBusOff ? "var(--danger)" : isPassive ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const sbLed = $("#sbLed");
sbLed.className = "led " + (isBusOff ? "led-err" : (valid ? "led-on" : ""));
}
/* ═══════════════════════ 控制模式联动 ═══════════════════════ */
const modeSelect = $("#modeSelect");
function applyModeVisibility() {
const mode = parseInt(modeSelect.value, 10);
const targets = MODES[mode].targets;
$("#fldCurrent").classList.toggle("target-hidden", !targets.includes("current"));
$("#fldVelocity").classList.toggle("target-hidden", !targets.includes("velocity"));
$("#fldAngle").classList.toggle("target-hidden", !targets.includes("angle"));
$("#modeDesc").textContent = MODE_DESCS[mode];
updateQuickHint();
}
modeSelect.addEventListener("change", applyModeVisibility);
/* ═══════════════════════ 发送 CMD ═══════════════════════ */
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 (!state.connected) { if (!silent) toast("设备未连接", "err"); return; }
if (!state.chEnabled[state.ch]) { if (!silent) toast("CH" + state.ch + " 已禁用(GD32 固件未启用此通道)", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const node = state.selectedNode;
/* silent=周期:读已提交值;一帧发送:先按输入框同步再构帧 */
const bytes = buildFormCmdBytes({ fromCommitted: !!silent });
const id = ID_CMD(node);
const dataHex = bytesToHexStr(bytes);
const mode = parseInt($("#modeSelect").value, 10) || 0;
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
if (state.sendInFlight) {
/* latest-wins 合并:上一包仍在途 → 新帧覆盖合并槽(不排队、不丢弃),
* sendBatch 的 finally 排空时立即发出最新帧,周期指令始终收敛到最新值 */
state.pendingCmd = { id: id, dlc: 32, data: dataHex };
$("#sbLastSend").textContent = `最近发送: CMD → ${hexId(id)} [32B] 已合并(上一包在途) ${time}`;
return;
}
$("#sbLastSend").textContent = `最近发送: CMD → ${hexId(id)} [32B] ${bytesToHexSpaced(bytes, 8)} … ${time}`;
const res = await sendOneFrame(id, 32, dataHex, { silent: !!silent });
if (res.ok) {
state.lastSentMode.set(node, mode);
state.lastSentTargets.set(node, {
mode: mode,
angle: parseFloat(cmdVal("inAngle")) || 0,
velocity: parseFloat(cmdVal("inVelocity")) || 0,
current: parseFloat(cmdVal("inCurrent")) || 0,
voltage: parseFloat(cmdVal("inVoltage")) || 0,
});
state.txCount++;
renderNodeList();
renderStatusPanel();
if (!silent) {
toast(`CMD → Node ${node} · ${MODES[mode].zh} 发送成功`, "ok");
} else if (state.pendingCmd) {
/* 周期模式:仅在合并发生时轻提示,让用户知道发送速率跟不上周期 */
$("#sbLastSend").textContent = `最近发送: CMD → ${hexId(id)} [32B] 周期发送中(部分帧已合并) ${time}`;
}
} else if (!res.busy && !res.networkError && !silent) {
toast(`CMD 发送失败: ${res.desc}`, "err");
}
}
$("#sendCmdBtn").addEventListener("click", () => doSendCmd(false));
/* ═══════════════════════ 快捷运行按钮 ═══════════════════════
* 一键把按钮值写入当前模式的主目标字段并立即发送 CMD:
* 含角度目标的模式(1/3/4)→ Target_Angle;速度模式(2/6/7)→ Target_Velocity;
* 电流模式(5)→ Target_Current;Disable(0)无目标,仅提示。
* 写入走 cmdCommitted 提交制(等同回车确认),周期发送随后沿用同一目标。 */
function quickFieldOfMode(mode) {
const t = MODES[mode].targets;
if (t.includes("angle")) return { id: "inAngle", name: "目标角度", unit: "deg" };
if (t.includes("velocity")) return { id: "inVelocity", name: "目标速度", unit: "deg/s" };
if (t.includes("current")) return { id: "inCurrent", name: "目标电流", unit: "mA" };
return null;
}
function updateQuickHint() {
const f = quickFieldOfMode(parseInt(modeSelect.value, 10) || 0);
$("#qrTargetHint").textContent = f ? `→ ${f.name} ${f.unit}` : "→ 当前模式无目标";
}
document.querySelectorAll("#quickRun .qr-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const f = quickFieldOfMode(parseInt(modeSelect.value, 10) || 0);
if (!f) { toast("当前为 Disable 模式,无目标值可设置", "warn"); return; }
const input = document.getElementById(f.id);
input.value = btn.dataset.val;
cmdCommitted[f.id] = btn.dataset.val;
await doSendCmd(false);
});
});
/* ═══════════════════════ 周期发送(最小 80ms) ═══════════════════════ */
let repeatTimer = null;
const repeatBtn = $("#sendRepeatBtn");
const periodInput = $("#repeatPeriod");
function getPeriod() {
return clampNum(parseInt(periodInput.value, 10) || 100, MIN_CMD_PERIOD_MS, 10000);
}
function startRepeat() {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
stopSeq("周期发送已启动,命令序列已停止", true); /* 与命令序列互斥 */
const period = getPeriod();
periodInput.value = period;
syncCmdCommittedFromDom(); /* 周期起点 = 输入框当前值,与一帧发送一致 */
stopTimer("repeat");
repeatTimer = setInterval(() => doSendCmd(true), period);
state.poll.repeat = repeatTimer;
repeatBtn.textContent = "停止周期"; repeatBtn.classList.add("btn-primary");
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; state.poll.repeat = null; }
state.pendingCmd = null; /* 丢弃合并槽残留帧,避免停止后仍发出 */
repeatBtn.textContent = "周期发送"; repeatBtn.classList.remove("btn-primary");
if (!silent) toast("周期发送已停止", "warn");
}
repeatBtn.addEventListener("click", () => (repeatTimer ? stopRepeat() : startRepeat()));
periodInput.addEventListener("change", () => {
const p = getPeriod(); periodInput.value = p;
if (repeatTimer) { stopRepeat(true); startRepeat(); }
});
/* ═══════════════════════ 命令序列发送(循环发送设定步骤) ═══════════════════════
* 实现见 js/motor/seq.js(P1 重做:机械外提,契约不变)。 */
const seqSys = createSeqSys({
MODES, FIXED_MOTOR_NODES, state, clampNum, escapeHtml, $,
cmdVal, debounceSaveUi, saveUiState, inBusCooldown,
renderNodeList, nowMs, sendBurst, showModal, sleep,
stopRepeat, syncCmdCommittedFromDom, toast,
});
const {
seq, seqListEl, seqStartBtn, seqLoopsInput,
normStep, renderSeqList, startSeq, stopSeq, updateSeqUi,
} = seqSys;
/* ═══════════════════════ 配置操作(CMD_CONFIG,DLC 24) ═══════════════════════ */
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 或重新上电才能唤醒。" },
};
/* 构造 CMD_CONFIG 24 字节脉冲帧:仅置位对应 bit,其余为 0 */
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");
}
});
});
/* ═══════════════════════ 版本查询 ═══════════════════════
* 协议:主机与电机共用 0x000N00FF(同 ID 应答,DLC 24)。
* GD32 为解析核心:捕获电机应答缓存后经 0xC6 同步,前端读 /motor/version。
* 载荷布局(DBC v0.71 = 电机 APP sendVersionInfoViaCan 实际发送):
* soft@0 hard@8 boot@16,每组 major|minor|patch|build 4×u16 LE,24B 无空洞。
* 旧 v0.70 把字段误当 YYYY.MM.DD 日期解析(3 信号+2B 空洞),年份校验
* 把 major=26 判非法 → 全部标 "(?)" 且丢 build 字段,已废弃。
*/
const VERSION_FULL_BYTES = 24; /* 3 组 × 4 字段 × u16,满帧 24B */
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);
/* 与电机 versionToString 同格式:major.minor.patch.build,不补零 */
return `${major}.${minor}.${patch}.${build}`;
}
/**
* 解析 0x00FF 载荷。以 hex 实际字节数为准(勿被 dlc 声明截短)。
* 固定布局 soft@0 hard@8 boot@16,不再做双布局猜测。
*/
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,
rawHex: bytesToHexStr(copy.subarray(0, n)),
byteLen: n,
complete: n >= VERSION_FULL_BYTES && !!(soft && hard && boot),
};
}
function setVer(id, text) {
const el = $("#" + id);
if (!el) return;
if (text) {
el.textContent = text;
el.classList.remove("ver-empty");
} else {
el.textContent = "--";
el.classList.add("ver-empty");
}
}
/** 回填节点缓存的版本号到版本卡片(切节点/刷新后调用)。
* 未缓存时显示 "--",不覆盖正在查询中的状态。 */
function restoreVersionUi(node) {
if (node == null) {
setVer("verSoft", null);
setVer("verHard", null);
setVer("verBoot", null);
return;
}
const v = state.versions.get(node);
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);
}
/* ── 版本查询:GD32 为解析核心,电机 0x00FF 应答由 GD32 缓存后经 0xC6 推送,
* ESP32 以 /motor/version 提供缓存。前端发请求后轮询 /motor/version,
* 按 seq 增长判定是否为本次查询的新应答(不再从 /can/feedback 取原始帧)。 ── */
async function fetchMotorVersionNode(node) {
try {
/* WiFi:读 ESP32 的 /motor/version 缓存(GD32 解析后经 0xC6 同步)。
* EtherCAT:版本应答从批量接收区回来,由传输层自行累积并维护 seq,
* 所以下面"按 seq 增长判新应答"的逻辑对两种链路都成立。 */
return await state.tp.fetchVersionNode(node);
} catch (e) {
return null; /* 瞬时 busy 忽略 */
}
}
function setVerWaiting(label) {
["verSoft", "verHard", "verBoot"].forEach((k) => {
const el = $("#" + k);
if (el) { el.textContent = label; el.classList.add("ver-empty"); }
});
}
/** EtherCAT:强制消费 motor/rx(WiFi 无此接口,空操作)。 */
async function drainVersionRx() {
if (state.tp && typeof state.tp.drainPendingRx === "function") {
try { await state.tp.drainPendingRx(); } catch (_) {}
}
}
/** 取一次版本:先 drain,再 map,最后盯 xrx 本节点 0x00FF。 */
async function sampleVersionOnce(node, prevSeq, sentAt) {
await drainVersionRx();
const mapV = await fetchMotorVersionNode(node);
if (mapV && mapV.version &&
(prevSeq == null || mapV.seq > prevSeq || (mapV.at && mapV.at >= sentAt))) {
return mapV;
}
if (state.tp && typeof state.tp.peekVersionXrx === "function") {
const x = await state.tp.peekVersionXrx(node);
if (x && x.version) return x;
}
return null;
}
/**
* EtherCAT 专用等待:不绑主 tick,每 ~25ms 主动 drain + 看 xrx。
* 旧逻辑只在 pollLoop 末尾 checkVersionWait,且要求 seq 增长 ——
* xrx 上是别的节点/同内容时会整段「无回报」,表现为阵发全败。
*/
async function waitVersionEcat(node, prevSeq, sentAt, timeoutMs) {
const deadline = nowMs() + (timeoutMs || 2500);
while (nowMs() < deadline) {
const v = await sampleVersionOnce(node, prevSeq, sentAt);
if (v && v.version) return v;
await sleep(25);
}
return null;
}
/** 版本等待:挂载到主 tick(pollLoop),每拍检查一次 seq,命中或超时才 resolve。
* 节拍跟随 state.refreshIntervalMs(与 motor 状态轮询共用同一调度),
* 不再开独立 sleep 循环,避免与主 tick 节拍错位造成的窗口延迟。
* 超时返回 null(与原轮询语义一致)。 */
function waitMotorVersionOnTick(node, prevSeq, timeoutMs, sentAt) {
return new Promise((resolve) => {
state.versionWait = {
node,
prevSeq,
sentAt: sentAt || nowMs(),
deadline: nowMs() + (timeoutMs || 5000),
resolve,
};
});
}
/** 主 tick 每拍调用:检查挂起的版本等待。
* seq 增长,或(ECAT)缓存时间戳不早于本次发送 → resolve。
* EtherCAT 必须先 drain:版本帧可能已在 motor/rx 队列,但快照 count 只报本拍新增。 */
async function checkVersionWait() {
const w = state.versionWait;
if (!w) return;
if (nowMs() >= w.deadline) {
state.versionWait = null;
w.resolve(null);
return;
}
try {
const v = await sampleVersionOnce(w.node, w.prevSeq, w.sentAt);
if (v && v.version) {
state.versionWait = null;
w.resolve(v);
}
} catch (e) { /* 瞬时 busy 忽略,下拍再查 */ }
}
$("#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); /* 0x000N00FF 请求(与应答同 ID) */
const bytes = new Uint8Array(24);
const isEcat = !!(state.tp && state.tp.id === "ecat");
/* EtherCAT:xrx 单槽 + 多节点抢槽,多打并独立轮询 */
const maxAttempts = isEcat ? 3 : 1;
setVerWaiting("查询中…");
await drainVersionRx();
let v = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const before = await fetchMotorVersionNode(node);
const prevSeq = before ? before.seq : null;
const sentAt = nowMs();
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;
if (isEcat) {
v = await waitVersionEcat(node, prevSeq, sentAt, timeoutMs);
} else {
const immediate = await sampleVersionOnce(node, prevSeq, sentAt);
if (immediate && immediate.version) {
v = immediate;
} else {
v = await waitMotorVersionOnTick(node, prevSeq, timeoutMs, sentAt);
}
}
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"
);
});
/* ═══════════ 节点 ID / 反馈刷新率参数(SET 0x000N0400 / GET 0x000N0401) ═══════════
* 电机 v0.4.1 起支持扩展格式:
* SET DLC4 [选择器 u16 LE][值 u16 LE] → 单播回 5B ACK [status][选择器][值]
* GET DLC2 [选择器 u16 LE] → 回 4B [选择器][值]
* 选择器:0x0000=节点 ID(仅单播)、0x0100/0x0101=STATUS/CONFIG 帧刷新周期 ms
* (0=停发;电机写 Flash 掉电保存,SET 即时生效并清发送分频计数器)。
* SET DLC1~2 与 GET DLC<=1 保持旧格式不变;旧固件对 GET 任何 DLC 都回 2B 节点
* ID,对 SET DLC4 按旧语义把前两字节当节点号 —— 刷新率选择器(≥0x0100)远超
* 合法范围 1~9,必被旧固件 NAK,无害。
*
* 回执取法(两链路不同,见 queryParam/setParam):
* WiFi —— GD32 的 0xC0 ACK 只有 1 字节状态、不带电机回帧;回帧落在 GD32
* per-ID 快照表(0xC1),经 fetchFeedbackFull 按需拉取。同一 ID 的
* 回帧快照只存最新一条 → 一次快照只配一次请求(STATUS/CONFIG
* 分开读写,不做同 ID 批量)。
* EtherCAT —— 回帧异步经批量接收区(motor/rx)回来,takeNodeIdReport 取走
* 即清,按长度区分 4B 扩展 / 2B 旧格式 / 5B SET ACK。 */
/* 参数选择器(= 电机 config.h 的 CANFD_PARAM_NODE_ID / 反馈帧基础 ID) */
const PARAM_SEL_NODE_ID = 0x0000;
const PARAM_SEL_STATUS = 0x0100; /* STATUS(0x000N0100) 帧刷新周期选择器 */
const PARAM_SEL_CONFIG = 0x0101; /* CONFIG(0x000N0101) 帧刷新周期选择器 */
function u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); }
/** WiFi:从 GD32 per-ID 快照表取最近一帧指定 ID 的数据(请求-应答回执通道)。
* 回帧在请求发出后 ~1ms 落表,这里留一个全量查询(~12-24ms)的窗口再拉。 */
async function readReplyFromSnapshot(replyId) {
await sleep(80);
let fb;
try { fb = await state.tp.fetchFeedbackFull(); } catch (e) { return null; }
const ch = (fb && fb.channels) ? fb.channels[state.ch] : null;
if (!ch || !ch.valid || !Array.isArray(ch.entries)) return null;
const hit = ch.entries.find((e) => e && Number(e.id) === replyId);
return hit ? hexToBytes(hit.data) : null;
}
/** ECAT:应答异步经批量接收区回来,短轮询取走即清(与旧节点 ID 读取同款)。 */
async function takeReplyPolling(node, waitMs) {
if (!state.tp.takeNodeIdReport) return null;
const deadline = nowMs() + waitMs;
while (nowMs() < deadline) {
await sleep(120);
const hex = state.tp.takeNodeIdReport(node);
if (hex) return hexToBytes(hex);
}
return null;
}
/**
* GET 0x000N0401 扩展格式:查选择器对应值。
* @returns {ok, val?, oldFw?, desc?} oldFw=true 表示旧固件(只回 2B 节点 ID,
* 没有刷新率参数概念,val 即节点号,调用方不要当周期用)。
*/
async function queryParam(node, selector) {
const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]);
const id = ID_GET_NODE_ID(node);
const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 2) {
const b = hexToBytes(r.recv);
if (b.length === 2 || b.length === 4) rb = b; /* 未来 GD32 ACK 带回帧时直读 */
}
if (!rb && state.tp.fetchFeedbackFull) rb = await readReplyFromSnapshot(id);
if (!rb) rb = await takeReplyPolling(node, 1500);
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 4) {
const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return { ok: true, val };
}
if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) };
return { ok: false, desc: "应答格式异常" };
}
/**
* SET 0x000N0400 扩展格式(DLC4):设置选择器对应值。电机先写 Flash、成功后
* 才生效并回 ACK;status=0 为 NACK(Flash 写入失败)。
*/
async function setParam(node, selector, value) {
const bytes = new Uint8Array([
selector & 0xFF, (selector >> 8) & 0xFF,
value & 0xFF, (value >> 8) & 0xFF,
]);
const id = ID_SET_NODE_ID(node);
const res = await sendOneFrameWhenFree(id, 4, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv);
if (!rb && state.tp.fetchFeedbackFull) rb = await readReplyFromSnapshot(id);
if (!rb) rb = await takeReplyPolling(node, 1500);
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 5) {
const st = rb[0], sel = u16LeAt(rb, 1);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" };
}
/* 3B NAK(旧格式)或其它长度:多半是电机固件过旧,把 DLC4 当旧格式节点设置 */
return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" };
}
/* ── 频率 ↔ 周期换算(电机按 1ms 节拍分频,上限 1000Hz;0=停发) ──
* 界面一律用 Hz,协议值是周期 ms:写入时 hz→ms 取整,读取时 ms→hz 反算。
* 取整有损(300Hz→3ms→实际 333.3Hz),所以读回后如实显示换算值。 */
const PERIOD_MAX_HZ = 1000;
function hzFromPeriodMs(ms) {
return ms === 0 ? 0 : 1000 / ms;
}
/* 读回的周期 → 显示文本(0=停发;<10Hz 带一位小数) */
function periodToHzText(ms) {
if (ms === 0) return "停发";
const hz = 1000 / ms;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
/* 读回的周期 → 填进 Hz 输入框的值(保留两位小数,够表达 0.01Hz 粒度) */
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");
/* 输入是 Hz;协议值是周期 ms = round(1000/Hz)(电机 1ms 节拍分频) */
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"));
/* ── 节点 ID 设置/读取(旧格式 DLC2 设置保持不变;读取走扩展选择器 0x0000) ── */
$("#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;
/* CANFD_SET_NODE_ID 旧格式 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;
/* 扩展格式:选择器 0x0000=节点 ID。电机 v0.4.1 回 4B [选择器][值];
* 旧固件忽略载荷回 2B 节点号,queryParam 按长度分派,两者都兼容。 */
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", () => {
/* 当前链路不支持的页签(EtherCAT 下的电机 OTA)不可切入。
* <button disabled> 本身不会派发 click,这里是防止将来改成 <div> 后失效。 */
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");
});
});
/* ═══════════════════════ 电机 OTA(relay 模式)═══════════════
* 实现见 js/motor/ota.js(P1 重做:机械外提)。 */
const otaSys = createOtaSys({
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
});
const { ota, otaRestorePersistedFirmware } = otaSys;
/* ═══════════════════════ 通道切换(无连接按钮) ═══════════════════════ */
$("#chSelect").addEventListener("change", () => {
state.ch = parseInt($("#chSelect").value, 10) || 0;
scopeSys.reset(); /* 换通道:清波形历史,避免不同通道电机数据混流 */
updateConnUi();
updateChSelectOptions(); /* 同步选框字色:切到有信号通道则绿色,否则默认 */
/* 按新通道重刷节点列表 / STATUS 面板 / 在线汇总(通道视图过滤立即生效) */
refreshChannelViews();
saveUiState(); /* 通道也是持久化字段,切换后立刻落盘(bindAutoSave 的 change 也会存,这里显式双保险) */
/* 通道切换不额外拉 feedback:等下一轮 pollLoop,避免与单飞链并发 */
});
/* ═══════════════════════ 刷新速度(motor 轮询间隔) ═══════════════════════ */
/* 用户改 #refreshRate 后:clamp、回填、写**当前链路那一份**并立即重排节拍。
* 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。
* 两条链路各记一份(state.refreshByTp,见 refresh-rate.js):这里是
* "用户输入 → 存回当前链路",反方向的"切链路 → 装载该链路的值"在
* applyTransportRefresh。 */
const refreshRateInput = $("#refreshRate");
/* 下发 GD32 0xC5 主动推送周期:复用通用 SPI 透传口 /spi/send(不新增 HTTP 路由)。
* 命令码 0xC5 + 子操作码 0xFE,data 十六进制 = FE <ms_lo> <ms_hi>(毫秒 LE)。
* GD32 端钳制 [50,5000]ms 并回 ACK。失败静默(不影响本地轮询)。 */
async function sendMotorPeriod(ms) {
/* WiFi:经 /spi/send 下发 0xC5 子命令 0xFE,让 GD32 主动推送周期与前端一致。
* EtherCAT:PDO 周期由后台循环独立维持(1000ms 过程数据看门狗要求持续交换),
* 与界面刷新率无关,传输层实现为空操作。 */
if (!state.tp.supportsPeriodSync) return;
try {
await state.tp.setPeriod(ms);
} catch (e) { /* 周期下发失败静默:GD32 保持旧周期,本地轮询不受影响 */ }
}
function applyRefreshRate() {
const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS;
const lo = minRefreshFor(state.tpId); /* 下限按链路:ECAT 到 6ms,WiFi 仍 50ms */
const v = clampNum(parseInt(refreshRateInput.value, 10) || dflt, lo, REFRESH_MAX_MS);
refreshRateInput.value = v;
state.refreshIntervalMs = v;
state.refreshByTp[state.tpId] = v; /* 只改当前链路那一份,另一条保持原值 */
saveUiState(); /* #refreshRate 不在 PERSIST_FIELDS 里,得自己存 */
updateNodeLabels(); /* STATUS 面板右上角 Hz 跟随刷新速度 */
/* 后台节流兜底模式下清掉旧 setTimeout 节拍,避免双链 */
if (state.poll.tick) {
clearTimeout(state.poll.tick);
state.poll.tick = null;
}
if (state.connected && !state.probing) {
tickNextAt = 0; /* 节拍基准重置:立即按新间隔重新对齐 */
schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
}
/* 极小间隔别报"≈166 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
* (ECAT 每拍一次 HTTP,6ms 档实测约 160Hz,正好贴着屏幕绘制上限)。这时只说"不限速",
* 实际到达频率看 STATUS 面板右上角那个按数据到达时间算出来的 Hz。 */
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", () => {
/* 输入过程中只 clamp 下限显示,松手/change 再应用节拍,避免每个键都 toast */
const raw = parseInt(refreshRateInput.value, 10);
const lo = minRefreshFor(state.tpId);
if (Number.isFinite(raw) && raw < lo) refreshRateInput.value = lo;
});
/* ═══════════════════════ 示波器系统 ═══════════════════════ */
const scopeSys = createScopeSys({ state, motorKey, clampNum, $ });
/* ═══════════════════════ 初始化 ═══════════════════════ */
/* 传输必须最先建:updateConnUi / renderStatusPanel 等都会读 state.tp。 */
useTransport(loadTransportId());
/* 本页不提供连接配置:WiFi 链路读 ConnectionHub 共享 IP,进入后自动探测 */
state.ip = getSharedDeviceIp();
/* 其它页改 IP 后同步本页缓存(外壳 Owner 会再广播 set-ip 后的快照) */
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;
}
});
/* 先恢复用户上次输入(modeSelect / 目标值 / PID / 通道 / 选中节点),
* 再 applyModeVisibility 才能让保存的 mode 决定字段可见性。 */
loadUiState();
/* 装载当前链路那一份刷新速度(回填输入框 + 写 refreshIntervalMs)。
* 必须在 updateNodeLabels / renderStatusPanel 之前:它们要按刷新率算显示 Hz。 */
applyTransportRefresh();
/* 固定节点 1–9:恢复后若无有效选中,默认 N1,保证离线也能直接刷写 */
if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) {
state.selectedNode = 1;
}
applyModeVisibility();
updateNodeLabels();
updateConnUi();
renderNodeList();
renderStatusPanel();
restoreVersionUi(state.selectedNode); /* 回填上次缓存的版本号 */
renderSeqList(); /* 命令序列步骤渲染(依赖 loadUiState 恢复的数据) */
seqLoopsInput.value = seq.loops;
bindAutoSave();
initCmdCommit(); /* CMD 输入提交制:必须在 loadUiState 回填之后初始化,否则初始提交值是 HTML 默认值 */
otaRestorePersistedFirmware(); /* 异步恢复上次固件(无阻塞) */
/* 链路切换下拉框:切 WiFi/EtherCAT。放在最后绑,此时 state.tp 已就绪。 */
const transportSelect = $("#transportSelect");
if (transportSelect) {
transportSelect.value = state.tpId;
transportSelect.addEventListener("change", () => {
switchTransport(transportSelect.value);
});
}
/* EtherCAT 网卡匹配子串:改完立即重连该网口(换网卡时用)。 */
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");
/* 重新走一遍探测:ensureOpen 会用新子串 open,再自动进 OP + 起循环 */
stopDataPolling();
state.connected = false;
connectShared();
});
}
applyTransportUi(); /* 按当前链路能力刷新 OTA 页签可用性 + 提示文案 */
/* 暴露诊断对象到 window,供控制台排查慢请求:
* window.phaseDebug.lastSlowReq // 上次慢请求(>200ms)path+耗时
* window.phaseDebug.slowStats // 累计慢请求统计
* window.phaseDebug.reset() // 清空慢请求统计 */
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] 慢请求统计已清空");
},
};
/* 先恢复 localStorage(秒开),再拉磁盘配置(可能更新),最后才开始连接 ——
* 磁盘里可能带着链路/序列,连早了会先用旧参数探测一圈。 */
(async () => {
try { await hydrateUiFromDisk(); } catch (_) {}
connectShared();
})();