chore: 解决 stash pop 与 device-link 合并冲突并收回本地 WIP

保留 ConnectionHub(deviceLink 导入/ECAT 自启/pmHold 幂等)与主线
WIP(断线清指标、AUTO_POLL=false、boot_ecat 等)。
This commit is contained in:
cbb
2026-09-16 12:31:39 +08:00
parent 0c772f5924
commit e98950cde2
12 changed files with 1026 additions and 248 deletions
+14 -1
View File
@@ -445,6 +445,11 @@
border-color: var(--red); color: #fff;
box-shadow: 0 0 12px rgba(255,107,107,.4);
}
.stage-node.warn .stage-dot {
background: linear-gradient(135deg, #e6a23c, #cf9236);
border-color: #e6a23c; color: #1a1203;
box-shadow: 0 0 12px rgba(230,162,60,.4);
}
.stage-label {
font-family: var(--font-display);
font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3;
@@ -457,10 +462,12 @@
}
.stage-node.live .stage-label,
.stage-node.done .stage-label,
.stage-node.err .stage-label { color: var(--text); }
.stage-node.err .stage-label,
.stage-node.warn .stage-label { color: var(--text); }
.stage-node.live .stage-detail { color: var(--esp32-hi); }
.stage-node.done .stage-detail { color: var(--green); }
.stage-node.err .stage-detail { color: var(--red); }
.stage-node.warn .stage-detail { color: #e6a23c; }
/* ---- 阶段状态条 ---- */
.stage-status-bar {
@@ -501,6 +508,12 @@
}
.stage-status-bar.err .ssb-dot { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.4); animation: none; }
.stage-status-bar.err .ssb-text { color: var(--red); }
.stage-status-bar.warn {
border-color: rgba(230,162,60,.35);
background: linear-gradient(135deg, rgba(230,162,60,.09), rgba(230,162,60,.02));
}
.stage-status-bar.warn .ssb-dot { background: #e6a23c; box-shadow: 0 0 8px rgba(230,162,60,.4); animation: none; }
.stage-status-bar.warn .ssb-text { color: #e6a23c; }
/* ---- 校验信息面板 ---- */
.stage-verify-info {
+125 -2
View File
@@ -249,6 +249,69 @@ select option { background: var(--bg-panel); color: var(--text); }
color: #04142b;
box-shadow: 0 4px 16px rgba(95,168,255,.3), inset 0 1px 0 rgba(255,255,255,.25);
}
#modeEcatBtn.btn-primary {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
color: #0f0a1f;
box-shadow: 0 4px 16px rgba(139,92,246,.3), inset 0 1px 0 rgba(255,255,255,.25);
}
#modeWifiBtn.btn-outline {
border-color: rgba(47,216,193,.45);
color: var(--esp32);
}
#modeBleBtn.btn-outline {
border-color: rgba(95,168,255,.45);
color: var(--ble);
}
#modeEcatBtn.btn-outline {
border-color: rgba(139,92,246,.45);
color: #a78bfa;
}
#modeWifiBtn.btn-outline:hover:not(:disabled) {
border-color: var(--esp32);
background: rgba(47,216,193,.08);
color: var(--esp32);
}
#modeBleBtn.btn-outline:hover:not(:disabled) {
border-color: var(--ble);
background: rgba(95,168,255,.08);
color: var(--ble);
}
#modeEcatBtn.btn-outline:hover:not(:disabled) {
border-color: #8b5cf6;
background: rgba(139,92,246,.08);
color: #a78bfa;
}
/* 升级目标:左右两个等宽扁按钮(GD32 琥珀 · ESP32 青绿) */
.target-tab-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.target-tab-btn {
flex: 1 1 0;
min-width: 0;
min-height: 40px;
padding: 10px 14px;
font-size: 14px;
font-weight: 700;
letter-spacing: .04em;
flex-direction: row;
gap: 8px;
border-radius: var(--radius-sm);
}
.target-tab-btn .target-tab-ico {
font-size: 15px;
line-height: 1;
opacity: .9;
}
.target-tab-btn .target-tab-txt {
line-height: 1.1;
}
.target-tab-btn.btn-outline {
border-width: 1.5px;
background: rgba(255,255,255,.03);
}
#targetGd32Tab.btn-primary {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02;
@@ -257,6 +320,58 @@ select option { background: var(--bg-panel); color: var(--text); }
#targetEsp32Tab.btn-primary {
background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
color: #04231e;
box-shadow: 0 4px 16px rgba(47,216,193,.28), inset 0 1px 0 rgba(255,255,255,.25);
}
/* 未选中时用各自子系统的淡描边,避免两个灰按钮看不出身份 */
#targetGd32Tab.btn-outline {
border-color: rgba(252,191,63,.45);
color: var(--gd32);
}
#targetEsp32Tab.btn-outline {
border-color: rgba(47,216,193,.45);
color: var(--esp32);
}
#targetGd32Tab.btn-outline:hover:not(:disabled) {
border-color: var(--gd32);
background: rgba(252,191,63,.08);
color: var(--gd32);
}
#targetEsp32Tab.btn-outline:hover:not(:disabled) {
border-color: var(--esp32);
background: rgba(47,216,193,.08);
color: var(--esp32);
}
#ppEcatOta.active {
color: #2b1d02;
background: linear-gradient(135deg, var(--gd32), #f59e0b);
box-shadow: 0 2px 12px rgba(252,191,63,.35);
}
/* EtherCAT 连接说明 + 探测:与卡片内其他控件同一套字重/字距 */
.ecat-mode-note {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
background: rgba(252,191,63,.06);
font-size: 12px;
line-height: 1.7;
color: var(--text-dim);
}
.ecat-mode-note b {
color: var(--gd32);
font-weight: 600;
}
#bootEcatProbeBtn {
margin-bottom: 10px;
border-color: rgba(252,191,63,.4);
color: var(--gd32);
background: rgba(252,191,63,.05);
}
#bootEcatProbeBtn:hover:not(:disabled) {
border-color: var(--gd32);
background: rgba(252,191,63,.12);
color: var(--gd32-hi);
}
/* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */
#uploadGd32Btn {
@@ -264,6 +379,14 @@ select option { background: var(--bg-panel); color: var(--text); }
color: #2b1d02;
box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
}
#bootEcatRunBtn {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02;
box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
}
#bootEcatRunBtn:disabled {
opacity: .38;
}
#uploadEsp32Btn {
box-shadow: 0 6px 20px rgba(47,216,193,.32), inset 0 1px 0 rgba(255,255,255,.28);
}
@@ -275,10 +398,10 @@ select option { background: var(--bg-panel); color: var(--text); }
background: var(--bg-elevated); border: 1px solid var(--border);
font-size: 13px; font-weight: 500;
}
.conn-indicator .light { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.conn-indicator .light { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; background: var(--text-label); }
.conn-indicator.ok .light { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
.conn-indicator.err .light { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
.conn-indicator.warn .light { background: var(--amber); }
.conn-indicator.warn .light { background: var(--amber); box-shadow: 0 0 8px rgba(252,191,63,.45); }
.helper-note {
margin-top: 10px; padding: 10px 13px;
+10
View File
@@ -671,6 +671,16 @@ class Handler(SimpleHTTPRequestHandler):
timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
"/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0
# EtherCAT 直灌 BOOT: run 要走 519 个邮箱帧 + 整区预擦, 几十秒量级。
# 默认 6s 会在"正在正常刷写"的过程中假失败 —— 浏览器报错离场, native
# 却还在往 flash 里写, 比不刷更糟。同一路由下的 open/push 都是纯内存
# 毫秒级, 不连它们一起放大(后端没起时探测会干等几分钟), 故按 body 区分。
# probe/reboot 走 boot_dl_probe 的两级邮箱自愈: 最坏 2×2s statecheck +
# 重枚举 + 三次 700ms SDO 读 ≈ 7s+。这里必须给足, 因为 _ecat_proxy 超时
# 后会拉起后端并**把同一个 POST 重发一次**(见其尾部重试), 带副作用的请求
# 假超时 = 让后端把写过的命令再写一遍。
if path.endswith("/ota/ecat"):
timeout = 300.0 if b'"run"' in raw else 60.0
try:
status, body = _ecat_proxy(path, raw, timeout)
_note_ecat_loop(path, status, body)
+22 -1
View File
@@ -31,7 +31,7 @@ import {
markPhaseDone, setProgress, resetProgress, esp32PhaseLabels
} from './ota/progress.js';
import {
updateStagesFromRelayState, updateSystemStatusUi
updateStagesFromRelayState, updateSystemStatusUi, clearLiveMetricsOnDisconnect
} from './ota/stages.js';
import { startEcatLoadPoller } from './ota/ecat_load.js';
import { deviceLink } from './common/device-link.js';
@@ -45,6 +45,9 @@ import {
triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch,
startRelayProgressWatch, emitRelayProgress, ensureRelayProgressComplete
} from './ota/upload-gd32.js';
import {
bootEcatProbe, bootEcatFlash, bootEcatAbort, bootEcatPick, bootEcatReboot, refreshBootEcatUi
} from './ota/boot_ecat.js';
// ==================== 工具函数 ====================
function formatPercent(value) {
@@ -73,6 +76,9 @@ function updateUploadButtonLabel() {
var xhrBusy = !!window.isUploading;
if (esp32Btn) esp32Btn.disabled = !window.esp32File || xhrBusy;
if (gd32Btn) gd32Btn.disabled = !window.gd32File || xhrBusy;
/* EtherCAT 直灌那条通道共用同一块 flash 与同一份已选镜像,可用性在这里一次算完,
* 不在 file-handle / progress / console-mode 各抄一遍条件。 */
refreshBootEcatUi();
}
function resetStageFlow() {
['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) {
@@ -91,6 +97,8 @@ function resetStageFlow() {
function applyDisconnectedRuntimeState() {
document.getElementById('pillIP').textContent = '--';
document.getElementById('pillRSSI').textContent = '--';
// 断线约 1s(本轮轮询失败)即清实时指标,防止残留旧温度/电压误导
clearLiveMetricsOnDisconnect();
// ★ 状态机守卫:会话活跃时不重置进度/阶段,避免覆盖进行中的 OTA 进度
if (otaSessionActive()) return;
// FAILED 态:保留失败进度显示,只清连接胶囊(让用户看到失败原因)
@@ -158,6 +166,7 @@ window.resetProgress = resetProgress;
window.updateStagesFromRelayState = updateStagesFromRelayState;
window.updateSystemStatusUi = updateSystemStatusUi;
window.clearLiveMetricsOnDisconnect = clearLiveMetricsOnDisconnect;
window.handleFileSelect = handleFileSelect;
window.pickFileViaFSAPI = pickFileViaFSAPI;
@@ -179,6 +188,13 @@ window.startRelayProgressWatch = startRelayProgressWatch;
window.emitRelayProgress = emitRelayProgress;
window.ensureRelayProgressComplete = ensureRelayProgressComplete;
window.bootEcatProbe = bootEcatProbe;
window.bootEcatFlash = bootEcatFlash;
window.bootEcatAbort = bootEcatAbort;
window.bootEcatPick = bootEcatPick;
window.bootEcatReboot = bootEcatReboot;
window.refreshBootEcatUi = refreshBootEcatUi;
// ==================== 挂载 common/* 函数到 window(供 polling.js 等通过 window.* 调用) ====================
window.formatBytes = formatBytes;
window.formatTime = formatTime;
@@ -231,6 +247,11 @@ document.addEventListener('click', function (ev) {
case 'ble-pick-file': var fi = document.getElementById('firmwareFile'); if (fi) fi.click(); break;
case 'ble-clear-file': if (window.bleOta) window.bleOta.clearFile(); break;
case 'start-upload': startUpload(arg); break;
case 'boot-ecat-probe': bootEcatProbe(true); break;
case 'boot-ecat-pick': bootEcatPick(); break;
case 'boot-ecat-run': bootEcatFlash(); break;
case 'boot-ecat-abort': bootEcatAbort(); break;
case 'boot-ecat-reboot': bootEcatReboot(); break;
case 'restart-esp32': restartEsp32(); break;
case 'refresh-system-status': refreshSystemStatusNow(ev); break;
}
+5 -1
View File
@@ -286,8 +286,12 @@ export async function pollStatus() {
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA 后台运行中),等待回 IDLE', '设备忙');
updateConnectionUi(true, '设备忙');
} else {
// 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留
// 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留;
// 实时指标同样清空,避免残留断线前温度/电压
updateConnectionUi(false, '未连接');
if (typeof window.clearLiveMetricsOnDisconnect === 'function') {
window.clearLiveMetricsOnDisconnect();
}
}
} else if (window.otaSessionState === window.OtaSessionState.UPLOADING) {
window.applyUploadingRuntimeState();
+67 -21
View File
@@ -31,16 +31,32 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
var esp32El = document.getElementById('esp32Stages');
var gd32El = document.getElementById('gd32Stages');
var bleEl = document.getElementById('bleStages');
var ecatEl = document.getElementById('ecatStages');
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
if (ecatEl) ecatEl.style.display = 'none';
if (bleEl) bleEl.style.display = '';
return;
}
if (target === 'ecat') {
window.currentOtaTarget = 'ecat';
var esp32E = document.getElementById('esp32Stages');
var gd32E = document.getElementById('gd32Stages');
var bleE = document.getElementById('bleStages');
var ecatE = document.getElementById('ecatStages');
if (esp32E) esp32E.style.display = 'none';
if (gd32E) gd32E.style.display = 'none';
if (bleE) bleE.style.display = 'none';
if (ecatE) ecatE.style.display = '';
return;
}
if (typeof originalSwitchStageFlow === 'function') {
originalSwitchStageFlow(target);
}
var bleEl = document.getElementById('bleStages');
if (bleEl) bleEl.style.display = 'none';
var bleEl2 = document.getElementById('bleStages');
if (bleEl2) bleEl2.style.display = 'none';
var ecatEl2 = document.getElementById('ecatStages');
if (ecatEl2) ecatEl2.style.display = 'none';
};
window.setConsoleMode = function (mode) {
@@ -48,24 +64,33 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
// notifyFileChange → updateFileInfo → finalizeFileInfo → setProgress(0) 会覆盖
// 进行中的 WiFi/BLE 上传进度(与 selectFlashTarget 同样的守卫策略)。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (window.isUploading || bleBusy || window.bootEcatBusy) {
var active = window.bootEcatBusy ? 'ECAT' : (bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase()));
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换连接模式');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
var isBle = mode === 'ble';
window.connectionMode = isBle ? 'ble' : 'wifi';
var m = (mode === 'ble' || mode === 'ecat') ? mode : 'wifi';
var isBle = m === 'ble';
window.connectionMode = m;
var wifiCards = document.getElementById('wifiModeCards');
var bleCards = document.getElementById('bleModeCards');
var ecatCards = document.getElementById('ecatModeCards');
var wifiBtn = document.getElementById('modeWifiBtn');
var bleBtn = document.getElementById('modeBleBtn');
if (wifiCards) wifiCards.style.display = isBle ? 'none' : '';
if (bleCards) bleCards.style.display = isBle ? '' : 'none';
if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary';
if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline';
var ecatBtn = document.getElementById('modeEcatBtn');
if (wifiCards) wifiCards.style.display = m === 'wifi' ? '' : 'none';
if (bleCards) bleCards.style.display = m === 'ble' ? '' : 'none';
if (ecatCards) ecatCards.style.display = m === 'ecat' ? '' : 'none';
if (wifiBtn) wifiBtn.className = 'btn target-tab-btn ' + (m === 'wifi' ? 'btn-primary' : 'btn-outline');
if (bleBtn) bleBtn.className = 'btn target-tab-btn ' + (m === 'ble' ? 'btn-primary' : 'btn-outline');
if (ecatBtn) ecatBtn.className = 'btn target-tab-btn ' + (m === 'ecat' ? 'btn-primary' : 'btn-outline');
applySharedModeUi();
syncBleFileFromSelectedTarget();
/* 切到 EtherCAT 时自动探测一次,和 WiFi「测试」一样不用手点才知道通没通 */
if (m === 'ecat' && typeof window.bootEcatProbe === 'function') {
Promise.resolve(window.bootEcatProbe(false)).catch(function () { /* 后端未起时忽略 */ });
}
if (isBle) {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
@@ -74,6 +99,15 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
window.bleOta.refreshButtons();
}
} else if (m === 'ecat') {
window.switchStageFlow('ecat');
if (typeof setActivePhase === 'function') setActivePhase('ecatOta');
if (typeof window.resetEcatStages === 'function') window.resetEcatStages();
if (typeof window.setProgress === 'function') window.setProgress(0, '等待刷写...');
var sb = document.getElementById('stageStatusBar');
var st = document.getElementById('stageStatusText');
if (sb) sb.className = 'stage-status-bar';
if (st) st.textContent = '待机';
} else {
window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32'));
}
@@ -83,8 +117,8 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
// ★ 兼容 BLE 模式:BLE 上传用 bleOta.state.uploading,window.isUploading 仅 WiFi 通道使用。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (window.isUploading || bleBusy || window.bootEcatBusy) {
var active = window.bootEcatBusy ? 'ECAT' : (bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase()));
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
@@ -273,31 +307,42 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
};
window.applySharedModeUi = function () {
var isBle = window.connectionMode === 'ble';
var m = (window.connectionMode === 'ble' || window.connectionMode === 'ecat')
? window.connectionMode : 'wifi';
var isBle = m === 'ble';
var isEcat = m === 'ecat';
var isGd32 = window.flashTarget === 'gd32';
var targetEsp32 = document.getElementById('targetEsp32Tab');
var targetGd32 = document.getElementById('targetGd32Tab');
if (targetEsp32) targetEsp32.className = isGd32 ? 'btn btn-outline' : 'btn btn-primary';
if (targetGd32) targetGd32.className = isGd32 ? 'btn btn-primary' : 'btn btn-outline';
if (targetEsp32) targetEsp32.className = 'btn target-tab-btn ' + (isGd32 ? 'btn-outline' : 'btn-primary');
if (targetGd32) targetGd32.className = 'btn target-tab-btn ' + (isGd32 ? 'btn-primary' : 'btn-outline');
/* EtherCAT 只能刷 GD32(引导程序写应用区),目标切换行留着会以为还能选 ESP32 */
var targetRow = document.getElementById('targetTabRow');
if (targetRow) targetRow.style.display = isEcat ? 'none' : '';
var ids = {
wifiEsp32UploadPane: !isGd32,
wifiGd32UploadPane: isGd32,
/* EtherCAT 复用 GD32 上传区(同一 fileInputGd32 / processFile),选文件 UI 与 WiFi/BLE 一致 */
wifiEsp32UploadPane: !isEcat && !isGd32,
wifiGd32UploadPane: isGd32 || isEcat,
bleUploadPane: false,
wifiEsp32ActionPane: !isBle && !isGd32,
wifiGd32ActionPane: !isBle && isGd32,
bleActionPane: isBle
wifiEsp32ActionPane: !isBle && !isEcat && !isGd32,
wifiGd32ActionPane: !isBle && !isEcat && isGd32,
bleActionPane: isBle,
bootEcatPane: isEcat
};
Object.keys(ids).forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.display = ids[id] ? '' : 'none';
});
/* EtherCAT 与 WiFi/BLE 共用同一张进度卡,只是切到 ecatStages + ppEcatOta */
var hint = document.getElementById('readyHint');
var autoBtn = document.getElementById('autoFlashBtn');
if (autoBtn && isBle) {
autoBtn.textContent = isGd32 ? '一键刷写 GD32' : '一键刷写 ESP32';
}
if (hint) {
if (isBle) {
if (isEcat) {
hint.innerHTML = 'EtherCAT 模式:选择 GD32 固件 → 探测对面是谁 → 刷写 APP。';
} else if (isBle) {
hint.innerHTML = isGd32
? 'BLE 模式 · 目标 GD32:连接设备 → 选择 GD32 固件 → 一键刷写。'
: 'BLE 模式 · 目标 ESP32:连接设备 → 选择 ESP32 固件 → 一键刷写。';
@@ -307,6 +352,7 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
}
}
if (typeof window.refreshBootEcatUi === 'function') window.refreshBootEcatUi();
};
applySharedModeUi();
+104 -6
View File
@@ -10,8 +10,19 @@
* 与 /system/status 轮询完全独立:ESP32 离线不影响本显示,反之亦然;
* 失败只静默退避,绝不计入 ESP32 的连接失败计数。
*
* **自动拉起**(2026-09):读到 invalid/过期时,代替用户走完
* 服务→网卡→OP→PDO 循环 四级自愈(与 motor/transport-ecat.js 同一套 API),
* 不必再单独打开电机页选 EtherCAT。避让:
* - ECAT 刷写中(window.bootEcatBusy)或其它 OTA 在跑时**绝不动总线**
* —— open 会 loop_stop、motor/start 会抢 OP,会把正在灌写的会话掐死;
* - 冷却 8s,避免无适配器/无从站时每秒刷 open;
* - 首次成功只打一条日志,之后静默。
* 首页需持有 app 租约(index.html 挂 app-lease.js),否则服务端在
* 租约归零宽限期后会把刚拉起的循环又停掉。
*
* 失效语义(都显示 '--'):
* - valid:false —— ECAT 后端没在跑 / 总线从未打开过 / 固件 < 1.5.61(段 5 恒 0);
* - valid:false —— ECAT 后端没在跑 / 总线从未打开过 / 固件 < 1.5.61(段 5 恒 0)
* / 循环刚拉起、首个 1s 窗口还没滚完(seq 仍 0);
* - age_ms > 3000 —— PDO 循环已停(没有页面持有 app 租约时 ecat_native 会
* 自动停循环退回 INIT),负载值不再滚动,视为过期;
* - 代理 503 —— ecat_native 没在跑。退避轮询(1s→5s→30s),避免不用 EtherCAT
@@ -23,9 +34,12 @@ import { deviceLink } from '../common/device-link.js';
var POLL_MS = 1000; /* 正常轮询节拍(负载窗口 1s 一滚,再快没意义) */
var STALE_MS = 3000; /* 超过 3 个窗口没刷新即视为过期 */
var START_COOLDOWN_MS = 8000; /* 自动 open/start 冷却,防无设备时刷屏 */
var failCount = 0; /* 连续失败/过期次数,驱动退避 */
var timerId = null;
var startAttemptAt = 0; /* 上次尝试自动拉起的时刻 */
var startLogged = false; /* 成功拉起只记一条日志 */
function el() { return document.getElementById('mGd32Load'); }
@@ -41,8 +55,9 @@ function setTitle(node, title) {
}
var TIP_BASE = 'DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口';
var TIP_OFF = TIP_BASE + ';当前不可用:控制台外壳启动时会自动 open+motor/start;'
+ '若仍为 --,检查网线/从站供电,或手动打开电机控制页选 EtherCAT。';
var TIP_OFF = TIP_BASE + ';当前不可用:外壳/本页会自动 open+motor/start;'
+ '若持续为 --,检查网卡/总线供电,或手动打开电机控制页选 EtherCAT。'
+ 'ECAT 刷写期间会暂停自动拉起。';
/** @description 把 /api/app/load 的 JSON 渲染到 #mGd32Load。返回值是否有效(驱动退避)。 */
function render(d) {
@@ -71,6 +86,81 @@ function render(d) {
return true;
}
function ecatPost(path, body, timeoutMs) {
return fetch('/ecat/api/' + path.replace(/^\/+/, ''), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs || 8000),
cache: 'no-store',
}).then(function (r) { return r.json(); });
}
function adapterSubstr() {
try {
var s = localStorage.getItem('dcu_ecat_adapter');
if (s && s.trim()) return s.trim();
} catch (_) {}
return 'I210';
}
/** 刷写/上传进行中:绝不动总线(open=loop_stop,start=抢 OP)。 */
function busBusy() {
return !!window.bootEcatBusy || !!window.isUploading
|| !!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading());
}
/**
* 确保网卡已开 + OP + 后台 PDO 循环在跑。
* 与 motor/transport-ecat.js 的 ensureOpen/ensureStarted 同一套 API,
* 只做布尔成败,不引入 transient/慢请求表(本页没有那套基础设施)。
*/
async function ensurePdoCycle() {
var st = null;
try {
st = await ecatPost('status', {}, 6000);
} catch (_) {
/* 后端未起:下面 open 会触发代理 _ecat_autostart */
}
if (!st || st.ok !== true) {
var o = await ecatPost('open', { substr: adapterSubstr() }, 25000);
if (!o || o.ok !== true) return false;
}
/* 周期/自旋与电机页共用 localStorage,用户调过的档位不被本页覆盖。 */
var body = {};
try {
var v = parseFloat(localStorage.getItem('dcu_ecat_period_ms'));
if (v > 0) body.period_ms = v;
var sp = localStorage.getItem('dcu_ecat_spin');
if (sp === '0' || sp === '1') body.spin = (sp === '1');
} catch (_) {}
/* motor/start 内部未进 OP 时自己走 PREOP→SAFEOP→OP,最坏约 24s */
var sr = await ecatPost('motor/start', body, 30000);
return !!(sr && sr.ok === true);
}
/** 读数无效时尝试自动拉起。冷却 + 刷写避让。返回是否本次调用成功启动。 */
async function tryAutoStart() {
if (busBusy()) return false;
var now = Date.now();
if (now - startAttemptAt < START_COOLDOWN_MS) return false;
startAttemptAt = now;
try {
var ok = await ensurePdoCycle();
if (ok) {
if (!startLogged) {
startLogged = true;
if (window.addLog) {
window.addLog('I', 'ECAT',
'已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)');
}
}
return true;
}
} catch (_) { /* 代理/后端离线、open/start 失败:走退避 */ }
return false;
}
async function tick() {
var node = el();
if (!node) return; /* 页面上没有该指标就不再调度 */
@@ -78,10 +168,18 @@ async function tick() {
try {
ok = render(await deviceLink.ecatPost('app/load', {}, 3000));
} catch (_) { /* 代理/后端离线,走退避 */ }
if (!ok) {
failCount++;
} else {
if (ok) {
failCount = 0;
} else {
var started = false;
try { started = await tryAutoStart(); } catch (_) {}
if (started) {
/* 刚 start 成功时 seq 仍可能是 0(首个 1s 窗口未滚完),
* 温和计数:保持 1s 节拍等窗口,不要立刻 5s/30s 退避。 */
failCount = Math.min(failCount, 1);
} else {
failCount++;
}
}
var wait = POLL_MS;
if (failCount > 6) wait = 30000;
+6 -2
View File
@@ -17,7 +17,7 @@ import { addLog } from '../common/log.js';
var ESP32_STAGE_IDS = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var ESP32_DETAIL_IDS = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
// 顶部步骤 pill → phase 名映射
var STEP_MAP = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
var STEP_MAP = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota', ecatOta: 'ppEcatOta' };
// 进度条 DOM 元素懒缓存(首次访问后复用,DOM 未就绪时回退到 getElementById)
var progressFillEl = null;
var progressTxtEl = null;
@@ -228,6 +228,10 @@ export function markPhaseDone(phase) {
/** @description 更新进度条百分比和文本显示。对 pct 做边界校验,过滤 NaN/undefined。 */
export function setProgress(pct, text) {
/* EtherCAT 直灌期间进度条由 boot_ecat 的 setEcatPct 独占。
* pollStatus→stages 的 GD32 OTA 中继进度(60+0.3*pct,常落在 44–45)
* 会把灌写 0–100% 冲成假值,这里一刀切掉。 */
if (window.bootEcatBusy) return;
// 数值校验:BLE ACK 高频调用路径偶尔传入 undefined/NaN,需兜底避免出现 "undefined%"
var n = Number(pct);
if (!Number.isFinite(n)) n = 0;
@@ -248,7 +252,7 @@ export function resetProgress() {
window.activePhase = null;
window.esp32OtaPhase = 0;
switchStageFlow(null);
['ppEsp32', 'ppGd32Ota'].forEach(function (id) {
['ppEsp32', 'ppGd32Ota', 'ppEcatOta'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
+31
View File
@@ -6,6 +6,7 @@
*
* 结构:
* applyDashboardUi — 堆/连接/版本/温湿度等常驻面板
* clearLiveMetricsOnDisconnect — 断线后清空实时指标(防残留误导)
* applyGd32OtaSessionUi — GD32 会话进度/完成/失败(唯一 UI 应用路径)
* updateSystemStatusUi — 入口:仪表盘 →(非 BLE/非 ESP32-OTA)会话
*
@@ -118,6 +119,32 @@ function setNodeTitle(el, title) {
if (el && el.title !== title) el.title = title;
}
/**
* 通信断开后清空 /system/status 驱动的实时指标,避免残留上次在线数值误导。
* 调用时机:轮询失败判定离线(约 1 个轮询周期 ≈ 1s),且非 OTA 忙态路径。
* 不清 #mGd32Load:该字段走 EtherCAT 独立通道,与 ESP32 HTTP 轮询无关。
*/
export function clearLiveMetricsOnDisconnect() {
var tip = '通信中断,数据已清空(等待恢复后自动刷新)';
var metricIds = ['mTemp', 'mGd32Temp', 'mVcc', 'mVdda', 'mGd32App', 'mGd32Boot', 'mGd32Init'];
for (var i = 0; i < metricIds.length; i++) {
var el = document.getElementById(metricIds[i]);
if (!el) continue;
setNodeText(el, '--');
setNodeClass(el, 'v');
setNodeTitle(el, tip);
}
var mh = document.getElementById('mHealth');
if (mh) {
setNodeText(mh, '--');
setNodeClass(mh, 'v');
}
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) setNodeText(uptimeEl, '⏱ --');
/* 不再展示过期 JSON,避免折叠面板里仍是断线前快照 */
window.latestSystemStatus = null;
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。
* @param {object} data /system/status JSON
@@ -503,6 +530,10 @@ export function updateSystemStatusUi(data) {
if (window.connectionMode === 'ble') {
return;
}
/* EtherCAT 直灌:进度/阶段由 boot_ecat 独占,中继轮询不得覆盖 */
if (window.connectionMode === 'ecat' || window.bootEcatBusy) {
return;
}
/* ESP32 OTA 进行中:勿用 GD32 relay 覆盖阶段条 */
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) {
return;
+18 -9
View File
@@ -17,7 +17,8 @@
恢复可靠性(为什么睡不死):ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),
芯片睡得再深 0xA2 命令照样送达 GD32;NRST 硬复位覆盖 LAN9252 的一切软件可达状态
(D3 睡死/HBI 假死都在内);GD32 自身挂了还有断电重启兜底(固件 hardwareReset 自动重走
受控复位序列)。页面常驻 2s 自动状态轮询,睡没睡一目了然。
受控复位序列)。自动状态轮询默认已关(AUTO_POLL=false),仅手动「读状态/唤醒/休眠」,
避免持续 /spi/send 0xA2 在 ESP32 串口刷「WEB SPI」日志;需恢复自动刷新改 AUTO_POLL。
-->
<html lang="zh-CN">
<head>
@@ -151,7 +152,7 @@
<div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🔋</span> 芯片电源状态 <span style="margin-left:auto;font-size:.68rem;color:var(--text-label);font-weight:400">2s 自动刷新</span></div>
<div class="card-head"><span class="ico">🔋</span> 芯片电源状态 <span style="margin-left:auto;font-size:.68rem;color:var(--text-label);font-weight:400">仅手动读状态</span></div>
<div class="card-body">
<div class="pm-sleep-banner" id="sleepBanner">🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复</div>
<div class="pm-grid">
@@ -323,10 +324,11 @@
$("sleepBanner").className = "pm-sleep-banner show";
}
/* ---- 自动状态轮询:连接后 2s 一次;读到睡眠态即暂停(1.6.24 实测 HBI 读
* 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 3 次标红停轮。
* pollGen:丢弃 enterMode/唤醒 之后仍在途的过期 1E 响应,避免竞态覆盖 UI。 ---- */
var pollFail = 0, pollTimer = null, pollPaused = false, pollGen = 0;
/* ---- 自动状态轮询已关闭(默认只手动):此前 2s 轮询会持续打 /spi/send 0xA2,
* 在 ESP32 串口刷「WEB SPI」日志。手动「读状态 / 唤醒 / 休眠」仍可用。
* 若需恢复自动刷新:把 AUTO_POLL 改 true。 ---- */
var AUTO_POLL = false;
var pollFail = 0, pollTimer = null, pollPaused = !AUTO_POLL, pollGen = 0;
/** 主站看门狗豁免是否已置位(pmHold 幂等,防 2s 轮询刷屏) */
var pmHoldOn = false;
function bumpPollGen() { return ++pollGen; }
@@ -369,6 +371,10 @@
}
function resumePolling() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (!AUTO_POLL) {
pollPaused = true;
return;
}
pollPaused = false;
bumpPollGen();
pollTimer = setTimeout(pollOnce, 2000);
@@ -393,8 +399,9 @@
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
setConn(true, "已连接");
log("设备可达: " + deviceIp(), "ok");
pollOnce();
log("设备可达: " + deviceIp() +
(AUTO_POLL ? "。" : "。自动状态轮询已关闭,请点「读状态」手动查询。"), "ok");
if (AUTO_POLL) pollOnce();
})
.catch(function (e) {
setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
@@ -624,7 +631,9 @@
pmHold(1);
probeEspOnly();
} else {
log("就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。", "warn");
log(AUTO_POLL
? "就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。"
: "就绪。自动状态轮询已关闭(避免持续 SPI 诊断流量);需要时点「读状态」。改设备 IP 请到 OTA 中继页。", "warn");
probe();
}
})();
+543 -193
View File
File diff suppressed because it is too large Load Diff
+81 -12
View File
@@ -13,7 +13,7 @@
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/layout.css?v=targettabs20260914g">
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/can.css?v=c06f5688">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
@@ -59,9 +59,19 @@
<div class="card card--ble">
<div class="card-head"><span class="ico">🔗</span> 设备连接</div>
<div class="card-body">
<div style="display:flex;gap:6px;margin-bottom:14px;">
<button id="modeWifiBtn" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" data-action="set-console-mode" data-arg="wifi">WiFi OTA</button>
<button id="modeBleBtn" class="btn btn-outline" style="flex:1;font-size:12px;padding:7px 10px;" data-action="set-console-mode" data-arg="ble">BLE 蓝牙</button>
<div class="target-tab-row" id="modeTabRow">
<button id="modeWifiBtn" class="btn target-tab-btn btn-primary" data-action="set-console-mode" data-arg="wifi" type="button">
<span class="target-tab-ico" aria-hidden="true">🌐</span>
<span class="target-tab-txt">WiFi OTA</span>
</button>
<button id="modeBleBtn" class="btn target-tab-btn btn-outline" data-action="set-console-mode" data-arg="ble" type="button">
<span class="target-tab-ico" aria-hidden="true">📡</span>
<span class="target-tab-txt">BLE 蓝牙</span>
</button>
<button id="modeEcatBtn" class="btn target-tab-btn btn-outline" data-action="set-console-mode" data-arg="ecat" type="button">
<span class="target-tab-ico" aria-hidden="true">⚡</span>
<span class="target-tab-txt">EtherCAT</span>
</button>
</div>
<div id="wifiModeCards">
<div class="form-row">
@@ -135,6 +145,35 @@
<div class="ble-launch-hint">点击按钮连接 / 断开蓝牙设备</div>
</div>
</div>
<!-- EtherCAT 模式:这条链路没有 IP / 配对概念,"连接对象"就是本机 EtherCAT 主站。
总线的打开/关闭由主站侧(ecat_native)负责,本页只在下面几个读数里反映现状。 -->
<div id="ecatModeCards" style="display:none;">
<div class="ecat-mode-note">
主站经引导程序邮箱刷写 GD32 APP,<b>ESP32 离线不影响</b>
</div>
<div class="form-label">总线对面</div>
<div class="conn-indicator" id="bootEcatConn" style="margin-bottom:10px;">
<span class="light" id="bootEcatConnLight"></span>
<span id="bootEcatConnTxt">未探测</span>
</div>
<button class="btn btn-outline btn-block" id="bootEcatProbeBtn" data-action="boot-ecat-probe">探测对面是谁</button>
<button class="btn btn-outline btn-block" id="bootEcatRebootBtn" data-action="boot-ecat-reboot"
style="margin-top:8px;" title="写 APP 的 CoE 0x2502:00=1,让它复位进引导程序">让应用回引导程序</button>
<div class="file-info-grid" style="margin-top:10px;">
<div class="file-meta-item"><span>对面是谁</span><b id="bootEcatWho">--</b></div>
<div class="file-meta-item"><span>从站是否放行</span><b id="bootEcatArm">--</b></div>
</div>
<div class="file-info-grid" style="margin-top:8px;">
<div class="file-meta-item"><span>主站已缓存</span><b id="bootEcatSrv">0 B</b></div>
<div class="file-meta-item"><span>从站已写 flash</span><b id="bootEcatRecv">--</b></div>
</div>
<div class="file-meta-item" style="margin-top:8px;">
<span>设备名(0x1008)</span><b id="bootEcatName" style="word-break:break-all;">—</b>
</div>
<div class="file-meta-item">
<span>软件版本(0x100A)</span><b id="bootEcatSw">—</b>
</div>
</div>
<!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 -->
</div>
</div>
@@ -143,9 +182,15 @@
<div class="card card--gd32">
<div class="card-head"><span class="ico">🎯</span> 升级目标、固件文件与执行操作</div>
<div class="card-body">
<div style="display:flex;gap:6px;margin-bottom:14px;">
<button id="targetGd32Tab" class="btn btn-outline" style="flex:1;font-size:12px;padding:7px 10px;" data-action="select-flash-target" data-arg="gd32">GD32</button>
<button id="targetEsp32Tab" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" data-action="select-flash-target" data-arg="esp32">ESP32</button>
<div class="target-tab-row" id="targetTabRow">
<button id="targetGd32Tab" class="btn target-tab-btn btn-outline" data-action="select-flash-target" data-arg="gd32" type="button">
<span class="target-tab-ico" aria-hidden="true">⚙</span>
<span class="target-tab-txt">GD32</span>
</button>
<button id="targetEsp32Tab" class="btn target-tab-btn btn-primary" data-action="select-flash-target" data-arg="esp32" type="button">
<span class="target-tab-ico" aria-hidden="true">📶</span>
<span class="target-tab-txt">ESP32</span>
</button>
</div>
<div style="border-top:1px solid var(--border);margin:0 -16px 14px;padding-top:14px;padding-left:16px;padding-right:16px;">
<div id="wifiEsp32UploadPane">
@@ -233,18 +278,28 @@
<button id="autoFlashBtn" class="btn btn-primary" disabled>一键刷写</button>
<button id="abortBtn" class="btn btn-danger" disabled>中止任务</button>
</div>
<!-- EtherCAT 直灌面板:只在「设备连接 = EtherCAT」时显示(applySharedModeUi 控制)。
固件复用同一个 #fileInputGd32 与同一条 processFile 转换/校验,
所以这里不出现第二份 HEX 解析器;"能不能灌"只由 BOOT 里的 ecatArmed()
裁决,前端不复制判据(见 js/ota/boot_ecat.js 头注)。 -->
<div id="bootEcatPane" style="display:none;">
<button class="btn btn-block" id="bootEcatRunBtn" data-action="boot-ecat-run" disabled>EtherCAT 刷写 APP</button>
<button class="btn btn-danger btn-block" id="bootEcatAbortBtn"
data-action="boot-ecat-abort" style="margin-top:8px;">放弃本轮</button>
</div>
</div>
</div>
</div>
<!-- 4. Flash Progress + Stage Flow -->
<div class="card card--esp32">
<!-- 4. Flash Progress + Stage Flow(WiFi/BLE/EtherCAT 共用同一张进度卡) -->
<div class="card card--esp32" id="progressCard">
<div class="card-head"><span class="ico">⚡</span> 刷写进度与流程阶段</div>
<div class="card-body">
<!-- Phase step indicators -->
<div class="progress-phase-row">
<div class="pp-step active" id="ppEsp32"><span class="pp-ico">📦</span><span class="pp-txt">ESP32 OTA</span></div>
<div class="pp-step" id="ppGd32Ota"><span class="pp-ico">📤</span><span class="pp-txt">GD32 OTA</span></div>
<div class="pp-step" id="ppEcatOta"><span class="pp-ico">⚡</span><span class="pp-txt">EtherCAT</span></div>
</div>
<!-- Unified progress bar -->
<div class="progress-wrap show" id="progressWrap">
@@ -282,6 +337,16 @@
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">APP 启动</div><div class="stage-detail" id="sd5"></div></div>
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">OTA完成</div><div class="stage-detail" id="sd6"></div></div>
</div>
<!-- EtherCAT 刷写 stages -->
<div class="stage-flow" id="ecatStages" style="display:none;">
<div class="stage-node" id="ec1"><div class="stage-dot">1</div><div class="stage-label">读取镜像</div><div class="stage-detail" id="ecd1"></div></div>
<div class="stage-node" id="ec2"><div class="stage-dot">2</div><div class="stage-label">推到主站</div><div class="stage-detail" id="ecd2"></div></div>
<div class="stage-node" id="ec3"><div class="stage-dot">3</div><div class="stage-label">交接进 BOOT</div><div class="stage-detail" id="ecd3"></div></div>
<div class="stage-node" id="ec4"><div class="stage-dot">4</div><div class="stage-label">确认引导程序</div><div class="stage-detail" id="ecd4"></div></div>
<div class="stage-node" id="ec5"><div class="stage-dot">5</div><div class="stage-label">邮箱灌写</div><div class="stage-detail" id="ecd5"></div></div>
<div class="stage-node" id="ec6"><div class="stage-dot">6</div><div class="stage-label">从站复位</div><div class="stage-detail" id="ecd6"></div></div>
<div class="stage-node" id="ec7"><div class="stage-dot">7</div><div class="stage-label">刷写完成</div><div class="stage-detail" id="ecd7"></div></div>
</div>
<div class="stage-status-bar" id="stageStatusBar">
<span class="ssb-dot" id="stageStatusDot"></span>
<span class="ssb-text" id="stageStatusText">待机</span>
@@ -344,7 +409,7 @@
<div class="card-body">
<div class="metric-grid">
<div class="metric"><div class="k">ESP32 温度</div><div class="v" id="mTemp">--</div></div>
<div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温度">--</div></div>
<div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温度">--</div></div>
<div class="metric"><div class="k">GD32 负载</div><div class="v" id="mGd32Load" title="DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口">--</div></div>
<div class="metric"><div class="k">输入 VCC</div><div class="v" id="mVcc" title="PA6 分压反推:VCC—47k—ADC—4.7k—GND">--</div></div>
<div class="metric"><div class="k">VDDA</div><div class="v" id="mVdda" title="片内 VREFINT 反推的 MCU 模拟供电">--</div></div>
@@ -500,8 +565,12 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="../js/app.js?v=20260905a"></script>
<script type="module" src="../js/ota/console-mode.js?v=20260901d"></script>
<!-- app 窗口租约:本页会自动拉起 EtherCAT PDO 循环(GD32 负载),
必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉。
与 tabs.html / motor.html 并存时连接数>1,互不影响。 -->
<script src="../js/common/app-lease.js"></script>
<script type="module" src="../js/app.js?v=20260915a"></script>
<script type="module" src="../js/ota/console-mode.js?v=20260914b"></script>
<script type="module" src="../js/ble_ota.js?v=20260901d"></script>
<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked
不依赖 ES 模块(子模块可能被浏览器缓存为旧版,不含 data-ble-state 逻辑)。