Files
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

435 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);