纯前端上位机界面 + esp32_server.py 静态服务/桌面窗口外壳, 不含固件、 不直接访问硬件; 业务 API 由 ESP32_COMM 固件 80 端口跨域提供。 - tabs.html 单窗口多标签外壳, iframe 承载各功能页 - index.html OTA 中继(ESP32 WiFi / GD32 relay / BLE), js/ota + js/ble - can.html / can_monitor.html CAN 通信与接收监控, js/can - motor.html 电机 CAN FD 控制台, js/motor - continuity_test.html LAN9252 通断测试, spi_test.html SPI 测速(临时, 自包含) - 三入口: esp32_app.vbs 桌面窗口 / esp32_web.vbs 浏览器 / serve.bat 控制台调试 - 补 README(页面与目录约定、?v= 缓存版本等改动须知)、.gitignore、 .gitattributes(* -text 保持 Windows 换行原样)
614 lines
27 KiB
JavaScript
614 lines
27 KiB
JavaScript
/**
|
||
* upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级
|
||
*
|
||
* 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、
|
||
* XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。
|
||
*
|
||
* 共享状态(通过 window.* 引用):
|
||
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
|
||
* window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr
|
||
* window.isUploading / window.uploadBtnOrigText / window.activeUploadChip
|
||
*
|
||
* 复用的函数(通过 window.* 引用):
|
||
* window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset
|
||
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget
|
||
* window.cleanupAfterOtaFailure / window.pausePollingForUpload
|
||
* window.resumePollingAfterUpload / window.updateEsp32StagesFromPhase
|
||
* window.switchStageFlow / window.setActivePhase / window.setProgress
|
||
* window.markPhaseDone / window.startUploadWaitHeartbeat / window.stopUploadWaitHeartbeat
|
||
* window.updateUploadButtonLabel / window.processFile / window.clearFile
|
||
* window.triggerGd32Relay / window.rebootObservedDown / window.rebootStartTime
|
||
* window.isEsp32Rebooting / window.getBaseUrl / window.formatBytes
|
||
* window.showToast / window.addLog
|
||
*/
|
||
import { formatBytes } from '../common/utils.js';
|
||
import { showToast } from '../common/toast.js';
|
||
import { addLog } from '../common/log.js';
|
||
import {
|
||
otaSessionActive, otaSessionTerminated, otaSessionReset,
|
||
otaSessionTransition, OtaSessionState, stopUploadWaitHeartbeat,
|
||
startUploadWaitHeartbeat
|
||
} from './session.js';
|
||
import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js';
|
||
import { computeFileSha } from './file-handle.js';
|
||
|
||
function isOtaBusyError(status, detail) {
|
||
var text = (detail || '').toLowerCase();
|
||
return status === 403 ||
|
||
status === 409 ||
|
||
status === 423 ||
|
||
text.indexOf('busy') >= 0 ||
|
||
text.indexOf('occupied') >= 0 ||
|
||
text.indexOf('another ota') >= 0 ||
|
||
text.indexOf('invalid state') >= 0 ||
|
||
text.indexOf('占用') >= 0 ||
|
||
text.indexOf('互斥') >= 0 ||
|
||
text.indexOf('系统忙') >= 0;
|
||
}
|
||
|
||
function formatHttpErrorDetail(text) {
|
||
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
|
||
return detail;
|
||
}
|
||
|
||
/** @description 启动 ESP32 本机 OTA 上传流程(互斥获取→分块上传→SHA验证→重启)。 */
|
||
export async function startUpload(chip) {
|
||
var file = chip === 'esp32' ? window.esp32File : window.gd32File;
|
||
if (!file) return;
|
||
// ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。
|
||
// FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。
|
||
// RELAYING 可能因 ERROR 被设备软清为 IDLE 而前端未收尾,先探测再决定。
|
||
if (otaSessionActive()) {
|
||
var busyState = window.otaSessionState;
|
||
if (busyState === OtaSessionState.RELAYING || busyState === OtaSessionState.VERIFYING) {
|
||
try {
|
||
var staleProbe = await fetch(window.getBaseUrl() + '/system/status', {
|
||
signal: AbortSignal.timeout(2000)
|
||
});
|
||
if (staleProbe.ok) {
|
||
var staleSt = await staleProbe.json();
|
||
var so = staleSt.ota || {};
|
||
var ssc0 = Number.isFinite(so.sys_state_code) ? so.sys_state_code : -1;
|
||
var rs0 = Number.isFinite(so.relay_state) ? so.relay_state : -1;
|
||
/* 设备已空闲/完成/错误残留:前端会话是陈旧活跃态,强制清掉 */
|
||
var deviceFree = (ssc0 === 0 || ssc0 === 2 || ssc0 === 4 || ssc0 === 8) &&
|
||
(rs0 === 0 || rs0 === 5 || rs0 === 255 || rs0 < 0);
|
||
if (deviceFree) {
|
||
addLog('W', '上传', '检测到陈旧会话 ' + busyState +
|
||
'(设备已 ' + (so.sys_state || ssc0) + '),自动清理后继续');
|
||
otaSessionReset();
|
||
}
|
||
}
|
||
} catch (_) { /* 探测失败则仍按占用拒绝 */ }
|
||
}
|
||
}
|
||
if (otaSessionActive()) {
|
||
var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase();
|
||
addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传');
|
||
showToast('OTA 通道正在占用,请等待当前刷写结束', 'error');
|
||
return;
|
||
}
|
||
// 上一次会话已终结(DONE/FAILED):发起新会话前先复位状态机
|
||
if (otaSessionTerminated()) otaSessionReset();
|
||
// 记录本会话目标
|
||
window.otaSessionTarget = chip;
|
||
|
||
window.pausePollingForUpload();
|
||
/* 先进入 UPLOADING(同步 isUploading),再 await 读文件,防止重复提交 */
|
||
otaSessionTransition(OtaSessionState.UPLOADING);
|
||
window.activeUploadChip = chip;
|
||
/* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */
|
||
window.gd32CompleteAnnounced = false;
|
||
window.relayWatchLastSeq = -1;
|
||
window.gd32RelaySessionLive = false;
|
||
if (chip === 'esp32') {
|
||
updateEsp32StagesFromPhase(1);
|
||
}
|
||
// 跨通道互斥前置检查(含 BLE 取消后的瞬态重试)
|
||
var _probeReady = false;
|
||
for (var _pi = 0; _pi < 4; _pi++) {
|
||
if (_pi > 0) {
|
||
var _pd = [1500, 3000, 3000][_pi - 1];
|
||
addLog('I', '上传', '等待设备清理 OTA 状态 (' + _pd + 'ms)...');
|
||
await new Promise(function(r) { setTimeout(r, _pd); });
|
||
}
|
||
try {
|
||
var probe = await fetch(window.getBaseUrl() + '/system/status', {
|
||
signal: AbortSignal.timeout(2000)
|
||
});
|
||
if (probe.ok) {
|
||
var st = await probe.json();
|
||
var ota = st.ota || {};
|
||
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
|
||
var otaChannel = Number.isFinite(ota.channel) ? ota.channel : -1;
|
||
var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI');
|
||
// ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出,
|
||
// 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。
|
||
// ERROR(8) 放行给后端清理;REBOOTING(7) 不可逆。
|
||
// FINALIZING(6):真收尾时后端会 403;SHA 失败后可能假占用(core 已空),
|
||
// status 自愈 / begin 残留清理需要请求触达设备,前端同通道不硬拦。
|
||
if ((ssc === 5 || ssc === 6) && otaChannel === 1 && _pi < 3) continue;
|
||
var esp32Busy = (ssc === 7 ||
|
||
(ssc === 6 && otaChannel !== 0) ||
|
||
(ssc === 5 && otaChannel !== 0));
|
||
// GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。
|
||
var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0));
|
||
if ((esp32Busy || gd32Busy)) {
|
||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + ', channel=' + otaChannelName + '),拒绝本次 ' + chip.toUpperCase() + ' 上传');
|
||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||
// ★ 状态机:预检失败也需完整清理(转入 FAILED)
|
||
cleanupAfterOtaFailure('设备 OTA 占用中');
|
||
return;
|
||
}
|
||
}
|
||
_probeReady = true;
|
||
break;
|
||
} catch (e) {
|
||
// 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。
|
||
addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)');
|
||
_probeReady = true;
|
||
break;
|
||
}
|
||
}
|
||
// 热重载:有 FSAPI handle 则重新读盘(本地编译会改同路径固件)
|
||
var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
|
||
if (handle) {
|
||
try {
|
||
var freshFile = await handle.getFile();
|
||
if (chip === 'gd32' && freshFile.name.toLowerCase().endsWith('.hex')) {
|
||
/* HEX 必须按最新源文件再转 BIN,不能用选文件时的旧 prepared */
|
||
await window.processFile(freshFile, 'gd32', { silent: true, skipHandlePersist: true });
|
||
file = window.gd32File;
|
||
} else {
|
||
if (chip === 'esp32') window.esp32File = freshFile;
|
||
else window.gd32File = freshFile;
|
||
file = freshFile;
|
||
/* .bin 直读分支此前无任何日志,热重载是否生效不可观察,补一条 */
|
||
addLog('I', '文件', '热重载: 已重新读取 ' + freshFile.name +
|
||
' (' + formatBytes(freshFile.size) + ')');
|
||
}
|
||
} catch (e) {
|
||
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
|
||
}
|
||
} else {
|
||
/* 无 FSAPI 句柄(拖拽/传统方式选择):无法读盘,只能用缓存。
|
||
GD32 缓存是内存中转换出的 Blob,arrayBuffer 校验永远通过,
|
||
若不显式告知,本地重新编译后点上传会静默刷入旧固件 */
|
||
var cachedAt = file.lastModified
|
||
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
|
||
: '未知时间';
|
||
addLog('W', '文件', '热重载不可用(无 FSAPI 句柄):本次上传缓存文件 ' + file.name +
|
||
'(磁盘修改于 ' + cachedAt + ')。如需自动读取最新编译产物,请点击上传区重新选择');
|
||
showToast('热重载不可用,上传的是缓存文件', 'warning');
|
||
try {
|
||
await file.arrayBuffer();
|
||
} catch (e) {
|
||
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
|
||
showToast('文件已变更,请重新选择', 'error');
|
||
window.clearFile(chip);
|
||
cleanupAfterOtaFailure('本地文件失效');
|
||
return;
|
||
}
|
||
}
|
||
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
|
||
window.uploadBtnOrigText = uploadBtn.textContent;
|
||
uploadBtn.disabled = true;
|
||
uploadBtn.textContent = '上传中...';
|
||
switchStageFlow(chip);
|
||
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota');
|
||
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(本地固件会改,不能沿用选文件时的旧值) */
|
||
var savedSha = await computeFileSha(file, chip, arrayBuffer);
|
||
if (!savedSha) {
|
||
addLog('E', '文件', chip.toUpperCase() + ' SHA-256 计算失败');
|
||
showToast('SHA-256 计算失败', 'error');
|
||
cleanupAfterOtaFailure('SHA-256 计算失败');
|
||
return;
|
||
}
|
||
var fileSize = file.size;
|
||
var body, url = window.getBaseUrl() + '/ota/upload';
|
||
if (chip === 'esp32') {
|
||
var headerObj = { type: 'esp32', total_size: fileSize };
|
||
var payload = arrayBuffer;
|
||
addLog('I', '上传', '直传: ' + formatBytes(fileSize) +
|
||
'(设备边收边写 Flash;进度 100% 后仍需等 HTTP 响应)');
|
||
setProgress(1, '连接设备并开始写入…');
|
||
var headerJson = JSON.stringify(headerObj);
|
||
var encoder = new TextEncoder();
|
||
var headerBytes = encoder.encode(headerJson);
|
||
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
|
||
combined.set(headerBytes, 0);
|
||
combined.set(new Uint8Array(payload), headerBytes.length);
|
||
body = combined.buffer;
|
||
} else {
|
||
var headerObj = { total_size: fileSize };
|
||
var payload = arrayBuffer;
|
||
addLog('I', '上传', '直传: ' + formatBytes(fileSize));
|
||
var gd32Header = JSON.stringify(headerObj);
|
||
var encoder = new TextEncoder();
|
||
var headerBytes = encoder.encode(gd32Header);
|
||
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
|
||
combined.set(headerBytes, 0);
|
||
combined.set(new Uint8Array(payload), headerBytes.length);
|
||
body = combined.buffer;
|
||
}
|
||
window.activeXhr = new XMLHttpRequest();
|
||
var xhr = window.activeXhr;
|
||
xhr.upload.addEventListener('progress', function (e) {
|
||
if (e.lengthComputable) {
|
||
var pct = Math.round((e.loaded / e.total) * 100);
|
||
window.uploadProgress = pct;
|
||
if (chip === 'gd32') {
|
||
var overallPct = Math.min(45, Math.round(pct * 0.45));
|
||
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||
} else if (pct >= 100) {
|
||
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
|
||
} else {
|
||
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
|
||
}
|
||
}
|
||
});
|
||
xhr.upload.addEventListener('load', function () {
|
||
// 请求体发送完毕 ≠ 服务端处理完毕
|
||
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
|
||
addLog('I', '上传', '浏览器已发完全部数据,等待 ESP32 HTTP 响应…');
|
||
startUploadWaitHeartbeat();
|
||
});
|
||
// 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈
|
||
xhr.timeout = 300000;
|
||
xhr.addEventListener('load', function () {
|
||
stopUploadWaitHeartbeat();
|
||
/* 成功路径摘掉 activeXhr;失败路径留给 cleanupAfterOtaFailure 统一摘掉并 abort 幂等 */
|
||
if (window.activeXhr === xhr) window.activeXhr = null;
|
||
if (xhr.status === 200) {
|
||
/* 校验 body:避免空响应/网关误 200 被当成上传成功 */
|
||
var bodyTxt = (xhr.responseText || '').trim();
|
||
var bodyOk = false;
|
||
try {
|
||
var bj = bodyTxt ? JSON.parse(bodyTxt) : null;
|
||
bodyOk = !!(bj && (bj.status === 'ok' || bj.target === chip));
|
||
} catch (_pe) {
|
||
bodyOk = bodyTxt.indexOf('"status"') >= 0 && bodyTxt.indexOf('ok') >= 0;
|
||
}
|
||
if (!bodyOk) {
|
||
addLog('E', '上传', 'HTTP 200 但响应异常: ' + (bodyTxt.slice(0, 120) || '(空)'));
|
||
showToast('上传响应异常,请重试', 'error');
|
||
cleanupAfterOtaFailure('响应异常');
|
||
return;
|
||
}
|
||
/* 成功:恢复轮询/按钮(会话仍 VERIFYING/RELAYING,isUploading 由 transition 清) */
|
||
window.resumePollingAfterUpload();
|
||
uploadBtn.disabled = false;
|
||
uploadBtn.textContent = window.uploadBtnOrigText;
|
||
window.updateUploadButtonLabel();
|
||
// 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复
|
||
if (chip === 'esp32') {
|
||
addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…');
|
||
showToast('固件上传成功', 'success');
|
||
setProgress(100, '上传完成');
|
||
markPhaseDone('esp32Ota');
|
||
updateEsp32StagesFromPhase(2); // OTA 写入阶段
|
||
// ★ 状态机:ESP32 上传完成 → 进入 VERIFYING(verifyEsp32Firmware 内部继续推进)
|
||
otaSessionTransition(OtaSessionState.VERIFYING);
|
||
verifyEsp32Firmware();
|
||
} else {
|
||
addLog('I', 'OTA', '上传完成,校验 SHA 并触发 GD32 中继…');
|
||
showToast('上传完成,GD32 OTA 已自动开始', 'success');
|
||
switchStageFlow('gd32');
|
||
setActivePhase('gd32Ota');
|
||
setProgress(45, '上传完成,校验并触发 GD32 OTA…');
|
||
// ★ 状态机:GD32 上传完成 → 进入 RELAYING(triggerGd32Relay 内部驱动中继)
|
||
otaSessionTransition(OtaSessionState.RELAYING);
|
||
window.triggerGd32Relay();
|
||
}
|
||
} else if (isOtaBusyError(xhr.status, formatHttpErrorDetail(xhr.responseText))) {
|
||
var busyDetail = formatHttpErrorDetail(xhr.responseText);
|
||
addLog('E', '上传', chip.toUpperCase() + ' OTA 被其他通道占用(BLE/WiFi 互斥)' + (busyDetail ? ': ' + busyDetail : ''));
|
||
showToast('OTA 通道被占用,请稍后重试', 'error');
|
||
cleanupAfterOtaFailure('通道被占用');
|
||
} else {
|
||
var detail = formatHttpErrorDetail(xhr.responseText);
|
||
var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : '');
|
||
addLog('E', '上传', message);
|
||
showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error');
|
||
cleanupAfterOtaFailure('HTTP ' + xhr.status);
|
||
}
|
||
});
|
||
xhr.addEventListener('error', function () {
|
||
stopUploadWaitHeartbeat();
|
||
// cleanup 会先摘 activeXhr 再 abort;仅当本 xhr 仍是活动请求或会话仍在 UPLOADING 时清理
|
||
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
|
||
showToast('上传过程发生网络错误', 'error');
|
||
cleanupAfterOtaFailure('网络错误');
|
||
}
|
||
});
|
||
xhr.addEventListener('abort', function () {
|
||
stopUploadWaitHeartbeat();
|
||
// clearOtaSessionResiduals 先置 activeXhr=null 再 abort → 此处勿二次 cleanup
|
||
if (window.activeXhr === xhr) {
|
||
showToast('上传已中止', 'error');
|
||
cleanupAfterOtaFailure('已中止');
|
||
}
|
||
});
|
||
xhr.addEventListener('timeout', function () {
|
||
stopUploadWaitHeartbeat();
|
||
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
|
||
showToast('上传超时:设备可能忙或连接被踢,请重试', 'error');
|
||
cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)');
|
||
}
|
||
});
|
||
xhr.open('POST', url);
|
||
xhr.send(body);
|
||
} catch (e) {
|
||
addLog('E', '上传', '上传失败: ' + e.message);
|
||
showToast('上传失败: ' + e.message, 'error');
|
||
cleanupAfterOtaFailure('上传异常');
|
||
}
|
||
}
|
||
|
||
// ==================== ESP32 OTA 完成升级 ====================
|
||
// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮)
|
||
//
|
||
// 竞态说明(易误报“应用失败”):
|
||
// 1) finalize 成功 → 回 JSON ok → 100ms 后 esp_restart
|
||
// 2) 响应丢失 / 浏览器只收到半截 / 请求迟到打到已重启的新固件
|
||
// → 正文可能是明文或 JSON 的 "OTA not started"
|
||
// 3) 此时 /system/status 已是 IDLE,uptime 往往很小
|
||
// 旧逻辑:resp.json() 抛错 → 探测到 IDLE → cleanupAfterOtaFailure
|
||
// 正确:上传已成功的 VERIFYING 会话里,优先按「已应用并重启」推进 REBOOTING。
|
||
|
||
/** @description 进入 ESP32 重启等待(校验成功或推断已应用)。 */
|
||
function enterEsp32RebootingUi(reason, opts) {
|
||
opts = opts || {};
|
||
var resultEl = document.getElementById('sviResult');
|
||
if (resultEl && !opts.keepResult) {
|
||
resultEl.textContent = opts.resultText || '已应用,等待重启';
|
||
resultEl.className = 'svi-val ok';
|
||
}
|
||
addLog('I', 'OTA', reason || '设备重启中…');
|
||
showToast(opts.toast || '设备重启中…', 'success');
|
||
updateEsp32StagesFromPhase(4);
|
||
updateEsp32StagesFromPhase(5);
|
||
window.rebootObservedDown = !!opts.observedDown;
|
||
window.rebootStartTime = Date.now();
|
||
otaSessionTransition(OtaSessionState.REBOOTING);
|
||
}
|
||
|
||
/**
|
||
* /ota/update 结果不明确时探测设备真实状态。
|
||
* @param {string} detail 原始错误摘要
|
||
* @param {{notStarted?: boolean}} flags
|
||
*/
|
||
async function resolveEsp32VerifyAmbiguous(detail, flags) {
|
||
flags = flags || {};
|
||
addLog('W', 'OTA', '应用结果不明确: ' + (detail || 'unknown') + ',探测设备状态…');
|
||
|
||
var sawOffline = false;
|
||
var delays = [0, 800, 1500, 2500, 4000];
|
||
for (var i = 0; i < delays.length; i++) {
|
||
if (delays[i] > 0) {
|
||
await new Promise(function (r) { setTimeout(r, delays[i]); });
|
||
}
|
||
try {
|
||
var probe = await fetch(window.getBaseUrl() + '/system/status', {
|
||
signal: AbortSignal.timeout(4000)
|
||
});
|
||
if (!probe.ok) {
|
||
sawOffline = true;
|
||
continue;
|
||
}
|
||
var st = await probe.json();
|
||
var o = st.ota || {};
|
||
var ssc = Number.isFinite(o.sys_state_code) ? o.sys_state_code : -1;
|
||
var uptime = Number(st.uptime_sec);
|
||
if (!Number.isFinite(uptime)) uptime = -1;
|
||
|
||
// 7=REBOOTING
|
||
if (ssc === 7) {
|
||
enterEsp32RebootingUi('探测到设备 REBOOTING,按重启中处理', {
|
||
toast: '设备正在重启…',
|
||
observedDown: false
|
||
});
|
||
return;
|
||
}
|
||
// 5=RECEIVING / 6=FINALIZING:仍在收尾,再等几轮
|
||
if (ssc === 5 || ssc === 6) {
|
||
addLog('I', 'OTA', '设备仍在 ESP32 OTA (sys=' + ssc + '),继续等待…');
|
||
continue;
|
||
}
|
||
// IDLE/其它:上传本会话已成功过;短 uptime 或曾离线 → 几乎一定是已 finalize 并重启
|
||
// 「OTA not started」在 VERIFYING 上下文下同理(新固件无会话)。
|
||
var freshBoot = uptime >= 0 && uptime < 120;
|
||
if (flags.notStarted || freshBoot || sawOffline) {
|
||
enterEsp32RebootingUi(
|
||
flags.notStarted
|
||
? ('设备回报 OTA not started(uptime=' + uptime +
|
||
's)— 上传已完成,按已应用并重启处理')
|
||
: ('探测到设备已在线 (sys=' + ssc + ', uptime=' + uptime +
|
||
's) — 按已应用并重启处理'),
|
||
{
|
||
toast: '固件可能已应用,等待启动完成…',
|
||
observedDown: sawOffline,
|
||
resultText: '已应用(响应未完整返回,按重启完成确认)'
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
// 长 uptime + IDLE 且非 notStarted:更像请求失败、设备从未进入 finalize
|
||
addLog('E', 'OTA', '应用请求失败且设备非重启态 (sys=' + ssc +
|
||
', uptime=' + uptime + 's): ' + (detail || ''));
|
||
var resultElFail = document.getElementById('sviResult');
|
||
if (resultElFail) {
|
||
resultElFail.textContent = '✗ 请求失败: ' + (detail || 'unknown');
|
||
resultElFail.className = 'svi-val err';
|
||
}
|
||
showToast('应用请求失败: ' + (detail || 'unknown'), 'error');
|
||
cleanupAfterOtaFailure('应用请求失败');
|
||
return;
|
||
} catch (_probeErr) {
|
||
sawOffline = true;
|
||
}
|
||
}
|
||
|
||
// 多轮无响应,或一直停在 FINALIZING
|
||
if (sawOffline) {
|
||
enterEsp32RebootingUi('设备无响应,按重启中观察(可能 TCP 被 esp_restart 掐断)', {
|
||
toast: '设备无响应,等待重启恢复…',
|
||
observedDown: true,
|
||
resultText: '等待设备重启…'
|
||
});
|
||
return;
|
||
}
|
||
addLog('E', 'OTA', '应用阶段设备长时间仍忙或状态不明: ' + (detail || ''));
|
||
var resultElBusy = document.getElementById('sviResult');
|
||
if (resultElBusy) {
|
||
resultElBusy.textContent = '✗ 请求中断,设备仍忙';
|
||
resultElBusy.className = 'svi-val err';
|
||
}
|
||
cleanupAfterOtaFailure('应用请求中断(设备仍忙)');
|
||
}
|
||
|
||
/** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */
|
||
export 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 {
|
||
// 带 sha256 时设备端校验、set_boot 并自动重启;无需二次“应用”请求
|
||
var requestBody = {};
|
||
if (sha256) requestBody.sha256 = sha256;
|
||
|
||
var resp = await fetch(window.getBaseUrl() + '/ota/update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(requestBody),
|
||
signal: AbortSignal.timeout(120000)
|
||
});
|
||
|
||
// 禁止裸 resp.json():失败体可能是 httpd 明文或半截响应
|
||
var bodyTxt = '';
|
||
try {
|
||
bodyTxt = await resp.text();
|
||
} catch (te) {
|
||
await resolveEsp32VerifyAmbiguous('读取响应失败: ' + te.message, {});
|
||
return;
|
||
}
|
||
var data = null;
|
||
if (bodyTxt && bodyTxt.trim()) {
|
||
try {
|
||
data = JSON.parse(bodyTxt);
|
||
} catch (_pe) {
|
||
data = null;
|
||
}
|
||
}
|
||
|
||
var notStarted =
|
||
(data && (data.code === 'ota_not_started' ||
|
||
/OTA not started/i.test(String(data.message || '')))) ||
|
||
/OTA not started/i.test(bodyTxt || '');
|
||
|
||
if (resp.ok && data && 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 (resultEl) {
|
||
if (sha256 && data.sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) {
|
||
resultEl.textContent = '验证通过';
|
||
resultEl.className = 'svi-val ok';
|
||
} else {
|
||
resultEl.textContent = '已应用(未比对 SHA)';
|
||
resultEl.className = 'svi-val ok';
|
||
}
|
||
}
|
||
enterEsp32RebootingUi('SHA-256 校验通过,设备自动重启中...', {
|
||
toast: '校验通过,设备重启中...',
|
||
keepResult: true,
|
||
observedDown: false
|
||
});
|
||
return;
|
||
}
|
||
|
||
// SHA 明确不匹配
|
||
if (data && (data.status === 'error' || !resp.ok) &&
|
||
/mismatch|不匹配/i.test(String(data.message || bodyTxt || ''))) {
|
||
var detail = data.message || 'SHA-256 不匹配';
|
||
addLog('E', 'OTA', '校验失败: ' + detail);
|
||
showToast('校验失败: ' + detail, 'error');
|
||
if (data.device) {
|
||
var dSha = document.getElementById('sviDeviceSha');
|
||
if (dSha) dSha.textContent = data.device;
|
||
}
|
||
var resultElMm = document.getElementById('sviResult');
|
||
if (resultElMm) {
|
||
resultElMm.textContent = '✗ ' + detail;
|
||
resultElMm.className = 'svi-val err';
|
||
}
|
||
cleanupAfterOtaFailure('校验失败');
|
||
return;
|
||
}
|
||
|
||
// 迟到请求打到已重启固件 / 非 JSON 响应:勿当硬失败
|
||
if (notStarted) {
|
||
await resolveEsp32VerifyAmbiguous(
|
||
bodyTxt.slice(0, 120) || 'OTA not started',
|
||
{ notStarted: true }
|
||
);
|
||
return;
|
||
}
|
||
|
||
var failDetail = (data && data.message) ||
|
||
formatHttpErrorDetail(bodyTxt) ||
|
||
('HTTP ' + resp.status);
|
||
await resolveEsp32VerifyAmbiguous(failDetail, {});
|
||
} catch (e) {
|
||
// fetch 异常 ≠ 一定失败:finalize 成功后 esp_restart 会掐断 TCP
|
||
await resolveEsp32VerifyAmbiguous(e && e.message ? e.message : String(e), {});
|
||
}
|
||
}
|
||
|
||
export async function restartEsp32() {
|
||
if (!confirm('确定要重启 ESP32 吗?')) return;
|
||
addLog('I', '系统', '=== 请求重启 ESP32 ===');
|
||
showToast('ESP32 正在重启...', 'success');
|
||
try {
|
||
var resp = await fetch(window.getBaseUrl() + '/restart', {
|
||
method: 'POST', signal: AbortSignal.timeout(5000)
|
||
});
|
||
if (resp.ok) {
|
||
var data = await resp.json();
|
||
addLog('I', '系统', '重启已触发: ' + data.message);
|
||
showToast('ESP32 重启中,页面将自动恢复...', 'success');
|
||
window.isEsp32Rebooting = true;
|
||
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
|
||
window.rebootStartTime = Date.now();
|
||
}
|
||
} catch (e) {
|
||
addLog('E', '系统', '重启请求失败: ' + e.message);
|
||
showToast('重启失败', 'error');
|
||
}
|
||
} |