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

141 lines
7.8 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.
/**
* motor/refresh-rate.js — 刷新速度的「每链路一份」纯逻辑
*
* 为什么单独一个文件:这里是唯一带分支的部分(默认值、钳制、旧数据迁移),
* 而 main.js 里剩下的都是 DOM 副作用。抽出来后自测能直接跑生产代码
* (见 scripts/test_motor_refresh.mjs),不用为了测三个 if 去搭 DOM 桩。
*
* 为什么两条链路不能共用一个值:往返代价差一个数量级。
* wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐;
* ecat 50ms(下限 6ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
* PDO 总线周期由后台循环独立维持(默认 4ms,喂 1000ms 看门狗),
* 界面读得快只是显示更跟手,不给总线加负担。
* 6ms 下限的来由:实测 1ms 档能跑到 ~550Hz,而画面绘制上限由显示器
* 刷新率封顶(本机 160Hz),160Hz 以上那部分轮询没有消费者,
* 纯粹是 HTTP 过路费(每拍 ≈1.6KB 快照,renderer / 网络服务 /
* browser / Python 代理各被点一次)。填 6 的语义仍是「不限速」,
* 见 UNTHROTTLED_MAX_MS。
* 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
*
* 后半部分还住着一块与「实测数据率」相关的纯逻辑(同样为了脱离 DOM 自测):
* staleLimitMs —— EtherCAT 槽判活的离线阈值,随实测 seq 间隔自适应,
* transport-ecat.js 用它,慢速回报的电机不再被固定 2s 误判离线。
*/
"use strict";
/** 每条链路的默认刷新间隔(ms)。新链路在这里加一行即可。 */
export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 };
/**
* 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同:
* wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求
* 排队积压(tickStuck 亮黄),毫无收益。
* ecat 6ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
* 注意 6ms **不等于** 166Hz:单飞语义下上一拍完成才发下一拍,
* 而每拍要过一次 HTTP(代理 → ecat_native → 回来),实测均值
* ≈ 1.8ms,所以 6ms 档实测约 160Hz —— 正好贴着本机 160Hz 屏的
* 绘制上限,再快只是多收一份 HTTP 过路费。填 6 的语义仍是
* "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示,
* 见 UNTHROTTLED_MAX_MS。
*/
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 6 };
/** 兜底最小值:未知链路 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_native 全链),
* 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;
}
/* ═══════════ 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));
}