Files
ESP32_COMM/_assistant_backups/2026-05-05/app.js.bak_2026-05-05_0451
T
小龙虾 6641ca4afd feat(health_monitor): 移除电压监控功能并优化OTA健康检查
- 移除ESP32电压监控相关代码,因为ESP32-C3无内置电压传感器
- 更新健康监控模块,从"温度+电压+WiFi RSSI监控"改为"温度+WiFi RSSI监控"
- 修改OTA健康检查频率,从每次包处理改为每32个包检查一次
- 添加OTA期间WiFi RSSI采样年龄限制和更宽松的阈值

docs: 更新文档中的系统监控描述

- 从README.md、开发指南和设计文档中移除电压监控相关内容
- 更新WebUI接口文档中的状态查询描述

refactor(wifi_ota): 增加系统状态响应中的运行时间信息

- 在/system/status接口响应中添加uptime_sec字段
- 前端工具页面显示系统运行时间

feat(wifi_ota): 添加ESP32重启接口和前端控制

- 新增/restart HTTP POST接口用于重启ESP32
- 在前端界面添加重启按钮替代健康检查按钮
- 移除前端健康检查功能

perf(ota_relay): 调整OTA块大小提升传输性能

- 将OTA块大小从4096字节增加到8192字节
- 更新固件上传和存储相关的缓冲区大小配置
2026-05-05 21:55:29 +08:00

558 lines
19 KiB
Plaintext

// ==================== 全局状态 ====================
let currentFile = null;
let uploadProgress = 0;
let isUploading = false;
let logFilter = 'all';
let logEntries = [];
let logSocket = null;
let latestSystemStatus = null;
// ==================== 工具函数 ====================
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) {
return date.toTimeString().split(' ')[0];
}
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
const icon = document.getElementById('toastIcon');
const msg = document.getElementById('toastMessage');
toast.className = `toast ${type} show`;
icon.textContent = type === 'success' ? '✓' : type === 'error' ? '✕' : '⚠';
msg.textContent = message;
setTimeout(() => toast.classList.remove('show'), 3000);
}
function getEspIp() {
return document.getElementById('espIp').value.trim() || '192.168.31.127';
}
function getBaseUrl() {
return `http://${getEspIp()}`;
}
// ==================== 日志系统 ====================
function addLog(level, tag, message) {
const logContent = document.getElementById('logContent');
const time = formatTime(new Date());
const entry = { time, level, tag, message };
logEntries.push(entry);
if (logFilter !== 'all' && level !== logFilter) {
return;
}
const div = document.createElement('div');
div.className = 'log-entry';
div.innerHTML = `
<span class="log-time">[${time}]</span>
<span class="log-level ${level}">${level}</span>
<span class="log-tag">[${tag}]</span>
<span class="log-msg">${message}</span>
`;
logContent.appendChild(div);
logContent.scrollTop = logContent.scrollHeight;
document.getElementById('logCount').textContent = logEntries.length;
}
function clearLogs() {
document.getElementById('logContent').innerHTML = '';
logEntries = [];
document.getElementById('logCount').textContent = '0';
}
function toggleFilter(level, btn) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
logFilter = level;
const logContent = document.getElementById('logContent');
logContent.innerHTML = '';
logEntries.forEach(entry => {
if (level === 'all' || entry.level === level) {
const div = document.createElement('div');
div.className = 'log-entry';
div.innerHTML = `
<span class="log-time">[${entry.time}]</span>
<span class="log-level ${entry.level}">${entry.level}</span>
<span class="log-tag">[${entry.tag}]</span>
<span class="log-msg">${entry.message}</span>
`;
logContent.appendChild(div);
}
});
logContent.scrollTop = logContent.scrollHeight;
}
// ==================== 连接管理 ====================
function updateConnectionUi(connected, text = null) {
const badge = document.getElementById('connectionBadge');
const label = document.getElementById('connectionText');
badge.className = connected ? 'badge connected' : 'badge disconnected';
label.textContent = text || (connected ? '已连接' : '未连接');
}
function updateHealthUi(healthCode, freeHeap) {
const healthNames = ['正常', '警告', '危险', '关机'];
const health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
document.getElementById('healthText').textContent = `系统${health}`;
document.getElementById('healthBadge').className = healthCode === 0 ? 'badge connected' : 'badge warning';
if (freeHeap !== undefined) {
document.getElementById('statHeap').textContent = formatBytes(freeHeap);
}
}
function updateSystemStatusUi(data) {
if (!data) return;
latestSystemStatus = data;
const wifi = data.wifi || {};
const ota = data.ota || {};
const healthCode = data.health;
const relayStates = {
0: '待机',
1: '等待握手',
2: '握手已发',
3: '挑战已发',
4: '收到握手响应',
5: '会话密钥已发',
6: '认证完成',
7: '大小已发',
8: '大小确认',
9: '接收中',
10: '转发中',
11: '数据包已发',
12: 'ACK正常',
13: '等待重发',
14: '接收完成',
15: '传输完成',
16: '结束已发',
17: 'OTA完成',
255: '错误'
};
updateConnectionUi(!!wifi.connected, wifi.connected ? `${wifi.mode || 'STA'} 已连接` : `${wifi.mode || 'STA'} 未连接`);
updateHealthUi(healthCode, data.free_heap);
// 显示固件版本
if (data.version) {
document.getElementById('fwVersion').textContent = `v${data.version}`;
}
document.getElementById('statTemperature').textContent = Number.isFinite(data.temperature) ? `${data.temperature.toFixed(1)}°C` : '--';
document.getElementById('statVoltage').textContent = Number.isFinite(data.voltage) ? `${data.voltage.toFixed(2)}V` : '--';
document.getElementById('statWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接';
document.getElementById('statSignal').textContent = (wifi.rssi !== undefined && wifi.rssi !== -127) ? `${wifi.rssi} dBm` : '--';
document.getElementById('statHeap').textContent = data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
document.getElementById('statMinHeap').textContent = data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
const total = ota.total_size || 0;
const stored = ota.stored_size || 0;
const progress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
document.getElementById('statProgress').textContent = `${progress}%`;
document.getElementById('statRelayState').textContent = relayStates[ota.relay_state] ?? '待机';
}
async function testConnection() {
if (!latestSystemStatus) {
addLog('W', '连接', '暂无同步数据,请等待 1 秒自动同步');
showToast('暂无同步数据,请稍等 1 秒', 'warning');
return;
}
const wifi = latestSystemStatus.wifi || {};
const connected = !!wifi.connected;
updateSystemStatusUi(latestSystemStatus);
addLog('I', '连接', connected
? `当前已连接:${wifi.mode || 'STA'} ${wifi.ip || ''}`.trim()
: `当前未连接:${wifi.mode || 'STA'}`);
showToast(connected ? 'ESP32 当前在线' : 'ESP32 当前未连接', connected ? 'success' : 'warning');
}
async function healthCheck() {
if (!latestSystemStatus) {
addLog('W', '健康', '暂无同步数据,请等待 1 秒自动同步');
showToast('暂无同步数据,请稍等 1 秒', 'warning');
return;
}
updateSystemStatusUi(latestSystemStatus);
const data = latestSystemStatus;
const healthNames = ['正常', '警告', '危险', '关机'];
const health = data.health !== undefined ? (healthNames[data.health] || '未知') : '未知';
addLog('I', '健康', `当前状态: ${health}, 空闲堆: ${data.free_heap} bytes, 温度: ${data.temperature}°C, 电压: ${data.voltage}V`);
showToast(`当前健康状态: ${health}`, 'success');
}
// ==================== CAN 帧发送 ====================
async function sendCanFrame() {
const ch = parseInt(document.getElementById('canChannel').value);
const idStr = document.getElementById('canId').value.trim();
const dlc = parseInt(document.getElementById('canDlc').value) || 8;
const dataStr = document.getElementById('canData').value.trim();
let extId = 0;
if (idStr.startsWith('0x') || idStr.startsWith('0X')) {
extId = parseInt(idStr, 16);
} else {
extId = parseInt(idStr, 10);
if (isNaN(extId)) extId = parseInt(idStr, 16);
}
if (isNaN(extId)) {
showToast('ID 格式错误', 'error');
return;
}
let 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 {
const response = await fetch(`${getBaseUrl()}/can/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ch: ch,
id: extId,
dlc: dlc,
data: dataHex
}),
signal: AbortSignal.timeout(5000)
});
const result = await response.json();
if (result.status === 'ok') {
addLog('I', 'CAN', `响应: ${result.recv || '(无数据)'} (${result.recv_len || 0}B)`);
const respDiv = document.getElementById('canResponse');
const respContent = document.getElementById('canRespContent');
respDiv.style.display = 'block';
respContent.textContent = result.recv
? `0x${result.recv} (${result.recv_len} 字节)`
: '无响应数据';
showToast(`CH${ch} 发送成功`, 'success');
} else {
addLog('E', 'CAN', `发送失败: code=${result.code}`);
showToast(`发送失败: code=${result.code}`, 'error');
}
} catch (e) {
addLog('E', 'CAN', `发送失败: ${e.message}`);
showToast('发送失败', 'error');
}
}
function onTargetChipChange() {
const chip = document.getElementById('targetChip').value;
const relayBtn = document.getElementById('relayBtn');
const esp32Actions = document.getElementById('esp32Actions');
if (chip === 'esp32') {
relayBtn.style.display = 'none';
esp32Actions.style.display = 'block';
} else {
relayBtn.style.display = '';
esp32Actions.style.display = 'none';
}
addLog('I', '目标', `切换到: ${chip === 'esp32' ? 'ESP32 自身升级' : 'GD32 Flash 中继'}`);
}
function clearCanFields() {
document.getElementById('canId').value = '0x18DA00F1';
document.getElementById('canDlc').value = '8';
document.getElementById('canData').value = '0102030405060708';
document.getElementById('canResponse').style.display = 'none';
}
// ==================== 文件处理 ====================
function handleFileSelect(event) {
const file = event.target.files[0];
if (file) processFile(file);
}
document.getElementById('uploadZone').addEventListener('dragover', (e) => {
e.preventDefault();
e.currentTarget.classList.add('dragover');
});
document.getElementById('uploadZone').addEventListener('dragleave', (e) => {
e.currentTarget.classList.remove('dragover');
});
document.getElementById('uploadZone').addEventListener('drop', (e) => {
e.preventDefault();
e.currentTarget.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) processFile(file);
});
function processFile(file) {
currentFile = file;
const fileInfo = document.getElementById('fileInfo');
const uploadBtn = document.getElementById('uploadBtn');
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileType').textContent = file.name.endsWith('.hex') ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize').textContent = formatBytes(file.size);
const chunks = Math.ceil(file.size / 4096);
document.getElementById('fileChunks').textContent = chunks + ' 包';
fileInfo.classList.add('visible');
uploadBtn.disabled = false;
addLog('I', '文件', `已选择: ${file.name} (${formatBytes(file.size)})`);
showToast('文件已选择', 'success');
}
function clearFile() {
currentFile = null;
document.getElementById('fileInput').value = '';
document.getElementById('fileInfo').classList.remove('visible');
document.getElementById('uploadBtn').disabled = true;
document.getElementById('progressSection').classList.remove('visible');
addLog('I', '文件', '文件已清除');
}
// ==================== 上传固件 ====================
async function startUpload() {
if (!currentFile || isUploading) return;
const chip = document.getElementById('targetChip').value;
isUploading = true;
const uploadBtn = document.getElementById('uploadBtn');
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
const progressSection = document.getElementById('progressSection');
progressSection.classList.add('visible');
addLog('I', '上传', `开始上传: ${currentFile.name} → ${chip.toUpperCase()}`);
showToast(`开始上传固件到 ${chip.toUpperCase()}`, 'success');
try {
const arrayBuffer = await currentFile.arrayBuffer();
const fileSize = currentFile.size;
let body;
let url = `${getBaseUrl()}/ota/upload`;
if (chip === 'esp32') {
const headerJson = JSON.stringify({
type: 'esp32',
total_size: fileSize
});
const encoder = new TextEncoder();
const headerBytes = encoder.encode(headerJson);
const fileBytes = new Uint8Array(arrayBuffer);
const combined = new Uint8Array(headerBytes.length + fileSize);
combined.set(headerBytes, 0);
combined.set(fileBytes, headerBytes.length);
body = combined.buffer;
} else {
body = arrayBuffer;
}
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
uploadProgress = percent;
document.getElementById('progressBar').style.width = percent + '%';
document.getElementById('progressText').textContent =
`已上传 ${formatBytes(e.loaded)} / ${formatBytes(e.total)}`;
document.getElementById('progressPercent').textContent = percent + '%';
document.getElementById('statProgress').textContent = percent + '%';
}
});
xhr.addEventListener('load', () => {
isUploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = '⬆️ 上传固件';
if (xhr.status === 200) {
addLog('I', '上传', '固件上传完成!');
showToast('固件上传成功', 'success');
if (chip === 'esp32') {
document.getElementById('esp32Actions').style.display = 'block';
// 安全间隔:3 秒后自动调用刷写,留时间确认状态
let countdown = 3;
addLog('I', 'OTA', `${countdown} 秒后自动调用 POST /ota/update 完成升级并重启...`);
const timer = setInterval(() => {
countdown--;
if (countdown > 0) {
addLog('I', 'OTA', `${countdown} 秒后开始刷写...`);
} else {
clearInterval(timer);
addLog('I', 'OTA', '开始刷写!');
triggerEsp32Update();
}
}, 1000);
}
} else {
addLog('E', '上传', `上传失败: HTTP ${xhr.status}`);
showToast('上传失败', 'error');
}
});
xhr.addEventListener('error', () => {
isUploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = '⬆️ 上传固件';
addLog('E', '上传', '上传过程发生错误');
showToast('上传出错', 'error');
});
xhr.open('POST', url);
xhr.send(body);
} catch (e) {
addLog('E', '上传', `上传失败: ${e.message}`);
showToast('上传失败', 'error');
isUploading = false;
uploadBtn.disabled = false;
uploadBtn.textContent = '⬆️ 上传固件';
}
}
// ==================== ESP32 OTA 完成升级 ====================
async function triggerEsp32Update() {
addLog('I', 'OTA', '=== ESP32 OTA 完成,开始重启 ===');
showToast('ESP32 正在重启...', 'success');
try {
const response = await fetch(`${getBaseUrl()}/ota/update`, {
method: 'POST',
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
const data = await response.json();
addLog('I', 'OTA', `升级完成: ${data.message}`);
addLog('I', 'OTA', 'ESP32 即将重启,新固件将生效,页面将继续通过 1 秒状态同步自动恢复显示');
showToast('ESP32 重启中...', 'success');
}
} catch (e) {
addLog('E', 'OTA', `升级失败: ${e.message}`);
showToast('升级失败', 'error');
}
}
// ==================== 触发 OTA 中继 ====================
async function triggerRelay() {
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
showToast('正在触发 OTA 中继', 'success');
document.getElementById('stage1').className = 'stage active';
document.getElementById('statRelayState').textContent = '进行中';
try {
const response = await fetch(`${getBaseUrl()}/ota/relay`, {
method: 'POST',
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
addLog('I', 'OTA', 'OTA 中继已触发,正在等待 GD32 响应...');
setTimeout(() => {
document.getElementById('stage1').className = 'stage completed';
document.getElementById('stage2').className = 'stage active';
}, 2000);
setTimeout(() => {
document.getElementById('stage2').className = 'stage completed';
document.getElementById('stage3').className = 'stage active';
}, 4000);
setTimeout(() => {
document.getElementById('stage3').className = 'stage completed';
document.getElementById('stage4').className = 'stage active';
}, 6000);
setTimeout(() => {
document.getElementById('stage4').className = 'stage completed';
document.getElementById('statRelayState').textContent = '完成';
addLog('I', 'OTA', '=== OTA 中继完成 ===');
showToast('OTA 流程完成!', 'success');
}, 8000);
} else {
addLog('E', 'OTA', `触发失败: HTTP ${response.status}`);
showToast('触发失败', 'error');
}
} catch (e) {
addLog('E', 'OTA', `触发失败: ${e.message}`);
showToast('触发失败', 'error');
document.getElementById('statRelayState').textContent = '失败';
}
}
// ==================== 轮询状态 ====================
async function pollStatus() {
try {
const response = await fetch(`${getBaseUrl()}/system/status`, {
signal: AbortSignal.timeout(3000)
});
if (response.ok) {
const data = await response.json();
updateSystemStatusUi(data);
}
} catch (e) {
updateConnectionUi(false, '未连接');
}
}
setInterval(pollStatus, 1000);
// ==================== ESP32 实时日志拉取 ====================
async function pollLogs() {
try {
const response = await fetch(`${getBaseUrl()}/logs`, {
signal: AbortSignal.timeout(2000)
});
if (!response.ok) return;
const logs = await response.json();
if (!Array.isArray(logs) || logs.length === 0) return;
// 解析 [I][WIFI] xxx 格式
const levelMap = { 'E': 'E', 'W': 'W', 'I': 'I', 'D': 'D' };
logs.forEach(line => {
// 格式: [I][WIFI] message 或 [E][OTA] message
const m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
if (m) {
addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd());
} else {
addLog('I', 'ESP32', line.trimEnd());
}
});
} catch (e) {
// 静默失败,不打扰用户
}
}
setInterval(pollLogs, 1000);
setTimeout(() => addLog('I', '系统', '上位机初始化完成,等待固件文件...'), 500);