- 移除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字节 - 更新固件上传和存储相关的缓冲区大小配置
558 lines
19 KiB
Plaintext
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);
|