createSeqSys 注入依赖;main 保留 seq/renderSeqList/stopSeq 绑定。 STATUS offlineOverlay 契约未改。UI 验收通过(绿灯+数据+无报错)。
3852 lines
177 KiB
JavaScript
3852 lines
177 KiB
JavaScript
/**
|
||
* 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=20260924d";
|
||
import { createSeqSys } from "./seq.js?v=20260924d";
|
||
|
||
/* ─── 控制模式定义 ─── */
|
||
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 模式)═══════════════════════
|
||
* 两段式(与 BLE 刷 GD32 对齐):
|
||
* 步1 POST /motor/ota 收固件写 ESP32 flash(不碰 SPI,避免与 GD32 按铃竞态)
|
||
* 步2 POST /motor/ota/relay 触发 SPI START + feedTask 喂 GD32 → GD32 本地 AES 握手 + CAN stop-and-wait
|
||
* 前端负责:选文件 → 上传 → 进BOOT → 触发转发 → 轮询进度 → 显示结果。
|
||
* ★ 顺序(2026-07-27 调整):先收完固件再让电机进 BOOT——上传期间电机仍在 APP
|
||
* 正常运行,上传失败/固件损坏时电机不会被白白踢进 BOOT 空等。 */
|
||
const OTA_MAX_FW = 0xC000; // 48 KB 上限
|
||
const OTA_POLL_MS = 500; // 进度轮询间隔
|
||
const OTA_TOTAL_TIMEOUT_MS = 120000; // OTA 整体超时(含进 BOOT + 上传 + 传输),超时自动取消
|
||
const ota = {
|
||
running: false,
|
||
file: null,
|
||
bytes: null,
|
||
pollTimer: null,
|
||
watchdogTimer: null, /* 整体超时看门狗 */
|
||
startTime: 0, /* OTA 开始时间戳 */
|
||
fileHandle: null, /* FileSystemFileHandle(FSAPI),用于刷新后恢复固件 */
|
||
pendingHandle: null, /* 待重新授权的 handle(权限丢失时暂存,点上传区再授权) */
|
||
};
|
||
|
||
/* ─── 固件文件持久化(仿主页面 file-handle.js 模式)───
|
||
* localStorage 存文件元数据(名/大小),IndexedDB 存 FileSystemFileHandle。
|
||
* 刷新后:权限仍 granted → 静默恢复 bytes;否则展示文件名,点"选择固件"区重新授权。
|
||
* File 对象本身不可序列化,bytes 不入 localStorage;IndexedDB 仅存 handle(可结构化克隆)。 */
|
||
const LS_OTA_FW_META = "dcu_motor_ota_fw_meta";
|
||
const IDB_OTA_NAME = "dcu_motor_ota_fw_v1";
|
||
const IDB_OTA_STORE = "file_handle";
|
||
let _otaIdbDb = null;
|
||
|
||
function otaIdbOpen() {
|
||
if (_otaIdbDb) return Promise.resolve(_otaIdbDb);
|
||
if (!window.indexedDB) return Promise.reject(new Error("no IDB"));
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(IDB_OTA_NAME, 1);
|
||
req.onupgradeneeded = () => {
|
||
if (!req.result.objectStoreNames.contains(IDB_OTA_STORE)) req.result.createObjectStore(IDB_OTA_STORE);
|
||
};
|
||
req.onsuccess = () => { _otaIdbDb = req.result; resolve(_otaIdbDb); };
|
||
req.onerror = () => { _otaIdbDb = null; reject(req.error); };
|
||
});
|
||
}
|
||
function otaIdbPutHandle(handle) {
|
||
return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
|
||
const tx = db.transaction(IDB_OTA_STORE, "readwrite");
|
||
if (handle) tx.objectStore(IDB_OTA_STORE).put(handle, "motor");
|
||
else tx.objectStore(IDB_OTA_STORE).delete("motor");
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
})).catch(() => {});
|
||
}
|
||
function otaIdbGetHandle() {
|
||
return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
|
||
const tx = db.transaction(IDB_OTA_STORE, "readonly");
|
||
const r = tx.objectStore(IDB_OTA_STORE).get("motor");
|
||
r.onsuccess = () => resolve(r.result || null);
|
||
r.onerror = () => reject(r.error);
|
||
})).catch(() => null);
|
||
}
|
||
|
||
function otaSaveFwMeta(file) {
|
||
try {
|
||
if (file && file.name) {
|
||
localStorage.setItem(LS_OTA_FW_META, JSON.stringify({ name: file.name, size: file.size || 0, lastModified: file.lastModified || 0 }));
|
||
} else {
|
||
localStorage.removeItem(LS_OTA_FW_META);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
function otaLoadFwMeta() {
|
||
try {
|
||
const raw = localStorage.getItem(LS_OTA_FW_META);
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch (_) { return null; }
|
||
}
|
||
|
||
/** 查询 handle 权限(不 request,刷新无手势时 request 会失败) */
|
||
async function otaQueryPermission(handle) {
|
||
if (!handle || typeof handle.getFile !== "function") return "denied";
|
||
try {
|
||
if (typeof handle.queryPermission === "function") return await handle.queryPermission({ mode: "read" });
|
||
await handle.getFile();
|
||
return "granted";
|
||
} catch (_) { return "denied"; }
|
||
}
|
||
|
||
/** 把恢复的 File 应用到 ota 对象 + UI */
|
||
async function otaApplyRestoredFile(handle, file, opts) {
|
||
opts = opts || {};
|
||
ota.fileHandle = handle || null;
|
||
ota.file = file;
|
||
ota.bytes = new Uint8Array(await file.arrayBuffer());
|
||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||
otaSaveFwMeta(file);
|
||
if (!opts.silent) otaLog(`<span class="log-info">[固件]</span> ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
|
||
}
|
||
|
||
/** 无权限时展示文件名,提示点上传区重新授权 */
|
||
function otaShowPendingReauth(handle, meta) {
|
||
ota.fileHandle = handle || null;
|
||
ota.pendingHandle = handle || null;
|
||
const name = (meta && meta.name) || (handle && handle.name) || "上次固件";
|
||
$("#otaFileName").textContent = `${name} — 点击「选择固件」重新授权恢复`;
|
||
if (meta && meta.size) $("#otaPktTotal").textContent = String(Math.ceil(meta.size / 48));
|
||
otaLog(`<span class="log-info">[固件]</span> 上次固件「${escapeHtml(name)}」需点击「选择固件」重新授权(浏览器安全限制)`);
|
||
}
|
||
|
||
/** 页面加载后恢复上次固件(权限 granted → 静默恢复;否则提示重新授权) */
|
||
async function otaRestorePersistedFirmware() {
|
||
let handle;
|
||
try { handle = await otaIdbGetHandle(); } catch (_) { handle = null; }
|
||
const meta = otaLoadFwMeta();
|
||
if (!handle) {
|
||
/* 无 handle(非 FSAPI 选择的旧文件或首次):仅展示元数据提示 */
|
||
if (meta && meta.name) {
|
||
$("#otaFileName").textContent = `${meta.name} — ${meta.size ? meta.size.toLocaleString() + " B · " : ""}请重新选择`;
|
||
otaLog(`<span class="log-info">[固件]</span> 上次固件「${escapeHtml(meta.name)}」需重新选择(无 FSAPI handle)`);
|
||
}
|
||
return;
|
||
}
|
||
const perm = await otaQueryPermission(handle);
|
||
if (perm === "granted") {
|
||
try {
|
||
const file = await handle.getFile();
|
||
await otaApplyRestoredFile(handle, file, { silent: true });
|
||
otaLog(`<span class="log-ok">[固件]</span> 已恢复刷新前的固件: ${escapeHtml(file.name)}`);
|
||
} catch (e) {
|
||
otaShowPendingReauth(handle, meta);
|
||
}
|
||
} else {
|
||
otaShowPendingReauth(handle, meta);
|
||
}
|
||
}
|
||
|
||
/** 用户手势中:对 pending handle 重新授权并恢复(点"选择固件"时先尝试此路径) */
|
||
async function otaResumeWithGesture() {
|
||
const handle = ota.pendingHandle;
|
||
if (!handle || typeof handle.getFile !== "function") return false;
|
||
try {
|
||
let perm = "granted";
|
||
if (typeof handle.queryPermission === "function") perm = await handle.queryPermission({ mode: "read" });
|
||
if (perm !== "granted" && typeof handle.requestPermission === "function") perm = await handle.requestPermission({ mode: "read" });
|
||
if (perm !== "granted") { toast("未授予读取权限,请在弹窗中允许或重新选择", "warn"); return false; }
|
||
const file = await handle.getFile();
|
||
if (file.size > OTA_MAX_FW) { toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err"); return false; }
|
||
await otaApplyRestoredFile(handle, file, {});
|
||
ota.pendingHandle = null;
|
||
toast(`已恢复上次固件: ${file.name}`, "ok");
|
||
return true;
|
||
} catch (e) {
|
||
otaLog(`<span class="log-err">[固件]</span> 恢复失败: ${escapeHtml(e.message || e)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function otaLog(html) {
|
||
const el = $("#otaLog");
|
||
const div = document.createElement("div");
|
||
div.innerHTML = html;
|
||
el.appendChild(div);
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
/* OTA 日志工具栏:清空 / 复制 */
|
||
$("#otaLogClearBtn").addEventListener("click", function () {
|
||
const el = $("#otaLog");
|
||
el.innerHTML = '<div class="log-info">[已清空] 日志已清除</div>';
|
||
});
|
||
$("#otaLogCopyBtn").addEventListener("click", function () {
|
||
const el = $("#otaLog");
|
||
const text = el.innerText.trim();
|
||
if (!text) { toast("日志为空", "warn"); return; }
|
||
const btn = this;
|
||
function onDone() {
|
||
btn.classList.add("copied");
|
||
setTimeout(function () { btn.classList.remove("copied"); }, 1200);
|
||
toast("已复制日志", "ok");
|
||
}
|
||
function fallback() {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand("copy"); onDone(); } catch (_) {}
|
||
document.body.removeChild(ta);
|
||
}
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
|
||
} else {
|
||
fallback();
|
||
}
|
||
});
|
||
function setOtaStage(stageIdx) {
|
||
document.querySelectorAll(".ota-stage").forEach((el, i) => {
|
||
el.classList.toggle("done", i < stageIdx);
|
||
el.classList.toggle("current", i === stageIdx);
|
||
});
|
||
}
|
||
function resetOtaUi() {
|
||
setOtaStage(-1);
|
||
$("#otaProgressFill").style.width = "0%";
|
||
$("#otaPktNum").textContent = "0";
|
||
}
|
||
|
||
/* 状态名 → 阶段索引映射(6 阶段:上传固件 → 进入BOOT → 转发握手 → 擦除 → 传输 → 完成)
|
||
* 后端 motor_ota_state_t 不含 upload/boot 阶段(纯前端驱动),后端状态从 handshake_req 起。
|
||
* 顺序:上传固件(stage0) → 进BOOT(stage1) → 触发转发 → GD32 握手(stage2)/擦除(stage3)/传输(stage4)/完成(stage5)。 */
|
||
const OTA_STATE_STAGE = {
|
||
idle: -1, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2,
|
||
erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finished: 5, error: -1,
|
||
};
|
||
const OTA_STATE_ZH = {
|
||
idle: "空闲", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
|
||
handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输",
|
||
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误",
|
||
};
|
||
|
||
$("#otaFileBtn").addEventListener("click", async () => {
|
||
/* 优先尝试恢复待授权的 handle(FSAPI),成功则不再弹文件选择框 */
|
||
if (ota.pendingHandle && await otaResumeWithGesture()) return;
|
||
/* FSAPI:showOpenFilePicker 可拿到 handle 用于刷新后恢复 */
|
||
if (window.showOpenFilePicker) {
|
||
try {
|
||
const [handle] = await window.showOpenFilePicker({
|
||
types: [{ description: "固件 (.bin)", accept: { "application/octet-stream": [".bin"] } }],
|
||
multiple: false,
|
||
});
|
||
const file = await handle.getFile();
|
||
if (file.size > OTA_MAX_FW) {
|
||
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
|
||
return;
|
||
}
|
||
ota.fileHandle = handle;
|
||
ota.pendingHandle = null;
|
||
ota.file = file;
|
||
ota.bytes = new Uint8Array(await file.arrayBuffer());
|
||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||
$("#otaCrc").textContent = "—";
|
||
otaSaveFwMeta(file);
|
||
otaIdbPutHandle(handle);
|
||
otaLog(`<span class="log-info">[固件]</span> ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
|
||
toast(`固件已加载: ${file.name}`, "ok");
|
||
return;
|
||
} catch (e) {
|
||
if (e && e.name === "AbortError") return; /* 用户取消,静默 */
|
||
/* FSAPI 失败则回退到 input[type=file] */
|
||
}
|
||
}
|
||
$("#otaFileInput").click();
|
||
});
|
||
$("#otaFileInput").addEventListener("change", async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
if (file.size > OTA_MAX_FW) {
|
||
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
|
||
e.target.value = "";
|
||
return;
|
||
}
|
||
const buf = await file.arrayBuffer();
|
||
ota.file = file;
|
||
ota.bytes = new Uint8Array(buf);
|
||
ota.fileHandle = null; /* input 选择无 handle,刷新后只能展示文件名 */
|
||
ota.pendingHandle = null;
|
||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||
$("#otaCrc").textContent = "—";
|
||
otaSaveFwMeta(file);
|
||
/* input 选择无 FSAPI handle,清掉旧 handle 避免恢复到旧文件 */
|
||
otaIdbPutHandle(null);
|
||
otaLog(`<span class="log-info">[固件]</span> ${escapeHtml(file.name)} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
|
||
toast(`固件已加载: ${file.name}`, "ok");
|
||
});
|
||
|
||
$("#otaStartBtn").addEventListener("click", startOta);
|
||
$("#otaCancelBtn").addEventListener("click", cancelOta);
|
||
|
||
async function startOta() {
|
||
if (ota.running) return;
|
||
if (!state.connected) { toast("设备未连接", "err"); return; }
|
||
/* 电机 OTA 依赖 ESP32 固件的 /motor/ota* 上传 + GD32 本地 relay,
|
||
* EtherCAT 链路上没有这条通路(过程数据只跑 CAN 帧)。这里必须硬拦:
|
||
* 否则会去打设备 IP 的 HTTP,在只连了网线的场合表现为莫名超时。 */
|
||
if (!state.tp.supportsOta) {
|
||
toast("当前链路(" + state.tp.label + ")不支持电机 OTA —— 请切回 WiFi 链路", "err");
|
||
return;
|
||
}
|
||
if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
|
||
/* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
|
||
* node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
|
||
const node = state.selectedNode;
|
||
if (node == null || node < 1) {
|
||
toast("请先在左侧节点列表选中目标电机(node ≥ 1)", "warn");
|
||
return;
|
||
}
|
||
const ch = state.ch || 0;
|
||
|
||
/* ★ 每次刷写都重新从磁盘读取固件,确保用最新文件内容,不依赖内存缓存。
|
||
* 之前只读一次存 ota.bytes,之后多次刷写复用缓存——若磁盘文件被重新编译
|
||
* 覆盖(改代码 rebuild),前端仍发旧数据,导致刷进电机的不是最新固件。
|
||
* 有 FSAPI handle 时从 handle 重新读(handle 总是指向同一文件路径);
|
||
* 无 handle(input 选择)时从 ota.file 重新读 File 对象。
|
||
* 读取失败提示用户重新选择固件,绝不带着旧/坏缓存刷写。 */
|
||
try {
|
||
let file = ota.file;
|
||
if (ota.fileHandle && typeof ota.fileHandle.getFile === "function") {
|
||
file = await ota.fileHandle.getFile();
|
||
}
|
||
if (file.size > OTA_MAX_FW) {
|
||
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
|
||
return;
|
||
}
|
||
ota.file = file;
|
||
ota.bytes = new Uint8Array(await file.arrayBuffer());
|
||
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
|
||
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
|
||
} catch (e) {
|
||
otaLog(`<span class="log-err">[固件]</span> 重新读取失败:${escapeHtml(e.message || e)},请重新选择固件`);
|
||
toast("固件读取失败,请重新选择", "err");
|
||
return;
|
||
}
|
||
|
||
const ok = await showModal("开始电机 OTA",
|
||
`将先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备并校验 SHA-256,` +
|
||
`再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` +
|
||
`由 GD32 本地完成 AES 握手与 CAN 传输。OTA 期间请勿操作电机。`);
|
||
if (!ok) return;
|
||
|
||
stopRepeat(true);
|
||
stopSeq("OTA 开始,命令序列已停止", true);
|
||
ota.running = true;
|
||
ota.node = node; /* 记录目标节点:finished 后 verifyNewApp 查版本用 */
|
||
ota.startTime = performance.now();
|
||
$("#otaStartBtn").disabled = true;
|
||
$("#otaCancelBtn").disabled = false;
|
||
resetOtaUi();
|
||
setOtaStage(0); /* 上传固件阶段 */
|
||
|
||
/* 整体超时看门狗:上传 + 进 BOOT + 传输 + 跳转 总计不应超过 OTA_TOTAL_TIMEOUT_MS。
|
||
* 超时后自动取消,避免卡死时前端无限轮询 + ESP32 feedTask 残留。 */
|
||
if (ota.watchdogTimer) clearTimeout(ota.watchdogTimer);
|
||
ota.watchdogTimer = setTimeout(() => {
|
||
if (!ota.running) return;
|
||
otaLog(`<span class="log-err">[超时]</span> OTA 整体超时 (${OTA_TOTAL_TIMEOUT_MS / 1000}s),自动取消…`);
|
||
cancelOta();
|
||
}, OTA_TOTAL_TIMEOUT_MS);
|
||
|
||
/* OTA 期间暂停 motor/feedback/status 三链路轮询:
|
||
* 三条链各占一个 HTTP socket + httpd 线程时间,叠加固件上传 + OTA poll(500ms)
|
||
* + cancel 需要的新连接,6 个 max_open_sockets 会紧张,
|
||
* 导致 cancel "Failed to fetch"(连接建立失败/被 lru_purge 踢掉);
|
||
* 且电机随后将跳 BOOT,motor 状态轮询无意义。
|
||
* OTA 完成后 otaDone→startPolling 恢复。 */
|
||
stopDataPolling();
|
||
|
||
/* ── 步骤 1:前端先算本地固件 SHA-256(只算一次,重传不变)── */
|
||
const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes);
|
||
const fwShaHex = Array.from(new Uint8Array(fwSha256))
|
||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||
otaLog(`<span class="log-info">[校验]</span> 本地固件 SHA-256: ${fwShaHex}`);
|
||
|
||
try {
|
||
/* ── 步骤 2:上传 + SHA 比对,不通过重传(最多 3 次尝试 = 首发 + 重发 2 次)──
|
||
* POST /motor/ota 只收固件写 flash(不触发 SPI),ESP32 收尾从 flash 读回算 SHA 随响应返回;
|
||
* 前端比对不一致 = flash 写坏 → 重传(每次上传都全盘擦除重写);
|
||
* 连续 3 次不过才中断——此时电机仍在 APP 正常运行,重试/中断都不影响电机。 */
|
||
const kUploadMaxAttempts = 3;
|
||
let uploadOk = false;
|
||
for (let attempt = 1; attempt <= kUploadMaxAttempts && ota.running; attempt++) {
|
||
otaLog(`<span class="log-info">[上传]</span> 上传固件到 ESP32 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`);
|
||
let upJson = {};
|
||
try {
|
||
const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/octet-stream" },
|
||
body: ota.bytes,
|
||
signal: AbortSignal.timeout(15000),
|
||
});
|
||
upJson = await upResp.json().catch(() => ({}));
|
||
if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`);
|
||
} catch (e) {
|
||
otaLog(`<span class="log-err">[上传]</span> 第 ${attempt} 次上传失败:${escapeHtml(e.message || e)}`);
|
||
if (attempt < kUploadMaxAttempts) await sleep(300);
|
||
continue;
|
||
}
|
||
otaLog(`<span class="log-ok">[上传]</span> 固件已存储 (${escapeHtml(String(upJson.size || ota.bytes.length))} B)`);
|
||
|
||
/* 比对:本地 SHA vs ESP32 从 flash 读回算的 SHA(upJson.sha256) */
|
||
if (!upJson.sha256 || upJson.sha256.length !== 64) {
|
||
/* 旧 ESP32 固件无 sha256 字段:无法本地比对,且旧固件 relay 还要求 body(新前端已不发),
|
||
* 需刷新 ESP32 固件;继续尝试,若 relay 被拒会报错中止。 */
|
||
otaLog(`<span class="log-err">[校验]</span> 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`);
|
||
uploadOk = true;
|
||
break;
|
||
}
|
||
if (upJson.sha256.toLowerCase() === fwShaHex) {
|
||
otaLog(`<span class="log-ok">[校验]</span> flash SHA 比对一致,允许进 BOOT`);
|
||
uploadOk = true;
|
||
break;
|
||
}
|
||
otaLog(`<span class="log-err">[校验]</span> 第 ${attempt} 次 flash SHA 不一致(device=${escapeHtml(String(upJson.sha256.slice(0, 16)))}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`);
|
||
if (attempt < kUploadMaxAttempts) await sleep(300);
|
||
}
|
||
if (!uploadOk) {
|
||
throw new Error(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`);
|
||
}
|
||
|
||
/* ── 步骤 3:发 0x000N0000 进 BOOT 帧(DLC=0,fire-and-forget)──
|
||
* 固件已收完且 flash SHA 比对通过,此刻才让电机离开 APP。
|
||
* APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。
|
||
* 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK,
|
||
* 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败——
|
||
* 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。
|
||
* 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */
|
||
setOtaStage(1); /* 进入 BOOT 阶段 */
|
||
const bootId = canId(node, 0x0000); /* 0x000N0000 */
|
||
otaLog(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
|
||
try {
|
||
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
|
||
if (r.ok) {
|
||
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
|
||
} else {
|
||
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
|
||
}
|
||
} catch (e) {
|
||
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
|
||
}
|
||
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出,立即触发转发…`);
|
||
|
||
/* ── 步骤 4:POST /motor/ota/relay 触发 SPI START + 喂数据(纯启动扳机,无 body)──
|
||
* SHA 校验已在步骤 2 闭环(比对+重传),relay 不再传/比 SHA。
|
||
* 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。
|
||
* SPI 0xC0(进 BOOT) 与 0xD0(START) 经 SPI worker 串行,顺序有保证。 */
|
||
const rResp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${ch}`, {
|
||
method: "POST",
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
const rJson = await rResp.json().catch(() => ({}));
|
||
if (!rResp.ok) throw new Error(rJson.error || `HTTP ${rResp.status}`);
|
||
otaLog(`<span class="log-ok">[转发]</span> GD32 开始握手…`);
|
||
startOtaPoll();
|
||
} catch (e) {
|
||
otaLog(`<span class="log-err">[失败]</span> ${escapeHtml(e.message || e)}`);
|
||
toast("OTA 启动失败:" + (e.message || e), "err");
|
||
otaDone();
|
||
}
|
||
}
|
||
|
||
function startOtaPoll() {
|
||
const startTime = performance.now();
|
||
let lastState = "";
|
||
let lastLoggedPkt = -1;
|
||
ota.pollTimer = setInterval(async () => {
|
||
try {
|
||
const resp = await fetch(getBaseUrl() + "/motor/ota/status", {
|
||
signal: AbortSignal.timeout(2000), cache: "no-store",
|
||
});
|
||
const st = await resp.json();
|
||
updateOtaUi(st, startTime);
|
||
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
|
||
if (st.state && st.state !== lastState) {
|
||
const zh = OTA_STATE_ZH[st.state] || escapeHtml(String(st.state));
|
||
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
|
||
* 不显示 x/y 进度——
|
||
* 握手未成时 packets_acked 是 ESP32 预填充字节数,不是电机真实 ACK,
|
||
* 显示 "0/912" 无意义且让用户误以为在传输。transfer 起才显示进度。 */
|
||
const showPkt = (st.state === "transfer" || st.state === "wait_app_run" ||
|
||
st.state === "finish_ota" || st.state === "finished");
|
||
otaLog(`<span class="log-info">[状态]</span> ${zh}` +
|
||
(showPkt && st.total_packets ? ` · ${Number(st.packets_acked) || 0}/${Number(st.total_packets) || 0}` : ""));
|
||
lastState = st.state;
|
||
} else if (st.state === "transfer" && st.total_packets > 0) {
|
||
const acked = Number(st.packets_acked) || 0;
|
||
/* 传输期每约 64 包补一条进度,避免只有握手日志 */
|
||
if (acked - lastLoggedPkt >= 64 || acked >= st.total_packets) {
|
||
otaLog(`<span class="log-info">[传输]</span> ${acked}/${Number(st.total_packets) || 0}`);
|
||
lastLoggedPkt = acked;
|
||
}
|
||
}
|
||
if (st.state === "finished" || st.state === "error" || st.state === "idle") {
|
||
clearInterval(ota.pollTimer);
|
||
ota.pollTimer = null;
|
||
if (st.state === "finished") {
|
||
otaLog(`<span class="log-ok">[完成]</span> 传输完成 · ${Number(st.packets_acked) || 0} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
|
||
} else if (st.state === "error") {
|
||
const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
|
||
otaLog(`<span class="log-err">[错误]</span> 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${errHint}`);
|
||
toast("电机 OTA 失败 (0x" + Number(st.error || 0).toString(16) + ")", "err");
|
||
}
|
||
/* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel
|
||
* 会让 GD32 发 0x00000000(kOtaCancelId),被刚启动的电机 APP 当作
|
||
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s → APP 看似卡死。
|
||
* 只有 error/idle/异常退出才发 cancel 兜底清理设备端残留状态。 */
|
||
otaDone(st.state !== "finished");
|
||
/* ★ finished = GD32 已收到电机新 APP 上电自主发的 [0A AC] 运行确认
|
||
* (kWaitAppRun → kFinished,无确认 3s 超时报 0x25)——APP 已确认在跑。
|
||
* verifyNewApp 只做最后一步:查版本号核对并回填 UI。 */
|
||
if (st.state === "finished") verifyNewApp(ota.node);
|
||
}
|
||
} catch (e) { /* 轮询瞬时失败静默 */ }
|
||
}, OTA_POLL_MS);
|
||
}
|
||
|
||
/* ── OTA 终验:查版本号核对新 APP ──
|
||
* 运行确认已由设备端闭环:电机新 APP 上电自主发 [0A AC],GD32 kWaitAppRun
|
||
* 收到才报 finished(无确认 3s → error 0x25)——走到这里 APP 必然在跑且 CAN 已就绪。
|
||
* 本函数只补最后一步:发 GET_VERSION(0x000N00FF) 拿新版本号展示/核对,
|
||
* 无应答仅提醒(不推翻设备端的运行确认结论)。 */
|
||
async function verifyNewApp(node) {
|
||
if (node == null || node < 1) return;
|
||
otaLog(`<span class="log-info">[验证]</span> 查询新 APP 版本号 (${hexId(ID_GET_VERSION_REQ(node))})…`);
|
||
await sleep(200); /* APP 已发过 0AAC,CAN 已就绪,留小间隔避开启动期杂帧 */
|
||
const kVerifyAttempts = 2;
|
||
for (let attempt = 1; attempt <= kVerifyAttempts; attempt++) {
|
||
try {
|
||
const before = await fetchMotorVersionNode(node);
|
||
const prevSeq = before ? before.seq : null;
|
||
const res = await sendOneFrameWhenFree(ID_GET_VERSION_REQ(node), 24, bytesToHexStr(new Uint8Array(24)));
|
||
if (res.ok) {
|
||
const v = await waitMotorVersionOnTick(node, prevSeq, 4000);
|
||
if (v && v.version) {
|
||
const parsed = parseVersionPayload(v.version);
|
||
if (parsed && parsed.soft) {
|
||
/* 同步版本卡片与缓存,用户无需再手动点查版本 */
|
||
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();
|
||
otaLog(`<span class="log-ok">[成功]</span> OTA 成功 · 新 APP v${parsed.soft} 运行中(0AAC 确认 + 版本应答)`);
|
||
toast(`电机 OTA 成功 · APP v${parsed.soft}`, "ok");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* 单轮异常按未应答处理,进入重试 */ }
|
||
if (attempt < kVerifyAttempts) {
|
||
otaLog(`<span class="log-info">[验证]</span> 第 ${attempt} 次无应答,重试…`);
|
||
}
|
||
}
|
||
otaLog(`<span class="log-err">[提醒]</span> APP 已发 0AAC 确认运行,但版本查询无应答,请手动点「查版本」核对`);
|
||
toast("OTA 完成(版本查询无应答,请手动核对)", "warn");
|
||
}
|
||
|
||
function updateOtaUi(st, startTime) {
|
||
const stage = OTA_STATE_STAGE[st.state] ?? -1;
|
||
setOtaStage(stage);
|
||
const acked = Number(st.packets_acked) || 0;
|
||
const total = st.total_packets || 1;
|
||
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
|
||
* 不更新进度条和包数——
|
||
* 此时 packets_acked 是 ESP32 预填充字节数不是电机真实 ACK,
|
||
* 显示 0/912 会让人误以为在传输。transfer 起才显示进度。 */
|
||
const inTransfer = (st.state === "transfer" || st.state === "wait_app_run" ||
|
||
st.state === "finish_ota" || st.state === "finished");
|
||
if (inTransfer) {
|
||
$("#otaPktNum").textContent = String(acked);
|
||
$("#otaPktTotal").textContent = String(total);
|
||
$("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
|
||
} else {
|
||
/* 握手阶段进度条归零,避免残留上次进度 */
|
||
$("#otaPktNum").textContent = "0";
|
||
$("#otaProgressFill").style.width = "0%";
|
||
}
|
||
const elapsed = (performance.now() - startTime) / 1000;
|
||
if (elapsed > 0.5 && acked > 0 && inTransfer) {
|
||
$("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
|
||
}
|
||
$("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
|
||
}
|
||
|
||
async function cancelOta() {
|
||
if (!ota.running) return;
|
||
otaLog(`<span class="log-err">[取消]</span> 正在取消…`);
|
||
/* 先停 OTA 进度轮询,腾出 HTTP socket 给 cancel 请求本身。
|
||
* motor/feedback/status 三链路在 startOta 时已 stopDataPolling,
|
||
* 这里只剩 OTA poll(500ms)可能正占用一个 socket。
|
||
* 先停 poll 再发 cancel,避免 cancel 被挤导致 "Failed to fetch"。 */
|
||
if (ota.pollTimer) { clearInterval(ota.pollTimer); ota.pollTimer = null; }
|
||
/* 取消请求重试 3 次:单次失败(网络抖动/ESP32 忙)时不能静默吞掉,
|
||
* 否则 ESP32 侧 feedTask 不会退出,motorOtaIsActive() 残留为 true,
|
||
* 导致后续 ESP32/GD32 OTA 全被"电机 OTA 进行中"拒绝。 */
|
||
let ok = false;
|
||
for (let i = 1; i <= 3 && !ok; i++) {
|
||
try {
|
||
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(5000) });
|
||
ok = r.ok;
|
||
} catch (e) {
|
||
otaLog(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(e.message || e)}`);
|
||
}
|
||
if (!ok && i < 3) await sleep(500);
|
||
}
|
||
if (!ok) otaLog(`<span class="log-err">[取消]</span> 3 次均失败,设备端 feedTask 可能残留,建议刷新页面或等待自动超时`);
|
||
/* 成功时不再经 otaDone 兜底重发 cancel:设备端已在取消流程中,
|
||
* 双发 CANCEL 会再次触发 motorOtaCancel(GD32 重复 resetToIdle),
|
||
* 徒占 SPI 且延长 cancel task 存活窗口。仅 3 次全失败时保留兜底。 */
|
||
otaDone(!ok);
|
||
}
|
||
|
||
/* otaDone(sendCancel): 清理前端 OTA 状态。
|
||
* sendCancel=true 时发 POST /motor/ota/cancel 兜底清理设备端残留状态。
|
||
* sendCancel=false(正常 finished)时不发 cancel——电机 APP 已跳转启动,
|
||
* 此时发 cancel 会让 GD32 发 0x00000000(kOtaCancelId),被电机 APP 当作
|
||
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s,APP 看似卡死。
|
||
* 调用方:
|
||
* - startOtaPoll 正常完成 → otaDone(false)
|
||
* - startOtaPoll error/idle → otaDone(true)
|
||
* - cancelOta(用户主动取消)→ otaDone(true)
|
||
* - startOta 启动失败 → otaDone(true) */
|
||
function otaDone(sendCancel = true) {
|
||
ota.running = false;
|
||
$("#otaStartBtn").disabled = false;
|
||
$("#otaCancelBtn").disabled = true;
|
||
if (ota.watchdogTimer) { clearTimeout(ota.watchdogTimer); ota.watchdogTimer = null; }
|
||
/* OTA 期间暂停了 motor/feedback/status 三链路轮询,完成后恢复。
|
||
* startPolling 内部先 stopDataPolling 再重建,安全可重复调用。 */
|
||
if (state.connected && !state.probing) {
|
||
startPolling();
|
||
}
|
||
/* 仅在需要时发 cancel 兜底清理设备端 motorOtaIsActive。
|
||
* 正常完成(finished)时 feedTask 已自行退出 + GD32 已进 kFinished,
|
||
* 不需要 cancel;发了反而干扰刚跳转的电机 APP(见上注释)。 */
|
||
if (sendCancel && state.connected) {
|
||
fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(3000) })
|
||
.catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════ 通道切换(无连接按钮) ═══════════════════════ */
|
||
$("#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();
|
||
})();
|