feat(ble): 完善BLE OTA架构并优化性能
- 实现BLE OTA数据路径分离:添加ble_ota_data.cpp处理重排序/工作线程/DATA_IN, 和ble_ota_host.cpp处理GATT/GAP/广播/主机功能 - 将滑动ACK窗口从32增大到64,实测Windows WebBT环境下固件传输速度提升约10% (从40KB/s提升到44KB/s),内存消耗约30KB重排序缓冲区 - 优化调试日志级别,将OTA相关信息从INFO调整为DBG以减少日志冗余 refactor(can): 改进CAN反馈监听机制 - 将CAN反馈任务从1Hz主动轮询模式改为被动事件监听模式, 使用SpiMaster::takeEvent()替代主动发送0xC1轮询请求 - 更新相关头文件注释说明架构变更,将"周期性轮询"改为"被动接收推送" - 修改调试日志级别,将暂停/恢复状态日志从INFO调整为DBG
This commit is contained in:
@@ -18,20 +18,25 @@ import { showToast } from './toast.js';
|
||||
|
||||
// ==================== 连接管理 ====================
|
||||
export function updateConnectionUi(connected, text) {
|
||||
var label = text || (connected ? '已连接' : '未连接');
|
||||
// header pill
|
||||
var pc = document.getElementById('pillConn');
|
||||
var pct = document.getElementById('pillConnTxt');
|
||||
pc.className = connected ? 'pill online' : 'pill offline';
|
||||
pct.textContent = text || (connected ? '已连接' : '未连接');
|
||||
if (pc) {
|
||||
pc.className = connected ? 'pill online' : 'pill offline';
|
||||
if (pct) pct.textContent = label;
|
||||
}
|
||||
// connection card indicator
|
||||
var cs = document.getElementById('connStatus');
|
||||
var cst = document.getElementById('connStatusTxt');
|
||||
if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
|
||||
if (cst) cst.textContent = connected ? (text || '已连接') : '未连接';
|
||||
if (cst) cst.textContent = connected ? label : '未连接';
|
||||
// primary badge
|
||||
var bc = document.getElementById('bConn');
|
||||
bc.textContent = connected ? '在线' : '离线';
|
||||
bc.className = 'v ' + (connected ? 'ok' : 'err');
|
||||
if (bc) {
|
||||
bc.textContent = connected ? '在线' : '离线';
|
||||
bc.className = 'v ' + (connected ? 'ok' : 'err');
|
||||
}
|
||||
}
|
||||
export function updateHealthUi(healthCode, freeHeap) {
|
||||
var healthNames = ['正常', '警告', '危险', '关机'];
|
||||
@@ -141,15 +146,14 @@ function updateBootInitUi(data) {
|
||||
if (!window._bootInitLogged) {
|
||||
window._bootInitLogged = true;
|
||||
var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : '');
|
||||
try {
|
||||
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
|
||||
} catch (_) {}
|
||||
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
|
||||
}
|
||||
ensureHealthBootToggleBound();
|
||||
}
|
||||
|
||||
export function updateHeapAndTaskStatusUi(data) {
|
||||
if (!data) return;
|
||||
var formatPercent = window.formatPercent;
|
||||
var ota = data.ota || {};
|
||||
var setText = function (id, value) {
|
||||
var el = document.getElementById(id);
|
||||
@@ -214,9 +218,9 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
|
||||
});
|
||||
|
||||
setText('cpuUsageText', 'CPU 已用 ' + window.formatPercent(usagePct));
|
||||
setText('cpuUsagePct', window.formatPercent(usagePct));
|
||||
setText('cpuIdlePct', window.formatPercent(idlePct));
|
||||
setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
|
||||
setText('cpuUsagePct', formatPercent(usagePct));
|
||||
setText('cpuIdlePct', formatPercent(idlePct));
|
||||
setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--');
|
||||
setText('cpuTaskCount', String(tasks.length));
|
||||
setCpuFill(usagePct);
|
||||
@@ -234,13 +238,13 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
'<div class="status-task cpu-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(pct)) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(pct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
'<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
|
||||
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
|
||||
'<div><span>占比</span><b>' + escapeHtml(window.formatPercent(pct)) + '</b></div>' +
|
||||
'<div><span>占比</span><b>' + escapeHtml(formatPercent(pct)) + '</b></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -294,7 +298,7 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
'<div class="status-task">' +
|
||||
'<div class="status-task-head">' +
|
||||
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(usagePct)) + '</div>' +
|
||||
'<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
|
||||
'<div class="status-task-meta">' +
|
||||
@@ -351,38 +355,50 @@ export async function diagnoseNetwork() {
|
||||
return result.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 /system/status 抓取器:独立 AbortController,超时后中断,
|
||||
* 避免与递归调度的 pollStatus 互相干扰。
|
||||
* 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。
|
||||
*/
|
||||
async function fetchSystemStatus(timeoutMs) {
|
||||
var ac = new AbortController();
|
||||
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
|
||||
try {
|
||||
var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal });
|
||||
if (!resp.ok) {
|
||||
var err = new Error('HTTP ' + resp.status);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return await resp.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 测试与 ESP32 的连接(GET /status)。 */
|
||||
export async function testConnection() {
|
||||
// 立即发一次请求检测
|
||||
// 手动检测使用独立的 AbortController,避免打断递归调度的 pollStatus
|
||||
var testAbort = new AbortController();
|
||||
var testTimer = setTimeout(function() { try { testAbort.abort(); } catch(_e) {} }, 3000);
|
||||
try {
|
||||
var resp = await fetch(window.getBaseUrl() + '/system/status', {
|
||||
signal: testAbort.signal
|
||||
});
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
window.latestSystemStatus = data;
|
||||
window.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');
|
||||
}
|
||||
var data = await fetchSystemStatus(3000);
|
||||
window.latestSystemStatus = data;
|
||||
window.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');
|
||||
} catch (e) {
|
||||
if (e && e.status) {
|
||||
addLog('E', '连接', 'HTTP ' + e.status);
|
||||
showToast('设备响应异常: HTTP ' + e.status, 'error');
|
||||
return;
|
||||
}
|
||||
addLog('E', '连接', '连接失败: ' + e.message);
|
||||
// 连接失败时自动诊断
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
showToast('无法连接: ' + diag, 'error');
|
||||
} finally {
|
||||
clearTimeout(testTimer);
|
||||
}
|
||||
}
|
||||
export async function refreshSystemStatusNow(event) {
|
||||
@@ -390,27 +406,19 @@ export async function refreshSystemStatusNow(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
// 手动刷新使用独立的 AbortController,避免与递归调度的 pollStatus 互相干扰
|
||||
var manualAbort = new AbortController();
|
||||
var manualTimer = setTimeout(function() { try { manualAbort.abort(); } catch(_e) {} }, 3000);
|
||||
try {
|
||||
var resp = await fetch(window.getBaseUrl() + '/system/status', {
|
||||
signal: manualAbort.signal
|
||||
});
|
||||
if (!resp.ok) {
|
||||
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
|
||||
showToast('状态刷新失败: HTTP ' + resp.status, 'error');
|
||||
return;
|
||||
}
|
||||
var data = await resp.json();
|
||||
var data = await fetchSystemStatus(3000);
|
||||
window.latestSystemStatus = data;
|
||||
window.updateSystemStatusUi(data);
|
||||
window.consecutivePollFailures = 0;
|
||||
addLog('I', '状态', '已手动刷新 /system/status');
|
||||
} catch (e) {
|
||||
if (e && e.status) {
|
||||
addLog('E', '状态', '刷新失败: HTTP ' + e.status);
|
||||
showToast('状态刷新失败: HTTP ' + e.status, 'error');
|
||||
return;
|
||||
}
|
||||
addLog('E', '状态', '刷新失败: ' + e.message);
|
||||
showToast('状态刷新失败', 'error');
|
||||
} finally {
|
||||
clearTimeout(manualTimer);
|
||||
}
|
||||
}
|
||||
|
||||
+69
-38
@@ -1,70 +1,101 @@
|
||||
/**
|
||||
* log.js - 日志系统(带去重 + 自动滚动)
|
||||
* log.js - 日志系统(带去重 + 自动滚动 + 上限裁剪)
|
||||
*
|
||||
* 日志环形缓冲、去重、过滤、自动滚动逻辑。
|
||||
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
|
||||
*/
|
||||
import { formatTime } from './utils.js';
|
||||
import { formatTime, escapeHtml } from './utils.js';
|
||||
|
||||
// ==================== 日志系统状态 ====================
|
||||
let logFilter = 'all';
|
||||
let logEntries = [];
|
||||
let lastLogHash = null;
|
||||
let autoScroll = true;
|
||||
var MAX_LOGS = 1000; // 缓冲上限,超出后丢弃最旧条目
|
||||
var logFilter = 'all';
|
||||
var logEntries = [];
|
||||
var lastLogHash = null;
|
||||
var autoScroll = true;
|
||||
|
||||
// 缓存 DOM 引用,避免高频日志下重复 getElementById
|
||||
var scrollEl = document.getElementById('logScroll');
|
||||
var countEl = document.getElementById('logCount');
|
||||
|
||||
// ==================== 日志系统 (带去重 + 自动滚动) ====================
|
||||
function makeLogHash(level, tag, message) {
|
||||
return level + '|' + tag + '|' + message;
|
||||
}
|
||||
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
|
||||
export 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;
|
||||
/** 构造一条日志 DOM 节点(tag/message 经 HTML 转义,防 XSS)。 */
|
||||
function createLogLine(entry) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'log-line';
|
||||
div.innerHTML =
|
||||
'<span class="lt">[' + time + ']</span>' +
|
||||
'<span class="lv ' + level + '">' + level + '</span>' +
|
||||
'<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;
|
||||
'<span class="lt">[' + escapeHtml(entry.time) + ']</span>' +
|
||||
'<span class="lv ' + escapeHtml(entry.level) + '">' + escapeHtml(entry.level) + '</span>' +
|
||||
'<span class="lg">[' + escapeHtml(entry.tag) + ']</span>' +
|
||||
'<span class="lm">' + escapeHtml(entry.message) + '</span>';
|
||||
return div;
|
||||
}
|
||||
/** 同步 #logCount 为当前可见条目数。 */
|
||||
function syncCount() {
|
||||
if (countEl) countEl.textContent = logEntries.length;
|
||||
}
|
||||
/** 超过上限时丢弃最旧条目,并同步移除其 DOM 节点。 */
|
||||
function trimIfNeeded() {
|
||||
while (logEntries.length > MAX_LOGS) {
|
||||
var old = logEntries.shift();
|
||||
if (old && old.div && old.div.parentNode) old.div.parentNode.removeChild(old.div);
|
||||
}
|
||||
}
|
||||
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
|
||||
export function addLog(level, tag, message) {
|
||||
var hash = makeLogHash(level, tag, message);
|
||||
if (hash === lastLogHash) return; // 去重
|
||||
lastLogHash = hash;
|
||||
var entry = {
|
||||
time: formatTime(new Date()),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
div: null
|
||||
};
|
||||
logEntries.push(entry);
|
||||
trimIfNeeded();
|
||||
if (logFilter !== 'all' && level !== logFilter) {
|
||||
syncCount();
|
||||
return;
|
||||
}
|
||||
var div = createLogLine(entry);
|
||||
entry.div = div;
|
||||
if (scrollEl) {
|
||||
scrollEl.appendChild(div);
|
||||
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}
|
||||
syncCount();
|
||||
}
|
||||
export function clearLogs() {
|
||||
document.getElementById('logScroll').innerHTML = '';
|
||||
if (scrollEl) scrollEl.innerHTML = '';
|
||||
logEntries = [];
|
||||
lastLogHash = null;
|
||||
document.getElementById('logCount').textContent = '0';
|
||||
syncCount();
|
||||
}
|
||||
export function setLogFilter(level, btn) {
|
||||
document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
if (btn) btn.classList.add('active');
|
||||
logFilter = level;
|
||||
var scroll = document.getElementById('logScroll');
|
||||
scroll.innerHTML = '';
|
||||
if (!scrollEl) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
logEntries.forEach(function (e) {
|
||||
if (level === 'all' || e.level === level) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'log-line';
|
||||
div.innerHTML =
|
||||
'<span class="lt">[' + e.time + ']</span>' +
|
||||
'<span class="lv ' + e.level + '">' + e.level + '</span>' +
|
||||
'<span class="lg">[' + e.tag + ']</span>' +
|
||||
'<span class="lm">' + e.message + '</span>';
|
||||
scroll.appendChild(div);
|
||||
var div = createLogLine(e);
|
||||
e.div = div;
|
||||
frag.appendChild(div);
|
||||
} else {
|
||||
e.div = null;
|
||||
}
|
||||
});
|
||||
if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
|
||||
scrollEl.innerHTML = '';
|
||||
scrollEl.appendChild(frag);
|
||||
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}
|
||||
export function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
var btn = document.getElementById('autoScrollBtn');
|
||||
btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
|
||||
if (btn) btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
|
||||
}
|
||||
|
||||
+91
-71
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取
|
||||
*
|
||||
* 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。
|
||||
* 递归 setTimeout 调度 /system/status + 递归 setTimeout 调度 /logs,避免 setInterval 慢响应被 abort / 重叠抢 socket。
|
||||
*
|
||||
* 模块级状态(仅本模块使用,不导出):
|
||||
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs
|
||||
* uploadPollChip / lastLogTimestamp / pageVisible
|
||||
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / logsInFlight / statusPollIntervalMs
|
||||
* uploadPollChip / pageVisible
|
||||
*
|
||||
* 共享状态(通过 window.* 引用):
|
||||
* window.pollingPausedForUpload — 上传期间暂停轮询标志
|
||||
@@ -24,6 +24,7 @@
|
||||
* window.getEspIp / window.getBaseUrl
|
||||
* window.updateSystemStatusUi / window.updateEsp32StagesFromPhase
|
||||
* window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState
|
||||
* window.applyDeviceBusyRuntimeState
|
||||
* window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset
|
||||
* window.updateUploadButtonLabel
|
||||
*/
|
||||
@@ -31,6 +32,17 @@ import { addLog } from './log.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { updateConnectionUi, diagnoseNetwork } from './connection.js';
|
||||
|
||||
// ==================== 状态码常量(与后端 ota_coordinator.h / ota_relay.h 枚举对齐) ====================
|
||||
// sys_state_code
|
||||
const SYS_GD32_STORING = 1;
|
||||
const SYS_GD32_RELAYING = 3;
|
||||
const SYS_ESP32_RECEIVING = 5;
|
||||
const SYS_ESP32_FINALIZING = 6;
|
||||
const SYS_ESP32_REBOOTING = 7;
|
||||
// relay_state(1~4 为握手/协商/传输/收尾,非 IDLE)
|
||||
const RELAY_ACTIVE_MIN = 1;
|
||||
const RELAY_ACTIVE_MAX = 4;
|
||||
|
||||
// ==================== 轮询状态 ====================
|
||||
// 共享状态(初始化到 window,供 connection.js 等其他模块读写)
|
||||
window.consecutivePollFailures = 0;
|
||||
@@ -40,9 +52,10 @@ window.lastRelayState = 0; // 上次成功的 relay_state
|
||||
// 模块级状态(仅本模块使用)
|
||||
var statusTimerId = null;
|
||||
var logsTimerId = null;
|
||||
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用)
|
||||
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(5s 超时用)
|
||||
var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms)
|
||||
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
|
||||
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
|
||||
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
|
||||
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
|
||||
|
||||
@@ -62,8 +75,11 @@ export function setStatusPollIntervalMs(ms) {
|
||||
* 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval,
|
||||
* 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。
|
||||
* 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。
|
||||
* ★ 上传暂停期间(window.pollingPausedForUpload)不重挂定时器,
|
||||
* 避免在途请求的 finally 把刚清掉的轮询又挂回来。
|
||||
*/
|
||||
export function scheduleStatusPoll(delayMs) {
|
||||
if (window.pollingPausedForUpload) return;
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
var interval = statusPollIntervalMs;
|
||||
statusTimerId = setTimeout(function() {
|
||||
@@ -72,15 +88,24 @@ export function scheduleStatusPoll(delayMs) {
|
||||
}, delayMs !== undefined ? delayMs : interval);
|
||||
}
|
||||
|
||||
/** @description 调度下一次 /logs 轮询(递归 setTimeout,仿 scheduleStatusPoll)。 */
|
||||
function scheduleLogsPoll(delayMs) {
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
logsTimerId = setTimeout(function() {
|
||||
logsTimerId = null;
|
||||
pollLogs();
|
||||
}, delayMs !== undefined ? delayMs : statusPollIntervalMs);
|
||||
}
|
||||
|
||||
/** @description 启动定时轮询(状态 + 日志)。 */
|
||||
|
||||
export function startPolling() {
|
||||
window.consecutivePollFailures = 0;
|
||||
statusPollIntervalMs = 1000;
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度
|
||||
logsTimerId = setInterval(pollLogs, 1000);
|
||||
scheduleLogsPoll(0);
|
||||
}
|
||||
export function pausePollingForUpload() {
|
||||
if (window.pollingPausedForUpload) return;
|
||||
@@ -96,7 +121,7 @@ export function pausePollingForUpload() {
|
||||
statusTimerId = null;
|
||||
}
|
||||
if (logsTimerId) {
|
||||
clearInterval(logsTimerId);
|
||||
clearTimeout(logsTimerId);
|
||||
logsTimerId = null;
|
||||
}
|
||||
if (statusAbortCtrl) {
|
||||
@@ -111,16 +136,48 @@ export function resumePollingAfterUpload() {
|
||||
uploadPollChip = null;
|
||||
window.consecutivePollFailures = 0;
|
||||
statusPollIntervalMs = 1000;
|
||||
if (logsTimerId) clearInterval(logsTimerId);
|
||||
logsTimerId = setInterval(pollLogs, statusPollIntervalMs);
|
||||
if (logsTimerId) clearTimeout(logsTimerId);
|
||||
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
|
||||
if (statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
} else {
|
||||
scheduleStatusPoll(0);
|
||||
}
|
||||
pollLogs();
|
||||
scheduleLogsPoll(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 统一的 ESP32 重启完成处理:清重启标志 → 推进阶段 6 → 状态机转 DONE → 刷新按钮。
|
||||
* 抽出来供 pollStatus 成功分支与 pollLogs 启动 banner 分支复用,避免两处逻辑漂移。
|
||||
* 以 isEsp32Rebooting 作幂等守卫:已处理过的重启不再重复弹 toast / 写日志 / 重复转 DONE。
|
||||
* @param {string} source 日志来源描述(如 '启动 banner 收到'),用于区分日志文案。
|
||||
*/
|
||||
function markEsp32RebootComplete(source) {
|
||||
if (!window.isEsp32Rebooting) return;
|
||||
window.isEsp32Rebooting = false;
|
||||
window.rebootObservedDown = false;
|
||||
window.updateEsp32StagesFromPhase(6); // 启动完成
|
||||
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
// ★ 状态机:ESP32 重启完成 → DONE
|
||||
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
|
||||
window.otaSessionTransition(window.OtaSessionState.DONE);
|
||||
}
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 判断设备最近一次成功轮询时是否处于「忙」状态(OTA 存储/中继/接收/收尾/重启 或 relay 握手~收尾)。 */
|
||||
function isLastDeviceBusy() {
|
||||
var s = window.lastSysStateCode;
|
||||
var r = window.lastRelayState;
|
||||
return s === SYS_GD32_STORING || s === SYS_GD32_RELAYING ||
|
||||
s === SYS_ESP32_RECEIVING || s === SYS_ESP32_FINALIZING ||
|
||||
s === SYS_ESP32_REBOOTING ||
|
||||
(r >= RELAY_ACTIVE_MIN && r <= RELAY_ACTIVE_MAX);
|
||||
}
|
||||
|
||||
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
|
||||
export async function pollStatus() {
|
||||
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
|
||||
@@ -153,25 +210,12 @@ export async function pollStatus() {
|
||||
if (window.isEsp32Rebooting ||
|
||||
window.otaSessionState === window.OtaSessionState.REBOOTING) {
|
||||
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
|
||||
if (!window.rebootObservedDown && rbElapsed < 10) {
|
||||
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询
|
||||
// 保持"重启中"状态,不清除标志,不推进阶段
|
||||
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner(MAIN: ESP32-C3 OTA Relay v1.0 启动)触发
|
||||
} else {
|
||||
// 设备已重启并恢复在线(rebootObservedDown=true)
|
||||
// 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复
|
||||
window.isEsp32Rebooting = false;
|
||||
window.rebootObservedDown = false;
|
||||
window.updateEsp32StagesFromPhase(6); // 启动完成
|
||||
addLog('I', '系统', 'ESP32 已恢复在线');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
// ★ 状态机:ESP32 重启完成 → DONE
|
||||
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
|
||||
window.otaSessionTransition(window.OtaSessionState.DONE);
|
||||
}
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询,
|
||||
// 保持"重启中"状态,不清除标志,不推进阶段。
|
||||
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner 触发;
|
||||
// 这里只在已观察到下线(rebootObservedDown)或超 10s 安全阈值时接受恢复。
|
||||
if (window.rebootObservedDown || rbElapsed >= 10) {
|
||||
markEsp32RebootComplete();
|
||||
}
|
||||
}
|
||||
try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
|
||||
@@ -188,15 +232,7 @@ export async function pollStatus() {
|
||||
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
|
||||
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
|
||||
if (pageVisible) window.consecutivePollFailures++;
|
||||
// 判断设备是否处于"已知忙/重启"状态(依据最近一次成功轮询的 sys_state_code):
|
||||
// 1=GD32_STORING, 3=GD32_RELAYING, 5=ESP32_RECEIVING,
|
||||
// 6=ESP32_FINALIZING, 7=ESP32_REBOOTING
|
||||
// 也包含 relay_state 非 IDLE(1-4 表示握手/协商/传输/收尾)
|
||||
var lastDeviceBusy =
|
||||
window.lastSysStateCode === 1 || window.lastSysStateCode === 3 ||
|
||||
window.lastSysStateCode === 5 || window.lastSysStateCode === 6 ||
|
||||
window.lastSysStateCode === 7 ||
|
||||
(window.lastRelayState >= 1 && window.lastRelayState <= 4);
|
||||
var lastDeviceBusy = isLastDeviceBusy();
|
||||
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
|
||||
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
|
||||
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
|
||||
@@ -216,14 +252,7 @@ export async function pollStatus() {
|
||||
if (sessionFailed) {
|
||||
if (lastDeviceBusy) {
|
||||
// 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段
|
||||
var pcF = document.getElementById('pillConn');
|
||||
var pctF = document.getElementById('pillConnTxt');
|
||||
if (pcF) pcF.className = 'pill processing';
|
||||
if (pctF) pctF.textContent = '设备忙';
|
||||
var csF = document.getElementById('connStatus');
|
||||
var cstF = document.getElementById('connStatusTxt');
|
||||
if (csF) csF.className = 'conn-indicator warn';
|
||||
if (cstF) cstF.textContent = '设备忙(OTA 后台运行中),等待回 IDLE';
|
||||
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA 后台运行中),等待回 IDLE', '设备忙');
|
||||
updateConnectionUi(true, '设备忙');
|
||||
} else {
|
||||
// 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留
|
||||
@@ -253,14 +282,7 @@ export async function pollStatus() {
|
||||
} else if (sessionActive || lastDeviceBusy) {
|
||||
// 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙"
|
||||
// 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导)
|
||||
var pc = document.getElementById('pillConn');
|
||||
var pct = document.getElementById('pillConnTxt');
|
||||
if (pc) pc.className = 'pill processing';
|
||||
if (pct) pct.textContent = '设备忙';
|
||||
var cs = document.getElementById('connStatus');
|
||||
var cst = document.getElementById('connStatusTxt');
|
||||
if (cs) cs.className = 'conn-indicator warn';
|
||||
if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询';
|
||||
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA/重启中),保持 1s 轮询', '设备忙');
|
||||
updateConnectionUi(true, '设备忙');
|
||||
} else {
|
||||
updateConnectionUi(false, '未连接');
|
||||
@@ -271,7 +293,9 @@ export async function pollStatus() {
|
||||
statusAbortCtrl = null;
|
||||
/* 固定周期调度:interval 从本轮开始计时扣减请求耗时。
|
||||
* 旧逻辑「完成后 + 1000ms」→ 窗口稳定 1100+;现 sample_ms 贴近 1000/400。
|
||||
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。 */
|
||||
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。
|
||||
* ★ 上传暂停期间不重挂(由 scheduleStatusPoll 内部判断),避免在途请求 abort 后
|
||||
* 的 finally 把刚清掉的轮询又挂回来,导致上传中途再打 /system/status 抢 socket。 */
|
||||
var elapsed = Date.now() - pollStartedAt;
|
||||
var delay = Math.max(0, statusPollIntervalMs - elapsed);
|
||||
scheduleStatusPoll(delay);
|
||||
@@ -297,10 +321,11 @@ document.addEventListener('visibilitychange', function () {
|
||||
pollLogs();
|
||||
});
|
||||
// ==================== ESP32 实时日志拉取 (增量) ====================
|
||||
var lastLogTimestamp = 0;
|
||||
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
|
||||
export async function pollLogs() {
|
||||
// ★ ESP32 上传期间也允许日志轮询
|
||||
// ★ ESP32 上传期间也允许日志轮询;in-flight 守卫避免与 /system/status、/ota/upload 抢 socket
|
||||
if (logsInFlight) return;
|
||||
logsInFlight = true;
|
||||
try {
|
||||
var resp = await fetch(window.getBaseUrl() + '/logs', {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
@@ -308,28 +333,17 @@ export 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*(.*)$/);
|
||||
if (m) {
|
||||
addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd());
|
||||
addLog(m[1], m[2], m[3].trimEnd());
|
||||
// ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。
|
||||
// esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断
|
||||
// "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动,
|
||||
// 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。
|
||||
/* 与 main 启动 banner 对齐:ESP32-C3 OTA Relay <FW_VERSION> 启动(勿写死 v1.0) */
|
||||
if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay .*启动/.test(m[3])) {
|
||||
window.isEsp32Rebooting = false;
|
||||
window.rebootObservedDown = false;
|
||||
window.updateEsp32StagesFromPhase(6); // 启动完成
|
||||
addLog('I', '系统', 'ESP32 已恢复在线(启动 banner 收到)');
|
||||
showToast('ESP32 已恢复在线', 'success');
|
||||
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
|
||||
window.otaSessionTransition(window.OtaSessionState.DONE);
|
||||
}
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
markEsp32RebootComplete('启动 banner 收到');
|
||||
}
|
||||
/*
|
||||
* 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。
|
||||
@@ -347,7 +361,13 @@ export async function pollLogs() {
|
||||
addLog('I', 'ESP32', line.trimEnd());
|
||||
}
|
||||
});
|
||||
} catch (e) { /* 静默 */ }
|
||||
} catch (e) { /* 静默 */ } finally {
|
||||
logsInFlight = false;
|
||||
// 递归调度下一轮:固定周期扣减本轮耗时,避免慢响应时重叠抢 socket
|
||||
if (!window.pollingPausedForUpload) {
|
||||
scheduleLogsPoll(statusPollIntervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTimeout(function () {
|
||||
window.updateUploadButtonLabel();
|
||||
|
||||
@@ -2,32 +2,37 @@
|
||||
* toast.js - Toast 提示组件
|
||||
*
|
||||
* showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。
|
||||
* 支持 info / success / warning / error 类型,内置 5s 去重防抖。
|
||||
* 支持 success / error / warning 三类;info 走 warning 同款样式(历史兼容,无专属配色)。
|
||||
* 内置 5s 去重防抖,相同 (type|message) 5s 内不重复弹出。
|
||||
*/
|
||||
/** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */
|
||||
/** @description 显示 Toast 提示消息,支持 success/error/warning(info 同 warning)。 */
|
||||
const TOAST_DEDUPE_MS = 5000;
|
||||
const TOAST_AUTOHIDE_MS = 3000;
|
||||
|
||||
var _toastHideTimer = null;
|
||||
var _toastLastKey = '';
|
||||
var _toastLastAt = 0;
|
||||
export function showToast(message, type) {
|
||||
type = type || 'success';
|
||||
var toast = document.getElementById('toast');
|
||||
if (!toast) return;
|
||||
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
|
||||
var key = type + '|' + message;
|
||||
var now = Date.now();
|
||||
if (key === _toastLastKey && (now - _toastLastAt) < 5000) {
|
||||
if (key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
|
||||
return;
|
||||
}
|
||||
_toastLastKey = key;
|
||||
_toastLastAt = now;
|
||||
|
||||
const toast = document.getElementById('toast');
|
||||
const ico = document.getElementById('toastIco');
|
||||
const msg = document.getElementById('toastMsg');
|
||||
var ico = document.getElementById('toastIco');
|
||||
var msg = document.getElementById('toastMsg');
|
||||
toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
|
||||
ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
|
||||
msg.textContent = message;
|
||||
if (ico) ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
|
||||
if (msg) msg.textContent = message;
|
||||
if (_toastHideTimer) clearTimeout(_toastHideTimer);
|
||||
_toastHideTimer = setTimeout(function () {
|
||||
toast.classList.remove('show');
|
||||
_toastHideTimer = null;
|
||||
}, 3000);
|
||||
}, TOAST_AUTOHIDE_MS);
|
||||
}
|
||||
|
||||
@@ -42,3 +42,19 @@ export function escapeHtml(text) {
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
/**
|
||||
* @description 将字节数组(Uint8Array 或 ArrayBuffer)转为小写 hex 字符串。
|
||||
* SHA-256 等哈希显示前需要 toUpperCase()。
|
||||
* @param {Uint8Array|ArrayBuffer} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bytesToHex(bytes) {
|
||||
var arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
var out = '';
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
var h = arr[i].toString(16);
|
||||
if (h.length < 2) h = '0' + h;
|
||||
out += h;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user