/** * app.js - ESP32 OTA 中继控制台入口(WiFi OTA 通道) * * 薄入口:import 所有 ota/* + common/* 模块,将 HTML onclick 引用的函数 * 显式挂载到 window 对象,保留原有内联事件处理器兼容。 * * 模块通过 window.* 共享状态,模块间依赖通过 import 解耦。 * 此入口只负责挂载 + 顶层副作用(拖拽区绑定、SPI 输入监听、启动轮询)。 */ import { formatBytes, formatTime, formatUptime, escapeHtml } from './common/utils.js'; import { showToast } from './common/toast.js'; import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js'; import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl, updateLinkPills, setWifiPillOverride } from './common/connection.js'; import { scheduleStatusPoll, startPolling, pausePollingForUpload, resumePollingAfterUpload, pollStatus, pollLogs, setStatusPollIntervalMs } from './common/polling.js?v=20260916e'; import { OtaSessionState, otaSessionState, otaSessionTarget, otaFailReason, otaSessionActive, otaSessionTerminated, otaSessionTransition, otaSessionReset, clearOtaSessionResiduals, stopUploadWaitHeartbeat, startUploadWaitHeartbeat } from './ota/session.js'; import { toggleCollapse, switchStageFlow, updateEsp32StagesFromPhase, updateEsp32StagesError, cleanupAfterOtaFailure, setActivePhase, markPhaseDone, setProgress, resetProgress, esp32PhaseLabels } from './ota/progress.js'; import { updateStagesFromRelayState, updateSystemStatusUi, clearLiveMetricsOnDisconnect } from './ota/stages.js?v=20260916d'; import { startEcatLoadPoller } from './ota/ecat_load.js'; import { deviceLink } from './common/device-link.js'; import { isValidIpv4 } from './common/base-url.js'; import { handleFileSelect, pickFileViaFSAPI, setupDropZone, parseIntelHex, isIntelHexText, convertHexFileToBin, processFile, clearFile, resumePersistedFirmwareWithGesture } from './ota/file-handle.js'; import { startUpload, verifyEsp32Firmware, restartEsp32 } from './ota/upload-esp32.js'; import { triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch, startRelayProgressWatch, emitRelayProgress, ensureRelayProgressComplete } from './ota/upload-gd32.js'; import { bootEcatProbe, bootEcatFlash, bootEcatAbort, bootEcatPick, bootEcatReboot, refreshBootEcatUi } from './ota/boot_ecat.js?v=20261013b'; // ==================== 工具函数 ==================== function formatPercent(value) { if (!Number.isFinite(value)) return '--'; return value.toFixed(1).replace(/\.0$/, '') + '%'; } /** 设备 IP:优先本页 #espIp 输入框,否则 ConnectionHub。 * 只在值变化时 setIp——Owner 会把 set-ip 当成立即重探,读路径每次写会 1Hz 风暴。 * 输入框不是合法 IPv4 时**不参与拼 URL**:否则本页打到脏地址、Owner 与其余 * 标签还打在旧端点上,会出现"灯是绿的、上传是失败的"这种分裂状态。 */ function getEspIp() { var el = document.getElementById('espIp'); var ip = el && el.value ? el.value.trim() : ''; if (ip && !isValidIpv4(ip)) return deviceLink.getIp(); if (!ip) return deviceLink.getIp(); if (ip !== deviceLink.getIp()) { try { deviceLink.setIp(ip); } catch (_) {} } return ip; } function getBaseUrl() { return 'http://' + getEspIp(); } function updateUploadButtonLabel() { var esp32Btn = document.getElementById('uploadEsp32Btn'); var gd32Btn = document.getElementById('uploadGd32Btn'); /* 仅 XHR 上传期禁用(isUploading)。校验/中继/重启期允许按钮恢复可点, * startUpload 用 otaSessionActive() 拒绝并发会话。 */ 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) { var el = document.getElementById(id); if (el) el.className = 'stage-node'; }); ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) { var el = document.getElementById(id); if (el) el.textContent = ''; }); var statusBar = document.getElementById('stageStatusBar'); if (statusBar) statusBar.className = 'stage-status-bar'; var statusText = document.getElementById('stageStatusText'); if (statusText) statusText.textContent = '待机'; } function applyDisconnectedRuntimeState() { var elIp = document.getElementById('pillIP'); if (elIp) elIp.textContent = '--'; var elRssi = document.getElementById('pillRSSI'); if (elRssi) elRssi.textContent = '--'; // 断线约 1s(本轮轮询失败)即清实时指标,防止残留旧温度/电压误导 clearLiveMetricsOnDisconnect(); // ★ 状态机守卫:会话活跃时不重置进度/阶段,避免覆盖进行中的 OTA 进度 if (otaSessionActive()) return; // FAILED 态:保留失败进度显示,只清连接胶囊(让用户看到失败原因) if (otaSessionState === OtaSessionState.FAILED) return; resetProgress(); resetStageFlow(); } function applyRebootingRuntimeState() { var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); setWifiPillOverride('重启中 ' + elapsed + 's'); var bc = document.getElementById('bConn'); if (bc) bc.textContent = '重启中'; if (bc) bc.className = 'v warn'; } function applyUploadingRuntimeState() { // ★ 状态机守卫:会话已失败/完成时,绝不重新应用"上传中"运行时态, // 防止网络抖动后的轮询错误分支把 UI 拉回"上传中"。 if (otaSessionState === OtaSessionState.FAILED || otaSessionState === OtaSessionState.DONE) { return; } setWifiPillOverride('上传中'); var bc = document.getElementById('bConn'); if (bc) bc.textContent = '上传中'; if (bc) bc.className = 'v warn'; } /** 设备忙:覆盖 WiFi 胶囊文案 + 系统状态徽章。 */ function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) { setWifiPillOverride(pillText || '设备忙'); var bc = document.getElementById('bConn'); if (bc) bc.textContent = badgeText || '设备忙'; if (bc) bc.className = 'v warn'; } // ==================== 挂载 ota/* 函数到 window(供其他模块通过 window.* 调用) ==================== window.OtaSessionState = OtaSessionState; window.otaSessionActive = otaSessionActive; window.otaSessionTerminated = otaSessionTerminated; window.otaSessionTransition = otaSessionTransition; window.otaSessionReset = otaSessionReset; window.clearOtaSessionResiduals = clearOtaSessionResiduals; window.stopUploadWaitHeartbeat = stopUploadWaitHeartbeat; window.startUploadWaitHeartbeat = startUploadWaitHeartbeat; window.toggleCollapse = toggleCollapse; window.switchStageFlow = switchStageFlow; window.updateEsp32StagesFromPhase = updateEsp32StagesFromPhase; window.updateEsp32StagesError = updateEsp32StagesError; window.cleanupAfterOtaFailure = cleanupAfterOtaFailure; window.setActivePhase = setActivePhase; window.markPhaseDone = markPhaseDone; window.setProgress = setProgress; window.resetProgress = resetProgress; window.updateStagesFromRelayState = updateStagesFromRelayState; window.updateSystemStatusUi = updateSystemStatusUi; window.clearLiveMetricsOnDisconnect = clearLiveMetricsOnDisconnect; window.handleFileSelect = handleFileSelect; window.pickFileViaFSAPI = pickFileViaFSAPI; window.resumePersistedFirmwareWithGesture = resumePersistedFirmwareWithGesture; window.parseIntelHex = parseIntelHex; window.isIntelHexText = isIntelHexText; window.convertHexFileToBin = convertHexFileToBin; window.processFile = processFile; window.clearFile = clearFile; window.startUpload = startUpload; window.verifyEsp32Firmware = verifyEsp32Firmware; window.restartEsp32 = restartEsp32; window.triggerGd32Relay = triggerGd32Relay; window.markGd32OtaUiComplete = markGd32OtaUiComplete; window.stopRelayProgressWatch = stopRelayProgressWatch; 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; window.formatUptime = formatUptime; window.escapeHtml = escapeHtml; window.formatPercent = formatPercent; window.showToast = showToast; window.addLog = addLog; window.clearLogs = clearLogs; window.setLogFilter = setLogFilter; window.toggleAutoScroll = toggleAutoScroll; window.updateConnectionUi = updateConnectionUi; window.updateLinkPills = updateLinkPills; window.setWifiPillOverride = setWifiPillOverride; window.updateHealthUi = updateHealthUi; window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi; window.testConnection = testConnection; window.refreshSystemStatusNow = refreshSystemStatusNow; window.systemStatusUrl = systemStatusUrl; window.scheduleStatusPoll = scheduleStatusPoll; window.setStatusPollIntervalMs = setStatusPollIntervalMs; window.startPolling = startPolling; window.pausePollingForUpload = pausePollingForUpload; window.resumePollingAfterUpload = resumePollingAfterUpload; window.pollStatus = pollStatus; window.pollLogs = pollLogs; window.diagnoseNetwork = diagnoseNetwork; // ==================== 挂载本入口的辅助函数到 window ==================== window.getEspIp = getEspIp; window.getBaseUrl = getBaseUrl; window.updateUploadButtonLabel = updateUploadButtonLabel; window.resetStageFlow = resetStageFlow; window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState; window.applyRebootingRuntimeState = applyRebootingRuntimeState; window.applyUploadingRuntimeState = applyUploadingRuntimeState; window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState; // ==================== data-action 事件委托(替代 HTML 内联 onclick) ==================== // 处理函数分散在 app.js / ota/console-mode.js / ble_ota.js 三个入口,均挂载在 // window 上;委托在点击时才解引用,天然规避模块加载顺序问题。 document.addEventListener('click', function (ev) { var target = ev.target.closest('[data-action]'); if (!target) return; var arg = target.getAttribute('data-arg'); switch (target.getAttribute('data-action')) { case 'set-console-mode': if (window.setConsoleMode) window.setConsoleMode(arg); break; case 'test-connection': testConnection(); break; case 'select-flash-target': if (window.selectFlashTarget) window.selectFlashTarget(arg); break; case 'pick-shared-firmware': if (window.pickSharedFirmware) window.pickSharedFirmware(arg); break; case 'clear-shared-firmware': if (window.clearSharedFirmware) window.clearSharedFirmware(arg); break; 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': if (!window.bootEcatBusy) 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; } }); // ==================== ESP32 WiFi 使能(PC9 → ESP32 EN) ==================== // 走 EtherCAT SDO 0x2503(/ecat/api/esp32/en),与 WiFi 解耦。 // action:0=拉高(开) 1=拉低保持(关) function sendEsp32En(action) { return fetch('/ecat/api/esp32/en', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action }), signal: AbortSignal.timeout(8000) }).then(function (r) { return r.json().then(function (j) { if (!r.ok || j.ok === false) throw new Error((j.log && j.log[0]) || j.err || ('HTTP ' + r.status)); return typeof j.status === 'number' ? j.status : -1; }); }); } function setWifiEnChecked(on) { var sw = document.getElementById('esp32WifiSw'); if (sw) sw.checked = !!on; } function applyEsp32WifiSwitch(on) { sendEsp32En(on ? 0 : 1).then(function () { setWifiEnChecked(on); showToast(on ? 'ESP32 WiFi 已开启' : 'ESP32 WiFi 已关断', on ? 'success' : 'warning'); }).catch(function (e) { setWifiEnChecked(!on); showToast('EtherCAT 下发失败: ' + e.message, 'error'); }); } (function bindWifiEnSwitch() { var sw = document.getElementById('esp32WifiSw'); if (!sw) return; sw.addEventListener('change', function () { applyEsp32WifiSwitch(sw.checked); }); })(); // 共享固件文件选择(原内联 onchange="handleSharedFileSelect(event, chip)") ['esp32', 'gd32'].forEach(function (chip) { var input = document.getElementById(chip === 'esp32' ? 'fileInputEsp32' : 'fileInputGd32'); if (input) { input.addEventListener('change', function (ev) { if (window.handleSharedFileSelect) window.handleSharedFileSelect(ev, chip); }); } }); // BLE 上传区拖拽(原内联 ondragover/ondragleave/ondrop) var bleZone = document.getElementById('uploadZone'); if (bleZone) { bleZone.addEventListener('dragover', function (ev) { ev.preventDefault(); bleZone.classList.add('dragover'); }); bleZone.addEventListener('dragleave', function () { bleZone.classList.remove('dragover'); }); bleZone.addEventListener('drop', function (ev) { ev.preventDefault(); bleZone.classList.remove('dragover'); var f = ev.dataTransfer.files[0]; if (f && window.bleOta) window.bleOta.notifyFileChange(f, null); }); } // ==================== 顶层副作用(原 app.js 末尾的初始化) ==================== setupDropZone('uploadZoneEsp32', 'esp32'); setupDropZone('uploadZoneGd32', 'gd32'); // 启动轮询(必须在所有模块加载完成、window.OtaSessionState 已挂载后调用, // 否则 polling.js 的 scheduleStatusPoll 会因 window.OtaSessionState 未定义而抛 TypeError) startPolling(); /* DeviceLink 快照判 ESP32 离线时也清指标: * 本页 pollStatus 可能 AbortError 静默返回,或 iframe 里旧模块未清—— * 外壳 Owner 是更权威的「设备在线」来源,离线布尔一翻就清。 */ deviceLink.subscribe(function (s) { try { if (!s || !s.ts || !s.links) return; if ((Date.now() - s.ts) >= 5000) return; /* 顶栏三链路:快照一到就刷 WiFi/ECAT 灯(BLE 另走 __bleConnState) */ if (window.updateLinkPills) window.updateLinkPills(null, null); if (!s.links.esp32) return; if (s.links.esp32.online) return; clearLiveMetricsOnDisconnect(); } catch (_) {} }); // GD32 CPU 负载率(EtherCAT 通道,独立于 /system/status 轮询;页面上无该指标则自动空转) startEcatLoadPoller(); /* ── ECAT 胶囊:0x1008 设备名只在「断开→重连」时读一次,平时不轮询 ── * 路径:/ecat/api/ota/ecat probe 的 name(对象字典 0x1008,如 GD32E508 DCU) * 注意:status.slave.name 是 SOEM SII 名(DCU Project),不是这里要的。 */ function refreshEcatPillName() { /* 只负责取从站名字(0x1008)并公布到 window.__ecatProbeName; 灯色与文案由 connection.js 的 updateLinkPills 单点出。这里以前连 className 一起写, 和快照那条路节奏不同,实测出现过"绿点 + 文字离线"的自相矛盾。 */ fetch('/ecat/api/ota/ecat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op: 'probe' }), cache: 'no-store', signal: AbortSignal.timeout(5000), }).then(function (r) { return r.json(); }).then(function (st) { /* probe 对 APP 返回 ok:false,name 仍是 0x1008 读数 */ window.__ecatProbeName = (st && st.name) ? String(st.name).trim() : ''; updateLinkPills(); }).catch(function () { window.__ecatProbeName = ''; updateLinkPills(); }); } (function bindEcatNameOnReconnect() { var lastOnline = null; deviceLink.subscribe(function (s) { var on = !!(s && s.links && s.links.ecat && s.links.ecat.busOpen); if (!on) { /* 总线没开:名字作废,灯色/文案交给 updateLinkPills 按快照出,不在这里另写一套 */ if (lastOnline !== false) { lastOnline = false; window.__ecatProbeName = ''; updateLinkPills(); } return; } /* 首次在线 或 断开后重连 → 读一次 0x1008;保持在线则不再读 */ if (lastOnline !== true) { lastOnline = true; refreshEcatPillName(); } }); })(); // IP 输入变化时清零失败计数并立即刷新状态 var ipInput = document.getElementById('espIp'); if (ipInput) { try { var savedIp = deviceLink.getIp(); if (savedIp && savedIp.trim()) ipInput.value = savedIp.trim(); } catch (_) {} ipInput.addEventListener('change', function () { var r = null; try { r = deviceLink.setIp(ipInput.value.trim()); } catch (_) { r = { ok: false, reason: '写入异常' }; } if (!r || !r.ok) { /* 拒绝要说得出原因,也不把输入框停在脏值上:静默收下脏 IP 会让六个标签 打到旧端点、输入框显示新值,"看起来连着"就是假绿灯。 */ var why = (r && r.reason) || '未接受'; addLog('E', '系统', '端点未改:' + why); showToast('端点未改:' + why, 'error', { force: true }); var eff = deviceLink.getIp(); if (eff && ipInput.value.trim() !== eff) ipInput.value = eff; return; } window.consecutivePollFailures = 0; addLog('I', '系统', 'IP 已修改 → ' + r.ip + ',重新启动轮询...'); if (!window.statusPollInFlight) { scheduleStatusPoll(0); } }); /* 端点被别处改了(发现面板选中、另一页手输)要回流到本输入框, 否则六个标签共用新端点、这一页的框还显示旧值。用户正在敲时不覆盖。 */ deviceLink.subscribe(function (s) { if (!s || !s.ip) return; if (document.activeElement === ipInput) return; if (ipInput.value.trim() !== s.ip) ipInput.value = s.ip; }); } // 初始化按钮状态与提示 setTimeout(function () { updateUploadButtonLabel(); addLog('I', '系统', '控制台初始化完成,等待固件文件...'); }, 500);