/** * motor/refresh-rate.js — 刷新速度的「每链路一份」纯逻辑 * * 为什么单独一个文件:这里是唯一带分支的部分(默认值、钳制、旧数据迁移), * 而 main.js 里剩下的都是 DOM 副作用。抽出来后自测能直接跑生产代码 * (见 scripts/test_motor_refresh.mjs),不用为了测三个 if 去搭 DOM 桩。 * * 为什么两条链路不能共用一个值:往返代价差一个数量级。 * wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐; * ecat 50ms(下限 1ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms), * PDO 总线周期由后台循环独立维持(默认 4ms,喂 1000ms 看门狗), * 界面读得快只是显示更跟手,不给总线加负担。 * 1ms 的语义是「不限速」而非 1000Hz,见 UNTHROTTLED_MAX_MS。 * 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队 * (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。 */ "use strict"; /** 每条链路的默认刷新间隔(ms)。新链路在这里加一行即可。 */ export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 }; /** * 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同: * wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求 * 排队积压(tickStuck 亮黄),毫无收益。 * ecat 1ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。 * 注意 1ms **不等于** 1000Hz:单飞语义下上一拍完成才发下一拍, * 而每拍要过一次 HTTP(代理 → ecat_native → 回来),实测均值 * ≈ 1.4ms,浏览器里 1ms 档实测约 125Hz。填 1 的语义是 * "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示, * 见 UNTHROTTLED_MAX_MS。 */ export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 1 }; /** 兜底最小值:未知链路 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; }