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 // 累计慢请求统计
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* motor/refresh-rate.js — 刷新速度的「每链路一份」纯逻辑
|
||||
*
|
||||
* 为什么单独一个文件:这里是唯一带分支的部分(默认值、钳制、旧数据迁移),
|
||||
* 而 main.js 里剩下的都是 DOM 副作用。抽出来后自测能直接跑生产代码
|
||||
* (见 scripts/test_motor_refresh.mjs),不用为了测三个 if 去搭 DOM 桩。
|
||||
*
|
||||
* 为什么两条链路不能共用一个值:往返代价差一个数量级。
|
||||
* wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐;
|
||||
* ecat 50ms(下限 1ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
|
||||
* PDO 总线周期由后台循环独立维持(默认 4ms,喂 1000ms 看门狗),
|
||||
* 界面读得快只是显示更跟手,不给总线加负担。
|
||||
* 1ms 的语义是「不限速」而非 1000Hz,见 UNTHROTTLED_MAX_MS。
|
||||
* 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
|
||||
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/** 每条链路的默认刷新间隔(ms)。新链路在这里加一行即可。 */
|
||||
export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 };
|
||||
/**
|
||||
* 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同:
|
||||
* wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求
|
||||
* 排队积压(tickStuck 亮黄),毫无收益。
|
||||
* ecat 1ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
|
||||
* 注意 1ms **不等于** 1000Hz:单飞语义下上一拍完成才发下一拍,
|
||||
* 而每拍要过一次 HTTP(代理 → ecat_server → 回来),实测均值
|
||||
* ≈ 1.4ms,浏览器里 1ms 档实测约 125Hz。填 1 的语义是
|
||||
* "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示,
|
||||
* 见 UNTHROTTLED_MAX_MS。
|
||||
*/
|
||||
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 1 };
|
||||
/** 兜底最小值:未知链路 id 时用(防御,正常不该出现)。 */
|
||||
export const REFRESH_MIN_MS = 50;
|
||||
export const REFRESH_MAX_MS = 5000;
|
||||
/**
|
||||
* 低于此间隔视为「不限速」模式:设定值已经小于单次往返耗时,
|
||||
* 节拍源会比请求完成得更快,于是每拍都撞上"上一拍还在飞"。
|
||||
*
|
||||
* 这在 1ms 档是**设计行为而不是故障**,所以调用方要据此关掉两处显示:
|
||||
* 1. tickStuck 卡顿黄标 —— 否则通道下拉框会常亮黄并报"连续 N 拍未按时完成",
|
||||
* 而用户要的就是"能多快就多快";
|
||||
* 2. 「≈N Hz」的理论值 —— 1ms 硬算是 1000Hz,浏览器里实测约 125Hz,那是谎报。
|
||||
* 这时只显示按数据到达时间实测出来的频率。
|
||||
* 取 20ms(50Hz):浏览器里每拍实际约 8ms(fetch + 代理 + ecat_server 全链),
|
||||
* 20ms 留了足够余量不误判;又远小于常用档位(50/250ms),不会误伤正常设置。
|
||||
*/
|
||||
export const UNTHROTTLED_MAX_MS = 20;
|
||||
|
||||
/** 该间隔是否处于「不限速」区间(见 UNTHROTTLED_MAX_MS)。 */
|
||||
export function isUnthrottled(ms) {
|
||||
return Number.isFinite(ms) && ms < UNTHROTTLED_MAX_MS;
|
||||
}
|
||||
|
||||
/** 某条链路的最小间隔。 */
|
||||
export function minRefreshFor(tpId) {
|
||||
const v = REFRESH_MIN_BY_TP[tpId];
|
||||
return v === undefined ? REFRESH_MIN_MS : v;
|
||||
}
|
||||
|
||||
/** 按**该链路自己的**下限钳制(这是本文件里唯一需要区分链路的地方)。 */
|
||||
function clampFor(tpId, v) {
|
||||
return Math.max(minRefreshFor(tpId), Math.min(REFRESH_MAX_MS, v));
|
||||
}
|
||||
|
||||
/** 全新的一份默认表(调用方可随意改,不会污染 REFRESH_DEFAULTS)。 */
|
||||
export function defaultRefreshMap() {
|
||||
return Object.assign({}, REFRESH_DEFAULTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 读出的内容归一成完整的「链路 → 间隔」表。
|
||||
*
|
||||
* @param raw 持久化的 refreshByTp(可能是 undefined / 脏数据)
|
||||
* @param legacySingle 旧版单个 refreshRate 字段(两条链路曾共用一个值)
|
||||
*
|
||||
* 迁移取向:旧的单值是**为 WiFi 调的**(那时只有 WiFi 链路),所以只迁到
|
||||
* wifi,ecat 留给默认 50ms —— 把一个为无线调出来的 250ms 套到本地快照上,
|
||||
* 等于让用户平白无故继承一个不合适的值。
|
||||
*/
|
||||
export function normalizeRefreshMap(raw, legacySingle) {
|
||||
const out = defaultRefreshMap();
|
||||
if (raw && typeof raw === "object") {
|
||||
for (const [id, v] of Object.entries(raw)) {
|
||||
const n = parseInt(v, 10);
|
||||
/* 只认已知链路:脏 key 直接丢,避免 localStorage 里攒出永不使用的条目 */
|
||||
if (Number.isFinite(n) && out[id] !== undefined) out[id] = clampFor(id, n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const legacy = parseInt(legacySingle, 10);
|
||||
if (Number.isFinite(legacy)) out.wifi = clampFor("wifi", legacy);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 取某条链路的生效值:脏值/缺失回落到该链路默认,再按该链路下限钳制。 */
|
||||
export function pickRefresh(map, tpId) {
|
||||
const raw = map ? map[tpId] : undefined;
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n)) return clampFor(tpId, n);
|
||||
return REFRESH_DEFAULTS[tpId] || REFRESH_DEFAULTS.wifi;
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
|
||||
*
|
||||
* 后端是 tools/ecat/src/ecat_server.py(另一个 git 仓库),经本地页面服务器
|
||||
* 的 /ecat/api/* 反向代理同源访问 —— 那个服务只绑 127.0.0.1 且没有 CORS,
|
||||
* 浏览器直连跨端口会卡在预检上,详见 esp32_server.py 的 _ecat_proxy 注释。
|
||||
*
|
||||
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
|
||||
* 1. 服务没起 → 代理侧 _ecat_autostart() 用 tools/ecat\venv 拉起(已有实例只复用)
|
||||
* 2. 网卡没开 → ensureOpen() 调 /api/open(网卡子串存 localStorage dcu_ecat_adapter)
|
||||
* 3. 没进 OP → /api/motor/start 内部自己走 PREOP→SAFEOP→OP
|
||||
* 4. 循环没跑 → /api/motor/start 起后台 PDO 循环(喂 1000ms 过程数据看门狗)
|
||||
* 断线后 main.js 的探测器每 2s 重来一遍,所以中途拔网线再插也会自己恢复。
|
||||
*
|
||||
* 本模块的**唯一职责**是把 EtherCAT 过程数据的形状翻译成 WiFi 传输那套契约,
|
||||
* 好让 main.js 的解码器 / 节点列表 / STATUS 面板 / 波形 / 命令序列全部原样复用。
|
||||
* 三处形状差异必须在这里补平,否则上层就得为两种传输各写一遍:
|
||||
*
|
||||
* 1. **config_data**:WiFi 的 /motor/state 每个电机自带 24B CONFIG 回报;
|
||||
* EtherCAT 状态窗口只有 32B STATUS,CONFIG(0x000N0101) 是当普通帧从
|
||||
* 批量接收区回来的。故这里累积 configs[node],回填到 motors[].config_data。
|
||||
* 2. **版本**:WiFi 由 GD32 解析后经 0xC6 缓存到 /motor/version;EtherCAT
|
||||
* 没有这个缓存,版本应答(0x000N00FF)同样从批量接收区来,这里自建缓存并
|
||||
* 维护 seq,让 main.js 那套"按 seq 增长判新应答"的逻辑不用改。
|
||||
* 3. **节点 ID 应答**:WiFi 走 SPI 请求-应答,帧结果里直接带 recv;
|
||||
* EtherCAT 是异步的,应答稍后才到,这里存起来供上层取。
|
||||
*
|
||||
* 另外两处**不能假装有**的东西,宁可报 null 让 UI 显示 "--":
|
||||
* - 每通道 TEC/REC/err:过程数据里根本没有(bridge 只有全局计数),
|
||||
* 编 0 会让"总线正常"和"没这个数据"看起来一样,违背监控 UI 的约定。
|
||||
* - OTA:EtherCAT 侧没有固件上传 + GD32 relay 链路,supportsOta=false。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数(与 ecat_server.MOT_SLOTS_PER_PDO 一致) */
|
||||
const MOT_SLOTS_PER_PDO = 7;
|
||||
/** 状态槽总数 = 9 节点 × 3 通道(ecat_server.MOTOR_SLOT_COUNT) */
|
||||
const MOTOR_SLOT_COUNT = 27;
|
||||
/** 扫完全表所需周期数(ecat_server.MOTOR_SCAN_CYCLES)。
|
||||
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 ——
|
||||
* ecat_server 会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机
|
||||
* 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */
|
||||
const MOTOR_SCAN_CYCLES = 4;
|
||||
|
||||
/**
|
||||
* 槽 seq 停止增长多久算离线(ms)。
|
||||
* 为什么需要它:ecat_server 的快照把见过的槽**永久累积**,电机拔了也不会消失,
|
||||
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则
|
||||
* 慢周期下会把正常电机误判离线:period 10ms × 4 周期 = 40ms,取 2s 有充足余量。
|
||||
*/
|
||||
const STALE_OFFLINE_MS = 2000;
|
||||
|
||||
/** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */
|
||||
function ecoreList(mask) {
|
||||
const out = [];
|
||||
let m = mask >>> 0;
|
||||
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
|
||||
/* 高 32 位(>31 的核,本机 E 核就在 16..27,但大核数机器会越过 31) */
|
||||
let hi = Math.floor(mask / 4294967296);
|
||||
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (hi & 1) out.push(b + 32);
|
||||
return out.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 连续核号压成区间:[16..27] → "16-27"。状态栏只有一行,
|
||||
* 12 个核号全列出来会把从站名挤掉,而"16-27"信息量一样。 */
|
||||
function ecoreRanges(list) {
|
||||
if (!list.length) return "";
|
||||
const parts = [];
|
||||
let s = list[0], p = list[0];
|
||||
for (let i = 1; i <= list.length; i++) {
|
||||
if (i < list.length && list[i] === p + 1) { p = list[i]; continue; }
|
||||
parts.push(s === p ? String(s) : `${s}-${p}`);
|
||||
if (i < list.length) { s = p = list[i]; }
|
||||
}
|
||||
return parts.join(",");
|
||||
}
|
||||
|
||||
/** @description 创建 EtherCAT 传输实例。 */
|
||||
export function createEcatTransport(ctx) {
|
||||
const { recordSlow, isTransient } = ctx;
|
||||
|
||||
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I226);
|
||||
* 存 localStorage 以便换网卡后不用每次重设。 */
|
||||
const LS_ADAPTER = "dcu_ecat_adapter";
|
||||
let adapterSubstr = "I226";
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_ADAPTER);
|
||||
if (saved && saved.trim()) adapterSubstr = saved.trim();
|
||||
} catch (_) {}
|
||||
|
||||
/* ── 传输内部累积状态 ──
|
||||
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
|
||||
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
|
||||
const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode
|
||||
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
|
||||
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx} 判活 + 计更新次数
|
||||
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
|
||||
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
|
||||
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
|
||||
* 窗口轮到它才更新。ecat_server 的窗口自适应会把窗口收缩到只盖有主的槽,
|
||||
* 稳态下两者相等;但开机首圈、或多台电机分散落在多个窗口时,
|
||||
* slotPeriodMs 会是 periodMs 的整数倍。状态栏显示这个才不谎报"数据多新"。 */
|
||||
let slotPeriodMs = 0;
|
||||
/* 节拍源是否为自旋(来自 motor/start 回报与快照)。
|
||||
* 必须显示出来:自旋能把 PDO 周期跑准(实测抖动 0.000ms),代价是**吃满一个
|
||||
* CPU 核**。用户把刷新调到 1ms 档时默认就会自旋,不写清楚的话 CPU 占用
|
||||
* 看起来像凭空冒出来的。可用 dcu_ecat_spin 显式覆盖("0"=强制内核睡眠)。 */
|
||||
let spinOn = false;
|
||||
/* 自旋线程被钉到的逻辑处理器位掩码(快照 ecore_mask)。0 = 没钉(对称 CPU
|
||||
* 或探测失败)。混合架构下钉到 E 核才有意义,所以要把落在哪些核显示出来。 */
|
||||
let ecoreMask = 0;
|
||||
const LS_SPIN = "dcu_ecat_spin";
|
||||
let started = false; // 后台循环是否已启动
|
||||
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
|
||||
|
||||
/** POST 到本地代理。失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。 */
|
||||
async function api(path, body, timeoutMs) {
|
||||
const url = "/ecat/api/" + path.replace(/^\/+/, "");
|
||||
const t0 = performance.now();
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
signal: AbortSignal.timeout(timeoutMs || 4000),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
err.transient = isTransient(err);
|
||||
recordSlow(url, performance.now() - t0, err.message);
|
||||
throw err;
|
||||
}
|
||||
recordSlow(url, performance.now() - t0);
|
||||
const text = await resp.text();
|
||||
let json = {};
|
||||
try { json = text ? JSON.parse(text) : {}; } catch (e) {
|
||||
throw new Error("EtherCAT 服务响应非合法 JSON");
|
||||
}
|
||||
if (!resp.ok) {
|
||||
/* 代理连不上后端会给 503 + 同构信封;ecat_server 的 404 体里带
|
||||
* "服务改过但没重启"的排查提示,两者都在 log[] 里,原样冒泡给用户。 */
|
||||
const err = new Error(
|
||||
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
|
||||
err.status = resp.status;
|
||||
err.transient = (resp.status === 503);
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** ok:false 是 ecat_server 的常规业务错误信道(HTTP 仍是 200)。 */
|
||||
function unwrap(json, what) {
|
||||
if (!json || json.ok !== true) {
|
||||
const msg = (json && json.log && json.log.length) ? json.log.join(" / ") : (what + "失败");
|
||||
const err = new Error(msg);
|
||||
/* "后台 PDO 循环未启动" 是可自愈的:下一拍 ensureStarted 会重开,
|
||||
* 归为瞬时错误,避免刚切过来的一瞬间就把页面判成断线。 */
|
||||
err.transient = /未启动|周期异常|排队|busy/i.test(msg);
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保网卡已打开(从站已被发现)。
|
||||
*
|
||||
* 服务刚被自动拉起时是"没连接"状态(g_master=None),此时任何
|
||||
* /api/motor/* 都会回 {ok:false, log:["未连接"]}。这里代替用户去点
|
||||
* ecat.html 上的"连接"按钮 —— 否则"自动启动"只自动了一半,
|
||||
* 用户还是得开另一个页面点两下才能用。
|
||||
*
|
||||
* 幂等:ecat_server 的 api_open 会先 close 掉旧 master 再重开,
|
||||
* 所以只在确实没连接时才调,不要每拍都调(那会把总线反复重置)。
|
||||
*/
|
||||
async function ensureOpen() {
|
||||
const st = await api("status", {}, 6000);
|
||||
if (st && st.ok === true) return st; // 已连接
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I226);
|
||||
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
|
||||
const r = await api("open", { substr: adapterSubstr }, 20000);
|
||||
if (!r || r.ok !== true) {
|
||||
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "打开网卡失败";
|
||||
const err = new Error(msg);
|
||||
/* 网线没插/板子没供电属"外部条件不满足",不是瞬时抖动:
|
||||
* 标成 transient 会让页面一直转圈却不给红灯提示。
|
||||
* 但"适配器没找到"往往是网卡名不同,值得把候选列表原样冒泡上去。 */
|
||||
err.transient = false;
|
||||
throw err;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
|
||||
async function ensureStarted(reqPeriodMs) {
|
||||
const body = {};
|
||||
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
|
||||
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。 */
|
||||
if (reqPeriodMs) body.period_ms = reqPeriodMs;
|
||||
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
|
||||
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
|
||||
let spinPref = null;
|
||||
try {
|
||||
const v = localStorage.getItem(LS_SPIN);
|
||||
if (v === "0" || v === "1") spinPref = (v === "1");
|
||||
} catch (_) { /* 隐私模式下 localStorage 会抛,忽略即可 */ }
|
||||
if (spinPref !== null) body.spin = spinPref;
|
||||
/* motor/start 内部会在未进 OP 时自己跑 PREOP→SAFEOP→OP(还会自动重试一次),
|
||||
* 最坏约 24s,所以超时给 30s —— 给小了会在正常进 OP 的过程中假失败。 */
|
||||
const r = unwrap(await api("motor/start", body, 30000), "启动 PDO 循环");
|
||||
periodMs = r.period_ms || periodMs;
|
||||
if (typeof r.spin === "boolean") spinOn = r.spin;
|
||||
started = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。 */
|
||||
function dispatchFrame(id, dlc, dataHex) {
|
||||
if (!dataHex) return;
|
||||
const node = (id >> 16) & 0xF;
|
||||
const sub = id & 0xFFFF;
|
||||
if (sub === 0x0101) {
|
||||
configs.set(node, dataHex); // CONFIG 目标值回报
|
||||
} else if (sub === 0x00FF) {
|
||||
const prev = versions.get(node);
|
||||
versions.set(node, {
|
||||
node,
|
||||
seq: (prev ? prev.seq : 0) + 1, // 自建 seq:让上层"seq 增长=新应答"逻辑通用
|
||||
version: dataHex,
|
||||
});
|
||||
} else if (sub === 0x0400 || sub === 0x0401) {
|
||||
nodeIdReports.set(node, dataHex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 消费批量接收区(取走即移除,语义是消费不是查看)。 */
|
||||
async function drainRx() {
|
||||
const r = await api("motor/rx", {}, 3000);
|
||||
if (!r || !r.frames) return;
|
||||
for (const f of r.frames) {
|
||||
if (!f.valid) continue;
|
||||
/* STATUS 已由状态窗口按槽聚合,重复解一遍纯属浪费 */
|
||||
if ((f.id & 0xFFFF) === 0x0100) continue;
|
||||
dispatchFrame(f.id, f.dlc, f.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照 → WiFi 的 /motor/state 契约。
|
||||
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活(见 STALE_OFFLINE_MS)。
|
||||
*/
|
||||
function snapshotToMotorState(snap) {
|
||||
const now = Date.now();
|
||||
const motors = [];
|
||||
for (const m of (snap.motors || [])) {
|
||||
const key = m.ch + ":" + m.node;
|
||||
const prev = seqSeen.get(key);
|
||||
let rx = prev ? prev.rx : 0;
|
||||
let at = prev ? prev.at : now;
|
||||
if (!prev || prev.seq !== m.seq) {
|
||||
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
|
||||
at = now;
|
||||
}
|
||||
seqSeen.set(key, { seq: m.seq, at, rx });
|
||||
const fresh = (now - at) <= STALE_OFFLINE_MS;
|
||||
motors.push({
|
||||
node: m.node,
|
||||
ch: m.ch,
|
||||
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽 */
|
||||
online: !!m.online && fresh,
|
||||
dlc: m.dlc,
|
||||
rx_count: rx,
|
||||
seq: m.seq,
|
||||
data: m.data,
|
||||
/* CONFIG 从批量接收区累积而来(WiFi 侧是每电机自带的) */
|
||||
config_data: configs.get(m.node) || "",
|
||||
config_seq: 0,
|
||||
config_rx_count: 0,
|
||||
});
|
||||
}
|
||||
const head = snap.head || {};
|
||||
return {
|
||||
have_data: motors.length > 0,
|
||||
active_count: head.active || 0,
|
||||
/* 快照 ts 是服务端 time.time()(秒);与本地时钟比会受偏差影响,
|
||||
* 只用它算个量级即可,UI 也只把 age 当"有没有数据"的粗指标。 */
|
||||
age_ms: snap.ts ? Math.max(0, Math.round(now - snap.ts * 1000)) : null,
|
||||
boot_active: !!head.boot_active,
|
||||
motors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "ecat",
|
||||
label: "EtherCAT",
|
||||
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
|
||||
supportsOta: false,
|
||||
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
|
||||
supportsPeriodSync: false,
|
||||
|
||||
sendHint(ch) {
|
||||
return "POST /api/motor/send \u00b7 CH" + ch +
|
||||
(periodMs ? " \u00b7 PDO " + periodMs + "ms" : "");
|
||||
},
|
||||
|
||||
/**
|
||||
* 连接探测 —— 一路自动到能用:
|
||||
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
|
||||
* 网卡没开 → ensureOpen() 代替用户点"连接"
|
||||
* 没进 OP → ensureStarted() 里 motor/start 自己走 PREOP→SAFEOP→OP
|
||||
* 循环没跑 → ensureStarted() 起后台 PDO 循环(喂 1000ms 看门狗)
|
||||
* 四步都自动,用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
|
||||
*
|
||||
* 只有 /api/status 通不算连上 —— 循环没跑就读不到任何电机状态,
|
||||
* 那样 LED 会绿着但界面全是 "--",比直接报未连接更难排查。
|
||||
*/
|
||||
async probe() {
|
||||
const st = await ensureOpen();
|
||||
await ensureStarted();
|
||||
return st;
|
||||
},
|
||||
|
||||
/** 健康检查:EtherCAT 没有 /system/status,用从站身份/AL 状态代替。 */
|
||||
async fetchStatus() {
|
||||
const st = unwrap(await api("status", {}, 6000), "读从站状态");
|
||||
if (!started) await ensureStarted();
|
||||
return st;
|
||||
},
|
||||
|
||||
describeStatus(data) {
|
||||
const s = (data && data.slave) || {};
|
||||
const AL = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
|
||||
const parts = [];
|
||||
parts.push("AL " + (AL[s.state] || ("0x" + Number(s.state || 0).toString(16))));
|
||||
if (s.al_status) parts.push("code 0x" + Number(s.al_status).toString(16).toUpperCase().padStart(4, "0"));
|
||||
if (s.name) parts.push(s.name);
|
||||
/* PDO 周期 = 总线速度;单槽周期 = 某个电机的状态多久更新一次。
|
||||
* 两者不等时(电机分散在多个窗口)必须都显示,否则用户会拿 PDO 周期
|
||||
* 当成数据新鲜度 —— 那正是"看着 4ms 实际 16ms"的谎报。 */
|
||||
if (periodMs) {
|
||||
parts.push(slotPeriodMs && slotPeriodMs !== periodMs
|
||||
? "PDO " + periodMs + "ms \u00b7 单槽 " + slotPeriodMs + "ms"
|
||||
: "PDO " + periodMs + "ms");
|
||||
}
|
||||
/* 自旋会占满一个核,属于用户能感知的代价,必须在状态栏可见而不是只写在文档里。
|
||||
* 钉到 E 核时把核号一起写出来,用户才知道占的是能效核不是性能核。 */
|
||||
if (periodMs && spinOn) {
|
||||
const rng = ecoreRanges(ecoreList(ecoreMask));
|
||||
parts.push(rng ? `⚡ 自旋节拍 · 钉在 E 核 ${rng}` : "⚡ 自旋节拍(占 1 核)");
|
||||
}
|
||||
return parts;
|
||||
},
|
||||
|
||||
/** EtherCAT 不经 IP 通信(走网卡二层),不回报设备 IP。 */
|
||||
reportedIp() { return null; },
|
||||
|
||||
async fetchMotorState() {
|
||||
const snap = unwrap(await api("motor/state", {}, 4000), "读电机快照");
|
||||
lastSnapshot = snap;
|
||||
/* 单槽刷新周期随在线电机分布变化(窗口自适应),每拍跟着更新 */
|
||||
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
|
||||
if (typeof snap.spin === "boolean") spinOn = snap.spin;
|
||||
if (typeof snap.ecore_mask === "number") ecoreMask = snap.ecore_mask;
|
||||
/* 有帧待取才去消费,省一次往返 */
|
||||
const rxb = snap.rx_batch;
|
||||
if (rxb && (rxb.count > 0 || rxb.pending > 0)) {
|
||||
try { await drainRx(); } catch (e) { /* 瞬时失败下拍再取 */ }
|
||||
}
|
||||
/* 长帧快照(CONFIG/版本这类 DLC24 帧在 8B 短快照里会被截断)也要分派 */
|
||||
if (snap.xrx && snap.xrx.data) {
|
||||
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data);
|
||||
}
|
||||
return snapshotToMotorState(snap);
|
||||
},
|
||||
|
||||
/**
|
||||
* CAN 反馈(状态栏)。直接复用上一次快照里的 bridge,不再打一次后端:
|
||||
* 那本来就是同一份过程数据,多打一次只会拿到同样的值。
|
||||
*
|
||||
* 逐字段说明为什么很多是 null:过程数据的 bridge 段是**桥全局**计数,
|
||||
* 没有按通道拆分,也完全没有 TEC/REC。按监控 UI 的约定,
|
||||
* "通信正常但无此数据" 必须显示 "--",不能编 0 冒充正常。
|
||||
*/
|
||||
async fetchFeedback() {
|
||||
const snap = lastSnapshot;
|
||||
const b = (snap && snap.bridge) || null;
|
||||
const linkOk = !!(snap && b && b.valid && snap.wkc === snap.expected_wkc);
|
||||
const channels = [];
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
channels.push({
|
||||
ch,
|
||||
enabled: true, // 过程数据不回报通道启用状态,不能据此禁止发送
|
||||
valid: linkOk,
|
||||
rx: linkOk ? b.rx_count : null,
|
||||
tx: linkOk ? b.tx_count : null,
|
||||
err: linkOk ? b.drop : null,
|
||||
tec: null, // 过程数据里没有
|
||||
rec: null, // 过程数据里没有
|
||||
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : 0) : null,
|
||||
});
|
||||
}
|
||||
return { channels };
|
||||
},
|
||||
|
||||
/**
|
||||
* 发一批帧:一次提交的多帧被后端塞进同一个 PDO 周期的批量发送区,
|
||||
* 在**同一个 seq 沿**由从站一次性发出 —— 这是多轴同步下发的前提,
|
||||
* 逐帧调用会退化成一帧一个周期。
|
||||
* 返回值补成 WiFi 的 results[] 形状(后端只回 queued/backlog)。
|
||||
*/
|
||||
async sendFrames(frames, opts) {
|
||||
if (!started) await ensureStarted();
|
||||
const payload = {
|
||||
frames: frames.map((f) => ({
|
||||
ch: opts.ch, id: f.id, dlc: f.dlc, data: f.data,
|
||||
})),
|
||||
};
|
||||
const r = await api("motor/send", payload, 6000);
|
||||
if (!r || r.ok !== true) {
|
||||
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
||||
return frames.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
||||
}
|
||||
/* 后端不做逐帧回执(帧是同一个 seq 沿一次性发出的),
|
||||
* 统一回 ok;真正的总线级错误在下一拍 bridge.tx_result 里体现。 */
|
||||
return frames.map(() => ({ status: "ok" }));
|
||||
},
|
||||
|
||||
/** 突发发送:EtherCAT 的批量区天然就是"同一 seq 沿",直接复用 sendFrames。 */
|
||||
async sendBurst(frames, opts) {
|
||||
const built = frames.map((f) => ({
|
||||
id: ((f.node & 0xF) << 16) | (opts.suffix & 0xFFFF),
|
||||
dlc: opts.dlc,
|
||||
data: f.data,
|
||||
ch: f.ch,
|
||||
}));
|
||||
if (!started) await ensureStarted();
|
||||
const r = await api("motor/send", { frames: built }, 6000);
|
||||
if (!r || r.ok !== true) {
|
||||
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
||||
return built.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
||||
}
|
||||
return built.map(() => ({ status: "ok" }));
|
||||
},
|
||||
|
||||
/** 版本:由批量接收区累积(见模块头注释第 2 点)。 */
|
||||
async fetchVersionNode(node) {
|
||||
return versions.get(node) || null;
|
||||
},
|
||||
|
||||
/** 节点 ID 应答:异步到达,取走即清(供 main.js 在发完 GET 后短轮询)。 */
|
||||
takeNodeIdReport(node) {
|
||||
const hex = nodeIdReports.get(node);
|
||||
if (hex) nodeIdReports.delete(node);
|
||||
return hex || null;
|
||||
},
|
||||
|
||||
/** PDO 周期由后台循环维持,与界面刷新率无关,故不实现。 */
|
||||
async setPeriod() { /* no-op:见 supportsPeriodSync */ },
|
||||
|
||||
/**
|
||||
* 切走本传输:**故意不停后台 PDO 循环**。
|
||||
* 从站 SM2 有 1000ms 过程数据看门狗,停超 1 秒会 OP→SAFEOP 掉线报 AL 0x001B;
|
||||
* 用户只是切到 WiFi 看看,不该把 EtherCAT 链路打断。
|
||||
*/
|
||||
async teardown() { started = false; lastSnapshot = null; },
|
||||
|
||||
/** 当前网卡匹配子串(工具栏输入框回填用)。 */
|
||||
getAdapter() { return adapterSubstr; },
|
||||
|
||||
/**
|
||||
* 换网卡:立即重开并重进 OP。
|
||||
* 换网卡意味着当前 master 指向错的网口,留着旧连接毫无意义,
|
||||
* 所以这里主动 close 让下一次 ensureOpen 走全新的 open。
|
||||
*/
|
||||
async setAdapter(substr) {
|
||||
const v = String(substr || "").trim();
|
||||
if (!v || v === adapterSubstr) return false;
|
||||
adapterSubstr = v;
|
||||
try { localStorage.setItem(LS_ADAPTER, v); } catch (_) {}
|
||||
started = false;
|
||||
lastSnapshot = null;
|
||||
try { await api("close", {}, 8000); } catch (_) { /* 没连接时无所谓 */ }
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设节拍源:true=自旋(周期跑得准,占满一个核),false=内核睡眠(省 CPU,
|
||||
* 周期超调约 0.5ms),null=交回后端按周期自动决定。
|
||||
*
|
||||
* 必须走 /api/motor/spin 而不是 /api/motor/start:后者在
|
||||
* _BUS_EXCLUSIVE_PATHS 里,dispatcher 会先停掉 PDO 循环再执行,而停手超过
|
||||
* 从站那 1000ms 过程数据看门狗就会把它踢出 OP,重进时还可能撞上 SSC 邮箱
|
||||
* 楔死(实测把一块好板子弄死过两次)。自旋只是循环里读的一个标志,热改即可。
|
||||
*/
|
||||
async setSpin(v) {
|
||||
try {
|
||||
if (v === null || v === undefined) localStorage.removeItem(LS_SPIN);
|
||||
else localStorage.setItem(LS_SPIN, v ? "1" : "0");
|
||||
} catch (_) {}
|
||||
const body = (v === null || v === undefined) ? {} : { spin: !!v };
|
||||
const r = unwrap(await api("motor/spin", body, 8000), "切换节拍源");
|
||||
if (typeof r.spin === "boolean") spinOn = r.spin;
|
||||
if (r.period_ms) periodMs = r.period_ms;
|
||||
return spinOn;
|
||||
},
|
||||
|
||||
/** 诊断用(控制台 motorDbg.transport)。 */
|
||||
debug() {
|
||||
return {
|
||||
periodMs, slotPeriodMs, spinOn, ecoreMask, ecores: ecoreList(ecoreMask),
|
||||
started, adapter: adapterSubstr,
|
||||
configs: configs.size, versions: versions.size,
|
||||
slots: seqSeen.size, snapshot: lastSnapshot,
|
||||
slotsPerPdo: MOT_SLOTS_PER_PDO,
|
||||
slotCount: MOTOR_SLOT_COUNT,
|
||||
scanCycles: MOTOR_SCAN_CYCLES,
|
||||
winBases: (lastSnapshot && lastSnapshot.win_bases) || null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* transport-wifi.js — 电机控制台的 WiFi / HTTP 传输实现
|
||||
*
|
||||
* 从 motor/main.js 里原地抽出的**原有**链路,行为未改:
|
||||
* GET /system/status 探测 + 健康检查(lite/full 交替)
|
||||
* GET /motor/state?bin=1 电机状态(二进制,~408B)
|
||||
* GET /can/feedback?lite=1 CAN 计数/总线状态
|
||||
* POST /can/send_batch 发送(唯一入口)
|
||||
* POST /can/send_burst 突发同时发送(多轴)
|
||||
* GET /motor/version 版本缓存(GD32 经 0xC6 同步给 ESP32)
|
||||
* POST /spi/send 0xC5 推送周期对齐
|
||||
*
|
||||
* 本模块只做"发请求 + 校验响应形状",**不含任何流控**:
|
||||
* 互斥、0xE4 退避重试、0xE2/0xE3 冷却、latest-wins 排空全部留在 main.js,
|
||||
* 因为那些是与传输无关的业务策略,两种传输必须共用同一套,否则 ECAT
|
||||
* 传输会悄悄失去这些保护(这正是"所有组件复用"的要点)。
|
||||
*
|
||||
* ctx 依赖(由 main.js 注入,避免与 main.js 形成循环 import):
|
||||
* getBaseUrl() → "http://<设备IP>"
|
||||
* recordSlow(path,ms,err?) 慢请求统计
|
||||
* isTransient(err) → bool,500/503/超时归为瞬时
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/** @description 创建 WiFi/HTTP 传输实例。 */
|
||||
export function createWifiTransport(ctx) {
|
||||
const { getBaseUrl, recordSlow, isTransient } = ctx;
|
||||
|
||||
/** 统一 GET JSON。失败抛 Error,附 status / transient(与原 fetchDeviceJson 一致)。 */
|
||||
async function getJson(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 = isTransient(err);
|
||||
recordSlow(path, performance.now() - t0, err.message);
|
||||
throw err;
|
||||
}
|
||||
recordSlow(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();
|
||||
}
|
||||
|
||||
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
|
||||
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]}。
|
||||
* data / config_data 保持 hex 字符串,让上层 hexToBytes 直接解析。 */
|
||||
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);
|
||||
if (dv.getUint16(0, true) !== 0x4D53) return out; /* 'MS' */
|
||||
if (dv.getUint8(2) !== 1) return out; /* ver */
|
||||
const flags = dv.getUint8(3);
|
||||
const age = dv.getUint32(4, true);
|
||||
const active = dv.getUint8(8);
|
||||
const count = dv.getUint8(9);
|
||||
const have = (age !== 0xFFFFFFFF);
|
||||
out.have_data = have;
|
||||
out.active_count = active;
|
||||
out.age_ms = have ? age : null;
|
||||
out.boot_active = (flags & 0x01) !== 0;
|
||||
|
||||
const u8 = new Uint8Array(ab);
|
||||
let off = 12;
|
||||
const hexOf = (start, len) => {
|
||||
let s = "";
|
||||
for (let i = 0; i < len; i++) s += u8[start + i].toString(16).padStart(2, "0").toUpperCase();
|
||||
return s;
|
||||
};
|
||||
for (let i = 0; i < count && off + 76 <= ab.byteLength; i++) {
|
||||
out.motors.push({
|
||||
node: u8[off],
|
||||
ch: u8[off + 1],
|
||||
online: u8[off + 2] !== 0,
|
||||
dlc: u8[off + 3],
|
||||
rx_count: dv.getUint32(off + 4, true),
|
||||
seq: dv.getUint32(off + 8, true),
|
||||
data: hexOf(off + 12, 32),
|
||||
config_rx_count: dv.getUint32(off + 44, true),
|
||||
config_seq: dv.getUint32(off + 48, true),
|
||||
config_data: hexOf(off + 52, 24),
|
||||
});
|
||||
off += 76;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** POST JSON,返回解析后的 json;HTTP 非 2xx / 响应缺 results 时抛错。 */
|
||||
async function postFrames(path, payload, timeoutMs, frameCount) {
|
||||
const resp = await fetch(getBaseUrl() + path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
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");
|
||||
/* 响应短于请求帧数:用 abort_code 补齐,让上层按帧一一对应地报错 */
|
||||
const results = json.results.slice();
|
||||
const need = frameCount - 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);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return {
|
||||
id: "wifi",
|
||||
label: "WiFi / HTTP",
|
||||
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay),EtherCAT 侧没有这条链路 */
|
||||
supportsOta: true,
|
||||
/** 0xC5 推送周期可与前端刷新率对齐 */
|
||||
supportsPeriodSync: true,
|
||||
|
||||
sendHint(ch) { return "POST /can/send_batch \u00b7 CH" + ch; },
|
||||
|
||||
/** 连接探测:/system/status 能通即视为在线(与原 probeOnce 完全一致)。 */
|
||||
async probe() { return getJson("/system/status", 2500); },
|
||||
|
||||
/** 健康检查:lite ~150B / full ~3KB(5s 一次)。 */
|
||||
async fetchStatus(wantFull) {
|
||||
return getJson(wantFull ? "/system/status" : "/system/status?lite=1",
|
||||
wantFull ? 3500 : 1500);
|
||||
},
|
||||
|
||||
/** 设备状态摘要 → #devStatus 文本片段数组。 */
|
||||
describeStatus(data) {
|
||||
const wifi = (data && data.wifi) || {};
|
||||
const parts = [];
|
||||
if (data && data.gd32_features && data.gd32_features.known) {
|
||||
parts.push("feat 0x" + (data.gd32_features.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 ? " 在线" : " 离线"));
|
||||
return parts;
|
||||
},
|
||||
|
||||
/** 设备回报的 IP(用于写回共享 localStorage);ECAT 传输无此概念。 */
|
||||
reportedIp(data) { return (data && data.wifi && data.wifi.ip) || null; },
|
||||
|
||||
async fetchMotorState() {
|
||||
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 = isTransient(err);
|
||||
recordSlow("/motor/state?bin=1", performance.now() - t0, err.message);
|
||||
throw err;
|
||||
}
|
||||
recordSlow("/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;
|
||||
}
|
||||
return parseMotorStateBinary(await resp.arrayBuffer());
|
||||
},
|
||||
|
||||
async fetchFeedback() { return getJson("/can/feedback?lite=1", 1500); },
|
||||
|
||||
/**
|
||||
* 发一批帧。frames = [{id,dlc,data}],opts.ch = 批级通道。
|
||||
* @returns {Promise<object[]>} 已按帧数补齐的 results[]
|
||||
*/
|
||||
async sendFrames(frames, opts) {
|
||||
const n = frames.length;
|
||||
/* 单帧 3s:ESP32 handler 内部单帧超时 1200ms + HTTP 排队余量 */
|
||||
const timeoutMs = (n <= 1) ? 3000 : Math.max(15000, n * 500);
|
||||
return postFrames("/can/send_batch", { ch: opts.ch, frames }, timeoutMs, n);
|
||||
},
|
||||
|
||||
/**
|
||||
* 突发(近同时)发送:ESP32 转 0xC7,GD32 背靠背装载各通道邮箱,组内偏差 µs 级。
|
||||
* frames = [{ch,node,data}],共用 suffix + dlc。
|
||||
*/
|
||||
async sendBurst(frames, opts) {
|
||||
const timeoutMs = Math.max(3000, frames.length * 250);
|
||||
return postFrames("/can/send_burst",
|
||||
{ suffix: opts.suffix, dlc: opts.dlc, frames },
|
||||
timeoutMs, frames.length);
|
||||
},
|
||||
|
||||
/** 版本缓存查询(GD32 解析后经 0xC6 同步,ESP32 提供缓存)。 */
|
||||
async fetchVersionNode(node) {
|
||||
const data = await getJson("/motor/version", 2000);
|
||||
if (!data || !data.have_data || !Array.isArray(data.nodes)) return null;
|
||||
return data.nodes.find((n) => n.node === node) || null;
|
||||
},
|
||||
|
||||
/** 把前端刷新周期同步给 GD32(0xC5 主动推送周期),失败静默。 */
|
||||
async setPeriod(ms) {
|
||||
const v = Math.max(20, Math.min(65535, Math.round(ms)));
|
||||
const lo = (v & 0xFF).toString(16).padStart(2, "0").toUpperCase();
|
||||
const hi = ((v >> 8) & 0xFF).toString(16).padStart(2, "0").toUpperCase();
|
||||
await fetch(getBaseUrl() + "/spi/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "C5", data: "FE" + lo + hi, timeout_ms: 2000 }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
},
|
||||
|
||||
/** 切走本传输时的收尾:HTTP 无状态,无需处理。 */
|
||||
async teardown() { /* no-op */ },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* transport.js — 电机控制台的传输选择层
|
||||
*
|
||||
* 电机页支持两条链路,业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、
|
||||
* 流控)**完全共用**,只有"数据怎么进出"这一层不同:
|
||||
*
|
||||
* wifi ESP32 固件 80 端口的 HTTP API(原有实现,行为未变)
|
||||
* ecat EtherCAT 过程数据,经本地页面服务器 /ecat/api/* 代理到
|
||||
* tools/ecat/src/ecat_server.py
|
||||
*
|
||||
* 传输实例必须实现的接口(详见两个实现文件的注释):
|
||||
* id / label / supportsOta / supportsPeriodSync
|
||||
* sendHint(ch) -> string
|
||||
* probe() -> statusData 连接探测,抛错=没连上
|
||||
* fetchStatus(wantFull) -> statusData 健康检查
|
||||
* describeStatus(data) -> string[] #devStatus 文本片段
|
||||
* reportedIp(data) -> string|null 设备回报 IP(ECAT 恒 null)
|
||||
* fetchMotorState() -> {have_data, age_ms, active_count, boot_active, motors[]}
|
||||
* fetchFeedback() -> {channels:[{ch,enabled,valid,rx,tx,err,tec,rec,bus_state}]}
|
||||
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
|
||||
* sendBurst(frames, {suffix,dlc}) -> results[]
|
||||
* fetchVersionNode(node) -> {node,seq,version}|null
|
||||
* setPeriod(ms) -> void 不支持则空实现
|
||||
* teardown() -> void 切走时收尾
|
||||
* 可选:takeNodeIdReport(node)、debug()
|
||||
*
|
||||
* motors[] 每项的字段契约(ECAT 实现负责补平差异):
|
||||
* {node, ch, online, dlc, rx_count, seq, data(64hex), config_data(48hex|"")}
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
import { createWifiTransport } from "./transport-wifi.js";
|
||||
import { createEcatTransport } from "./transport-ecat.js";
|
||||
|
||||
/** localStorage key:记住上次选的传输,刷新页面后保持 */
|
||||
export const LS_TRANSPORT = "dcu_motor_transport";
|
||||
|
||||
export const TRANSPORT_IDS = ["wifi", "ecat"];
|
||||
|
||||
/** @description 按 id 创建传输实例。未知 id 回退到 wifi。 */
|
||||
export function createTransport(id, ctx) {
|
||||
return (id === "ecat") ? createEcatTransport(ctx) : createWifiTransport(ctx);
|
||||
}
|
||||
|
||||
/** @description 读上次选择的传输 id(默认 wifi —— 不改变老用户的既有行为)。 */
|
||||
export function loadTransportId() {
|
||||
try {
|
||||
const v = localStorage.getItem(LS_TRANSPORT);
|
||||
if (v && TRANSPORT_IDS.includes(v)) return v;
|
||||
} catch (_) {}
|
||||
return "wifi";
|
||||
}
|
||||
|
||||
/** @description 保存传输选择。 */
|
||||
export function saveTransportId(id) {
|
||||
try { localStorage.setItem(LS_TRANSPORT, id); } catch (_) {}
|
||||
}
|
||||
Reference in New Issue
Block a user