feat(motor): 支持 WiFi 与 EtherCAT 双链路传输

- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用
- 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题
- 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询
- 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
This commit is contained in:
cbb
2026-09-04 12:18:06 +08:00
parent 31b90f68b3
commit 2d1743ca4f
15 changed files with 2889 additions and 211 deletions
+409 -203
View File
@@ -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 // 累计慢请求统计