From e98950cde239e7306e361794b2b2ab807c7dd163 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Wed, 16 Sep 2026 12:31:39 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=A7=A3=E5=86=B3=20stash=20pop=20?= =?UTF-8?q?=E4=B8=8E=20device-link=20=E5=90=88=E5=B9=B6=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E5=B9=B6=E6=94=B6=E5=9B=9E=E6=9C=AC=E5=9C=B0=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 ConnectionHub(deviceLink 导入/ECAT 自启/pmHold 幂等)与主线 WIP(断线清指标、AUTO_POLL=false、boot_ecat 等)。 --- css/components.css | 15 +- css/layout.css | 127 ++++++- esp32_server.py | 10 + js/app.js | 23 +- js/common/polling.js | 6 +- js/ota/console-mode.js | 88 +++-- js/ota/ecat_load.js | 110 +++++- js/ota/progress.js | 8 +- js/ota/stages.js | 31 ++ lab/lan9252_pm.html | 27 +- lab/spi_test.html | 736 ++++++++++++++++++++++++++++++----------- pages/index.html | 93 +++++- 12 files changed, 1026 insertions(+), 248 deletions(-) diff --git a/css/components.css b/css/components.css index c913767..347edc9 100644 --- a/css/components.css +++ b/css/components.css @@ -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 { diff --git a/css/layout.css b/css/layout.css index 5fe2922..f7953e4 100644 --- a/css/layout.css +++ b/css/layout.css @@ -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; diff --git a/esp32_server.py b/esp32_server.py index 50620df..17314a6 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -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) diff --git a/js/app.js b/js/app.js index 9135900..c71b1a6 100644 --- a/js/app.js +++ b/js/app.js @@ -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; } diff --git a/js/common/polling.js b/js/common/polling.js index 67b3ec4..39c8383 100644 --- a/js/common/polling.js +++ b/js/common/polling.js @@ -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(); diff --git a/js/ota/console-mode.js b/js/ota/console-mode.js index d1018eb..bf0cc92 100644 --- a/js/ota/console-mode.js +++ b/js/ota/console-mode.js @@ -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(); diff --git a/js/ota/ecat_load.js b/js/ota/ecat_load.js index 62ffdc1..c0340e5 100644 --- a/js/ota/ecat_load.js +++ b/js/ota/ecat_load.js @@ -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; diff --git a/js/ota/progress.js b/js/ota/progress.js index 7ffb72e..eebcaba 100644 --- a/js/ota/progress.js +++ b/js/ota/progress.js @@ -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'); }); diff --git a/js/ota/stages.js b/js/ota/stages.js index cc0e22e..bd5126d 100644 --- a/js/ota/stages.js +++ b/js/ota/stages.js @@ -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; diff --git a/lab/lan9252_pm.html b/lab/lan9252_pm.html index 90039cb..2d4cd95 100644 --- a/lab/lan9252_pm.html +++ b/lab/lan9252_pm.html @@ -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。 --> @@ -151,7 +152,7 @@
-
🔋 芯片电源状态 2s 自动刷新
+
🔋 芯片电源状态 仅手动读状态
🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复
@@ -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(); } })(); diff --git a/lab/spi_test.html b/lab/spi_test.html index 96254e5..28fc3eb 100644 --- a/lab/spi_test.html +++ b/lab/spi_test.html @@ -13,7 +13,7 @@ EtherCAT 侧的**硬约束**(改动前务必读): 1. 只调只读路由:/api/motor/state · /api/app/load · /api/motor/rxraw · - /api/status · /api/native/health · /api/motor/dbg · /api/motor/period(热改)。 + /api/motor/pdoin · /api/status · /api/native/health · /api/motor/dbg · /api/motor/period(热改)。 2. **绝不调 /api/motor/rx** —— 它是"取走即移除"的消费语义,会把电机页 赖以攒 CONFIG/版本的批量接收区抢干净。本页要帧就从未消费的 /api/motor/state.rx_batch 与 /api/motor/rxraw 里拿。 @@ -49,12 +49,14 @@ --text:#e9f0fa; --text-dim:#93a8c4; --text-label:#617a99; --esp32:#2fd8c1; --esp32-hi:#6ff2e0; --esp32-dim:rgba(47,216,193,.14); --gd32:#fcbf3f; --gd32-dim:rgba(252,191,63,.14); - --can:#43d97b; + --can:#43d97b; --can-hi:#7deda6; + --ble:#5fa8ff; --mem:#a88bfa; --mem-hi:#c7b3ff; --mem-dim:rgba(168,139,250,.14); --green:#3ddc8e; --amber:#fcbf3f; --red:#ff6b6b; --red-dim:rgba(255,107,107,.14); --spi:var(--esp32); --ecat:var(--mem); --ecat-hi:var(--mem-hi); --ecat-dim:var(--mem-dim); - --radius-sm:8px; --radius:12px; + --radius-sm:8px; --radius:12px; --radius-lg:16px; --font:"PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif; + --font-display:var(--font); --mono:"SF Mono","Fira Code","Cascadia Code","Consolas",monospace; --shadow-card:0 10px 30px rgba(3,7,16,.45), 0 1px 0 rgba(255,255,255,.03) inset; } @@ -89,14 +91,73 @@ /* 面板内模块用 gap 分隔(对齐全站 .main-layout / .card+.card 的 --gap=18px 习惯) */ #pane-spi, #pane-ecat { display:flex; flex-direction:column; gap:18px; } - /* ═══════ 头部 + 面板切换 ═══════ */ - header { display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end; margin-bottom:18px; } - h1 { font-size:21px; letter-spacing:.3px; } - h1 .mark { color:var(--ecat-hi); } - .sub { color:var(--text-label); font-size:12px; margin-top:2px; } - .tabs { display:flex; gap:6px; margin-left:auto; } + /* ═══════ 页头命令栏(对齐 layout.css .header-bar) ═══════ */ + .header-bar { + position:relative; z-index:20; + display:flex; align-items:center; gap:16px; + padding:16px 22px; margin-bottom:26px; + background: + radial-gradient(620px 240px at 88% -40%, rgba(47,216,193,.12), transparent 60%), + linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%); + border:1px solid var(--border); + border-radius:var(--radius-lg); + box-shadow:var(--shadow-card); + flex-wrap:wrap; + } + .header-bar::before { + content:""; position:absolute; top:0; left:0; right:0; height:2px; + background:linear-gradient(90deg, var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%); + opacity:.85; + border-radius:var(--radius-lg) var(--radius-lg) 0 0; + } + .header-brand { + display:flex; align-items:center; gap:12px; + padding-right:20px; border-right:1px solid var(--border); + white-space:nowrap; + } + .brand-logo { + width:40px; height:40px; flex-shrink:0; + display:inline-flex; align-items:center; justify-content:center; + border-radius:11px; + background:linear-gradient(145deg, rgba(47,216,193,.16), rgba(95,168,255,.10)); + border:1px solid rgba(47,216,193,.3); + box-shadow:0 0 18px rgba(47,216,193,.18), inset 0 0 12px rgba(47,216,193,.08); + color:var(--esp32); + } + .brand-logo svg { width:24px; height:24px; display:block; } + .brand-text { display:flex; flex-direction:column; line-height:1.15; } + .brand-text strong { + font-family:var(--font-display); + font-size:17px; font-weight:700; letter-spacing:.02em; + background:linear-gradient(92deg, var(--text) 30%, var(--esp32-hi) 100%); + -webkit-background-clip:text; background-clip:text; + -webkit-text-fill-color:transparent; + } + .brand-sub { + font-family:var(--mono); + font-size:10px; letter-spacing:.22em; text-transform:uppercase; + color:var(--text-label); margin-top:2px; + } + .header-status { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } + .header-sub { display:flex; gap:10px; margin-left:auto; flex-wrap:wrap; align-items:center; } + .pill { + display:inline-flex; align-items:center; gap:7px; + padding:6px 13px; border-radius:999px; + font-size:12px; font-weight:500; font-family:var(--mono); + background:var(--bg-elevated); border:1px solid var(--border); + color:var(--text-dim); white-space:nowrap; + } + .pill .indicator { width:7px; height:7px; border-radius:50%; flex-shrink:0; background:var(--text-label); } + .pill.online .indicator { background:var(--green); box-shadow:0 0 8px rgba(61,220,142,.6); } + .pill.offline .indicator { background:var(--red); box-shadow:0 0 8px rgba(255,107,107,.5); } + .pill.live .indicator, .pill.processing .indicator { background:var(--amber); animation:blink 1s infinite; } + .pill.online { color:var(--can-hi); border-color:rgba(61,220,142,.35); } + .pill.offline { color:#ffb3b3; border-color:rgba(255,107,107,.3); } + .pill.live, .pill.processing { color:var(--gd32-hi); border-color:rgba(252,191,63,.35); } + @keyframes blink { 50% { opacity:.3; } } + .tabs { display:flex; gap:6px; } .tab { - background:var(--bg-card); color:var(--text-dim); border:1px solid var(--border); + background:var(--bg-elevated); color:var(--text-dim); border:1px solid var(--border); border-radius:99px; padding:7px 18px; font:inherit; font-size:13px; cursor:pointer; display:flex; align-items:center; gap:7px; transition:background .16s,color .16s,border-color .16s; } @@ -113,25 +174,43 @@ background:linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.92) 100%); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow-card); - padding:0 16px 16px; + padding:0 18px 17px; /* 卡头用负 margin 通栏,正文保留侧/底内边距 */ } - /* 顶部 2px 子系统色带(layout.css .card::before 同款) */ .card::before { - content:""; display:block; height:2px; margin:0 -16px; - background:linear-gradient(90deg, var(--ecat), transparent 85%); - opacity:.7; pointer-events:none; + content:""; position:absolute; top:0; left:0; right:0; height:2px; + background:linear-gradient(90deg, var(--card-accent, var(--ecat)), transparent 85%); + opacity:.7; pointer-events:none; z-index:1; } - #pane-spi .card::before { background:linear-gradient(90deg, var(--spi), transparent 85%); } + .card--mem { --card-accent:var(--mem); --card-accent-dim:var(--mem-dim); } + .card--esp32 { --card-accent:var(--esp32); --card-accent-dim:var(--esp32-dim); } + /* 卡头:card-head 与旧 h2 同一视觉,避免改一半把下面挤坏 */ + .card-head, .card > h2 { - margin:0 -16px 14px; padding:13px 16px 12px; + display:flex; align-items:center; gap:10px; flex-wrap:wrap; + margin:0 -18px 14px; + padding:14px 18px; border-bottom:1px solid var(--border-subtle); background:rgba(255,255,255,.02); - font-size:12px; font-weight:600; letter-spacing:.9px; text-transform:uppercase; - color:var(--text-label); display:flex; align-items:center; gap:9px; flex-wrap:wrap; + font-family:var(--font-display); + font-size:13px; font-weight:600; letter-spacing:.05em; + color:var(--text); user-select:none; + line-height:1.3; } - .card > h2 .hint2 { font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; color:var(--text-label); } - .card > h2::before { content:""; width:3px; height:12px; border-radius:2px; background:var(--ecat); flex:0 0 auto; } - #pane-spi .card > h2::before { background:var(--spi); } + .card-head .ico { + width:28px; height:28px; flex-shrink:0; + display:inline-flex; align-items:center; justify-content:center; + font-size:15px; border-radius:8px; + background:var(--card-accent-dim, var(--mem-dim)); + border:1px solid rgba(168,139,250,.28); + } + .card--esp32 .card-head .ico { border-color:rgba(47,216,193,.28); } + .card-head .hint2, + .card > h2 .hint2 { + font-family:var(--font); + font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; + color:var(--text-label); + } + .card-body { padding:0; } .row { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; } .row + .row { margin-top:10px; } @@ -159,6 +238,30 @@ button.stop { background:var(--red-dim); border-color:rgba(255,107,107,.5); color:#ffc9c9; } button.ghost { background:transparent; } button.sm { padding:4px 10px; font-size:12px; } + /* 左右拨动开关 */ + button.tsw { + display:inline-flex; align-items:center; gap:8px; background:var(--bg-elevated); + border:1px solid var(--border); border-radius:999px; padding:3px 12px 3px 4px; + font:inherit; font-size:12px; color:var(--text-dim); cursor:pointer; + transition:border-color .15s, color .15s; + } + button.tsw:hover:not(:disabled) { border-color:#3a5378; } + button.tsw .tr { + width:30px; height:16px; border-radius:99px; background:#2a3a52; position:relative; + flex:0 0 auto; transition:background .15s; + } + button.tsw .tr::after { + content:""; position:absolute; left:2px; top:2px; width:12px; height:12px; + border-radius:99px; background:#93a8c4; transition:left .15s, background .15s; + } + button.tsw.on { border-color:rgba(168,139,250,.55); color:var(--text); } + button.tsw.on .tr { background:var(--mem); } + button.tsw.on .tr::after { left:16px; background:#fff; } + button.tsw.amber.on { border-color:rgba(252,191,63,.6); } + button.tsw.amber.on .tr { background:var(--amber); } + button.tsw.amber.on .tr::after { background:#1a1400; } + button.tsw.live.on { border-color:rgba(61,220,142,.55); } + button.tsw.live.on .tr { background:var(--green); } .pill { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:99px; @@ -203,6 +306,8 @@ padding:9px 11px; font-family:var(--mono); font-size:12px; line-height:1.65; white-space:pre-wrap; word-break:break-all; max-height:300px; overflow:auto; } + /* 原始字节区:多列紧凑 hex,行宽固定以便排满横向 */ + #rawDecodeL { font-size:11.5px; line-height:1.5; } /* 多列 hex 网格:每行定宽,auto-fill 按可用宽度自动排 2~4 列。 单列 pre 时每行 54 字符只占面板四成宽度,剩下全是空白。 */ .hexgrid { @@ -211,9 +316,16 @@ padding:7px 10px; height:var(--hex-h,190px); overflow:auto; font-family:var(--mono); font-size:11px; line-height:1.5; color:var(--text-dim); } + .hexgrid.dense-raw { grid-template-columns:1fr; } + /* paneFlex 内:去掉定高,跟着列高走 */ + .paneFlex > .hexgrid.grow { height:auto; min-height:0; } + .hexgrid.dense-raw i.slot { margin-top:2px; } .hexgrid i { font-style:normal; white-space:pre; } .hexgrid i.hdr { color:var(--text-label); } .hexgrid i.slot { color:var(--mem-hi); } + .hexgrid i.dec { color:var(--esp32); margin:2px 0 4px; } + .hexgrid i.blk { color:var(--text-dim); margin-top:8px; } + .hexgrid i.dim { color:var(--text-label); } pre.fail { border-color:rgba(255,107,107,.5); color:#fca5a5; } .kv { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:4px 14px; } .kv div { font-size:12px; color:var(--text-dim); display:flex; gap:9px; justify-content:space-between; border-bottom:1px dotted var(--border-subtle); padding:2.5px 0; min-width:0; } @@ -231,7 +343,10 @@ .lbl { font-size:11px; color:var(--text-label); letter-spacing:.3px; margin-bottom:4px; } .hint code { font-family:var(--mono); color:var(--text-dim); background:var(--bg-input); padding:1px 4px; border-radius:4px; } .cols2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; } - @media (max-width:1080px){ .cols2 { grid-template-columns:1fr; } } + /* 原始数据主区:左宽右窄,raw 吃掉大部分横向空间 */ + .raw-main { display:grid; grid-template-columns:minmax(0,2.2fr) minmax(0,1fr); gap:14px; } + .raw-side { min-width:0; } + @media (max-width:1080px){ .cols2, .raw-main { grid-template-columns:1fr; } } details > summary { cursor:pointer; font-size:11.5px; color:var(--text-label); padding:3px 0; } details[open] > summary { color:var(--text-dim); } [hidden] { display:none !important; } @@ -239,21 +354,37 @@
-
-
-

链路测速台 · DCU 总线吞吐取证

-
临时诊断工具 [SPEEDTEST] — SPI 探针 0xA1 APP PING · EtherCAT 只读观测 ecat_native 过程数据
+
+
+ +
+ 链路测速台 + DCU · SPI / EtherCAT · SPEEDTEST +
+
+
+ 未开始 + 链路 — + AL — +
+
+
-
@@ -546,10 +657,15 @@ function kv(boxId, rows) { box.appendChild(d); } } -/** 状态胶囊:SPI / ECAT / 热改周期共用同一套 led + 文案结构 */ +/** 状态胶囊:与 header-bar .pill 同构(.indicator);ok/warn/bad 映射 online/live/offline */ function setPill(sel, cls, text) { - const n = $(sel); n.className = "pill" + (cls ? " " + cls : ""); - n.innerHTML = ''; n.appendChild(el("span", null, text)); + const n = $(sel); + if (!n) return; + const map = { ok: "online", warn: "live", bad: "offline", good: "online", live: "live" }; + const k = map[cls] || cls || ""; + n.className = "pill" + (k ? " " + k : ""); + n.innerHTML = ''; + n.appendChild(el("span", null, text)); } const isNum = (v) => typeof v === "number" && isFinite(v); const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—"); @@ -604,6 +720,66 @@ function ls(key, val) { // localStorage 读写包装(隐私模式会 try { if (val === undefined) return window.localStorage.getItem(key); window.localStorage.setItem(key, val); } catch (_) { return null; } } +/* 面板开关持久化:刷新后恢复。冻结默认不恢复(冻住空面板没意义),其余全存。 */ +const UI_OPT_KEY = "spi_test_ui_opts"; +function saveUiOpts() { + const gv = (id) => { const n = document.getElementById(id); return n && n.value !== undefined ? n.value : ""; }; + ls(UI_OPT_KEY, JSON.stringify({ + rxraw: !!E.rxraw, + heavyHex: !!E.heavyHex, + heavyFrames: !!E.heavyFrames, + ring: !!E.ring, + chartMax: !!(E.chartOpt && E.chartOpt.max), + chartSkip: !!(E.chartOpt && E.chartOpt.skip), + hz: gv("hz"), + periodPick: gv("periodPick"), + frames: gv("frames"), + payload: gv("payload"), + spiTimeout: gv("spiTimeout"), + })); +} +function applyToggleBtn(id, on, label) { + const n = document.getElementById(id); + if (!n) return; + n.classList.toggle("on", !!on); + n.setAttribute("aria-pressed", on ? "true" : "false"); + const em = n.querySelector("em"); + if (em) em.textContent = label; + else n.textContent = label + (on ? ":开" : ":关"); +} +function restoreUiOpts() { + let o = null; + try { o = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) { o = null; } + if (!o) return; + E.rxraw = !!o.rxraw; + E.heavyHex = !!o.heavyHex; + E.heavyFrames = !!o.heavyFrames; + E.ring = !!o.ring; + E.frozen = false; /* 刷新后解冻,避免冻在「等待」占位 */ + E.chartOpt = E.chartOpt || { max: false, skip: false }; + E.chartOpt.max = !!o.chartMax; + E.chartOpt.skip = !!o.chartSkip; + applyToggleBtn("btnRxraw", E.rxraw, "原始窗口"); + applyToggleBtn("btnHeavyHex", E.heavyHex, "hex每拍重建"); + applyToggleBtn("btnHeavyFrames", E.heavyFrames, "每拍重建"); + applyToggleBtn("btnRing", E.ring, "逐拍环"); + applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max"); + applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍"); + const hz = document.getElementById("hz"); + if (hz && o.hz !== undefined && o.hz !== "") hz.value = o.hz; + const pick = document.getElementById("periodPick"); + if (pick && o.periodPick !== undefined) pick.value = o.periodPick; + const frames = document.getElementById("frames"); + if (frames && o.frames) frames.value = o.frames; + const payload = document.getElementById("payload"); + if (payload && o.payload !== undefined && o.payload !== "") payload.value = o.payload; + const stmo = document.getElementById("spiTimeout"); + if (stmo && o.spiTimeout) stmo.value = o.spiTimeout; + if (E.rxraw) { + const b = document.getElementById("btnRxraw"); + if (b) b.title = "每拍额外读 /api/motor/rxraw + /api/motor/pdoin(段2 与整包 600B)。对 EtherCAT 有负载,看完建议关掉"; + } +} /* ═══════════════════ 面板切换 ═══════════════════ */ const PANES = { spi: $("#pane-spi"), ecat: $("#pane-ecat") }; @@ -910,16 +1086,17 @@ const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs" /* ═══════════════════ EtherCAT:实时观测状态 ═══════════════════ */ const E = { - live: false, ring: false, rxraw: false, + live: false, ring: false, rxraw: false, frozen: false, + frozenSnap: null, /* 冻结瞬间的 {st,raw,pdoin},解冻前不再重画 */ heavyFrames: false, heavyHex: false, wantLive: false, everStarted: false, pausedByHidden: false, win: new RateWindow(3000), pollDts: [], pollHz: null, intervalMs: null, lastRates: null, - lastState: null, status: null, health: null, load: null, raw: null, dbg: null, + lastState: null, status: null, health: null, load: null, raw: null, dbg: null, pdoin: null, tornPrev: null, reentryPrev: null, tornCap: false, reentryCap: false, overPrev: null, rejPrev: null, overAccum: 0, rejAccum: 0, lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(), - lastSeq: {}, seqAt: {}, densLast: null, + lastSeq: {}, seqAt: {}, densLast: null, lastRenderedRawCycle: null, chart: { t: [], hz: [], us: [], usMax: [], lateHz: [] }, chartOpt: { max: false, skip: false }, errors: 0, errText: "", @@ -1005,10 +1182,14 @@ async function handleTick(st) { load = done[0]; dbg = done[1]; rtt = Math.max(rtt, (load && load.__rtt) || 0, (dbg && dbg.__rtt) || 0); } catch (e) { E.errors++; E.errText = (e && e.message) || String(e); } - /* rxraw 响应体最大(约 600B hex + parsed),默认关。开启时才拉,并计入 rtt。 */ + /* rxraw 只含段2(dense~176B / win~232B);pdoin 是整包 PDO 输入镜像(~600B)。 */ if (E.rxraw) { try { raw = await eapi("motor/rxraw", {}, 4000); if (raw) rtt = Math.max(rtt, raw.__rtt || 0); } catch (_) { raw = null; } + try { + const pin = await eapi("motor/pdoin", {}, 4000); + if (pin && pin.ok) { E.pdoin = pin; rtt = Math.max(rtt, pin.__rtt || 0); } + } catch (_) { /* 旧 ecat_native 无此路由时静默 */ } } const t = performance.now(); // 与计数同一瞬间,不能事后再取 const b = st.bridge || {}, head = st.head || {}; @@ -1024,18 +1205,25 @@ async function handleTick(st) { rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending, }); E.lastState = st; E.load = load; E.dbg = dbg; E.raw = raw; + if (!E.rxraw) E.pdoin = null; const rates = E.win.calc(); E.lastRates = rates; renderMetrics(st, load, dbg, rates, rtt); renderBus(st); renderBridge(st); renderMotors(st, rates); - /* 帧表 / hex 网格:各自模块的「每拍重建」开关(默认关)。关着时 DOM 冻结。 */ - if (E.rxraw && raw) { + /* 帧表 / hex 网格:各自模块的「每拍重建」开关(默认关)。关着时 DOM 冻结。 + * 原始窗口开着时:cycle 变了就刷一次完整解码(本拍内容真正变了才值得重画)。 */ + if (E.frozen) { + /* 冻结:只更新内存里的最新快照,不重画面板③ */ + E.frozenSnap = { st, raw, pdoin: E.pdoin }; + } else if (E.rxraw && raw) { if (E.heavyFrames) renderFrames(st, raw); - else countSeenFrames(raw); /* 计数照走,只是不重画表 */ - if (E.heavyHex) renderRaw(st, raw); - } else if (E.heavyHex) { + else countSeenFrames(raw); + /* 完整解码必须跟上 raw:hex 网格也可每拍重建(dense 仅 ~176B,开销可忽略) */ + renderRaw(st, raw); + E.lastRenderedRawCycle = raw.cycle; + } else { renderRaw(st, null); } if (dbg) renderBeats(dbg); @@ -1048,7 +1236,7 @@ function startEcatIfIdle() { E.live = true; E.everStarted = true; E.wantLive = true; E.pausedByHidden = false; tickPrev = null; E.pollDts = []; E.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null; - $("#btnLive").textContent = "暂停观测"; + applyToggleBtn("btnLive", true, "实时观测"); setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : "")); eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); }) .catch((e) => { E.status = null; E.errText = e.message; }); @@ -1059,7 +1247,7 @@ function startEcatIfIdle() { function stopEcat() { E.live = false; E.wantLive = false; E.pausedByHidden = false; ticker.stop(); - $("#btnLive").textContent = "开始实时观测"; + applyToggleBtn("btnLive", false, "实时观测"); setPill("#ecatState", "", "已暂停"); } /* 后台标签页:Worker 定时器不受节流,live 着=持续打代理。暂停但记住 wantLive。 */ @@ -1068,7 +1256,7 @@ function pauseEcatHidden() { E.pausedByHidden = true; E.live = false; ticker.stop(); - $("#btnLive").textContent = "后台已暂停 · 点此恢复"; + applyToggleBtn("btnLive", false, "后台已暂停"); setPill("#ecatState", "", "后台已暂停"); } function resumeEcatShown() { @@ -1083,39 +1271,85 @@ document.addEventListener("visibilitychange", () => { $("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); }; $("#btnRing").onclick = () => { E.ring = !E.ring; - $("#btnRing").textContent = "逐拍环:" + (E.ring ? "开" : "关"); + applyToggleBtn("btnRing", E.ring, "逐拍环"); if (!E.ring) { tblBeatsBody.textContent = ""; $("#beatStat").textContent = "逐拍环未开启。"; } + saveUiOpts(); }; $("#btnRxraw").onclick = () => { E.rxraw = !E.rxraw; - $("#btnRxraw").textContent = "原始窗口:" + (E.rxraw ? "开" : "关"); + applyToggleBtn("btnRxraw", E.rxraw, "原始窗口"); $("#btnRxraw").title = E.rxraw - ? "每拍额外读 /api/motor/rxraw(约 600B hex)。对 EtherCAT 有负载,看完建议关掉" - : "每拍多打 /api/motor/rxraw(约 600B hex)。默认关"; + ? "每拍额外读 /api/motor/rxraw + /api/motor/pdoin(段2 与整包 600B)。对 EtherCAT 有负载,看完建议关掉" + : "每拍多打 /api/motor/rxraw + pdoin。默认关"; if (E.live) setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : "")); - if (!E.rxraw) E.raw = null; - if (E.rxraw && E.lastState && E.raw) { - if (E.heavyFrames) renderFrames(E.lastState, E.raw); - if (E.heavyHex) renderRaw(E.lastState, E.raw); + if (!E.rxraw) { + E.raw = null; + E.lastRenderedRawCycle = null; + if (!E.frozen) renderRaw(E.lastState, null); + } else if (!E.frozen) { + if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw); + renderRaw(E.lastState || null, E.raw || null); + if (E.raw) E.lastRenderedRawCycle = E.raw.cycle; } + saveUiOpts(); +}; +function setFreezeUi() { + const b = document.getElementById("btnFreeze"); + const capL = document.getElementById("rawDecodeCap"); + const capR = document.getElementById("rawDecodeCapR"); + if (b) { + applyToggleBtn("btnFreeze", E.frozen, "冻结"); + b.title = E.frozen + ? "已冻结:面板③定格在冻结瞬间那一帧,轮询不会改写。再点恢复实时。" + : "冻结后本面板定格在当前帧,不再随轮询刷新;再点恢复实时"; + } + if (capL) capL.textContent = E.frozen + ? "【已冻结】原始字节" + : "原始字节 —— 整包 PDO(有 pdoin)/ 段2 rxraw;16B/行 多列"; + if (capR) capR.textContent = E.frozen + ? "【已冻结】解析摘要" + : "解析摘要(不重复贴 raw)"; +} +$("#btnFreeze").onclick = () => { + E.frozen = !E.frozen; + if (E.frozen) { + /* 冻结:锁住当前 DOM;同时记住快照(解冻重画用最新内存值,不回放冻结帧) */ + E.frozenSnap = { st: E.lastState, raw: E.raw, pdoin: E.pdoin }; + } else { + E.frozenSnap = null; + /* 解冻:立刻用最新内存数据重画,不等下一拍 */ + if (E.rxraw) renderRaw(E.lastState || null, E.raw || null); + else renderRaw(E.lastState || null, null); + } + setFreezeUi(); }; $("#btnHeavyFrames").onclick = () => { E.heavyFrames = !E.heavyFrames; - $("#btnHeavyFrames").textContent = "每拍重建:" + (E.heavyFrames ? "开" : "关"); + applyToggleBtn("btnHeavyFrames", E.heavyFrames, "每拍重建"); if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw); + saveUiOpts(); }; $("#btnHeavyHex").onclick = () => { E.heavyHex = !E.heavyHex; - $("#btnHeavyHex").textContent = "hex每拍重建:" + (E.heavyHex ? "开" : "关"); - if (E.heavyHex && E.lastState) { - if (E.rxraw && E.raw) renderRaw(E.lastState, E.raw); - else renderRaw(E.lastState, null); + applyToggleBtn("btnHeavyHex", E.heavyHex, "hex每拍重建"); + if (E.heavyHex && E.lastState && !E.frozen) { + if (E.rxraw && E.raw) { + renderRaw(E.lastState, E.raw); + E.lastRenderedRawCycle = E.raw.cycle; + } else renderRaw(E.lastState, null); } + saveUiOpts(); }; $("#hz").onchange = () => { E.win.reset(); if (E.live) ticker.update(1000 / pollHz()); + saveUiOpts(); }; +/* PDO 档位 / SPI 测参变更即落盘 */ +["periodPick", "frames", "payload", "spiTimeout"].forEach((id) => { + const n = document.getElementById(id); + if (n) n.onchange = saveUiOpts; +}); /* ═══════════════════ 渲染:指标卡 ═══════════════════ */ function renderMetrics(st, load, dbg, r, rtt) { @@ -1393,7 +1627,16 @@ function renderFrames(st, raw) { const src = best.get((f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase()); const tr = el("tr"); const td = (txt, cls) => tr.appendChild(el("td", cls, txt)); - const tdHex = (v) => { const c = el("td", "hex", hexShort(v, 32)); if (String(v).length > 32) { c.title = v; c.classList.add("trunc"); } tr.appendChild(c); }; + const tdHex = (v) => { + const s = String(v || ""); + const c = el("td", "hex", s ? s.replace(/(.{16})/g, "$1 ").trim() : "—"); + c.style.whiteSpace = "normal"; + c.style.wordBreak = "break-all"; + c.style.maxWidth = "22em"; + c.style.fontSize = "11px"; + if (s) c.title = s; + tr.appendChild(c); + }; td(clock(new Date()), "num"); td(src ? src.ch : (isNum(f.ch) ? f.ch : "—"), "num"); td("0x" + (f.id >>> 0).toString(16).toUpperCase().padStart(8, "0"), "num"); @@ -1554,22 +1797,172 @@ function renderDenseRound(raw, body) { td(s.dlc, "num"); td("0x" + (0x180 + s.node).toString(16).toUpperCase(), "num"); td("N" + s.node + " · STATUS"); - td(hexShort(s.data, 16), "hex"); + /* 48B STATUS 全量:112 hex 字符过长会撑破表,折行显示 + title 原文 */ + const fullHex = s.full || s.data || ""; + const cFull = el("td", "hex", fullHex ? fullHex.replace(/(.{16})/g, "$1 ").trim() : "—"); + cFull.style.whiteSpace = "normal"; + cFull.style.wordBreak = "break-all"; + cFull.style.fontFamily = "var(--mono)"; + cFull.style.fontSize = "11px"; + cFull.style.maxWidth = "28em"; + cFull.style.lineHeight = "1.45"; + if (fullHex) cFull.title = fullHex; + tr.appendChild(cFull); td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"), isNew || prev === undefined ? "good" : "dim"); - tr.title = "48B STATUS: " + (s.full || s.data); body.appendChild(tr); } } +/** 把 hex 串按 offset 切成「字段名 + 全量字节」行(dense 完整解码用) */ +function fmtHexBytes(hex, off, len) { + const h = String(hex || "").toUpperCase(); + const s = h.substr(off * 2, len * 2); + return s ? s.replace(/(.{2})/g, "$1 ").trim() : "—"; +} +/** xrx 长帧完整文本:ch/dlc/id + 全 payload(不截断) */ +function xrxFullText(x) { + if (!x || !x.data) return "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里"; + return "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() + + " flags=" + (x.flags ?? "—") + + "\n" + String(x.data).replace(/(.{8})/g, "$1 ").trim(); +} +/** 600B PDO 输入分区图(1.6.21 dense)—— 只读路由拼不齐整包镜像,这里按段展示能拿到的 */ +const PDO_SEG = [ + { name: "段1 桥状态", off: 0, len: 32, src: "state.bridge" }, + { name: "段2 头+密排槽", off: 32, len: 512, src: "/api/motor/rxraw" }, + { name: "段3 长帧", off: 544, len: 40, src: "state.xrx" }, + { name: "段5 诊断", off: 584, len: 12, src: "app/load" }, + { name: "保留", off: 596, len: 4, src: "—" }, +]; +function pushDec(box, text, cls) { + const n = document.createElement("i"); + n.className = cls || ""; + n.textContent = text; + box.appendChild(n); +} +/** 左=raw 多列 hex(主);右=紧凑摘要(不重复贴 STATUS raw) */ +function renderRawDecode(st, raw) { + const L = document.getElementById("rawDecodeL"); + const R = document.getElementById("rawDecodeR"); + if (!L) return; + L.textContent = ""; + if (R) R.textContent = ""; + const x = (st && st.xrx) || null; + const load = E.load || {}; + + if (!raw || !raw.raw) { + pushDec(L, E.rxraw + ? "等待本拍 /api/motor/rxraw…(保持「开始实时观测」)" + : "原始窗口未开启 —— 点「原始窗口:开」", "hdr"); + if (R) pushDec(R, "—", "hdr"); + return; + } + + const hex = String(raw.raw).toUpperCase(); + const b = new Uint8Array(hex.length / 2); + for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16); + const dv = new DataView(b.buffer); + const pin = E.pdoin && E.pdoin.ok && E.pdoin.raw ? E.pdoin : null; + + /* ── 左:原始字节(多列铺满)── */ + const dumpHex = (label, h, baseOff) => { + if (!h) return; + pushDec(L, label, "blk"); + for (let i = 0; i < h.length; i += 32) { + const offB = baseOff + i / 2; + pushDec(L, String(offB).padStart(4, " ") + " " + fmtHexBytes(h, i / 2, 16), + (offB === 0 || offB === 32 || offB === 544 || offB === 584) ? "slot" : null); + } + }; + if (pin) { + dumpHex("PDO " + pin.len + "B · cycle " + (pin.cycle || "—"), String(pin.raw).toUpperCase(), 0); + } else { + dumpHex("rxraw 段2 " + raw.len + "B · " + raw.layout, hex, raw.layout === "dense" ? 32 : 0); + } + if (x && x.data) { + pushDec(L, "", "dim"); + dumpHex("xrx dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase(), + String(x.data).toUpperCase(), pin ? 552 : 0); + } + + /* ── 右:摘要(不重复 raw)── */ + if (!R) return; + if (raw.layout === "dense") { + const count = b[0], active = b[1], boot = b[2], magic = b[3]; + const gseq = hex.length >= 16 ? dv.getUint32(4, true) : null; + pushDec(R, "头 count=" + count + " active=" + active + " boot=" + boot + + " magic=0x" + magic.toString(16).toUpperCase().padStart(2, "0") + + " gseq=" + (isNum(gseq) ? gseq.toLocaleString("en-US") : "—"), "blk"); + const n = Math.max(0, Math.min(count | 0, Math.floor((hex.length / 2 - DENSE_HDR) / DENSE_SLOT) || 0)); + for (let s = 0; s < n; s++) { + const o = DENSE_HDR + s * DENSE_SLOT; + const node = b[o], ch = b[o + 1], online = b[o + 2], dlc = b[o + 3]; + const seq = dv.getUint32(o + 4, true); + pushDec(R, "槽" + s + " n" + node + " ch" + ch + (online ? " on" : " off") + + " dlc" + dlc + " seq" + seq.toLocaleString("en-US"), "slot"); + const d = decodeStatus(fmtHexBytes(hex, o + 8, Math.min(dlc || 0, DENSE_STATUS)).replace(/ /g, "")); + if (d) { + const stBits = [d.motStatus ? "err" : null, d.magStatus ? "mag" : null, + d.thermal ? "hot" : null, d.eepromStatus ? "ee" + d.eepromStatus : null] + .filter(Boolean).join(" ") || "ok"; + pushDec(R, " " + d.mcuTemp.toFixed(1) + "/" + d.motTemp.toFixed(1) + "°C" + + " " + d.powerVoltage.toFixed(1) + "V" + + " ang" + d.rtAngle.toFixed(1) + + " " + d.cpuUsage + "%" + + " " + stBits, "dec"); + } + } + } else if (raw.layout === "win") { + pushDec(R, "win slots=" + raw.count + " wr=" + raw.lost + " ovf=" + raw.pending, "blk"); + const n = Math.min(WIN_SLOT_COUNT, Math.floor((hex.length / 2) / WIN_SLOT_SIZE)); + for (let s = 0; s < n; s++) { + const o = s * WIN_SLOT_SIZE; + const seq = dv.getUint32(o + 8, true); + if (!seq) { pushDec(R, "槽" + s + " 空", "dim"); continue; } + pushDec(R, "槽" + s + " ch" + b[o] + " dlc" + b[o + 1] + + " id0x" + dv.getUint32(o + 4, true).toString(16).toUpperCase() + + " seq" + seq + ((seq & 1) ? " 写入中" : ""), "slot"); + } + } else { + pushDec(R, raw.layout + " len=" + raw.len + " count=" + raw.count, "blk"); + } + if (x && x.data) { + pushDec(R, "", "dim"); + pushDec(R, "xrx ch" + x.ch + " dlc" + x.dlc + + " id0x" + (x.id >>> 0).toString(16).toUpperCase(), "slot"); + } + const frames = (raw && raw.parsed) || []; + pushDec(R, "", "dim"); + pushDec(R, "parsed 新帧 " + frames.length, "blk"); + frames.forEach((f, i) => { + const sub = f.id & 0xFFFF; + pushDec(R, "#" + i + " 0x" + (f.id >>> 0).toString(16).toUpperCase() + + " dlc" + f.dlc + " " + (SUB_NAME[sub] || ""), "slot"); + }); + pushDec(R, "", "dim"); + pushDec(R, "torn=" + (isNum(load.rx_torn) ? load.rx_torn : "—") + + " relto=" + (isNum(load.reltimeout) ? load.reltimeout : "—") + + " ovr=" + (isNum(load.overfull) ? load.overfull : "—"), "dim"); +} /* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */ +function setXrxFull(x) { + const xf = document.getElementById("xrxFull"); + if (xf) xf.textContent = xrxFullText(x); +} +function setRawDecodeMsg(msg) { + const L = document.getElementById("rawDecodeL"); + const R = document.getElementById("rawDecodeR"); + if (L) { L.textContent = ""; pushDec(L, msg, "hdr"); } + if (R) { R.textContent = ""; pushDec(R, "—", "hdr"); } +} function renderRaw(st, raw) { const x = (st && st.xrx) || null; - if (!E.rxraw && !raw) { - /* 原始窗口关:只刷 kv/xrx(来自 state,零额外请求),hex 区写提示不重建网格 */ + /* rxraw 开着但本拍还没拿到 raw:不能继续解 raw.layout(会抛错,后面解码/hex 全不画) */ + if (!raw) { kv("rawKv", [ ["rxraw cycle", null], - ["layout", "—(原始窗口未开)"], + ["layout", E.rxraw ? "—(等待 /api/motor/rxraw)" : "—(原始窗口未开)"], ["count / lost / pending", null], ["gseq", null], ["len (字节)", null], @@ -1578,18 +1971,10 @@ function renderRaw(st, raw) { ["xrx data", x ? x.data : null], ["wr(楔死恢复)", isNum(st && st.wr) ? st.wr : null, (st && st.wr) ? "warn" : "good"], ]); - const xf = document.getElementById("xrxFull"); - if (x && x.data) { - xf.textContent = "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() + - "\n" + x.data.replace(/(.{8})/g, "$1 ").trim(); - } else { - xf.textContent = "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里"; - } - const grid = document.getElementById("rawHex"); - if (grid.childNodes.length !== 1 || !grid.firstChild.classList || !grid.firstChild.classList.contains("hdr")) { - grid.textContent = ""; - grid.appendChild(el("i", "hdr", "原始窗口未开启 —— 点「原始窗口:开」才会每拍读 /api/motor/rxraw(对 EtherCAT 有负载)")); - } + setXrxFull(x); + setRawDecodeMsg(E.rxraw + ? "原始窗口已开,等待本拍 /api/motor/rxraw…(需保持「开始实时观测」)" + : "原始窗口未开启 —— 点上方「原始窗口:开」,并保持「开始实时观测」。"); return; } /* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf; @@ -1606,55 +1991,15 @@ function renderRaw(st, raw) { [hLbl[1], raw ? raw.lost : null], [hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null], ["gseq", isDense && isNum(raw.gseq) ? raw.gseq.toLocaleString("en-US") : null], - ["len (字节)", raw ? raw.len : null], + ["len 段2(rxraw)", raw ? raw.len : null], + ["pdoin 整包", E.pdoin && E.pdoin.len ? E.pdoin.len + "B" : "—(旧 native 或未开窗)"], ["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null], ["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null], ["xrx data", x ? x.data : null], ["wr(楔死恢复)", isNum(st && st.wr) ? st.wr : null, (st && st.wr) ? "warn" : "good"], ]); - /* xrx 完整载荷:kv 里那行是截断显示的,这里给全量 */ - const xf = document.getElementById("xrxFull"); - if (x && x.data) { - xf.textContent = "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() + - "\n" + x.data.replace(/(.{8})/g, "$1 ").trim(); - } else { - xf.textContent = "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里"; - } - const grid = document.getElementById("rawHex"); - grid.textContent = ""; - const cell = (t, cls) => grid.appendChild(el("i", cls, t)); - if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; } - if (raw.layout === "dense") { - /* 密排:8B 头 + count×56B 槽。头单独一行,每槽 56B=112 hex 切成 2 行 */ - const hex = raw.raw; - if (hex.length >= 16) cell("头 " + hexGrp(hex.substr(0, 16)), "hdr"); - for (let s = 0; s * DENSE_SLOT * 2 < hex.length - 16; s++) { - const off = 16 + s * DENSE_SLOT * 2, slotHex = hex.substr(off, DENSE_SLOT * 2); - if (!slotHex) break; - cell("槽" + s + " " + hexGrp(slotHex.substr(0, 16)), "slot"); - cell(" " + hexGrp(slotHex.substr(16, 32))); - if (slotHex.length > 48) cell(" " + hexGrp(slotHex.substr(48, 32))); - if (slotHex.length > 80) cell(" " + hexGrp(slotHex.substr(80))); - } - return; - } - if (raw.layout !== "win") { - for (let i = 0; i < raw.raw.length; i += 32) - cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32))); - return; - } - /* win 布局:5 槽 × 44B(头 12B + 数据 32B)。按槽切行、行宽固定,所以能排成多列网格 - * —— 原来单列 pre 每行只用掉面板四成宽度。快照长 232B,5 槽只占 220B, - * 剩下 12B 是段 4 之后的邻接字节:单独一行标"尾",不能当"槽5"(会被读成窗口多一格)。 */ - for (let s = 0; s < WIN_SLOT_COUNT && s * 88 < raw.raw.length; s++) { - const hdr = raw.raw.substr(s * 88, 24), dat = raw.raw.substr(s * 88 + 24, 64); - if (!hdr) break; - cell("槽" + s + " " + hexGrp(hdr), "slot"); - cell(" " + hexGrp(dat.substr(0, 32))); - if (dat.length > 32) cell(" " + hexGrp(dat.substr(32, 32))); - } - const tail = raw.raw.substr(WIN_SLOT_COUNT * 88); - if (tail) cell("尾 " + hexGrp(tail), "hdr"); + setXrxFull(x); + renderRawDecode(st, raw); } /* ═══════════════════ 渲染:逐拍节拍环 ═══════════════════ */ @@ -1787,13 +2132,15 @@ function drawChart() { } $("#btnChartMax").onclick = () => { E.chartOpt.max = !E.chartOpt.max; - $("#btnChartMax").textContent = "单拍 max:" + (E.chartOpt.max ? "开" : "关"); + applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max"); drawChart(); + saveUiOpts(); }; $("#btnChartSkip").onclick = () => { E.chartOpt.skip = !E.chartOpt.skip; - $("#btnChartSkip").textContent = "跳拍:" + (E.chartOpt.skip ? "开" : "关"); + applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍"); drawChart(); + saveUiOpts(); }; window.addEventListener("resize", layoutChart); @@ -1833,9 +2180,12 @@ $("#btnRxbClear").onclick = () => { /* ═══════════════════ 初始化 ═══════════════════ */ showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat"); layoutChart(); -window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); }); +restoreUiOpts(); /* 恢复上次的开关/采样率/曲线选项 */ +renderRaw(null, null); /* 面板③先画「未开启」占位,避免两块空白看起来像坏了 */ +setFreezeUi(); +window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); saveUiOpts(); }); /* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */ -window.__speedtest = { E, eapi, RateWindow, decodeStatus }; +window.__speedtest = { E, eapi, RateWindow, decodeStatus, renderRaw, renderRawDecode, saveUiOpts, restoreUiOpts }; })(); diff --git a/pages/index.html b/pages/index.html index 0b4c8ff..a841fb5 100644 --- a/pages/index.html +++ b/pages/index.html @@ -13,7 +13,7 @@ - + @@ -59,9 +59,19 @@
🔗 设备连接
-
- - +
+ + +
@@ -135,6 +145,35 @@
点击按钮连接 / 断开蓝牙设备
+ +
@@ -143,9 +182,15 @@
🎯 升级目标、固件文件与执行操作
-
- - +
+ +
@@ -233,18 +278,28 @@
+ +
- -
+ +
⚡ 刷写进度与流程阶段
📦ESP32 OTA
📤GD32 OTA
+
⚡EtherCAT
@@ -282,6 +337,16 @@
5
APP 启动
6
OTA完成
+ +
待机 @@ -344,7 +409,7 @@
ESP32 温度
--
-
GD32 温度
--
+
GD32 温度
--
GD32 负载
--
输入 VCC
--
VDDA
--
@@ -500,8 +565,12 @@ 操作成功
- - + + + +