docs: 新增 .qoder Repowiki 知识库文档
This commit is contained in:
+177
-94
@@ -332,8 +332,8 @@ function showModal(title, desc) {
|
||||
$("#modalCancel").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(false); };
|
||||
$("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(true); };
|
||||
|
||||
/* ═══════════════════════ STATUS 帧解码(对齐 DBC Motor_CAN_FD v0.70) ═══════════════════════
|
||||
* BO_ STATUS DLC=32,信号起止与字节(Intel @1,除非注明 Motorola @0):
|
||||
/* ═══════════════════════ STATUS 帧解码(对齐 DBC Motor_CAN_FD v0.74) ═══════════════════════
|
||||
* BO_ STATUS DLC=48,信号起止与字节(Intel @1,除非注明 Motorola @0):
|
||||
* MCU_temp 0|16@1- ×0.01 → B0-1
|
||||
* Motor_temp 16|16@1- ×0.01 → B2-3
|
||||
* U/V/W_cur 32/48/64|16@1- mA → B4-5 / B6-7 / B8-9
|
||||
@@ -348,15 +348,18 @@ $("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); i
|
||||
* test_float 200|32@1- float → B25-28
|
||||
* test_U16 232|16@1+ → B29-30
|
||||
* CPU_Usage 248|8@1+ % → B31
|
||||
* InitStatus 256|32@1+ → B32-35 初始化错误码位图(0=全部正常)
|
||||
*
|
||||
* 旧实现误把 Power 当 B12、float 当 B16/20/24/28,导致角度/速度/电流整体错位乱码。
|
||||
*/
|
||||
function decodeStatus(bytes) {
|
||||
if (!bytes || bytes.length < 32) return null;
|
||||
/* 拷到独立 ArrayBuffer,避免 SharedArrayBuffer/子视图偏移干扰 DataView */
|
||||
/* 拷到独立 ArrayBuffer,避免 SharedArrayBuffer/子视图偏移干扰 DataView;
|
||||
* 36B 覆盖 v0.74 新增的 InitStatus(B32-35)。载荷不足 36B 说明这条链路根本没
|
||||
* 送这个字段 —— 必须返回 null 让 UI 显示"—",补 0 会把"无数据"显示成"初始化正常"。 */
|
||||
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
const copy = new Uint8Array(32);
|
||||
copy.set(u8.subarray(0, 32));
|
||||
const copy = new Uint8Array(36);
|
||||
copy.set(u8.subarray(0, Math.min(36, u8.length)));
|
||||
const dv = new DataView(copy.buffer);
|
||||
const b10 = copy[10];
|
||||
|
||||
@@ -383,6 +386,7 @@ function decodeStatus(bytes) {
|
||||
testFloat: f32(25), /* bit200 → B25 */
|
||||
testU16: dv.getUint16(29, true), /* bit232 → B29 */
|
||||
cpuUsage: copy[31], /* bit248 → B31 */
|
||||
initStatus: u8.length >= 36 ? dv.getUint32(32, true) : null, /* bit256 → B32-35,0=全部正常;null=这条链路没送 */
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,8 +420,8 @@ function decodeConfig(hexStr) {
|
||||
|
||||
/**
|
||||
* STATUS 故障位简写(节点卡片右上角,空间紧凑)。
|
||||
* 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit)。
|
||||
* 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3"。
|
||||
* 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit),及 InitStatus(B32-35)。
|
||||
* 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3" / "初始化"。
|
||||
*/
|
||||
function faultShortParts(d) {
|
||||
if (!d) return [];
|
||||
@@ -426,6 +430,7 @@ function faultShortParts(d) {
|
||||
if (d.magStatus) parts.push("磁编");
|
||||
if (d.thermalStatus) parts.push("过温");
|
||||
if (d.eepromStatus) parts.push("EE" + d.eepromStatus);
|
||||
if (d.initStatus) parts.push("初始化");
|
||||
return parts;
|
||||
}
|
||||
function faultShortLabel(d) {
|
||||
@@ -766,14 +771,14 @@ function applyTransportRefresh() {
|
||||
state.refreshIntervalMs = v;
|
||||
/* 输入框无条件回填:初始化时 state 恰好与 HTML 默认值相同也要写,
|
||||
* 否则输入框会停在 HTML 里的 250 而实际链路用的是别的值。
|
||||
* min/step 也随链路走:ECAT 允许到 1ms(读本机快照,见 refresh-rate.js),
|
||||
* min/step 也随链路走:ECAT 允许到 6ms(读本机快照,见 refresh-rate.js),
|
||||
* WiFi 仍是 50ms 起。不同步 min 的话,浏览器自带的数字校验会把
|
||||
* 合法的 1ms 标成 :invalid,微调箭头也会停在错误的档位上。 */
|
||||
* 合法值标成 :invalid,微调箭头也会停在错误的档位上。 */
|
||||
const el = $("#refreshRate");
|
||||
if (el) {
|
||||
const lo = minRefreshFor(state.tpId);
|
||||
el.min = String(lo);
|
||||
el.step = lo <= 1 ? "1" : "50"; /* 1ms 档用 1 步进,否则 50 步进太粗 */
|
||||
el.step = isUnthrottled(lo) ? "1" : "50"; /* 不限速档要能逐 ms 微调 */
|
||||
el.value = v;
|
||||
}
|
||||
if (!changed) return v; /* 值没变就别白重排节拍 */
|
||||
@@ -1191,10 +1196,10 @@ function tickHandler() {
|
||||
if (!state.connected || state.probing) return;
|
||||
if (state.tickInFlight) {
|
||||
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
|
||||
* 「不限速」档(如 ECAT 的 1ms)下这是**预期行为**:设定间隔本就小于
|
||||
* 单次往返耗时,节拍源必然比请求快,每拍都会撞上。此时不计卡顿,
|
||||
* 否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",而用户要的
|
||||
* 恰恰就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
|
||||
* 「不限速」档(如 ECAT 的 6ms 下限档)下这是**预期行为**:设定间隔贴着
|
||||
* 单次往返耗时(均值 ≈1.8ms,但尾部会越过 6ms),节拍源撞上在途请求是常态。
|
||||
* 此时不计卡顿,否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",
|
||||
* 而用户要的就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
|
||||
if (!isUnthrottled(state.refreshIntervalMs)) {
|
||||
state.tickStuck = (state.tickStuck || 0) + 1;
|
||||
updateTickStuckUi();
|
||||
@@ -1602,8 +1607,9 @@ const RECV_HZ_WINDOW = 10;
|
||||
function statusHzText() {
|
||||
const ts = state.recvTs;
|
||||
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
|
||||
* 「不限速」档要按实测往返而非设定值算这个窗口:1ms × 2 × 10 = 20ms,
|
||||
* 会把刚到的样本全判成"旧数据"清空,Hz 就永远退回理论值(谎报 1000Hz)。
|
||||
* 「不限速」档要按实测往返而非设定值算这个窗口:6ms × 2 × 10 = 120ms,
|
||||
* 一次尾部抖动(>120ms)就把刚到的样本全判成"旧数据"清空,
|
||||
* Hz 会永远退回理论值(谎报 166Hz)。
|
||||
* 这里给不限速档一个固定的 2s 窗口,足够攒满 10 个样本算出真实频率。 */
|
||||
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
|
||||
const maxAge = isUnthrottled(iv) ? 2000 : iv * 2 * RECV_HZ_WINDOW;
|
||||
@@ -1618,7 +1624,7 @@ function statusHzText() {
|
||||
}
|
||||
}
|
||||
/* 窗口数据不足时回退到理论值,不让面板空着。
|
||||
* 不限速档没有有意义的理论值(1ms≠1000Hz),显示 "…" 等实测数据到齐。 */
|
||||
* 不限速档没有有意义的理论值(6ms≠166Hz),显示 "…" 等实测数据到齐。 */
|
||||
if (isUnthrottled(iv)) return "\u2026 Hz";
|
||||
const hz = 1000 / iv;
|
||||
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
|
||||
@@ -1640,10 +1646,27 @@ function updateNodeLabels() {
|
||||
}
|
||||
|
||||
/* ═══════════════════════ STATUS 面板渲染 ═══════════════════════ */
|
||||
/* 值变了才写 DOM。轮询下限 6ms(≈160Hz)下,无条件的 textContent/className/style
|
||||
* 覆写每一拍都会把整片面板标脏,样式重算和重绘全程满载 —— 而其中绝大多数拍
|
||||
* 写的值和上一拍一模一样。左侧节点列表早就是这个写法,面板对齐它。 */
|
||||
function setTextIfChanged(el, s) { if (el && el.textContent !== s) el.textContent = s; }
|
||||
function setClassIfChanged(el, c) { if (el && el.className !== c) el.className = c; }
|
||||
function setStyleIfChanged(el, prop, v) { if (el && el.style[prop] !== v) el.style[prop] = v; }
|
||||
|
||||
/* 高亮"续期"而不是每拍重排:160Hz 轮询下每拍都会调这里,若每次都 add + 排一个
|
||||
* 350ms 的 remove,就是每秒 ~480 个定时器,而且摘与下一拍重新加上之间会留
|
||||
* 一个可见的空档。改成每次只把 _flashUntil 往后推,收尾定时器发现还在续期
|
||||
* 窗口内就自己改期重排 —— 数据在流期间 class 一直挂着,停流 350ms 后才摘。 */
|
||||
function flashEl(el) {
|
||||
if (!el) return;
|
||||
el._flashUntil = nowMs() + 350;
|
||||
if (el.classList.contains("flash")) return;
|
||||
el.classList.add("flash");
|
||||
setTimeout(() => el.classList.remove("flash"), 350);
|
||||
setTimeout(function done() {
|
||||
const left = el._flashUntil - nowMs();
|
||||
if (left > 0) { setTimeout(done, left); return; } /* 期间被续期,改期再摘 */
|
||||
el.classList.remove("flash");
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function renderStatusPanel() {
|
||||
@@ -1661,81 +1684,95 @@ function renderStatusPanel() {
|
||||
$("#statusLive").classList.toggle("hide", offline);
|
||||
if (offline) {
|
||||
if (!state.connected) {
|
||||
$("#offlineTitle").textContent = "设备未连接";
|
||||
$("#offlineSub").textContent = "本页共用 OTA 主页 IP,请到主页配置并测试连接";
|
||||
setTextIfChanged($("#offlineTitle"), "设备未连接");
|
||||
setTextIfChanged($("#offlineSub"), "本页共用 OTA 主页 IP,请到主页配置并测试连接");
|
||||
} else if (onOtherCh) {
|
||||
const chs = others.map((o) => "CH" + o.ch).sort().join(" / ");
|
||||
$("#offlineTitle").textContent = `节点 ${n} 在 ${chs} 有信号`;
|
||||
$("#offlineSub").textContent = `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`;
|
||||
setTextIfChanged($("#offlineTitle"), `节点 ${n} 在 ${chs} 有信号`);
|
||||
setTextIfChanged($("#offlineSub"), `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`);
|
||||
} else if (!motor || !motor.online) {
|
||||
$("#offlineTitle").textContent = n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)";
|
||||
$("#offlineSub").textContent = "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写";
|
||||
setTextIfChanged($("#offlineTitle"), n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)");
|
||||
setTextIfChanged($("#offlineSub"), "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写");
|
||||
} else {
|
||||
$("#offlineTitle").textContent = `节点 ${n} 暂无解码数据`;
|
||||
$("#offlineSub").textContent = "等待 STATUS 帧;OTA 刷写不受影响";
|
||||
setTextIfChanged($("#offlineTitle"), `节点 ${n} 暂无解码数据`);
|
||||
setTextIfChanged($("#offlineSub"), "等待 STATUS 帧;OTA 刷写不受影响");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$("#rtAngle").textContent = d.rtAngle.toFixed(2);
|
||||
$("#rtVelocity").textContent = d.rtVelocity.toFixed(1);
|
||||
$("#rtCurrent").textContent = d.rtCurrent.toFixed(0);
|
||||
$("#mcuTemp").textContent = d.mcuTemp.toFixed(2) + " °C";
|
||||
$("#motTemp").textContent = d.motTemp.toFixed(2) + " °C";
|
||||
setTextIfChanged($("#rtAngle"), d.rtAngle.toFixed(2));
|
||||
setTextIfChanged($("#rtVelocity"), d.rtVelocity.toFixed(1));
|
||||
setTextIfChanged($("#rtCurrent"), d.rtCurrent.toFixed(0));
|
||||
setTextIfChanged($("#mcuTemp"), d.mcuTemp.toFixed(2) + " °C");
|
||||
setTextIfChanged($("#motTemp"), d.motTemp.toFixed(2) + " °C");
|
||||
|
||||
/* 目标值:优先使用电机 CONFIG 帧回报(0x000N0101, Motor→Host),
|
||||
* 无 CONFIG 帧时回退到前端 lastSentTargets 记录 */
|
||||
const cfg = state.decodedConfig.get(motorKey(motor));
|
||||
if (cfg) {
|
||||
$("#tgtAngle").textContent = cfg.targetAngle.toFixed(1);
|
||||
$("#tgtVelocity").textContent = cfg.targetVelocity.toFixed(0);
|
||||
$("#tgtCurrent").textContent = cfg.targetCurrent.toFixed(0);
|
||||
setTextIfChanged($("#tgtAngle"), cfg.targetAngle.toFixed(1));
|
||||
setTextIfChanged($("#tgtVelocity"), cfg.targetVelocity.toFixed(0));
|
||||
setTextIfChanged($("#tgtCurrent"), cfg.targetCurrent.toFixed(0));
|
||||
} else {
|
||||
const tgt = state.lastSentTargets.get(n);
|
||||
if (tgt) {
|
||||
const MODES_TARGETS = { 1: ["angle"], 2: ["velocity"], 3: ["velocity", "angle"], 4: ["current", "velocity", "angle"], 5: ["current"], 6: ["velocity"], 7: ["velocity"] };
|
||||
const active = MODES_TARGETS[tgt.mode] || [];
|
||||
const fmtTgt = (id, val, active) => { const el = $("#" + id); if (el) el.textContent = active ? String(val) : "--"; };
|
||||
const fmtTgt = (id, val, active) => setTextIfChanged($("#" + id), active ? String(val) : "--");
|
||||
fmtTgt("tgtAngle", tgt.angle.toFixed(1), active.includes("angle"));
|
||||
fmtTgt("tgtVelocity", tgt.velocity.toFixed(0), active.includes("velocity"));
|
||||
fmtTgt("tgtCurrent", tgt.current.toFixed(0), active.includes("current"));
|
||||
} else {
|
||||
$("#tgtAngle").textContent = "--";
|
||||
$("#tgtVelocity").textContent = "--";
|
||||
$("#tgtCurrent").textContent = "--";
|
||||
setTextIfChanged($("#tgtAngle"), "--");
|
||||
setTextIfChanged($("#tgtVelocity"), "--");
|
||||
setTextIfChanged($("#tgtCurrent"), "--");
|
||||
}
|
||||
}
|
||||
|
||||
const mcuPct = clampNum(d.mcuTemp, 0, 100);
|
||||
const motPct = clampNum(d.motTemp, 0, 100);
|
||||
$("#mcuTempBar").style.width = mcuPct + "%";
|
||||
$("#mcuTempBar").className = "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : "");
|
||||
$("#motTempBar").style.width = motPct + "%";
|
||||
$("#motTempBar").className = "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : "");
|
||||
setStyleIfChanged($("#mcuTempBar"), "width", mcuPct + "%");
|
||||
setClassIfChanged($("#mcuTempBar"), "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : ""));
|
||||
setStyleIfChanged($("#motTempBar"), "width", motPct + "%");
|
||||
setClassIfChanged($("#motTempBar"), "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : ""));
|
||||
|
||||
$("#valU").textContent = `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`;
|
||||
$("#valV").textContent = `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`;
|
||||
$("#valW").textContent = `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`;
|
||||
setTextIfChanged($("#valU"), `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`);
|
||||
setTextIfChanged($("#valV"), `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`);
|
||||
setTextIfChanged($("#valW"), `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`);
|
||||
|
||||
$("#pwrVolt").textContent = d.powerVoltage.toFixed(3) + " V";
|
||||
$("#testFloat").textContent = d.testFloat.toFixed(3);
|
||||
$("#testU16").textContent = String(d.testU16);
|
||||
$("#cpuPct").textContent = Math.round(d.cpuUsage) + "%";
|
||||
$("#cpuSub").textContent = `seq ${motor.seq} · rx ${motor.rx_count}`;
|
||||
setTextIfChanged($("#pwrVolt"), d.powerVoltage.toFixed(3) + " V");
|
||||
setTextIfChanged($("#testFloat"), d.testFloat.toFixed(3));
|
||||
setTextIfChanged($("#testU16"), String(d.testU16));
|
||||
setTextIfChanged($("#cpuPct"), Math.round(d.cpuUsage) + "%");
|
||||
setTextIfChanged($("#cpuSub"), `seq ${motor.seq} · rx ${motor.rx_count}`);
|
||||
const circumference = 131.9;
|
||||
$("#cpuRingFg").style.strokeDashoffset = (circumference * (1 - clampNum(d.cpuUsage, 0, 100) / 100)).toFixed(1);
|
||||
setStyleIfChanged($("#cpuRingFg"), "strokeDashoffset",
|
||||
(circumference * (1 - clampNum(d.cpuUsage, 0, 100) / 100)).toFixed(1));
|
||||
|
||||
/* 状态标志位 */
|
||||
const setFlag = (itemId, ledId, stateId, active, isFault, okText, faultText) => {
|
||||
$("#" + itemId).className = "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : "");
|
||||
$("#" + ledId).className = "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : "");
|
||||
$("#" + stateId).textContent = active ? (isFault ? faultText : okText) : "—";
|
||||
setClassIfChanged($("#" + itemId), "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : ""));
|
||||
setClassIfChanged($("#" + ledId), "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : ""));
|
||||
setTextIfChanged($("#" + stateId), active ? (isFault ? faultText : okText) : "—");
|
||||
};
|
||||
/* motStatus:DBC 0=正常 / 1=报错(不是使能位) */
|
||||
setFlag("flagItemMot", "flagMot", "flagMotState", true, d.motStatus === 1, "NORMAL", "FAULT");
|
||||
setFlag("flagItemMag", "flagMag", "flagMagState", true, d.magStatus === 1, "NORMAL", "FAULT");
|
||||
setFlag("flagItemThermal", "flagThermal", "flagThermalState", true, d.thermalStatus === 1, "NORMAL", "过温!");
|
||||
setFlag("flagItemEeprom", "flagEeprom", "flagEepromState", true, d.eepromStatus !== 0, "NORMAL", "ERR=" + d.eepromStatus);
|
||||
/* InitStatus(v0.74 STATUS B32-35):初始化错误码位图,0=全部正常;
|
||||
* 含电流检测配平超限/无采样帧等,详见 config.h INIT_ERR_* */
|
||||
const INIT_ERR_NAMES = ["角度传感器", "ADC", "CAN", "角度滤波", "速度滤波", "FOC", "电流配平", "电流无帧", "门驱动"];
|
||||
let initFaultTxt = "";
|
||||
if (d.initStatus) {
|
||||
const names = [];
|
||||
for (let i = 0; i < INIT_ERR_NAMES.length; i++) {
|
||||
if (d.initStatus & (1 << i)) names.push(INIT_ERR_NAMES[i]);
|
||||
}
|
||||
initFaultTxt = names.length ? names.join("·") : "0x" + (d.initStatus >>> 0).toString(16);
|
||||
}
|
||||
setFlag("flagItemInit", "flagInit", "flagInitState", d.initStatus !== null,
|
||||
d.initStatus !== 0, "NORMAL", initFaultTxt);
|
||||
|
||||
flashEl($("#rtAngle")); flashEl($("#rtVelocity")); flashEl($("#rtCurrent"));
|
||||
}
|
||||
@@ -1776,16 +1813,16 @@ function updateChSelectOptions() {
|
||||
const en = state.chEnabled[i];
|
||||
const cnt = state.chOnline[i] || 0;
|
||||
const base = "CH" + i;
|
||||
opt.textContent = !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base);
|
||||
opt.style.color = !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : "");
|
||||
setTextIfChanged(opt, !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base));
|
||||
setStyleIfChanged(opt, "color", !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : ""));
|
||||
}
|
||||
/* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效),
|
||||
* 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白。
|
||||
* 卡顿优先:轮询卡顿时显示黄色,覆盖在线绿色(updateTickStuckUi 触发本函数同步) */
|
||||
const curEn = state.chEnabled[state.ch];
|
||||
const curCnt = state.chOnline[state.ch] || 0;
|
||||
sel.style.color = (state.tickStuck || 0) >= TICK_STUCK_WARN ? "var(--warn,#f59e0b)"
|
||||
: (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : ""));
|
||||
setStyleIfChanged(sel, "color", (state.tickStuck || 0) >= TICK_STUCK_WARN ? "var(--warn,#f59e0b)"
|
||||
: (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "")));
|
||||
}
|
||||
|
||||
function onFeedback(data) {
|
||||
@@ -3272,7 +3309,7 @@ async function sendMotorPeriod(ms) {
|
||||
}
|
||||
function applyRefreshRate() {
|
||||
const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS;
|
||||
const lo = minRefreshFor(state.tpId); /* 下限按链路:ECAT 到 1ms,WiFi 仍 50ms */
|
||||
const lo = minRefreshFor(state.tpId); /* 下限按链路:ECAT 到 6ms,WiFi 仍 50ms */
|
||||
const v = clampNum(parseInt(refreshRateInput.value, 10) || dflt, lo, REFRESH_MAX_MS);
|
||||
refreshRateInput.value = v;
|
||||
state.refreshIntervalMs = v;
|
||||
@@ -3289,8 +3326,8 @@ function applyRefreshRate() {
|
||||
schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */
|
||||
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
|
||||
}
|
||||
/* 极小间隔别报"≈1000 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
|
||||
* (ECAT 每拍一次 HTTP,浏览器里实测约 125Hz)。这时只说"不限速",
|
||||
/* 极小间隔别报"≈166 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
|
||||
* (ECAT 每拍一次 HTTP,6ms 档实测约 160Hz,正好贴着屏幕绘制上限)。这时只说"不限速",
|
||||
* 实际到达频率看 STATUS 面板右上角那个按数据到达时间算出来的 Hz。 */
|
||||
const hz = 1000 / v;
|
||||
toast(isUnthrottled(v)
|
||||
@@ -3329,16 +3366,44 @@ const scopeSys = (() => {
|
||||
let C = colors();
|
||||
setInterval(() => { C = colors(); }, 2000);
|
||||
|
||||
function fit(canvas) {
|
||||
const dpr = dprOf();
|
||||
/* 尺寸缓存:fit 每帧调一次 getBoundingClientRect = 每秒 160 次强制同步布局。
|
||||
* 改成 ResizeObserver 只在真变化时量一次;borderBoxSize 与原来的
|
||||
* getBoundingClientRect 同口径(迷你波形的 canvas 有 1px 边框,
|
||||
* 用 contentRect 会让几何悄悄缩 2px)。 */
|
||||
const sizes = new WeakMap();
|
||||
let sizeObs = null;
|
||||
function cssSize(canvas) {
|
||||
let s = sizes.get(canvas);
|
||||
if (s) return s;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (r.width === 0) return false;
|
||||
if (canvas.width !== Math.round(r.width * dpr) || canvas.height !== Math.round(r.height * dpr)) {
|
||||
canvas.width = Math.round(r.width * dpr);
|
||||
canvas.height = Math.round(r.height * dpr);
|
||||
s = { w: r.width, h: r.height };
|
||||
sizes.set(canvas, s);
|
||||
if (!sizeObs) {
|
||||
sizeObs = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const t = sizes.get(e.target);
|
||||
if (!t) continue;
|
||||
const b = e.borderBoxSize && e.borderBoxSize[0];
|
||||
t.w = b ? b.inlineSize : e.contentRect.width;
|
||||
t.h = b ? b.blockSize : e.contentRect.height;
|
||||
}
|
||||
});
|
||||
}
|
||||
canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return true;
|
||||
sizeObs.observe(canvas);
|
||||
return s;
|
||||
}
|
||||
/** 对齐 backing store 与 CSS 尺寸;返回 CSS 尺寸,宽度为 0(未显示)时返回 null。 */
|
||||
function fit(canvas, ctx) {
|
||||
const dpr = dprOf();
|
||||
const s = cssSize(canvas);
|
||||
if (s.w === 0) return null;
|
||||
const cw = Math.round(s.w * dpr), ch = Math.round(s.h * dpr);
|
||||
if (canvas.width !== cw || canvas.height !== ch) {
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return s;
|
||||
}
|
||||
function grid(ctx, w, h) {
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.05)";
|
||||
@@ -3351,34 +3416,40 @@ const scopeSys = (() => {
|
||||
/* 分段描线:valid 样本连成实线(发光 + 可选基线填充);无数据样本(离线
|
||||
* 补零段)画虚线、无发光无填充 —— 数据停更一眼可辨。相邻样本任一无效,
|
||||
* 该连线即按虚线处理;边界点同时属于两段,段间不脱节。 */
|
||||
/* 每帧 new 300 个点对象是 160Hz 下最大的分配源(一次全量重绘 ≈2400 个对象,
|
||||
* 也正是工作集一路涨到几 GB 的原因)。改成把 y 写进复用的 scratch 数组,
|
||||
* x 因为等间距直接算出来,不再存。 */
|
||||
const scratchY = new Float32Array(BUF);
|
||||
const okAt = (i) => valid[(head + i) % BUF] === 1;
|
||||
function drawTrace(ctx, buf, color, yOf, base, w, lw, fill) {
|
||||
const pts = [];
|
||||
for (let i = 0; i < BUF; i++) {
|
||||
const idx = (head + i) % BUF;
|
||||
pts.push({ x: i / (BUF - 1) * w, y: yOf(buf[idx]), ok: valid[idx] === 1 });
|
||||
}
|
||||
const xOf = (i) => i / (BUF - 1) * w;
|
||||
for (let i = 0; i < BUF; i++) scratchY[i] = yOf(buf[(head + i) % BUF]);
|
||||
const linePath = (a, b) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[a].x, pts[a].y);
|
||||
for (let i = a + 1; i <= b; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||
ctx.moveTo(xOf(a), scratchY[a]);
|
||||
for (let i = a + 1; i <= b; i++) ctx.lineTo(xOf(i), scratchY[i]);
|
||||
};
|
||||
ctx.lineWidth = lw; ctx.lineJoin = "round"; ctx.strokeStyle = color;
|
||||
ctx.lineJoin = "round"; ctx.strokeStyle = color;
|
||||
let start = 0;
|
||||
while (start < BUF - 1) {
|
||||
const solid = pts[start].ok && pts[start + 1].ok;
|
||||
const solid = okAt(start) && okAt(start + 1);
|
||||
let end = start + 1;
|
||||
while (end + 1 < BUF && (pts[end].ok && pts[end + 1].ok) === solid) end++;
|
||||
while (end + 1 < BUF && (okAt(end) && okAt(end + 1)) === solid) end++;
|
||||
if (solid && fill) {
|
||||
linePath(start, end);
|
||||
ctx.lineTo(pts[end].x, base); ctx.lineTo(pts[start].x, base); ctx.closePath();
|
||||
ctx.lineTo(xOf(end), base); ctx.lineTo(xOf(start), base); ctx.closePath();
|
||||
ctx.fillStyle = color + "16";
|
||||
ctx.fill();
|
||||
}
|
||||
linePath(start, end);
|
||||
ctx.setLineDash(solid ? [] : [5, 4]);
|
||||
ctx.shadowColor = color; ctx.shadowBlur = solid ? 5 : 0;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
/* 发光:同一条路径先描一道宽而淡的底。原来的 shadowBlur 是逐像素模糊,
|
||||
* 视觉上两者等价,成本差一个量级。 */
|
||||
if (solid) {
|
||||
ctx.globalAlpha = 0.16; ctx.lineWidth = lw * 3.2; ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.lineWidth = lw; ctx.stroke();
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
@@ -3395,8 +3466,9 @@ const scopeSys = (() => {
|
||||
const big = $("#scopeCanvas");
|
||||
const bigCtx = big.getContext("2d");
|
||||
function drawBig() {
|
||||
if (!fit(big)) return;
|
||||
const w = big.width / dprOf(), h = big.height / dprOf();
|
||||
const s = fit(big, bigCtx);
|
||||
if (!s) return;
|
||||
const w = s.w, h = s.h;
|
||||
bigCtx.clearRect(0, 0, w, h);
|
||||
grid(bigCtx, w, h);
|
||||
trace(bigCtx, hist.u, C.u, 5000, w, h, 1.6, false);
|
||||
@@ -3415,7 +3487,13 @@ const scopeSys = (() => {
|
||||
mcuTemp: { el: $("#miniMcuTemp"), buf: hist.mcuTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.v },
|
||||
motTemp: { el: $("#miniMotTemp"), buf: hist.motTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.w },
|
||||
};
|
||||
for (const m of Object.values(MINIS)) m.ctx = m.el.getContext("2d");
|
||||
/* ctx 与宿主卡片引用都在初始化时取一次:卡片 DOM 是静态的,
|
||||
* 原来每帧每个 mini 都要 querySelector 一遍(6 个查询 × 30 次/s)。 */
|
||||
const MINI_LIST = Object.entries(MINIS).map(([sig, m]) => {
|
||||
m.ctx = m.el.getContext("2d");
|
||||
m.card = document.querySelector(`[data-signal="${sig}"]`);
|
||||
return m;
|
||||
});
|
||||
|
||||
const MONO = '"Cascadia Code","Fira Code",Consolas,monospace';
|
||||
const fmtTick = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "k" : String(v));
|
||||
@@ -3492,8 +3570,9 @@ const scopeSys = (() => {
|
||||
}
|
||||
|
||||
function drawMini(m) {
|
||||
if (!fit(m.el)) return;
|
||||
const w = m.el.width / dprOf(), h = m.el.height / dprOf();
|
||||
const s = fit(m.el, m.ctx);
|
||||
if (!s) return;
|
||||
const w = s.w, h = s.h;
|
||||
/* auto 模式:每帧根据 buf 实际数据重算 Y 轴范围 + ticks */
|
||||
if (m.auto) {
|
||||
const sc = autoScale(m.buf);
|
||||
@@ -3546,13 +3625,17 @@ const scopeSys = (() => {
|
||||
}
|
||||
|
||||
let lastSample = 0;
|
||||
/* 只有写入了新样本才重绘:采样节流在 33ms(≈30Hz),而 rAF 跟着显示器跑到
|
||||
* 160Hz —— 中间那 ~130 帧画的是同一份数据,像素一模一样,纯属白烧 GPU 进程。 */
|
||||
let dirty = true;
|
||||
function loop(ts) {
|
||||
/* 采样节流到 ~30Hz,避免 1Hz 数据下过度密集 */
|
||||
if (ts - lastSample > 33) { sample(); lastSample = ts; }
|
||||
drawBig();
|
||||
for (const [sig, m] of Object.entries(MINIS)) {
|
||||
const card = document.querySelector(`[data-signal="${sig}"]`);
|
||||
if (card && card.classList.contains("open")) drawMini(m);
|
||||
if (ts - lastSample > 33) { sample(); lastSample = ts; dirty = true; }
|
||||
if (dirty) {
|
||||
dirty = false;
|
||||
drawBig();
|
||||
for (const m of MINI_LIST) {
|
||||
if (m.card && m.card.classList.contains("open")) drawMini(m);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user