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字节 - 更新固件上传和存储相关的缓冲区大小配置
This commit is contained in:
@@ -12,7 +12,7 @@ ESP32-C3FH4 OTA 中继固件,通过 WiFi 接收上位机 OTA 固件包,通
|
||||
|
||||
| 模块 | 描述 |
|
||||
|------|------|
|
||||
| `health_monitor` | 温度 + 电压 + WiFi RSSI 监控 |
|
||||
| `health_monitor` | 温度 + WiFi RSSI 监控 |
|
||||
| `ota_relay` | OTA 数据包接收与转发 |
|
||||
| `wifi_ota` | WiFi OTA HTTP 服务器 + CAN 帧转发 |
|
||||
| `spi_master` | SPI 主站,与 GD32 通信 |
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
// ==================== 全局状态 ====================
|
||||
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);
|
||||
@@ -0,0 +1,556 @@
|
||||
// ==================== 全局状态 ====================
|
||||
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('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`);
|
||||
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);
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* app_config.h - 统一配置文件(C++ 版本)
|
||||
* ESP32-C3 OTA 中继项目
|
||||
*
|
||||
* 使用 constexpr 代替 #define,提供类型安全
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// ============================================================
|
||||
// 固件版本
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = "0.1.1";
|
||||
|
||||
// ============================================================
|
||||
// WiFi 工作模式选择
|
||||
// ============================================================
|
||||
// 0 = AP 模式(设备当热点)
|
||||
// 1 = STA 模式(连接路由器)
|
||||
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
|
||||
|
||||
// ============================================================
|
||||
// WiFi STA 模式配置(连接路由器)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_STA_SSID = "004舰桥";
|
||||
inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622";
|
||||
inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000;
|
||||
inline constexpr uint8_t WIFI_STA_MAX_RETRY = 5; // 最大重连次数,超过后切换 AP 模式
|
||||
|
||||
// STA 静态 IP
|
||||
inline constexpr uint8_t WIFI_STA_USE_STATIC_IP = 0; // 先关闭静态 IP,使用 DHCP 排查连接问题
|
||||
|
||||
// 静态 IP 配置(始终定义,由 WIFI_STA_USE_STATIC_IP 在运行时决定是否使用)
|
||||
inline constexpr const char* WIFI_STA_STATIC_IP = "192.168.31.32";
|
||||
inline constexpr const char* WIFI_STA_STATIC_GW = "192.168.31.1";
|
||||
inline constexpr const char* WIFI_STA_STATIC_NETMASK = "255.255.255.0";
|
||||
inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.31.1";
|
||||
|
||||
// ============================================================
|
||||
// WiFi AP 模式配置(设备当热点)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_AP_SSID = "ESP32_OTA_RELAY";
|
||||
inline constexpr const char* WIFI_AP_PASSWORD = "12345678";
|
||||
inline constexpr uint8_t WIFI_AP_CHANNEL = 6;
|
||||
inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4;
|
||||
|
||||
// ============================================================
|
||||
// HTTP 服务器配置
|
||||
// ============================================================
|
||||
inline constexpr uint16_t HTTP_SERVER_PORT = 80;
|
||||
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192;
|
||||
|
||||
// ============================================================
|
||||
// OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 4096;
|
||||
inline constexpr size_t ESP32_OTA_RECV_BLOCK_SIZE = 8192;
|
||||
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// ============================================================
|
||||
// 健康监控阈值
|
||||
// ============================================================
|
||||
inline constexpr int32_t WIFI_RSSI_CRITICAL = -85;
|
||||
inline constexpr int32_t WIFI_RSSI_WARNING = -75;
|
||||
inline constexpr int32_t TEMP_WARNING = 80;
|
||||
inline constexpr int32_t TEMP_CRITICAL = 100;
|
||||
inline constexpr size_t HEAP_WARNING_MIN = 10240;
|
||||
|
||||
// ============================================================
|
||||
// 调试配置(UDP 日志)
|
||||
// ============================================================
|
||||
inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; // 设为 0 禁用调试日志
|
||||
inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; // 上位机 IP
|
||||
inline constexpr uint16_t DEBUG_LOG_PORT = 6666;
|
||||
|
||||
// ============================================================
|
||||
// BLE OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr const char* BLE_DEVICE_NAME = "ESP32-OTA-RELAY";
|
||||
inline constexpr uint16_t BLE_MTU_SIZE = 512;
|
||||
inline constexpr uint16_t BLE_OTA_SERVICE_UUID = 0xFFFF;
|
||||
inline constexpr uint16_t BLE_OTA_CTRL_UUID = 0x0001;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_IN_UUID = 0x0002;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003;
|
||||
inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004;
|
||||
|
||||
// ============================================================
|
||||
// 安全常量(需与 GD32 固件团队确认)
|
||||
// ⚠️ 请填入实际密钥
|
||||
// ============================================================
|
||||
inline constexpr uint8_t MASTER_KEY[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F
|
||||
};
|
||||
|
||||
inline constexpr uint8_t SCRAMBLE_CONST[] = {
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
|
||||
};
|
||||
|
||||
// ⚠️ 请填入实际的 GD32 芯片唯一码(12字节)
|
||||
inline constexpr uint8_t GD32_CHIP_UID[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
|
||||
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* app_config.h - 统一配置文件(C++ 版本)
|
||||
* ESP32-C3 OTA 中继项目
|
||||
*
|
||||
* 使用 constexpr 代替 #define,提供类型安全
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// ============================================================
|
||||
// 固件版本
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = "0.1.1";
|
||||
|
||||
// ============================================================
|
||||
// WiFi 工作模式选择
|
||||
// ============================================================
|
||||
// 0 = AP 模式(设备当热点)
|
||||
// 1 = STA 模式(连接路由器)
|
||||
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
|
||||
|
||||
// ============================================================
|
||||
// WiFi STA 模式配置(连接路由器)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_STA_SSID = "004舰桥";
|
||||
inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622";
|
||||
inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000;
|
||||
inline constexpr uint8_t WIFI_STA_MAX_RETRY = 5; // 最大重连次数,超过后切换 AP 模式
|
||||
|
||||
// STA 静态 IP
|
||||
inline constexpr uint8_t WIFI_STA_USE_STATIC_IP = 0; // 先关闭静态 IP,使用 DHCP 排查连接问题
|
||||
|
||||
// 静态 IP 配置(始终定义,由 WIFI_STA_USE_STATIC_IP 在运行时决定是否使用)
|
||||
inline constexpr const char* WIFI_STA_STATIC_IP = "192.168.31.32";
|
||||
inline constexpr const char* WIFI_STA_STATIC_GW = "192.168.31.1";
|
||||
inline constexpr const char* WIFI_STA_STATIC_NETMASK = "255.255.255.0";
|
||||
inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.31.1";
|
||||
|
||||
// ============================================================
|
||||
// WiFi AP 模式配置(设备当热点)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_AP_SSID = "ESP32_OTA_RELAY";
|
||||
inline constexpr const char* WIFI_AP_PASSWORD = "12345678";
|
||||
inline constexpr uint8_t WIFI_AP_CHANNEL = 6;
|
||||
inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4;
|
||||
|
||||
// ============================================================
|
||||
// HTTP 服务器配置
|
||||
// ============================================================
|
||||
inline constexpr uint16_t HTTP_SERVER_PORT = 80;
|
||||
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192;
|
||||
|
||||
// ============================================================
|
||||
// OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 4096;
|
||||
inline constexpr size_t ESP32_OTA_RECV_BLOCK_SIZE = 8192;
|
||||
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// ============================================================
|
||||
// 健康监控阈值
|
||||
// ============================================================
|
||||
inline constexpr int32_t WIFI_RSSI_CRITICAL = -85;
|
||||
inline constexpr int32_t WIFI_RSSI_WARNING = -75;
|
||||
inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; // OTA 期间更宽松,避免瞬时波动误判
|
||||
inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; // OTA 最多接受 15 秒内的 RSSI 样本
|
||||
inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; // 每 32 个 48B 包做一次 OTA 健康检查
|
||||
inline constexpr int32_t TEMP_WARNING = 80;
|
||||
inline constexpr int32_t TEMP_CRITICAL = 100;
|
||||
inline constexpr size_t HEAP_WARNING_MIN = 10240;
|
||||
|
||||
// ============================================================
|
||||
// 调试配置(UDP 日志)
|
||||
// ============================================================
|
||||
inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; // 设为 0 禁用调试日志
|
||||
inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; // 上位机 IP
|
||||
inline constexpr uint16_t DEBUG_LOG_PORT = 6666;
|
||||
|
||||
// ============================================================
|
||||
// BLE OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr const char* BLE_DEVICE_NAME = "ESP32-OTA-RELAY";
|
||||
inline constexpr uint16_t BLE_MTU_SIZE = 512;
|
||||
inline constexpr uint16_t BLE_OTA_SERVICE_UUID = 0xFFFF;
|
||||
inline constexpr uint16_t BLE_OTA_CTRL_UUID = 0x0001;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_IN_UUID = 0x0002;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003;
|
||||
inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004;
|
||||
|
||||
// ============================================================
|
||||
// 安全常量(需与 GD32 固件团队确认)
|
||||
// ⚠️ 请填入实际密钥
|
||||
// ============================================================
|
||||
inline constexpr uint8_t MASTER_KEY[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F
|
||||
};
|
||||
|
||||
inline constexpr uint8_t SCRAMBLE_CONST[] = {
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
|
||||
};
|
||||
|
||||
// ⚠️ 请填入实际的 GD32 芯片唯一码(12字节)
|
||||
inline constexpr uint8_t GD32_CHIP_UID[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
|
||||
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* app_config.h - 统一配置文件(C++ 版本)
|
||||
* ESP32-C3 OTA 中继项目
|
||||
*
|
||||
* 使用 constexpr 代替 #define,提供类型安全
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// ============================================================
|
||||
// 固件版本
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = "0.1.1";
|
||||
|
||||
// ============================================================
|
||||
// WiFi 工作模式选择
|
||||
// ============================================================
|
||||
// 0 = AP 模式(设备当热点)
|
||||
// 1 = STA 模式(连接路由器)
|
||||
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
|
||||
|
||||
// ============================================================
|
||||
// WiFi STA 模式配置(连接路由器)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_STA_SSID = "004舰桥";
|
||||
inline constexpr const char* WIFI_STA_PASSWORD = "q13362875622";
|
||||
inline constexpr uint32_t WIFI_STA_CONNECT_TIMEOUT_MS = 30000;
|
||||
inline constexpr uint8_t WIFI_STA_MAX_RETRY = 5; // 最大重连次数,超过后切换 AP 模式
|
||||
|
||||
// STA 静态 IP
|
||||
inline constexpr uint8_t WIFI_STA_USE_STATIC_IP = 0; // 先关闭静态 IP,使用 DHCP 排查连接问题
|
||||
|
||||
// 静态 IP 配置(始终定义,由 WIFI_STA_USE_STATIC_IP 在运行时决定是否使用)
|
||||
inline constexpr const char* WIFI_STA_STATIC_IP = "192.168.31.32";
|
||||
inline constexpr const char* WIFI_STA_STATIC_GW = "192.168.31.1";
|
||||
inline constexpr const char* WIFI_STA_STATIC_NETMASK = "255.255.255.0";
|
||||
inline constexpr const char* WIFI_STA_STATIC_DNS = "192.168.31.1";
|
||||
|
||||
// ============================================================
|
||||
// WiFi AP 模式配置(设备当热点)
|
||||
// ============================================================
|
||||
inline constexpr const char* WIFI_AP_SSID = "ESP32_OTA_RELAY";
|
||||
inline constexpr const char* WIFI_AP_PASSWORD = "12345678";
|
||||
inline constexpr uint8_t WIFI_AP_CHANNEL = 6;
|
||||
inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4;
|
||||
|
||||
// ============================================================
|
||||
// HTTP 服务器配置
|
||||
// ============================================================
|
||||
inline constexpr uint16_t HTTP_SERVER_PORT = 80;
|
||||
inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192;
|
||||
|
||||
// ============================================================
|
||||
// OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 8192;
|
||||
inline constexpr size_t ESP32_OTA_RECV_BLOCK_SIZE = 8192;
|
||||
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// ============================================================
|
||||
// 健康监控阈值
|
||||
// ============================================================
|
||||
inline constexpr int32_t WIFI_RSSI_CRITICAL = -85;
|
||||
inline constexpr int32_t WIFI_RSSI_WARNING = -75;
|
||||
inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; // OTA 期间更宽松,避免瞬时波动误判
|
||||
inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; // OTA 最多接受 15 秒内的 RSSI 样本
|
||||
inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; // 每 32 个 48B 包做一次 OTA 健康检查
|
||||
inline constexpr int32_t TEMP_WARNING = 80;
|
||||
inline constexpr int32_t TEMP_CRITICAL = 100;
|
||||
inline constexpr size_t HEAP_WARNING_MIN = 10240;
|
||||
|
||||
// ============================================================
|
||||
// 调试配置(UDP 日志)
|
||||
// ============================================================
|
||||
inline constexpr uint8_t DEBUG_LOG_ENABLED = 1; // 设为 0 禁用调试日志
|
||||
inline constexpr const char* DEBUG_LOG_IP = "192.168.31.3"; // 上位机 IP
|
||||
inline constexpr uint16_t DEBUG_LOG_PORT = 6666;
|
||||
|
||||
// ============================================================
|
||||
// BLE OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr const char* BLE_DEVICE_NAME = "ESP32-OTA-RELAY";
|
||||
inline constexpr uint16_t BLE_MTU_SIZE = 512;
|
||||
inline constexpr uint16_t BLE_OTA_SERVICE_UUID = 0xFFFF;
|
||||
inline constexpr uint16_t BLE_OTA_CTRL_UUID = 0x0001;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_IN_UUID = 0x0002;
|
||||
inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003;
|
||||
inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004;
|
||||
|
||||
// ============================================================
|
||||
// 安全常量(需与 GD32 固件团队确认)
|
||||
// ⚠️ 请填入实际密钥
|
||||
// ============================================================
|
||||
inline constexpr uint8_t MASTER_KEY[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
||||
0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F
|
||||
};
|
||||
|
||||
inline constexpr uint8_t SCRAMBLE_CONST[] = {
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
|
||||
};
|
||||
|
||||
// ⚠️ 请填入实际的 GD32 芯片唯一码(12字节)
|
||||
inline constexpr uint8_t GD32_CHIP_UID[] = {
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
|
||||
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
|
||||
};
|
||||
@@ -0,0 +1,643 @@
|
||||
/*
|
||||
* ble_ota.cpp - BLE OTA GATT 服务器实现 (ESP-IDF 6.0 NimBLE)
|
||||
* T5.1 - T5.5
|
||||
*
|
||||
* 通过 BLE GATT 接收上位机固件,存储到 Flash (gd32_ota 分区),
|
||||
* 接收完成后触发 OTA 中继任务转发到 GD32。
|
||||
*
|
||||
* GATT 服务:
|
||||
* Service UUID: 0xFFFF
|
||||
* - CTRL (0x0001, Write): 写入 0xF7 触发中继
|
||||
* - DATA_IN (0x0002, Write): 接收固件数据块
|
||||
* - DATA_OUT (0x0003, Notify): 推送 GD32 响应
|
||||
* - VERSION (0x0004, Read): 固件版本
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <inttypes.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "esp_err.h"
|
||||
|
||||
// NimBLE 协议栈
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/ble_gap.h"
|
||||
#include "services/gap/ble_svc_gap.h"
|
||||
#include "services/gatt/ble_svc_gatt.h"
|
||||
|
||||
#include "app_config.h"
|
||||
#include "ble_ota.h"
|
||||
#include "ota_relay.h"
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
#define TAG "BLE_OTA"
|
||||
|
||||
// ============================================================
|
||||
// 静态状态
|
||||
// ============================================================
|
||||
|
||||
static bool s_ble_ready = false; // NimBLE 同步完成
|
||||
static bool s_ble_connected = false; // GATT 客户端已连接
|
||||
/** @brief 当前 BLE 连接句柄。 */
|
||||
static uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||
static uint16_t s_data_out_val_handle = 0; // DATA_OUT 值句柄 (供通知用)
|
||||
|
||||
// ============================================================
|
||||
// NimBLE 工具: 从 mbuf 读取原始数据
|
||||
/**
|
||||
* @brief 将 NimBLE mbuf 中的数据拷贝到普通字节缓冲区。
|
||||
* @param om NimBLE mbuf 数据指针。
|
||||
* @param buf 输出缓冲区。
|
||||
* @param buf_len 缓冲区最大长度。
|
||||
* @return 拷贝的字节数,失败返回 -1。
|
||||
*/
|
||||
static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
|
||||
{
|
||||
if (!om || !buf) return -1;
|
||||
uint16_t om_len = OS_MBUF_PKTLEN(om);
|
||||
uint16_t copy_len = (om_len > buf_len) ? (uint16_t)buf_len : om_len;
|
||||
os_mbuf_copydata(om, 0, copy_len, buf);
|
||||
return (int)copy_len;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GATT 访问回调
|
||||
// ============================================================
|
||||
|
||||
/*
|
||||
* CTRL (0x0001) - Write
|
||||
* 写入 0xF7 触发 OTA 中继
|
||||
*/
|
||||
static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg)
|
||||
{
|
||||
(void)conn_handle;
|
||||
(void)attr_handle;
|
||||
(void)arg;
|
||||
|
||||
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
|
||||
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
uint8_t buf[4] = {0};
|
||||
int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
|
||||
if (len < 1) {
|
||||
DEBUG_WARN(TAG, "CTRL 数据为空");
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X", buf[0]);
|
||||
|
||||
if (buf[0] == 0xF7) {
|
||||
// 检查固件是否存储完毕
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = meta_load(&meta);
|
||||
if (err == ESP_OK &&
|
||||
meta.total_size > 0 &&
|
||||
meta.stored_size >= meta.total_size) {
|
||||
DEBUG_INFO(TAG, "固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继",
|
||||
meta.stored_size, meta.total_size);
|
||||
ble_ota_signal_relay();
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "固件未就绪, 未触发中继 (stored=%" PRIu32 ", total=%" PRIu32 ")",
|
||||
meta.stored_size, meta.total_size);
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
|
||||
}
|
||||
|
||||
/*
|
||||
* DATA_IN (0x0002) - Write
|
||||
* 数据块格式:
|
||||
* [0] cmd (0x00=数据)
|
||||
* [1-2] seq uint16 LE - 分块序号
|
||||
* [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效)
|
||||
* [7..n-4] payload 固件数据
|
||||
* [n-4..n] crc 校验 (预留)
|
||||
*/
|
||||
static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg)
|
||||
{
|
||||
(void)conn_handle;
|
||||
(void)attr_handle;
|
||||
(void)arg;
|
||||
|
||||
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
|
||||
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
uint8_t buf[BLE_MTU_SIZE] = {0};
|
||||
int recv_len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
|
||||
|
||||
if (recv_len < 12) {
|
||||
DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 12)", recv_len);
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
// 解析包头
|
||||
uint8_t cmd = buf[0];
|
||||
uint16_t seq = (uint16_t)(buf[1] | (buf[2] << 8));
|
||||
uint32_t total_size = (uint32_t)(buf[3] | (buf[4] << 8) |
|
||||
(buf[5] << 16) | (buf[6] << 24));
|
||||
|
||||
size_t data_offset = 7;
|
||||
size_t data_len = (size_t)recv_len - data_offset;
|
||||
|
||||
// 尾 4 字节 CRC 预留,实际 payload = data_len - 4
|
||||
if (data_len < 4) {
|
||||
DEBUG_WARN(TAG, "DATA_IN 负载不足: data_len=%zu", data_len);
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
data_len -= 4; // 减去尾部 CRC 预留
|
||||
|
||||
DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu",
|
||||
cmd, seq, total_size, data_len);
|
||||
|
||||
// --- 首个分块: 创建存储元数据 ---
|
||||
if (seq == 0 && total_size > 0) {
|
||||
if (total_size > OTA_MAX_FIRMWARE_SIZE) {
|
||||
DEBUG_ERR(TAG, "固件太大: %" PRIu32 " > %zu",
|
||||
total_size, OTA_MAX_FIRMWARE_SIZE);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
ota_meta_t meta = {};
|
||||
meta.total_size = total_size;
|
||||
meta.stored_size = 0;
|
||||
meta.last_seq = 0;
|
||||
meta.chunk_count = (total_size + (OTA_BLOCK_SIZE - 1)) / OTA_BLOCK_SIZE;
|
||||
|
||||
esp_err_t err = meta_save(&meta);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "元数据保存失败");
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32,
|
||||
meta.total_size, meta.chunk_count);
|
||||
}
|
||||
|
||||
// --- 写入 Flash ---
|
||||
uint32_t flash_offset = seq * OTA_BLOCK_SIZE;
|
||||
esp_err_t err = flash_write_chunk(flash_offset, &buf[data_offset], data_len);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s",
|
||||
seq, flash_offset, esp_err_to_name(err));
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
err = meta_update(flash_offset + data_len, seq);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq);
|
||||
}
|
||||
|
||||
// --- 检查是否接收完毕 ---
|
||||
ota_meta_t meta;
|
||||
if (meta_load(&meta) == ESP_OK &&
|
||||
meta.total_size > 0 &&
|
||||
meta.stored_size >= meta.total_size) {
|
||||
DEBUG_INFO(TAG, "★ 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
|
||||
meta.stored_size, meta.total_size, seq + 1);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* VERSION (0x0004) - Read
|
||||
* 返回固件版本字符串
|
||||
*/
|
||||
static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg)
|
||||
{
|
||||
(void)conn_handle;
|
||||
(void)attr_handle;
|
||||
(void)arg;
|
||||
|
||||
if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) {
|
||||
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const char *version = "1.0.0";
|
||||
int rc = os_mbuf_append(ctxt->om, version, strlen(version));
|
||||
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
/*
|
||||
* DATA_OUT (0x0003) - Notify only
|
||||
* 访问回调不做读写处理,通知由 ble_ota_notify_response() 发送
|
||||
*/
|
||||
static int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
struct ble_gatt_access_ctxt *ctxt, void *arg)
|
||||
{
|
||||
(void)conn_handle;
|
||||
(void)attr_handle;
|
||||
(void)ctxt;
|
||||
(void)arg;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GATT 服务定义
|
||||
// ============================================================
|
||||
|
||||
// BLE_UUID16_DECLARE 在 C++ 中产生 "taking address of rvalue" 编译错误。
|
||||
// 改用 static ble_uuid16_t 变量,再取 .u 地址。
|
||||
/** @brief OTA GATT 服务 UUID */
|
||||
static const ble_uuid16_t GATT_SVC_UUID = BLE_UUID16_INIT(BLE_OTA_SERVICE_UUID);
|
||||
/** @brief CTRL 特征 UUID,写入 0xF7 触发中继 */
|
||||
static const ble_uuid16_t GATT_CTRL_UUID = BLE_UUID16_INIT(BLE_OTA_CTRL_UUID);
|
||||
/** @brief DATA_IN 特征 UUID,接收固件数据块 */
|
||||
static const ble_uuid16_t GATT_DATA_IN_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_IN_UUID);
|
||||
/** @brief DATA_OUT 特征 UUID,Notify 推送 GD32 响应 */
|
||||
static const ble_uuid16_t GATT_DATA_OUT_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_OUT_UUID);
|
||||
/** @brief VERSION 特征 UUID,返回固件版本字符串 */
|
||||
static const ble_uuid16_t GATT_VERSION_UUID = BLE_UUID16_INIT(BLE_OTA_VERSION_UUID);
|
||||
|
||||
// NimBLE C 结构体在 C++ designated initializer 不能显式初始化全部字段。
|
||||
// 用 pragma 局部关闭 -Wmissing-field-initializers 警告。
|
||||
#ifdef __cplusplus
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
|
||||
#endif
|
||||
|
||||
static const struct ble_gatt_chr_def gatt_ota_chrs[] = {
|
||||
{
|
||||
.uuid = &GATT_CTRL_UUID.u,
|
||||
.access_cb = ble_ota_ctrl_access,
|
||||
.flags = BLE_GATT_CHR_F_WRITE,
|
||||
},
|
||||
{
|
||||
.uuid = &GATT_DATA_IN_UUID.u,
|
||||
.access_cb = ble_ota_data_in_access,
|
||||
.flags = BLE_GATT_CHR_F_WRITE,
|
||||
},
|
||||
{
|
||||
.uuid = &GATT_DATA_OUT_UUID.u,
|
||||
.access_cb = ble_ota_data_out_access,
|
||||
.flags = BLE_GATT_CHR_F_NOTIFY,
|
||||
.val_handle = &s_data_out_val_handle,
|
||||
},
|
||||
{
|
||||
.uuid = &GATT_VERSION_UUID.u,
|
||||
.access_cb = ble_ota_version_access,
|
||||
.flags = BLE_GATT_CHR_F_READ,
|
||||
},
|
||||
{} // 终止符
|
||||
};
|
||||
|
||||
static const struct ble_gatt_svc_def gatt_ota_svcs[] = {
|
||||
{
|
||||
.type = BLE_GATT_SVC_TYPE_PRIMARY,
|
||||
.uuid = &GATT_SVC_UUID.u,
|
||||
.characteristics = (struct ble_gatt_chr_def *)gatt_ota_chrs,
|
||||
},
|
||||
{} // 终止符
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
|
||||
// ============================================================
|
||||
// GAP 事件回调
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief BLE GAP 事件回调,处理连接和断开事件。
|
||||
* @param event
|
||||
* @param arg
|
||||
* @return 0 成功。
|
||||
*/
|
||||
static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
switch (event->type) {
|
||||
|
||||
case BLE_GAP_EVENT_CONNECT:
|
||||
if (event->connect.status == 0) {
|
||||
// 连接成功
|
||||
s_ble_connected = true;
|
||||
/** @brief 当前 BLE 连接句柄。 */
|
||||
s_conn_handle = event->connect.conn_handle;
|
||||
DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", s_conn_handle);
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
|
||||
/** @brief BLE GATT 客户端连接状态。 */
|
||||
s_ble_connected = false;
|
||||
}
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_DISCONNECT:
|
||||
DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d",
|
||||
event->disconnect.reason);
|
||||
/** @brief BLE GATT 客户端连接状态。 */
|
||||
s_ble_connected = false;
|
||||
/** @brief 当前 BLE 连接句柄。 */
|
||||
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||
// 恢复广播以接受新连接
|
||||
ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
|
||||
NULL, ble_ota_gap_event, NULL);
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_ADV_COMPLETE:
|
||||
DEBUG_INFO(TAG, "广播周期结束,重新开始");
|
||||
ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
|
||||
NULL, ble_ota_gap_event, NULL);
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_SUBSCRIBE:
|
||||
DEBUG_INFO(TAG, "CCC 描述符更新: conn=%d attr=%d notify=%d indicate=%d",
|
||||
event->subscribe.conn_handle,
|
||||
event->subscribe.attr_handle,
|
||||
event->subscribe.cur_notify,
|
||||
event->subscribe.cur_indicate);
|
||||
return 0;
|
||||
|
||||
case BLE_GAP_EVENT_MTU:
|
||||
DEBUG_INFO(TAG, "MTU 协商: conn=%d mtu=%d",
|
||||
event->mtu.conn_handle, event->mtu.value);
|
||||
return 0;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BLE 广播
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 启动 BLE 广播,使设备可被发现和连接。
|
||||
*/
|
||||
static void ble_ota_start_advertising(void)
|
||||
{
|
||||
uint8_t adv_data[31] = {0};
|
||||
int adv_pos = 0;
|
||||
|
||||
// AD Flags: LE General Discoverable + BR/EDR Not Supported
|
||||
adv_data[adv_pos++] = 0x02; // 长度
|
||||
adv_data[adv_pos++] = 0x01; // 类型 = Flags
|
||||
adv_data[adv_pos++] = 0x06; // 值
|
||||
|
||||
// Complete Local Name
|
||||
const char *name = BLE_DEVICE_NAME;
|
||||
size_t name_len = strlen(name);
|
||||
if (name_len > 29 - adv_pos - 2) {
|
||||
name_len = 29 - adv_pos - 2; // 截断以适配广播包
|
||||
}
|
||||
adv_data[adv_pos++] = (uint8_t)(name_len + 1);
|
||||
adv_data[adv_pos++] = 0x09; // 类型 = Complete Local Name
|
||||
memcpy(&adv_data[adv_pos], name, name_len);
|
||||
adv_pos += name_len;
|
||||
|
||||
int rc = ble_gap_adv_set_data(adv_data, adv_pos);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "设置广播数据失败: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
// 扫描响应: 包含 Service UUID 以帮助发现
|
||||
uint8_t scan_rsp[31] = {0};
|
||||
int rsp_pos = 0;
|
||||
scan_rsp[rsp_pos++] = 0x11; // 长度 (2 + 15)
|
||||
scan_rsp[rsp_pos++] = 0x09; // 类型 = Complete Local Name
|
||||
name_len = strlen(name);
|
||||
if (name_len > (size_t)(31 - rsp_pos)) {
|
||||
name_len = 31 - rsp_pos;
|
||||
}
|
||||
memcpy(&scan_rsp[rsp_pos], name, name_len);
|
||||
rsp_pos += name_len;
|
||||
|
||||
rc = ble_gap_adv_rsp_set_data(scan_rsp, rsp_pos);
|
||||
if (rc != 0) {
|
||||
DEBUG_WARN(TAG, "设置扫描响应数据失败: %d", rc);
|
||||
}
|
||||
|
||||
// 开始广播
|
||||
struct ble_gap_adv_params adv_params = {};
|
||||
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||
// 从 kconfig 获取 BLE 地址类型,默认 Public
|
||||
#ifdef CONFIG_BLE_ADDR_TYPE_PUBLIC
|
||||
uint8_t own_addr_type = BLE_OWN_ADDR_PUBLIC;
|
||||
#else
|
||||
uint8_t own_addr_type = BLE_OWN_ADDR_RANDOM;
|
||||
#endif
|
||||
|
||||
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
|
||||
&adv_params, ble_ota_gap_event, NULL);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "启动广播失败: %d", rc);
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "BLE 广播已启动: \"%s\"", name);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NimBLE 同步/重置回调
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief NimBLE 协议栈同步完成回调,初始化广播。
|
||||
*/
|
||||
static void ble_ota_on_sync(void)
|
||||
{
|
||||
DEBUG_INFO(TAG, "NimBLE 协议栈同步完成");
|
||||
|
||||
// GATT 服务已在 ble_ota_server_start() 中注册,不再重复注册
|
||||
// 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY
|
||||
|
||||
s_ble_ready = true;
|
||||
ble_ota_start_advertising();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief NimBLE 协议栈重置回调。
|
||||
* @param reason
|
||||
*/
|
||||
static void ble_ota_on_reset(int reason)
|
||||
{
|
||||
DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason);
|
||||
/** @brief NimBLE 协议栈同步完成标志。 */
|
||||
s_ble_ready = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NimBLE Host 任务(独立 FreeRTOS 任务)
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief NimBLE 主机任务入口,处理协议栈事件循环。
|
||||
* @param param
|
||||
*/
|
||||
static void ble_ota_nimble_host_task(void *param)
|
||||
{
|
||||
(void)param;
|
||||
DEBUG_INFO(TAG, "NimBLE Host 任务启动");
|
||||
nimble_port_run();
|
||||
// 正常情况下不会到达这里
|
||||
DEBUG_INFO(TAG, "NimBLE Host 任务退出");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 公开 API
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief 启动 BLE GATT 服务器并注册 OTA 服务。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t ble_ota_server_start(void)
|
||||
{
|
||||
int rc;
|
||||
|
||||
DEBUG_INFO(TAG, "=== 初始化 BLE OTA GATT 服务器 ===");
|
||||
DEBUG_INFO(TAG, " Device: %s", BLE_DEVICE_NAME);
|
||||
DEBUG_INFO(TAG, " MTU: %" PRIu16, BLE_MTU_SIZE);
|
||||
DEBUG_INFO(TAG, " Svc: 0x%04X", BLE_OTA_SERVICE_UUID);
|
||||
|
||||
esp_err_t err = nimble_port_init();
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "nimble_port_init 失败: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
DEBUG_INFO(TAG, "nimble_port_init 完成");
|
||||
|
||||
ble_svc_gap_init();
|
||||
ble_svc_gatt_init();
|
||||
|
||||
rc = ble_svc_gap_device_name_set(BLE_DEVICE_NAME);
|
||||
if (rc != 0) {
|
||||
DEBUG_WARN(TAG, "设置设备名称失败: %d", rc);
|
||||
}
|
||||
|
||||
// 5. 配置 Host 回调
|
||||
// sync_cb: NimBLE 事件循环就绪时调用 (开始广播)
|
||||
// reset_cb: NimBLE 重置时调用
|
||||
ble_hs_cfg.sync_cb = ble_ota_on_sync;
|
||||
ble_hs_cfg.reset_cb = ble_ota_on_reset;
|
||||
// 不做 bonding,不需要 store 回调
|
||||
|
||||
rc = ble_gatts_count_cfg(gatt_ota_svcs);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "GATT 服务计数失败: %d", rc);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
rc = ble_gatts_add_svcs(gatt_ota_svcs);
|
||||
if (rc != 0) {
|
||||
DEBUG_ERR(TAG, "GATT 服务注册失败: %d", rc);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
nimble_port_freertos_init(ble_ota_nimble_host_task);
|
||||
|
||||
DEBUG_INFO(TAG, "BLE OTA 服务器初始化完成");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 通过 BLE Notify 将 GD32 响应推送给上位机。
|
||||
* @param data 待推送的数据指针。
|
||||
* @param len 数据长度(字节)。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t ble_ota_notify_response(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (!s_ble_connected) {
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (s_data_out_val_handle == 0) {
|
||||
DEBUG_WARN(TAG, "DATA_OUT 值句柄未设置");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (len == 0) return ESP_OK;
|
||||
|
||||
struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len);
|
||||
if (om == NULL) {
|
||||
DEBUG_ERR(TAG, "分配 mbuf 失败 (%zu 字节)", len);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
int rc = ble_gattc_notify_custom(s_conn_handle, s_data_out_val_handle, om);
|
||||
if (rc != 0) {
|
||||
DEBUG_WARN(TAG, "通知发送失败: rc=%d", rc);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
DEBUG_DBG(TAG, "通知已发送: %zu 字节", len);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 检查当前是否有 BLE 客户端连接。
|
||||
* @return true 已连接,false 未连接。
|
||||
*/
|
||||
bool ble_ota_is_connected(void)
|
||||
{
|
||||
/** @brief BLE GATT 客户端连接状态。 */
|
||||
return s_ble_connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 通知中继模块开始向 GD32 转发固件。
|
||||
*/
|
||||
void ble_ota_signal_relay(void)
|
||||
{
|
||||
if (g_ota_dispatch_queue == NULL) {
|
||||
DEBUG_ERR(TAG, "OTA 调度队列未初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t msg = 1;
|
||||
BaseType_t ret = xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(200));
|
||||
if (ret != pdTRUE) {
|
||||
DEBUG_ERR(TAG, "OTA 调度信号发送失败: 队列满");
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_INFO(TAG, "OTA 调度信号已发送 → ota_relay_task");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BLE OTA FreeRTOS 任务入口
|
||||
//
|
||||
// 注意: 此任务的职责是初始化 BLE 服务器并保持存活。
|
||||
// 实际的 BLE 事件循环在独立的 NimBLE Host 任务中运行。
|
||||
// 为避免与 ota_relay_task 竞争,此任务不从 g_ota_dispatch_queue 消费。
|
||||
// ============================================================
|
||||
/**
|
||||
* @brief BLE OTA FreeRTOS 任务,初始化 NimBLE 并运行 GATT 服务。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void ble_ota_task(void *pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
DEBUG_INFO(TAG, "=== BLE OTA 任务启动 ===");
|
||||
|
||||
esp_err_t err = ble_ota_server_start();
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "BLE 服务器启动失败: %s → BLE OTA 不可用",
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
|
||||
// 保持任务存活 — 所有 BLE 事件由 NimBLE Host 任务处理
|
||||
uint32_t heartbeat = 0;
|
||||
while (1) {
|
||||
heartbeat++;
|
||||
|
||||
// 每 60 秒打印一次心跳(确认任务存活)
|
||||
if (heartbeat % 6 == 0) {
|
||||
DEBUG_DBG(TAG, "心跳 #%lu | BLE %s | 连接: %s",
|
||||
(unsigned long)(heartbeat / 6),
|
||||
s_ble_ready ? "就绪" : "未就绪",
|
||||
s_ble_connected ? "已连接" : "未连接");
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* health_monitor.cpp - 系统健康监控
|
||||
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控 + 电压监控
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_err.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
|
||||
#include "health_monitor.h"
|
||||
#include "debug_log.h"
|
||||
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
health_state_t g_health_state = SYS_NORMAL;
|
||||
|
||||
/** @brief 健康状态互斥锁。 */
|
||||
static SemaphoreHandle_t g_health_mutex = NULL;
|
||||
/** @brief 内置温度传感器句柄。 */
|
||||
static temperature_sensor_handle_t s_temp_handle = NULL;
|
||||
/** @brief 最近一次采样的芯片温度(摄氏度)。 */
|
||||
static float s_last_temperature = 0.0f;
|
||||
/** @brief 最近一次估算的系统电压(伏特)。 */
|
||||
static float s_last_voltage = 3.30f;
|
||||
|
||||
// ----------------------------------------
|
||||
// 健康状态管理
|
||||
/**
|
||||
* @brief 初始化健康监控模块,创建互斥锁和基础状态。
|
||||
*/
|
||||
void health_monitor_init(void)
|
||||
{
|
||||
/** @brief 健康状态互斥锁。 */
|
||||
g_health_mutex = xSemaphoreCreateMutex();
|
||||
if (g_health_mutex == NULL) {
|
||||
DEBUG_ERR("HEALTH", "创建健康状态互斥锁失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 获取当前系统健康状态。
|
||||
* @return 当前健康状态枚举值。
|
||||
*/
|
||||
health_state_t health_get_current(void)
|
||||
{
|
||||
health_state_t state;
|
||||
if (g_health_mutex) {
|
||||
xSemaphoreTake(g_health_mutex, portMAX_DELAY);
|
||||
}
|
||||
state = g_health_state;
|
||||
if (g_health_mutex) {
|
||||
xSemaphoreGive(g_health_mutex);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 返回最近一次采样到的芯片温度(摄氏度)。
|
||||
* @return 最近一次温度采样值(摄氏度)。
|
||||
*/
|
||||
float health_get_last_temperature(void)
|
||||
{
|
||||
/** @brief 最近一次采样的芯片温度(摄氏度)。 */
|
||||
return s_last_temperature;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 返回最近一次记录的系统电压估算值。
|
||||
* @return 最近一次电压估算值(伏特)。
|
||||
*/
|
||||
float health_get_last_voltage(void)
|
||||
{
|
||||
/** @brief 最近一次估算的系统电压(伏特)。 */
|
||||
return s_last_voltage;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 温度监控(定时器回调)
|
||||
/**
|
||||
* @brief 温度定时回调,采样并根据阈值更新健康状态。
|
||||
* @param arg
|
||||
*/
|
||||
static void temp_monitor_callback(void *arg)
|
||||
{
|
||||
float temp = 0.0f;
|
||||
esp_err_t err = temperature_sensor_get_celsius(s_temp_handle, &temp);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN("TEMP", "读取温度失败: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
/** @brief 最近一次采样的芯片温度(摄氏度)。 */
|
||||
s_last_temperature = temp;
|
||||
|
||||
if (temp >= TEMP_CRITICAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_SAFE_SHUTDOWN;
|
||||
DEBUG_ERR("TEMP", "!!! 危险 !!! 温度 %.1f°C >= %d°C,触发安全关机", temp, TEMP_CRITICAL);
|
||||
DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN");
|
||||
} else if (temp >= TEMP_WARNING) {
|
||||
if (g_health_state < SYS_WARNING) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_WARNING;
|
||||
DEBUG_WARN("TEMP", "警告: 温度 %.1f°C >= %d°C", temp, TEMP_WARNING);
|
||||
DEBUG_WARN("TEMP", "健康状态变更为: SYS_WARNING");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 电压监控(启动时调用一次)
|
||||
/**
|
||||
* @brief 电压定时回调,采样估算电压并根据阈值更新健康状态。
|
||||
* @param arg
|
||||
*/
|
||||
static void voltage_monitor_callback(void *arg)
|
||||
{
|
||||
float voltage = 3.30f; // ESP32-C3 无内置电压传感器,使用估算
|
||||
/** @brief 最近一次估算的系统电压(伏特)。 */
|
||||
s_last_voltage = voltage;
|
||||
|
||||
if (voltage < 2.3f) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_SAFE_SHUTDOWN;
|
||||
DEBUG_ERR("VOLT", "!!! 危险 !!! 电压 %.2fV < 2.3V,触发安全关机", voltage);
|
||||
DEBUG_ERR("VOLT", "健康状态变更为: SYS_SAFE_SHUTDOWN");
|
||||
} else if (voltage < 2.5f) {
|
||||
if (g_health_state < SYS_WARNING) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_WARNING;
|
||||
DEBUG_WARN("VOLT", "警告: 电压 %.2fV < 2.5V", voltage);
|
||||
DEBUG_WARN("VOLT", "健康状态变更为: SYS_WARNING");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 温度电压监控任务入口
|
||||
/**
|
||||
* @brief 启动温度和电压监控定时器,周期性巡检系统健康。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void health_monitor_task(void *pvParameters)
|
||||
{
|
||||
DEBUG_INFO("MON", "=== 健康监控任务启动 ===");
|
||||
|
||||
temperature_sensor_config_t temp_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 80);
|
||||
esp_err_t err = temperature_sensor_install(&temp_config, &s_temp_handle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("MON", "温度传感器初始化失败: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_ERROR_CHECK(temperature_sensor_enable(s_temp_handle));
|
||||
DEBUG_INFO("MON", "温度传感器已启用 (范围 20°C ~ 80°C)");
|
||||
}
|
||||
|
||||
// 定时器,每1秒读取一次温度
|
||||
esp_timer_handle_t temp_timer;
|
||||
esp_timer_create_args_t timer_args = {};
|
||||
timer_args.callback = &temp_monitor_callback;
|
||||
timer_args.arg = nullptr;
|
||||
timer_args.name = "temp_monitor";
|
||||
timer_args.dispatch_method = ESP_TIMER_TASK;
|
||||
timer_args.skip_unhandled_events = false;
|
||||
esp_timer_create(&timer_args, &temp_timer);
|
||||
esp_timer_start_periodic(temp_timer, 1 * 1000000); // 1秒
|
||||
|
||||
DEBUG_INFO("MON", "温度监控定时器已启动 (间隔 1 秒)");
|
||||
|
||||
voltage_monitor_callback(NULL);
|
||||
|
||||
// 每1秒刷新一次估算电压
|
||||
esp_timer_handle_t volt_timer;
|
||||
esp_timer_create_args_t volt_timer_args = {};
|
||||
volt_timer_args.callback = &voltage_monitor_callback;
|
||||
volt_timer_args.arg = nullptr;
|
||||
volt_timer_args.name = "volt_monitor";
|
||||
volt_timer_args.dispatch_method = ESP_TIMER_TASK;
|
||||
volt_timer_args.skip_unhandled_events = false;
|
||||
esp_timer_create(&volt_timer_args, &volt_timer);
|
||||
esp_timer_start_periodic(volt_timer, 1 * 1000000); // 1秒
|
||||
DEBUG_INFO("MON", "电压监控定时器已启动 (间隔 1 秒)");
|
||||
|
||||
// 保活循环:定时器由 esp_timer 任务驱动,此任务只需保持存活
|
||||
// 状态汇报已由 status_monitor_task 负责,此处只在异常时告警
|
||||
while (1) {
|
||||
uint32_t free_heap = esp_get_free_heap_size();
|
||||
if (free_heap < HEAP_WARNING_MIN) {
|
||||
DEBUG_WARN("MON", "空闲堆内存不足: %lu < %d", (unsigned long)free_heap, HEAP_WARNING_MIN);
|
||||
}
|
||||
if (g_health_state >= SYS_CRITICAL) {
|
||||
DEBUG_ERR("MON", "健康状态危险: %d", g_health_state);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 健康事件日志
|
||||
/**
|
||||
* @brief 记录一条健康监控事件日志。
|
||||
* @param event 事件名称字符串。
|
||||
* @param value 事件关联的数值。
|
||||
*/
|
||||
void health_log_event(const char *event, int value)
|
||||
{
|
||||
DEBUG_INFO("HEALTH", "[健康事件] %s: %d", event, value);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// OTA 传输前的健康检查
|
||||
/**
|
||||
* @brief OTA 传输前执行健康检查,决定是否允许传输。
|
||||
* @return 健康检查结果,决定是否允许 OTA。
|
||||
*/
|
||||
health_status_t health_check_before_ota(void)
|
||||
{
|
||||
health_status_t status = HEALTH_MON_OK;
|
||||
|
||||
if (g_health_state >= SYS_SAFE_SHUTDOWN) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_SAFE_SHUTDOWN,禁止传输");
|
||||
return HEALTH_MON_SHUTDOWN;
|
||||
} else if (g_health_state >= SYS_CRITICAL) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_CRITICAL,禁止传输");
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (g_health_state >= SYS_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意");
|
||||
}
|
||||
|
||||
wifi_ap_record_t ap_info;
|
||||
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
|
||||
if (ap_info.rssi < WIFI_RSSI_CRITICAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_CRITICAL;
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (危险阈值)",
|
||||
ap_info.rssi, WIFI_RSSI_CRITICAL);
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (ap_info.rssi < WIFI_RSSI_NORMAL) {
|
||||
if (status < HEALTH_MON_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
}
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (警告阈值)",
|
||||
ap_info.rssi, WIFI_RSSI_NORMAL);
|
||||
}
|
||||
}
|
||||
|
||||
if (status == HEALTH_MON_OK) {
|
||||
DEBUG_DBG("HEALTH", "OTA 前检查: 正常");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 根据 WiFi RSSI 更新健康状态并返回检查结果。
|
||||
* @param rssi 当前 WiFi 信号强度(dBm)。
|
||||
* @return 健康检查结果。
|
||||
*/
|
||||
health_status_t health_update_from_wifi_rssi(int8_t rssi)
|
||||
{
|
||||
if (rssi < WIFI_RSSI_CRITICAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_CRITICAL;
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL);
|
||||
health_log_event("WIFI危险", rssi);
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (rssi < WIFI_RSSI_NORMAL) {
|
||||
if (g_health_state < SYS_WARNING) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_WARNING;
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
health_log_event("WIFI警告", rssi);
|
||||
}
|
||||
return HEALTH_MON_WARNING;
|
||||
}
|
||||
|
||||
if (g_health_state == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_NORMAL;
|
||||
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
health_log_event("WIFI恢复", rssi);
|
||||
}
|
||||
|
||||
return HEALTH_MON_OK;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* health_monitor.cpp - 系统健康监控
|
||||
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_err.h"
|
||||
#include "driver/temperature_sensor.h"
|
||||
|
||||
#include "health_monitor.h"
|
||||
#include "debug_log.h"
|
||||
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
health_state_t g_health_state = SYS_NORMAL;
|
||||
|
||||
/** @brief 健康状态互斥锁。 */
|
||||
static SemaphoreHandle_t g_health_mutex = NULL;
|
||||
/** @brief 内置温度传感器句柄。 */
|
||||
static temperature_sensor_handle_t s_temp_handle = NULL;
|
||||
/** @brief 最近一次采样的芯片温度(摄氏度)。 */
|
||||
static float s_last_temperature = 0.0f;
|
||||
|
||||
// ----------------------------------------
|
||||
// 健康状态管理
|
||||
/**
|
||||
* @brief 初始化健康监控模块,创建互斥锁和基础状态。
|
||||
*/
|
||||
void health_monitor_init(void)
|
||||
{
|
||||
/** @brief 健康状态互斥锁。 */
|
||||
g_health_mutex = xSemaphoreCreateMutex();
|
||||
if (g_health_mutex == NULL) {
|
||||
DEBUG_ERR("HEALTH", "创建健康状态互斥锁失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 获取当前系统健康状态。
|
||||
* @return 当前健康状态枚举值。
|
||||
*/
|
||||
health_state_t health_get_current(void)
|
||||
{
|
||||
health_state_t state;
|
||||
if (g_health_mutex) {
|
||||
xSemaphoreTake(g_health_mutex, portMAX_DELAY);
|
||||
}
|
||||
state = g_health_state;
|
||||
if (g_health_mutex) {
|
||||
xSemaphoreGive(g_health_mutex);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 返回最近一次采样到的芯片温度(摄氏度)。
|
||||
* @return 最近一次温度采样值(摄氏度)。
|
||||
*/
|
||||
float health_get_last_temperature(void)
|
||||
{
|
||||
/** @brief 最近一次采样的芯片温度(摄氏度)。 */
|
||||
return s_last_temperature;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 温度监控(定时器回调)
|
||||
/**
|
||||
* @brief 温度定时回调,采样并根据阈值更新健康状态。
|
||||
* @param arg
|
||||
*/
|
||||
static void temp_monitor_callback(void *arg)
|
||||
{
|
||||
float temp = 0.0f;
|
||||
esp_err_t err = temperature_sensor_get_celsius(s_temp_handle, &temp);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN("TEMP", "读取温度失败: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
/** @brief 最近一次采样的芯片温度(摄氏度)。 */
|
||||
s_last_temperature = temp;
|
||||
|
||||
if (temp >= TEMP_CRITICAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_SAFE_SHUTDOWN;
|
||||
DEBUG_ERR("TEMP", "!!! 危险 !!! 温度 %.1f°C >= %d°C,触发安全关机", temp, TEMP_CRITICAL);
|
||||
DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN");
|
||||
} else if (temp >= TEMP_WARNING) {
|
||||
if (g_health_state < SYS_WARNING) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_WARNING;
|
||||
DEBUG_WARN("TEMP", "警告: 温度 %.1f°C >= %d°C", temp, TEMP_WARNING);
|
||||
DEBUG_WARN("TEMP", "健康状态变更为: SYS_WARNING");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 温度监控任务入口
|
||||
/**
|
||||
* @brief 启动温度监控定时器,周期性巡检系统健康。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void health_monitor_task(void *pvParameters)
|
||||
{
|
||||
DEBUG_INFO("MON", "=== 健康监控任务启动 ===");
|
||||
|
||||
temperature_sensor_config_t temp_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(20, 80);
|
||||
esp_err_t err = temperature_sensor_install(&temp_config, &s_temp_handle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("MON", "温度传感器初始化失败: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_ERROR_CHECK(temperature_sensor_enable(s_temp_handle));
|
||||
DEBUG_INFO("MON", "温度传感器已启用 (范围 20°C ~ 80°C)");
|
||||
}
|
||||
|
||||
// 定时器,每1秒读取一次温度
|
||||
esp_timer_handle_t temp_timer;
|
||||
esp_timer_create_args_t timer_args = {};
|
||||
timer_args.callback = &temp_monitor_callback;
|
||||
timer_args.arg = nullptr;
|
||||
timer_args.name = "temp_monitor";
|
||||
timer_args.dispatch_method = ESP_TIMER_TASK;
|
||||
timer_args.skip_unhandled_events = false;
|
||||
esp_timer_create(&timer_args, &temp_timer);
|
||||
esp_timer_start_periodic(temp_timer, 1 * 1000000); // 1秒
|
||||
|
||||
DEBUG_INFO("MON", "温度监控定时器已启动 (间隔 1 秒)");
|
||||
|
||||
// 保活循环:定时器由 esp_timer 任务驱动,此任务只需保持存活
|
||||
// 状态汇报已由 status_monitor_task 负责,此处只在异常时告警
|
||||
while (1) {
|
||||
uint32_t free_heap = esp_get_free_heap_size();
|
||||
if (free_heap < HEAP_WARNING_MIN) {
|
||||
DEBUG_WARN("MON", "空闲堆内存不足: %lu < %d", (unsigned long)free_heap, HEAP_WARNING_MIN);
|
||||
}
|
||||
if (g_health_state >= SYS_CRITICAL) {
|
||||
DEBUG_ERR("MON", "健康状态危险: %d", g_health_state);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 健康事件日志
|
||||
/**
|
||||
* @brief 记录一条健康监控事件日志。
|
||||
* @param event 事件名称字符串。
|
||||
* @param value 事件关联的数值。
|
||||
*/
|
||||
void health_log_event(const char *event, int value)
|
||||
{
|
||||
DEBUG_INFO("HEALTH", "[健康事件] %s: %d", event, value);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// OTA 传输前的健康检查
|
||||
/**
|
||||
* @brief OTA 传输前执行健康检查,决定是否允许传输。
|
||||
* @return 健康检查结果,决定是否允许 OTA。
|
||||
*/
|
||||
health_status_t health_check_before_ota(void)
|
||||
{
|
||||
health_status_t status = HEALTH_MON_OK;
|
||||
|
||||
if (g_health_state >= SYS_SAFE_SHUTDOWN) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_SAFE_SHUTDOWN,禁止传输");
|
||||
return HEALTH_MON_SHUTDOWN;
|
||||
} else if (g_health_state >= SYS_CRITICAL) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_CRITICAL,禁止传输");
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (g_health_state >= SYS_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意");
|
||||
}
|
||||
|
||||
wifi_ap_record_t ap_info;
|
||||
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
|
||||
if (ap_info.rssi < WIFI_RSSI_CRITICAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_CRITICAL;
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (危险阈值)",
|
||||
ap_info.rssi, WIFI_RSSI_CRITICAL);
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (ap_info.rssi < WIFI_RSSI_NORMAL) {
|
||||
if (status < HEALTH_MON_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
}
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (警告阈值)",
|
||||
ap_info.rssi, WIFI_RSSI_NORMAL);
|
||||
}
|
||||
}
|
||||
|
||||
if (status == HEALTH_MON_OK) {
|
||||
DEBUG_DBG("HEALTH", "OTA 前检查: 正常");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 根据 WiFi RSSI 更新健康状态并返回检查结果。
|
||||
* @param rssi 当前 WiFi 信号强度(dBm)。
|
||||
* @return 健康检查结果。
|
||||
*/
|
||||
health_status_t health_update_from_wifi_rssi(int8_t rssi)
|
||||
{
|
||||
if (rssi < WIFI_RSSI_CRITICAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_CRITICAL;
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL);
|
||||
health_log_event("WIFI危险", rssi);
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (rssi < WIFI_RSSI_NORMAL) {
|
||||
if (g_health_state < SYS_WARNING) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_WARNING;
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
health_log_event("WIFI警告", rssi);
|
||||
}
|
||||
return HEALTH_MON_WARNING;
|
||||
}
|
||||
|
||||
if (g_health_state == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_NORMAL;
|
||||
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
health_log_event("WIFI恢复", rssi);
|
||||
}
|
||||
|
||||
return HEALTH_MON_OK;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* health_monitor.h - 系统健康监控头文件
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include "app_config.h"
|
||||
|
||||
// T9.3: 健康状态等级
|
||||
typedef enum {
|
||||
SYS_NORMAL = 0, // 正常
|
||||
SYS_WARNING = 1, // 警告
|
||||
SYS_CRITICAL = 2, // 危险
|
||||
SYS_SAFE_SHUTDOWN = 3 // 安全关机
|
||||
} health_state_t;
|
||||
|
||||
typedef enum {
|
||||
HEALTH_MON_OK = 0,
|
||||
HEALTH_MON_WARNING = 1,
|
||||
HEALTH_MON_CRITICAL = 2,
|
||||
HEALTH_MON_SHUTDOWN = 3
|
||||
} health_status_t;
|
||||
|
||||
// 外部共享状态(互斥锁保护)
|
||||
extern health_state_t g_health_state;
|
||||
|
||||
// WiFi RSSI 阈值(来自 app_config.h)
|
||||
#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING
|
||||
#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL
|
||||
|
||||
/**
|
||||
* @brief 初始化健康监控模块,创建内部互斥锁与基础状态。
|
||||
*/
|
||||
void health_monitor_init(void);
|
||||
|
||||
/**
|
||||
* @brief 健康监控任务入口,负责启动温度/电压定时检测并周期性巡检系统状态。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void health_monitor_task(void *pvParameters);
|
||||
|
||||
/**
|
||||
* @brief 在 OTA 传输开始前执行健康检查,返回当前是否允许传输。
|
||||
* @return 健康检查结果,决定是否允许 OTA。
|
||||
*/
|
||||
health_status_t health_check_before_ota(void);
|
||||
|
||||
/**
|
||||
* @brief 记录一条健康相关事件日志,便于故障追踪与状态回溯。
|
||||
* @param event 事件名称字符串。
|
||||
* @param value 事件关联的数值。
|
||||
*/
|
||||
void health_log_event(const char *event, int value);
|
||||
|
||||
/**
|
||||
* @brief 获取当前系统健康状态。
|
||||
* @return 当前健康状态枚举值。
|
||||
*/
|
||||
health_state_t health_get_current(void);
|
||||
|
||||
/**
|
||||
* @brief 根据最新 WiFi RSSI 更新健康状态,并返回对应健康检查结果。
|
||||
* @param rssi 当前 WiFi 信号强度(dBm)。
|
||||
* @return 健康检查结果。
|
||||
*/
|
||||
health_status_t health_update_from_wifi_rssi(int8_t rssi);
|
||||
|
||||
/**
|
||||
* @brief 获取最近一次采样到的芯片温度(摄氏度)。
|
||||
* @return 最近一次温度采样值(摄氏度)。
|
||||
*/
|
||||
float health_get_last_temperature(void);
|
||||
|
||||
/**
|
||||
* @brief 获取最近一次记录的系统电压估算值(伏特)。
|
||||
* @return 最近一次电压估算值(伏特)。
|
||||
*/
|
||||
float health_get_last_voltage(void);
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* health_monitor.h - 系统健康监控头文件
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include "app_config.h"
|
||||
|
||||
// T9.3: 健康状态等级
|
||||
typedef enum {
|
||||
SYS_NORMAL = 0, // 正常
|
||||
SYS_WARNING = 1, // 警告
|
||||
SYS_CRITICAL = 2, // 危险
|
||||
SYS_SAFE_SHUTDOWN = 3 // 安全关机
|
||||
} health_state_t;
|
||||
|
||||
typedef enum {
|
||||
HEALTH_MON_OK = 0,
|
||||
HEALTH_MON_WARNING = 1,
|
||||
HEALTH_MON_CRITICAL = 2,
|
||||
HEALTH_MON_SHUTDOWN = 3
|
||||
} health_status_t;
|
||||
|
||||
// 外部共享状态(互斥锁保护)
|
||||
extern health_state_t g_health_state;
|
||||
|
||||
// WiFi RSSI 阈值(来自 app_config.h)
|
||||
#define WIFI_RSSI_NORMAL WIFI_RSSI_WARNING
|
||||
#define WIFI_RSSI_WEAK WIFI_RSSI_CRITICAL
|
||||
|
||||
/**
|
||||
* @brief 初始化健康监控模块,创建内部互斥锁与基础状态。
|
||||
*/
|
||||
void health_monitor_init(void);
|
||||
|
||||
/**
|
||||
* @brief 健康监控任务入口,负责启动温度定时检测并周期性巡检系统状态。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void health_monitor_task(void *pvParameters);
|
||||
|
||||
/**
|
||||
* @brief 在 OTA 传输开始前执行健康检查,返回当前是否允许传输。
|
||||
* @return 健康检查结果,决定是否允许 OTA。
|
||||
*/
|
||||
health_status_t health_check_before_ota(void);
|
||||
|
||||
/**
|
||||
* @brief 记录一条健康相关事件日志,便于故障追踪与状态回溯。
|
||||
* @param event 事件名称字符串。
|
||||
* @param value 事件关联的数值。
|
||||
*/
|
||||
void health_log_event(const char *event, int value);
|
||||
|
||||
/**
|
||||
* @brief 获取当前系统健康状态。
|
||||
* @return 当前健康状态枚举值。
|
||||
*/
|
||||
health_state_t health_get_current(void);
|
||||
|
||||
/**
|
||||
* @brief 根据最新 WiFi RSSI 更新健康状态,并返回对应健康检查结果。
|
||||
* @param rssi 当前 WiFi 信号强度(dBm)。
|
||||
* @return 健康检查结果。
|
||||
*/
|
||||
health_status_t health_update_from_wifi_rssi(int8_t rssi);
|
||||
|
||||
/**
|
||||
* @brief 获取最近一次采样到的芯片温度(摄氏度)。
|
||||
* @return 最近一次温度采样值(摄氏度)。
|
||||
*/
|
||||
float health_get_last_temperature(void);
|
||||
@@ -0,0 +1,587 @@
|
||||
/*
|
||||
* ota_relay.c - OTA 中继核心状态机实现
|
||||
* T2.3, T2.4, T3.1 - T3.7
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <inttypes.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_partition.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
#include "ota_relay.h"
|
||||
#include "spi_master.h"
|
||||
#include "protocol.h"
|
||||
#include "health_monitor.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
relay_state_t g_relay_state = RELAY_IDLE;
|
||||
|
||||
static const esp_partition_t *g_gd32_partition = NULL;
|
||||
/** @brief OTA 认证会话上下文,包含会话密钥和 IV。 */
|
||||
static session_ctx_t g_session_ctx;
|
||||
|
||||
/** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */
|
||||
QueueHandle_t g_ota_dispatch_queue = NULL;
|
||||
/** @brief OTA 结果回传队列,将执行结果返回给调用方。 */
|
||||
QueueHandle_t g_ota_result_queue = NULL;
|
||||
|
||||
/**
|
||||
* @brief OTA 握手阶段:向 GD32 发送握手请求并等待进入认证流程。
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t send_handshake_req(void);
|
||||
|
||||
/**
|
||||
* @brief 认证阶段:生成随机数并完成与 GD32 的双向认证。
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t send_nonce_and_authenticate(void);
|
||||
|
||||
/**
|
||||
* @brief 向 GD32 发送待升级固件的总大小。
|
||||
* @param size 固件总字节数。
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t send_firmware_size(uint32_t size);
|
||||
|
||||
/**
|
||||
* @brief 从 Flash 逐包读取固件并中继转发给 GD32。
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t relay_packets_from_flash(void);
|
||||
|
||||
/**
|
||||
* @brief OTA 收尾阶段:发送结束命令并等待 GD32 确认。
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t finish_ota(void);
|
||||
|
||||
/**
|
||||
* @brief 从 Flash 加载元数据
|
||||
* @param meta 输出参数,加载后的元数据结构体。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_load(ota_meta_t *meta)
|
||||
{
|
||||
if (!meta) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
memset(meta, 0, sizeof(ota_meta_t));
|
||||
|
||||
FILE *f = fopen(META_FILE, "r");
|
||||
if (!f) {
|
||||
// 文件不存在是正常状态(首次使用,未刷过 GD32)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
char buf[256];
|
||||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||||
buf[n] = '\0';
|
||||
fclose(f);
|
||||
|
||||
cJSON *json = cJSON_Parse(buf);
|
||||
if (!json) {
|
||||
DEBUG_ERR("OTA", "元数据 JSON 解析失败");
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
|
||||
cJSON *item = cJSON_GetObjectItem(json, "total_size");
|
||||
if (item) meta->total_size = (uint32_t)item->valueint;
|
||||
|
||||
item = cJSON_GetObjectItem(json, "stored_size");
|
||||
if (item) meta->stored_size = (uint32_t)item->valueint;
|
||||
|
||||
item = cJSON_GetObjectItem(json, "last_seq");
|
||||
if (item) meta->last_seq = (uint32_t)item->valueint;
|
||||
|
||||
item = cJSON_GetObjectItem(json, "chunk_count");
|
||||
if (item) meta->chunk_count = (uint32_t)item->valueint;
|
||||
|
||||
cJSON_Delete(json);
|
||||
DEBUG_INFO("OTA", "元数据已加载: total=%u, stored=%u, seq=%u",
|
||||
meta->total_size, meta->stored_size, meta->last_seq);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 保存元数据到 Flash
|
||||
* @param meta 待保存的元数据结构体。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_save(const ota_meta_t *meta)
|
||||
{
|
||||
if (!meta) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
FILE *f = fopen(META_FILE, "w");
|
||||
if (!f) {
|
||||
DEBUG_ERR("OTA", "无法打开元数据文件写入");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
fprintf(f,
|
||||
"{\n"
|
||||
" \"total_size\": %" PRIu32 ",\n"
|
||||
" \"stored_size\": %" PRIu32 ",\n"
|
||||
" \"last_seq\": %" PRIu32 ",\n"
|
||||
" \"chunk_count\": %" PRIu32 "\n"
|
||||
"}",
|
||||
meta->total_size, meta->stored_size, meta->last_seq, meta->chunk_count);
|
||||
|
||||
fclose(f);
|
||||
DEBUG_INFO("OTA", "元数据已保存到 Flash");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 传输过程中更新元数据
|
||||
* @param stored_size 已存储的固件大小(字节)。
|
||||
* @param last_seq 最后成功的分块序号。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_update(uint32_t stored_size, uint32_t last_seq)
|
||||
{
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = meta_load(&meta);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
meta.stored_size = stored_size;
|
||||
meta.last_seq = last_seq;
|
||||
|
||||
return meta_save(&meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief OTA 完成后或中止时清除元数据
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t meta_clear(void)
|
||||
{
|
||||
DEBUG_INFO("OTA", "清除 OTA 元数据");
|
||||
ota_meta_t meta = {};
|
||||
return meta_save(&meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Flash 写入接口
|
||||
* @param offset 分区内写入偏移。
|
||||
* @param data 待写入的数据指针。
|
||||
* @param len 数据长度(字节)。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t flash_write_chunk(uint32_t offset, const uint8_t *data, size_t len)
|
||||
{
|
||||
if (!g_gd32_partition) {
|
||||
g_gd32_partition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota");
|
||||
if (!g_gd32_partition) {
|
||||
DEBUG_ERR("OTA", "找不到 gd32_ota 分区");
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
|
||||
return esp_partition_write(g_gd32_partition, offset, data, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 从 OTA 缓存分区读取指定偏移和长度的数据块。
|
||||
* @param offset 分区内读取偏移。
|
||||
* @param data 读取缓冲区指针。
|
||||
* @param len 读取长度(字节)。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t flash_read_chunk(uint32_t offset, uint8_t *data, size_t len)
|
||||
{
|
||||
if (!g_gd32_partition) {
|
||||
g_gd32_partition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota");
|
||||
if (!g_gd32_partition) {
|
||||
DEBUG_ERR("OTA", "找不到 gd32_ota 分区");
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
|
||||
return esp_partition_read(g_gd32_partition, offset, data, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 阶段1 - 发起与 GD32 的握手
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t send_handshake_req(void)
|
||||
{
|
||||
DEBUG_INFO("OTA", "=== 阶段1: 握手开始 ===");
|
||||
DEBUG_INFO("OTA", "发送 HANDSHAKE_REQ (0x00 0xAC)");
|
||||
|
||||
uint8_t tx_frame[32];
|
||||
uint8_t rx_frame[32];
|
||||
|
||||
uint8_t payload[] = {0x00, 0xAC};
|
||||
frame_build(CMD_HANDSHAKE_REQ, payload, sizeof(payload), tx_frame);
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_HANDSHAKE_SENT;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_HANDSHAKE_SENT");
|
||||
|
||||
esp_err_t err = spi_sync_transfer(CMD_HANDSHAKE_REQ, payload, sizeof(payload),
|
||||
rx_frame, sizeof(rx_frame), 3000);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "HANDSHAKE_REQ 失败, err=%d", err);
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "握手请求已发送,等待 GD32 响应...");
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
return OTA_RESULT_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 发送随机数并与 GD32 相互认证
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t send_nonce_and_authenticate(void)
|
||||
{
|
||||
uint8_t tx_frame[64];
|
||||
uint8_t rx_frame[64];
|
||||
uint8_t nonce_server[16];
|
||||
|
||||
uint32_t timestamp = xTaskGetTickCount();
|
||||
uint32_t mem_addr = (uint32_t)nonce_server;
|
||||
uint32_t rand_seed = 0x12345678;
|
||||
|
||||
scramble_generate(timestamp, mem_addr, rand_seed, nonce_server);
|
||||
|
||||
DEBUG_INFO("OTA", "--- 认证步骤 1: 发送 CHALLENGE ---");
|
||||
DEBUG_INFO("OTA", "发送 nonce_server (16B), timestamp=0x%08X", timestamp);
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_CHALLENGE_SENT;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_CHALLENGE_SENT");
|
||||
|
||||
frame_build(CMD_CHALLENGE, nonce_server, 16, tx_frame);
|
||||
|
||||
esp_err_t err = spi_sync_transfer(CMD_CHALLENGE, nonce_server, 16,
|
||||
rx_frame, sizeof(rx_frame), 5000);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "CHALLENGE 发送失败, err=%d", err);
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_CHALLENGE_RESPOND;
|
||||
DEBUG_INFO("OTA", "已收到 GD32 握手数据包");
|
||||
DEBUG_INFO("OTA", "状态: RELAY_CHALLENGE_RESPOND");
|
||||
|
||||
// 发送 SESSION_KEY(带变换后的随机数)
|
||||
uint8_t transformed_nonce[16];
|
||||
memcpy(transformed_nonce, nonce_server, 16);
|
||||
|
||||
DEBUG_INFO("OTA", "--- 认证步骤 2: 发送 SESSION_KEY ---");
|
||||
DEBUG_INFO("OTA", "发送变换后的随机数 (16B)");
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_SESSION_KEY_SENT;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_SESSION_KEY_SENT");
|
||||
|
||||
frame_build(CMD_SESSION_KEY, transformed_nonce, 16, tx_frame);
|
||||
esp_err_t err2 = spi_sync_transfer(CMD_SESSION_KEY, transformed_nonce, 16,
|
||||
rx_frame, sizeof(rx_frame), 3000);
|
||||
if (err2 != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "SESSION_KEY 发送失败, err=%d", err2);
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_AUTH_COMPLETE;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_AUTH_COMPLETE");
|
||||
DEBUG_INFO("OTA", "=== 阶段1 认证完成: 安全通道已建立 ===");
|
||||
|
||||
uint8_t k_session[16] = {0};
|
||||
session_key_init(&g_session_ctx, k_session);
|
||||
|
||||
return OTA_RESULT_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 向 GD32 发送固件总大小
|
||||
* @param size 固件总字节数。
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t send_firmware_size(uint32_t size)
|
||||
{
|
||||
uint8_t tx_frame[32];
|
||||
uint8_t rx_frame[32];
|
||||
|
||||
DEBUG_INFO("OTA", "=== 阶段2: 发送固件大小 ===");
|
||||
DEBUG_INFO("OTA", "固件大小: %u 字节", size);
|
||||
|
||||
uint8_t size_payload[4];
|
||||
size_payload[0] = (uint8_t)(size & 0xFF);
|
||||
size_payload[1] = (uint8_t)((size >> 8) & 0xFF);
|
||||
size_payload[2] = (uint8_t)((size >> 16) & 0xFF);
|
||||
size_payload[3] = (uint8_t)((size >> 24) & 0xFF);
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_SIZE_SENT;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_SIZE_SENT");
|
||||
|
||||
frame_build(CMD_SEND_SIZE, size_payload, 4, tx_frame);
|
||||
esp_err_t err = spi_sync_transfer(CMD_SEND_SIZE, size_payload, 4,
|
||||
rx_frame, sizeof(rx_frame), 3000);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "SEND_SIZE 失败, err=%d", err);
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "大小已发送: %u 字节,等待 GD32 确认...", size);
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_SIZE_ACK;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_SIZE_ACK");
|
||||
|
||||
return OTA_RESULT_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 从 Flash 向 GD32 转发存储的固件(48B/包)
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t relay_packets_from_flash(void)
|
||||
{
|
||||
DEBUG_INFO("OTA", "=== 阶段3: 从 Flash 转发固件 ===");
|
||||
|
||||
ota_meta_t meta;
|
||||
if (meta_load(&meta) != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "元数据加载失败");
|
||||
return OTA_RESULT_ERR_STORAGE;
|
||||
}
|
||||
|
||||
uint32_t total_packets = (meta.total_size + 47) / 48;
|
||||
uint32_t flash_offset = 0;
|
||||
uint8_t tx_buf[64];
|
||||
uint8_t rx_buf[32];
|
||||
ota_result_t result = OTA_RESULT_OK;
|
||||
|
||||
DEBUG_INFO("OTA", "总包数: %u, 每包 48 字节", total_packets);
|
||||
DEBUG_INFO("OTA", "状态: RELAY_RELAY_ACTIVE");
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_RELAY_ACTIVE;
|
||||
|
||||
for (uint32_t seq = 0; seq < total_packets; seq++) {
|
||||
health_status_t health = health_check_before_ota();
|
||||
if (health == HEALTH_MON_SHUTDOWN || health == HEALTH_MON_CRITICAL) {
|
||||
DEBUG_ERR("OTA", "健康检查危险,中止 OTA");
|
||||
return OTA_RESULT_ERR_HEALTH;
|
||||
}
|
||||
|
||||
size_t chunk_size = 48;
|
||||
if (seq == total_packets - 1) {
|
||||
chunk_size = meta.total_size - (seq * 48);
|
||||
}
|
||||
|
||||
esp_err_t err = flash_read_chunk(flash_offset, &tx_buf[2], chunk_size);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "Flash 读取失败, offset=%u, err=%d", flash_offset, err);
|
||||
return OTA_RESULT_ERR_STORAGE;
|
||||
}
|
||||
|
||||
tx_buf[0] = (uint8_t)(seq & 0xFF);
|
||||
tx_buf[1] = (uint8_t)((seq >> 8) & 0xFF);
|
||||
|
||||
uint16_t crc = crc16_modbus(tx_buf, 2 + chunk_size);
|
||||
tx_buf[2 + chunk_size] = (uint8_t)(crc & 0xFF);
|
||||
tx_buf[2 + chunk_size + 1] = (uint8_t)((crc >> 8) & 0xFF);
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_PACKET_SENT;
|
||||
|
||||
uint8_t cmd = (seq == total_packets - 1) ? CMD_DATA_END : CMD_DATA_PACKET;
|
||||
size_t tx_len = 2 + chunk_size + 2;
|
||||
|
||||
err = spi_sync_transfer(cmd, tx_buf, tx_len, rx_buf, sizeof(rx_buf), 3000);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_WARN("OTA", "数据包 %u 发送失败, err=%d, 重试中...", seq, err);
|
||||
result = OTA_RESULT_ERR_TIMEOUT;
|
||||
break;
|
||||
}
|
||||
|
||||
flash_offset += chunk_size;
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_PACKET_ACK_OK;
|
||||
|
||||
// 每 10% 记录一次进度
|
||||
if (seq % (total_packets / 10 + 1) == 0) {
|
||||
uint32_t progress = (seq * 100) / total_packets;
|
||||
DEBUG_INFO("OTA", "转发进度: %u/%u 包 (%u%%)",
|
||||
seq, total_packets, progress);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_RECEIVE_DONE;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_RECEIVE_DONE");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 结束 OTA - 发送 0x0A AC 并清理
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
static ota_result_t finish_ota(void)
|
||||
{
|
||||
DEBUG_INFO("OTA", "=== 阶段4: 结束 OTA ===");
|
||||
DEBUG_INFO("OTA", "发送 FINISH_OTA (0x0A 0xAC)");
|
||||
|
||||
uint8_t tx_frame[16];
|
||||
uint8_t rx_frame[16];
|
||||
|
||||
uint8_t payload[] = {0x0A, 0xAC};
|
||||
frame_build(CMD_FINISH_OTA, payload, sizeof(payload), tx_frame);
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_FINISH_SENT;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_FINISH_SENT");
|
||||
|
||||
esp_err_t err = spi_sync_transfer(CMD_FINISH_OTA, payload, sizeof(payload),
|
||||
rx_frame, sizeof(rx_frame), 5000);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "FINISH_OTA 失败, err=%d", err);
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "等待 GD32 跳转确认...");
|
||||
|
||||
session_key_clear(&g_session_ctx);
|
||||
memset(&g_session_ctx, 0, sizeof(session_ctx_t));
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_DONE;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_DONE");
|
||||
DEBUG_INFO("OTA", "=== OTA 完成 ===");
|
||||
|
||||
meta_clear();
|
||||
|
||||
return OTA_RESULT_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 启动完整 OTA 流程的主入口
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
ota_result_t ota_relay_start_handshake(void)
|
||||
{
|
||||
ota_result_t res;
|
||||
|
||||
res = send_handshake_req();
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
res = send_nonce_and_authenticate();
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
ota_meta_t meta;
|
||||
meta_load(&meta);
|
||||
res = send_firmware_size(meta.total_size);
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
res = relay_packets_from_flash();
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
res = finish_ota();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 从 Flash 向 GD32 转发固件
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
ota_result_t ota_relay_forward_firmware(void)
|
||||
{
|
||||
ota_meta_t meta;
|
||||
if (meta_load(&meta) != ESP_OK) {
|
||||
return OTA_RESULT_ERR_STORAGE;
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "开始固件转发: %u 字节, %u 块",
|
||||
meta.total_size, meta.chunk_count);
|
||||
|
||||
ota_result_t res = send_handshake_req();
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
res = send_nonce_and_authenticate();
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
res = send_firmware_size(meta.total_size);
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
res = relay_packets_from_flash();
|
||||
if (res != OTA_RESULT_OK) return res;
|
||||
|
||||
res = finish_ota();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 获取当前状态
|
||||
* @return 当前中继状态枚举值。
|
||||
*/
|
||||
relay_state_t ota_relay_get_state(void)
|
||||
{
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
return g_relay_state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 重置状态机
|
||||
*/
|
||||
void ota_relay_reset(void)
|
||||
{
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_IDLE;
|
||||
session_key_clear(&g_session_ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief OTA 中继 FreeRTOS 任务
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void ota_relay_task(void *pvParameters)
|
||||
{
|
||||
DEBUG_INFO("OTA", "OTA 中继任务启动");
|
||||
|
||||
/** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */
|
||||
g_ota_dispatch_queue = xQueueCreate(4, sizeof(uint32_t));
|
||||
/** @brief OTA 结果回传队列,将执行结果返回给调用方。 */
|
||||
g_ota_result_queue = xQueueCreate(4, sizeof(uint32_t));
|
||||
|
||||
uint32_t msg;
|
||||
BaseType_t ret = xQueueReceive(g_ota_dispatch_queue, &msg, portMAX_DELAY);
|
||||
|
||||
if (ret == pdTRUE) {
|
||||
DEBUG_INFO("OTA", "收到 OTA 调度信号,开始转发...");
|
||||
ota_result_t result = ota_relay_forward_firmware();
|
||||
|
||||
uint32_t result_msg = (uint32_t)result;
|
||||
xQueueSend(g_ota_result_queue, &result_msg, pdMS_TO_TICKS(100));
|
||||
|
||||
if (result == OTA_RESULT_OK) {
|
||||
DEBUG_INFO("OTA", "OTA 转发成功完成");
|
||||
} else {
|
||||
DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result);
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "OTA 中继任务结束");
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
|
||||
### WebUI 实际在用的接口
|
||||
|
||||
- `GET /system/status`:每 1 秒轮询一次,用于在线状态、WiFi、heap、温度、电压、OTA 状态展示
|
||||
- `GET /system/status`:每 1 秒轮询一次,用于在线状态、WiFi、heap、温度、OTA 状态展示
|
||||
- `GET /logs`:每 1 秒轮询一次,用于拉取并清空日志环形缓冲区
|
||||
- `POST /can/send`:发送 CAN 请求到 GD32
|
||||
- `POST /ota/upload` / `POST /ota/relay` / `POST /ota/update`:OTA 相关
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
|
||||
+-- 存储 GD32 固件到 Flash
|
||||
+-- 四阶段安全握手协议
|
||||
+-- 内置温度/电压/WiFi 自检
|
||||
+-- 内置温度/WiFi 自检
|
||||
```
|
||||
|
||||
ESP32 的角色是**数据透传 + 存储中继**,GD32 OTA 的加密握手和数据传输逻辑全部由 ESP32 透传到 GD32,ESP32 本身不解析固件内容。
|
||||
@@ -74,7 +74,6 @@ ESP32 的角色是**数据透传 + 存储中继**,GD32 OTA 的加密握手和
|
||||
不外挂传感器,使用 ESP32 内置传感器:
|
||||
|
||||
- **芯片温度**(正常 < 80°C / 警告 80-100°C / 停机 > 100°C)— 1s 轮询
|
||||
- **供电电压**(正常 2.5-3.6V / 警告 < 2.5V / 停机 < 2.3V)— 5s 轮询
|
||||
- **WiFi RSSI**(正常 > -70 / 弱 -70~-85 / 断开 < -85 dBm)— 10s 轮询
|
||||
- **BLE 连接状态** — 事件驱动
|
||||
|
||||
|
||||
@@ -604,7 +604,6 @@ ESP32-C3FH4 利用芯片内置传感器实现运行安全监控,不外挂额
|
||||
| 检测项 | 来源 | 阈值范围 | 处理动作 |
|
||||
|---|---|---|---|
|
||||
| 芯片温度 | 内部温度传感器 | 正常: < 80°C / 警告: 80-100°C / 停机: > 100°C | 警告时降频,停机时中止 OTA |
|
||||
| 供电电压 | VDD 电压传感器 | 正常: 2.5V - 3.6V / 警告: < 2.5V / 停机: < 2.3V | 警告时提示,停机时挂起任务 |
|
||||
| Flash 空间 | 系统 API | 可用 > 16KB(存储分区元数据) | 空间不足时拒绝 OTA 请求 |
|
||||
| WiFi 信号强度 | WiFi RSSI | 正常: > -70 dBm / 弱: -70 ~ -85 dBm / 断开: < -85 dBm | 弱时降低 OTA 速度,断开时暂停 |
|
||||
| BLE 连接状态 | BLE stack | 连接中 / 断开 | 断开时通知 APP 重连 |
|
||||
@@ -614,7 +613,6 @@ ESP32-C3FH4 利用芯片内置传感器实现运行安全监控,不外挂额
|
||||
| 检测项 | 频率 | 方式 |
|
||||
|---|---|---|
|
||||
| 芯片温度 | 每 1 秒 | FreeRTOS Timer |
|
||||
| 供电电压 | 每 5 秒 | FreeRTOS Timer |
|
||||
| Flash 空间 | OTA 开始前检查 | 一次性 |
|
||||
| WiFi RSSI | 每 10 秒 | 轮询 |
|
||||
| BLE 连接 | 事件驱动 | BLE GAP 回调 |
|
||||
@@ -648,7 +646,6 @@ typedef enum {
|
||||
|
||||
```
|
||||
[TIMESTAMP] TEMP_WARNING: 85°C
|
||||
[TIMESTAMP] VOLT_WARNING: 2.45V
|
||||
[TIMESTAMP] OTA_PAUSED: temp_critical
|
||||
[TIMESTAMP] OTA_RESUMED
|
||||
```
|
||||
@@ -663,7 +660,6 @@ typedef enum {
|
||||
│ (FreeRTOS Task, Priority High) │
|
||||
├──────────────────────────────────┤
|
||||
│ temp_monitor (1s timer) │
|
||||
│ voltage_monitor (5s timer) │
|
||||
│ wifi_monitor (10s timer) │
|
||||
│ ble_monitor (event-driven) │
|
||||
├──────────────────────────────────┤
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ extern "C" void app_main(void)
|
||||
DEBUG_INFO("MAIN", "WiFi: %s", wifi_sta_ok ? "STA 连接中" : "将使用 AP 模式");
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
|
||||
// 健康监控任务(温度 + 电压 + WiFi RSSI)
|
||||
// 健康监控任务(温度 + WiFi RSSI)
|
||||
BaseType_t xRet;
|
||||
xRet = xTaskCreatePinnedToCore(
|
||||
health_monitor_task,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// ============================================================
|
||||
// 固件版本
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = "0.0.6";
|
||||
inline constexpr const char *FW_VERSION = "0.1.4";
|
||||
|
||||
// ============================================================
|
||||
// WiFi 工作模式选择
|
||||
@@ -53,7 +53,7 @@ inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192;
|
||||
// ============================================================
|
||||
// OTA 配置
|
||||
// ============================================================
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 4096;
|
||||
inline constexpr size_t OTA_BLOCK_SIZE = 8192;
|
||||
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// ============================================================
|
||||
@@ -61,6 +61,9 @@ inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
// ============================================================
|
||||
inline constexpr int32_t WIFI_RSSI_CRITICAL = -85;
|
||||
inline constexpr int32_t WIFI_RSSI_WARNING = -75;
|
||||
inline constexpr int32_t OTA_WIFI_RSSI_CRITICAL = -88; // OTA 期间更宽松,避免瞬时波动误判
|
||||
inline constexpr uint32_t OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS = 15000; // OTA 最多接受 15 秒内的 RSSI 样本
|
||||
inline constexpr uint32_t OTA_PACKET_HEALTH_CHECK_INTERVAL = 32; // 每 32 个 48B 包做一次 OTA 健康检查
|
||||
inline constexpr int32_t TEMP_WARNING = 80;
|
||||
inline constexpr int32_t TEMP_CRITICAL = 100;
|
||||
inline constexpr size_t HEAP_WARNING_MIN = 10240;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* health_monitor.cpp - 系统健康监控
|
||||
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控 + 电压监控
|
||||
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#include "health_monitor.h"
|
||||
#include "debug_log.h"
|
||||
#include "server/wifi_ota.h"
|
||||
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
health_state_t g_health_state = SYS_NORMAL;
|
||||
@@ -24,8 +25,10 @@ static SemaphoreHandle_t g_health_mutex = NULL;
|
||||
static temperature_sensor_handle_t s_temp_handle = NULL;
|
||||
/** @brief 最近一次采样的芯片温度(摄氏度)。 */
|
||||
static float s_last_temperature = 0.0f;
|
||||
/** @brief 最近一次估算的系统电压(伏特)。 */
|
||||
static float s_last_voltage = 3.30f;
|
||||
/** @brief 最近一次健康监控采样得到的 WiFi RSSI。 */
|
||||
static int8_t s_last_wifi_rssi = -127;
|
||||
/** @brief 最近一次 WiFi RSSI 采样时间(毫秒)。 */
|
||||
static int64_t s_last_wifi_rssi_ms = 0;
|
||||
|
||||
// ----------------------------------------
|
||||
// 健康状态管理
|
||||
@@ -68,14 +71,18 @@ float health_get_last_temperature(void)
|
||||
return s_last_temperature;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 返回最近一次记录的系统电压估算值。
|
||||
* @return 最近一次电压估算值(伏特)。
|
||||
*/
|
||||
float health_get_last_voltage(void)
|
||||
bool health_get_last_wifi_rssi(int8_t *out_rssi, uint32_t *out_age_ms)
|
||||
{
|
||||
/** @brief 最近一次估算的系统电压(伏特)。 */
|
||||
return s_last_voltage;
|
||||
if (s_last_wifi_rssi_ms <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t now_ms = esp_timer_get_time() / 1000;
|
||||
uint32_t age_ms = (now_ms > s_last_wifi_rssi_ms) ? (uint32_t)(now_ms - s_last_wifi_rssi_ms) : 0;
|
||||
|
||||
if (out_rssi) *out_rssi = s_last_wifi_rssi;
|
||||
if (out_age_ms) *out_age_ms = age_ms;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
@@ -112,36 +119,9 @@ static void temp_monitor_callback(void *arg)
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 电压监控(启动时调用一次)
|
||||
// 温度监控任务入口
|
||||
/**
|
||||
* @brief 电压定时回调,采样估算电压并根据阈值更新健康状态。
|
||||
* @param arg
|
||||
*/
|
||||
static void voltage_monitor_callback(void *arg)
|
||||
{
|
||||
float voltage = 3.30f; // ESP32-C3 无内置电压传感器,使用估算
|
||||
/** @brief 最近一次估算的系统电压(伏特)。 */
|
||||
s_last_voltage = voltage;
|
||||
|
||||
if (voltage < 2.3f) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_SAFE_SHUTDOWN;
|
||||
DEBUG_ERR("VOLT", "!!! 危险 !!! 电压 %.2fV < 2.3V,触发安全关机", voltage);
|
||||
DEBUG_ERR("VOLT", "健康状态变更为: SYS_SAFE_SHUTDOWN");
|
||||
} else if (voltage < 2.5f) {
|
||||
if (g_health_state < SYS_WARNING) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_WARNING;
|
||||
DEBUG_WARN("VOLT", "警告: 电压 %.2fV < 2.5V", voltage);
|
||||
DEBUG_WARN("VOLT", "健康状态变更为: SYS_WARNING");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// 温度电压监控任务入口
|
||||
/**
|
||||
* @brief 启动温度和电压监控定时器,周期性巡检系统健康。
|
||||
* @brief 启动温度监控定时器,周期性巡检系统健康。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void health_monitor_task(void *pvParameters)
|
||||
@@ -170,23 +150,15 @@ void health_monitor_task(void *pvParameters)
|
||||
|
||||
DEBUG_INFO("MON", "温度监控定时器已启动 (间隔 1 秒)");
|
||||
|
||||
voltage_monitor_callback(NULL);
|
||||
|
||||
// 每1秒刷新一次估算电压
|
||||
esp_timer_handle_t volt_timer;
|
||||
esp_timer_create_args_t volt_timer_args = {};
|
||||
volt_timer_args.callback = &voltage_monitor_callback;
|
||||
volt_timer_args.arg = nullptr;
|
||||
volt_timer_args.name = "volt_monitor";
|
||||
volt_timer_args.dispatch_method = ESP_TIMER_TASK;
|
||||
volt_timer_args.skip_unhandled_events = false;
|
||||
esp_timer_create(&volt_timer_args, &volt_timer);
|
||||
esp_timer_start_periodic(volt_timer, 1 * 1000000); // 1秒
|
||||
DEBUG_INFO("MON", "电压监控定时器已启动 (间隔 1 秒)");
|
||||
|
||||
// 保活循环:定时器由 esp_timer 任务驱动,此任务只需保持存活
|
||||
// 状态汇报已由 status_monitor_task 负责,此处只在异常时告警
|
||||
while (1) {
|
||||
int8_t rssi = wifi_get_rssi();
|
||||
if (wifi_is_connected() && rssi != -127) {
|
||||
s_last_wifi_rssi = rssi;
|
||||
s_last_wifi_rssi_ms = esp_timer_get_time() / 1000;
|
||||
}
|
||||
|
||||
uint32_t free_heap = esp_get_free_heap_size();
|
||||
if (free_heap < HEAP_WARNING_MIN) {
|
||||
DEBUG_WARN("MON", "空闲堆内存不足: %lu < %d", (unsigned long)free_heap, HEAP_WARNING_MIN);
|
||||
@@ -231,20 +203,26 @@ health_status_t health_check_before_ota(void)
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意");
|
||||
}
|
||||
|
||||
wifi_ap_record_t ap_info;
|
||||
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
|
||||
if (ap_info.rssi < WIFI_RSSI_CRITICAL) {
|
||||
/** @brief 全局健康状态,受互斥锁保护。 */
|
||||
g_health_state = SYS_CRITICAL;
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (危险阈值)",
|
||||
ap_info.rssi, WIFI_RSSI_CRITICAL);
|
||||
if (!wifi_is_connected()) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi 未连接");
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (ap_info.rssi < WIFI_RSSI_NORMAL) {
|
||||
}
|
||||
|
||||
int8_t last_rssi = -127;
|
||||
uint32_t age_ms = 0;
|
||||
if (health_get_last_wifi_rssi(&last_rssi, &age_ms)) {
|
||||
if (age_ms <= OTA_WIFI_RSSI_SAMPLE_MAX_AGE_MS) {
|
||||
if (last_rssi < OTA_WIFI_RSSI_CRITICAL) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: 最近 RSSI %d < %d (样本年龄 %lu ms)",
|
||||
last_rssi, OTA_WIFI_RSSI_CRITICAL, (unsigned long)age_ms);
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (last_rssi < WIFI_RSSI_NORMAL) {
|
||||
if (status < HEALTH_MON_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
}
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: WiFi RSSI %d < %d (警告阈值)",
|
||||
ap_info.rssi, WIFI_RSSI_NORMAL);
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: 最近 RSSI %d < %d (样本年龄 %lu ms)",
|
||||
last_rssi, WIFI_RSSI_NORMAL, (unsigned long)age_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ extern health_state_t g_health_state;
|
||||
void health_monitor_init(void);
|
||||
|
||||
/**
|
||||
* @brief 健康监控任务入口,负责启动温度/电压定时检测并周期性巡检系统状态。
|
||||
* @brief 健康监控任务入口,负责启动温度定时检测并周期性巡检系统状态。
|
||||
* @param pvParameters FreeRTOS 任务参数,未使用。
|
||||
*/
|
||||
void health_monitor_task(void *pvParameters);
|
||||
@@ -72,7 +72,9 @@ health_status_t health_update_from_wifi_rssi(int8_t rssi);
|
||||
float health_get_last_temperature(void);
|
||||
|
||||
/**
|
||||
* @brief 获取最近一次记录的系统电压估算值(伏特)。
|
||||
* @return 最近一次电压估算值(伏特)。
|
||||
* @brief 获取最近一次健康监控采样得到的 WiFi RSSI。
|
||||
* @param out_rssi 输出 RSSI。
|
||||
* @param out_age_ms 输出样本距今时长(毫秒)。
|
||||
* @return true 有有效样本,false 当前无有效样本。
|
||||
*/
|
||||
float health_get_last_voltage(void);
|
||||
bool health_get_last_wifi_rssi(int8_t *out_rssi, uint32_t *out_age_ms);
|
||||
|
||||
@@ -376,11 +376,13 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
g_relay_state = RELAY_RELAY_ACTIVE;
|
||||
|
||||
for (uint32_t seq = 0; seq < total_packets; seq++) {
|
||||
if ((seq % OTA_PACKET_HEALTH_CHECK_INTERVAL) == 0 || seq == 0 || seq == total_packets - 1) {
|
||||
health_status_t health = health_check_before_ota();
|
||||
if (health == HEALTH_MON_SHUTDOWN || health == HEALTH_MON_CRITICAL) {
|
||||
DEBUG_ERR("OTA", "健康检查危险,中止 OTA");
|
||||
return OTA_RESULT_ERR_HEALTH;
|
||||
}
|
||||
}
|
||||
|
||||
size_t chunk_size = 48;
|
||||
if (seq == total_packets - 1) {
|
||||
|
||||
@@ -445,6 +445,27 @@ static esp_err_t esp32_ota_update_handler(httpd_req_t *req)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief HTTP POST /restart - 立即重启 ESP32
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
static esp_err_t restart_handler(httpd_req_t *req)
|
||||
{
|
||||
(void)req;
|
||||
http_set_cors_headers(req);
|
||||
|
||||
DEBUG_INFO("OTA", "收到重启请求,500ms 后重启...");
|
||||
|
||||
const char resp[] = "{\"status\": \"ok\", \"message\": \"rebooting\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief HTTP GET /ota/update - 查询 ESP32 OTA 状态
|
||||
* @param req
|
||||
@@ -558,7 +579,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
}
|
||||
|
||||
// 堆分配缓冲区,避免栈溢出(HTTP server 栈仅 8KB)
|
||||
uint8_t *fw_buf = (uint8_t *)malloc(1024);
|
||||
uint8_t *fw_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
|
||||
if (!fw_buf) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败");
|
||||
return ESP_FAIL;
|
||||
@@ -570,7 +591,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
|
||||
while (total_received < req->content_len) {
|
||||
size_t to_read = req->content_len - total_received;
|
||||
if (to_read > 1024) to_read = 1024;
|
||||
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
|
||||
|
||||
int ret = httpd_req_recv(req, (char *)fw_buf, to_read);
|
||||
if (ret <= 0) break;
|
||||
@@ -628,7 +649,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
uint32_t fw_size = gd32_header[2] | (gd32_header[3] << 8) | (gd32_header[4] << 16) | (gd32_header[5] << 24);
|
||||
if (seq == 0) {
|
||||
ota_meta_t meta = { .total_size = fw_size, .stored_size = 0, .last_seq = 0,
|
||||
.chunk_count = (fw_size + 4095) / 4096, .checksum = {0} };
|
||||
.chunk_count = (fw_size + OTA_BLOCK_SIZE - 1) / OTA_BLOCK_SIZE, .checksum = {0} };
|
||||
meta_save(&meta);
|
||||
DEBUG_INFO("WIFI", "开始 GD32 固件存储: %u 字节", fw_size);
|
||||
}
|
||||
@@ -943,14 +964,14 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
memset(&meta, 0, sizeof(meta));
|
||||
meta_load(&meta);
|
||||
|
||||
char resp[640];
|
||||
char resp[700];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"version\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"voltage\": %.2f, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d}}",
|
||||
"{\"version\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d}}",
|
||||
FW_VERSION,health_get_current(),
|
||||
(unsigned long)esp_get_free_heap_size(),
|
||||
(unsigned long)esp_get_minimum_free_heap_size(),
|
||||
(double)health_get_last_temperature(),
|
||||
(double)health_get_last_voltage(),
|
||||
(unsigned long)(esp_timer_get_time() / 1000000),
|
||||
s_wifi_mode == OTA_WIFI_MODE_AP ? "AP" : "STA",
|
||||
ip_str,
|
||||
rssi,
|
||||
@@ -1047,6 +1068,21 @@ static esp_err_t http_ota_server_start(void)
|
||||
};
|
||||
httpd_register_uri_handler(s_http_server, &esp32_update_options_uri);
|
||||
|
||||
httpd_uri_t restart_uri = {
|
||||
.uri = "/restart",
|
||||
.method = HTTP_POST,
|
||||
.handler = restart_handler,
|
||||
.user_ctx = NULL
|
||||
};
|
||||
httpd_register_uri_handler(s_http_server, &restart_uri);
|
||||
httpd_uri_t restart_options_uri = {
|
||||
.uri = "/restart",
|
||||
.method = HTTP_OPTIONS,
|
||||
.handler = http_options_handler,
|
||||
.user_ctx = NULL
|
||||
};
|
||||
httpd_register_uri_handler(s_http_server, &restart_options_uri);
|
||||
|
||||
httpd_uri_t esp32_status_uri = {
|
||||
.uri = "/ota/esp32_status",
|
||||
.method = HTTP_GET,
|
||||
|
||||
+38
-17
@@ -18,6 +18,18 @@ function formatTime(date) {
|
||||
return date.toTimeString().split(' ')[0];
|
||||
}
|
||||
|
||||
function formatUptime(sec) {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '--';
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
const icon = document.getElementById('toastIcon');
|
||||
@@ -150,7 +162,8 @@ function updateSystemStatusUi(data) {
|
||||
}
|
||||
|
||||
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` : '--';
|
||||
const uptimeEl = document.getElementById('statUptime');
|
||||
if (uptimeEl) uptimeEl.textContent = formatUptime(data.uptime_sec);
|
||||
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) : '--';
|
||||
@@ -179,20 +192,6 @@ async function testConnection() {
|
||||
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() {
|
||||
@@ -311,7 +310,7 @@ function processFile(file) {
|
||||
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);
|
||||
const chunks = Math.ceil(file.size / 8192);
|
||||
document.getElementById('fileChunks').textContent = chunks + ' 包';
|
||||
|
||||
fileInfo.classList.add('visible');
|
||||
@@ -450,7 +449,6 @@ async function triggerEsp32Update() {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
addLog('I', 'OTA', `升级完成: ${data.message}`);
|
||||
addLog('I', 'OTA', 'ESP32 即将重启,新固件将生效,页面将继续通过 1 秒状态同步自动恢复显示');
|
||||
showToast('ESP32 重启中...', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -459,6 +457,29 @@ async function triggerEsp32Update() {
|
||||
}
|
||||
}
|
||||
|
||||
async function restartEsp32() {
|
||||
if (!confirm('确定要重启 ESP32 吗?')) return;
|
||||
|
||||
addLog('I', '系统', '=== 请求重启 ESP32 ===');
|
||||
showToast('ESP32 正在重启...', 'success');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/restart`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
addLog('I', '系统', `重启已触发: ${data.message}`);
|
||||
showToast('ESP32 重启中,页面将自动恢复...', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
addLog('E', '系统', `重启请求失败: ${e.message}`);
|
||||
showToast('重启失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 触发 OTA 中继 ====================
|
||||
async function triggerRelay() {
|
||||
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
|
||||
|
||||
+3
-9
@@ -58,8 +58,8 @@
|
||||
<option value="esp32">ESP32(自身升级)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%; margin-top:8px;" onclick="healthCheck()">
|
||||
🔍 执行健康检查
|
||||
<button class="btn btn-primary" style="width:100%; margin-top:8px;" onclick="restartEsp32()">
|
||||
🔄 重启 ESP32
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -113,9 +113,6 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="esp32Actions" style="display:none; margin-top:12px;">
|
||||
<button class="btn btn-primary" style="width:100%;" onclick="triggerEsp32Update()">
|
||||
🔄 完成 ESP32 升级(重启)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,6 +153,7 @@
|
||||
<div class="card-title">
|
||||
<div class="icon">📈</div>
|
||||
实时状态
|
||||
<span style="font-size:12px;color:var(--text-muted);font-weight:normal;margin-left:8px;">运行: <span id="statUptime">--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
@@ -163,10 +161,6 @@
|
||||
<div class="value" id="statTemperature">--</div>
|
||||
<div class="label">芯片温度</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="value" id="statVoltage">--</div>
|
||||
<div class="label">电源电压</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="value" id="statWifi">--</div>
|
||||
<div class="label">WiFi 状态</div>
|
||||
|
||||
Reference in New Issue
Block a user