- 添加了upload-esp32.js用于处理ESP32的OTA上传,包括文件上传、SHA-256验证和设备重启。 - 引入了upload-gd32.js,用于触发GD32 OTA中继并跟踪中继进度。 - 针对OTA流程,增强了会话管理和错误处理能力。 - 通过在上传过程中显示吐司通知和进度更新,改进了用户反馈。
443 lines
21 KiB
JavaScript
443 lines
21 KiB
JavaScript
/**
|
||
* upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级
|
||
*
|
||
* 从 app.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';
|
||
|
||
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)。
|
||
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();
|
||
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
|
||
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
|
||
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
|
||
// 设备端互斥被绕过、403 路径走漏。
|
||
// ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true)
|
||
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) 也放行给后端清理底层 OTA 句柄;FINALIZING(6)/REBOOTING(7) 是不可逆忙态。
|
||
if (ssc === 5 && otaChannel === 1 && _pi < 3) continue;
|
||
var esp32Busy = (ssc === 6 || ssc === 7 || (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;
|
||
}
|
||
}
|
||
// 每次上传前尝试重新从本地读取文件
|
||
var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
|
||
if (handle) {
|
||
// 有 FileSystemFileHandle,调用 getFile() 获取最新内容
|
||
try {
|
||
var freshFile = await handle.getFile();
|
||
if (chip === 'esp32') {
|
||
window.esp32File = freshFile;
|
||
file = freshFile;
|
||
} else {
|
||
if (freshFile.name.toLowerCase().endsWith('.hex')) {
|
||
window.processFile(freshFile, 'gd32');
|
||
await new Promise(function(resolve) { setTimeout(resolve, 200); });
|
||
file = window.gd32File;
|
||
} else {
|
||
window.gd32File = freshFile;
|
||
file = freshFile;
|
||
}
|
||
}
|
||
addLog('I', '文件', '已重新读取本地文件: ' + file.name + ' (' + formatBytes(file.size) + ')');
|
||
} catch (e) {
|
||
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
|
||
}
|
||
} else {
|
||
// 没有 handle,尝试直接读取当前 File 对象
|
||
try {
|
||
await file.arrayBuffer();
|
||
} catch (e) {
|
||
// File 引用已失效,提示用户重新选择
|
||
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-256(ESP32 和 GD32 都算)
|
||
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);
|
||
}
|
||
} else if (chip === 'gd32') {
|
||
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._gd32FileSha256 = hashHex.toUpperCase();
|
||
addLog('I', '文件', 'GD32 SHA-256: ' + window._gd32FileSha256);
|
||
} catch (e) {
|
||
addLog('W', '文件', 'GD32 SHA-256 重新计算失败: ' + e.message);
|
||
window._gd32FileSha256 = null;
|
||
}
|
||
}
|
||
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('timeout', function () {
|
||
stopUploadWaitHeartbeat();
|
||
if (window.activeXhr) {
|
||
cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)');
|
||
showToast('上传超时:设备可能忙或连接被踢,请重试', 'error');
|
||
}
|
||
});
|
||
xhr.addEventListener('load', function () {
|
||
stopUploadWaitHeartbeat();
|
||
window.isUploading = false;
|
||
window.activeXhr = null;
|
||
window.resumePollingAfterUpload();
|
||
uploadBtn.disabled = false;
|
||
uploadBtn.textContent = window.uploadBtnOrigText;
|
||
window.updateUploadButtonLabel();
|
||
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;
|
||
}
|
||
// 成功路径只打一条「上传完成」日志,避免 [上传]+[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();
|
||
if (window.activeXhr) {
|
||
cleanupAfterOtaFailure('网络错误');
|
||
showToast('上传过程发生网络错误', 'error');
|
||
}
|
||
});
|
||
xhr.addEventListener('abort', function () {
|
||
stopUploadWaitHeartbeat();
|
||
if (window.activeXhr) {
|
||
cleanupAfterOtaFailure('已中止');
|
||
showToast('上传已中止', 'error');
|
||
}
|
||
});
|
||
xhr.open('POST', url);
|
||
xhr.send(body);
|
||
} catch (e) {
|
||
addLog('E', '上传', '上传失败: ' + e.message);
|
||
showToast('上传失败: ' + e.message, 'error');
|
||
cleanupAfterOtaFailure('上传异常');
|
||
}
|
||
}
|
||
|
||
// ==================== ESP32 OTA 完成升级 ====================
|
||
// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮)
|
||
/** @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)
|
||
});
|
||
|
||
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 (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';
|
||
}
|
||
}
|
||
// 服务器已设置启动分区并自动重启,前端直接进入重启等待状态
|
||
addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...');
|
||
showToast('校验通过,设备重启中...', 'success');
|
||
updateEsp32StagesFromPhase(4); // 应用固件
|
||
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
|
||
updateEsp32StagesFromPhase(5); // 重启中
|
||
window.rebootStartTime = Date.now();
|
||
// ★ 状态机:校验通过 → 进入 REBOOTING(内部同步 isEsp32Rebooting = true)
|
||
otaSessionTransition(OtaSessionState.REBOOTING);
|
||
} 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'; }
|
||
cleanupAfterOtaFailure('校验失败');
|
||
}
|
||
} catch (e) {
|
||
// fetch 异常仅代表请求未完成,不能推断设备已重启:
|
||
// 设备可能仍在 finalize(SHA 计算耗时)、可能已崩溃、可能网络抖动。
|
||
// 如实报告失败,由 pollStatus 探测设备真实状态。
|
||
addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)');
|
||
showToast('应用请求失败: ' + e.message, 'error');
|
||
var resultEl = document.getElementById('sviResult');
|
||
if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; }
|
||
cleanupAfterOtaFailure('应用请求失败');
|
||
}
|
||
}
|
||
|
||
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');
|
||
}
|
||
} |