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);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
*
|
||||
* 为什么两条链路不能共用一个值:往返代价差一个数量级。
|
||||
* wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐;
|
||||
* ecat 50ms(下限 1ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
|
||||
* ecat 50ms(下限 6ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
|
||||
* PDO 总线周期由后台循环独立维持(默认 4ms,喂 1000ms 看门狗),
|
||||
* 界面读得快只是显示更跟手,不给总线加负担。
|
||||
* 1ms 的语义是「不限速」而非 1000Hz,见 UNTHROTTLED_MAX_MS。
|
||||
* 6ms 下限的来由:实测 1ms 档能跑到 ~550Hz,而画面绘制上限由显示器
|
||||
* 刷新率封顶(本机 160Hz),160Hz 以上那部分轮询没有消费者,
|
||||
* 纯粹是 HTTP 过路费(每拍 ≈1.6KB 快照,renderer / 网络服务 /
|
||||
* browser / Python 代理各被点一次)。填 6 的语义仍是「不限速」,
|
||||
* 见 UNTHROTTLED_MAX_MS。
|
||||
* 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
|
||||
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
|
||||
*/
|
||||
@@ -22,14 +26,15 @@ export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 };
|
||||
* 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同:
|
||||
* wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求
|
||||
* 排队积压(tickStuck 亮黄),毫无收益。
|
||||
* ecat 1ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
|
||||
* 注意 1ms **不等于** 1000Hz:单飞语义下上一拍完成才发下一拍,
|
||||
* ecat 6ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
|
||||
* 注意 6ms **不等于** 166Hz:单飞语义下上一拍完成才发下一拍,
|
||||
* 而每拍要过一次 HTTP(代理 → ecat_native → 回来),实测均值
|
||||
* ≈ 1.4ms,浏览器里 1ms 档实测约 125Hz。填 1 的语义是
|
||||
* ≈ 1.8ms,所以 6ms 档实测约 160Hz —— 正好贴着本机 160Hz 屏的
|
||||
* 绘制上限,再快只是多收一份 HTTP 过路费。填 6 的语义仍是
|
||||
* "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示,
|
||||
* 见 UNTHROTTLED_MAX_MS。
|
||||
*/
|
||||
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 1 };
|
||||
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 6 };
|
||||
/** 兜底最小值:未知链路 id 时用(防御,正常不该出现)。 */
|
||||
export const REFRESH_MIN_MS = 50;
|
||||
export const REFRESH_MAX_MS = 5000;
|
||||
|
||||
@@ -32,21 +32,22 @@
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致) */
|
||||
const MOT_SLOTS_PER_PDO = 7;
|
||||
/** 单个 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) */
|
||||
const MOTOR_SLOT_COUNT = 27;
|
||||
/** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/7⌉ = 4)。
|
||||
/** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/5⌉ = 6)。
|
||||
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 ——
|
||||
* 后端会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机
|
||||
* 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */
|
||||
const MOTOR_SCAN_CYCLES = 4;
|
||||
const MOTOR_SCAN_CYCLES = 6;
|
||||
|
||||
/**
|
||||
* 槽 seq 停止增长多久算离线(ms)。
|
||||
* 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
|
||||
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则
|
||||
* 慢周期下会把正常电机误判离线:period 10ms × 4 周期 = 40ms,取 2s 有充足余量。
|
||||
* 慢周期下会把正常电机误判离线:period 10ms × 6 周期 = 60ms,取 2s 有充足余量。
|
||||
*/
|
||||
const STALE_OFFLINE_MS = 2000;
|
||||
|
||||
@@ -86,10 +87,10 @@ function fmtPeriod(ms) {
|
||||
export function createEcatTransport(ctx) {
|
||||
const { recordSlow, isTransient } = ctx;
|
||||
|
||||
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I226);
|
||||
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I210,2026-09 网卡 I226→I210);
|
||||
* 存 localStorage 以便换网卡后不用每次重设。 */
|
||||
const LS_ADAPTER = "dcu_ecat_adapter";
|
||||
let adapterSubstr = "I226";
|
||||
let adapterSubstr = "I210";
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_ADAPTER);
|
||||
if (saved && saved.trim()) adapterSubstr = saved.trim();
|
||||
@@ -193,7 +194,7 @@ export function createEcatTransport(ctx) {
|
||||
async function ensureOpen() {
|
||||
const st = await api("status", {}, 6000);
|
||||
if (st && st.ok === true) return st; // 已连接
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I226);
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I210);
|
||||
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
|
||||
const r = await api("open", { substr: adapterSubstr }, 20000);
|
||||
if (!r || r.ok !== true) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 从 motor/main.js 里原地抽出的**原有**链路,行为未改:
|
||||
* GET /system/status 探测 + 健康检查(lite/full 交替)
|
||||
* GET /motor/state?bin=1 电机状态(二进制,~408B)
|
||||
* GET /motor/state?bin=1 电机状态(二进制,27 槽满 ~2508B)
|
||||
* GET /can/feedback?lite=1 CAN 计数/总线状态
|
||||
* POST /can/send_batch 发送(唯一入口)
|
||||
* POST /can/send_burst 突发同时发送(多轴)
|
||||
@@ -52,6 +52,10 @@ export function createWifiTransport(ctx) {
|
||||
}
|
||||
|
||||
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
|
||||
* 双版本兼容(设备固件与前端升级节奏不一定同步):
|
||||
* ver=1 v0.70:entry 76,data 32(CONFIG 在 44/48/52)
|
||||
* ver=2 v0.74:entry 92,data 48(字节 32~35 = InitStatus,CONFIG 后移到 60/64/68)
|
||||
* data 32B 时 decodeStatus 高位补 0 → initStatus=0,上层无需感知版本。
|
||||
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]}。
|
||||
* data / config_data 保持 hex 字符串,让上层 hexToBytes 直接解析。 */
|
||||
function parseMotorStateBinary(ab) {
|
||||
@@ -59,7 +63,12 @@ export function createWifiTransport(ctx) {
|
||||
if (!ab || ab.byteLength < 12) return out;
|
||||
const dv = new DataView(ab);
|
||||
if (dv.getUint16(0, true) !== 0x4D53) return out; /* 'MS' */
|
||||
if (dv.getUint8(2) !== 1) return out; /* ver */
|
||||
const ver = dv.getUint8(2);
|
||||
if (ver !== 1 && ver !== 2) return out;
|
||||
const dataLen = (ver === 2) ? 48 : 32;
|
||||
const entry = 12 + dataLen + 8 + 24; /* 92 / 76(4+4+24 = config 段) */
|
||||
const cfgCntOff = 12 + dataLen; /* config_rx_count 相对 entry 起点 */
|
||||
const cfgSeqOff = cfgCntOff + 4;
|
||||
const flags = dv.getUint8(3);
|
||||
const age = dv.getUint32(4, true);
|
||||
const active = dv.getUint8(8);
|
||||
@@ -77,7 +86,7 @@ export function createWifiTransport(ctx) {
|
||||
for (let i = 0; i < len; i++) s += u8[start + i].toString(16).padStart(2, "0").toUpperCase();
|
||||
return s;
|
||||
};
|
||||
for (let i = 0; i < count && off + 76 <= ab.byteLength; i++) {
|
||||
for (let i = 0; i < count && off + entry <= ab.byteLength; i++) {
|
||||
out.motors.push({
|
||||
node: u8[off],
|
||||
ch: u8[off + 1],
|
||||
@@ -85,12 +94,12 @@ export function createWifiTransport(ctx) {
|
||||
dlc: u8[off + 3],
|
||||
rx_count: dv.getUint32(off + 4, true),
|
||||
seq: dv.getUint32(off + 8, true),
|
||||
data: hexOf(off + 12, 32),
|
||||
config_rx_count: dv.getUint32(off + 44, true),
|
||||
config_seq: dv.getUint32(off + 48, true),
|
||||
config_data: hexOf(off + 52, 24),
|
||||
data: hexOf(off + 12, dataLen),
|
||||
config_rx_count: dv.getUint32(off + cfgCntOff, true),
|
||||
config_seq: dv.getUint32(off + cfgSeqOff, true),
|
||||
config_data: hexOf(off + cfgSeqOff + 4, 24),
|
||||
});
|
||||
off += 76;
|
||||
off += entry;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+45
-4
@@ -199,8 +199,15 @@ export async function resumePersistedFirmwareWithGesture(chip) {
|
||||
return false;
|
||||
}
|
||||
var file = await handle.getFile();
|
||||
await applyRestoredFile(chip, handle, file, { silent: true });
|
||||
var accepted = await applyRestoredFile(chip, handle, file, { silent: true });
|
||||
window._pendingFwHandles[chip] = null;
|
||||
if (!accepted) {
|
||||
/* 文件内容没过 GD32 身份校验(applyRestoredFile 已清句柄/共享槽/IDB):
|
||||
* 不得报成功,返回 false 让 pickFileViaFSAPI 继续弹文件选择框,
|
||||
* 否则用户点了上传区只会反复恢复这个非法文件,永远选不了新文件 */
|
||||
addLog('W', '文件', chip.toUpperCase() + ' 上次固件未通过校验,已放弃恢复,请重新选择文件');
|
||||
return false;
|
||||
}
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
|
||||
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
|
||||
return true;
|
||||
@@ -221,13 +228,28 @@ function applyRestoredFile(chip, handle, file, options) {
|
||||
if (window.sharedFirmwareFiles) {
|
||||
window.sharedFirmwareFiles[chip] = { file: file, handle: handle };
|
||||
}
|
||||
/* 等 SHA 写入对应槽后再 sync BLE,避免二次重算 */
|
||||
/* 等 SHA 写入对应槽后再 sync BLE,避免二次重算。
|
||||
* GD32 文件可能被 processFile 身份校验拒绝(rejectGd32Identity 清掉 gd32File 但不抛异常):
|
||||
* 此时恢复必须视为失败——清干净窗口内句柄/共享槽/IDB 持久化,
|
||||
* 否则句柄会一直复活这个非法文件(sync 会从共享槽把它重新绑进 BLE 会话),
|
||||
* 且调用方误报"恢复成功"挡住文件选择框。 */
|
||||
return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})))
|
||||
.then(function () {
|
||||
var accepted = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
|
||||
if (!accepted) {
|
||||
if (chip === 'esp32') window.esp32FileHandle = null;
|
||||
else window.gd32FileHandle = null;
|
||||
window._pendingFwHandles[chip] = null;
|
||||
if (window.sharedFirmwareFiles) {
|
||||
window.sharedFirmwareFiles[chip] = { file: null, handle: null };
|
||||
}
|
||||
persistFileHandle(chip, null);
|
||||
}
|
||||
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
|
||||
window.syncBleFileFromSelectedTarget();
|
||||
}
|
||||
safeUpdateUploadButtonLabel();
|
||||
return accepted;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,9 +297,14 @@ export async function restorePersistedFirmwareSelection() {
|
||||
var perm = await queryHandlePermission(handle);
|
||||
if (perm === 'granted') {
|
||||
var file = await handle.getFile();
|
||||
await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
|
||||
var accepted = await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
|
||||
window._pendingFwHandles[chip] = null;
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
|
||||
if (accepted) {
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
|
||||
} else {
|
||||
addLog('W', '文件', chip.toUpperCase() + ' 上次固件「' + file.name +
|
||||
'」未通过 GD32 校验,已拒绝恢复,请重新选择文件');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
/* prompt/denied:不在此处 requestPermission(无用户手势会失败) */
|
||||
@@ -371,6 +398,16 @@ export async function pickFileViaFSAPI(chip) {
|
||||
else window.gd32FileHandle = handle;
|
||||
window._pendingFwHandles[chip] = null;
|
||||
await processFile(file, chip);
|
||||
/* GD32 身份校验拒绝时 processFile 只清文件不抛异常:
|
||||
* 此时不能持久化句柄/元数据(否则刷新后 resume 会一直复活这个非法文件、
|
||||
* 且 resume 返回 true 会挡住文件选择框),窗口内句柄也要清掉避免热重载读到它 */
|
||||
var accepted = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
|
||||
if (!accepted) {
|
||||
if (chip === 'esp32') window.esp32FileHandle = null;
|
||||
else window.gd32FileHandle = null;
|
||||
persistFileHandle(chip, null);
|
||||
return false;
|
||||
}
|
||||
persistFileHandle(chip, handle);
|
||||
persistFileMeta(chip, file);
|
||||
persistFlashTarget(chip);
|
||||
@@ -560,6 +597,9 @@ function rejectGd32Identity(fileName, kind) {
|
||||
showToast(msg, 'error');
|
||||
window.gd32File = null;
|
||||
window._gd32FileSha256 = null;
|
||||
/* 元数据一并清掉:processFile 入口已把本次(被拒)文件写进 meta,
|
||||
* 不清的话下次刷新会拿这个非法文件名当"上次固件"展示 */
|
||||
persistFileMeta('gd32', null);
|
||||
var info = document.getElementById('fileInfoGd32');
|
||||
if (info) info.classList.remove('show');
|
||||
safeUpdateUploadButtonLabel();
|
||||
@@ -674,6 +714,7 @@ export function processFile(file, chip, options) {
|
||||
if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error');
|
||||
window.gd32File = null;
|
||||
window._gd32FileSha256 = null;
|
||||
persistFileMeta(chip, null);
|
||||
var info = document.getElementById('fileInfo' + suffix);
|
||||
if (info) info.classList.remove('show');
|
||||
safeUpdateUploadButtonLabel();
|
||||
|
||||
Reference in New Issue
Block a user