refactor(app): 删除过时的app.js备份文件

移除 _assistant_backups 目录中的 app.js.bak_2026-05-05_0451 备份文件,
该文件包含完整的JavaScript应用程序代码,包括全局状态管理、工具函数、
日志系统、连接管理、CAN帧发送、文件处理、固件上传等功能模块。
This commit is contained in:
小龙虾
2026-05-07 23:50:04 +08:00
parent 6641ca4afd
commit adb7dc65ca
24 changed files with 1754 additions and 8969 deletions
@@ -1,557 +0,0 @@
// ==================== 全局状态 ====================
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);
@@ -1,556 +0,0 @@
// ==================== 全局状态 ====================
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);
@@ -1,105 +0,0 @@
/*
* 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
};
@@ -1,108 +0,0 @@
/*
* 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
};
@@ -1,108 +0,0 @@
/*
* 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
};
@@ -1,643 +0,0 @@
/*
* 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));
}
}
@@ -1,289 +0,0 @@
/*
* 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;
}
@@ -1,236 +0,0 @@
/*
* 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;
}
@@ -1,78 +0,0 @@
/*
* 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);
@@ -1,72 +0,0 @@
/*
* 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);
@@ -1,587 +0,0 @@
/*
* 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
+9 -2
View File
@@ -10,7 +10,7 @@
// ============================================================
// 固件版本
// ============================================================
inline constexpr const char *FW_VERSION = "0.1.4";
inline constexpr const char *FW_VERSION = "0.2.0";
// ============================================================
// WiFi 工作模式选择
@@ -54,7 +54,8 @@ inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192;
// OTA 配置
// ============================================================
inline constexpr size_t OTA_BLOCK_SIZE = 8192;
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 2 * 1024 * 1024; // 2MB
inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB,对齐 GD32 APP_MAX_SIZE (0x36000)
inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷
// ============================================================
// 健康监控阈值
@@ -85,6 +86,12 @@ 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;
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_GD32 = 0xF7;
inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_ESP32 = 0xF8;
inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00;
inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01;
inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; // 默认目标 payload;ATT Write Command 可写值≈(MTU-3),当前 512 MTU 下协议 payload 建议 ≤ 498B
inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; // cmd(1)+seq(2)+size(4)+crc(4)
// ============================================================
// 安全常量(需与 GD32 固件团队确认)
+227 -110
View File
@@ -10,6 +10,7 @@
#include "esp_err.h"
#include "esp_partition.h"
#include "cJSON.h"
#include "aes/esp_aes.h"
#include "ota_relay.h"
#include "spi_master.h"
@@ -23,6 +24,8 @@ 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;
static uint8_t g_nonce_server[16] = {0};
static uint8_t g_k_session[16] = {0};
/** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */
QueueHandle_t g_ota_dispatch_queue = NULL;
@@ -60,6 +63,93 @@ static ota_result_t relay_packets_from_flash(void);
*/
static ota_result_t finish_ota(void);
static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
esp_aes_context aes;
esp_aes_init(&aes);
int ret = esp_aes_setkey(&aes, key, 128);
if (ret == 0) {
ret = esp_aes_crypt_ecb(&aes, ESP_AES_ENCRYPT, in, out);
}
esp_aes_free(&aes);
return ret;
}
static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
esp_aes_context aes;
esp_aes_init(&aes);
int ret = esp_aes_setkey(&aes, key, 128);
if (ret == 0) {
ret = esp_aes_crypt_ecb(&aes, ESP_AES_DECRYPT, in, out);
}
esp_aes_free(&aes);
return ret;
}
static bool aes128_ecb_encrypt_48bytes_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
for (int i = 0; i < 3; ++i) {
if (aes128_ecb_encrypt_block_local(key, in + i * 16, out + i * 16) != 0) {
return false;
}
}
return true;
}
static void calculate_crc32_with_key_local(const uint8_t *key, const uint8_t *data48, uint8_t *output)
{
uint32_t crc = 0xFFFFFFFFU;
for (int i = 0; i < 16; ++i) {
crc ^= key[i];
for (int j = 0; j < 8; ++j) {
crc = (crc >> 1) ^ (0xEDB88320U & (0U - (crc & 1U)));
}
}
for (size_t i = 0; i < GD32_OTA_PACKET_SIZE; ++i) {
crc ^= data48[i];
for (int j = 0; j < 8; ++j) {
crc = (crc >> 1) ^ (0xEDB88320U & (0U - (crc & 1U)));
}
}
crc ^= 0xFFFFFFFFU;
output[0] = (uint8_t)(crc & 0xFFU);
output[1] = (uint8_t)((crc >> 8) & 0xFFU);
output[2] = (uint8_t)((crc >> 16) & 0xFFU);
output[3] = (uint8_t)((crc >> 24) & 0xFFU);
}
static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len)
{
char hex[193] = {0};
size_t n = (len > 32U) ? 32U : len;
for (size_t i = 0; i < n; ++i) {
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", payload[i]);
}
DEBUG_INFO("OTA", "%s 响应: type=0x%02X len=%u payload[%u]=%s%s",
stage, resp_type, (unsigned)len, (unsigned)n,
n > 0 ? hex : "<empty>",
len > n ? "..." : "");
}
static esp_err_t wait_until_not_wait(const char *stage, uint8_t *resp_type,
uint8_t *resp_payload, size_t resp_max,
size_t *resp_len, uint32_t timeout_ms, uint32_t max_wait_rounds)
{
if (!resp_type) return ESP_ERR_INVALID_ARG;
uint32_t round = 0;
while (*resp_type == SPI_OTA_RSP_WAIT && round < max_wait_rounds) {
++round;
DEBUG_WARN("OTA", "%s 收到 WAIT,开始第 %u 次只读轮询...", stage, round);
esp_err_t err = spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
if (err != ESP_OK) {
return err;
}
log_resp_payload(stage, *resp_type, resp_payload, resp_len ? *resp_len : 0);
}
return ESP_OK;
}
/**
* @brief 从 Flash 加载元数据
* @param meta 输出参数,加载后的元数据结构体。
@@ -215,28 +305,29 @@ 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);
uint8_t resp_type = 0;
uint8_t resp_payload[64] = {0};
size_t resp_len = 0;
/** @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 中继状态机状态。 */
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_REQ, payload, sizeof(payload),
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
log_resp_payload("HANDSHAKE_REQ", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("HANDSHAKE_REQ", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 8);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
resp_payload[0] != 0x00 || resp_payload[1] != 0xAC) {
DEBUG_ERR("OTA", "HANDSHAKE_REQ 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_TIMEOUT;
}
DEBUG_INFO("OTA", "握手请求已发送,等待 GD32 响应...");
vTaskDelay(pdMS_TO_TICKS(100));
DEBUG_INFO("OTA", "握手请求已确认");
return OTA_RESULT_OK;
}
@@ -246,66 +337,77 @@ static ota_result_t send_handshake_req(void)
*/
static ota_result_t send_nonce_and_authenticate(void)
{
uint8_t tx_frame[64];
uint8_t rx_frame[64];
uint8_t nonce_server[16];
uint8_t resp_type = 0;
uint8_t resp_payload[64] = {0};
size_t resp_len = 0;
uint32_t timestamp = xTaskGetTickCount();
uint32_t mem_addr = (uint32_t)nonce_server;
uint32_t mem_addr = (uint32_t)(uintptr_t)g_nonce_server;
uint32_t rand_seed = 0x12345678;
scramble_generate(timestamp, mem_addr, rand_seed, nonce_server);
scramble_generate(timestamp, mem_addr, rand_seed, g_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 中继状态机状态。 */
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_REQ, g_nonce_server, 16,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 5000);
log_resp_payload("CHALLENGE", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("CHALLENGE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 12);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len != 36) {
DEBUG_ERR("OTA", "CHALLENGE 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
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);
if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, g_k_session) != 0) {
DEBUG_ERR("OTA", "计算 K_session 失败");
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_AUTH;
}
DEBUG_INFO("OTA", "--- 认证步骤 2: 发送 SESSION_KEY ---");
DEBUG_INFO("OTA", "发送变换后的随机数 (16B)");
/** @brief 当前 OTA 中继状态机状态。 */
uint8_t plaintext[16];
for (int i = 0; i < 16; ++i) {
plaintext[i] = g_nonce_server[i] ^ 0x55U;
}
uint8_t encrypted_confirm[16] = {0};
if (aes128_ecb_encrypt_block_local(g_k_session, plaintext, encrypted_confirm) != 0) {
DEBUG_ERR("OTA", "加密确认块失败");
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_AUTH;
}
DEBUG_INFO("OTA", "--- 认证步骤 2: 发送 SESSION_KEY/CONFIRM ---");
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 中继状态机状态。 */
resp_len = 0;
err = spi_ota_transfer(SPI_OTA_CMD_REQ, encrypted_confirm, 16,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
log_resp_payload("CONFIRM", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("CONFIRM", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 8);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
resp_payload[0] != 0xBA || resp_payload[1] != 0xC0) {
DEBUG_ERR("OTA", "SESSION_KEY/CONFIRM 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
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);
session_key_init(&g_session_ctx, g_k_session);
return OTA_RESULT_OK;
}
@@ -316,8 +418,9 @@ static ota_result_t send_nonce_and_authenticate(void)
*/
static ota_result_t send_firmware_size(uint32_t size)
{
uint8_t tx_frame[32];
uint8_t rx_frame[32];
uint8_t resp_type = 0;
uint8_t resp_payload[32] = {0};
size_t resp_len = 0;
DEBUG_INFO("OTA", "=== 阶段2: 发送固件大小 ===");
DEBUG_INFO("OTA", "固件大小: %u 字节", size);
@@ -328,22 +431,23 @@ static ota_result_t send_firmware_size(uint32_t size)
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 中继状态机状态。 */
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_DATA, size_payload, sizeof(size_payload),
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
log_resp_payload("SEND_SIZE", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("SEND_SIZE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1500, 12);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
resp_payload[0] != 0xBA || resp_payload[1] != 0xC0) {
DEBUG_ERR("OTA", "SEND_SIZE 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
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");
@@ -364,15 +468,16 @@ static ota_result_t relay_packets_from_flash(void)
return OTA_RESULT_ERR_STORAGE;
}
uint32_t total_packets = (meta.total_size + 47) / 48;
uint32_t total_packets = (meta.total_size + GD32_OTA_PACKET_SIZE - 1U) / GD32_OTA_PACKET_SIZE;
uint32_t flash_offset = 0;
uint8_t tx_buf[64];
uint8_t rx_buf[32];
uint8_t tx_buf[64] = {0};
uint8_t resp_type = 0;
uint8_t resp_payload[32] = {0};
size_t resp_len = 0;
ota_result_t result = OTA_RESULT_OK;
DEBUG_INFO("OTA", "总包数: %u, 每包 48 字节", total_packets);
DEBUG_INFO("OTA", "总包数: %u, 每包 %u 字节", total_packets, (unsigned)GD32_OTA_PACKET_SIZE);
DEBUG_INFO("OTA", "状态: RELAY_RELAY_ACTIVE");
/** @brief 当前 OTA 中继状态机状态。 */
g_relay_state = RELAY_RELAY_ACTIVE;
for (uint32_t seq = 0; seq < total_packets; seq++) {
@@ -384,50 +489,76 @@ static ota_result_t relay_packets_from_flash(void)
}
}
size_t chunk_size = 48;
uint8_t plain48[GD32_OTA_PACKET_SIZE];
memset(plain48, 0xFF, sizeof(plain48));
size_t chunk_size = GD32_OTA_PACKET_SIZE;
if (seq == total_packets - 1) {
chunk_size = meta.total_size - (seq * 48);
chunk_size = meta.total_size - (seq * GD32_OTA_PACKET_SIZE);
}
esp_err_t err = flash_read_chunk(flash_offset, &tx_buf[2], chunk_size);
esp_err_t err = flash_read_chunk(flash_offset, plain48, 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);
tx_buf[0] = 0x00; tx_buf[1] = 0x00; tx_buf[2] = 0x00; tx_buf[3] = 0x00; /* session_num 预留 */
tx_buf[4] = (uint8_t)(seq & 0xFFU);
tx_buf[5] = (uint8_t)((seq >> 8) & 0xFFU);
tx_buf[6] = 0x00;
tx_buf[7] = 0x00;
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);
if (!aes128_ecb_encrypt_48bytes_local(g_k_session, plain48, &tx_buf[8])) {
DEBUG_ERR("OTA", "包 %u 加密失败", seq);
return OTA_RESULT_ERR_AUTH;
}
uint32_t if_end = (seq == total_packets - 1) ? 0xFFFFFFFFU : 0x00000000U;
tx_buf[56] = (uint8_t)(if_end & 0xFFU);
tx_buf[57] = (uint8_t)((if_end >> 8) & 0xFFU);
tx_buf[58] = (uint8_t)((if_end >> 16) & 0xFFU);
tx_buf[59] = (uint8_t)((if_end >> 24) & 0xFFU);
calculate_crc32_with_key_local(g_k_session, plain48, &tx_buf[60]);
DEBUG_INFO("OTA", "包 %u: chunk=%u if_end=0x%08X crc32=%02X%02X%02X%02X",
seq, (unsigned)chunk_size, (unsigned)if_end,
tx_buf[60], tx_buf[61], tx_buf[62], tx_buf[63]);
/** @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);
resp_len = 0;
err = spi_ota_transfer(SPI_OTA_CMD_DATA, tx_buf, sizeof(tx_buf),
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
log_resp_payload("DATA_PACKET", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 4) {
DEBUG_WARN("OTA", "数据包 %u 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len);
result = OTA_RESULT_ERR_TIMEOUT;
break;
}
flash_offset += chunk_size;
/** @brief 当前 OTA 中继状态机状态。 */
uint32_t ack_value = (uint32_t)resp_payload[0] |
((uint32_t)resp_payload[1] << 8) |
((uint32_t)resp_payload[2] << 16) |
((uint32_t)resp_payload[3] << 24);
uint32_t expected_ack = (seq == total_packets - 1) ? 0xFFFFFFFFU : (seq + 1U);
if (ack_value != expected_ack) {
DEBUG_ERR("OTA", "包 %u ACK 不匹配: got=0x%08X expected=0x%08X", seq, ack_value, expected_ack);
result = OTA_RESULT_ERR_GD32;
break;
}
flash_offset += (uint32_t)chunk_size;
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);
uint32_t progress = (seq * 100U) / 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;
@@ -440,39 +571,18 @@ static ota_result_t relay_packets_from_flash(void)
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 跳转确认...");
DEBUG_INFO("OTA", "GD32 当前实现已在最后一个 DATA 包后自行 ACK 并跳转,无需额外 FINISH 命令");
session_key_clear(&g_session_ctx);
memset(&g_session_ctx, 0, sizeof(session_ctx_t));
memset(g_k_session, 0, sizeof(g_k_session));
memset(g_nonce_server, 0, sizeof(g_nonce_server));
/** @brief 当前 OTA 中继状态机状态。 */
g_relay_state = RELAY_DONE;
DEBUG_INFO("OTA", "状态: RELAY_DONE");
DEBUG_INFO("OTA", "=== OTA 完成 ===");
meta_clear();
return OTA_RESULT_OK;
}
@@ -567,11 +677,20 @@ void ota_relay_task(void *pvParameters)
/** @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 (!g_ota_dispatch_queue || !g_ota_result_queue) {
DEBUG_ERR("OTA", "OTA 队列创建失败");
vTaskDelete(NULL);
return;
}
if (ret == pdTRUE) {
DEBUG_INFO("OTA", "收到 OTA 调度信号,开始转发...");
while (1) {
uint32_t msg = 0;
BaseType_t ret = xQueueReceive(g_ota_dispatch_queue, &msg, portMAX_DELAY);
if (ret != pdTRUE) {
continue;
}
DEBUG_INFO("OTA", "收到 OTA 调度信号: %lu,开始转发...", (unsigned long)msg);
ota_result_t result = ota_relay_forward_firmware();
uint32_t result_msg = (uint32_t)result;
@@ -581,9 +700,7 @@ void ota_relay_task(void *pvParameters)
DEBUG_INFO("OTA", "OTA 转发成功完成");
} else {
DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result);
ota_relay_reset();
}
}
DEBUG_INFO("OTA", "OTA 中继任务结束");
vTaskDelete(NULL);
}
+413 -31
View File
@@ -2,14 +2,15 @@
* ble_ota.cpp - BLE OTA GATT 服务器实现 (ESP-IDF 6.0 NimBLE)
* T5.1 - T5.5
*
* 通过 BLE GATT 接收上位机固件,存储到 Flash (gd32_ota 分区),
* 接收完成后触发 OTA 中继任务转发到 GD32。
* 通过 BLE GATT 接收上位机固件,支持两种目标:
* - GD32: 存入 Flash → 触发 SPI 中继转发到 GD32
* - ESP32: 直接写入 OTA 分区 → 触发重启升级自身
*
* GATT 服务:
* Service UUID: 0xFFFF
* - CTRL (0x0001, Write): 写入 0xF7 触发中继
* - DATA_IN (0x0002, Write): 接收固件数据块
* - DATA_OUT (0x0003, Notify): 推送 GD32 响应
* - CTRL (0x0001, Write): 0xF7=触发GD32中继, 0xF8=触发ESP32 OTA升级
* - DATA_IN (0x0002, Write): cmd=0x00=GD32数据, cmd=0x01=ESP32数据
* - DATA_OUT (0x0003, Notify): 推送响应
* - VERSION (0x0004, Read): 固件版本
*/
#include <string.h>
@@ -19,6 +20,9 @@
#include "freertos/semphr.h"
#include "freertos/queue.h"
#include "esp_err.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
// NimBLE 协议栈
#include "nimble/nimble_port.h"
@@ -27,6 +31,7 @@
#include "host/ble_gap.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "host/ble_att.h"
#include "app_config.h"
#include "ble_ota.h"
@@ -35,6 +40,25 @@
#include "monitor/debug_log.h"
#define TAG "BLE_OTA"
#define BLE_OTA_NOTIFY_ACK 0xA1
#define BLE_OTA_NOTIFY_FINISH 0xB1
#define BLE_OTA_FINISH_BEGIN_OTA_END 0x01
#define BLE_OTA_FINISH_DONE_OTA_END 0x02
#define BLE_OTA_FINISH_BEGIN_SET_BOOT 0x03
#define BLE_OTA_FINISH_DONE_SET_BOOT 0x04
#define BLE_OTA_FINISH_BEGIN_DELAY 0x05
#define BLE_OTA_FINISH_DONE_DELAY 0x06
#define BLE_OTA_FINISH_REBOOT_NOW 0x07
#define BLE_OTA_FINISH_ERR_OTA_END 0x81
#define BLE_OTA_FINISH_ERR_SET_BOOT 0x82
#define BLE_OTA_ACK_WINDOW 8
// ============================================================
// 前向声明(GAP event handler 在广播函数之前定义,需要声明)
// ============================================================
static void ble_ota_start_advertising(void);
static void ble_ota_send_window_ack(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
static void ble_ota_send_finish_event(uint8_t stage, uint32_t value);
// ============================================================
// 静态状态
@@ -46,6 +70,97 @@ static bool s_ble_connected = false; // GATT 客户端已连接
static uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
static uint16_t s_data_out_val_handle = 0; // DATA_OUT 值句柄 (供通知用)
// ESP32 自身 OTA 状态
static bool s_esp32_ota_active = false;
static esp_ota_handle_t s_esp32_ota_handle = 0;
static const esp_partition_t *s_esp32_update_partition = NULL;
static size_t s_esp32_received = 0;
static bool s_esp32_reboot_pending = false;
static uint8_t s_esp32_last_cmd = 0;
static uint16_t s_esp32_last_seq = 0xFFFFU;
static void ble_esp32_finish_and_reboot_task(void *pvParameters)
{
(void)pvParameters;
DEBUG_INFO(TAG, "ESP32 OTA 异步收尾任务启动");
if (s_esp32_last_seq != 0xFFFFU) {
ble_ota_send_window_ack(s_esp32_last_cmd, s_esp32_last_seq, (uint32_t)s_esp32_received);
DEBUG_INFO(TAG, "ESP32 最终 ACK 已补发: seq=%u", s_esp32_last_seq);
}
vTaskDelay(pdMS_TO_TICKS(150));
if (!s_esp32_ota_active || s_esp32_update_partition == NULL) {
DEBUG_WARN(TAG, "异步收尾时 OTA 状态无效,放弃重启");
s_esp32_reboot_pending = false;
vTaskDelete(NULL);
return;
}
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节, 设置启动分区...", (unsigned int)s_esp32_received);
int64_t t0_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_end()");
ble_ota_send_finish_event(BLE_OTA_FINISH_BEGIN_OTA_END, 0);
esp_err_t err = esp_ota_end(s_esp32_ota_handle);
int64_t t1_us = esp_timer_get_time();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "[FINISH] esp_ota_end 失败: %s (耗时=%lld ms)",
esp_err_to_name(err), (long long)((t1_us - t0_us) / 1000));
ble_ota_send_finish_event(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err);
esp_ota_abort(s_esp32_ota_handle);
s_esp32_ota_active = false;
s_esp32_ota_handle = 0;
s_esp32_update_partition = NULL;
s_esp32_received = 0;
s_esp32_reboot_pending = false;
vTaskDelete(NULL);
return;
}
DEBUG_INFO(TAG, "[FINISH] esp_ota_end 完成 (耗时=%lld ms)",
(long long)((t1_us - t0_us) / 1000));
ble_ota_send_finish_event(BLE_OTA_FINISH_DONE_OTA_END, (uint32_t)((t1_us - t0_us) / 1000));
int64_t t2_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_set_boot_partition()");
ble_ota_send_finish_event(BLE_OTA_FINISH_BEGIN_SET_BOOT, 0);
err = esp_ota_set_boot_partition(s_esp32_update_partition);
int64_t t3_us = esp_timer_get_time();
if (err != ESP_OK) {
DEBUG_ERR(TAG, "[FINISH] 设置启动分区失败: %s (耗时=%lld ms)",
esp_err_to_name(err), (long long)((t3_us - t2_us) / 1000));
ble_ota_send_finish_event(BLE_OTA_FINISH_ERR_SET_BOOT, (uint32_t)err);
s_esp32_ota_active = false;
s_esp32_ota_handle = 0;
s_esp32_update_partition = NULL;
s_esp32_received = 0;
s_esp32_reboot_pending = false;
vTaskDelete(NULL);
return;
}
DEBUG_INFO(TAG, "[FINISH] esp_ota_set_boot_partition 完成 (耗时=%lld ms)",
(long long)((t3_us - t2_us) / 1000));
ble_ota_send_finish_event(BLE_OTA_FINISH_DONE_SET_BOOT, (uint32_t)((t3_us - t2_us) / 1000));
s_esp32_ota_active = false;
s_esp32_ota_handle = 0;
s_esp32_update_partition = NULL;
s_esp32_received = 0;
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,800ms 后重启...");
int64_t t4_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 即将进入 800ms 重启前等待");
ble_ota_send_finish_event(BLE_OTA_FINISH_BEGIN_DELAY, 800);
vTaskDelay(pdMS_TO_TICKS(800));
int64_t t5_us = esp_timer_get_time();
DEBUG_INFO(TAG, "[FINISH] 800ms 等待结束 (实际耗时=%lld ms)",
(long long)((t5_us - t4_us) / 1000));
ble_ota_send_finish_event(BLE_OTA_FINISH_DONE_DELAY, (uint32_t)((t5_us - t4_us) / 1000));
DEBUG_INFO(TAG, "ESP32 即将重启");
ble_ota_send_finish_event(BLE_OTA_FINISH_REBOOT_NOW, 0);
esp_restart();
}
// ============================================================
// NimBLE 工具: 从 mbuf 读取原始数据
/**
@@ -64,6 +179,116 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
return (int)copy_len;
}
static esp_err_t ble_esp32_ota_begin(size_t total_size)
{
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx",
boot_partition->label, boot_partition->size, boot_partition->address);
if (s_esp32_ota_active) {
DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA,会先中止旧会话");
esp_ota_abort(s_esp32_ota_handle);
s_esp32_ota_active = false;
s_esp32_ota_handle = 0;
s_esp32_update_partition = NULL;
s_esp32_received = 0;
s_esp32_reboot_pending = false;
s_esp32_last_seq = 0xFFFFU;
}
s_esp32_update_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
if (s_esp32_update_partition == NULL ||
s_esp32_update_partition->address == boot_partition->address) {
s_esp32_update_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
}
if (s_esp32_update_partition == NULL) {
DEBUG_ERR(TAG, "找不到可用的 OTA 分区");
return ESP_FAIL;
}
DEBUG_INFO(TAG, "ESP32 OTA 目标分区: %s, size=%u, address=0x%lx",
s_esp32_update_partition->label,
s_esp32_update_partition->size,
s_esp32_update_partition->address);
if (total_size > s_esp32_update_partition->size) {
DEBUG_ERR(TAG, "ESP32 固件太大: %u > 分区大小: %u",
total_size, s_esp32_update_partition->size);
s_esp32_update_partition = NULL;
return ESP_FAIL;
}
esp_err_t err = esp_ota_begin(s_esp32_update_partition, total_size, &s_esp32_ota_handle);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err));
s_esp32_update_partition = NULL;
s_esp32_ota_handle = 0;
return err;
}
s_esp32_received = 0;
s_esp32_ota_active = true;
s_esp32_reboot_pending = false;
s_esp32_last_cmd = 0;
s_esp32_last_seq = 0xFFFFU;
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s", s_esp32_update_partition->label);
return ESP_OK;
}
static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len)
{
if (!s_esp32_ota_active || s_esp32_update_partition == NULL) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_ota_write(s_esp32_ota_handle, data, len);
if (err == ESP_OK) {
s_esp32_received += len;
}
return err;
}
static void ble_ota_send_window_ack(uint8_t cmd, uint16_t seq, uint32_t completed_bytes)
{
uint8_t ack[8] = {0};
ack[0] = BLE_OTA_NOTIFY_ACK;
ack[1] = cmd;
ack[2] = (uint8_t)(seq & 0xFFU);
ack[3] = (uint8_t)((seq >> 8) & 0xFFU);
ack[4] = (uint8_t)(completed_bytes & 0xFFU);
ack[5] = (uint8_t)((completed_bytes >> 8) & 0xFFU);
ack[6] = (uint8_t)((completed_bytes >> 16) & 0xFFU);
ack[7] = (uint8_t)((completed_bytes >> 24) & 0xFFU);
esp_err_t err = ble_ota_notify_response(ack, sizeof(ack));
if (err != ESP_OK) {
DEBUG_WARN(TAG, "发送 ACK 失败: cmd=0x%02X seq=%u err=%s",
cmd, seq, esp_err_to_name(err));
} else {
DEBUG_DBG(TAG, "已发送 ACK: cmd=0x%02X seq=%u bytes=%lu",
cmd, seq, (unsigned long)completed_bytes);
}
}
static void ble_ota_send_finish_event(uint8_t stage, uint32_t value)
{
uint8_t pkt[6] = {0};
pkt[0] = BLE_OTA_NOTIFY_FINISH;
pkt[1] = stage;
pkt[2] = (uint8_t)(value & 0xFFU);
pkt[3] = (uint8_t)((value >> 8) & 0xFFU);
pkt[4] = (uint8_t)((value >> 16) & 0xFFU);
pkt[5] = (uint8_t)((value >> 24) & 0xFFU);
esp_err_t err = ble_ota_notify_response(pkt, sizeof(pkt));
if (err != ESP_OK) {
DEBUG_WARN(TAG, "发送 FINISH 事件失败: stage=0x%02X err=%s",
stage, esp_err_to_name(err));
}
}
// ============================================================
// GATT 访问回调
// ============================================================
@@ -92,20 +317,43 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
DEBUG_INFO(TAG, "CTRL 写入: cmd=0x%02X", buf[0]);
if (buf[0] == 0xF7) {
// 检查固件是否存储完毕
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
// 检查固件是否存储完毕 → 触发 GD32 OTA 中继
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 " 字节),触发中继",
DEBUG_INFO(TAG, "GD32 固件就绪 (%" PRIu32 " / %" PRIu32 " 字节),触发中继",
meta.stored_size, meta.total_size);
ble_ota_signal_relay();
} else {
DEBUG_WARN(TAG, "固件未就绪, 未触发中继 (stored=%" PRIu32 ", total=%" PRIu32 ")",
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
meta.stored_size, meta.total_size);
}
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
if (!s_esp32_ota_active || s_esp32_update_partition == NULL) {
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
return 0;
}
if (s_esp32_reboot_pending) {
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
return 0;
}
s_esp32_reboot_pending = true;
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
"ble_ota_finish",
4096,
NULL,
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
s_esp32_reboot_pending = false;
} else {
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(由设备端异步完成)");
}
}
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
@@ -158,7 +406,78 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu",
cmd, seq, total_size, data_len);
// --- 首个分块: 创建存储元数据 ---
// --- ESP32 自身 OTA:直接写 OTA 分区 ---
if (cmd == BLE_OTA_CMD_ESP32_DATA) {
if (seq == 0) {
if (total_size == 0) {
DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size);
return BLE_ATT_ERR_UNLIKELY;
}
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
const esp_partition_t *check_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
if (check_partition == NULL || check_partition->address == boot_partition->address) {
check_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
}
if (check_partition == NULL) {
DEBUG_ERR(TAG, "找不到 ESP32 OTA 目标分区");
return BLE_ATT_ERR_UNLIKELY;
}
if (total_size > check_partition->size) {
DEBUG_ERR(TAG, "ESP32 固件太大: %" PRIu32 " > OTA分区 %u",
total_size, check_partition->size);
return BLE_ATT_ERR_UNLIKELY;
}
esp_err_t err = ble_esp32_ota_begin(total_size);
if (err != ESP_OK) {
return BLE_ATT_ERR_UNLIKELY;
}
}
esp_err_t err = ble_esp32_ota_write(&buf[data_offset], data_len);
if (err != ESP_OK) {
DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s",
seq, esp_err_to_name(err));
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u",
seq, (unsigned int)s_esp32_received);
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
(total_size > 0 && s_esp32_received >= total_size)) {
ble_ota_send_window_ack(cmd, seq, (uint32_t)s_esp32_received);
}
if (total_size > 0 && s_esp32_received >= total_size) {
DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)",
(unsigned int)s_esp32_received, total_size, seq + 1);
s_esp32_last_cmd = cmd;
s_esp32_last_seq = seq;
if (!s_esp32_reboot_pending) {
s_esp32_reboot_pending = true;
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
"ble_ota_finish",
4096,
NULL,
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
s_esp32_reboot_pending = false;
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启");
} else {
DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发");
}
}
return 0;
}
// --- GD32 OTA:首个分块时创建存储元数据 ---
if (seq == 0 && total_size > 0) {
if (total_size > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR(TAG, "固件太大: %" PRIu32 " > %zu",
@@ -170,7 +489,7 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
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;
meta.chunk_count = (total_size + (uint32_t)data_len - 1U) / (uint32_t)data_len;
esp_err_t err = meta_save(&meta);
if (err != ESP_OK) {
@@ -178,12 +497,31 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_UNLIKELY;
}
DEBUG_INFO(TAG, "固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32,
meta.total_size, meta.chunk_count);
DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32 ", payload=%zu",
meta.total_size, meta.chunk_count, data_len);
}
ota_meta_t meta = {};
if (meta_load(&meta) != ESP_OK || meta.total_size == 0) {
DEBUG_ERR(TAG, "GD32 元数据无效,拒绝写入: seq=%u", seq);
return BLE_ATT_ERR_UNLIKELY;
}
if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) {
DEBUG_ERR(TAG, "GD32 分包序号不连续: seq=%u last_seq=%" PRIu32,
seq, meta.last_seq);
return BLE_ATT_ERR_UNLIKELY;
}
// --- GD32 写入 Flash ---
// 按已存储字节数顺序追加,兼容更大的 BLE payload,避免把步长硬编码死。
uint32_t flash_offset = meta.stored_size;
if (flash_offset + (uint32_t)data_len > meta.total_size) {
DEBUG_ERR(TAG, "GD32 写入越界: offset=%" PRIu32 " len=%zu total=%" PRIu32,
flash_offset, data_len, meta.total_size);
return BLE_ATT_ERR_UNLIKELY;
}
// --- 写入 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",
@@ -191,17 +529,25 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_UNLIKELY;
}
err = meta_update(flash_offset + data_len, seq);
uint32_t stored_size = flash_offset + (uint32_t)data_len;
err = meta_update(stored_size, seq);
if (err != ESP_OK) {
DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq);
}
// --- 检查是否接收完毕 ---
ota_meta_t meta;
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
(meta.total_size > 0 && stored_size >= meta.total_size)) {
ble_ota_send_window_ack(cmd, seq, stored_size);
}
DEBUG_DBG(TAG, "Flash 写入成功: seq=%u offset=%" PRIu32 " stored=%" PRIu32,
seq, flash_offset, stored_size);
// --- 检查 GD32 固件是否接收完毕 ---
if (meta_load(&meta) == ESP_OK &&
meta.total_size > 0 &&
meta.stored_size >= meta.total_size) {
DEBUG_INFO(TAG, "★ 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
meta.stored_size, meta.total_size, seq + 1);
}
@@ -223,7 +569,7 @@ static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
const char *version = "1.0.0";
const char *version = FW_VERSION;
int rc = os_mbuf_append(ctxt->om, version, strlen(version));
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
}
@@ -326,6 +672,22 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
/** @brief 当前 BLE 连接句柄。 */
s_conn_handle = event->connect.conn_handle;
DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", s_conn_handle);
// 向对端请求更快的连接参数,提升 OTA 吞吐
struct ble_gap_upd_params up = {};
up.itvl_min = 6; // 7.5 ms 最小连接间隔
up.itvl_max = 12; // 15 ms 最大连接间隔
up.latency = 0; // 无延迟
up.supervision_timeout = 2000; // 2 秒监控超时
up.min_ce_len = 0; // 控制器自行决定
up.max_ce_len = 0;
int rc = ble_gap_update_params(s_conn_handle, &up);
if (rc != 0) {
DEBUG_WARN(TAG, "请求更新连接参数失败: rc=%d(对端不一定接受,继续运行)", rc);
} else {
DEBUG_INFO(TAG, "已请求更快的连接参数: itvl=%.1f-%.1fms latency=0",
up.itvl_min * 1.25f, up.itvl_max * 1.25f);
}
} else {
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
/** @brief BLE GATT 客户端连接状态。 */
@@ -341,14 +703,12 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
/** @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);
ble_ota_start_advertising();
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);
ble_ota_start_advertising();
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
@@ -377,6 +737,26 @@ static int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
*/
static void ble_ota_start_advertising(void)
{
int rc;
// 自动推断可用地址类型(优先 Public,不可用则生成 Random)
uint8_t own_addr_type;
rc = ble_hs_id_infer_auto(0, &own_addr_type);
if (rc != 0) {
DEBUG_ERR(TAG, "推断地址类型失败: rc=%d, 尝试生成随机地址", rc);
// 生成不可解析随机地址作为 fallback
ble_addr_t rnd_addr;
rc = ble_hs_id_gen_rnd(0, &rnd_addr);
if (rc != 0) {
DEBUG_ERR(TAG, "生成随机地址也失败: rc=%d, BLE 广播无法启动", rc);
return;
}
own_addr_type = rnd_addr.type;
}
DEBUG_INFO(TAG, "使用地址类型: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random");
// --- 构建广播数据 ---
uint8_t adv_data[31] = {0};
int adv_pos = 0;
@@ -396,7 +776,7 @@ static void ble_ota_start_advertising(void)
memcpy(&adv_data[adv_pos], name, name_len);
adv_pos += name_len;
int rc = ble_gap_adv_set_data(adv_data, adv_pos);
rc = ble_gap_adv_set_data(adv_data, adv_pos);
if (rc != 0) {
DEBUG_ERR(TAG, "设置广播数据失败: %d", rc);
return;
@@ -423,12 +803,7 @@ static void ble_ota_start_advertising(void)
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
// own_addr_type 已在上面由 ble_hs_id_infer_auto 推断
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
&adv_params, ble_ota_gap_event, NULL);
@@ -514,6 +889,13 @@ esp_err_t ble_ota_server_start(void)
DEBUG_WARN(TAG, "设置设备名称失败: %d", rc);
}
rc = ble_att_set_preferred_mtu(BLE_MTU_SIZE);
if (rc != 0) {
DEBUG_WARN(TAG, "设置首选 MTU 失败: %d", rc);
} else {
DEBUG_INFO(TAG, "首选 ATT MTU 已设置: %u", (unsigned)BLE_MTU_SIZE);
}
// 5. 配置 Host 回调
// sync_cb: NimBLE 事件循环就绪时调用 (开始广播)
// reset_cb: NimBLE 重置时调用
+67 -13
View File
@@ -629,7 +629,34 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
return ESP_FAIL;
}
if (g_relay_state != RELAY_IDLE) {
DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", g_relay_state);
ota_relay_reset();
}
s_gd32_storage_done = false;
s_relay_triggered = false;
const esp_partition_t *gd32_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota");
if (!gd32_partition) {
DEBUG_ERR("WIFI", "找不到 gd32_ota 分区");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "找不到 gd32_ota 分区");
return ESP_FAIL;
}
esp_err_t erase_err = esp_partition_erase_range(gd32_partition, 0, gd32_partition->size);
if (erase_err != ESP_OK) {
DEBUG_ERR("WIFI", "擦除 gd32_ota 分区失败: %s", esp_err_to_name(erase_err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "擦除分区失败");
return ESP_FAIL;
}
meta_clear();
uint8_t *buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
if (!buf) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败");
return ESP_FAIL;
}
int received = 0;
// peek 字节是 GD32 数据的第一字节,先放回缓冲区
@@ -643,10 +670,17 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
if (got > 0) header_read += got;
}
// 先处理已读的 header
uint32_t expected_fw_size = 0;
if (header_read >= 6) {
uint16_t seq = gd32_header[0] | (gd32_header[1] << 8);
uint32_t fw_size = gd32_header[2] | (gd32_header[3] << 8) | (gd32_header[4] << 16) | (gd32_header[5] << 24);
expected_fw_size = fw_size;
if (fw_size > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR("WIFI", "GD32 固件头声明大小超限: %u > %u", fw_size, OTA_MAX_FIRMWARE_SIZE);
free(buf);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "GD32 固件大小超限");
return ESP_FAIL;
}
if (seq == 0) {
ota_meta_t meta = { .total_size = fw_size, .stored_size = 0, .last_seq = 0,
.chunk_count = (fw_size + OTA_BLOCK_SIZE - 1) / OTA_BLOCK_SIZE, .checksum = {0} };
@@ -655,7 +689,7 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
}
}
// 继续读剩余数据
// 首包已经读走 6 字节头,因此后续 body 全部按 header_read 为偏移基准顺序落盘
while (received + header_read < req->content_len) {
size_t to_read = req->content_len - received - header_read;
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
@@ -663,30 +697,39 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
int ret = httpd_req_recv(req, (char *)buf, to_read);
if (ret <= 0) break;
uint32_t flash_offset = ((received + header_read) / OTA_BLOCK_SIZE) * OTA_BLOCK_SIZE;
uint32_t flash_offset = (uint32_t)received;
esp_err_t err = flash_write_chunk(flash_offset, buf, ret);
if (err == ESP_OK) {
meta_update(flash_offset + ret, (received + header_read) / OTA_BLOCK_SIZE);
if (err != ESP_OK) {
DEBUG_ERR("WIFI", "Flash 写入失败: offset=%u len=%d err=%s",
flash_offset, ret, esp_err_to_name(err));
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "写入 gd32_ota 失败");
return ESP_FAIL;
}
received += ret;
uint32_t progress = ((received + header_read) * 100) / req->content_len;
received += ret;
if (expected_fw_size > 0) {
meta_update((uint32_t)received, (uint32_t)(received / OTA_BLOCK_SIZE));
}
uint32_t progress = expected_fw_size > 0
? ((uint32_t)received * 100U) / expected_fw_size
: ((uint32_t)(received + header_read) * 100U) / (uint32_t)req->content_len;
if (progress % 10 == 0) {
DEBUG_INFO("WIFI", "GD32 上传进度: %d / %d (%u%%)",
received + header_read, req->content_len, progress);
DEBUG_INFO("WIFI", "GD32 上传进度: %d / %u (%u%%)",
received, expected_fw_size, progress);
}
}
free(buf);
DEBUG_INFO("WIFI", "GD32 固件存储完成: %d 字节", received + header_read);
DEBUG_INFO("WIFI", "GD32 固件存储完成: payload=%d 字节, 头=%d 字节", received, header_read);
ota_meta_t meta;
meta_load(&meta);
if (meta.stored_size >= meta.total_size) {
if (meta.total_size > 0 && meta.stored_size >= meta.total_size) {
/** @brief GD32 固件存储完成标志。 */
s_gd32_storage_done = true;
DEBUG_INFO("WIFI", "GD32 固件已存储,触发转发...");
wifi_ota_signal_relay();
DEBUG_INFO("WIFI", "GD32 固件已存储完成,等待触发转发...");
}
const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\"}";
@@ -1202,6 +1245,14 @@ static esp_err_t http_ota_server_start(void)
*/
void wifi_ota_signal_relay(void)
{
ota_meta_t meta;
memset(&meta, 0, sizeof(meta));
if (meta_load(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) {
DEBUG_WARN("WIFI", "GD32 固件未完整存储,拒绝触发转发: stored=%lu total=%lu",
(unsigned long)meta.stored_size, (unsigned long)meta.total_size);
return;
}
if (s_relay_triggered) {
DEBUG_WARN("WIFI", "转发已经触发过");
return;
@@ -1214,6 +1265,9 @@ void wifi_ota_signal_relay(void)
uint32_t msg = 1;
xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(100));
DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
} else {
DEBUG_ERR("WIFI", "OTA 中继队列尚未就绪");
s_relay_triggered = false;
}
}
+151
View File
@@ -15,6 +15,63 @@
#include "spi_master.h"
#include "monitor/debug_log.h"
static uint8_t spi_ota_crc8(const uint8_t *data, size_t len)
{
uint8_t crc = 0x00;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
}
return crc;
}
static bool spi_ota_build_frame(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *out, size_t *out_len)
{
if (!out || !out_len) return false;
size_t total = 5 + payload_len;
if (total > SPI_OTA_FRAME_MAX) return false;
out[0] = SPI_OTA_SOF;
out[1] = type;
out[2] = (uint8_t)(payload_len & 0xFF);
out[3] = (uint8_t)((payload_len >> 8) & 0xFF);
if (payload && payload_len > 0) {
memcpy(&out[4], payload, payload_len);
}
out[4 + payload_len] = spi_ota_crc8(out, 4 + payload_len);
*out_len = total;
return true;
}
static bool spi_ota_parse_frame(const uint8_t *frame, size_t frame_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len)
{
if (!frame || frame_len < 5 || frame[0] != SPI_OTA_SOF) return false;
size_t len = (size_t)frame[2] | ((size_t)frame[3] << 8);
size_t expected = 5 + len;
if (expected > frame_len) return false;
uint8_t crc = spi_ota_crc8(frame, 4 + len);
if (crc != frame[4 + len]) return false;
if (type) *type = frame[1];
if (payload) *payload = &frame[4];
if (payload_len) *payload_len = len;
return true;
}
static bool spi_ota_find_frame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset)
{
if (!buf || buf_len < 5) return false;
for (size_t i = 0; i + 5 <= buf_len; ++i) {
if (buf[i] != SPI_OTA_SOF) continue;
if (spi_ota_parse_frame(&buf[i], buf_len - i, type, payload, payload_len)) {
if (frame_offset) *frame_offset = i;
return true;
}
}
return false;
}
/** @brief SPI2 从设备句柄。 */
spi_device_handle_t g_spi_device = NULL;
@@ -132,6 +189,100 @@ esp_err_t spi_recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_
return spi_sync_transfer(cmd, dummy, len, recv_buf, len, timeout_ms);
}
esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
if (g_spi_device == NULL) {
return ESP_ERR_INVALID_STATE;
}
if (resp_len) *resp_len = 0;
if (resp_type) *resp_type = 0;
uint8_t rx_raw[SPI_OTA_FRAME_MAX] = {0};
uint8_t dummy[SPI_OTA_FRAME_MAX];
memset(dummy, 0xFF, sizeof(dummy));
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
uint32_t attempt = 0;
while (xTaskGetTickCount() <= deadline) {
++attempt;
memset(rx_raw, 0xFF, sizeof(rx_raw));
spi_transaction_t rsp = {};
rsp.length = SPI_OTA_FRAME_MAX * 8;
rsp.rxlength = SPI_OTA_FRAME_MAX * 8;
rsp.tx_buffer = dummy;
rsp.rx_buffer = rx_raw;
esp_err_t ret = spi_device_transmit(g_spi_device, &rsp);
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "SPI OTA 响应读取失败[%u]: %s", attempt, esp_err_to_name(ret));
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
uint8_t parsed_type = 0;
const uint8_t *parsed_payload = nullptr;
size_t parsed_len = 0;
size_t frame_offset = 0;
if (spi_ota_find_frame(rx_raw, sizeof(rx_raw), &parsed_type, &parsed_payload, &parsed_len, &frame_offset)) {
DEBUG_INFO("SPI", "OTA响应: type=0x%02X len=%u offset=%u attempt=%u",
parsed_type, (unsigned)parsed_len, (unsigned)frame_offset, (unsigned)attempt);
if (resp_type) *resp_type = parsed_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (parsed_len < resp_max) ? parsed_len : resp_max;
memcpy(resp_payload, parsed_payload, copy_len);
if (resp_len) *resp_len = copy_len;
} else if (resp_len) {
*resp_len = parsed_len;
}
return ESP_OK;
}
bool all_ff = true;
for (size_t i = 0; i < sizeof(rx_raw); ++i) {
if (rx_raw[i] != 0xFF) {
all_ff = false;
break;
}
}
DEBUG_DBG("SPI", "OTA响应未就绪[%u]: %s", attempt, all_ff ? "all-FF" : "no-valid-SOF");
vTaskDelay(pdMS_TO_TICKS(10));
}
DEBUG_ERR("SPI", "OTA响应超时: timeout=%ums", (unsigned)timeout_ms);
return ESP_ERR_TIMEOUT;
}
esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
if (g_spi_device == NULL) {
return ESP_ERR_INVALID_STATE;
}
if (resp_len) *resp_len = 0;
if (resp_type) *resp_type = 0;
uint8_t tx_frame[SPI_OTA_FRAME_MAX] = {0};
size_t tx_len = 0;
if (!spi_ota_build_frame(type, payload, payload_len, tx_frame, &tx_len)) {
return ESP_ERR_INVALID_ARG;
}
DEBUG_INFO("SPI", "OTA事务: req_type=0x%02X req_len=%u", type, (unsigned)payload_len);
spi_transaction_t req = {};
req.length = tx_len * 8;
req.tx_buffer = tx_frame;
esp_err_t ret = spi_device_transmit(g_spi_device, &req);
if (ret != ESP_OK) {
DEBUG_ERR("SPI", "SPI OTA 请求事务失败: %s", esp_err_to_name(ret));
return ret;
}
vTaskDelay(pdMS_TO_TICKS(2));
return spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
}
// CANFD 帧发送
// 帧格式: [通道(1B)] [扩展帧ID(4B BE)] [数据(1~64B)]
static int can_frame_send(can_channel_t channel, uint32_t ext_id,
+32 -1
View File
@@ -10,9 +10,19 @@
// SPI 配置常量
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
#define SPI_CLOCK_HZ (40 * 1000 * 1000) // 40MHz
#define SPI_CLOCK_HZ (4 * 1000 * 1000) // 先降到 4MHz 便于联调
#define SPI_MODE (0) // CPOL=0, CPHA=0
#define SPI_QUEUE_SIZE (1)
#define SPI_OTA_FRAME_MAX (96)
// GD32 SPI OTA 帧类型
#define SPI_OTA_SOF (0xA5)
#define SPI_OTA_CMD_REQ (0x01)
#define SPI_OTA_CMD_DATA (0x02)
#define SPI_OTA_CMD_CANCEL (0x03)
#define SPI_OTA_RSP_ACK (0x80)
#define SPI_OTA_RSP_WAIT (0x81)
#define SPI_OTA_RSP_ERROR (0x82)
// SPI 设备句柄(外部引用)
extern spi_device_handle_t g_spi_device;
@@ -108,4 +118,25 @@ esp_err_t spi_send(uint8_t cmd, const uint8_t *payload, size_t len);
*/
esp_err_t spi_recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms);
/**
* @brief 按 GD32 SPI OTA 最小协议执行一次请求事务 + 一次响应读取事务。
* @param type 请求帧类型(REQ/DATA/CANCEL)。
* @param payload 请求负载。
* @param payload_len 负载长度。
* @param resp_type 输出响应类型(ACK/WAIT/ERROR)。
* @param resp_payload 输出响应负载缓冲区。
* @param resp_max 输出响应负载最大长度。
* @param resp_len 输出实际响应负载长度。
* @param timeout_ms 超时。
*/
esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
/**
* @brief 仅执行 SPI OTA 响应读取事务,不重复发送请求帧。
*/
esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
#endif // SPI_MASTER_H
+831
View File
@@ -0,0 +1,831 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 BLE OTA 刷写台</title>
<style>
:root {
--bg: #07111f;
--panel: rgba(15, 27, 47, 0.84);
--line: rgba(126, 231, 255, 0.14);
--line-strong: rgba(126, 231, 255, 0.28);
--text: #eaf7ff;
--muted: #8fa8bb;
--cyan: #76e7ff;
--blue: #4f8cff;
--violet: #9785ff;
--green: #21d19f;
--amber: #ffb84d;
--red: #ff6b7a;
--shadow: 0 20px 70px rgba(0,0,0,.45);
--radius: 20px;
}
* { box-sizing: border-box; }
html, body { margin:0; min-height:100%; }
body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(118,231,255,.12), transparent 34%),
radial-gradient(circle at 90% 14%, rgba(151,133,255,.16), transparent 26%),
linear-gradient(160deg, var(--bg), #06101c 44%, #050b14 100%);
color: var(--text);
}
body::before {
content:"";
position:fixed; inset:0;
background-image:
linear-gradient(rgba(255,255,255,.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.02) 1px, transparent 1px);
background-size: 24px 24px;
mask-image: radial-gradient(circle at center, black, transparent 82%);
pointer-events:none;
}
.wrap { max-width: 1380px; margin: 0 auto; padding: 28px; position: relative; }
.hero { display:grid; grid-template-columns: 1.2fr .8fr; gap: 20px; margin-bottom: 20px; }
.hero-card, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.hero-card { padding: 28px; overflow:hidden; position:relative; }
.hero-card::after {
content:"";
position:absolute; right:-60px; top:-60px; width:200px; height:200px;
background: radial-gradient(circle, rgba(118,231,255,.26), transparent 64%);
pointer-events:none;
}
.eyebrow {
display:inline-flex; align-items:center; gap:8px;
font-size:12px; letter-spacing:.12em; text-transform:uppercase;
color: var(--cyan); padding:8px 12px; border:1px solid var(--line-strong);
border-radius:999px; background:rgba(118,231,255,.07);
}
h1 { margin: 18px 0 10px; font-size: 42px; line-height: 1.05; }
.hero p { margin: 0; color: var(--muted); line-height: 1.7; font-size: 15px; }
.hero-points { display:grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 22px; }
.mini { padding: 14px 16px; border-radius: 16px; border:1px solid var(--line); background: rgba(255,255,255,.03); }
.mini b { display:block; font-size: 18px; margin-bottom: 4px; }
.mini span { color: var(--muted); font-size: 12px; }
.status-stack { display:grid; gap: 14px; padding: 20px; }
.status-pill {
display:flex; justify-content:space-between; align-items:center;
border:1px solid var(--line); border-radius:16px; padding:14px 16px;
background: rgba(255,255,255,.03);
gap: 12px;
}
.status-pill .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing:.12em; }
.status-pill .value { font-weight:700; font-size: 15px; }
.ok { color: var(--green); }
.warn { color: var(--amber); }
.bad { color: var(--red); }
.grid { display:grid; grid-template-columns: 1.05fr .95fr; gap: 20px; }
.card { padding: 22px; }
.card h2 { margin: 0 0 18px; font-size: 18px; }
.section { margin-bottom: 18px; }
.section:last-child { margin-bottom: 0; }
.label { display:block; margin-bottom: 8px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .12em; }
.row-2, .row-3, .row-4 { display:grid; gap: 12px; }
.row-2 { grid-template-columns: 1fr 1fr; }
.row-3 { grid-template-columns: repeat(3, 1fr); }
.row-4 { grid-template-columns: repeat(4, 1fr); }
input[type="text"], input[type="number"], input[type="file"], select, textarea {
width:100%; border-radius: 14px; border:1px solid var(--line);
background: rgba(255,255,255,.04); color: var(--text);
padding: 13px 14px; outline: none; font-size: 14px;
}
select option { background:#0b1828; color:var(--text); }
input:focus, textarea:focus, select:focus { border-color: var(--cyan); box-shadow: 0 0 0 4px rgba(118,231,255,.08); }
textarea { min-height: 110px; resize: vertical; }
button, .btn-link {
border:0; border-radius: 14px; padding: 13px 16px; cursor:pointer;
color:#03111d; font-weight:700; font-size:14px; text-decoration:none;
background: linear-gradient(135deg, var(--cyan), #9df5ff);
transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease;
box-shadow: 0 12px 28px rgba(118,231,255,.18);
display:inline-flex; align-items:center; justify-content:center; gap:8px;
}
button:hover:not(:disabled), .btn-link:hover { transform: translateY(-1px); }
button:disabled { opacity:.45; cursor:not-allowed; box-shadow:none; }
.secondary { background: linear-gradient(135deg, #ccd7e2, #e9f0f6); }
.ghost {
color: var(--text); background: rgba(255,255,255,.05);
border:1px solid var(--line); box-shadow:none;
}
.danger { background: linear-gradient(135deg, #ff8393, #ffb1bb); }
.top-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:18px; }
.progress-shell { margin-top: 12px; padding: 16px; border-radius: 16px; background: rgba(255,255,255,.03); border:1px solid var(--line); }
.progress-bar { width:100%; height: 14px; border-radius:999px; overflow:hidden; background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.06); }
.progress-fill { width:0%; height:100%; border-radius:999px; background: linear-gradient(90deg, var(--cyan), var(--blue), var(--violet)); transition: width .18s linear; }
.progress-meta { display:flex; justify-content:space-between; gap:12px; margin-top:10px; color: var(--muted); font-size: 12px; }
.metric-grid { display:grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.metric { padding: 14px; border-radius: 16px; border:1px solid var(--line); background: rgba(255,255,255,.03); }
.metric .k { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.metric .v { font-size: 20px; font-weight: 700; word-break: break-word; }
.hint { padding: 12px 14px; border-radius: 14px; border:1px solid rgba(255,184,77,.18); background: rgba(255,184,77,.08); color: #ffd7a3; font-size: 13px; line-height: 1.7; }
.noti-box, .log-box { border-radius: 16px; border:1px solid var(--line); background: rgba(255,255,255,.03); }
.noti-box { padding: 14px; min-height: 110px; }
.noti-line {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px; color: #bfefff; padding: 8px 10px; border-radius: 10px;
background: rgba(118,231,255,.06); margin-bottom: 8px; word-break: break-all;
}
.log-box {
height: 420px; overflow:auto; padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px;
}
.log-line { margin-bottom: 8px; line-height: 1.6; color: #cfe3f3; }
.log-line .t { color: #7aa2bd; }
.log-line .tag { color: var(--cyan); }
.footer-note { margin-top: 16px; color: var(--muted); font-size: 12px; line-height: 1.7; }
.accent { color: var(--cyan); font-weight: 700; }
code { color: #ccf5ff; }
@media (max-width: 1120px) {
.hero, .grid { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 720px) {
.wrap { padding: 16px; }
h1 { font-size: 30px; }
.row-2, .row-3, .row-4, .hero-points, .metric-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<div class="hero-card">
<div class="eyebrow">BLE • OTA • Dual Target</div>
<h1>ESP32 Web Bluetooth 刷写台</h1>
<p>这个页面专门走 BLE GATT,不依赖 WiFi。它能扫描并连接 <b>ESP32-OTA-RELAY</b>,读取版本,订阅通知,把固件按协议分包写入 <b>DATA_IN</b>,并在完成后自动向 <b>CTRL</b> 写入对应命令:<span class="accent">GD32 = 0xF7</span>,<span class="accent">ESP32 = 0xF8</span>。</p>
<div class="top-actions">
<button id="connectBtn">连接设备</button>
<button id="disconnectBtn" class="secondary" disabled>断开</button>
<button id="versionBtn" class="ghost" disabled>读取版本</button>
<button id="subscribeBtn" class="ghost" disabled>开启通知</button>
<a class="btn-link ghost" href="index.html">回 WiFi 页面</a>
</div>
<div class="hero-points">
<div class="mini"><b>GD32 / ESP32</b><span>双目标 BLE 刷写</span></div>
<div class="mini"><b>480B</b><span>默认 Payload</span></div>
<div class="mini"><b>一键刷写</b><span>写入完成后自动触发</span></div>
</div>
</div>
<div class="hero-card status-stack">
<div class="status-pill"><div><div class="label">连接状态</div><div class="value" id="connState">未连接</div></div><div class="value bad" id="connBadge">offline</div></div>
<div class="status-pill"><div><div class="label">设备名称</div><div class="value" id="deviceName">-</div></div><div class="value" id="deviceId">-</div></div>
<div class="status-pill"><div><div class="label">GATT/MTU 提示</div><div class="value" id="gattState">未就绪</div></div><div class="value warn" id="mtuHint">浏览器决定</div></div>
<div class="status-pill"><div><div class="label">版本</div><div class="value" id="versionText">未读取</div></div><div class="value ok" id="notifyState">notify off</div></div>
</div>
</section>
<section class="grid">
<div class="card">
<h2>刷写控制台</h2>
<div class="section">
<label class="label" for="firmwareFile">固件文件</label>
<input id="firmwareFile" type="file" accept=".bin,.hex,application/octet-stream">
</div>
<div class="section row-3">
<div>
<label class="label" for="targetChip">目标芯片</label>
<select id="targetChip">
<option value="gd32">GD32(写入后触发中继)</option>
<option value="esp32">ESP32(写入后重启升级)</option>
</select>
</div>
<div>
<label class="label" for="payloadSize">每包 Payload 大小</label>
<input id="payloadSize" type="number" min="32" max="498" value="480">
</div>
<div>
<label class="label" for="ctrlHex">触发命令(HEX)</label>
<input id="ctrlHex" type="text" value="F7" maxlength="8">
</div>
</div>
<div class="section hint" id="protocolHint">
当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,最后自动写 <b>CTRL=0xF7</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload + crc占位4</b>。
</div>
<div class="section progress-shell">
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-meta">
<span id="progressText">等待选择文件</span>
<span id="progressPercent">0%</span>
</div>
</div>
<div class="section row-4" style="margin-top:16px;">
<button id="uploadBtn" disabled>仅写入 DATA_IN</button>
<button id="finishBtn" class="ghost" disabled>仅发送 CTRL</button>
<button id="autoFlashBtn" disabled>一键刷写</button>
<button id="abortBtn" class="danger" disabled>中止当前任务</button>
</div>
<div class="footer-note">
浏览器要求:建议用 <b>Chrome / Edge / Android Chromium</b>,并通过 <b>http(s)</b> 页面打开。某些桌面浏览器的 <code>file://</code> 下 Web Bluetooth 会被拒绝。若目标为 <b>ESP32</b>,一键刷写最后一步会触发设备重启,BLE 断开属于正常现象。<br>
默认 payload 已提高到 <b>480B</b>;如果首包就失败,说明当前系统实际协商到的 ATT MTU 较小,把 payload 手动降回 <b>240B</b> 或更低再试。
</div>
</div>
<div class="card">
<h2>文件与进度</h2>
<div class="metric-grid">
<div class="metric"><div class="k">文件名</div><div class="v" id="fileName">-</div></div>
<div class="metric"><div class="k">文件大小</div><div class="v" id="fileSize">0 B</div></div>
<div class="metric"><div class="k">总包数</div><div class="v" id="packetCount">0</div></div>
<div class="metric"><div class="k">已写包</div><div class="v" id="sentCount">0</div></div>
</div>
<div class="section" style="margin-top:16px;">
<label class="label">DATA_OUT 通知</label>
<div class="noti-box" id="notifyBox"><div class="noti-line">暂无通知</div></div>
</div>
</div>
</section>
<section class="card" style="margin-top:20px;">
<h2>运行日志</h2>
<div class="log-box" id="logBox"></div>
</section>
</div>
<script>
(() => {
const UUIDS = {
service: 0xFFFF,
ctrl: 0x0001,
dataIn: 0x0002,
dataOut: 0x0003,
version: 0x0004,
};
const UI_BUILD = '2026-05-07-ble-finishnotify-v15';
const BLE_OTA_NOTIFY_ACK = 0xA1;
const BLE_OTA_NOTIFY_FINISH = 0xB1;
const BLE_OTA_ACK_WINDOW = 8;
const TARGETS = {
gd32: {
label: 'GD32',
packetCmd: 0x00,
ctrlHex: 'F7',
recommendedPayload: 480,
finishText: '发送 CTRL(F7)',
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,最后自动写 <b>CTRL=0xF7</b>,由 ESP32 触发 SPI OTA 中继。'
},
esp32: {
label: 'ESP32',
packetCmd: 0x01,
ctrlHex: 'F8',
recommendedPayload: 480,
finishText: '发送 CTRL(F8)',
hint: '当前目标:<b>ESP32</b>。页面会使用 <b>cmd=0x01</b> 发送数据,最后自动写 <b>CTRL=0xF8</b>,由 ESP32 完成 OTA 并重启。'
}
};
const state = {
device: null,
server: null,
service: null,
ctrlChar: null,
dataInChar: null,
dataOutChar: null,
versionChar: null,
notificationsEnabled: false,
selectedFile: null,
selectedFileBytes: null,
selectedFileKey: '',
abortRequested: false,
uploading: false,
autoFlashing: false,
lastNotificationHex: '',
lastAckCmd: null,
lastAckSeq: -1,
lastAckBytes: 0,
ackWaiter: null,
};
const $ = (id) => document.getElementById(id);
const els = {
connectBtn: $('connectBtn'),
disconnectBtn: $('disconnectBtn'),
versionBtn: $('versionBtn'),
subscribeBtn: $('subscribeBtn'),
uploadBtn: $('uploadBtn'),
finishBtn: $('finishBtn'),
autoFlashBtn: $('autoFlashBtn'),
abortBtn: $('abortBtn'),
firmwareFile: $('firmwareFile'),
payloadSize: $('payloadSize'),
targetChip: $('targetChip'),
ctrlHex: $('ctrlHex'),
protocolHint: $('protocolHint'),
connState: $('connState'),
connBadge: $('connBadge'),
deviceName: $('deviceName'),
deviceId: $('deviceId'),
gattState: $('gattState'),
mtuHint: $('mtuHint'),
versionText: $('versionText'),
notifyState: $('notifyState'),
progressFill: $('progressFill'),
progressText: $('progressText'),
progressPercent: $('progressPercent'),
fileName: $('fileName'),
fileSize: $('fileSize'),
packetCount: $('packetCount'),
sentCount: $('sentCount'),
notifyBox: $('notifyBox'),
logBox: $('logBox'),
};
function currentTarget() {
return TARGETS[els.targetChip.value] || TARGETS.gd32;
}
function log(tag, message, level = 'I') {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const line = document.createElement('div');
line.className = 'log-line';
line.innerHTML = `<span class="t">[${time}]</span> <span class="tag">[${level}/${tag}]</span> ${escapeHtml(message)}`;
els.logBox.appendChild(line);
els.logBox.scrollTop = els.logBox.scrollHeight;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
}
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 toHex(uint8) {
return Array.from(uint8).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
}
function hexToBytes(hex) {
const clean = hex.replace(/[^0-9a-fA-F]/g, '');
if (clean.length === 0 || clean.length % 2 !== 0) throw new Error('HEX 长度必须为偶数');
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < clean.length; i += 2) out[i / 2] = parseInt(clean.slice(i, i + 2), 16);
return out;
}
function setProgress(percent, text) {
const p = Math.max(0, Math.min(100, percent));
els.progressFill.style.width = `${p}%`;
els.progressPercent.textContent = `${p.toFixed(0)}%`;
els.progressText.textContent = text;
}
function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
const isEsp32Target = currentTarget().label === 'ESP32';
els.connState.textContent = connected ? '已连接' : '未连接';
els.connBadge.textContent = connected ? 'online' : 'offline';
els.connBadge.className = `value ${connected ? 'ok' : 'bad'}`;
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
els.disconnectBtn.disabled = !connected;
els.versionBtn.disabled = !connected;
els.subscribeBtn.disabled = !connected;
els.uploadBtn.disabled = !idle;
els.finishBtn.disabled = !connected || state.uploading || isEsp32Target;
els.autoFlashBtn.disabled = !idle;
els.abortBtn.disabled = !state.uploading;
}
function resetUiAfterDisconnect() {
state.server = null;
state.service = null;
state.ctrlChar = null;
state.dataInChar = null;
state.dataOutChar = null;
state.versionChar = null;
state.notificationsEnabled = false;
state.uploading = false;
state.autoFlashing = false;
els.deviceName.textContent = '-';
els.deviceId.textContent = '-';
els.versionText.textContent = '未读取';
els.notifyState.textContent = 'notify off';
els.notifyState.className = 'value ok';
setConnectionState(false);
}
function refreshTargetUi() {
const cfg = currentTarget();
els.ctrlHex.value = cfg.ctrlHex;
els.payloadSize.value = String(cfg.recommendedPayload);
els.finishBtn.textContent = cfg.label === 'ESP32' ? 'ESP32 自动完成' : `仅发送 CTRL(${cfg.ctrlHex})`;
els.protocolHint.innerHTML = `${cfg.hint} 推荐 payload:<b>${cfg.recommendedPayload}B</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload + crc占位4</b>。<br><b>传输机制:</b>每 <b>${BLE_OTA_ACK_WINDOW}</b> 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? ' <br><b>ESP32 模式:</b>最后一包到齐后由设备端自动完成 OTA 并重启,不再发送 CTRL。': ''}`;
if (state.selectedFile) updateFileInfo(state.selectedFile);
setConnectionState(!!state.server);
log('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ctrl=${cfg.ctrlHex}, payload=${cfg.recommendedPayload}`);
}
async function connectBle() {
if (!navigator.bluetooth) {
log('BLE', '当前浏览器不支持 Web Bluetooth', 'E');
alert('当前浏览器不支持 Web Bluetooth,请使用 Chrome / Edge。');
return;
}
try {
log('BLE', '打开设备选择器...');
const device = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: 'ESP32-OTA' }],
optionalServices: [UUIDS.service],
});
state.device = device;
state.device.addEventListener('gattserverdisconnected', onDisconnected);
els.deviceName.textContent = device.name || '未知设备';
els.deviceId.textContent = device.id || '-';
log('BLE', `已选择设备: ${device.name || 'unknown'}`);
const server = await device.gatt.connect();
state.server = server;
state.service = await server.getPrimaryService(UUIDS.service);
state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl);
state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn);
state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut);
state.versionChar = await state.service.getCharacteristic(UUIDS.version);
els.mtuHint.textContent = '实际 ATT MTU 由系统协商';
setConnectionState(true);
log('BLE', 'GATT 服务与特征获取完成');
} catch (err) {
log('BLE', `连接失败: ${err.message}`, 'E');
resetUiAfterDisconnect();
}
}
function onDisconnected() {
log('BLE', '设备已断开', 'W');
resetUiAfterDisconnect();
}
async function disconnectBle() {
try {
if (state.dataOutChar && state.notificationsEnabled) {
await state.dataOutChar.stopNotifications();
}
} catch (_) {}
if (state.device && state.device.gatt && state.device.gatt.connected) {
state.device.gatt.disconnect();
}
resetUiAfterDisconnect();
}
async function readVersion() {
try {
const value = await state.versionChar.readValue();
const text = new TextDecoder().decode(value.buffer).replace(/\0/g, '').trim() || '(空)';
els.versionText.textContent = text;
log('BLE', `版本: ${text}`);
} catch (err) {
log('BLE', `读取版本失败: ${err.message}`, 'E');
}
}
function handleNotify(event) {
const view = event.target.value;
const bytes = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
const hex = toHex(bytes);
state.lastNotificationHex = hex;
if (bytes.length >= 8 && bytes[0] === BLE_OTA_NOTIFY_ACK) {
const ackCmd = bytes[1];
const ackSeq = bytes[2] | (bytes[3] << 8);
const ackBytes = (bytes[4]) | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24);
state.lastAckCmd = ackCmd;
state.lastAckSeq = ackSeq;
state.lastAckBytes = ackBytes >>> 0;
if (state.ackWaiter && state.ackWaiter.cmd === ackCmd && ackSeq >= state.ackWaiter.expectedSeq) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.resolve({ seq: ackSeq, bytes: state.lastAckBytes });
}
return;
}
if (bytes.length >= 6 && bytes[0] === BLE_OTA_NOTIFY_FINISH) {
const stage = bytes[1];
const value = (bytes[2]) | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24);
const finishMap = {
0x01: '[FINISH] 即将执行 esp_ota_end()',
0x02: `[FINISH] esp_ota_end 完成 (耗时=${value} ms)`,
0x03: '[FINISH] 即将执行 esp_ota_set_boot_partition()',
0x04: `[FINISH] esp_ota_set_boot_partition 完成 (耗时=${value} ms)`,
0x05: `[FINISH] 即将进入重启前等待 (${value} ms)`,
0x06: `[FINISH] 重启前等待结束 (实际耗时=${value} ms)`,
0x07: '[FINISH] ESP32 即将重启',
0x81: `[FINISH] esp_ota_end 失败: err=${value}`,
0x82: `[FINISH] esp_ota_set_boot_partition 失败: err=${value}`,
};
log('FINISH', finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`);
return;
}
if (els.notifyBox.firstElementChild && els.notifyBox.firstElementChild.textContent === '暂无通知') {
els.notifyBox.innerHTML = '';
}
const div = document.createElement('div');
div.className = 'noti-line';
div.textContent = hex;
els.notifyBox.prepend(div);
while (els.notifyBox.children.length > 10) els.notifyBox.removeChild(els.notifyBox.lastChild);
log('NOTIFY', hex);
}
function waitForAck(cmd, expectedSeq, timeoutMs = 8000) {
if (state.lastAckCmd === cmd && state.lastAckSeq >= expectedSeq) {
return Promise.resolve({ seq: state.lastAckSeq, bytes: state.lastAckBytes });
}
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter.reject(new Error('ACK waiter 被新的窗口覆盖'));
state.ackWaiter = null;
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (state.ackWaiter && state.ackWaiter.expectedSeq === expectedSeq && state.ackWaiter.cmd === cmd) {
state.ackWaiter = null;
}
reject(new Error(`等待 ACK 超时: cmd=0x${cmd.toString(16).padStart(2, '0').toUpperCase()} seq=${expectedSeq}`));
}, timeoutMs);
state.ackWaiter = { cmd, expectedSeq, resolve, reject, timer };
});
}
async function enableNotifications() {
try {
await state.dataOutChar.startNotifications();
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
state.notificationsEnabled = true;
els.notifyState.textContent = 'notify on';
els.notifyState.className = 'value ok';
log('BLE', '已开启 DATA_OUT 通知');
} catch (err) {
log('BLE', `开启通知失败: ${err.message}`, 'E');
}
}
function updateFileInfo(file) {
els.fileName.textContent = file ? file.name : '-';
els.fileSize.textContent = file ? formatBytes(file.size) : '0 B';
const payloadSize = Number(els.payloadSize.value) || 480;
const packets = file ? Math.ceil(file.size / payloadSize) : 0;
els.packetCount.textContent = String(packets);
els.sentCount.textContent = '0';
state.selectedFileBytes = null;
state.selectedFileKey = file ? `${file.name}|${file.size}|${file.lastModified || 0}` : '';
state.selectedFile = file || null;
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
log('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
preloadSelectedFileBytes().catch((err) => {
log('FILE', `预读文件失败: ${err.message}`, 'W');
});
}
}
async function preloadSelectedFileBytes() {
if (!state.selectedFile) {
throw new Error('未选择固件文件');
}
if (state.selectedFileBytes && state.selectedFileBytes.length === state.selectedFile.size) {
return state.selectedFileBytes;
}
const bytes = new Uint8Array(await state.selectedFile.arrayBuffer());
state.selectedFileBytes = bytes;
log('FILE', `固件已缓存到内存: ${bytes.length} bytes`);
return bytes;
}
function buildPacket(seq, totalSize, payload, cmd) {
const packet = new Uint8Array(1 + 2 + 4 + payload.length + 4);
packet[0] = cmd;
packet[1] = seq & 0xFF;
packet[2] = (seq >> 8) & 0xFF;
packet[3] = totalSize & 0xFF;
packet[4] = (totalSize >> 8) & 0xFF;
packet[5] = (totalSize >> 16) & 0xFF;
packet[6] = (totalSize >> 24) & 0xFF;
packet.set(payload, 7);
return packet;
}
async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function drainGattQueue() {
// writeValueWithoutResponse 在浏览器/系统蓝牙栈里通常还有内部发送队列。
// 方案 B 已对尾包做轻微收油,这里只保留较短的排空等待,避免切 CTRL 太急。
await sleep(120);
}
function getTailDelayMs(seq, packetTotal) {
const tailPackets = Math.min(24, packetTotal);
const tailStart = Math.max(0, packetTotal - tailPackets);
if (seq < tailStart) return 0;
const remaining = packetTotal - (seq + 1);
if (remaining >= 12) return 1;
if (remaining >= 6) return 2;
if (remaining >= 3) return 4;
return 8;
}
async function writeCtrlWithRetry(cmd, cfg) {
const maxAttempts = 8;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await state.ctrlChar.writeValue(cmd);
log('CTRL', `已写入 ${cfg.label} CTRL: ${toHex(cmd)}`);
return true;
} catch (err) {
const msg = String(err && err.message ? err.message : err);
if (/GATT operation already in progress/i.test(msg) && attempt < maxAttempts) {
const waitMs = Math.min(1200, 120 * attempt);
log('CTRL', `CTRL busy,${waitMs}ms 后重试 (${attempt}/${maxAttempts - 1})`, 'W');
await sleep(waitMs);
continue;
}
log('CTRL', `写 CTRL 失败: ${msg}`, 'E');
return false;
}
}
return false;
}
async function uploadFirmware() {
if (!state.selectedFile || !state.dataInChar) return false;
const cfg = currentTarget();
const payloadSize = Math.max(32, Math.min(498, Number(els.payloadSize.value) || 480));
const file = state.selectedFile;
const totalSize = file.size;
const packetTotal = Math.ceil(totalSize / payloadSize);
state.abortRequested = false;
state.uploading = true;
state.lastAckCmd = null;
state.lastAckSeq = -1;
state.lastAckBytes = 0;
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
setConnectionState(true);
try {
if (!state.notificationsEnabled) {
log('BLE', '上传前自动开启 DATA_OUT 通知...');
await enableNotifications();
}
if (!state.notificationsEnabled) {
throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式');
}
log('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`);
let fileBytes = state.selectedFileBytes;
if (!fileBytes || fileBytes.length !== totalSize) {
setProgress(0, `[${cfg.label}] 正在读取固件文件到内存...`);
fileBytes = await preloadSelectedFileBytes();
} else {
setProgress(0, `[${cfg.label}] 使用已缓存的固件数据...`);
log('OTA', `使用缓存固件: ${fileBytes.length} bytes`);
}
log('OTA', `开始写入 DATA_IN: target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
for (let windowStart = 0; windowStart < packetTotal; windowStart += BLE_OTA_ACK_WINDOW) {
if (state.abortRequested) throw new Error('用户中止');
const endSeq = Math.min(windowStart + BLE_OTA_ACK_WINDOW, packetTotal) - 1;
for (let seq = windowStart; seq <= endSeq; seq++) {
const start = seq * payloadSize;
const end = Math.min(start + payloadSize, totalSize);
const payload = fileBytes.slice(start, end);
const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd);
await state.dataInChar.writeValueWithoutResponse(packet);
const percent = ((seq + 1) / packetTotal) * 100;
els.sentCount.textContent = String(seq + 1);
setProgress(percent, `[${cfg.label}] 已写 ${seq + 1}/${packetTotal} 包,等待窗口 ACK`);
if (seq === 0 || seq === endSeq || (seq + 1) % 40 === 0 || seq === packetTotal - 1) {
log('OTA', `${cfg.label} 分包 ${seq + 1}/${packetTotal} 已发送`);
}
}
const ack = await waitForAck(cfg.packetCmd, endSeq, 10000);
if (ack.seq + 1 === packetTotal || ((ack.seq + 1) % (BLE_OTA_ACK_WINDOW * 5)) === 0) {
log('OTA', `${cfg.label} 窗口 ACK 已确认: seq=${ack.seq}, bytes=${ack.bytes}`);
}
await sleep(0);
}
setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
if (cfg.label === 'ESP32') {
log('OTA', 'ESP32 数据已写完,设备端将自动完成 OTA 并重启,无需再发 CTRL F8');
return true;
}
log('OTA', `${cfg.label} 数据写入完成,等待 GATT 队列排空...`);
await drainGattQueue();
log('OTA', `${cfg.label} 数据写入完成,可发送 CTRL ${cfg.ctrlHex}`);
return true;
} catch (err) {
log('OTA', `写入失败: ${err.message}`, 'E');
setProgress(0, `写入失败: ${err.message}`);
return false;
} finally {
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
state.uploading = false;
setConnectionState(!!state.server);
}
}
async function sendFinish(explicitHex = '', force = false) {
try {
const cfg = currentTarget();
if (cfg.label === 'ESP32') {
log('CTRL', 'ESP32 模式已改为设备端自动完成 OTA,不再发送 CTRL');
return true;
}
const cmdHex = explicitHex || els.ctrlHex.value || cfg.ctrlHex;
const cmd = hexToBytes(cmdHex);
await drainGattQueue();
return await writeCtrlWithRetry(cmd, cfg);
} catch (err) {
log('CTRL', `写 CTRL 失败: ${err.message}`, 'E');
return false;
}
}
async function autoFlash() {
if (!state.selectedFile || !state.server) return;
const cfg = currentTarget();
state.autoFlashing = true;
try {
log('AUTO', `开始一键刷写 -> ${cfg.label}`);
const ok = await uploadFirmware();
if (!ok || state.abortRequested) {
log('AUTO', '一键刷写中止', 'W');
return;
}
if (cfg.label === 'ESP32') {
setProgress(100, '[ESP32] 数据已写入,等待设备自动完成 OTA 并重启');
log('AUTO', 'ESP32 只走新固件流程:设备端自动完成 OTA 并重启,不再回退发送 CTRL F8');
return;
}
setProgress(100, `[${cfg.label}] 数据已写入,正在发送 CTRL ${cfg.ctrlHex}`);
const finishOk = await sendFinish(cfg.ctrlHex);
if (finishOk) {
log('AUTO', 'GD32 CTRL 已发送,已触发 OTA 中继');
}
} catch (err) {
log('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
setProgress(0, `一键刷写异常: ${err && err.message ? err.message : err}`);
} finally {
state.autoFlashing = false;
setConnectionState(!!state.server);
}
}
function abortTask() {
state.abortRequested = true;
log('OTA', '已请求中止,等待当前分包返回');
}
els.connectBtn.addEventListener('click', connectBle);
els.disconnectBtn.addEventListener('click', disconnectBle);
els.versionBtn.addEventListener('click', readVersion);
els.subscribeBtn.addEventListener('click', enableNotifications);
els.uploadBtn.addEventListener('click', uploadFirmware);
els.finishBtn.addEventListener('click', () => sendFinish());
els.autoFlashBtn.addEventListener('click', autoFlash);
els.abortBtn.addEventListener('click', abortTask);
els.firmwareFile.addEventListener('change', (e) => updateFileInfo(e.target.files[0] || null));
els.payloadSize.addEventListener('change', () => updateFileInfo(state.selectedFile));
els.targetChip.addEventListener('change', refreshTargetUi);
refreshTargetUi();
setConnectionState(false);
log('SYS', `Web Bluetooth 刷写台已就绪 (${UI_BUILD})`);
})();
</script>
</body>
</html>
+3
View File
@@ -61,6 +61,9 @@
<button class="btn btn-primary" style="width:100%; margin-top:8px;" onclick="restartEsp32()">
🔄 重启 ESP32
</button>
<a class="btn btn-secondary" href="ble_ota.html" style="width:100%; margin-top:8px; text-decoration:none; display:flex; align-items:center; justify-content:center;">
📶 打开 BLE 刷写页面
</a>
</div>
<!-- 文件上传 -->
+21
View File
@@ -0,0 +1,21 @@
@echo off
:: ESP32 COMM Host Server Launcher (Windows)
:: Double-click to start, then visit http://localhost:8080
set PORT=8080
echo ========================================
echo ESP32 COMM Host Server
echo PORT: %PORT%
echo ========================================
echo.
echo WiFi OTA: http://localhost:%PORT%/index.html
echo BLE OTA: http://localhost:%PORT%/ble_ota.html
echo.
echo Press Ctrl+C to stop
echo ========================================
echo.
cd /d "%~dp0"
python -m http.server %PORT%
pause