Files
cbb 209a869f96 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 项全过)
2026-09-12 20:10:47 +08:00

173 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* test_motor_refresh.mjs — 刷新速度「每链路一份」的自测(Node,无浏览器、无服务)
*
* 跑法:node scripts/test_motor_refresh.mjs
*
* 测什么:**真的 import 生产代码** js/motor/refresh-rate.js。
* 这里唯一值得测的是"值会不会串到另一条链路上" —— 那是共用单个变量时
* 会出的 bug(ECAT 的 50ms 带到 WiFi 上会让 HTTP 请求排队卡顿),
* 也是旧数据迁移最容易搞错的地方。
*
* 覆盖:
* 1 默认值:wifi 250 / ecat 50,且 defaultRefreshMap 每次给新对象
* 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 6)
* 3 旧数据迁移:单个 refreshRate → 只进 wifi,ecat 保持默认
* 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值
* 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义)
* 6 不限速谓词:6ms 档要关掉卡顿黄标与理论 Hz 显示(6ms ≠ 166Hz)
* 7 判活阈值 staleLimitMs:随实测 seq 间隔自适应,钳在 [2s, 30s]
*/
import {
REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP,
UNTHROTTLED_MAX_MS, minRefreshFor, isUnthrottled,
defaultRefreshMap, normalizeRefreshMap, pickRefresh,
staleLimitMs, STALE_OFFLINE_MS, STALE_IV_FACTOR, STALE_MAX_MS
} from "../js/motor/refresh-rate.js";
let pass = 0, fail = 0;
const out = [];
function check(name, cond, detail) {
if (cond) { pass++; out.push(` \u2713 ${name}`); }
else { fail++; out.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); }
}
function section(t) { out.push(`\n${t}`); }
/* ── 1. 默认值 ── */
section("[1] 每链路默认值");
check("wifi 默认 250ms(无线 + ESP32 HTTP 往返)", REFRESH_DEFAULTS.wifi === 250,
`实际 ${REFRESH_DEFAULTS.wifi}`);
check("ecat 默认 50ms(本机快照,实测 p90≈22ms)", REFRESH_DEFAULTS.ecat === 50,
`实际 ${REFRESH_DEFAULTS.ecat}`);
check("两个默认值不相等(否则这次改动没意义)", REFRESH_DEFAULTS.wifi !== REFRESH_DEFAULTS.ecat);
check("上限 5000ms 与 #refreshRate 的 max 一致", REFRESH_MAX_MS === 5000);
const m1 = defaultRefreshMap(), m2 = defaultRefreshMap();
m1.wifi = 999;
check("defaultRefreshMap 每次返回新对象(改一份不污染另一份)", m2.wifi === 250);
check("defaultRefreshMap 不污染 REFRESH_DEFAULTS 常量", REFRESH_DEFAULTS.wifi === 250);
/* ── 1b. 下限也按链路 ── */
section("[1b] 每链路最小间隔");
check("ecat 下限 6ms(≈160Hz,与本机 160Hz 屏的绘制上限对齐)",
REFRESH_MIN_BY_TP.ecat === 6, `实际 ${REFRESH_MIN_BY_TP.ecat}`);
check("wifi 下限仍 50ms(每拍是一次无线 HTTP 往返,再快只会积压)",
REFRESH_MIN_BY_TP.wifi === 50, `实际 ${REFRESH_MIN_BY_TP.wifi}`);
check("minRefreshFor('ecat') = 6", minRefreshFor("ecat") === 6);
check("minRefreshFor('wifi') = 50", minRefreshFor("wifi") === 50);
check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope") === REFRESH_MIN_MS);
/* ── 2. 归一 + 钳制(下限按链路) ── */
section("[2] 归一与钳制(下限按链路)");
const norm = normalizeRefreshMap({ wifi: 400, ecat: 20 });
check("正常值原样保留", norm.wifi === 400);
check("ecat 的 20ms 合法(下限 6ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`);
check("ecat 可以设到 6ms", normalizeRefreshMap({ ecat: 6 }).ecat === 6);
/* 存量缓存里还留着旧的 1ms 档:读到就按新下限抬起来,不能原样放行 */
check("存量 1ms 被抬到 6ms(绘制上限之上没有消费者)",
normalizeRefreshMap({ ecat: 1 }).ecat === 6,
`实际 ${normalizeRefreshMap({ ecat: 1 }).ecat}`);
check("ecat 的 0/负数被抬到 6ms", normalizeRefreshMap({ ecat: 0 }).ecat === 6);
check("ecat 的 -5 被抬到 6ms", normalizeRefreshMap({ ecat: -5 }).ecat === 6);
check("wifi 的 1ms 仍被抬到 50(无线往返撑不住)",
normalizeRefreshMap({ wifi: 1 }).wifi === 50, `实际 ${normalizeRefreshMap({ wifi: 1 }).wifi}`);
check("wifi 的 20ms 也被抬到 50", normalizeRefreshMap({ wifi: 20 }).wifi === 50);
check("高于上限被压到 5000", normalizeRefreshMap({ wifi: 99999 }).wifi === REFRESH_MAX_MS);
check("ecat 高于上限同样压到 5000", normalizeRefreshMap({ ecat: 99999 }).ecat === REFRESH_MAX_MS);
check("字符串数字可用(localStorage 里存的是字符串)",
normalizeRefreshMap({ wifi: "300" }).wifi === 300);
check("ecat 字符串 \"8\" 可用", normalizeRefreshMap({ ecat: "8" }).ecat === 8);
const dirty = normalizeRefreshMap({ wifi: "abc", ecat: null });
check("脏值回落该链路默认(wifi)", dirty.wifi === 250, `实际 ${dirty.wifi}`);
check("脏值回落该链路默认(ecat)", dirty.ecat === 50, `实际 ${dirty.ecat}`);
check("未知链路 key 被丢弃(不在 localStorage 里攒垃圾)",
normalizeRefreshMap({ wifi: 300, bogus: 111 }).bogus === undefined);
const empty = normalizeRefreshMap(undefined, undefined);
check("完全没有历史数据 → 全用默认", empty.wifi === 250 && empty.ecat === 50);
check("传入非对象(数组/字符串)不炸且回落默认",
normalizeRefreshMap("garbage").wifi === 250);
/* ── 3. 旧数据迁移 ──
* 旧版只存单个 refreshRate(那时只有 WiFi 链路,值是为无线调的)。
* 迁到 wifi 才对;套到 ecat 上等于让用户平白继承一个不合适的值。 */
section("[3] 旧版单值迁移");
const mig = normalizeRefreshMap(undefined, 500);
check("旧单值迁进 wifi", mig.wifi === 500, `实际 ${mig.wifi}`);
check("ecat **不**继承旧单值,保持 50ms 默认", mig.ecat === 50, `实际 ${mig.ecat}`);
const migClamp = normalizeRefreshMap(undefined, "10");
check("旧单值也走 wifi 的下限钳制(10 → 50)", migClamp.wifi === 50);
const both = normalizeRefreshMap({ wifi: 300, ecat: 80 }, 500);
check("已有新格式时忽略旧字段(不被旧值覆盖)", both.wifi === 300 && both.ecat === 80);
/* ── 4. 取值 ── */
section("[4] pickRefresh 取当前链路的生效值");
check("正常取值", pickRefresh({ wifi: 300, ecat: 60 }, "ecat") === 60);
check("缺该链路 → 回落该链路默认(不是另一条的值)",
pickRefresh({ wifi: 300 }, "ecat") === 50);
check("map 为 null 不炸", pickRefresh(null, "wifi") === 250);
check("ecat 取出 8ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 8 }, "ecat") === 8);
check("ecat 取出旧的 1ms 抬到下限 6ms", pickRefresh({ ecat: 1 }, "ecat") === 6);
check("wifi 取出 1ms 被抬到 50", pickRefresh({ wifi: 1 }, "wifi") === 50);
check("未知链路 id 回落到 wifi 默认(防御,不应出现)",
pickRefresh({}, "somethingnew") === 250);
/* ── 5. 隔离:本次改动的核心 ── */
section("[5] 两条链路互不污染");
const map = defaultRefreshMap();
/* 模拟:用户在 ECAT 下把刷新速度拉到下限 6ms(applyRefreshRate 只写当前链路那一份) */
map.ecat = 6;
check("改 ecat 到 6ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250);
/* 模拟:切回 WiFi,装载的应是 wifi 那一份 */
check("切回 wifi 装载 250 而非 6", pickRefresh(map, "wifi") === 250);
/* 模拟:在 WiFi 下改成 1000ms,再切去 ECAT */
map.wifi = 1000;
check("改 wifi 后 ecat 仍是 6ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 6);
check("切到 ecat 装载 6 而非 1000", pickRefresh(map, "ecat") === 6);
/* 存盘 → 读回一圈后仍各自独立(saveUiState/loadUiState 的往返) */
const roundTrip = normalizeRefreshMap(JSON.parse(JSON.stringify(map)));
check("存盘再读回:wifi 保留 1000", roundTrip.wifi === 1000);
check("存盘再读回:ecat 保留 6ms(不被 wifi 的下限 50 顺手抬高)",
roundTrip.ecat === 6, `实际 ${roundTrip.ecat}`);
/* ── 6. 不限速谓词 ──
* 下限档(6ms)贴着往返耗时,节拍源仍会比请求完成得快,撞上"上一拍还在飞"是
* 预期行为,所以要据此关掉卡顿黄标;6ms 硬算成 166Hz 也偏乐观(实测 ≈160Hz)。 */
section("[6] 不限速谓词(关黄标 + 不谎报 Hz)");
check("阈值 20ms(50Hz):比浏览器实测每拍耗时(~8ms)宽裕,又远小于常用档",
UNTHROTTLED_MAX_MS === 20, `实际 ${UNTHROTTLED_MAX_MS}`);
check("1ms 属不限速", isUnthrottled(1) === true);
check("10ms 属不限速", isUnthrottled(10) === true);
check("19ms 属不限速(边界内)", isUnthrottled(19) === true);
check("20ms 不属不限速(边界外)", isUnthrottled(20) === false);
check("ECAT 默认 50ms 是正常限速档(黄标与 Hz 显示都保留)",
isUnthrottled(REFRESH_DEFAULTS.ecat) === false);
check("WiFi 默认 250ms 是正常限速档", isUnthrottled(REFRESH_DEFAULTS.wifi) === false);
check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)",
isUnthrottled(REFRESH_MIN_BY_TP.wifi) === false);
check("ECAT 下限 6ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false);
/* ── 7. ECAT 判活阈值自适应 ──
* 固定 2s 阈值的问题:电机 CAN 回报周期调到秒级后,两次**正常**回报之间
* 就超过 2s,节点反复判离线(LED 闪/波形虚线/面板切离线层)。
* 生效阈值 = clamp(3×EWMA(实测间隔), 2s, 30s)。
* 注:STATUS 面板的 Hz 刻意不按 seq 实测电机回报率 —— 面板显示的是网页
* 实际刷新率(数据到达口径),WiFi 慢于电机时按电机档位显示就是谎报。 */
section("[7] 判活阈值 staleLimitMs(随实测 seq 间隔自适应)");
check("下限 2s / 系数 3 / 上限 30s(契约常量)",
STALE_OFFLINE_MS === 2000 && STALE_IV_FACTOR === 3 && STALE_MAX_MS === 30000);
check("还没观察到第二次变化(iv=0)→ 下限 2s,与旧固定阈值行为一致",
staleLimitMs(0) === 2000, `实际 ${staleLimitMs(0)}`);
check("undefined 同样落到下限(防御)", staleLimitMs(undefined) === 2000);
check("快周期 20ms → 仍取下限 2s(兜底快周期,阈值不小于扫表+抖动)",
staleLimitMs(20) === 2000, `实际 ${staleLimitMs(20)}`);
check("回报周期 1s → 阈值 3s(连丢 3 拍才判离线,2s 不再误判)",
staleLimitMs(1000) === 3000, `实际 ${staleLimitMs(1000)}`);
check("回报周期 5s → 阈值 15s", staleLimitMs(5000) === 15000,
`实际 ${staleLimitMs(5000)}`);
check("回报周期 20s → 被 30s 上限压住(长停顿后不能迟迟不判离线)",
staleLimitMs(20000) === 30000, `实际 ${staleLimitMs(20000)}`);
console.log(out.join("\n"));
console.log("\n" + "=".repeat(56));
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
process.exit(fail === 0 ? 0 : 1);