feat(motor): 支持 WiFi 与 EtherCAT 双链路传输
- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用 - 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题 - 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询 - 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
This commit is contained in:
+409
-203
@@ -4,12 +4,27 @@
|
||||
* 原脚本已是 "use strict",转 module 行为中性;唯一对外暴露 window.phaseDebug。
|
||||
*/
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
电机控制台 — 共用 OTA 主页设备 IP(localStorage dcu_esp_ip),本页无连接配置
|
||||
数据源:
|
||||
GET /motor/state refreshIntervalMs 驱动(默认 250ms,50~5000 可调)→ 节点列表 + STATUS 解码
|
||||
POST /can/send_batch 唯一发送入口(CMD / CONFIG / VERSION / OTA)
|
||||
GET /can/feedback 1Hz → 状态栏 RX/TX/ERR/TEC/REC/BUS
|
||||
GET /system/status 进入页自动探测 + 5s 健康检查 / 断线 2s 探测
|
||||
电机控制台 — 双传输: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_server.py):
|
||||
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)
|
||||
@@ -22,6 +37,17 @@ import {
|
||||
DEFAULT_ESP_IP, getDeviceIp, saveDeviceIp
|
||||
} from "../common/base-url.js";
|
||||
import { escapeHtml } from "../common/utils.js";
|
||||
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
|
||||
import {
|
||||
createTransport, loadTransportId, saveTransportId
|
||||
} from "./transport.js";
|
||||
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
|
||||
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
|
||||
import {
|
||||
REFRESH_DEFAULTS, REFRESH_MAX_MS,
|
||||
defaultRefreshMap, normalizeRefreshMap, pickRefresh, minRefreshFor,
|
||||
isUnthrottled
|
||||
} from "./refresh-rate.js";
|
||||
|
||||
/* ─── 控制模式定义 ─── */
|
||||
const MODES = {
|
||||
@@ -57,6 +83,14 @@ 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 统计,驱动下拉框信号标记)
|
||||
@@ -65,8 +99,11 @@ const state = {
|
||||
decoded: new Map(), // node -> 解码后的 STATUS 对象
|
||||
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
|
||||
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
|
||||
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。 */
|
||||
* 注意:仅影响 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 互斥
|
||||
@@ -97,6 +134,10 @@ 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; },
|
||||
};
|
||||
/**
|
||||
* 连续「硬失败」多少次才判离线。
|
||||
@@ -133,10 +174,12 @@ function getSharedDeviceIp() {
|
||||
* 保存 strategy:用户改输入项时,把所有需保存项打包成 JSON 写 localStorage。
|
||||
* 恢复 strategy:页面加载时读出,按字段类型回填到对应 DOM + state。
|
||||
* 版本号按节点缓存(versions[node] = {soft,hard,boot}),切节点时回填。 */
|
||||
/* 注意:#refreshRate **不在**这里 —— 它按链路各存一份(refreshByTp),
|
||||
* 走通用 DOM 回填会把上一条链路的值套到当前链路上。见 applyTransportRefresh。 */
|
||||
const PERSIST_FIELDS = [
|
||||
"modeSelect", "inVoltage", "inVelocity", "inAngle",
|
||||
"pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect",
|
||||
"nodeIdInput", "refreshRate",
|
||||
"nodeIdInput",
|
||||
];
|
||||
|
||||
function saveUiState() {
|
||||
@@ -147,6 +190,8 @@ function saveUiState() {
|
||||
if (el) o[id] = el.value;
|
||||
}
|
||||
if (state.selectedNode != null) o.selectedNode = state.selectedNode;
|
||||
/* 刷新速度:按链路分别保存(旧版是单个 refreshRate 字段,见 loadUiState 的迁移) */
|
||||
o.refreshByTp = Object.assign({}, state.refreshByTp);
|
||||
/* 命令序列:步骤 + 循环次数(有内容才写入,保持旧数据兼容) */
|
||||
if (seq.steps.length > 0 || seq.loops !== 0) {
|
||||
o.seq = { loops: seq.loops, steps: seq.steps };
|
||||
@@ -189,16 +234,11 @@ function loadUiState() {
|
||||
if (Number.isFinite(node) && v) state.versions.set(node, { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null });
|
||||
}
|
||||
}
|
||||
/* 恢复刷新速度到 state(loadUiState 在 initPage 阶段调用,
|
||||
* 此时 polling 尚未启动,直接写 state 即可,无需重排定时器) */
|
||||
const rr = parseInt(o.refreshRate, 10);
|
||||
if (Number.isFinite(rr)) {
|
||||
/* 与 #refreshRate min/max 一致:最快 50ms(20Hz),最慢 5s */
|
||||
const clamped = clampNum(rr, 50, 5000);
|
||||
state.refreshIntervalMs = clamped;
|
||||
const el = document.getElementById("refreshRate");
|
||||
if (el) el.value = clamped;
|
||||
}
|
||||
/* 恢复刷新速度:按链路各一份,归一与旧数据迁移都在 refresh-rate.js 里。
|
||||
* loadUiState 在 initPage 阶段调用(polling 未启动),写 state 即可,无需重排定时器;
|
||||
* 输入框由随后的 applyTransportRefresh() 按当前链路回填 —— 这里**不要**直接写 DOM,
|
||||
* 否则会把另一条链路的值显示出来。 */
|
||||
state.refreshByTp = normalizeRefreshMap(o.refreshByTp, o.refreshRate);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -500,28 +540,10 @@ async function sendBatch(frames, opts) {
|
||||
const n = frames.length;
|
||||
/* 通道:默认用顶部选中通道;调用方可经 opts.ch 指定(命令序列每步独立通道) */
|
||||
const txCh = (opts && Number.isInteger(opts.ch)) ? opts.ch : state.ch;
|
||||
/* 单帧 3s:ESP32 handler 内部单帧超时 1200ms + HTTP 排队余量;
|
||||
* 旧值 8s 会在 SPI 卡顿时长时间霸占互斥锁,阻塞后续周期 CMD */
|
||||
const timeoutMs = (n <= 1) ? 3000 : Math.max(15000, n * 500);
|
||||
try {
|
||||
const resp = await fetch(getBaseUrl() + "/can/send_batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ch: txCh, frames: frames }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await resp.text();
|
||||
let json = {};
|
||||
try { json = text ? JSON.parse(text) : {}; } catch (e) { throw new Error("响应非合法 JSON"); }
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (json.message || text || "发送失败"));
|
||||
if (!json.results || !Array.isArray(json.results)) throw new Error("响应缺少 results");
|
||||
|
||||
let results = json.results.slice();
|
||||
const need = n - results.length;
|
||||
if (need > 0) {
|
||||
const pad = { status: "error", code: json.abort_code != null ? json.abort_code : -2, gd32_err: json.abort_gd32_err || 0 };
|
||||
for (let p = 0; p < need; p++) results.push(pad);
|
||||
}
|
||||
/* 传输层只负责"把帧送出去 + 把响应补齐成 results[]";
|
||||
* 下面的流控(退避/冷却/latest-wins)与传输无关,两种链路共用。 */
|
||||
let results = await state.tp.sendFrames(frames, { ch: txCh });
|
||||
|
||||
/* 流控:检查首帧 gd32_err */
|
||||
const first = results[0] || {};
|
||||
@@ -595,24 +617,10 @@ async function sendBurst(actions, opts) {
|
||||
const frames = actions.map((a) => ({
|
||||
ch: a.ch, node: a.node, data: bytesToHexStr(buildCmdBytes(a)),
|
||||
}));
|
||||
const timeoutMs = Math.max(3000, frames.length * 250);
|
||||
try {
|
||||
const resp = await fetch(getBaseUrl() + "/can/send_burst", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ suffix: 0x0200, dlc: 32, frames: frames }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await resp.text();
|
||||
let json = {};
|
||||
try { json = text ? JSON.parse(text) : {}; } catch (e) { throw new Error("响应非合法 JSON"); }
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (json.message || text || "发送失败"));
|
||||
if (!json.results || !Array.isArray(json.results)) throw new Error("响应缺少 results");
|
||||
|
||||
const results = json.results.slice();
|
||||
while (results.length < frames.length) {
|
||||
results.push({ status: "error", code: -2, gd32_err: 0 });
|
||||
}
|
||||
/* 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") || {};
|
||||
@@ -681,7 +689,10 @@ function isTransientError(e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 GET JSON。失败抛 Error,附带 status / transient。
|
||||
* 统一 GET JSON(设备直连)。失败抛 Error,附带 status / transient。
|
||||
*
|
||||
* 常规数据链(状态/反馈/发送/版本)已全部走 state.tp 传输层,
|
||||
* 这里保留给 OTA 那条 WiFi 专属链路复用同一套错误语义。
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function fetchDeviceJson(path, timeoutMs) {
|
||||
@@ -729,6 +740,139 @@ function recordSlowReq(path, durMs, 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 允许到 1ms(读本机快照,见 refresh-rate.js),
|
||||
* WiFi 仍是 50ms 起。不同步 min 的话,浏览器自带的数字校验会把
|
||||
* 合法的 1ms 标成 :invalid,微调箭头也会停在错误的档位上。 */
|
||||
const el = $("#refreshRate");
|
||||
if (el) {
|
||||
const lo = minRefreshFor(state.tpId);
|
||||
el.min = String(lo);
|
||||
el.step = lo <= 1 ? "1" : "50"; /* 1ms 档用 1 步进,否则 50 步进太粗 */
|
||||
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++;
|
||||
@@ -777,62 +921,113 @@ function scheduleProbe(delayMs) {
|
||||
}, delayMs == null ? PROBE_INTERVAL_MS : delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把冗长的链路错误压成一句人能看的话。
|
||||
*
|
||||
* 为什么需要:ecat_server 会把整串诊断塞进 log[],SSC 邮箱楔死那条会带
|
||||
* **64 个一模一样的 MailboxError** 加一段 SM 寄存器现场,原样显示等于没显示。
|
||||
* 这里做两件事:把重复项折叠成"×N",并对已知故障给出**该怎么办**
|
||||
* —— 楔死是软件恢复不了的(ecat_server 自己的注释写着必须复位 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 板供电与网线是否接在该网口";
|
||||
}
|
||||
if (/venv|pysoem|Npcap/.test(s)) return s; /* 环境类错误原样给,它本身就是指引 */
|
||||
/* 兜底:截断,完整内容留在 title 里 */
|
||||
return s.length > 140 ? s.slice(0, 140) + "…" : s;
|
||||
}
|
||||
|
||||
async function probeOnce() {
|
||||
if (!state.probing) return;
|
||||
state.ip = getSharedDeviceIp();
|
||||
const label = $("#ipLabel");
|
||||
if (label) label.textContent = "IP " + state.ip;
|
||||
if (label) label.textContent = transportTargetLabel();
|
||||
try {
|
||||
const data = await fetchDeviceJson("/system/status", 2500);
|
||||
const data = await state.tp.probe();
|
||||
const offlineMs = state.offlineSince ? (nowMs() - state.offlineSince) : 0;
|
||||
stopProbe();
|
||||
state.connected = true;
|
||||
state.connecting = false;
|
||||
state.failCount = 0;
|
||||
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("连接已恢复 — " + state.ip, "ok");
|
||||
connToast("连接已恢复 — " + transportTargetLabel(), "ok");
|
||||
}
|
||||
startPolling();
|
||||
} catch (e) {
|
||||
/* 把失败原因显示出来。
|
||||
* 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP),
|
||||
* 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作**
|
||||
* (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / venv 缺 pysoem),
|
||||
* 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */
|
||||
const raw = (e && e.message) ? String(e.message) : "未知错误";
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用主页共享 IP 自动连接(本页无连接按钮)。
|
||||
* 失败不阻断页面,进入探测直到 /system/status 可用。
|
||||
* 自动连接当前链路(本页无连接按钮)。
|
||||
* 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.ch = parseInt($("#chSelect").value, 10) || 0;
|
||||
updateConnUi();
|
||||
$("#netBanner").classList.add("show");
|
||||
$("#netBannerText").textContent =
|
||||
"自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP";
|
||||
/* HTTP 探测驱动连接:/system/status 可用 → startPolling */
|
||||
$("#netBannerText").textContent = (state.tpId === "ecat")
|
||||
? "正在自动接入 EtherCAT … 按需拉起本地服务 → 打开网卡 → 进 OP → 启动 PDO 循环"
|
||||
: ("自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP");
|
||||
startProbe();
|
||||
}
|
||||
|
||||
/** 传输回报的状态 → #devStatus。字段差异由各传输的 describeStatus 消化。 */
|
||||
function applySystemStatus(data) {
|
||||
const wifi = (data && data.wifi) || {};
|
||||
if (wifi.ip) setSharedDeviceIp(wifi.ip);
|
||||
let parts = [];
|
||||
if (data && data.gd32_features) {
|
||||
const f = data.gd32_features;
|
||||
if (f.known) parts.push("feat 0x" + (f.flags >>> 0).toString(16).toUpperCase());
|
||||
}
|
||||
if (data && data.app_version) parts.push("APP " + data.app_version);
|
||||
if (data && data.boot_version) parts.push("BOOT " + data.boot_version);
|
||||
if (wifi.mode) parts.unshift(wifi.mode + (wifi.connected ? " 在线" : " 离线"));
|
||||
const ip = state.tp.reportedIp(data);
|
||||
if (ip) setSharedDeviceIp(ip);
|
||||
const parts = state.tp.describeStatus(data) || [];
|
||||
const el = $("#devStatus");
|
||||
if (el) el.textContent = parts.length ? parts.join(" · ") : "在线";
|
||||
}
|
||||
@@ -840,13 +1035,26 @@ function applySystemStatus(data) {
|
||||
function updateConnUi() {
|
||||
const led = $("#connLed");
|
||||
const ipLabel = $("#ipLabel");
|
||||
if (ipLabel) ipLabel.textContent = "IP " + state.ip;
|
||||
if (ipLabel) ipLabel.textContent = transportTargetLabel();
|
||||
/* 连接灯只反映稳定状态:在线绿 / 探测黄 / 离线红,中间失败不闪黄 */
|
||||
if (state.connecting || state.probing) {
|
||||
if (led) led.className = "led led-warn led-pulse";
|
||||
if (!state.connected) {
|
||||
const ds = $("#devStatus");
|
||||
if (ds) ds.textContent = "探测中…";
|
||||
/* 有失败原因就显示原因摘要,别一直"探测中…"(那等于什么都没说)。
|
||||
* 摘要截断: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) led.className = "led led-on";
|
||||
@@ -856,7 +1064,7 @@ function updateConnUi() {
|
||||
if (ds) ds.textContent = "未连接";
|
||||
}
|
||||
const sendHint = $("#sendHint");
|
||||
if (sendHint) sendHint.textContent = "POST /can/send_batch \u00b7 CH" + state.ch;
|
||||
if (sendHint) sendHint.textContent = state.tp.sendHint(state.ch);
|
||||
const sbCh = $("#sbCh");
|
||||
if (sbCh) sbCh.textContent = "CH" + state.ch;
|
||||
}
|
||||
@@ -980,9 +1188,15 @@ let tickUseFallback = false; /* Worker 创建/加载失败 → 回退到 setTim
|
||||
function tickHandler() {
|
||||
if (!state.connected || state.probing) return;
|
||||
if (state.tickInFlight) {
|
||||
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过并标记卡顿 */
|
||||
state.tickStuck = (state.tickStuck || 0) + 1;
|
||||
updateTickStuckUi();
|
||||
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
|
||||
* 「不限速」档(如 ECAT 的 1ms)下这是**预期行为**:设定间隔本就小于
|
||||
* 单次往返耗时,节拍源必然比请求快,每拍都会撞上。此时不计卡顿,
|
||||
* 否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",而用户要的
|
||||
* 恰恰就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
|
||||
if (!isUnthrottled(state.refreshIntervalMs)) {
|
||||
state.tickStuck = (state.tickStuck || 0) + 1;
|
||||
updateTickStuckUi();
|
||||
}
|
||||
return;
|
||||
}
|
||||
pollLoop(state.pollGen);
|
||||
@@ -1083,12 +1297,11 @@ function schedulePollLoop(gen, delayMs) {
|
||||
async function fetchSystemStatus(gen, wantFull) {
|
||||
if (gen !== state.pollGen || !state.connected || state.probing) return;
|
||||
try {
|
||||
/* wantFull=false(默认)走轻量路径 ?lite=1:~150B,
|
||||
* 不调 ESP32 端 heap_caps_get_info / 任务栈遍历 / OTA snapshot,
|
||||
* 避免 httpd 单线程被重 handler 阻塞几十~上百 ms。
|
||||
* wantFull=true 走完整路径,5s 一次,含 OTA/heap/SPI。 */
|
||||
const path = wantFull ? "/system/status" : "/system/status?lite=1";
|
||||
const data = await fetchDeviceJson(path, wantFull ? 3500 : 1500);
|
||||
/* 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();
|
||||
@@ -1102,9 +1315,10 @@ async function fetchMotorState(gen) {
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
if (!state.connected || state.probing) return false;
|
||||
try {
|
||||
/* 二进制模式:体积 ~408B vs JSON ~1200B,省 66%。
|
||||
* ESP32 端 memcpy 拼包,无 snprintf,CPU 开销降一个数量级。 */
|
||||
const data = await fetchMotorStateBinary();
|
||||
/* 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();
|
||||
@@ -1116,100 +1330,14 @@ async function fetchMotorState(gen) {
|
||||
}
|
||||
}
|
||||
|
||||
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
|
||||
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]},
|
||||
* 让 onMotorState 不用改。data 字段用 32 字节 hex 字符串保持兼容(前端
|
||||
* hexToBytes 解析 STATUS 帧)。 */
|
||||
async function fetchMotorStateBinary() {
|
||||
const t0 = performance.now();
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(getBaseUrl() + "/motor/state?bin=1", {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
err.transient = isTransientError(err);
|
||||
recordSlowReq("/motor/state?bin=1", performance.now() - t0, err.message);
|
||||
throw err;
|
||||
}
|
||||
recordSlowReq("/motor/state?bin=1", 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;
|
||||
}
|
||||
const ab = await resp.arrayBuffer();
|
||||
return parseMotorStateBinary(ab);
|
||||
}
|
||||
|
||||
/* 解析 ESP32 二进制 /motor/state?bin=1 响应。
|
||||
* Header 12B: magic u16 LE=0x4D53, ver u8, flags u8, age_ms u32 LE,
|
||||
* active u8, count u8, rsvd u16
|
||||
* Entry 44B: node u8, ch u8, online u8, dlc u8,
|
||||
* rx_count u32 LE, seq u32 LE, data[32]
|
||||
* 无效响应(magic 不匹配/长度不足)回退到 have_data:false。 */
|
||||
function parseMotorStateBinary(ab) {
|
||||
const out = { have_data: false, active_count: 0, age_ms: null, boot_active: false, motors: [] };
|
||||
if (!ab || ab.byteLength < 12) return out;
|
||||
const dv = new DataView(ab);
|
||||
const magic = dv.getUint16(0, true);
|
||||
if (magic !== 0x4D53) return out; /* 'MS' LE */
|
||||
const ver = dv.getUint8(2);
|
||||
if (ver !== 1) return out;
|
||||
const flags = dv.getUint8(3); /* flags bit0 = boot_active */
|
||||
const age = dv.getUint32(4, true);
|
||||
const active = dv.getUint8(8);
|
||||
const count = dv.getUint8(9);
|
||||
/* rsvd u16 @10..11 */
|
||||
|
||||
/* age == 0xFFFFFFFF 表示 ESP32 端无数据 */
|
||||
const have = (age !== 0xFFFFFFFF);
|
||||
out.have_data = have;
|
||||
out.active_count = active;
|
||||
out.age_ms = have ? age : null;
|
||||
out.boot_active = (flags & 0x01) !== 0; /* 总线有电机在 BOOT(0x103 心跳) */
|
||||
|
||||
const u8 = new Uint8Array(ab);
|
||||
let off = 12;
|
||||
for (let i = 0; i < count && off + 76 <= ab.byteLength; i++) {
|
||||
const node = u8[off];
|
||||
const ch = u8[off + 1];
|
||||
const online = u8[off + 2] !== 0;
|
||||
const dlc = u8[off + 3];
|
||||
const rx_count = dv.getUint32(off + 4, true);
|
||||
const seq = dv.getUint32(off + 8, true);
|
||||
/* data[32] @ off+12..off+43 转 hex 字符串(兼容 onMotorState 的 hexToBytes) */
|
||||
let hex = "";
|
||||
for (let b = 0; b < 32; b++) {
|
||||
hex += u8[off + 12 + b].toString(16).padStart(2, "0").toUpperCase();
|
||||
}
|
||||
/* config_data[24] @ off+52..off+67 转 hex */
|
||||
const config_rx_count = dv.getUint32(off + 44, true);
|
||||
const config_seq = dv.getUint32(off + 48, true);
|
||||
let config_hex = "";
|
||||
for (let b = 0; b < 24; b++) {
|
||||
config_hex += u8[off + 52 + b].toString(16).padStart(2, "0").toUpperCase();
|
||||
}
|
||||
out.motors.push({
|
||||
node, ch, online, dlc, rx_count, seq, data: hex,
|
||||
config_rx_count, config_seq, config_data: config_hex,
|
||||
});
|
||||
off += 76;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchFeedback(gen) {
|
||||
if (gen != null && gen !== state.pollGen) return false;
|
||||
if (!state.connected || state.probing) return false;
|
||||
try {
|
||||
/* ?lite=1:跳过 entries 数组(~3KB → ~200B)。motor.html 只用 ch/stats 字段,
|
||||
* 不需要 CAN 帧快照(那是 index.html/can.html 监控页用的)。
|
||||
* ESP32 端 lite 路径不走 chunked + httpJsonWorkMutex,handler <1ms。 */
|
||||
const data = await fetchDeviceJson("/can/feedback?lite=1", 1500);
|
||||
/* 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();
|
||||
@@ -1471,8 +1599,12 @@ const RECV_HZ_WINDOW = 10;
|
||||
* 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */
|
||||
function statusHzText() {
|
||||
const ts = state.recvTs;
|
||||
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃) */
|
||||
const maxAge = (state.refreshIntervalMs || TICK_INTERVAL_MS) * 2 * RECV_HZ_WINDOW;
|
||||
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
|
||||
* 「不限速」档要按实测往返而非设定值算这个窗口:1ms × 2 × 10 = 20ms,
|
||||
* 会把刚到的样本全判成"旧数据"清空,Hz 就永远退回理论值(谎报 1000Hz)。
|
||||
* 这里给不限速档一个固定的 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) {
|
||||
@@ -1483,8 +1615,10 @@ function statusHzText() {
|
||||
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
|
||||
}
|
||||
}
|
||||
/* 窗口数据不足时回退到理论值,不让面板空着 */
|
||||
const hz = 1000 / (state.refreshIntervalMs || TICK_INTERVAL_MS);
|
||||
/* 窗口数据不足时回退到理论值,不让面板空着。
|
||||
* 不限速档没有有意义的理论值(1ms≠1000Hz),显示 "…" 等实测数据到齐。 */
|
||||
if (isUnthrottled(iv)) return "\u2026 Hz";
|
||||
const hz = 1000 / iv;
|
||||
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
|
||||
}
|
||||
function updateNodeLabels() {
|
||||
@@ -2270,9 +2404,10 @@ function restoreVersionUi(node) {
|
||||
* 按 seq 增长判定是否为本次查询的新应答(不再从 /can/feedback 取原始帧)。 ── */
|
||||
async function fetchMotorVersionNode(node) {
|
||||
try {
|
||||
const data = await fetchDeviceJson("/motor/version", 2000);
|
||||
if (!data || !data.have_data || !Array.isArray(data.nodes)) return null;
|
||||
return data.nodes.find((n) => n.node === node) || null;
|
||||
/* WiFi:读 ESP32 的 /motor/version 缓存(GD32 解析后经 0xC6 同步)。
|
||||
* EtherCAT:版本应答从批量接收区回来,由传输层自行累积并维护 seq,
|
||||
* 所以下面"按 seq 增长判新应答"的逻辑对两种链路都成立。 */
|
||||
return await state.tp.fetchVersionNode(node);
|
||||
} catch (e) {
|
||||
return null; /* 瞬时 busy 忽略 */
|
||||
}
|
||||
@@ -2422,12 +2557,33 @@ $("#getNodeIdBtn").addEventListener("click", async () => {
|
||||
$("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] cur=${cur} … ${time}`;
|
||||
if (res.ok) {
|
||||
state.txCount++;
|
||||
let reported = null;
|
||||
const r = res.results && res.results[0];
|
||||
let reported = cur;
|
||||
if (r && r.recv) {
|
||||
/* WiFi:SPI 请求-应答,回执里直接带 recv */
|
||||
const rb = hexToBytes(r.recv);
|
||||
if (rb.length >= 2) reported = rb[0] | (rb[1] << 8);
|
||||
else if (rb.length >= 1) reported = rb[0];
|
||||
} else if (state.tp.takeNodeIdReport) {
|
||||
/* EtherCAT:应答是异步经批量接收区回来的,等几拍再取。
|
||||
* 不能像 WiFi 那样直接读回执 —— 那里没有 recv 字段,
|
||||
* 早期版本会把"没收到应答"显示成"回报=当前节点号",等于假装成功。 */
|
||||
const deadline = nowMs() + 1500;
|
||||
while (nowMs() < deadline) {
|
||||
await sleep(120);
|
||||
const hex = state.tp.takeNodeIdReport(cur);
|
||||
if (hex) {
|
||||
const rb = hexToBytes(hex);
|
||||
if (rb.length >= 2) reported = rb[0] | (rb[1] << 8);
|
||||
else if (rb.length >= 1) reported = rb[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reported == null) {
|
||||
$("#nodeIdResult").textContent = `节点 ${cur} 未回报 ID(已发出请求 ${hexId(id)})`;
|
||||
toast(`GET_NODE_ID → Node ${cur} 无应答`, "warn");
|
||||
return;
|
||||
}
|
||||
$("#nodeIdResult").textContent =
|
||||
`节点 ${cur} 回报 ID: ${reported} · ${hexId(id)}`;
|
||||
@@ -2441,6 +2597,9 @@ $("#getNodeIdBtn").addEventListener("click", async () => {
|
||||
/* ═══════════════════════ 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");
|
||||
@@ -2730,6 +2889,13 @@ $("#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(取消帧/广播),必须拒绝。 */
|
||||
@@ -3083,29 +3249,33 @@ $("#chSelect").addEventListener("change", () => {
|
||||
});
|
||||
|
||||
/* ═══════════════════════ 刷新速度(motor 轮询间隔) ═══════════════════════ */
|
||||
/* 用户改 #refreshRate 后:clamp、回填、写 state,并按「N×GD32周期」立即重排。
|
||||
* 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。 */
|
||||
/* 用户改 #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) {
|
||||
const lo = ms & 0xFF, hi = (ms >> 8) & 0xFF;
|
||||
const toHex = (b) => b.toString(16).padStart(2, "0").toUpperCase();
|
||||
/* WiFi:经 /spi/send 下发 0xC5 子命令 0xFE,让 GD32 主动推送周期与前端一致。
|
||||
* EtherCAT:PDO 周期由后台循环独立维持(1000ms 过程数据看门狗要求持续交换),
|
||||
* 与界面刷新率无关,传输层实现为空操作。 */
|
||||
if (!state.tp.supportsPeriodSync) return;
|
||||
try {
|
||||
await fetch(getBaseUrl() + "/spi/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "C5", data: "FE" + toHex(lo) + toHex(hi), timeout_ms: 2000 }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
await state.tp.setPeriod(ms);
|
||||
} catch (e) { /* 周期下发失败静默:GD32 保持旧周期,本地轮询不受影响 */ }
|
||||
}
|
||||
function applyRefreshRate() {
|
||||
const v = clampNum(parseInt(refreshRateInput.value, 10) || 250, 50, 5000);
|
||||
const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS;
|
||||
const lo = minRefreshFor(state.tpId); /* 下限按链路:ECAT 到 1ms,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) {
|
||||
@@ -3117,14 +3287,20 @@ function applyRefreshRate() {
|
||||
schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */
|
||||
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
|
||||
}
|
||||
/* 极小间隔别报"≈1000 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
|
||||
* (ECAT 每拍一次 HTTP,浏览器里实测约 125Hz)。这时只说"不限速",
|
||||
* 实际到达频率看 STATUS 面板右上角那个按数据到达时间算出来的 Hz。 */
|
||||
const hz = 1000 / v;
|
||||
toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
|
||||
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);
|
||||
if (Number.isFinite(raw) && raw < 50) refreshRateInput.value = 50;
|
||||
const lo = minRefreshFor(state.tpId);
|
||||
if (Number.isFinite(raw) && raw < lo) refreshRateInput.value = lo;
|
||||
});
|
||||
|
||||
/* ═══════════════════════ 示波器系统 ═══════════════════════ */
|
||||
@@ -3389,11 +3565,16 @@ const scopeSys = (() => {
|
||||
})();
|
||||
|
||||
/* ═══════════════════════ 初始化 ═══════════════════════ */
|
||||
/* 本页不提供连接配置:读主页 localStorage dcu_esp_ip,进入后自动探测 */
|
||||
/* 传输必须最先建:updateConnUi / renderStatusPanel 等都会读 state.tp。 */
|
||||
useTransport(loadTransportId());
|
||||
/* 本页不提供连接配置:WiFi 链路读主页 localStorage dcu_esp_ip,进入后自动探测 */
|
||||
state.ip = getSharedDeviceIp();
|
||||
/* 先恢复用户上次输入(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;
|
||||
@@ -3410,6 +3591,31 @@ 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 // 累计慢请求统计
|
||||
|
||||
Reference in New Issue
Block a user