feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本

变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
This commit is contained in:
cbb
2026-09-20 22:58:00 +08:00
parent e91d53e0fc
commit 383535bd1d
23 changed files with 2060 additions and 283 deletions
+103 -3
View File
@@ -11,6 +11,9 @@
* 0x04/0x02 改基址、段间空洞填 0xFF、逐行校验和),并用 checkGd32ImageFile 拦掉
* 「引导程序包 / 向量表非法」。所以这里只读扁平字节,不再抄一份 HEX 解析器 ——
* 解析器抄两份一定会漂,而灌错包的后果是"刷成功但永远跳不起来"。
* ⚠ 但刷前必须按 FSAPI 句柄重读磁盘(readFreshImage):本地重编会覆盖同路径的 .hex,
* 页面里的 window.gd32File 还是"选文件那一刻"的旧内容,不复读就会把旧包灌进 A 区,
* 表现为「控制台全绿、板子版本/Build 不变」。与 upload-esp32/BLE/电机 OTA 同一条规矩。
*
* 为什么点「直灌」时才 open+push,而不是选完文件就推:
* native 的镜像缓冲跨请求存活(op=abort 才丢)。选文件时推 = 服务端可能留着上一轮
@@ -160,8 +163,18 @@ function setRoleBadge(role, sw) {
if (swEl) swEl.textContent = sw || (r === 'unknown' ? '—' : '版本未知');
}
/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。 */
/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。
* 优先 FSAPI 选择器(与共享模式同一形状):它留下句柄并持久化,本卡片刷前才能
* "热重载"按句柄重读磁盘;传统 <input> 选完即无句柄,重编后会退化成中止+提示。 */
export function bootEcatPick() {
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
Promise.resolve(window.pickFileViaFSAPI('gd32')).catch(function (e) {
if (e && e.name !== 'AbortError') {
window.addLog('W', 'ECAT刷写', 'FSAPI 选择失败: ' + (e.message || e) + ',可改用上传区重新选择');
}
});
return;
}
var input = el('fileInputGd32');
if (!input) {
window.showToast('找不到 GD32 文件选择控件', 'error');
@@ -232,7 +245,10 @@ function setEcatPct(p, msg) {
var fill = el('progressFill');
var pct = el('progressPct');
var txt = el('progressTxt');
if (fill) fill.style.width = n + '%';
if (fill) {
fill.style.width = n + '%';
fill.classList.toggle('live', n > 0 && n < 100);
}
if (pct) pct.textContent = n + '%';
if (txt && msg != null && msg !== '') txt.textContent = msg;
}
@@ -379,6 +395,63 @@ function clearArm() {
if (b) { b.textContent = 'EtherCAT 刷写 APP'; b.className = 'btn btn-block'; }
}
/**
* @description 刷前从磁盘重读固件(与 WiFi/BLE/电机 OTA 同一条规矩)。
* 本地重编会覆盖同路径的 .hex,而 window.gd32File 只是"选文件那一刻"的 File 对象,
* 之后磁盘变了它不会跟着变 —— 直接拿它刷,灌进去的是上次那包,表现为
* 「控制台全绿、板子版本/Build 不变」(2026-09-18 现场)。
* @return {Promise<Uint8Array|null>} null = 拿不到可信镜像,调用方必须中止
* (此刻总线未动、A 区未擦,中止零代价)。
*/
async function readFreshImage() {
var handle = window.gd32FileHandle;
if (!handle) {
/* 拖拽/传统文件框选的:没有句柄就无从确认磁盘上现在是什么,而本路径
* begin 会先整区擦 A 区 —— 比 WiFi 那条(警告后继续)严格一档,直接收手。 */
window.addLog('E', 'ECAT刷写',
'无法确认镜像是否为最新:当前文件未经文件系统句柄选择(拖拽/传统文件框)。' +
'请点上面「GD32 固件」那张卡的上传区重新选择后再刷 —— ' +
'本路径会先擦 A 区,拿旧包灌写等于白刷');
window.showToast('请重新选择 GD32 固件后再刷', 'error');
return null;
}
var fresh;
try {
fresh = await handle.getFile();
} catch (e) {
window.addLog('E', 'ECAT刷写',
'重新读取固件失败:' + (e && e.message ? e.message : e) +
'(磁盘上那个产物被移动/删除?)—— 已收手,一个字节都没写');
window.showToast('固件重新读取失败,请重新选择', 'error');
return null;
}
try {
/* 统一走 processFile:.hex 按最新源文件重转 BIN,.bin 也过同一道身份校验
* (checkGd32ImageFile 挡「引导程序包 / 向量表非法」),结果写回 window.gd32File。
* 与 upload-esp32 的热重载分支同款。 */
if (typeof window.processFile !== 'function') {
throw new Error('processFile 不可用');
}
await window.processFile(fresh, 'gd32', { silent: true, skipHandlePersist: true });
} catch (e) {
window.addLog('E', 'ECAT刷写', '重读的镜像解析失败:' + (e && e.message ? e.message : e));
window.showToast('固件解析失败,请重新选择', 'error');
return null;
}
var file = window.gd32File;
if (!file) {
window.addLog('E', 'ECAT刷写',
'重读的镜像未通过 GD32 身份校验(引导程序包/向量表非法)—— 已收手,一个字节都没写');
return null;
}
var mtime = file.lastModified
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
: '未知时间';
window.addLog('I', 'ECAT刷写',
'镜像热重载: ' + file.name + '(' + bytes(file.size) + ',磁盘 mtime ' + mtime + ')');
return new Uint8Array(await file.arrayBuffer());
}
/**
* @description 七步一次点完:读镜像 → 开总线并把镜像收进主站 → 交接进 BOOT
* → 回读身份 → 邮箱灌写(run) → 从站复位 → 回读确认。
@@ -407,6 +480,11 @@ export async function bootEcatFlash() {
clearArm();
window.bootEcatBusy = true;
/* 置跨页"刷写中"标记(读侧见 common/utils.js flashBusyNow):交接与 BOOT↔APP
* 期间 GD32 刚复位,ESP32 的 CAN 反馈查询(0xC1)必被链路层拒,问了也是白问、
* 还会在日志里刷屏 —— CAN/电机页的 1Hz 轮询据此避让。
* 带到期时间(10min,覆盖最坏灌写 + probe 自愈),页面异常也不会把标记挂死。 */
try { localStorage.setItem('dcu_flash_busy_until', String(Date.now() + 10 * 60 * 1000)); } catch (_) {}
refreshBootEcatUi();
var runBtn = el('bootEcatRunBtn');
if (runBtn) { runBtn.disabled = true; runBtn.textContent = '刷写中…'; }
@@ -417,7 +495,15 @@ export async function bootEcatFlash() {
setEcatStage(1, 'live', '读取中');
setEcatStatus('active', '读取镜像…');
try {
var u8 = new Uint8Array(await file.arrayBuffer());
/* 步骤 1:按句柄从磁盘重读镜像 —— 不能信页面缓存(见 readFreshImage 注) */
var u8 = await readFreshImage();
if (!u8 || u8.length === 0) {
setEcatStage(1, 'err', '未取得镜像');
setEcatStatus('err', '镜像没就绪');
setEcatPct(0, '没拿到可信镜像 —— 一个字节都没写(见日志)');
return;
}
file = window.gd32File; /* readFreshImage 已把重读结果写回共享槽 */
setEcatStage(1, 'done', bytes(u8.length));
setEcatStage(2, 'live', '开总线');
setEcatStatus('active', '打开主站总线…');
@@ -429,6 +515,10 @@ export async function bootEcatFlash() {
return;
}
setEcatStage(2, 'live', 'open');
/* 刷前身份读数(读数,不是门):记下 0x1008/0x100A,末尾与刷后比对 ——
* 「刷完版本没变」要能被控制台自证,而不是只看流程全绿。
* 此拍在交接之前,BOOT 那 10s 窗口还没开始计时。 */
var pre = await bootEcatProbe(false);
setEcatStatus('active', '推送到主站…');
setEcatPct(8, '打开主站缓冲…');
var o = await api({ op: 'open', size: u8.length });
@@ -579,6 +669,14 @@ export async function bootEcatFlash() {
setEcatStage(7, 'done', '完成');
setEcatStatus('ok', '刷写完成');
setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—'));
/* 刷前刷后版本一致 = 灌进去的多半还是原来那包(页面缓存旧文件/选错包),
* 流程全绿说明不了"换了版本" —— 补一条可观察告警(只提示,不拦:同版本重刷合法)。 */
if (pre && pre.sw && after.sw && after.sw === pre.sw) {
window.addLog('W', 'ECAT刷写',
'0x100A 刷前刷后都是 ' + after.sw + ' —— 若这次是换版本/改代码,' +
'确认刷的是最新镜像(本页刷前已按句柄重读磁盘;仍相同则应查构建产物是否真被重编)');
window.showToast('刷前刷后版本号相同,请确认刷的是最新固件', 'warning');
}
} else if (okRun) {
/* commit 成功只证明 A 区写进去了;邮箱读不到 APP 名 = EtherCAT 没恢复,
* 对用户就是「不能用」。这里禁止画成半成功:进度钉在提交完成那一档,
@@ -612,6 +710,8 @@ export async function bootEcatFlash() {
window.addLog('E', 'ECAT刷写', '刷写请求异常:' + e);
} finally {
window.bootEcatBusy = false;
/* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */
try { localStorage.removeItem('dcu_flash_busy_until'); } catch (_) {}
if (runBtn) {
runBtn.textContent = 'EtherCAT 刷写 APP';
runBtn.className = 'btn btn-block';