Files
ESP32_COMM/appjs_diff.txt
T
小龙虾 2caf10b9de feat(ota): 添加OTA回滚保护和固件完整性校验功能
- 启用CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE和CONFIG_APP_ROLLBACK_ENABLE配置选项
- 在main.cpp中添加esp_ota_mark_app_valid_cancel_rollback()调用,防止意外回滚
- 实现SHA-256固件完整性校验,包括计算、存储和验证功能
- 添加ota_mutex.h头文件用于ESP32 OTA全局互斥锁管理

feat(health_monitor): 实现线程安全的健康状态管理

- 将HealthMonitor类的健康状态改为私有成员并通过mutex保护
- 添加getState()和setState()方法提供线程安全的状态访问
- 修复多任务并发访问健康状态时的竞争条件问题

feat(ota_relay): 增强中继模块的线程安全性和可靠性

- 为OtaRelay类添加静态mutex保护状态变量访问
- 实现StateLockGuard RAII锁管理器确保异常安全
- 添加全局超时机制防止OTA过程无限等待
- 改进flash写入逻辑支持非4字节对齐数据处理

refactor(app_config): 更新版本信息和配置常量

- 更新BUILD_TIME至最新编译时间
- 更新GIT_HASH至最新提交
- 添加BLE_OTA_CTRL_VERIFY_SHA256命令常量定义
2026-06-14 17:10:24 +08:00

1315 lines
50 KiB
Plaintext
Raw 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.
diff --git a/tools/app.js b/tools/app.js
index 8a3a857..7742275 100644
--- a/tools/app.js
+++ b/tools/app.js
@@ -15,6 +15,96 @@ let isEsp32Rebooting = false;
let rebootStartTime = 0;
// ==================== 刷写进度 ====================
var activePhase = null; // 'esp32Ota' | 'gd32Upload' | 'gd32Relay' | null
+var currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
+// 切换 OTA 阶段面板显示
+function switchStageFlow(target) {
+ currentOtaTarget = target;
+ var esp32El = document.getElementById('esp32Stages');
+ var gd32El = document.getElementById('gd32Stages');
+ if (target === 'esp32') {
+ if (esp32El) esp32El.style.display = '';
+ if (gd32El) gd32El.style.display = 'none';
+ } else {
+ if (esp32El) esp32El.style.display = 'none';
+ if (gd32El) gd32El.style.display = '';
+ }
+ // 重置 ESP32 阶段节点
+ ['es1','es2','es3','es4','es5','es6'].forEach(function(id) {
+ var el = document.getElementById(id);
+ if (el) el.className = 'stage-node';
+ });
+ ['esd1','esd2','esd3','esd4','esd5','esd6'].forEach(function(id) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = '';
+ });
+ // 重置 GD32 阶段节点
+ ['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 = '';
+ });
+}
+// ESP32 OTA 阶段状态: 0=待机, 1=上传中, 2=OTA写入, 3=SHA-256校验, 4=应用固件, 5=重启中, 6=启动完成
+var esp32OtaPhase = 0;
+var esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应用中', '重启中', '已完成'];
+function updateEsp32StagesFromPhase(phase) {
+ esp32OtaPhase = phase;
+ var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
+ var detailIds = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
+ var all = ids.map(function(id) { return document.getElementById(id); });
+ var details = detailIds.map(function(id) { return document.getElementById(id); });
+ all.forEach(function (s) { if (s) s.className = 'stage-node'; });
+ details.forEach(function (d) { if (d) d.textContent = ''; });
+ var statusBar = document.getElementById('stageStatusBar');
+ var statusText = document.getElementById('stageStatusText');
+ if (phase <= 0) {
+ if (statusBar) statusBar.className = 'stage-status-bar';
+ if (statusText) statusText.textContent = '待机';
+ return;
+ }
+ for (var i = 0; i < phase - 1 && i < 6; i++) {
+ if (all[i]) all[i].className = 'stage-node done';
+ }
+ if (phase >= 1 && phase <= 6) {
+ if (all[phase - 1]) all[phase - 1].className = 'stage-node live';
+ if (details[phase - 1]) details[phase - 1].textContent = esp32PhaseLabels[phase] || '';
+ }
+ if (statusBar && statusText) {
+ if (phase >= 6) {
+ statusBar.className = 'stage-status-bar ok';
+ statusText.textContent = 'ESP32 刷写完成';
+ } else {
+ statusBar.className = 'stage-status-bar active';
+ statusText.textContent = (esp32PhaseLabels[phase] || '进行中') + ' (' + phase + '/6)';
+ }
+ }
+}
+function updateEsp32StagesError() {
+ var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
+ var all = ids.map(function(id) { return document.getElementById(id); });
+ all.forEach(function (s) { if (s) s.className = 'stage-node'; });
+ var statusBar = document.getElementById('stageStatusBar');
+ var statusText = document.getElementById('stageStatusText');
+ if (statusBar) statusBar.className = 'stage-status-bar err';
+ if (statusText) statusText.textContent = '校验/应用失败';
+}
+
+function showVerifyActions(type) {
+ // type: 'apply' | 'retry' | 'none'
+ var actions = document.getElementById('stageVerifyActions');
+ if (!actions) return;
+ if (type === 'none') { actions.style.display = 'none'; return; }
+ actions.style.display = 'block';
+ var applyBtn = document.getElementById('applyEsp32Btn');
+ var retryBtn = document.getElementById('retryEsp32Btn');
+ var cancelBtn = document.getElementById('cancelEsp32Btn');
+ if (applyBtn) applyBtn.style.display = (type === 'apply') ? 'inline-block' : 'none';
+ if (retryBtn) retryBtn.style.display = (type === 'retry') ? 'inline-block' : 'none';
+ if (cancelBtn) cancelBtn.style.display = (type === 'retry') ? 'inline-block' : 'none';
+}
function setActivePhase(phase) {
activePhase = phase;
@@ -29,18 +119,16 @@ function setActivePhase(phase) {
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
-
function markPhaseDone(phase) {
var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' };
var el = document.getElementById(stepMap[phase]);
if (el) { el.classList.remove('active'); el.classList.add('done'); }
// All done? turn progress bar green
- if (phase === 'gd32Relay') {
+ if (phase === 'gd32Relay' || phase === 'esp32Ota') {
var fill = document.getElementById('progressFill');
if (fill) fill.classList.add('done');
}
}
-
function setProgress(pct, text) {
var fillEl = document.getElementById('progressFill');
var txtEl = document.getElementById('progressTxt');
@@ -49,10 +137,11 @@ function setProgress(pct, text) {
if (txtEl) txtEl.textContent = text;
if (pctEl) pctEl.textContent = pct + '%';
}
-
function resetProgress() {
setProgress(0, '等待操作...');
activePhase = null;
+ esp32OtaPhase = 0;
+ switchStageFlow(null);
['ppEsp32', 'ppGd32Up', 'ppGd32Relay'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
@@ -60,14 +149,12 @@ function resetProgress() {
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
-
// ==================== 工具函数 ====================
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
-
function formatTime(date) {
var h = String(date.getHours()).padStart(2, '0');
var m = String(date.getMinutes()).padStart(2, '0');
@@ -75,7 +162,6 @@ function formatTime(date) {
var ms = String(date.getMilliseconds()).padStart(3, '0');
return h + ':' + m + ':' + s + '.' + ms;
}
-
function formatUptime(sec) {
if (!Number.isFinite(sec) || sec < 0) return '--';
const d = Math.floor(sec / 86400);
@@ -87,13 +173,11 @@ function formatUptime(sec) {
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
-
function formatHttpErrorDetail(text) {
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
return detail;
}
-
function showToast(message, type) {
type = type || 'success';
const toast = document.getElementById('toast');
@@ -104,32 +188,25 @@ function showToast(message, type) {
msg.textContent = message;
setTimeout(function () { toast.classList.remove('show'); }, 3000);
}
-
function getEspIp() {
return document.getElementById('espIp').value.trim() || '192.168.31.127';
}
-
function getBaseUrl() {
return 'http://' + getEspIp();
}
-
// ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) {
return level + '|' + tag + '|' + message;
}
-
function addLog(level, tag, message) {
var scroll = document.getElementById('logScroll');
var time = formatTime(new Date());
var hash = makeLogHash(level, tag, message);
if (hash === lastLogHash) return; // 去重
lastLogHash = hash;
-
var entry = { time: time, level: level, tag: tag, message: message };
logEntries.push(entry);
-
if (logFilter !== 'all' && level !== logFilter) return;
-
var div = document.createElement('div');
div.className = 'log-line';
div.innerHTML =
@@ -138,18 +215,15 @@ function addLog(level, tag, message) {
'<span class="lg">[' + tag + ']</span>' +
'<span class="lm">' + message + '</span>';
scroll.appendChild(div);
-
if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
document.getElementById('logCount').textContent = logEntries.length;
}
-
function clearLogs() {
document.getElementById('logScroll').innerHTML = '';
logEntries = [];
lastLogHash = null;
document.getElementById('logCount').textContent = '0';
}
-
function setLogFilter(level, btn) {
document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
@@ -170,27 +244,23 @@ function setLogFilter(level, btn) {
});
if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
}
-
function toggleAutoScroll() {
autoScroll = !autoScroll;
var btn = document.getElementById('autoScrollBtn');
btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
}
-
function updateUploadButtonLabel() {
var esp32Btn = document.getElementById('uploadEsp32Btn');
var gd32Btn = document.getElementById('uploadGd32Btn');
if (esp32Btn) esp32Btn.disabled = !esp32File || isUploading;
if (gd32Btn) gd32Btn.disabled = !gd32File || isUploading;
}
-
function setHelper(id, text, type) {
var el = document.getElementById(id);
if (!el) return;
el.className = 'helper-note ' + (type || '');
el.innerHTML = text;
}
-
function updateCanHint() {
var dlc = parseInt(document.getElementById('canDlc').value) || 0;
var dataHex = document.getElementById('canData').value.trim().replace(/\s/g, '');
@@ -213,14 +283,20 @@ function updateCanHint() {
}
setHelper('canHint', 'HEX 数据合法,当前长度 <b>' + bytes + '</b> 字节,与 DLC 一致。', 'ok');
}
-
function resetStageFlow() {
- ['s1', 's2', 's3', 's4'].forEach(function (id) {
+ ['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() {
document.getElementById('pillIP').textContent = '--';
document.getElementById('pillRSSI').textContent = '--';
@@ -233,43 +309,35 @@ function applyDisconnectedRuntimeState() {
resetProgress();
resetStageFlow();
}
-
function applyRebootingRuntimeState() {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
pc.className = 'pill processing';
var elapsed = Math.round((Date.now() - rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
-
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
cs.className = 'conn-indicator warn';
cst.textContent = 'ESP32 正在重启...';
-
var bc = document.getElementById('bConn');
bc.textContent = '重启中';
bc.className = 'v warn';
-
document.getElementById('pillRelayTxt').textContent = '重启中';
document.getElementById('pillRelay').className = 'pill processing';
}
-
function applyUploadingRuntimeState() {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
pc.className = 'pill processing';
pct.textContent = '上传中';
-
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
cs.className = 'conn-indicator warn';
cst.textContent = '固件上传中,设备忙...';
-
var bc = document.getElementById('bConn');
bc.textContent = '上传中';
bc.className = 'v warn';
}
-
// ==================== 连接管理 ====================
function updateConnectionUi(connected, text) {
// header pill
@@ -277,19 +345,16 @@ function updateConnectionUi(connected, text) {
var pct = document.getElementById('pillConnTxt');
pc.className = connected ? 'pill online' : 'pill offline';
pct.textContent = text || (connected ? '已连接' : '未连接');
-
// connection card indicator
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
cst.textContent = connected ? (text || '已连接') : '未连接';
-
// primary badge
var bc = document.getElementById('bConn');
bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
}
-
function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
@@ -301,70 +366,93 @@ function updateHealthUi(healthCode, freeHeap) {
document.getElementById('mHeap').textContent = formatBytes(freeHeap);
}
}
-
// ==================== OTA 阶段驱动 (真实状态) ====================
+// Relay state → 6-stage mapping:
+// Stage 1 (认证握手): rs 1-6 握手/挑战/会话密钥/认证
+// Stage 2 (传输协商): rs 7-8 固件大小协商
+// Stage 3 (数据转发): rs 9-13 数据包收发/ACK/重发
+// Stage 4 (传输完成): rs 14-15 数据接收完毕
+// Stage 5 (OTA刷写): rs 16-17 OTA 结束/完成
+// Stage 6 (校验验证): rs 18 APP 校验
+// Error: rs 255
+var relayStateDetailMap = {
+ 0: '待机',
+ 1: '等待握手', 2: '握手已发', 3: '挑战已发',
+ 4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成',
+ 7: '大小已发', 8: '大小确认',
+ 9: '接收中', 10: '转发中', 11: '数据包已发', 12: 'ACK正常', 13: '等待重发',
+ 14: '接收完成', 15: '传输完成',
+ 16: '结束已发', 17: 'OTA完成',
+ 18: 'APP验证',
+ 255: '错误'
+};
function updateStagesFromRelayState(rs) {
- var s1 = document.getElementById('s1');
- var s2 = document.getElementById('s2');
- var s3 = document.getElementById('s3');
- var s4 = document.getElementById('s4');
- var all = [s1, s2, s3, s4];
-
+ var ids = ['s1', 's2', 's3', 's4', 's5', 's6'];
+ var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'];
+ var all = ids.map(function(id) { return document.getElementById(id); });
+ var details = detailIds.map(function(id) { return document.getElementById(id); });
// reset all
- all.forEach(function (s) { s.className = 'stage-node'; });
-
- if (rs === undefined || rs === null) return;
-
+ all.forEach(function (s) { if (s) s.className = 'stage-node'; });
+ details.forEach(function (d) { if (d) d.textContent = ''; });
+ // status bar
+ var statusBar = document.getElementById('stageStatusBar');
+ var statusText = document.getElementById('stageStatusText');
+ if (rs === undefined || rs === null) {
+ if (statusBar) statusBar.className = 'stage-status-bar';
+ if (statusText) statusText.textContent = '待机';
+ return;
+ }
if (rs === 255) {
- s1.className = 'stage-node err';
- s2.className = 'stage-node err';
- s3.className = 'stage-node err';
- s4.className = 'stage-node err';
+ all.forEach(function (s) { if (s) s.className = 'stage-node err'; });
+ if (statusBar) statusBar.className = 'stage-status-bar err';
+ if (statusText) statusText.textContent = '错误';
return;
}
-
var activeStage = 0;
if (rs >= 1 && rs <= 6) activeStage = 1;
else if (rs >= 7 && rs <= 8) activeStage = 2;
- else if (rs >= 9 && rs <= 15) activeStage = 3;
- else if (rs >= 16 && rs <= 18) activeStage = 4;
-
+ else if (rs >= 9 && rs <= 13) activeStage = 3;
+ else if (rs >= 14 && rs <= 15) activeStage = 4;
+ else if (rs >= 16 && rs <= 17) activeStage = 5;
+ else if (rs >= 18 && rs <= 18) activeStage = 6;
for (var i = 0; i < activeStage - 1; i++) {
- all[i].className = 'stage-node done';
+ if (all[i]) all[i].className = 'stage-node done';
+ }
+ if (activeStage >= 1 && activeStage <= 6) {
+ if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
+ }
+ // Show detail text for the active stage
+ var detailText = relayStateDetailMap[rs] || '';
+ if (activeStage >= 1 && activeStage <= 6 && details[activeStage - 1]) {
+ details[activeStage - 1].textContent = detailText;
}
- if (activeStage >= 1 && activeStage <= 4) {
- all[activeStage - 1].className = 'stage-node live';
+ // Update status bar
+ if (statusBar && statusText) {
+ if (rs === 0) {
+ statusBar.className = 'stage-status-bar';
+ statusText.textContent = '待机';
+ } else {
+ statusBar.className = 'stage-status-bar active';
+ statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')';
+ }
}
}
-
function updateSystemStatusUi(data) {
if (!data) return;
latestSystemStatus = data;
-
var wifi = data.wifi || {};
var ota = data.ota || {};
var healthCode = data.health;
-
- var relayStates = {
- 0: '待机', 1: '等待握手', 2: '握手已发', 3: '挑战已发',
- 4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成',
- 7: '大小已发', 8: '大小确认', 9: '接收中', 10: '转发中',
- 11: '数据包已发', 12: 'ACK正常', 13: '等待重发',
- 14: '接收完成', 15: '传输完成', 16: '结束已发', 17: 'OTA完成', 18: 'APP验证', 255: '错误'
- };
-
+ // Use global relayStateDetailMap defined in updateStagesFromRelayState section
var connected = !!wifi.connected;
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
-
updateConnectionUi(connected, connText);
updateHealthUi(healthCode, data.free_heap);
-
// version
if (data.version) {
document.getElementById('pillVer').textContent = 'v' + data.version;
document.getElementById('pillVer').title = data.build_time ? ('编译: ' + data.build_time) : '';
document.getElementById('mVersion').textContent = 'v' + data.version + (data.build_time ? ' (' + data.build_time + ')' : '');
-
// GD32 版本
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
@@ -387,28 +475,23 @@ function updateSystemStatusUi(data) {
}
}
}
-
// temperature
document.getElementById('mTemp').textContent =
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
-
// uptime
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
-
// wifi / signal
document.getElementById('mWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接';
document.getElementById('pillIP').textContent = wifi.ip || '--';
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
document.getElementById('mRSSI').textContent = rssi;
document.getElementById('pillRSSI').textContent = rssi;
-
// heap
document.getElementById('mHeap').textContent =
data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
document.getElementById('mMinHeap').textContent =
data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
-
// ota cache
var otaStored = ota.stored_size || 0;
var mOtaCache = document.getElementById('mOtaCache');
@@ -421,17 +504,21 @@ function updateSystemStatusUi(data) {
mOtaCache.className = 'v';
}
}
-
- // relay state
+ // relay state - skip GD32 relay UI when ESP32 OTA is active
+ if (currentOtaTarget === 'esp32' && esp32OtaPhase > 0) {
+ document.getElementById('pillRelayTxt').textContent = esp32OtaPhase >= 6 ? 'ESP32 OTA 完成' : 'ESP32 OTA';
+ var pr = document.getElementById('pillRelay');
+ if (pr) pr.className = esp32OtaPhase >= 6 ? 'pill online' : 'pill processing';
+ return;
+ }
var rs = ota.relay_state;
- var relayText = relayStates[rs] || '待机';
+ var relayText = relayStateDetailMap[rs] || '待机';
document.getElementById('pillRelayTxt').textContent = relayText;
var pr = document.getElementById('pillRelay');
if (rs === 255) pr.className = 'pill offline';
else if (rs > 0 && rs < 17) pr.className = 'pill processing';
else if (rs === 17 || rs === 18) pr.className = 'pill online';
else pr.className = 'pill idle';
-
// OTA cache progress (GD32 upload to ESP32 flash)
var total = ota.total_size || 0;
var stored = ota.stored_size || 0;
@@ -439,27 +526,46 @@ function updateSystemStatusUi(data) {
var bp = document.getElementById('bOTAProgress');
bp.textContent = storageProgress + '%';
bp.className = 'v ' + (storageProgress === 100 ? 'ok' : storageProgress > 0 ? 'warn' : '');
-
// GD32 relay forwarding progress
var relaySeq = ota.relay_seq || 0;
var relayTotal = ota.relay_total_packets || 0;
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
-
var gd32AppAlive = ota.gd32_app_alive;
- if (rs > 0 && rs < 17) {
- // Relay is active — show relay progress
+ if (rs > 0 && rs <= 8) {
+ // Authentication or size negotiation phase
+ setActivePhase('gd32Relay');
+ if (rs <= 6) {
+ setProgress(0, '认证中: ' + (relayStateDetailMap[rs] || ''));
+ } else {
+ setProgress(0, '协商固件大小...');
+ }
+ } else if (rs >= 9 && rs <= 13) {
+ // Active data forwarding
setActivePhase('gd32Relay');
setProgress(relayProgress, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包');
+ } else if (rs >= 14 && rs <= 15) {
+ // Transfer complete
+ setActivePhase('gd32Relay');
+ setProgress(100, '数据转发完成 (' + relayTotal + ' 包)');
+ } else if (rs === 16) {
+ // OTA end sent
+ setActivePhase('gd32Relay');
+ setProgress(100, 'OTA 刷写中...');
} else if (rs === 17) {
- // Relay done, waiting for APP verify
+ // OTA complete
setActivePhase('gd32Relay');
- setProgress(100, '转发完成,等待 APP 启动...');
+ setProgress(100, 'OTA 完成,等待 APP 启动...');
} else if (rs === 18) {
// APP verify phase
setActivePhase('gd32Relay');
if (gd32AppAlive) {
markPhaseDone('gd32Relay');
setProgress(100, 'GD32 刷写完成');
+ // Mark status bar as success
+ var sb = document.getElementById('stageStatusBar');
+ var st = document.getElementById('stageStatusText');
+ if (sb) sb.className = 'stage-status-bar ok';
+ if (st) st.textContent = 'GD32 刷写完成';
} else {
setProgress(100, '转发完成,验证 APP 存活...');
}
@@ -471,46 +577,60 @@ function updateSystemStatusUi(data) {
// Relay finished and reset, but APP is alive → truly done
markPhaseDone('gd32Relay');
setProgress(100, 'GD32 刷写完成');
+ var sb2 = document.getElementById('stageStatusBar');
+ var st2 = document.getElementById('stageStatusText');
+ if (sb2) sb2.className = 'stage-status-bar ok';
+ if (st2) st2.textContent = 'GD32 刷写完成';
}
-
// stage flow from real state
- updateStagesFromRelayState(rs);
+ if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(rs); }
}
-
async function testConnection() {
- if (!latestSystemStatus) {
- addLog('W', '连接', '暂无同步数据,请等待 1 秒自动同步');
- showToast('暂无同步数据,请稍等 1 秒', 'warning');
- return;
+ // 如果在降速模式,先恢复正常轮询
+ if (pollSlowMode) {
+ addLog('I', '连接', '正在重新连接...');
+ restorePolling();
+ }
+ // 立即发一次请求检测
+ try {
+ var resp = await fetch(getBaseUrl() + '/system/status', {
+ signal: AbortSignal.timeout(3000)
+ });
+ if (resp.ok) {
+ var data = await resp.json();
+ latestSystemStatus = data;
+ updateSystemStatusUi(data);
+ var wifi = data.wifi || {};
+ addLog('I', '连接', wifi.connected
+ ? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
+ : '未连接:' + (wifi.mode || 'STA'));
+ showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
+ wifi.connected ? 'success' : 'warning');
+ } else {
+ addLog('E', '连接', 'HTTP ' + resp.status);
+ showToast('设备响应异常: HTTP ' + resp.status, 'error');
+ }
+ } catch (e) {
+ addLog('E', '连接', '连接失败: ' + e.message);
+ // 连接失败时自动诊断
+ var diag = await diagnoseNetwork();
+ addLog('W', '诊断', diag);
+ showToast('无法连接: ' + diag, 'error');
}
- var wifi = latestSystemStatus.wifi || {};
- var connected = !!wifi.connected;
- updateSystemStatusUi(latestSystemStatus);
- addLog('I', '连接', connected
- ? '当前已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
- : '当前未连接:' + (wifi.mode || 'STA'));
- showToast(connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
- connected ? 'success' : 'warning');
}
-
// ==================== CAN 帧发送 ====================
async function sendCanFrame() {
var ch = parseInt(document.getElementById('canChannel').value);
var idStr = document.getElementById('canId').value.trim();
var dlc = parseInt(document.getElementById('canDlc').value) || 8;
var dataStr = document.getElementById('canData').value.trim();
-
var extId = 0;
if (idStr.toLowerCase().indexOf('0x') === 0) extId = parseInt(idStr, 16);
else { extId = parseInt(idStr, 10); if (isNaN(extId)) extId = parseInt(idStr, 16); }
-
if (isNaN(extId)) { showToast('ID 格式错误', 'error'); return; }
-
var dataHex = dataStr.replace(/\s/g, '');
if (dataHex.length % 2 !== 0) { showToast('数据长度必须为偶数', 'error'); return; }
-
addLog('I', 'CAN', '发送 CH' + ch + ' ID=0x' + extId.toString(16).toUpperCase() + ' DLC=' + dlc);
-
try {
var resp = await fetch(getBaseUrl() + '/can/send', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -534,7 +654,6 @@ async function sendCanFrame() {
showToast('发送失败', 'error');
}
}
-
function clearCanFields() {
document.getElementById('canId').value = '0x18DA00F1';
document.getElementById('canDlc').value = '8';
@@ -542,13 +661,11 @@ function clearCanFields() {
document.getElementById('canResp').style.display = 'none';
updateCanHint();
}
-
// ==================== SPI 发送 ====================
function updateSpiHint() {
var typeHex = document.getElementById('spiType').value.trim();
var dataHex = document.getElementById('spiData').value.trim().replace(/\s/g, '');
var hint = document.getElementById('spiHint');
-
if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
hint.className = 'helper-note err';
hint.innerHTML = 'type 需填 1 字节 hex(如 01, B0)。';
@@ -571,17 +688,14 @@ function updateSpiHint() {
(bytes > 0 ? ',payload: <b>' + bytes + '</b>B' : ',无 payload') +
',线上 <b>' + totalOnWire + '</b>B(SOF+TYPE+LEN+PAYLOAD+CRC)';
}
-
async function sendSpiFrame() {
var typeHex = document.getElementById('spiType').value.trim();
var dataStr = document.getElementById('spiData').value.trim();
var timeout = parseInt(document.getElementById('spiTimeout').value) || 1000;
-
if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
showToast('type 需填 1 字节 hex', 'error');
return;
}
-
var dataHex = dataStr.replace(/\s/g, '');
if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) {
showToast('数据包含非 HEX 字符', 'error');
@@ -591,14 +705,10 @@ async function sendSpiFrame() {
showToast('HEX 数据长度必须为偶数', 'error');
return;
}
-
var txBytes = dataHex ? dataHex.length / 2 : 0;
-
addLog('I', 'SPI', 'type=0x' + typeHex.toUpperCase() +
(txBytes > 0 ? ', payload=' + dataHex.toUpperCase() : ', 无负载'));
-
var body = { type: typeHex, data: dataHex.toUpperCase(), timeout_ms: timeout };
-
try {
var resp = await fetch(getBaseUrl() + '/spi/send', {
method: 'POST',
@@ -607,7 +717,6 @@ async function sendSpiFrame() {
signal: AbortSignal.timeout(timeout + 2000)
});
var result = await resp.json();
-
if (result.status === 'ok') {
var recvHex = result.recv || '';
var respTypeName = {'80': 'ACK', '81': 'WAIT', '82': 'ERROR'};
@@ -634,7 +743,6 @@ async function sendSpiFrame() {
showToast('SPI 发送失败: ' + e.message, 'error');
}
}
-
function clearSpiFields() {
document.getElementById('spiType').value = '01';
document.getElementById('spiData').value = '';
@@ -642,13 +750,11 @@ function clearSpiFields() {
document.getElementById('spiResp').style.display = 'none';
updateSpiHint();
}
-
// ==================== 文件处理 ====================
function handleFileSelect(event, chip) {
var file = event.target.files[0];
if (file) processFile(file, chip);
}
-
// 通过 File System Access API 选择文件(支持热重载)
async function pickFileViaFSAPI(chip) {
if (!window.showOpenFilePicker) {
@@ -679,7 +785,6 @@ async function pickFileViaFSAPI(chip) {
}
return false;
}
-
function setupDropZone(zoneId, chip) {
var zone = document.getElementById(zoneId);
if (!zone) return;
@@ -697,15 +802,11 @@ function setupDropZone(zoneId, chip) {
}
setupDropZone('uploadZoneEsp32', 'esp32');
setupDropZone('uploadZoneGd32', 'gd32');
-
document.getElementById('canData').addEventListener('input', updateCanHint);
document.getElementById('canDlc').addEventListener('input', updateCanHint);
-
document.getElementById('spiType').addEventListener('input', updateSpiHint);
document.getElementById('spiData').addEventListener('input', updateSpiHint);
-
// ==================== Intel HEX → BIN 转换 ====================
-
/**
* 将 Intel HEX 字符串解析为原始 BIN 字节数组。
*
@@ -720,17 +821,14 @@ function parseIntelHex(hexText) {
var segments = []; // { offset, data } 列表
var minAddr = 0xFFFFFFFF;
var maxAddr = 0;
-
var lines = hexText.split(/\r?\n/);
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line[0] !== ':') continue;
-
// 解析行: :LLAAAATT[DD...]CC
var byteCount = parseInt(line.substr(1, 2), 16);
var addr16 = parseInt(line.substr(3, 4), 16);
var recType = parseInt(line.substr(7, 2), 16);
-
// 校验和验证
var sum = 0;
for (var j = 1; j < line.length; j += 2) {
@@ -739,7 +837,6 @@ function parseIntelHex(hexText) {
if ((sum & 0xFF) !== 0) {
throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line);
}
-
if (recType === 0x01) {
// EOF
break;
@@ -763,11 +860,9 @@ function parseIntelHex(hexText) {
}
// 其他记录类型 (03, 05 等) 忽略
}
-
if (segments.length === 0) {
throw new Error('HEX 文件中没有有效数据记录');
}
-
// 合并为连续 bin,间隙填 0xFF
var totalSize = maxAddr - minAddr;
var bin = new Uint8Array(totalSize);
@@ -778,10 +873,8 @@ function parseIntelHex(hexText) {
var off = seg.offset - minAddr;
bin.set(seg.data, off);
}
-
return { data: bin, baseAddr: minAddr, endAddr: maxAddr };
}
-
/**
* 检测文件内容是否为 Intel HEX 格式。
* 看前几个非空行是否以 ':' 开头。
@@ -795,13 +888,10 @@ function isIntelHexText(text) {
}
return false;
}
-
// ==================== 文件处理 ====================
-
function processFile(file, chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
var isHexByName = file.name.toLowerCase().endsWith('.hex');
-
if (chip === 'gd32' && isHexByName) {
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
var reader = new FileReader();
@@ -825,12 +915,10 @@ function processFile(file, chip) {
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
var binFile = new File([binBlob], binName, { type: 'application/octet-stream' });
gd32File = binFile;
-
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + binName);
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.data.length));
showToast('HEX 已自动转换为 BIN', 'success');
-
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = binName + ' (由 ' + file.name + ' 转换)';
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
@@ -847,7 +935,6 @@ function processFile(file, chip) {
reader.readAsText(file);
return; // 异步,回调里设置 gd32File
}
-
// .bin 文件或 ESP32 文件: 直接使用
if (chip === 'esp32') esp32File = file;
else gd32File = file;
@@ -859,23 +946,41 @@ function processFile(file, chip) {
updateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
showToast(chip.toUpperCase() + ' 文件已选择', 'success');
+ // ESP32 文件: 计算并显示 SHA-256
+ if (chip === 'esp32') {
+ var shaElem = document.getElementById('fileSha256' + suffix);
+ if (shaElem) {
+ shaElem.textContent = '计算中...';
+ file.arrayBuffer().then(function(buf) {
+ return crypto.subtle.digest('SHA-256', buf);
+ }).then(function(hashBuffer) {
+ var hashArray = Array.from(new Uint8Array(hashBuffer));
+ var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
+ shaElem.textContent = hashHex.toUpperCase();
+ // 存储到全局变量供上传时使用
+ window._esp32FileSha256 = hashHex.toUpperCase();
+ addLog('I', '文件', 'ESP32 固件 SHA-256: ' + hashHex.toUpperCase());
+ }).catch(function(err) {
+ shaElem.textContent = '计算失败: ' + err.message;
+ });
+ }
+ }
}
-
function clearFile(chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
- if (chip === 'esp32') { esp32File = null; esp32FileHandle = null; }
+ if (chip === 'esp32') { esp32File = null; esp32FileHandle = null; window._esp32FileSha256 = null; }
else { gd32File = null; gd32FileHandle = null; }
document.getElementById('fileInput' + suffix).value = '';
document.getElementById('fileInfo' + suffix).classList.remove('show');
+ var shaElem = document.getElementById('fileSha256' + suffix);
+ if (shaElem) shaElem.textContent = '-';
updateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
}
-
// ==================== 上传固件 ====================
async function startUpload(chip) {
var file = chip === 'esp32' ? esp32File : gd32File;
if (!file || isUploading) return;
-
// 每次上传前尝试重新从本地读取文件
var handle = chip === 'esp32' ? esp32FileHandle : gd32FileHandle;
if (handle) {
@@ -911,21 +1016,43 @@ async function startUpload(chip) {
return;
}
}
-
isUploading = true;
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
var origText = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
+ switchStageFlow(chip);
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Upload');
+ if (chip === 'esp32') updateEsp32StagesFromPhase(1);
+ // Reset verification panel
+ var verifyInfo = document.getElementById('stageVerifyInfo');
+ if (verifyInfo) verifyInfo.style.display = 'none';
+ var sviFront = document.getElementById('sviFrontSha');
+ var sviDevice = document.getElementById('sviDeviceSha');
+ var sviResult = document.getElementById('sviResult');
+ if (sviFront) sviFront.textContent = '-';
+ if (sviDevice) sviDevice.textContent = '-';
+ if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; }
addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase());
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
-
try {
var arrayBuffer = await file.arrayBuffer();
+ // 刷写时重新计算 SHA-256
+ if (chip === 'esp32') {
+ try {
+ var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
+ var hashArray = Array.from(new Uint8Array(hashBuffer));
+ var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
+ window._esp32FileSha256 = hashHex.toUpperCase();
+ var shaElem = document.getElementById('fileSha256Esp32');
+ if (shaElem) shaElem.textContent = window._esp32FileSha256;
+ addLog('I', '文件', '刷写前重新计算 SHA-256: ' + window._esp32FileSha256);
+ } catch (e) {
+ addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message);
+ }
+ }
var fileSize = file.size;
var body, url = getBaseUrl() + '/ota/upload';
-
if (chip === 'esp32') {
var headerObj = { type: 'esp32', total_size: fileSize };
var payload = arrayBuffer;
@@ -958,7 +1085,6 @@ async function startUpload(chip) {
document.getElementById('bOTAProgress').textContent = pct + '%';
}
});
-
xhr.addEventListener('load', function () {
isUploading = false;
uploadBtn.disabled = false;
@@ -970,22 +1096,19 @@ async function startUpload(chip) {
setProgress(100, '上传完成');
if (chip === 'esp32') {
markPhaseDone('esp32Ota');
- var countdown = 3;
- addLog('I', 'OTA', countdown + ' 秒后自动调用 POST /ota/update 完成升级并重启...');
- var timer = setInterval(function () {
- countdown--;
- if (countdown > 0) addLog('I', 'OTA', countdown + ' 秒后开始刷写...');
- else {
- clearInterval(timer);
- addLog('I', 'OTA', '开始刷写!');
- triggerEsp32Update();
- }
- }, 1000);
+ updateEsp32StagesFromPhase(2); // OTA 写入阶段
+ addLog('I', 'OTA', '固件上传完成,准备 SHA-256 校验...');
+ verifyEsp32Firmware();
} else {
+ switchStageFlow('gd32');
markPhaseDone('gd32Upload');
setActivePhase('gd32Relay');
setProgress(0, '等待触发转发...');
}
+ } else if (xhr.status === 403) {
+ addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试');
+ showToast('ESP32 OTA 被其他通道占用,请稍后重试', 'error');
+ setProgress(0, 'OTA 被其他通道占用');
} else {
var detail = formatHttpErrorDetail(xhr.responseText);
var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : '');
@@ -993,7 +1116,6 @@ async function startUpload(chip) {
showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error');
}
});
-
xhr.addEventListener('error', function () {
isUploading = false;
uploadBtn.disabled = false;
@@ -1002,7 +1124,6 @@ async function startUpload(chip) {
addLog('E', '上传', '上传过程发生错误');
showToast('上传出错', 'error');
});
-
xhr.open('POST', url);
xhr.send(body);
} catch (e) {
@@ -1014,28 +1135,118 @@ async function startUpload(chip) {
updateUploadButtonLabel();
}
}
-
// ==================== ESP32 OTA 完成升级 ====================
-async function triggerEsp32Update() {
- addLog('I', 'OTA', '=== ESP32 OTA 完成,开始重启 ===');
- showToast('ESP32 正在重启...', 'success');
+async function verifyEsp32Firmware() {
+ updateEsp32StagesFromPhase(3); // 校验中
+ var sha256 = window._esp32FileSha256;
+ if (sha256) {
+ addLog('I', 'OTA', '前端文件 SHA-256: ' + sha256);
+ var verifyInfo = document.getElementById('stageVerifyInfo');
+ if (verifyInfo) verifyInfo.style.display = 'block';
+ var frontSha = document.getElementById('sviFrontSha');
+ if (frontSha) frontSha.textContent = sha256;
+ }
+
+ showToast('ESP32 正在校验固件...', 'success');
try {
+ var requestBody = {};
+ if (sha256) requestBody.sha256 = sha256;
+ requestBody.verify_only = true;
+
var resp = await fetch(getBaseUrl() + '/ota/update', {
- method: 'POST', signal: AbortSignal.timeout(5000)
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestBody),
+ signal: AbortSignal.timeout(120000)
+ });
+
+ var data = await resp.json();
+
+ if (data.status === 'ok') {
+ addLog('I', 'OTA', '设备端 SHA-256: ' + (data.sha256 || '-'));
+ if (data.sha256) {
+ var deviceSha = document.getElementById('sviDeviceSha');
+ if (deviceSha) deviceSha.textContent = data.sha256;
+ var resultEl = document.getElementById('sviResult');
+ if (sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) {
+ addLog('I', 'OTA', 'SHA-256 验证通过!固件完整性确认,自动应用...');
+ showToast('SHA-256 验证通过,自动应用中...', 'success');
+ if (resultEl) { resultEl.textContent = '验证通过'; resultEl.className = 'svi-val ok'; }
+ showVerifyActions('none');
+ applyEsp32Firmware();
+ }
+ }
+ } else {
+ var detail = data.message || (data.device ? '不匹配' : '未知错误');
+ addLog('E', 'OTA', '校验失败: ' + detail);
+ showToast('校验失败: ' + detail, 'error');
+ var resultEl = document.getElementById('sviResult');
+ if (resultEl) { resultEl.textContent = '❌ ' + detail; resultEl.className = 'svi-val err'; }
+ updateEsp32StagesError();
+ showVerifyActions('retry');
+ }
+ } catch (e) {
+ addLog('E', 'OTA', '校验请求失败: ' + e.message);
+ showToast('校验请求失败', 'error');
+ updateEsp32StagesError();
+ showVerifyActions('retry');
+ }
+}
+
+async function retryEsp32Upload() {
+ addLog('I', 'OTA', '重新上传固件...');
+ showVerifyActions('none');
+ var verifyInfo = document.getElementById('stageVerifyInfo');
+ if (verifyInfo) verifyInfo.style.display = 'none';
+ var sviFront = document.getElementById('sviFrontSha');
+ var sviDevice = document.getElementById('sviDeviceSha');
+ var sviResult = document.getElementById('sviResult');
+ if (sviFront) sviFront.textContent = '-';
+ if (sviDevice) sviDevice.textContent = '-';
+ if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; }
+ startUpload('esp32');
+}
+
+async function cancelEsp32Ota() {
+ addLog('I', 'OTA', '已取消 OTA 刷写');
+ showToast('已取消 OTA', 'error');
+ showVerifyActions('none');
+ resetProgress();
+}
+
+async function applyEsp32Firmware() {
+ addLog('I', 'OTA', '确认应用固件并重启 ESP32...');
+ showToast('正在应用固件并重启...', 'success');
+ try {
+ var resp = await fetch(getBaseUrl() + '/ota/update', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ signal: AbortSignal.timeout(120000)
});
+
if (resp.ok) {
var data = await resp.json();
- addLog('I', 'OTA', '升级完成: ' + data.message);
- showToast('ESP32 重启中...', 'success');
+ addLog('I', 'OTA', '升级完成: ' + (data.message || ''));
+ updateEsp32StagesFromPhase(4); // 应用固件
isEsp32Rebooting = true;
+ updateEsp32StagesFromPhase(5); // 重启中
rebootStartTime = Date.now();
+ } else {
+ var detail = '应用失败: HTTP ' + resp.status;
+ try { var d = await resp.json(); detail += ' - ' + (d.message || ''); } catch (_) {}
+ addLog('E', 'OTA', detail);
+ showToast(detail, 'error');
}
} catch (e) {
- addLog('E', 'OTA', '升级失败: ' + e.message);
- showToast('升级失败', 'error');
+ addLog('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message);
+ isEsp32Rebooting = true;
+ updateEsp32StagesFromPhase(5);
+ rebootStartTime = Date.now();
}
}
+
async function restartEsp32() {
if (!confirm('确定要重启 ESP32 吗?')) return;
addLog('I', '系统', '=== 请求重启 ESP32 ===');
@@ -1056,12 +1267,10 @@ async function restartEsp32() {
showToast('重启失败', 'error');
}
}
-
// ==================== 触发 OTA 中继 (真实状态驱动) ====================
async function triggerRelay() {
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
showToast('正在触发 OTA 中继', 'success');
-
try {
var resp = await fetch(getBaseUrl() + '/ota/relay', {
method: 'POST', signal: AbortSignal.timeout(5000)
@@ -1081,8 +1290,78 @@ async function triggerRelay() {
showToast('触发失败', 'error');
}
}
-
// ==================== 轮询状态 ====================
+var consecutivePollFailures = 0;
+var statusTimerId = null;
+var logsTimerId = null;
+var pollSlowMode = false; // 降速模式(10s 一次)
+var lastDiagTime = 0; // 上次诊断时间
+var diagResult = ''; // 最近一次诊断结果
+// 从设备 IP 推导网关 IP(取前三段 + .1)
+function getGatewayIp() {
+ var parts = getEspIp().split('.');
+ if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1';
+ return '192.168.31.1';
+}
+// 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通
+async function diagnoseNetwork() {
+ lastDiagTime = Date.now();
+ var result = [];
+ // 1) 浏览器是否在线
+ if (navigator.onLine === false) {
+ result.push('浏览器无网络连接');
+ diagResult = result.join(';');
+ return diagResult;
+ }
+ // 2) 尝试触达网关(超时 2s)
+ var gwIp = getGatewayIp();
+ var gwOk = false;
+ try {
+ await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) });
+ gwOk = true; // no-cors 模式下不抛异常即视为可达
+ } catch (_) {}
+ if (!gwOk) {
+ // 再试一次 favicon(有些路由器不响应 /)
+ try {
+ await fetch('http://' + gwIp + '/favicon.ico', { mode: 'no-cors', signal: AbortSignal.timeout(1500) });
+ gwOk = true;
+ } catch (_) {}
+ }
+ if (!gwOk) {
+ result.push('网关 ' + gwIp + ' 不可达(本地网络不通)');
+ } else {
+ result.push('网关 ' + gwIp + ' 可达');
+ // 3) 网关通但设备不通 → 设备自身问题
+ result.push('设备 ' + getEspIp() + ' 不响应(可能断电、重启中或 WiFi 断开)');
+ }
+ diagResult = result.join(';');
+ return diagResult;
+}
+function startPolling() {
+ consecutivePollFailures = 0;
+ pollSlowMode = false;
+ if (statusTimerId) clearInterval(statusTimerId);
+ if (logsTimerId) clearInterval(logsTimerId);
+ statusTimerId = setInterval(pollStatus, 1000);
+ logsTimerId = setInterval(pollLogs, 1000);
+}
+function slowDownPolling() {
+ pollSlowMode = true;
+ if (statusTimerId) clearInterval(statusTimerId);
+ if (logsTimerId) clearInterval(logsTimerId);
+ statusTimerId = setInterval(pollStatus, 10000);
+ logsTimerId = setInterval(pollLogs, 10000);
+}
+function restorePolling() {
+ if (pollSlowMode) {
+ pollSlowMode = false;
+ consecutivePollFailures = 0;
+ if (statusTimerId) clearInterval(statusTimerId);
+ if (logsTimerId) clearInterval(logsTimerId);
+ statusTimerId = setInterval(pollStatus, 1000);
+ logsTimerId = setInterval(pollLogs, 1000);
+ }
+}
async function pollStatus() {
try {
var resp = await fetch(getBaseUrl() + '/system/status', {
@@ -1090,14 +1369,43 @@ async function pollStatus() {
});
if (resp.ok) {
var data = await resp.json();
+ // 恢复:如果之前在降速模式,切回 1 秒轮询
+ if (consecutivePollFailures > 0 || pollSlowMode) {
+ var wasSlow = pollSlowMode;
+ consecutivePollFailures = 0;
+ if (wasSlow) {
+ restorePolling();
+ addLog('I', '系统', '设备已恢复在线,轮询恢复正常');
+ showToast('设备已恢复在线', 'success');
+ }
+ }
if (isEsp32Rebooting) {
isEsp32Rebooting = false;
+ updateEsp32StagesFromPhase(6); // 启动完成
+ addLog('I', '系统', 'ESP32 已恢复在线');
+ showToast('ESP32 已恢复在线', 'success');
+ } else if (currentOtaTarget === 'esp32' && esp32OtaPhase >= 3 && esp32OtaPhase < 6) {
+ updateEsp32StagesFromPhase(6);
addLog('I', '系统', 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
}
updateSystemStatusUi(data);
}
} catch (e) {
+ consecutivePollFailures++;
+ // 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的)
+ if (consecutivePollFailures === 5 && !pollSlowMode && !isUploading) {
+ addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
+ var diag = await diagnoseNetwork();
+ addLog('W', '诊断', diag);
+ updateConnectionUi(false, '未连接');
+ applyDisconnectedRuntimeState();
+ var cst = document.getElementById('connStatusTxt');
+ if (cst) cst.textContent = '诊断中: ' + diag;
+ // 降速到 10 秒继续监测,等待设备恢复
+ slowDownPolling();
+ addLog('I', '系统', '已切换为慢速轮询(10s),持续监测设备恢复...');
+ }
if (isUploading) {
applyUploadingRuntimeState();
} else if (isEsp32Rebooting) {
@@ -1111,14 +1419,13 @@ async function pollStatus() {
} else {
applyRebootingRuntimeState();
}
- } else {
+ } else if (!pollSlowMode) {
updateConnectionUi(false, '未连接');
applyDisconnectedRuntimeState();
}
}
}
-setInterval(pollStatus, 1000);
-
+startPolling();
// ==================== ESP32 实时日志拉取 (增量) ====================
var lastLogTimestamp = 0;
async function pollLogs() {
@@ -1129,7 +1436,6 @@ async function pollLogs() {
if (!resp.ok) return;
var logs = await resp.json();
if (!Array.isArray(logs) || logs.length === 0) return;
-
var levelMap = { E: 'E', W: 'W', I: 'I', D: 'D' };
logs.forEach(function (line) {
var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
@@ -1141,13 +1447,20 @@ async function pollLogs() {
});
} catch (e) { /* 静默 */ }
}
-setInterval(pollLogs, 1000);
-
setTimeout(function () {
updateUploadButtonLabel();
updateCanHint();
addLog('I', '系统', '控制台初始化完成,等待固件文件...');
updateSpiHint();
+ // IP 输入变化时自动恢复轮询
+ var ipInput = document.getElementById('espIp');
+ if (ipInput) {
+ ipInput.addEventListener('change', function () {
+ if (pollSlowMode) {
+ addLog('I', '系统', 'IP 已修改,重新启动轮询...');
+ restorePolling();
+ }
+ });
+ }
}, 500);
-