feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应

反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式,
选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms):
- 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz
  (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期,
  取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准
- WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries):
  GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新
  一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写)
- ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK
- GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答
  (SET DLC2 旧格式行为不变)
- transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照)

EtherCAT/基础设施(密排槽固件 1.6.21 配套):
- 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔
  自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线;
  STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉)
- esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例,
  双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘
- flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零),
  防"写入≠在跑"假台账
- lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板
  并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时
  优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
This commit is contained in:
cbb
2026-09-12 20:10:47 +08:00
parent 81096d0425
commit 209a869f96
21 changed files with 2550 additions and 981 deletions
+229 -47
View File
@@ -29,6 +29,7 @@
STATUS 0x000N0100 · CMD 0x000N0200(DLC32) · CMD_CONFIG 0x000N0301(DLC24)
GET_VERSION 请求/回报共用 0x000N00FF(同 ID, DLC24)
CANFD_BOOT 0x000N0000 · NODE_ID SET/GET 0x000N0400/0401
(扩展格式 DLC4/2:选择器 0x0000=节点ID · 0x0100/0x0101=STATUS/CONFIG 刷新周期 ms)
═══════════════════════════════════════════════════════════════ */
"use strict";
@@ -180,7 +181,7 @@ function getSharedDeviceIp() {
const PERSIST_FIELDS = [
"modeSelect", "inVoltage", "inVelocity", "inAngle",
"pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect",
"nodeIdInput",
"nodeIdInput", "periodStatusInput", "periodConfigInput",
];
function saveUiState() {
@@ -1610,8 +1611,14 @@ nodeListEl.addEventListener("keydown", (e) => {
const RECV_HZ_WINDOW = 10;
/* STATUS 面板右上角频率:基于实际数据到达时间计算,不用用户设定的数值做死算。
* 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz。
* 要显示的就是**网页实际刷新率**(数据每秒真到几次):电机 CAN 回报率可能高于
* 链路承载(WiFi 慢于电机时界面拿不到中间帧),按电机回报率显示会谎报;
* 按到达时间实测则快链路自动跑出高值、慢链路如实反映卡顿。
* 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz;
* 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */
function hzText(hz) {
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
function statusHzText() {
const ts = state.recvTs;
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
@@ -1626,16 +1633,12 @@ function statusHzText() {
if (ts.length >= 2) {
const span = ts[ts.length - 1] - ts[0];
const avg = span / (ts.length - 1);
if (avg > 0 && avg < 60000) {
const hz = 1000 / avg;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
if (avg > 0 && avg < 60000) return hzText(1000 / avg);
}
/* 窗口数据不足时回退到理论值,不让面板空着。
* 不限速档没有有意义的理论值(6ms≠166Hz),显示 "…" 等实测数据到齐。 */
if (isUnthrottled(iv)) return "\u2026 Hz";
const hz = 1000 / iv;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
return hzText(1000 / iv);
}
function updateNodeLabels() {
const n = state.selectedNode;
@@ -2729,7 +2732,217 @@ $("#queryVerBtn").addEventListener("click", async () => {
);
});
/* ═══════════════════════ 节点 ID(DBC:SET 0x000N0400 / GET 0x000N0401,DLC 2) ═══════════════════════ */
/* ═══════════ 节点 ID / 反馈刷新率参数(SET 0x000N0400 / GET 0x000N0401) ═══════════
* 电机 v0.4.1 起支持扩展格式:
* SET DLC4 [选择器 u16 LE][值 u16 LE] → 单播回 5B ACK [status][选择器][值]
* GET DLC2 [选择器 u16 LE] → 回 4B [选择器][值]
* 选择器:0x0000=节点 ID(仅单播)、0x0100/0x0101=STATUS/CONFIG 帧刷新周期 ms
* (0=停发;电机写 Flash 掉电保存,SET 即时生效并清发送分频计数器)。
* SET DLC1~2 与 GET DLC<=1 保持旧格式不变;旧固件对 GET 任何 DLC 都回 2B 节点
* ID,对 SET DLC4 按旧语义把前两字节当节点号 —— 刷新率选择器(≥0x0100)远超
* 合法范围 1~9,必被旧固件 NAK,无害。
*
* 回执取法(两链路不同,见 queryParam/setParam):
* WiFi —— GD32 的 0xC0 ACK 只有 1 字节状态、不带电机回帧;回帧落在 GD32
* per-ID 快照表(0xC1),经 fetchFeedbackFull 按需拉取。同一 ID 的
* 回帧快照只存最新一条 → 一次快照只配一次请求(STATUS/CONFIG
* 分开读写,不做同 ID 批量)。
* EtherCAT —— 回帧异步经批量接收区(motor/rx)回来,takeNodeIdReport 取走
* 即清,按长度区分 4B 扩展 / 2B 旧格式 / 5B SET ACK。 */
/* 参数选择器(= 电机 config.h 的 CANFD_PARAM_NODE_ID / 反馈帧基础 ID) */
const PARAM_SEL_NODE_ID = 0x0000;
const PARAM_SEL_STATUS = 0x0100; /* STATUS(0x000N0100) 帧刷新周期选择器 */
const PARAM_SEL_CONFIG = 0x0101; /* CONFIG(0x000N0101) 帧刷新周期选择器 */
function u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); }
/** WiFi:从 GD32 per-ID 快照表取最近一帧指定 ID 的数据(请求-应答回执通道)。
* 回帧在请求发出后 ~1ms 落表,这里留一个全量查询(~12-24ms)的窗口再拉。 */
async function readReplyFromSnapshot(replyId) {
await sleep(80);
let fb;
try { fb = await state.tp.fetchFeedbackFull(); } catch (e) { return null; }
const ch = (fb && fb.channels) ? fb.channels[state.ch] : null;
if (!ch || !ch.valid || !Array.isArray(ch.entries)) return null;
const hit = ch.entries.find((e) => e && Number(e.id) === replyId);
return hit ? hexToBytes(hit.data) : null;
}
/** ECAT:应答异步经批量接收区回来,短轮询取走即清(与旧节点 ID 读取同款)。 */
async function takeReplyPolling(node, waitMs) {
if (!state.tp.takeNodeIdReport) return null;
const deadline = nowMs() + waitMs;
while (nowMs() < deadline) {
await sleep(120);
const hex = state.tp.takeNodeIdReport(node);
if (hex) return hexToBytes(hex);
}
return null;
}
/**
* GET 0x000N0401 扩展格式:查选择器对应值。
* @returns {ok, val?, oldFw?, desc?} oldFw=true 表示旧固件(只回 2B 节点 ID,
* 没有刷新率参数概念,val 即节点号,调用方不要当周期用)。
*/
async function queryParam(node, selector) {
const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]);
const id = ID_GET_NODE_ID(node);
const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 2) {
const b = hexToBytes(r.recv);
if (b.length === 2 || b.length === 4) rb = b; /* 未来 GD32 ACK 带回帧时直读 */
}
if (!rb && state.tp.fetchFeedbackFull) rb = await readReplyFromSnapshot(id);
if (!rb) rb = await takeReplyPolling(node, 1500);
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 4) {
const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return { ok: true, val };
}
if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) };
return { ok: false, desc: "应答格式异常" };
}
/**
* SET 0x000N0400 扩展格式(DLC4):设置选择器对应值。电机先写 Flash、成功后
* 才生效并回 ACK;status=0 为 NACK(Flash 写入失败)。
*/
async function setParam(node, selector, value) {
const bytes = new Uint8Array([
selector & 0xFF, (selector >> 8) & 0xFF,
value & 0xFF, (value >> 8) & 0xFF,
]);
const id = ID_SET_NODE_ID(node);
const res = await sendOneFrameWhenFree(id, 4, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv);
if (!rb && state.tp.fetchFeedbackFull) rb = await readReplyFromSnapshot(id);
if (!rb) rb = await takeReplyPolling(node, 1500);
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 5) {
const st = rb[0], sel = u16LeAt(rb, 1);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" };
}
/* 3B NAK(旧格式)或其它长度:多半是电机固件过旧,把 DLC4 当旧格式节点设置 */
return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" };
}
/* ── 频率 ↔ 周期换算(电机按 1ms 节拍分频,上限 1000Hz;0=停发) ──
* 界面一律用 Hz,协议值是周期 ms:写入时 hz→ms 取整,读取时 ms→hz 反算。
* 取整有损(300Hz→3ms→实际 333.3Hz),所以读回后如实显示换算值。 */
const PERIOD_MAX_HZ = 1000;
function hzFromPeriodMs(ms) {
return ms === 0 ? 0 : 1000 / ms;
}
/* 读回的周期 → 显示文本(0=停发;<10Hz 带一位小数) */
function periodToHzText(ms) {
if (ms === 0) return "停发";
const hz = 1000 / ms;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
/* 读回的周期 → 填进 Hz 输入框的值(保留两位小数,够表达 0.01Hz 粒度) */
function periodToHzInput(ms) {
if (ms === 0) return 0;
return Math.round((1000 / ms) * 100) / 100;
}
function periodRowEls(kind) {
return kind === "status"
? { sel: PARAM_SEL_STATUS, name: "STATUS", input: $("#periodStatusInput") }
: { sel: PARAM_SEL_CONFIG, name: "CONFIG", input: $("#periodConfigInput") };
}
async function readPeriodRow(kind) {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const node = state.selectedNode;
const row = periodRowEls(kind);
const result = $("#periodResult");
const id = ID_GET_NODE_ID(node);
result.textContent = `${row.name} 读取中…`;
const r = await queryParam(node, row.sel);
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: GET 刷新率(${row.name}) → ${hexId(id)} [2B] … ${time}`;
if (r.ok && !r.oldFw) {
row.input.value = periodToHzInput(r.val);
result.textContent = `${row.name} 刷新率 = ${periodToHzText(r.val)}(周期 ${r.val} ms) · 节点 ${node}`;
toast(`${row.name} 刷新率已读取: ${periodToHzText(r.val)}`, "ok");
} else if (r.ok && r.oldFw) {
result.textContent = `${row.name} 读取失败:电机固件过旧(回报的是节点 ID),不支持刷新率参数`;
toast("电机固件不支持刷新率参数(需 v0.4.1+)", "warn");
} else if (!r.networkError && !r.busy) {
result.textContent = `${row.name} 读取失败: ${r.desc}`;
toast(`${row.name} 读取失败: ${r.desc}`, "err");
}
}
async function writePeriodRow(kind) {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const node = state.selectedNode;
const row = periodRowEls(kind);
const result = $("#periodResult");
/* 输入是 Hz;协议值是周期 ms = round(1000/Hz)(电机 1ms 节拍分频) */
const hz = parseFloat(row.input.value);
if (!Number.isFinite(hz) || hz < 0 || hz > PERIOD_MAX_HZ) {
toast(`${row.name} 刷新率范围 0~${PERIOD_MAX_HZ} Hz(0=停发)`, "err");
return;
}
const period = hz === 0 ? 0 : Math.round(1000 / hz);
if (hz > 0 && (period < 1 || period > 0xFFFF)) {
toast(`${row.name} 刷新率过低(周期超出 1~65535 ms)`, "err");
return;
}
const warn = (hz === 0 && kind === "status")
? "STATUS 帧将停发,右侧状态面板会显示离线(电机仍可控可刷写)。"
: (hz === 0 ? "CONFIG 帧将停发,目标值回显将不可用。" : "");
const ok = await showModal("设置反馈刷新率",
`将节点 ${node} 的 ${row.name} 帧刷新率设为 ${row.input.value} Hz(周期 ${period} ms),立即生效并写入电机 Flash。${warn}`);
if (!ok) return;
const id = ID_SET_NODE_ID(node);
result.textContent = `${row.name} 写入中…`;
const r = await setParam(node, row.sel, period);
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: SET 刷新率(${row.name}) → ${hexId(id)} [4B] ${period}ms(${row.input.value}Hz) … ${time}`;
if (r.ok) {
state.txCount++;
result.textContent = `${row.name} 刷新率 = ${periodToHzText(period)}(周期 ${period} ms)已生效并写入 Flash · 节点 ${node}`;
toast(`${row.name} 刷新率已设为 ${periodToHzText(period)}`, "ok");
saveUiState();
} else if (!r.networkError && !r.busy) {
result.textContent = `${row.name} 写入失败: ${r.desc}`;
toast(`${row.name} 写入失败: ${r.desc}`, "err");
}
}
$("#getPeriodStatusBtn").addEventListener("click", () => readPeriodRow("status"));
$("#setPeriodStatusBtn").addEventListener("click", () => writePeriodRow("status"));
$("#getPeriodConfigBtn").addEventListener("click", () => readPeriodRow("config"));
$("#setPeriodConfigBtn").addEventListener("click", () => writePeriodRow("config"));
/* ── 节点 ID 设置/读取(旧格式 DLC2 设置保持不变;读取走扩展选择器 0x0000) ── */
$("#setNodeIdBtn").addEventListener("click", async () => {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
@@ -2739,7 +2952,7 @@ $("#setNodeIdBtn").addEventListener("click", async () => {
const cur = state.selectedNode;
const ok = await showModal("设置节点 ID", `将节点 ${cur} 的地址修改为 ${newId},CAN ID 前缀将变为 0x000${nodeHex(newId)}xxxx。修改立即生效并写入 EEPROM。`);
if (!ok) return;
/* CANFD_SET_NODE_ID:NodeID_Set 0|16@1+ = 新节点号 u16 LE */
/* CANFD_SET_NODE_ID 旧格式 DLC2:NodeID_Set 0|16@1+ = 新节点号 u16 LE */
const bytes = new Uint8Array(2);
bytes[0] = newId & 0xFF;
bytes[1] = (newId >> 8) & 0xFF;
@@ -2763,47 +2976,16 @@ $("#getNodeIdBtn").addEventListener("click", async () => {
if (!requireNode()) return;
if (inBusCooldown()) return;
const cur = state.selectedNode;
/* CANFD_GET_NODE_ID 0x000N0401 DLC2:NodeID_Request 0|16@1+ */
const bytes = new Uint8Array(2);
bytes[0] = cur & 0xFF;
bytes[1] = 0;
/* 扩展格式:选择器 0x0000=节点 ID。电机 v0.4.1 回 4B [选择器][值];
* 旧固件忽略载荷回 2B 节点号,queryParam 按长度分派,两者都兼容。 */
const id = ID_GET_NODE_ID(cur);
const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes));
const res = await queryParam(cur, PARAM_SEL_NODE_ID);
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] cur=${cur} … ${time}`;
$("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] sel=0x0000 … ${time}`;
if (res.ok) {
state.txCount++;
let reported = null;
const r = res.results && res.results[0];
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)}`;
toast(`GET_NODE_ID → Node ${cur} 已读取: ${reported}`, "ok");
$("#nodeIdResult").textContent = `节点 ${cur} 回报 ID: ${res.val} · ${hexId(id)}`;
toast(`GET_NODE_ID → Node ${cur} 已读取: ${res.val}`, "ok");
} else if (!res.networkError && !res.busy) {
$("#nodeIdResult").textContent = `读取失败: ${res.desc}`;
toast(`GET_NODE_ID 失败: ${res.desc}`, "err");