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");
+33
View File
@@ -17,6 +17,10 @@
* 见 UNTHROTTLED_MAX_MS。
* 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
*
* 后半部分还住着一块与「实测数据率」相关的纯逻辑(同样为了脱离 DOM 自测):
* staleLimitMs —— EtherCAT 槽判活的离线阈值,随实测 seq 间隔自适应,
* transport-ecat.js 用它,慢速回报的电机不再被固定 2s 误判离线。
*/
"use strict";
@@ -105,3 +109,32 @@ export function pickRefresh(map, tpId) {
if (Number.isFinite(n)) return clampFor(tpId, n);
return REFRESH_DEFAULTS[tpId] || REFRESH_DEFAULTS.wifi;
}
/* ═══════════ ECAT 槽判活阈值:随实测 seq 间隔自适应 ═══════════
*
* 背景:电机 CAN 回报率可变(可在电机侧调档),回报周期调到秒级后 seq 几秒
* 才动一次,固定 2s 判活阈值会在两次**正常**回报之间把电机判离线。
* 注:STATUS 面板的 Hz 刻意**不**按 seq 实测电机回报率 —— 面板要显示的是
* 网页实际刷新率(数据到达口径),WiFi 慢于电机时按电机档位显示就是谎报;
* seq 只用于这里的判活。 */
/**
* 槽 seq 停涨多久算离线的**下限**(ms),生效阈值 = staleLimitMs(实测间隔)。
* 下限 2s 的来由(历史):要大于「扫完全表 + 一点抖动」—— 1.6.15 滑窗时代
* period 10ms × 6 周期 = 60ms,2s 有充足余量;1.6.21 密排后一拍整轮,更不是问题。
*/
export const STALE_OFFLINE_MS = 2000;
/** 生效阈值 = 实测 seq 变化间隔(EWMA) × 该系数。3 倍 ≈ 连丢 3 拍才判离线。 */
export const STALE_IV_FACTOR = 3;
/** 阈值上限:防一次长停顿(后台挂起/总线冻结恢复)把 EWMA 拉飞后迟迟不判离线。 */
export const STALE_MAX_MS = 30000;
/**
* 该槽的离线阈值:clamp(3 × EWMA(实测 seq 变化间隔), 2s, 30s)。
* 电机 CAN 回报率可变:回报周期调到秒级后,固定 2s 会在两次正常回报之间
* 把电机判离线(LED 闪烁/波形虚线/面板切离线层),阈值必须跟着间隔放大。
* ivMs = 0(还没观察到第二次变化)时返回下限 2s,与旧固定阈值行为一致。
*/
export function staleLimitMs(ivMs) {
return Math.max(STALE_OFFLINE_MS, Math.min(STALE_MAX_MS, (ivMs || 0) * STALE_IV_FACTOR));
}
+35 -18
View File
@@ -32,24 +32,31 @@
*/
"use strict";
/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致;
* 固件 1.6.4 起槽长 56B = 8B 头 + 48B STATUS,段 2 的 280B 正好放 5 个) */
const MOT_SLOTS_PER_PDO = 5;
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT) */
/* 判活阈值纯逻辑(随实测 seq 间隔自适应)住在 refresh-rate.js 的
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
import { staleLimitMs } from "./refresh-rate.js";
/** 单个 PDO 周期回传的状态槽数。
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
const MOT_SLOTS_PER_PDO = 9;
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
const MOTOR_SLOT_COUNT = 27;
/** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/5⌉ = 6)。
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 ——
* 后端会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机
* 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */
const MOTOR_SCAN_CYCLES = 6;
/** 扫完全表所需周期数。1.6.21 密排下恒 1(一拍整轮);旧滑窗为 ⌈27/5⌉=6。
* 显示"数据多新"请用快照里的 slot_period_ms。 */
const MOTOR_SCAN_CYCLES = 1;
/**
* 槽 seq 停止增长多久算离线(ms)。
* 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则
* 慢周期下会把正常电机误判离线:period 10ms × 6 周期 = 60ms,取 2s 有充足余量。
* 槽 seq 判活阈值:**不写死** —— 随该槽实测 seq 变化间隔自适应
* clamp(3×EWMA, 2s, 30s),见 refresh-rate.js 的 staleLimitMs()。
* 为什么需要判活:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
* 只能靠 seq 是否还在涨来判活。为什么必须自适应:电机 CAN 回报率可变,
* 回报周期调到秒级后,固定 2s 会在两次正常回报之间把正常电机判离线
* (LED 闪烁、波形画虚线、面板切离线覆盖层);下限 2s 仍兜底快周期
* (要大于"扫完全表 + 一点抖动",1.6.15 滑窗时代 period 10ms × 6 周期 = 60ms)。
*/
const STALE_OFFLINE_MS = 2000;
/** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */
function pcoreList(mask) {
@@ -101,7 +108,7 @@ export function createEcatTransport(ctx) {
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} 判活 + 计更新次数
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx, iv} 判活 + 计更新次数;iv=实测变化间隔 EWMA(ms)
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
@@ -278,7 +285,8 @@ export function createEcatTransport(ctx) {
/**
* 快照 → WiFi 的 /motor/state 契约。
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活(见 STALE_OFFLINE_MS)。
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活
* (阈值随实测回报周期自适应,见 refresh-rate.js 的 staleLimitMs)。
*/
function snapshotToMotorState(snap) {
const now = Date.now();
@@ -288,12 +296,21 @@ export function createEcatTransport(ctx) {
const prev = seqSeen.get(key);
let rx = prev ? prev.rx : 0;
let at = prev ? prev.at : now;
let iv = prev ? prev.iv : 0;
if (!prev || prev.seq !== m.seq) {
if (prev) {
/* 实测变化间隔 EWMA(α=0.3):判活阈值跟着电机回报率走。电机改回报
* 档后 ~3 次新间隔收敛;突发降速无法预知,收敛前最多闪一次离线。 */
const d = now - prev.at;
iv = iv ? iv + (d - iv) * 0.3 : d;
}
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
at = now;
}
seqSeen.set(key, { seq: m.seq, at, rx });
const fresh = (now - at) <= STALE_OFFLINE_MS;
seqSeen.set(key, { seq: m.seq, at, rx, iv });
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽。
* 阈值随实测回报周期放大(慢速电机不误判),下限 2s 兜底快周期。 */
const fresh = (now - at) <= staleLimitMs(iv);
motors.push({
node: m.node,
ch: m.ch,
+8
View File
@@ -192,6 +192,14 @@ export function createWifiTransport(ctx) {
async fetchFeedback() { return getJson("/can/feedback?lite=1", 1500); },
/**
* 全量 CAN 反馈快照(含 per-ID entries)。
* 请求-应答类命令(节点 ID / 反馈刷新率参数 0x0400·0x0401)在 WiFi 链路的
* 回执通道:GD32 的 0xC0 ACK 只有 1 字节状态、不携带电机回帧,回帧落在
* GD32 的 per-ID 快照表里,经这里按需拉取。ECAT 链路不需要(走 motor/rx)。
*/
async fetchFeedbackFull() { return getJson("/can/feedback", 2500); },
/**
* 发一批帧。frames = [{id,dlc,data}],opts.ch = 批级通道。
* @returns {Promise<object[]>} 已按帧数补齐的 results[]