feat(ota): 添加OTA回滚保护和固件完整性校验功能
- 启用CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE和CONFIG_APP_ROLLBACK_ENABLE配置选项 - 在main.cpp中添加esp_ota_mark_app_valid_cancel_rollback()调用,防止意外回滚 - 实现SHA-256固件完整性校验,包括计算、存储和验证功能 - 添加ota_mutex.h头文件用于ESP32 OTA全局互斥锁管理 feat(health_monitor): 实现线程安全的健康状态管理 - 将HealthMonitor类的健康状态改为私有成员并通过mutex保护 - 添加getState()和setState()方法提供线程安全的状态访问 - 修复多任务并发访问健康状态时的竞争条件问题 feat(ota_relay): 增强中继模块的线程安全性和可靠性 - 为OtaRelay类添加静态mutex保护状态变量访问 - 实现StateLockGuard RAII锁管理器确保异常安全 - 添加全局超时机制防止OTA过程无限等待 - 改进flash写入逻辑支持非4字节对齐数据处理 refactor(app_config): 更新版本信息和配置常量 - 更新BUILD_TIME至最新编译时间 - 更新GIT_HASH至最新提交 - 添加BLE_OTA_CTRL_VERIFY_SHA256命令常量定义
This commit is contained in:
+1314
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,8 @@ CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y
|
||||
|
||||
# OTA
|
||||
CONFIG_ESP_OTA_IMG_VERIFICATION=y
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
CONFIG_APP_ROLLBACK_ENABLE=y
|
||||
|
||||
# HTTP server
|
||||
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32_COMM 高风险代码评审报告</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--bg-card: #1c2128;
|
||||
--border: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--critical: #f85149;
|
||||
--critical-bg: rgba(248,81,73,0.12);
|
||||
--critical-border: rgba(248,81,73,0.4);
|
||||
--high: #d29922;
|
||||
--high-bg: rgba(210,153,34,0.12);
|
||||
--high-border: rgba(210,153,34,0.4);
|
||||
--medium: #58a6ff;
|
||||
--medium-bg: rgba(88,166,255,0.12);
|
||||
--medium-border: rgba(88,166,255,0.4);
|
||||
--accent: #58a6ff;
|
||||
--success: #3fb950;
|
||||
--success-bg: rgba(63,185,80,0.12);
|
||||
--code-bg: #0d1117;
|
||||
--table-stripe: rgba(110,118,129,0.05);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', Helvetica, Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.report-header {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.report-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, #f85149, #d29922, #58a6ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.report-header .subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.report-meta span { display: flex; align-items: center; gap: 0.4rem; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.stat-card .number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-card.critical { border-color: var(--critical-border); }
|
||||
.stat-card.critical .number { color: var(--critical); }
|
||||
.stat-card.high { border-color: var(--high-border); }
|
||||
.stat-card.high .number { color: var(--high); }
|
||||
.stat-card.medium { border-color: var(--medium-border); }
|
||||
.stat-card.medium .number { color: var(--medium); }
|
||||
.stat-card.total { border-color: var(--accent); }
|
||||
.stat-card.total .number { color: var(--accent); }
|
||||
.stat-card.verified { border-color: var(--success); }
|
||||
.stat-card.verified .number { color: var(--success); }
|
||||
|
||||
/* Dimension Table */
|
||||
.section { margin-bottom: 3rem; }
|
||||
|
||||
.section-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.section-title .icon { font-size: 1.2rem; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:nth-child(even) td { background: var(--table-stripe); }
|
||||
|
||||
/* Severity Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.critical { background: var(--critical-bg); color: var(--critical); border: 1px solid var(--critical-border); }
|
||||
.badge.high { background: var(--high-bg); color: var(--high); border: 1px solid var(--high-border); }
|
||||
.badge.medium { background: var(--medium-bg); color: var(--medium); border: 1px solid var(--medium-border); }
|
||||
|
||||
/* Finding Cards */
|
||||
.finding-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.2rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.finding-card:hover { border-color: var(--text-muted); }
|
||||
|
||||
.finding-card.critical { border-left: 4px solid var(--critical); }
|
||||
.finding-card.high { border-left: 4px solid var(--high); }
|
||||
|
||||
.finding-header {
|
||||
padding: 1.2rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.finding-header:hover { background: var(--bg-tertiary); }
|
||||
|
||||
.finding-title-group { flex: 1; }
|
||||
|
||||
.finding-number {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.finding-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.finding-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.finding-body {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.finding-card.open .finding-body { display: block; }
|
||||
|
||||
.finding-body .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-value { color: var(--text-primary); }
|
||||
|
||||
.code-block {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 0.8rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-block .comment { color: #6a9955; }
|
||||
.code-block .keyword { color: #c586c0; }
|
||||
.code-block .type { color: #4ec9b0; }
|
||||
.code-block .string { color: #ce9178; }
|
||||
.code-block .highlight { background: rgba(248,81,73,0.15); display: inline; padding: 0.1em 0.2em; border-radius: 3px; }
|
||||
|
||||
.impact-box {
|
||||
background: rgba(248,81,73,0.06);
|
||||
border: 1px solid rgba(248,81,73,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.impact-box .impact-label {
|
||||
color: var(--critical);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.fix-box {
|
||||
background: rgba(63,185,80,0.06);
|
||||
border: 1px solid rgba(63,185,80,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.fix-box .fix-label {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* Architecture Section */
|
||||
.arch-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.arch-card h4 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.arch-card p { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.7; }
|
||||
|
||||
/* Priority Table */
|
||||
.priority-table { margin-top: 1rem; }
|
||||
|
||||
.priority-table .p0 { color: var(--critical); font-weight: 700; }
|
||||
.priority-table .p1 { color: var(--high); font-weight: 600; }
|
||||
.priority-table .p2 { color: var(--medium); font-weight: 500; }
|
||||
.priority-table .p3 { color: var(--text-secondary); }
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.progress-bar .fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.fill.critical-fill { background: var(--critical); }
|
||||
.fill.high-fill { background: var(--high); }
|
||||
.fill.medium-fill { background: var(--medium); }
|
||||
|
||||
/* Chart */
|
||||
.chart-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1.5rem;
|
||||
height: 200px;
|
||||
padding: 1rem 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-bar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.chart-bar-stack {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
border-radius: 6px 6px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-bar-stack > div {
|
||||
width: 100%;
|
||||
transition: height 0.6s ease;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Expand/Collapse all */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.report-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container { padding: 1rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.finding-header { flex-direction: column; }
|
||||
.finding-badges { justify-content: flex-start; }
|
||||
.chart-container { gap: 0.8rem; }
|
||||
.chart-bar-stack { width: 40px; }
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { background: white; color: black; }
|
||||
.finding-body { display: block !important; }
|
||||
.stat-card, .finding-card, .arch-card { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="report-header">
|
||||
<h1>ESP32_COMM 高风险代码评审报告</h1>
|
||||
<div class="subtitle">多维度并行审查 + 对抗性验证 · 4维度 × 33项确认发现</div>
|
||||
<div class="report-meta">
|
||||
<span>📋 项目: ESP32_COMM (ESP32-C3 OTA Relay)</span>
|
||||
<span>📅 日期: 2026-06-13</span>
|
||||
<span>🔌 固件版本: v0.3.12</span>
|
||||
<span>👥 审查Agent: 4 Review + 36 Verify + 1 Synthesize</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card total">
|
||||
<div class="number">33</div>
|
||||
<div class="label">确认发现</div>
|
||||
</div>
|
||||
<div class="stat-card critical">
|
||||
<div class="number">8</div>
|
||||
<div class="label">CRITICAL</div>
|
||||
</div>
|
||||
<div class="stat-card high">
|
||||
<div class="number">18</div>
|
||||
<div class="label">HIGH</div>
|
||||
</div>
|
||||
<div class="stat-card medium">
|
||||
<div class="number">4</div>
|
||||
<div class="label">MEDIUM</div>
|
||||
</div>
|
||||
<div class="stat-card verified">
|
||||
<div class="number">33/36</div>
|
||||
<div class="label">验证通过率</div>
|
||||
</div>
|
||||
<div class="stat-card total">
|
||||
<div class="number">2</div>
|
||||
<div class="label">已驳回(误报)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dimension Chart -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">📊</span> 各维度发现分布</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">8</div>
|
||||
<div class="chart-bar-stack" style="height:160px;">
|
||||
<div style="height:60px;background:var(--critical);" title="2 CRITICAL"></div>
|
||||
<div style="height:80px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:20px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">内存安全</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">10</div>
|
||||
<div class="chart-bar-stack" style="height:180px;">
|
||||
<div style="height:54px;background:var(--critical);" title="3 CRITICAL"></div>
|
||||
<div style="height:108px;background:var(--high);" title="6 HIGH"></div>
|
||||
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">并发/RTOS</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">9</div>
|
||||
<div class="chart-bar-stack" style="height:170px;">
|
||||
<div style="height:72px;background:var(--critical);" title="4 CRITICAL"></div>
|
||||
<div style="height:72px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:18px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">OTA/固件</div>
|
||||
</div>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-value">7</div>
|
||||
<div class="chart-bar-stack" style="height:150px;">
|
||||
<div style="height:43px;background:var(--critical);" title="2 CRITICAL"></div>
|
||||
<div style="height:86px;background:var(--high);" title="4 HIGH"></div>
|
||||
<div style="height:14px;background:var(--medium);" title="1 MEDIUM"></div>
|
||||
</div>
|
||||
<div class="chart-label">SPI/协议</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOP 3 Critical -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🚨</span> TOP 3 最危险 Bug</div>
|
||||
|
||||
<!-- Finding 1 -->
|
||||
<div class="finding-card critical open" id="f1">
|
||||
<div class="finding-header" onclick="toggleFinding('f1')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #1 — CRITICAL</div>
|
||||
<div class="finding-title">SPI 总线无并发保护 — 多任务竞争导致协议损坏</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">并发竞态</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">spi_master.h:73-142, ota_relay.cpp, wifi_ota.cpp</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">并发/RTOS</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<p>SpiMaster 是单例但<strong>没有任何互斥锁</strong>。多个 FreeRTOS 任务直接调用 SPI 方法:ota_relay 任务 (优先级 MAX-2)、wifi_ota 任务 (MAX-3)、health_monitor 任务 (MAX-4)。协议操作是多步非原子的(如 otaTransfer = 发送帧 + 轮询响应,CS在两次传输之间释放),高优先级任务可在协议序列中间抢占。</p>
|
||||
<div class="code-block"><span class="comment">// spi_master.h — 无任何mutex成员</span>
|
||||
<span class="keyword">class</span> <span class="type">SpiMaster</span> {
|
||||
<span class="keyword">private</span>:
|
||||
<span class="type">spi_device_handle_t</span> m_spiDevice = <span class="keyword">NULL</span>; <span class="comment">// ← 唯一成员,无锁</span>
|
||||
};</div>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
在 SpiMaster 中添加 <code>SemaphoreHandle_t m_mutex</code>,所有公共方法入口加锁。对于多步协议操作(otaTransfer等),使用统一的锁粒度保护整个请求-响应序列。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finding 2 -->
|
||||
<div class="finding-card critical" id="f2">
|
||||
<div class="finding-header" onclick="toggleFinding('f2')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #2 — CRITICAL</div>
|
||||
<div class="finding-title">硬编码占位密钥 — 认证体系形同虚设</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">安全/OTA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">app_config.h:107-115</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">OTA/固件完整性</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<p>MASTER_KEY 使用 NIST AES 测试向量,SCRAMBLE_CONST 使用简单递增序列,GD32_CHIP_UID 使用 0x00-0x0B。注释标注"需填入实际密钥"但从未替换。</p>
|
||||
<div class="code-block"><span class="comment">// ⚠️ 请填入实际密钥 ← 注释说"需填入"但从未填入</span>
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> MASTER_KEY[] = {
|
||||
<span class="highlight">0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF</span>, <span class="comment">// ← NIST AES测试向量!</span>
|
||||
<span class="highlight">0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10</span>
|
||||
};
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> SCRAMBLE_CONST[] = {
|
||||
<span class="highlight">0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88</span>, <span class="comment">// ← 简单递增序列</span>
|
||||
<span class="highlight">0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00</span>
|
||||
};
|
||||
<span class="keyword">inline constexpr</span> <span class="type">uint8_t</span> GD32_CHIP_UID[] = {
|
||||
<span class="highlight">0x00, 0x01, 0x02, 0x03, 0x04, 0x05</span>, <span class="comment">// ← 0x00-0x0B</span>
|
||||
<span class="highlight">0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B</span>
|
||||
};</div>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
从安全存储(NVS/efuse)读取密钥;移除所有硬编码占位值;添加编译期断言确保非测试密钥;MASTER_KEY和SCRAMBLE_CONST应在首次设备配准时由安全随机数生成。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Finding 3 -->
|
||||
<div class="finding-card critical" id="f3">
|
||||
<div class="finding-header" onclick="toggleFinding('f3')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #3 — CRITICAL</div>
|
||||
<div class="finding-title">Flash 写入无对齐检查 — 可能导致固件损坏</div>
|
||||
</div>
|
||||
<div class="finding-badges">
|
||||
<span class="badge critical">CRITICAL</span>
|
||||
<span class="badge" style="background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border);">内存安全/OTA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span>
|
||||
<span class="detail-value">ota_relay.cpp:308-320, wifi_ota.cpp:711/740, ble_ota.cpp:575</span>
|
||||
<span class="detail-label">维度:</span>
|
||||
<span class="detail-value">内存安全 + OTA完整性</span>
|
||||
<span class="detail-label">验证:</span>
|
||||
<span class="detail-value" style="color:var(--success);">✔ HIGH置信度 — 源码确认</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="type">esp_err_t</span> OtaRelay::flashWriteChunk(<span class="type">uint32_t</span> offset, <span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len)
|
||||
{
|
||||
<span class="comment">// ... 无对齐检查 ...</span>
|
||||
<span class="keyword">return</span> <span class="highlight">esp_partition_write</span>(instance().m_gd32Partition, offset, data, len); <span class="comment">// ← offset/len可能非4字节对齐</span>
|
||||
}</div>
|
||||
<p>两个调用路径产生非对齐值:(1) WiFi OTA: gd32_offset 从 httpd_req_recv 返回值累加,最后一个chunk是固件余数;(2) BLE OTA: data_len = recv_len - 11(7字节头+4字节CRC),不保证4字节对齐。esp_partition_write 要求 offset 和 size 均4字节对齐。</p>
|
||||
<div class="impact-box">
|
||||
<div class="impact-label">⚠ 影响</div>
|
||||
非对齐写入导致 ESP_ERR_INVALID_ARG 或触发 read-modify-write 异常,GD32固件最后一个chunk损坏或丢失。
|
||||
</div>
|
||||
<div class="fix-box">
|
||||
<div class="fix-label">🔧 修复建议</div>
|
||||
flashWriteChunk 内部做4字节对齐对齐(不足部分 read-modify-write),或在上层确保只传对齐参数。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other CRITICAL Findings -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🔴</span> 其他 CRITICAL 级发现</div>
|
||||
|
||||
<div class="finding-card critical" id="f4">
|
||||
<div class="finding-header" onclick="toggleFinding('f4')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #4 — CRITICAL</div>
|
||||
<div class="finding-title">BLE/WiFi 双通道 OTA 同时触发 — ESP32 OTA状态冲突</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.h:42-46, ble_ota.h</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<p>BleOta 和 WifiOta 各自维护独立的 m_esp32OtaActive、m_esp32OtaHandle、m_esp32UpdatePartition,但底层 esp_ota_begin/write/end 操作的是同一个OTA分区。两个通道同时触发时:第二个 esp_ota_begin 会失败或覆盖句柄;esp_ota_write 可能交错写入同一分区;导致固件损坏+变砖。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>添加全局 OTA 互斥标志,同一时刻只允许一个OTA源(BLE或WiFi)。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f5">
|
||||
<div class="finding-header" onclick="toggleFinding('f5')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #5 — CRITICAL</div>
|
||||
<div class="finding-title">SPI 协议帧仅用 XOR 伪校验 — 数据损坏不可检测</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">spi_master.cpp:134-141</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="type">uint8_t</span> SpiMaster::otaCrc8(<span class="keyword">const</span> <span class="type">uint8_t</span> *data, <span class="type">size_t</span> len) {
|
||||
<span class="type">uint8_t</span> crc = <span class="highlight">0</span>;
|
||||
<span class="keyword">for</span> (<span class="type">size_t</span> i = <span class="highlight">0</span>; i < len; ++i)
|
||||
<span class="highlight">crc ^= data[i]</span>; <span class="comment">// ← 简单XOR,非CRC8</span>
|
||||
<span class="keyword">return</span> crc;
|
||||
}</div>
|
||||
<p>XOR校验无法检测:偶数个相同位置的位翻转、字节交换、全零段丢失。固件数据的静默损坏几乎不可检测。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>替换为 CRC-8/MAXIM 或 CRC-16/CCITT,与GD32团队协商。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f6">
|
||||
<div class="finding-header" onclick="toggleFinding('f6')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #6 — CRITICAL</div>
|
||||
<div class="finding-title">SPI_OTA_SOF 与 SPI_APP_ENTER_BOOT_ACK_OK 值碰撞 (0xA5)</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">spi_master.h:21,26</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block"><span class="keyword">#define</span> <span class="highlight">SPI_OTA_SOF</span> (<span class="highlight">0xA5</span>) <span class="comment">// OTA帧起始标记</span>
|
||||
<span class="keyword">#define</span> <span class="highlight">SPI_APP_ENTER_BOOT_ACK_OK</span> (<span class="highlight">0xA5</span>) <span class="comment">// APP确认进入Bootloader</span></div>
|
||||
<p>同一值 0xA5 表示两种完全不同的语义。GD32在OTA窗口回复ACK时,ESP32可能将ACK字节误解析为OTA帧起始,导致协议状态机混乱。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>修改其中一个常量值,消除歧义。例如将 SPI_APP_ENTER_BOOT_ACK_OK 改为 0xB5。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f7">
|
||||
<div class="finding-header" onclick="toggleFinding('f7')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #7 — CRITICAL</div>
|
||||
<div class="finding-title">OTA 会话无全局超时 — 可无限挂起</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">ota_relay.cpp:76-91,562; ota_relay.h:59</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<p>waitUntilNotWait() 每次调用有 max_wait_rounds 上限,但整个 forwardFirmware() 流程没有全局超时。GD32 持续返回 WAIT 时,单个 SEND_SIZE阶段可挂起 ~78秒,完整流程可挂起数分钟。OTA_RESULT_ERR_USER_ABORT 已定义但从未使用。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>在 forwardFirmware() 入口记录开始时间,每个阶段检查全局超时(如5分钟),超时则中止并清理。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finding-card critical" id="f8">
|
||||
<div class="finding-header" onclick="toggleFinding('f8')">
|
||||
<div class="finding-title-group">
|
||||
<div class="finding-number">Finding #8 — CRITICAL</div>
|
||||
<div class="finding-title">HTTP CORS 允许所有来源 — 任意网页可触发 OTA</div>
|
||||
</div>
|
||||
<div class="finding-badges"><span class="badge critical">CRITICAL</span></div>
|
||||
</div>
|
||||
<div class="finding-body">
|
||||
<div class="detail-grid">
|
||||
<span class="detail-label">文件:</span><span class="detail-value">wifi_ota.cpp:58</span>
|
||||
<span class="detail-label">验证:</span><span class="detail-value" style="color:var(--success);">✔ HIGH置信度</span>
|
||||
</div>
|
||||
<div class="code-block">httpd_resp_set_hdr(req, <span class="string">"Access-Control-Allow-Origin"</span>, <span class="highlight">"*"</span>);</div>
|
||||
<p>任何网页都可以向 ESP32 发起 OTA 请求。结合占位密钥问题,攻击链完整:恶意网页 → 上传伪造固件 → 通过占位密钥认证 → 烧录恶意GD32固件。</p>
|
||||
<div class="fix-box"><div class="fix-label">🔧 修复建议</div>限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HIGH Findings -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🟠</span> HIGH 级关键发现</div>
|
||||
<div class="toolbar">
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">共 18 项 HIGH 级发现,点击展开详情</span>
|
||||
<button onclick="toggleAll('high')">展开/折叠全部</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>严重度</th><th>文件</th><th>问题</th><th>影响</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>9</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:121</code></td><td>WiFi事件回调中调用 esp_wifi_connect() 重连</td><td>在系统事件任务中调用可能死锁</td></tr>
|
||||
<tr><td>10</td><td><span class="badge high">HIGH</span></td><td><code>health_monitor.cpp</code></td><td>healthState 静态变量无锁跨任务读写</td><td>竞态导致健康判断错误</td></tr>
|
||||
<tr><td>11</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:80-84</code></td><td>5个静态变量(relayState等)无锁跨任务访问</td><td>协议状态不一致</td></tr>
|
||||
<tr><td>12</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.h:33-47</code></td><td>WifiOta 所有成员 public 且无锁</td><td>HTTP回调与WiFi事件回调竞争</td></tr>
|
||||
<tr><td>13</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.h</code></td><td>BleOta 成员无锁,BLE GATT回调与主任务竞争</td><td>BLE OTA 数据损坏</td></tr>
|
||||
<tr><td>14</td><td><span class="badge high">HIGH</span></td><td><code>debug_log.cpp:296</code></td><td>log() 栈分配 msg[512] + log_buf[600] = 1112字节</td><td>低栈任务中栈溢出</td></tr>
|
||||
<tr><td>15</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:1008</code></td><td>spi_send_handler 栈分配 1728+ 字节</td><td>HTTP server栈仅8KB,接近溢出</td></tr>
|
||||
<tr><td>16</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.cpp:456</code></td><td>sendNonceAndAuthenticate() nonce 仅用 esp_random() 一次</td><td>认证会话密钥可预测</td></tr>
|
||||
<tr><td>17</td><td><span class="badge high">HIGH</span></td><td><code>ota_relay.h:25</code></td><td>ota_meta_t.checksum[32] 从未填充/验证</td><td>固件完整性无保障</td></tr>
|
||||
<tr><td>18</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:429-444</code></td><td>ESP32 OTA 完成→重启,无回滚验证</td><td>新固件若无法启动将变砖</td></tr>
|
||||
<tr><td>19</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.cpp:258</code></td><td>syncTransfer/recv 忽略 timeout_ms 参数</td><td>SPI事务可能无限等待</td></tr>
|
||||
<tr><td>20</td><td><span class="badge high">HIGH</span></td><td><code>wifi_ota.cpp:599-629</code></td><td>esp_ota_write 失败后 break,未调用 esp_ota_abort</td><td>OTA分区处于不一致状态</td></tr>
|
||||
<tr><td>21</td><td><span class="badge high">HIGH</span></td><td><code>ble_ota.cpp:439-444</code></td><td>BLE OTA 帧预留4字节CRC但减去计算不匹配</td><td>最后几个字节被误截断</td></tr>
|
||||
<tr><td>22</td><td><span class="badge high">HIGH</span></td><td><code>spi_master.h</code></td><td>SPI仅4线,无硬件流控/就绪信号</td><td>高速传输时GD32来不及处理</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Architecture Concerns -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🏗</span> 架构级隐患</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>① 全局状态泛滥,无封装无同步</h4>
|
||||
<p>项目中有 <strong>5个静态全局变量</strong> (relayState, relaySeq, relayTotalPackets, gd32AppAlive, gd32Version) 和 <strong>大量public成员</strong> (WifiOta/BleOta所有状态),跨越3+个FreeRTOS任务和多个ISR/callback上下文访问,<strong>没有任何一个</strong>被mutex/atomic/critical section保护。这是系统性问题,不是个别bug。</p>
|
||||
</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>② 单通道SPI + 多任务无仲裁</h4>
|
||||
<p>SpiMaster 单例是唯一的SPI通信通道,但没有任何仲裁机制。WiFi/BLE/健康监控/OTA中继四个功能模块各自直接调用,协议级操作(请求-响应对)不是原子的。需要从架构层面引入SPI总线仲裁器。</p>
|
||||
</div>
|
||||
|
||||
<div class="arch-card">
|
||||
<h4>③ 安全架构不完整</h4>
|
||||
<p>认证握手存在但密钥硬编码→形同虚设;固件校验字段存在但未填充→形同虚设;CORS开放→任何人可触发OTA。<strong>安全功能"看起来有"但实际上不提供保护</strong>,这种"安全假象"比没有安全功能更危险,因为它给了虚假的信心。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Fix Order -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">🛠</span> 优先修复顺序</div>
|
||||
<table class="priority-table">
|
||||
<thead>
|
||||
<tr><th>优先级</th><th>修复项</th><th>工作量</th><th>紧急度</th><th>进度</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>添加 SPI 总线互斥锁</td>
|
||||
<td>小</td>
|
||||
<td>极高 — 生产环境必崩</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>替换硬编码密钥为安全存储</td>
|
||||
<td>中</td>
|
||||
<td>极高 — 安全漏洞</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p0">P0</td>
|
||||
<td>Flash 写入对齐检查</td>
|
||||
<td>小</td>
|
||||
<td>极高 — 数据损坏</td>
|
||||
<td><div class="progress-bar"><div class="fill critical-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>添加全局 OTA 超时+中止机制</td>
|
||||
<td>中</td>
|
||||
<td>高 — 设备挂死</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>ESP32 OTA 添加回滚验证</td>
|
||||
<td>中</td>
|
||||
<td>高 — 变砖风险</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>替换 XOR 为 CRC-8/16 校验</td>
|
||||
<td>小</td>
|
||||
<td>高 — 固件损坏</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p1">P1</td>
|
||||
<td>消除 0xA5 值碰撞</td>
|
||||
<td>极小</td>
|
||||
<td>高 — 协议混乱</td>
|
||||
<td><div class="progress-bar"><div class="fill high-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>添加 OTA 单源互斥 (BLE/WiFi)</td>
|
||||
<td>中</td>
|
||||
<td>中高</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>为所有跨任务状态添加锁/atomic</td>
|
||||
<td>大</td>
|
||||
<td>中高</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p2">P2</td>
|
||||
<td>修复 WiFi 事件回调中的重连调用</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill medium-fill" style="width:0%"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>修复大栈分配问题</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>限制 CORS 来源</td>
|
||||
<td>小</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p3">P3</td>
|
||||
<td>实现 ota_meta_t 校验填充与验证</td>
|
||||
<td>中</td>
|
||||
<td>中</td>
|
||||
<td><div class="progress-bar"><div class="fill" style="width:0%;background:var(--text-muted);"></div></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Dismissed -->
|
||||
<div class="section">
|
||||
<div class="section-title"><span class="icon">✅</span> 已驳回发现(误报)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>原始严重度</th><th>问题描述</th><th>驳回原因</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="badge critical" style="text-decoration:line-through;opacity:0.5;">CRITICAL</span></td>
|
||||
<td>SPI DMA缓冲区栈分配导致数据损坏/硬Fault</td>
|
||||
<td>ESP-IDF 6.0 的 spi_master 驱动内置 <code>setup_dma_priv_buffer()</code> 函数自动检测并对齐DMA缓冲区,栈分配仅导致性能损失,不引发数据损坏</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge high" style="text-decoration:line-through;opacity:0.5;">HIGH</span></td>
|
||||
<td>TOCTOU: relayState 检查后 reset() 无锁</td>
|
||||
<td>虽然模式存在,但当前代码流程中检查和重置在同一线程同步执行,实际竞态窗口极小,风险被高估</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="report-footer">
|
||||
<p>ESP32_COMM 高风险代码评审报告 · 由 4维并行审查 + 36项对抗性验证 生成 · 2026-06-13</p>
|
||||
<p style="margin-top:0.5rem;">审查维度: 内存安全 · 并发/RTOS · OTA/固件完整性 · SPI通信/协议</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleFinding(id) {
|
||||
const card = document.getElementById(id);
|
||||
if (card) card.classList.toggle('open');
|
||||
}
|
||||
|
||||
let allHighOpen = false;
|
||||
function toggleAll() {
|
||||
allHighOpen = !allHighOpen;
|
||||
document.querySelectorAll('.finding-card').forEach(card => {
|
||||
if (allHighOpen) card.classList.add('open');
|
||||
else card.classList.remove('open');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,316 @@
|
||||
# BLE OTA 刷写修复测试计划
|
||||
|
||||
> 对应修复:问题1(偶发丢包检测错误)+ 问题2(通道占用延迟)
|
||||
> 涉及文件:`ble_ota.h` / `ble_ota.cpp` / `ble_ota.html`
|
||||
|
||||
---
|
||||
|
||||
## 一、正常流程测试
|
||||
|
||||
### T-01 标准 ESP32 OTA 全流程(基线回归)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 固件大小 | 使用常规固件(~200KB–1MB) |
|
||||
| 操作步骤 | BLE 连接 → 选择文件 → 一键刷写 |
|
||||
| 预期结果 | 刷写完成,设备重启,日志无报错 |
|
||||
|
||||
**验证要点:**
|
||||
- 每个窗口 ACK 字节数与期望完全匹配
|
||||
- 日志中出现 `已写 X/2035 包` 进度连续递增
|
||||
- 日志出现 `[FINISH] ESP32 OTA 刷写完成,等待重启` + toast 成功提示
|
||||
- 设备重启后 `readVersion` 返回新版本号
|
||||
|
||||
### T-02 标准 GD32 OTA 中继全流程(回归验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 目标 | GD32(经 ESP32 中继 SPI 转发) |
|
||||
| 操作步骤 | BLE 连接 → 选择 GD32 固件 → 一键刷写 |
|
||||
| 预期结果 | ESP32 中继正常,GD32 收到完整固件 |
|
||||
|
||||
**验证要点:**
|
||||
- ESP32 端去重逻辑仅作用于 `BLE_OTA_CMD_ESP32_DATA`(0x01),不影响 GD32 通道(0x02)
|
||||
- GD32 窗口 ACK 正常返回
|
||||
- 刷写完成后 CTRL 命令触发成功
|
||||
|
||||
### T-03 尾包非整窗口场景
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | `packetTotal % BLE_OTA_ACK_WINDOW != 0`,如固件 976752 字节 / 480 = 2035 包,最后一个窗口仅 19 包(seq 2016–2034) |
|
||||
| 预期 | 最后一个窗口的 `endSeq = 2034`,ACK 正常返回 |
|
||||
|
||||
**验证要点:**
|
||||
- `expectedBytes = Math.min((endSeq+1)*480, totalSize)` 正确截断
|
||||
- 尾包 `data_len < BLE_OTA_CHUNK_PAYLOAD` 触发 `isLastPacket`
|
||||
- 设备创建收尾任务,发送 `[FINISH] 即将执行 esp_ota_end()`
|
||||
|
||||
### T-04 多窗口连续刷写稳定性
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 固件 > 500KB(至少 16 个窗口) |
|
||||
| 操作 | 连续执行 3 次完整刷写 |
|
||||
| 预期 | 每次均成功,无丢包报错 |
|
||||
|
||||
**验证要点:**
|
||||
- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中正确重置为 0
|
||||
- 旧 OTA 会话被先中止(日志出现 `检测到未完成的 ESP32 OTA,会先中止旧会话`)
|
||||
- 每次刷写的 `m_esp32Received` 从 0 开始累加
|
||||
|
||||
---
|
||||
|
||||
## 二、边界条件测试
|
||||
|
||||
### T-05 固件大小恰好为窗口整数倍
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 构造固件大小 = N × 32 × 480,如 15360 字节(1 窗口)或 491520 字节(32 窗口) |
|
||||
| 预期 | 无尾包,最后一个窗口 `endSeq = packetTotal - 1`,`expectedBytes = totalSize` |
|
||||
|
||||
**验证要点:**
|
||||
- 最后一个包 `data_len == BLE_OTA_CHUNK_PAYLOAD`,不触发 `isLastPacket`
|
||||
- 完成条件走 `m_esp32Received >= total_size` 分支
|
||||
|
||||
### T-06 固件极小(单包/小于窗口)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 固件 < 480 字节(仅 1 包)或 480–15360 字节(1 窗口内) |
|
||||
| 预期 | 单包即触发尾包检测或窗口 ACK |
|
||||
|
||||
**验证要点:**
|
||||
- 单包场景:`seq=0` 同时满足 `(seq+1)%32==0` 否、但 `isLastPacket` 是
|
||||
- 设备发送 ACK,`m_esp32Received` 等于实际固件大小
|
||||
- `expectedBytes` 正确等于 `totalSize`
|
||||
|
||||
### T-07 固件极大(接近分区上限)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 固件大小接近 OTA 分区 size(如 ~1.5MB) |
|
||||
| 预期 | 完整刷写成功,约 50+ 个窗口全部通过 |
|
||||
|
||||
**验证要点:**
|
||||
- `m_esp32ExpectedSeq` 跨窗口持续递增,不会溢出 `uint16_t`(最大值 65535)
|
||||
- `ble_esp32_ota_begin` 中 `total_size > check_partition->size` 检查仍正确拒绝超大固件
|
||||
|
||||
### T-08 payloadSize 非默认值
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 条件 | 在页面修改 payload 大小为 256 / 400 / 498 |
|
||||
| 预期 | 包总数自动调整,每窗口 ACK 字节校验正确 |
|
||||
|
||||
**验证要点:**
|
||||
- 设备端 `BLE_OTA_CHUNK_PAYLOAD = 480` 是固定值
|
||||
- 若 Web 端 payload < 480,设备端 `remaining_after_header < BLE_OTA_CHUNK_PAYLOAD + 4` 会走 `data_len = remaining - 4` 分支
|
||||
- 若 Web 端 payload > 480,设备端会按 480 解析,剩余数据被当作下一包处理(可能出错)
|
||||
- **注意:** payload 大小必须与设备端 `BLE_OTA_CHUNK_PAYLOAD` 一致,此为已知约束
|
||||
|
||||
### T-09 BLE 断开重连后重新刷写
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 刷写到一半 → 手动断开 BLE → 重新连接 → 再次刷写 |
|
||||
| 预期 | 新刷写从 `ble_esp32_ota_begin` 重新开始 |
|
||||
|
||||
**验证要点:**
|
||||
- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中重置为 0
|
||||
- 旧 OTA 会话被中止,`m_esp32OtaActive` 被清除
|
||||
- 全局互斥 `esp32_ota_mutex_release` 被正确调用
|
||||
|
||||
---
|
||||
|
||||
## 三、故障注入测试
|
||||
|
||||
### T-10 BLE 链路层偶发丢包自动恢复(核心修复验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 在 BLE 信号较弱的环境中刷写(距离远/有干扰),或临时将包间延迟移除后快速发送 |
|
||||
| 预期 | 检测到丢包 → 自动重发窗口 → 恢复成功 |
|
||||
|
||||
**验证要点(日志检查):**
|
||||
- 出现 `丢包检测: seq=X 丢失Y字节(Z包),第1次重发整个窗口` 警告日志
|
||||
- 重发后 ACK 字节数匹配,继续下一窗口
|
||||
- 设备端日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y`
|
||||
- 最终刷写成功,无数据损坏
|
||||
|
||||
### T-11 单窗口连续 2 次丢包(重试用尽)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 极端信号劣化,使一个窗口连续 3 次发送都有丢包 |
|
||||
| 预期 | 2 次重试用尽后抛出错误 |
|
||||
|
||||
**验证要点:**
|
||||
- 日志出现第 1 次、第 2 次重发记录
|
||||
- 最终报错:`BLE 丢包检测: seq=X 期望Y字节 实际Z字节 丢失W字节(V包) 已重试2次`
|
||||
- `uploadFirmware` 返回 `false`,toast 显示失败
|
||||
- `state.lastUploadStatus = 'failed'`
|
||||
- 用户手动重新刷写可成功
|
||||
|
||||
### T-12 通道占用 → 立即失败(核心修复验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 先通过 WiFi 通道启动 ESP32 OTA(占用互斥锁),然后通过 BLE 发起刷写 |
|
||||
| 预期 | BLE 刷写在 < 1 秒内失败,而非等待 10 秒超时 |
|
||||
|
||||
**验证要点(日志检查):**
|
||||
- 设备端日志:`ESP32 OTA 被其他通道占用,拒绝 BLE OTA begin`
|
||||
- Web 端日志按顺序出现:
|
||||
1. `[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝`
|
||||
2. Toast 弹出 `ESP32 OTA 被其他通道占用,请稍后重试`
|
||||
3. `写入失败: 设备端 FINISH 错误: stage=0x83 value=0`
|
||||
4. `[AUTO] 一键刷写中止`
|
||||
- **关键时序**:从 FINISH 通知到写入失败之间 < 500ms(此前需要 10 秒)
|
||||
- 不应出现 `等待 ACK 超时` 日志
|
||||
|
||||
### T-13 通道占用后重试成功
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | T-12 失败后 → 等待 WiFi OTA 完成 → 重新 BLE 刷写 |
|
||||
| 预期 | 互斥锁已释放,BLE 刷写成功 |
|
||||
|
||||
**验证要点:**
|
||||
- `esp32_ota_mutex_release` 在 WiFi OTA 完成后被调用
|
||||
- `g_esp32OtaOccupied` 被清除为 `false`
|
||||
- BLE 刷写正常走完全流程
|
||||
|
||||
### T-14 esp_ota_end / set_boot_partition 失败快速中断
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 注入方式 | 使用损坏的固件文件(无有效 ESP32 镜像头),使 `esp_ota_end` 返回错误 |
|
||||
| 预期 | 设备发送 `FINISH stage=0x81`,Web 端立即报错 |
|
||||
|
||||
**验证要点:**
|
||||
- Web 端收到 FINISH 0x81,toast `ESP32 OTA 刷写失败`
|
||||
- `ackWaiter` 被 reject(如果此时有等待中的 ACK)
|
||||
- `uploadFirmware` 快速返回 `false`
|
||||
- 设备端 `esp_ota_abort` 被调用,互斥锁释放
|
||||
|
||||
### T-15 刷写过程中用户中止
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 刷写进行中点击「中止」按钮 |
|
||||
| 预期 | 当前窗口完成后抛出 `用户中止` 错误 |
|
||||
|
||||
**验证要点:**
|
||||
- `state.abortRequested = true` 在窗口循环开头被检测
|
||||
- `throw new Error('用户中止')` 被触发
|
||||
- `finally` 块清理 `ackWaiter`、设置 `state.uploading = false`
|
||||
- 设备端 OTA 会话在下次刷写时被 `ble_esp32_ota_begin` 清理
|
||||
|
||||
### T-16 窗口末尾丢包恢复(最常见场景)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 场景 | 窗口 0(seq 0–31),其中 seq=31 在首次发送时丢失 |
|
||||
| 预期 | 重发整个窗口后,seq=0–30 被跳过(重复包),seq=31 写入成功 |
|
||||
|
||||
**验证要点:**
|
||||
- 首次发送:seq=0–30 写入成功,`m_esp32ExpectedSeq = 31`
|
||||
- seq=31 丢失,设备未收到,不发送 ACK(因为 ACK 触发条件在 seq=31)
|
||||
- 主机 waitForAck 超时 → 触发窗口重发
|
||||
- 重发时 seq=0 到 seq=30:`seq < m_esp32ExpectedSeq(31)` → 重复包跳过
|
||||
- 重发时 seq=31:`seq == expectedSeq(31)` → 写入成功,`expectedSeq` 变为 32
|
||||
- 触发 ACK,`m_esp32Received = 32 × 480 = 15360`,匹配期望值
|
||||
- 设备日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y`
|
||||
|
||||
### T-17 窗口中间丢包恢复(严格顺序检测验证)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 场景 | 窗口 0(seq 0–31),其中 seq=15 在首次发送时丢失,seq=16–31 正常到达 |
|
||||
| 预期 | 乱序包被拒绝,重发后缺失包被填补 |
|
||||
|
||||
**验证要点:**
|
||||
- 首次发送:seq=0–14 写入成功,`m_esp32ExpectedSeq = 15`
|
||||
- seq=15 丢失
|
||||
- seq=16 到达:`seq(16) > expectedSeq(15)` → **乱序包跳过**,设备日志出现 `ESP32 OTA 乱序包,跳过: seq=16 expected=15 (前序包丢失)`
|
||||
- seq=17–31 同样被跳过(乱序)
|
||||
- seq=31 被跳过但仍触发 ACK(因 `(seq+1)%32==0`),ACK 回报 `m_esp32Received = 15 × 480 = 7200`
|
||||
- 主机检测到 7200 < 15360,触发重发
|
||||
- 重发:seq=0–14 跳过(重复),seq=15 写入(`expectedSeq` 变为 16),seq=16–31 写入
|
||||
- 最终 `m_esp32Received = 15360`,匹配期望值
|
||||
- 最终刷写成功,固件数据完整
|
||||
|
||||
### T-18 窗口内多包丢失跨轮次恢复
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 场景 | 窗口内 seq=5 和 seq=20 同时丢失 |
|
||||
| 预期 | 第 1 次重发填补 seq=5,第 2 次重发填补 seq=20 |
|
||||
|
||||
**验证要点:**
|
||||
- 首次:seq=0–4 写入,seq=5 丢失,seq=6–31 被拒绝(乱序),ACK 报 2400 字节
|
||||
- 第 1 次重发:seq=0–4 跳过,seq=5 写入,seq=6–19 写入,seq=20 丢失,seq=21–31 拒绝,ACK 报 9600 字节
|
||||
- 第 2 次重发:seq=0–19 跳过,seq=20 写入,seq=21–31 写入,ACK 报 15360 字节
|
||||
- 2 次重试内完成(`MAX_WINDOW_RETRIES = 2`),刷写成功
|
||||
|
||||
---
|
||||
|
||||
## 四、性能与回归测试
|
||||
|
||||
### T-19 包间延迟对总刷写时间的影响
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 测量 | 记录完整刷写耗时(修改前 vs 修改后) |
|
||||
| 预期 | 每窗口增加 ~62ms(31 × 2ms),64 窗口约增加 ~4 秒,可接受 |
|
||||
|
||||
**参考计算:**
|
||||
- 2035 包 ÷ 32 包/窗口 ≈ 64 窗口
|
||||
- 每窗口 31 个间隔 × 2ms = 62ms
|
||||
- 总额外延迟 ≈ 64 × 62ms ≈ 3.97 秒
|
||||
- 总刷写时间预计从 ~30 秒增加到 ~34 秒
|
||||
|
||||
### T-20 连续多次刷写无内存泄漏
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 连续刷写 5 次(每次完整流程) |
|
||||
| 预期 | 设备 free heap 无明显下降 |
|
||||
|
||||
**验证要点:**
|
||||
- `ble_esp32_ota_begin` 中旧会话清理完整(abort + mutex release + 状态重置)
|
||||
- `m_esp32ExpectedSeq` 每次重置,不会累积
|
||||
- 收尾任务(`ble_esp32_finish_and_reboot_task`)正常删除
|
||||
|
||||
### T-21 刷写完成后版本验证
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 操作 | 刷写完成 → 设备重启 → 重新 BLE 连接 → 读取版本号 |
|
||||
| 预期 | 版本号与新固件匹配 |
|
||||
|
||||
**验证要点:**
|
||||
- `esp_ota_set_boot_partition` 指向新分区
|
||||
- 设备从新分区启动
|
||||
- `readVersion` 返回 `version.h` 中定义的版本号
|
||||
|
||||
---
|
||||
|
||||
## 五、修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `main/server/ble_ota.h` | 新增 `m_esp32ExpectedSeq` 字段,用于跟踪下一个期望的包序号 |
|
||||
| `main/server/ble_ota.cpp` | `ble_esp32_ota_begin` 重置期望序号;DATA_IN 回调添加严格顺序检测(拒绝重复包 + 拒绝乱序包) |
|
||||
| `tools/ble_ota.html` | 包间 2ms 延迟;整窗口重传重试(最多 2 次,50ms 重发前等待);FINISH 错误事件立即 reject ackWaiter |
|
||||
|
||||
## 六、测试优先级
|
||||
|
||||
| 优先级 | 用例 | 说明 |
|
||||
|--------|------|------|
|
||||
| P0 | T-01, T-10, T-12, T-16, T-17 | 核心功能回归 + 两个修复的直接验证 + 丢包恢复场景 |
|
||||
| P1 | T-03, T-09, T-13, T-15, T-18 | 边界场景 + 恢复路径 + 多包丢失 |
|
||||
| P2 | T-05, T-06, T-07, T-11, T-14 | 极端边界 + 重试耗尽 |
|
||||
| P3 | T-02, T-04, T-08, T-19, T-20, T-21 | 回归 + 性能 + 长期稳定性 |
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "esp_system.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_ota_ops.h"
|
||||
|
||||
#include "monitor/health_monitor.h"
|
||||
#include "relay/ota_relay.h"
|
||||
@@ -19,15 +20,25 @@
|
||||
#include "server/ble_ota.h"
|
||||
#include "transport/protocol.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "app_config.h"
|
||||
#include "ota_mutex.h"
|
||||
|
||||
/**
|
||||
* @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。
|
||||
*/
|
||||
// 全局 ESP32 OTA 互斥变量(声明在 ota_mutex.h)
|
||||
SemaphoreHandle_t g_esp32OtaMutex = NULL;
|
||||
volatile bool g_esp32OtaOccupied = false;
|
||||
|
||||
extern "C" void app_main(void)
|
||||
{
|
||||
esp_chip_info_t chip_info;
|
||||
esp_chip_info(&chip_info);
|
||||
|
||||
// 创建 ESP32 OTA 全局互斥 Mutex
|
||||
g_esp32OtaMutex = xSemaphoreCreateMutex();
|
||||
g_esp32OtaOccupied = false;
|
||||
|
||||
// 初始化网络接口 + 事件循环(必须最早)
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
@@ -123,6 +134,16 @@ extern "C" void app_main(void)
|
||||
DEBUG_INFO("MAIN", "BLE OTA 任务已创建");
|
||||
}
|
||||
|
||||
// ★ OTA 回滚保护:标记当前固件有效
|
||||
// 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数
|
||||
// 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区
|
||||
esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback();
|
||||
if (ota_valid_err == ESP_OK) {
|
||||
DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效");
|
||||
} else {
|
||||
DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动", esp_err_to_name(ota_valid_err));
|
||||
}
|
||||
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中");
|
||||
DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* app_config.h - 统一配置文件(C++ 版本)
|
||||
* ESP32-C3 OTA 中继项目
|
||||
*
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "version.h"
|
||||
|
||||
// ============================================================
|
||||
@@ -95,6 +96,7 @@ 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_CTRL_VERIFY_SHA256 = 0xF9; // SHA-256哈希验证命令
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* ota_mutex.h - ESP32 OTA 全局互斥(BLE / WiFi 通道共享)
|
||||
*
|
||||
* 同一时刻只允许一个 OTA 源(BLE 或 WiFi)操作 ESP32 OTA 分区。
|
||||
* 变量定义 + Mutex 创建在 main.cpp app_main()。
|
||||
*/
|
||||
#pragma once
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <cstdint>
|
||||
|
||||
extern SemaphoreHandle_t g_esp32OtaMutex;
|
||||
extern volatile bool g_esp32OtaOccupied;
|
||||
|
||||
// 获取全局 OTA 占用标志(带超时),成功返回 true
|
||||
// 内部: Take Mutex → 检查标志 → 若空闲则置 true → Give Mutex
|
||||
static inline bool esp32_ota_mutex_acquire(uint32_t timeout_ms) {
|
||||
if (!g_esp32OtaMutex) return false;
|
||||
if (xSemaphoreTake(g_esp32OtaMutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) return false;
|
||||
if (g_esp32OtaOccupied) {
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
return false;
|
||||
}
|
||||
g_esp32OtaOccupied = true;
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 释放全局 OTA 占用标志
|
||||
// 内部: Take Mutex → 清除标志 → Give Mutex
|
||||
static inline void esp32_ota_mutex_release(void) {
|
||||
if (!g_esp32OtaMutex) return;
|
||||
xSemaphoreTake(g_esp32OtaMutex, portMAX_DELAY);
|
||||
g_esp32OtaOccupied = false;
|
||||
xSemaphoreGive(g_esp32OtaMutex);
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
#define FW_VERSION_STRING "0.3.12"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-06-13 01:22:52"
|
||||
#define BUILD_TIME "2026-06-14 17:05:57"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "d8e4222"
|
||||
#define GIT_HASH "6a6bc9d"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控
|
||||
*
|
||||
* 所有功能封装为 HealthMonitor 类方法。
|
||||
* 所有对 m_healthState / m_lastTemperature / m_lastWifiRssi 的访问
|
||||
* 均通过 m_mutex 保护,消除跨任务竞态。
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
@@ -18,11 +20,6 @@
|
||||
#include "debug_log.h"
|
||||
#include "server/wifi_ota.h"
|
||||
|
||||
// ============================================================
|
||||
// 静态成员
|
||||
// ============================================================
|
||||
health_state_t HealthMonitor::healthState = SYS_NORMAL;
|
||||
|
||||
// ============================================================
|
||||
// 单例
|
||||
// ============================================================
|
||||
@@ -44,36 +41,54 @@ void HealthMonitor::init()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 获取当前健康状态
|
||||
// 线程安全的健康状态读写
|
||||
// ============================================================
|
||||
health_state_t HealthMonitor::getState()
|
||||
{
|
||||
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
|
||||
health_state_t s = m_healthState;
|
||||
if (m_mutex) xSemaphoreGive(m_mutex);
|
||||
return s;
|
||||
}
|
||||
|
||||
void HealthMonitor::setState(health_state_t newState)
|
||||
{
|
||||
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
|
||||
m_healthState = newState;
|
||||
if (m_mutex) xSemaphoreGive(m_mutex);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 获取当前健康状态(public 接口)
|
||||
// ============================================================
|
||||
health_state_t HealthMonitor::getCurrent()
|
||||
{
|
||||
health_state_t state;
|
||||
if (m_mutex) {
|
||||
xSemaphoreTake(m_mutex, portMAX_DELAY);
|
||||
}
|
||||
state = healthState;
|
||||
if (m_mutex) {
|
||||
xSemaphoreGive(m_mutex);
|
||||
}
|
||||
return state;
|
||||
return getState();
|
||||
}
|
||||
|
||||
float HealthMonitor::getLastTemperature()
|
||||
{
|
||||
return m_lastTemperature;
|
||||
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
|
||||
float t = m_lastTemperature;
|
||||
if (m_mutex) xSemaphoreGive(m_mutex);
|
||||
return t;
|
||||
}
|
||||
|
||||
bool HealthMonitor::getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms)
|
||||
{
|
||||
if (m_lastWifiRssiMs <= 0) {
|
||||
if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY);
|
||||
int64_t lastMs = m_lastWifiRssiMs;
|
||||
int8_t rssi = m_lastWifiRssi;
|
||||
if (m_mutex) xSemaphoreGive(m_mutex);
|
||||
|
||||
if (lastMs <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t now_ms = esp_timer_get_time() / 1000;
|
||||
uint32_t age_ms = (now_ms > m_lastWifiRssiMs) ? (uint32_t)(now_ms - m_lastWifiRssiMs) : 0;
|
||||
uint32_t age_ms = (now_ms > lastMs) ? (uint32_t)(now_ms - lastMs) : 0;
|
||||
|
||||
if (out_rssi) *out_rssi = m_lastWifiRssi;
|
||||
if (out_rssi) *out_rssi = rssi;
|
||||
if (out_age_ms) *out_age_ms = age_ms;
|
||||
return true;
|
||||
}
|
||||
@@ -91,15 +106,18 @@ void HealthMonitor::tempMonitorCallback(void *arg)
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.m_mutex) xSemaphoreTake(self.m_mutex, portMAX_DELAY);
|
||||
self.m_lastTemperature = temp;
|
||||
if (self.m_mutex) xSemaphoreGive(self.m_mutex);
|
||||
|
||||
if (temp >= TEMP_CRITICAL) {
|
||||
healthState = SYS_SAFE_SHUTDOWN;
|
||||
self.setState(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 (healthState < SYS_WARNING) {
|
||||
healthState = SYS_WARNING;
|
||||
health_state_t cur = self.getState();
|
||||
if (cur < SYS_WARNING) {
|
||||
self.setState(SYS_WARNING);
|
||||
DEBUG_WARN("TEMP", "警告: 温度 %.1f°C >= %d°C", temp, TEMP_WARNING);
|
||||
DEBUG_WARN("TEMP", "健康状态变更为: SYS_WARNING");
|
||||
}
|
||||
@@ -144,8 +162,9 @@ void HealthMonitor::taskEntry(void *pvParameters)
|
||||
if (free_heap < HEAP_WARNING_MIN) {
|
||||
DEBUG_WARN("MON", "堆内存不足: %lu < %lu", free_heap, HEAP_WARNING_MIN);
|
||||
}
|
||||
if (healthState >= SYS_CRITICAL) {
|
||||
DEBUG_ERR("MON", "健康状态危险: %d", healthState);
|
||||
health_state_t cur = self.getState();
|
||||
if (cur >= SYS_CRITICAL) {
|
||||
DEBUG_ERR("MON", "健康状态危险: %d", cur);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||
}
|
||||
@@ -165,14 +184,15 @@ void HealthMonitor::logEvent(const char *event, int value)
|
||||
health_status_t HealthMonitor::checkBeforeOta()
|
||||
{
|
||||
health_status_t status = HEALTH_MON_OK;
|
||||
health_state_t cur = getState();
|
||||
|
||||
if (healthState >= SYS_SAFE_SHUTDOWN) {
|
||||
if (cur >= SYS_SAFE_SHUTDOWN) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_SAFE_SHUTDOWN,禁止传输");
|
||||
return HEALTH_MON_SHUTDOWN;
|
||||
} else if (healthState >= SYS_CRITICAL) {
|
||||
} else if (cur >= SYS_CRITICAL) {
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_CRITICAL,禁止传输");
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (healthState >= SYS_WARNING) {
|
||||
} else if (cur >= SYS_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意");
|
||||
}
|
||||
@@ -210,18 +230,19 @@ health_status_t HealthMonitor::checkBeforeOta()
|
||||
health_status_t HealthMonitor::checkBeforeOtaRelay()
|
||||
{
|
||||
health_status_t status = HEALTH_MON_OK;
|
||||
health_state_t cur = getState();
|
||||
|
||||
if (healthState >= SYS_SAFE_SHUTDOWN) {
|
||||
if (cur >= SYS_SAFE_SHUTDOWN) {
|
||||
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_SAFE_SHUTDOWN,禁止传输");
|
||||
return HEALTH_MON_SHUTDOWN;
|
||||
} else if (healthState >= SYS_CRITICAL) {
|
||||
} else if (cur >= SYS_CRITICAL) {
|
||||
/* SYS_CRITICAL 可能是由 WiFi RSSI 过低导致的,
|
||||
* 但 SPI 中继阶段不依赖 WiFi,只检查温度/硬件等本地状态。
|
||||
* 温度达到 TEMP_CRITICAL(100°C) 时 g_health_state 会被设为
|
||||
* 温度达到 TEMP_CRITICAL(100°C) 时 m_healthState 会被设为
|
||||
* SYS_SAFE_SHUTDOWN,在上面的分支已经拦截。
|
||||
* 所以 SYS_CRITICAL 在中继阶段只可能是 WiFi 导致的,允许继续。 */
|
||||
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_CRITICAL (WiFi 相关),SPI 中继允许继续");
|
||||
} else if (healthState >= SYS_WARNING) {
|
||||
} else if (cur >= SYS_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_WARNING,继续传输");
|
||||
}
|
||||
@@ -243,21 +264,23 @@ health_status_t HealthMonitor::checkBeforeOtaRelay()
|
||||
health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
|
||||
{
|
||||
if (rssi < WIFI_RSSI_CRITICAL) {
|
||||
healthState = SYS_CRITICAL;
|
||||
setState(SYS_CRITICAL);
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL);
|
||||
logEvent("WIFI危险", rssi);
|
||||
return HEALTH_MON_CRITICAL;
|
||||
} else if (rssi < WIFI_RSSI_NORMAL) {
|
||||
if (healthState < SYS_WARNING) {
|
||||
healthState = SYS_WARNING;
|
||||
health_state_t cur = getState();
|
||||
if (cur < SYS_WARNING) {
|
||||
setState(SYS_WARNING);
|
||||
DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
logEvent("WIFI警告", rssi);
|
||||
}
|
||||
return HEALTH_MON_WARNING;
|
||||
}
|
||||
|
||||
if (healthState == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) {
|
||||
healthState = SYS_NORMAL;
|
||||
health_state_t cur = getState();
|
||||
if (cur == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) {
|
||||
setState(SYS_NORMAL);
|
||||
DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm", rssi, WIFI_RSSI_NORMAL);
|
||||
logEvent("WIFI恢复", rssi);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public:
|
||||
/** @brief 记录一条健康事件日志。 */
|
||||
void logEvent(const char *event, int value);
|
||||
|
||||
/** @brief 获取当前系统健康状态。 */
|
||||
/** @brief 获取当前系统健康状态(线程安全)。 */
|
||||
health_state_t getCurrent();
|
||||
|
||||
/** @brief 根据 WiFi RSSI 更新健康状态。 */
|
||||
@@ -66,10 +66,13 @@ public:
|
||||
/** @brief 获取最近一次 WiFi RSSI 及其年龄。 */
|
||||
bool getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms);
|
||||
|
||||
/** @brief 全局健康状态(public,供外部读取)。 */
|
||||
static health_state_t healthState;
|
||||
|
||||
private:
|
||||
/** @brief 加锁读取健康状态。 */
|
||||
health_state_t getState();
|
||||
|
||||
/** @brief 加锁设置健康状态。 */
|
||||
void setState(health_state_t newState);
|
||||
|
||||
HealthMonitor() = default;
|
||||
~HealthMonitor() = default;
|
||||
HealthMonitor(const HealthMonitor&) = delete;
|
||||
@@ -77,6 +80,7 @@ private:
|
||||
|
||||
SemaphoreHandle_t m_mutex = NULL;
|
||||
temperature_sensor_handle_t m_tempHandle = NULL;
|
||||
health_state_t m_healthState = SYS_NORMAL;
|
||||
float m_lastTemperature = 0.0f;
|
||||
int8_t m_lastWifiRssi = -127;
|
||||
int64_t m_lastWifiRssiMs = 0;
|
||||
|
||||
+332
-16
@@ -1,10 +1,11 @@
|
||||
/*
|
||||
/*
|
||||
* ota_relay.cpp - OTA 中继核心状态机实现
|
||||
* T2.3, T2.4, T3.1 - T3.7
|
||||
*
|
||||
* 所有功能封装为 OtaRelay 类方法。
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <inttypes.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
@@ -14,6 +15,8 @@
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
#include "aes/esp_aes.h"
|
||||
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
|
||||
#include "mbedtls/private/sha256.h"
|
||||
|
||||
#include "ota_relay.h"
|
||||
#include "spi_master.h"
|
||||
@@ -31,12 +34,81 @@ volatile bool OtaRelay::gd32AppAlive = false;
|
||||
gd32_version_t OtaRelay::gd32Version = {};
|
||||
QueueHandle_t OtaRelay::dispatchQueue = NULL;
|
||||
QueueHandle_t OtaRelay::resultQueue = NULL;
|
||||
SemaphoreHandle_t OtaRelay::s_stateMutex = NULL;
|
||||
|
||||
OtaRelay& OtaRelay::instance()
|
||||
{
|
||||
static OtaRelay inst;
|
||||
return inst;
|
||||
}
|
||||
void OtaRelay::initMutex()
|
||||
{
|
||||
if (!s_stateMutex) {
|
||||
s_stateMutex = xSemaphoreCreateMutex();
|
||||
}
|
||||
}
|
||||
|
||||
relay_state_t OtaRelay::getState()
|
||||
{
|
||||
StateLockGuard lock;
|
||||
return relayState;
|
||||
}
|
||||
|
||||
uint32_t OtaRelay::getRelaySeq()
|
||||
{
|
||||
StateLockGuard lock;
|
||||
return relaySeq;
|
||||
}
|
||||
|
||||
uint32_t OtaRelay::getRelayTotalPackets()
|
||||
{
|
||||
StateLockGuard lock;
|
||||
return relayTotalPackets;
|
||||
}
|
||||
|
||||
bool OtaRelay::getGd32AppAlive()
|
||||
{
|
||||
StateLockGuard lock;
|
||||
return gd32AppAlive;
|
||||
}
|
||||
|
||||
void OtaRelay::getGd32Version(gd32_version_t *out)
|
||||
{
|
||||
if (!out) return;
|
||||
StateLockGuard lock;
|
||||
*out = gd32Version;
|
||||
}
|
||||
|
||||
void OtaRelay::setState(relay_state_t s)
|
||||
{
|
||||
StateLockGuard lock;
|
||||
relayState = s;
|
||||
}
|
||||
|
||||
void OtaRelay::setRelaySeq(uint32_t seq)
|
||||
{
|
||||
StateLockGuard lock;
|
||||
relaySeq = seq;
|
||||
}
|
||||
|
||||
void OtaRelay::setRelayTotalPackets(uint32_t total)
|
||||
{
|
||||
StateLockGuard lock;
|
||||
relayTotalPackets = total;
|
||||
}
|
||||
|
||||
void OtaRelay::setGd32AppAlive(bool alive)
|
||||
{
|
||||
StateLockGuard lock;
|
||||
gd32AppAlive = alive;
|
||||
}
|
||||
|
||||
void OtaRelay::setGd32Version(const gd32_version_t &ver)
|
||||
{
|
||||
StateLockGuard lock;
|
||||
gd32Version = ver;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============================================================
|
||||
@@ -236,6 +308,13 @@ esp_err_t OtaRelay::metaLoad(ota_meta_t *meta)
|
||||
if (nvs_get_u32(handle, "last_seq", &val) == ESP_OK) meta->last_seq = val;
|
||||
if (nvs_get_u32(handle, "chunk_count", &val) == ESP_OK) meta->chunk_count = val;
|
||||
|
||||
size_t sha_len = sizeof(meta->checksum);
|
||||
if (nvs_get_blob(handle, "checksum", meta->checksum, &sha_len) == ESP_OK && sha_len == sizeof(meta->checksum)) {
|
||||
// checksum 已从 NVS 恢复
|
||||
} else {
|
||||
memset(meta->checksum, 0, sizeof(meta->checksum));
|
||||
}
|
||||
|
||||
nvs_close(handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -260,6 +339,7 @@ esp_err_t OtaRelay::metaSave(const ota_meta_t *meta)
|
||||
nvs_set_u32(handle, "stored_size", meta->stored_size);
|
||||
nvs_set_u32(handle, "last_seq", meta->last_seq);
|
||||
nvs_set_u32(handle, "chunk_count", meta->chunk_count);
|
||||
nvs_set_blob(handle, "checksum", meta->checksum, sizeof(meta->checksum));
|
||||
err = nvs_commit(handle);
|
||||
nvs_close(handle);
|
||||
|
||||
@@ -298,6 +378,131 @@ esp_err_t OtaRelay::metaClear()
|
||||
return OtaRelay::metaSave(&meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 计算 gd32_ota 分区中已存储固件的 SHA-256
|
||||
* @param out_sha256 输出 32 字节 SHA-256 摘要。
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t OtaRelay::computeFlashSha256(uint8_t out_sha256[32])
|
||||
{
|
||||
if (!out_sha256) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoad(&meta);
|
||||
if (err != ESP_OK || meta.total_size == 0) {
|
||||
DEBUG_ERR("OTA", "computeSha256: 元数据无效");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
if (mbedtls_sha256_starts(&ctx, 0) != 0) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
static constexpr size_t kChunkSize = 1024;
|
||||
uint8_t *buf = (uint8_t *)malloc(kChunkSize);
|
||||
if (!buf) {
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
uint32_t remaining = meta.total_size;
|
||||
uint32_t offset = 0;
|
||||
while (remaining > 0) {
|
||||
size_t to_read = (remaining > kChunkSize) ? kChunkSize : remaining;
|
||||
err = flashReadChunk(offset, buf, to_read);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "computeSha256: Flash 读取失败 offset=%" PRIu32, offset);
|
||||
free(buf);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return err;
|
||||
}
|
||||
if (mbedtls_sha256_update(&ctx, buf, to_read) != 0) {
|
||||
free(buf);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
offset += to_read;
|
||||
remaining -= to_read;
|
||||
}
|
||||
|
||||
free(buf);
|
||||
int ret = mbedtls_sha256_finish(&ctx, out_sha256);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
return (ret == 0) ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 固件接收完毕后,计算 SHA-256 并写入元数据
|
||||
* @return ESP_OK 成功,其他失败。
|
||||
*/
|
||||
esp_err_t OtaRelay::metaFinalizeChecksum()
|
||||
{
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoad(&meta);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
if (meta.total_size == 0 || meta.stored_size < meta.total_size) {
|
||||
DEBUG_ERR("OTA", "metaFinalize: 固件不完整 stored=%" PRIu32 " total=%" PRIu32,
|
||||
meta.stored_size, meta.total_size);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
err = computeFlashSha256(meta.checksum);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "metaFinalize: SHA-256 计算失败");
|
||||
return err;
|
||||
}
|
||||
|
||||
char hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i)
|
||||
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", meta.checksum[i]);
|
||||
DEBUG_INFO("OTA", "固件 SHA-256: %s", hex);
|
||||
|
||||
return metaSave(&meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 中继转发前校验固件完整性
|
||||
* @return ESP_OK 校验通过,ESP_ERR_INVALID_CRC 校验失败。
|
||||
*/
|
||||
esp_err_t OtaRelay::metaVerifyChecksum()
|
||||
{
|
||||
ota_meta_t meta;
|
||||
esp_err_t err = metaLoad(&meta);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
// 全零 checksum 表示从未计算过(兼容旧固件)
|
||||
if (bytesAllValue(meta.checksum, sizeof(meta.checksum), 0x00)) {
|
||||
DEBUG_WARN("OTA", "metaVerify: checksum 未计算,跳过校验(旧数据兼容)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint8_t computed[32] = {0};
|
||||
err = computeFlashSha256(computed);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "metaVerify: SHA-256 计算失败");
|
||||
return err;
|
||||
}
|
||||
|
||||
if (memcmp(computed, meta.checksum, 32) != 0) {
|
||||
char hex_stored[65] = {0}, hex_computed[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&hex_stored[i * 2], sizeof(hex_stored) - i * 2, "%02X", meta.checksum[i]);
|
||||
snprintf(&hex_computed[i * 2], sizeof(hex_computed) - i * 2, "%02X", computed[i]);
|
||||
}
|
||||
DEBUG_ERR("OTA", "固件完整性校验失败!");
|
||||
DEBUG_ERR("OTA", " 存储 SHA-256: %s", hex_stored);
|
||||
DEBUG_ERR("OTA", " 计算 SHA-256: %s", hex_computed);
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "固件完整性校验通过 (SHA-256 匹配)");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Flash 写入接口
|
||||
* @param offset 分区内写入偏移。
|
||||
@@ -316,7 +521,58 @@ esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t
|
||||
}
|
||||
}
|
||||
|
||||
return esp_partition_write(instance().m_gd32Partition, offset, data, len);
|
||||
// --- offset 必须4字节对齐 ---
|
||||
if (offset % 4 != 0) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk offset非4对齐: offset=%" PRIu32, offset);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
const esp_partition_t *part = instance().m_gd32Partition;
|
||||
|
||||
// --- len已对齐:直接写入 ---
|
||||
if (len % 4 == 0) {
|
||||
if (offset + len > part->size) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu part_size=%" PRIu32,
|
||||
offset, len, part->size);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
return esp_partition_write(part, offset, data, len);
|
||||
}
|
||||
|
||||
// --- len未对齐:read-modify-write ---
|
||||
size_t aligned_len = (len + 3) & ~(size_t)3;
|
||||
|
||||
if (offset + aligned_len > part->size) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk对齐后越界: offset=%" PRIu32 " len=%zu aligned=%zu part=%" PRIu32,
|
||||
offset, len, aligned_len, part->size);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
DEBUG_WARN("OTA", "flashWriteChunk len非4对齐: len=%zu aligned_len=%zu offset=%" PRIu32,
|
||||
len, aligned_len, offset);
|
||||
|
||||
// 读回尾部padding字节(offset+len处,最多3字节)
|
||||
size_t pad_len = aligned_len - len;
|
||||
uint8_t pad[4] = {0};
|
||||
esp_err_t err = esp_partition_read(part, offset + len, pad, pad_len);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk读回padding失败: offset=%" PRIu32 " pad=%zu err=%s",
|
||||
offset + len, pad_len, esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
// 拼接:data + pad -> aligned_buf
|
||||
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
|
||||
if (!aligned_buf) {
|
||||
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
memcpy(aligned_buf, data, len);
|
||||
memcpy(aligned_buf + len, pad, pad_len);
|
||||
|
||||
err = esp_partition_write(part, offset, aligned_buf, aligned_len);
|
||||
free(aligned_buf);
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -579,7 +835,7 @@ ota_result_t OtaRelay::sendFirmwareSize(uint32_t size)
|
||||
* @brief 从 Flash 向 GD32 转发存储的固件(48B/包)
|
||||
* @return 操作结果码。
|
||||
*/
|
||||
ota_result_t OtaRelay::relayPacketsFromFlash()
|
||||
ota_result_t OtaRelay::relayPacketsFromFlash(TickType_t deadline_tick)
|
||||
{
|
||||
DEBUG_INFO("OTA", "=== 阶段3: 从 Flash 转发固件 ===");
|
||||
|
||||
@@ -613,6 +869,12 @@ ota_result_t OtaRelay::relayPacketsFromFlash()
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局超时检查:避免单包 WAIT 循环累计导致整体挂起 */
|
||||
if (deadline_tick != 0 && xTaskGetTickCount() >= deadline_tick) {
|
||||
DEBUG_ERR("OTA", "全局超时: 包 %u/%u 已超限,中止转发", seq, total_packets);
|
||||
return OTA_RESULT_ERR_USER_ABORT;
|
||||
}
|
||||
|
||||
uint8_t plain48[GD32_OTA_PACKET_SIZE];
|
||||
memset(plain48, 0xFF, sizeof(plain48));
|
||||
size_t chunk_size = GD32_OTA_PACKET_SIZE;
|
||||
@@ -920,10 +1182,19 @@ ota_result_t OtaRelay::startHandshake()
|
||||
|
||||
ota_meta_t meta;
|
||||
OtaRelay::metaLoad(&meta);
|
||||
|
||||
// 固件完整性校验
|
||||
esp_err_t verify_err = metaVerifyChecksum();
|
||||
if (verify_err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "固件完整性校验失败,中止转发");
|
||||
reset();
|
||||
return OTA_RESULT_ERR_CRC;
|
||||
}
|
||||
|
||||
res = sendFirmwareSize(meta.total_size);
|
||||
if (res != OTA_RESULT_OK) { reset(); return res; }
|
||||
|
||||
res = relayPacketsFromFlash();
|
||||
res = relayPacketsFromFlash(0);
|
||||
if (res != OTA_RESULT_OK) { reset(); return res; }
|
||||
|
||||
res = finishOta();
|
||||
@@ -945,40 +1216,82 @@ ota_result_t OtaRelay::forwardFirmware()
|
||||
DEBUG_INFO("OTA", "开始固件转发: %u 字节, %u 块",
|
||||
meta.total_size, meta.chunk_count);
|
||||
|
||||
// 固件完整性校验
|
||||
{
|
||||
esp_err_t verify_err = metaVerifyChecksum();
|
||||
if (verify_err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "固件完整性校验失败,中止转发");
|
||||
return OTA_RESULT_ERR_CRC;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局超时:动态计算,min = 5min, max = 10min, 按 total_size/1024*2ms 递增 */
|
||||
constexpr uint32_t kMinGlobalTimeoutMs = 5U * 60U * 1000U;
|
||||
constexpr uint32_t kMaxGlobalTimeoutMs = 10U * 60U * 1000U;
|
||||
uint32_t dynamic_ms = kMinGlobalTimeoutMs + (meta.total_size / 1024U) * 2U;
|
||||
uint32_t global_timeout_ms = (dynamic_ms > kMaxGlobalTimeoutMs) ? kMaxGlobalTimeoutMs : dynamic_ms;
|
||||
TickType_t start_tick = xTaskGetTickCount();
|
||||
TickType_t deadline_tick = start_tick + pdMS_TO_TICKS(global_timeout_ms);
|
||||
|
||||
DEBUG_INFO("OTA", "全局超时: %u ms (%u 秒), deadline tick=%u",
|
||||
global_timeout_ms, global_timeout_ms / 1000U, (unsigned)deadline_tick);
|
||||
|
||||
requestGd32AppEnterBootloader();
|
||||
|
||||
/* 在每个阶段前检查全局超时,超时则 reset() 并返回 OTA_RESULT_ERR_USER_ABORT */
|
||||
if (xTaskGetTickCount() >= deadline_tick) {
|
||||
DEBUG_ERR("OTA", "全局超时: 在 requestBootloader 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
|
||||
reset();
|
||||
return OTA_RESULT_ERR_USER_ABORT;
|
||||
}
|
||||
|
||||
ota_result_t res = sendHandshakeReq();
|
||||
if (res != OTA_RESULT_OK) { reset(); return res; }
|
||||
|
||||
if (xTaskGetTickCount() >= deadline_tick) {
|
||||
DEBUG_ERR("OTA", "全局超时: 在 sendHandshakeReq 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
|
||||
reset();
|
||||
return OTA_RESULT_ERR_USER_ABORT;
|
||||
}
|
||||
|
||||
res = sendNonceAndAuthenticate();
|
||||
if (res != OTA_RESULT_OK) { reset(); return res; }
|
||||
|
||||
if (xTaskGetTickCount() >= deadline_tick) {
|
||||
DEBUG_ERR("OTA", "全局超时: 在 sendNonceAndAuthenticate 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
|
||||
reset();
|
||||
return OTA_RESULT_ERR_USER_ABORT;
|
||||
}
|
||||
|
||||
res = sendFirmwareSize(meta.total_size);
|
||||
if (res != OTA_RESULT_OK) { reset(); return res; }
|
||||
|
||||
res = relayPacketsFromFlash();
|
||||
if (xTaskGetTickCount() >= deadline_tick) {
|
||||
DEBUG_ERR("OTA", "全局超时: 在 sendFirmwareSize 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
|
||||
reset();
|
||||
return OTA_RESULT_ERR_USER_ABORT;
|
||||
}
|
||||
|
||||
res = relayPacketsFromFlash(deadline_tick);
|
||||
if (res != OTA_RESULT_OK) { reset(); return res; }
|
||||
|
||||
if (xTaskGetTickCount() >= deadline_tick) {
|
||||
DEBUG_ERR("OTA", "全局超时: 在 relayPacketsFromFlash 阶段已超限 (%u ms),中止 OTA", global_timeout_ms);
|
||||
reset();
|
||||
return OTA_RESULT_ERR_USER_ABORT;
|
||||
}
|
||||
|
||||
res = finishOta();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 获取当前状态
|
||||
* @return 当前中继状态枚举值。
|
||||
*/
|
||||
relay_state_t OtaRelay::getState()
|
||||
{
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
return relayState;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 重置状态机
|
||||
*/
|
||||
void OtaRelay::reset()
|
||||
{
|
||||
StateLockGuard lock;
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
relayState = RELAY_IDLE;
|
||||
relaySeq = 0;
|
||||
@@ -998,6 +1311,8 @@ void OtaRelay::taskEntry(void *pvParameters)
|
||||
{
|
||||
DEBUG_INFO("OTA", "OTA 中继任务启动");
|
||||
|
||||
initMutex();
|
||||
|
||||
/** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */
|
||||
dispatchQueue = xQueueCreate(4, sizeof(uint32_t));
|
||||
/** @brief OTA 结果回传队列,将执行结果返回给调用方。 */
|
||||
@@ -1026,6 +1341,8 @@ void OtaRelay::taskEntry(void *pvParameters)
|
||||
DEBUG_INFO("OTA", "OTA 转发成功完成");
|
||||
/* 保持 RELAY_DONE 状态 3 秒,让网页轮询有机会看到完成状态 */
|
||||
vTaskDelay(pdMS_TO_TICKS(3000));
|
||||
} else if (result == OTA_RESULT_ERR_USER_ABORT) {
|
||||
DEBUG_ERR("OTA", "OTA 全局超时中止 (OTA_RESULT_ERR_USER_ABORT=%d)", result);
|
||||
} else {
|
||||
DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result);
|
||||
}
|
||||
@@ -1033,4 +1350,3 @@ void OtaRelay::taskEntry(void *pvParameters)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+44
-6
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* ota_relay.h - OTA 中继核心状态机
|
||||
* T2.3, T3.1 - T3.7
|
||||
*
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <stdint.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "transport/protocol.h"
|
||||
#include "transport/spi_master.h"
|
||||
#include "esp_partition.h"
|
||||
@@ -66,7 +67,23 @@ public:
|
||||
static void taskEntry(void *pvParameters);
|
||||
ota_result_t startHandshake();
|
||||
ota_result_t forwardFirmware();
|
||||
relay_state_t getState();
|
||||
|
||||
/* ---- 线程安全的状态访问接口 ----
|
||||
* OTA 任务写、HTTP 任务读,通过 mutex 保证一致性。
|
||||
* 状态机内部(OTA 任务)可直接写 static 成员,
|
||||
* 跨任务读/写必须走这些 API。
|
||||
*/
|
||||
static relay_state_t getState();
|
||||
static uint32_t getRelaySeq();
|
||||
static uint32_t getRelayTotalPackets();
|
||||
static bool getGd32AppAlive();
|
||||
static void getGd32Version(gd32_version_t *out);
|
||||
static void setState(relay_state_t s);
|
||||
static void setRelaySeq(uint32_t seq);
|
||||
static void setRelayTotalPackets(uint32_t total);
|
||||
static void setGd32AppAlive(bool alive);
|
||||
static void setGd32Version(const gd32_version_t &ver);
|
||||
|
||||
void reset();
|
||||
|
||||
static esp_err_t flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len);
|
||||
@@ -75,8 +92,11 @@ public:
|
||||
static esp_err_t metaSave(const ota_meta_t *meta);
|
||||
static esp_err_t metaUpdate(uint32_t stored_size, uint32_t last_seq);
|
||||
static esp_err_t metaClear();
|
||||
static esp_err_t computeFlashSha256(uint8_t out_sha256[32]);
|
||||
static esp_err_t metaFinalizeChecksum();
|
||||
static esp_err_t metaVerifyChecksum();
|
||||
|
||||
// 公开状态
|
||||
// 公开状态(状态机内部直接访问;跨任务请用 getter/setter)
|
||||
static relay_state_t relayState;
|
||||
static uint32_t relaySeq;
|
||||
static uint32_t relayTotalPackets;
|
||||
@@ -85,6 +105,26 @@ public:
|
||||
static QueueHandle_t dispatchQueue;
|
||||
static QueueHandle_t resultQueue;
|
||||
|
||||
// 线程安全锁
|
||||
static SemaphoreHandle_t s_stateMutex;
|
||||
static void initMutex();
|
||||
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50);
|
||||
|
||||
class StateLockGuard {
|
||||
public:
|
||||
explicit StateLockGuard(TickType_t timeout = kLockTimeout) {
|
||||
m_locked = (s_stateMutex && xSemaphoreTake(s_stateMutex, timeout) == pdTRUE);
|
||||
}
|
||||
~StateLockGuard() {
|
||||
if (m_locked && s_stateMutex) { xSemaphoreGive(s_stateMutex); }
|
||||
}
|
||||
bool isLocked() const { return m_locked; }
|
||||
StateLockGuard(const StateLockGuard&) = delete;
|
||||
StateLockGuard& operator=(const StateLockGuard&) = delete;
|
||||
private:
|
||||
bool m_locked = false;
|
||||
};
|
||||
|
||||
// 实例状态(public,供状态机方法访问)
|
||||
const esp_partition_t *m_gd32Partition = NULL;
|
||||
session_ctx_t m_sessionCtx;
|
||||
@@ -100,7 +140,7 @@ public:
|
||||
ota_result_t sendHandshakeReq();
|
||||
ota_result_t sendNonceAndAuthenticate();
|
||||
ota_result_t sendFirmwareSize(uint32_t size);
|
||||
ota_result_t relayPacketsFromFlash();
|
||||
ota_result_t relayPacketsFromFlash(TickType_t deadline_tick = 0);
|
||||
ota_result_t finishOta();
|
||||
|
||||
static int aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
@@ -142,5 +182,3 @@ private:
|
||||
};
|
||||
|
||||
#endif // OTA_RELAY_H
|
||||
|
||||
|
||||
|
||||
+197
-29
@@ -34,7 +34,12 @@
|
||||
#include "services/gatt/ble_svc_gatt.h"
|
||||
#include "host/ble_att.h"
|
||||
|
||||
// SHA-256 哈希计算
|
||||
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
|
||||
#include "mbedtls/private/sha256.h"
|
||||
|
||||
#include "app_config.h"
|
||||
#include "ota_mutex.h"
|
||||
#include "ble_ota.h"
|
||||
#include "ota_relay.h"
|
||||
#include "health_monitor.h"
|
||||
@@ -53,6 +58,9 @@
|
||||
#define BLE_OTA_FINISH_ERR_OTA_END 0x81
|
||||
#define BLE_OTA_FINISH_ERR_SET_BOOT 0x82
|
||||
#define BLE_OTA_ACK_WINDOW 32
|
||||
#define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用
|
||||
#define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用
|
||||
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小
|
||||
|
||||
// ============================================================
|
||||
// 前向声明
|
||||
@@ -97,6 +105,20 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节, 设置启动分区...", (unsigned int)BleOta::instance().m_esp32Received);
|
||||
|
||||
// 完成 SHA-256 哈希计算
|
||||
if (BleOta::instance().m_sha256Ctx != nullptr) {
|
||||
mbedtls_sha256_finish(BleOta::instance().m_sha256Ctx, BleOta::instance().m_esp32Sha256);
|
||||
mbedtls_sha256_free(BleOta::instance().m_sha256Ctx);
|
||||
BleOta::instance().m_esp32HashComputed = true;
|
||||
|
||||
// 打印SHA-256哈希值
|
||||
char hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", BleOta::instance().m_esp32Sha256[i]);
|
||||
}
|
||||
DEBUG_INFO(TAG, "ESP32 固件 SHA-256: %s", hex);
|
||||
}
|
||||
|
||||
int64_t t0_us = esp_timer_get_time();
|
||||
DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_end()");
|
||||
sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0);
|
||||
@@ -107,6 +129,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
esp_err_to_name(err), (long long)((t1_us - t0_us) / 1000));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err);
|
||||
esp_ota_abort(BleOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
BleOta::instance().m_esp32OtaActive = false;
|
||||
BleOta::instance().m_esp32OtaHandle = 0;
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
@@ -128,6 +151,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
DEBUG_ERR(TAG, "[FINISH] 设置启动分区失败: %s (耗时=%lld ms)",
|
||||
esp_err_to_name(err), (long long)((t3_us - t2_us) / 1000));
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_SET_BOOT, (uint32_t)err);
|
||||
esp32_ota_mutex_release();
|
||||
BleOta::instance().m_esp32OtaActive = false;
|
||||
BleOta::instance().m_esp32OtaHandle = 0;
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
@@ -140,10 +164,12 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
(long long)((t3_us - t2_us) / 1000));
|
||||
sendFinishEvent(BLE_OTA_FINISH_DONE_SET_BOOT, (uint32_t)((t3_us - t2_us) / 1000));
|
||||
|
||||
esp32_ota_mutex_release();
|
||||
BleOta::instance().m_esp32OtaActive = false;
|
||||
BleOta::instance().m_esp32OtaHandle = 0;
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
BleOta::instance().m_esp32Received = 0;
|
||||
// SHA-256哈希已在之前计算完成,保留供前端读取
|
||||
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,800ms 后重启...");
|
||||
int64_t t4_us = esp_timer_get_time();
|
||||
@@ -177,6 +203,20 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
|
||||
return (int)copy_len;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 清理 SHA-256 上下文和哈希状态
|
||||
*/
|
||||
static void ble_esp32_ota_cleanup_sha()
|
||||
{
|
||||
if (BleOta::instance().m_sha256Ctx != nullptr) {
|
||||
mbedtls_sha256_free(BleOta::instance().m_sha256Ctx);
|
||||
delete BleOta::instance().m_sha256Ctx;
|
||||
BleOta::instance().m_sha256Ctx = nullptr;
|
||||
}
|
||||
BleOta::instance().m_esp32HashComputed = false;
|
||||
memset(BleOta::instance().m_esp32Sha256, 0, sizeof(BleOta::instance().m_esp32Sha256));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 初始化 ESP32 本机 OTA 写入会话并选择目标分区。
|
||||
* @param total_size 固件总大小(字节)。
|
||||
@@ -184,13 +224,11 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_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);
|
||||
|
||||
// ★ 如果 BLE 自身已有未完成的 OTA 会话,先中止并释放全局互斥
|
||||
if (BleOta::instance().m_esp32OtaActive) {
|
||||
DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA,会先中止旧会话");
|
||||
esp_ota_abort(BleOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
BleOta::instance().m_esp32OtaActive = false;
|
||||
BleOta::instance().m_esp32OtaHandle = 0;
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
@@ -199,6 +237,16 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
|
||||
}
|
||||
|
||||
// ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用
|
||||
if (!esp32_ota_mutex_acquire(1000)) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 BLE OTA begin");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
BleOta::instance().m_esp32UpdatePartition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL);
|
||||
if (BleOta::instance().m_esp32UpdatePartition == NULL ||
|
||||
@@ -209,6 +257,7 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
|
||||
if (BleOta::instance().m_esp32UpdatePartition == NULL) {
|
||||
DEBUG_ERR(TAG, "找不到可用的 OTA 分区");
|
||||
esp32_ota_mutex_release();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -221,6 +270,7 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
DEBUG_ERR(TAG, "ESP32 固件太大: %u > 分区大小: %u",
|
||||
total_size, BleOta::instance().m_esp32UpdatePartition->size);
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
esp32_ota_mutex_release();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -233,6 +283,7 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err));
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
BleOta::instance().m_esp32OtaHandle = 0;
|
||||
esp32_ota_mutex_release();
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -241,6 +292,16 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
BleOta::instance().m_esp32LastCmd = 0;
|
||||
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
|
||||
|
||||
// 初始化 SHA-256 计算上下文
|
||||
if (BleOta::instance().m_sha256Ctx == nullptr) {
|
||||
BleOta::instance().m_sha256Ctx = new mbedtls_sha256_context();
|
||||
}
|
||||
mbedtls_sha256_init(BleOta::instance().m_sha256Ctx);
|
||||
mbedtls_sha256_starts(BleOta::instance().m_sha256Ctx, 0);
|
||||
BleOta::instance().m_esp32HashComputed = false;
|
||||
memset(BleOta::instance().m_esp32Sha256, 0, sizeof(BleOta::instance().m_esp32Sha256));
|
||||
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s", BleOta::instance().m_esp32UpdatePartition->label);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -256,6 +317,12 @@ static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len)
|
||||
if (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// 更新 SHA-256 哈希计算
|
||||
if (BleOta::instance().m_sha256Ctx != nullptr) {
|
||||
mbedtls_sha256_update(BleOta::instance().m_sha256Ctx, data, len);
|
||||
}
|
||||
|
||||
esp_err_t err = esp_ota_write(BleOta::instance().m_esp32OtaHandle, data, len);
|
||||
if (err == ESP_OK) {
|
||||
BleOta::instance().m_esp32Received += len;
|
||||
@@ -386,6 +453,52 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
} else {
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(由设备端异步完成)");
|
||||
}
|
||||
} else if (buf[0] == BLE_OTA_CTRL_VERIFY_SHA256) {
|
||||
// SHA-256 哈希验证命令
|
||||
// 期望数据格式: [cmd(1)] + [sha256(32)]
|
||||
if (len < 33) {
|
||||
DEBUG_WARN(TAG, "SHA-256 验证数据长度不足: %d (需要33)", len);
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
// 提取前端发送的SHA-256哈希
|
||||
uint8_t frontend_sha[32];
|
||||
memcpy(frontend_sha, &buf[1], 32);
|
||||
|
||||
// 打印前端SHA-256
|
||||
char frontend_hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&frontend_hex[i * 2], sizeof(frontend_hex) - i * 2, "%02X", frontend_sha[i]);
|
||||
}
|
||||
DEBUG_INFO(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
DEBUG_INFO(TAG, "📊 SHA-256 哈希对比验证");
|
||||
DEBUG_INFO(TAG, " 前端计算: %s", frontend_hex);
|
||||
|
||||
// 检查设备端是否已计算SHA-256
|
||||
if (!BleOta::instance().m_esp32HashComputed) {
|
||||
DEBUG_WARN(TAG, "⚠️ 设备端尚未完成SHA-256计算");
|
||||
sendFinishEvent(0xA0, 0); // 自定义事件:哈希未就绪
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 打印设备端SHA-256
|
||||
char device_hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02X", BleOta::instance().m_esp32Sha256[i]);
|
||||
}
|
||||
DEBUG_INFO(TAG, " 设备计算: %s", device_hex);
|
||||
|
||||
// 对比哈希值
|
||||
if (memcmp(frontend_sha, BleOta::instance().m_esp32Sha256, 32) == 0) {
|
||||
DEBUG_INFO(TAG, "✅ SHA-256 验证通过!固件传输完整无误");
|
||||
DEBUG_INFO(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
sendFinishEvent(0xA1, 1); // 自定义事件:验证成功
|
||||
} else {
|
||||
DEBUG_ERR(TAG, "❌ SHA-256 验证失败!固件传输过程中出现错误");
|
||||
DEBUG_ERR(TAG, " 请检查BLE链路质量并重试");
|
||||
DEBUG_ERR(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
sendFinishEvent(0xA2, 0); // 自定义事件:验证失败
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
|
||||
@@ -397,8 +510,8 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
* [0] cmd (0x00=数据)
|
||||
* [1-2] seq uint16 LE - 分块序号
|
||||
* [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效)
|
||||
* [7..n-4] payload 固件数据
|
||||
* [n-4..n] crc 校验 (预留)
|
||||
* [7..n] payload 固件数据
|
||||
* 注: 已移除CRC字段(BLE链路层已有CRC校验)
|
||||
*/
|
||||
/**
|
||||
* @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。
|
||||
@@ -419,32 +532,56 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
uint8_t buf[BLE_MTU_SIZE] = {0};
|
||||
// 使用较大静态缓冲区:writeValueWithoutResponse 快速发送时,
|
||||
// NimBLE 可能将多个写请求合并到同一个 mbuf 链中
|
||||
// ★ static: NimBLE host task 栈只有 4096 字节,不能用栈分配
|
||||
// ★ 4096 字节可容纳 8 个合并包(8×491=3928),避免截断丢包
|
||||
static constexpr size_t BLE_OTA_BUF_SIZE = 4096;
|
||||
static uint8_t buf[BLE_OTA_BUF_SIZE];
|
||||
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);
|
||||
if (recv_len < 8) {
|
||||
DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 8: 7头+1数据)", recv_len);
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
// 循环处理 mbuf 中的所有完整数据包
|
||||
size_t offset = 0;
|
||||
while (offset + 7 <= (size_t)recv_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));
|
||||
uint8_t cmd = buf[offset + 0];
|
||||
uint16_t seq = (uint16_t)(buf[offset + 1] | (buf[offset + 2] << 8));
|
||||
uint32_t total_size = (uint32_t)(buf[offset + 3] | (buf[offset + 4] << 8) |
|
||||
(buf[offset + 5] << 16) | (buf[offset + 6] << 24));
|
||||
|
||||
size_t data_offset = 7;
|
||||
size_t data_len = (size_t)recv_len - data_offset;
|
||||
size_t data_offset = offset + 7;
|
||||
|
||||
// 尾 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;
|
||||
// 根据包序号和固件总大小计算期望 payload,而非依赖 recv_len
|
||||
// 这样即使 NimBLE mbuf 合并导致缓冲区截断,也不会误判包长度
|
||||
uint32_t expected_payload = (total_size > 0)
|
||||
? ((uint32_t)BLE_OTA_CHUNK_PAYLOAD < (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD)
|
||||
? (uint32_t)BLE_OTA_CHUNK_PAYLOAD
|
||||
: (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD))
|
||||
: (uint32_t)BLE_OTA_CHUNK_PAYLOAD;
|
||||
|
||||
size_t data_len = (size_t)expected_payload;
|
||||
size_t remaining_after_header = (size_t)recv_len - data_offset;
|
||||
|
||||
// 如果缓冲区中实际可用数据不足,截断 data_len
|
||||
if (remaining_after_header < expected_payload) {
|
||||
if (remaining_after_header == 0) {
|
||||
// 没有更多数据,退出循环
|
||||
break;
|
||||
}
|
||||
// 缓冲区截断,使用实际可用数据
|
||||
data_len = remaining_after_header;
|
||||
}
|
||||
data_len -= 4; // 减去尾部 CRC 预留
|
||||
|
||||
DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu",
|
||||
cmd, seq, total_size, data_len);
|
||||
// 当前包总长度: header(7) + payload(data_len)
|
||||
size_t packet_len = 7 + data_len;
|
||||
|
||||
DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu offset=%zu",
|
||||
cmd, seq, total_size, data_len, offset);
|
||||
|
||||
// --- ESP32 自身 OTA:直接写 OTA 分区 ---
|
||||
if (cmd == BLE_OTA_CMD_ESP32_DATA) {
|
||||
@@ -471,6 +608,10 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
}
|
||||
esp_err_t err = ble_esp32_ota_begin(total_size);
|
||||
if (err != ESP_OK) {
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0);
|
||||
return BLE_ATT_ERR_OTA_BUSY;
|
||||
}
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
}
|
||||
@@ -479,14 +620,21 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s",
|
||||
seq, esp_err_to_name(err));
|
||||
esp_ota_abort(BleOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
BleOta::instance().m_esp32OtaActive = false;
|
||||
BleOta::instance().m_esp32OtaHandle = 0;
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
ble_esp32_ota_cleanup_sha(); // 清理SHA状态
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u",
|
||||
seq, (unsigned int)BleOta::instance().m_esp32Received);
|
||||
|
||||
// 尾包检测: data_len < 标准负载说明是最后一包(不满一个完整 payload)
|
||||
bool isLastPacket = (data_len < BLE_OTA_CHUNK_PAYLOAD);
|
||||
// 尾包检测: 期望 payload < 标准负载说明是真正的最后一包
|
||||
// 使用 expected_payload 而非 data_len,避免缓冲区截断导致误触发重启
|
||||
bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD);
|
||||
|
||||
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
|
||||
(total_size > 0 && BleOta::instance().m_esp32Received >= total_size) ||
|
||||
@@ -515,7 +663,13 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
NULL);
|
||||
if (ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
|
||||
esp_ota_abort(BleOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
BleOta::instance().m_esp32OtaActive = false;
|
||||
BleOta::instance().m_esp32OtaHandle = 0;
|
||||
BleOta::instance().m_esp32UpdatePartition = NULL;
|
||||
BleOta::instance().m_esp32RebootPending = false;
|
||||
ble_esp32_ota_cleanup_sha(); // 清理SHA状态
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启");
|
||||
@@ -523,15 +677,15 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发");
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- GD32 OTA:首个分块时创建存储元数据 ---
|
||||
else if (cmd == BLE_OTA_CMD_GD32_DATA) {
|
||||
if (seq == 0 && total_size > 0) {
|
||||
uint32_t rounded_size = (uint32_t)(((total_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE);
|
||||
if (total_size == 0U || total_size > OTA_MAX_FIRMWARE_SIZE || rounded_size > OTA_MAX_FIRMWARE_SIZE) {
|
||||
DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " rounded48=%" PRIu32 " max=%zu",
|
||||
total_size, rounded_size, OTA_MAX_FIRMWARE_SIZE);
|
||||
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_SIZE, total_size);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
@@ -539,7 +693,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 + (uint32_t)data_len - 1U) / (uint32_t)data_len;
|
||||
meta.chunk_count = (total_size + expected_payload - 1U) / expected_payload;
|
||||
|
||||
esp_err_t err = OtaRelay::metaSave(&meta);
|
||||
if (err != ESP_OK) {
|
||||
@@ -564,7 +718,6 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
@@ -587,7 +740,7 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
|
||||
if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U ||
|
||||
(meta.total_size > 0 && stored_size >= meta.total_size) ||
|
||||
(data_len < BLE_OTA_CHUNK_PAYLOAD)) {
|
||||
(expected_payload < BLE_OTA_CHUNK_PAYLOAD)) {
|
||||
sendWindowAck(cmd, seq, stored_size);
|
||||
}
|
||||
|
||||
@@ -600,6 +753,21 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
meta.stored_size >= meta.total_size) {
|
||||
DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)",
|
||||
meta.stored_size, meta.total_size, seq + 1);
|
||||
// 计算并存储 SHA-256 校验和
|
||||
esp_err_t sha_err = OtaRelay::metaFinalizeChecksum();
|
||||
if (sha_err != ESP_OK) {
|
||||
DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "DATA_IN 未知 cmd: 0x%02X offset=%zu", cmd, offset);
|
||||
}
|
||||
|
||||
// 推进到下一个包
|
||||
offset += packet_len;
|
||||
|
||||
// 检查剩余数据是否足够组成一个完整的包头
|
||||
if (offset + 7 > (size_t)recv_len) break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#include "esp_err.h"
|
||||
#include "esp_ota_ops.h"
|
||||
|
||||
// 前向声明 mbedtls_sha256_context
|
||||
typedef struct mbedtls_sha256_context mbedtls_sha256_context;
|
||||
|
||||
class BleOta {
|
||||
public:
|
||||
static BleOta& instance();
|
||||
@@ -31,6 +34,11 @@ public:
|
||||
bool m_esp32RebootPending = false;
|
||||
uint8_t m_esp32LastCmd = 0;
|
||||
uint16_t m_esp32LastSeq = 0xFFFFU;
|
||||
|
||||
// SHA-256 哈希校验
|
||||
mbedtls_sha256_context *m_sha256Ctx = nullptr; // SHA-256计算上下文
|
||||
uint8_t m_esp32Sha256[32] = {0}; // 计算得到的SHA-256哈希值
|
||||
bool m_esp32HashComputed = false; // 是否已计算哈希
|
||||
};
|
||||
|
||||
#endif // BLE_OTA_H
|
||||
+409
-95
@@ -21,7 +21,12 @@
|
||||
#include "esp_event.h"
|
||||
#include "esp_mac.h"
|
||||
|
||||
// SHA-256 哈希计算
|
||||
#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS
|
||||
#include "mbedtls/private/sha256.h"
|
||||
|
||||
#include "app_config.h"
|
||||
#include "ota_mutex.h"
|
||||
#include "wifi_ota.h"
|
||||
#include "ota_relay.h"
|
||||
#include "health_monitor.h"
|
||||
@@ -74,6 +79,69 @@ static esp_err_t http_options_handler(httpd_req_t *req)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// STA 后台重连任务(避免在系统事件回调中调用 esp_wifi_connect 死锁)
|
||||
// ----------------------------------------
|
||||
/**
|
||||
* @brief 独立 FreeRTOS 任务,处理 STA 连接/重连的退避延迟和 esp_wifi_connect() 调用。
|
||||
* 事件回调仅通过 xTaskNotifyGive 唤醒本任务,不直接在系统事件任务中阻塞或调用 WiFi API。
|
||||
*/
|
||||
void WifiOta::reconnectTask(void* pvParameters)
|
||||
{
|
||||
(void)pvParameters;
|
||||
while (1) {
|
||||
// 等待事件回调通知(阻塞,不消耗 CPU)
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
|
||||
WifiOta& self = WifiOta::instance();
|
||||
|
||||
// 已连接或已放弃,无需处理
|
||||
if (self.getStaConnected() || self.getStaRetryExhausted()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.stateLock();
|
||||
|
||||
// 达到最大重试次数
|
||||
if (self.getStaRetryCount() >= WIFI_STA_MAX_RETRY) {
|
||||
DEBUG_ERR("WIFI", "STA 重连失败 %d 次,放弃", self.getStaRetryCount());
|
||||
self.setStaRetryExhausted(true);
|
||||
self.stateUnlock();
|
||||
continue;
|
||||
}
|
||||
|
||||
self.setStaRetryCount(self.getStaRetryCount() + 1);
|
||||
int retryCount = self.getStaRetryCount();
|
||||
|
||||
// 计算退避延迟:冷启动前 3 次递增退避,其余固定 1s
|
||||
int delay_ms = 0;
|
||||
if (self.m_coldBoot && retryCount <= 3) {
|
||||
delay_ms = 2000 * retryCount; // 2s, 4s, 6s
|
||||
DEBUG_INFO("WIFI", "冷启动退避重连第 %d 次 (等 %dms)",
|
||||
retryCount, delay_ms);
|
||||
} else if (retryCount > 1) {
|
||||
delay_ms = 1000; // 普通重连 1s 延迟
|
||||
DEBUG_INFO("WIFI", "重连第 %d 次...", retryCount);
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "首次连接...");
|
||||
}
|
||||
|
||||
self.stateUnlock();
|
||||
|
||||
if (delay_ms > 0) {
|
||||
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
||||
}
|
||||
|
||||
// 延迟后再次检查状态,防止期间已连接或已放弃
|
||||
if (!self.getStaConnected() && !self.getStaRetryExhausted()) {
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("WIFI", "esp_wifi_connect() 失败: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// WiFi/IP 事件回调(事件驱动连接)
|
||||
// ----------------------------------------
|
||||
@@ -87,45 +155,49 @@ static esp_err_t http_options_handler(httpd_req_t *req)
|
||||
void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void* event_data)
|
||||
{
|
||||
WifiOta& inst = WifiOta::instance();
|
||||
|
||||
if (event_base == WIFI_EVENT) {
|
||||
switch (event_id) {
|
||||
case WIFI_EVENT_STA_START:
|
||||
DEBUG_INFO("WIFI", "STA 已启动,开始连接...");
|
||||
esp_wifi_connect();
|
||||
inst.stateLock();
|
||||
inst.setStaRetryCount(0);
|
||||
inst.setStaRetryExhausted(false);
|
||||
inst.stateUnlock();
|
||||
if (inst.m_reconnectTask) {
|
||||
xTaskNotifyGive(inst.m_reconnectTask);
|
||||
}
|
||||
break;
|
||||
|
||||
case WIFI_EVENT_STA_CONNECTED:
|
||||
DEBUG_INFO("WIFI", "STA 已关联到 AP");
|
||||
WifiOta::instance().m_staConnected = true;
|
||||
WifiOta::instance().m_staRetryCount = 0;
|
||||
WifiOta::instance().m_staRetryExhausted = false; // ★ 连接成功,重置耗尽标志
|
||||
if (WifiOta::instance().m_staConnectedSem) {
|
||||
xSemaphoreGive(WifiOta::instance().m_staConnectedSem);
|
||||
inst.stateLock();
|
||||
inst.setStaConnected(true);
|
||||
inst.setStaRetryCount(0);
|
||||
inst.setStaRetryExhausted(false); // ★ 连接成功,重置耗尽标志
|
||||
inst.stateUnlock();
|
||||
if (inst.m_staConnectedSem) {
|
||||
xSemaphoreGive(inst.m_staConnectedSem);
|
||||
}
|
||||
break;
|
||||
|
||||
case WIFI_EVENT_STA_DISCONNECTED: {
|
||||
wifi_event_sta_disconnected_t* disconn = (wifi_event_sta_disconnected_t*)event_data;
|
||||
DEBUG_WARN("WIFI", "STA 断开, reason=%d, retry=%d", disconn->reason, WifiOta::instance().m_staRetryCount);
|
||||
WifiOta::instance().m_staConnected = false;
|
||||
inst.stateLock();
|
||||
inst.setStaConnected(false);
|
||||
bool exhausted = inst.getStaRetryExhausted();
|
||||
int retryCount = inst.getStaRetryCount();
|
||||
inst.stateUnlock();
|
||||
|
||||
if (WifiOta::instance().m_staRetryCount < WIFI_STA_MAX_RETRY) {
|
||||
WifiOta::instance().m_staRetryCount++;
|
||||
// ★ 冷启动首次连接失败时增加退避延迟
|
||||
// 长时间断电后路由器可能还在初始化(ARP/DHCP/信道选择)
|
||||
// reason 201=NO_AP_FOUND, 15=AUTH_FAIL, 2=AUTH_EXPIRE, 204=HANDSHAKE_TIMEOUT
|
||||
if (WifiOta::instance().m_coldBoot && WifiOta::instance().m_staRetryCount <= 3) {
|
||||
int delay_ms = 2000 * WifiOta::instance().m_staRetryCount; // 2s, 4s, 6s 递增退避
|
||||
DEBUG_INFO("WIFI", "冷启动退避重连第 %d 次 (reason=%d, 等 %dms)...",
|
||||
WifiOta::instance().m_staRetryCount, disconn->reason, delay_ms);
|
||||
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "重连第 %d 次...", WifiOta::instance().m_staRetryCount);
|
||||
if (!exhausted) {
|
||||
DEBUG_WARN("WIFI", "STA 断开, reason=%d, retry=%d",
|
||||
disconn->reason, retryCount);
|
||||
if (inst.m_reconnectTask) {
|
||||
xTaskNotifyGive(inst.m_reconnectTask);
|
||||
}
|
||||
esp_wifi_connect();
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "STA 重连失败 %d 次,放弃", WifiOta::instance().m_staRetryCount);
|
||||
WifiOta::instance().m_staRetryExhausted = true;
|
||||
DEBUG_WARN("WIFI", "STA 断开, 重试次数已耗尽");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -225,8 +297,8 @@ esp_err_t WifiOta::initSta()
|
||||
DEBUG_INFO("WIFI", "=== 初始化 WiFi STA 模式 ===");
|
||||
|
||||
WifiOta::instance().m_wifiMode = OTA_WIFI_MODE_STA;
|
||||
WifiOta::instance().m_staRetryCount = 0;
|
||||
WifiOta::instance().m_staRetryExhausted = false;
|
||||
WifiOta::instance().setStaRetryCount(0);
|
||||
WifiOta::instance().setStaRetryExhausted(false);
|
||||
|
||||
// ★ 设置国家代码:中国,允许信道 1-13
|
||||
// ESP32 默认国家是 US(只允许 1-11),中国路由可能用 12/13
|
||||
@@ -307,18 +379,18 @@ esp_err_t WifiOta::waitStaConnected(char *ip_str, size_t ip_len)
|
||||
}
|
||||
DEBUG_INFO("WIFI", "%s 等待成功, 当前 IP: " IPSTR, wait_name, IP2STR(&ip_info.ip));
|
||||
}
|
||||
WifiOta::instance().m_staConnected = true;
|
||||
WifiOta::instance().setStaConnected(true);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ★ 重试已耗尽,不再等待
|
||||
if (WifiOta::instance().m_staRetryExhausted) {
|
||||
DEBUG_ERR("WIFI", "STA 重试已耗尽 (%d 次),立即切换 AP", WifiOta::instance().m_staRetryCount);
|
||||
if (WifiOta::instance().getStaRetryExhausted()) {
|
||||
DEBUG_ERR("WIFI", "STA 重试已耗尽 (%d 次),立即切换 AP", WifiOta::instance().getStaRetryCount());
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_ERR("WIFI", "等待 %s 超时 (%d ms), 重连次数: %d", wait_name, WIFI_STA_CONNECT_TIMEOUT_MS, WifiOta::instance().m_staRetryCount);
|
||||
DEBUG_ERR("WIFI", "等待 %s 超时 (%d ms), 重连次数: %d", wait_name, WIFI_STA_CONNECT_TIMEOUT_MS, WifiOta::instance().getStaRetryCount());
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
@@ -355,6 +427,18 @@ void WifiOta::setStaticIp()
|
||||
|
||||
// ----------------------------------------
|
||||
// ESP32 OTA 内部函数
|
||||
|
||||
/**
|
||||
* @brief 清理 WiFi OTA 的 SHA-256 上下文和哈希状态
|
||||
*/
|
||||
static void wifi_esp32_ota_cleanup_sha()
|
||||
{
|
||||
WifiOta::instance().m_esp32HashComputed = false;
|
||||
memset(WifiOta::instance().m_esp32Sha256, 0, sizeof(WifiOta::instance().m_esp32Sha256));
|
||||
WifiOta::instance().m_hasFrontendSha256 = false;
|
||||
memset(WifiOta::instance().m_frontendSha256, 0, sizeof(WifiOta::instance().m_frontendSha256));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 初始化 ESP32 自身的 OTA 写入流程。
|
||||
* @param total_size
|
||||
@@ -362,6 +446,12 @@ void WifiOta::setStaticIp()
|
||||
*/
|
||||
static esp_err_t esp32_ota_begin(size_t total_size)
|
||||
{
|
||||
// ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用
|
||||
if (!esp32_ota_mutex_acquire(1000)) {
|
||||
DEBUG_ERR("OTA", "ESP32 OTA 被其他通道占用,拒绝 WiFi OTA begin");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
|
||||
DEBUG_INFO("OTA", "当前启动分区: %s, size=%u, address=0x%lx",
|
||||
boot_partition->label, boot_partition->size, boot_partition->address);
|
||||
@@ -376,6 +466,7 @@ static esp_err_t esp32_ota_begin(size_t total_size)
|
||||
|
||||
if (WifiOta::instance().m_esp32UpdatePartition == NULL) {
|
||||
DEBUG_ERR("OTA", "找不到可用的 OTA 分区");
|
||||
esp32_ota_mutex_release();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -387,6 +478,7 @@ static esp_err_t esp32_ota_begin(size_t total_size)
|
||||
if (total_size > WifiOta::instance().m_esp32UpdatePartition->size) {
|
||||
DEBUG_ERR("OTA", "固件太大: %u > 分区大小: %u",
|
||||
total_size, WifiOta::instance().m_esp32UpdatePartition->size);
|
||||
esp32_ota_mutex_release();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -394,49 +486,199 @@ static esp_err_t esp32_ota_begin(size_t total_size)
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "esp_ota_begin 失败: %s", esp_err_to_name(err));
|
||||
WifiOta::instance().m_esp32UpdatePartition = NULL;
|
||||
esp32_ota_mutex_release();
|
||||
return err;
|
||||
}
|
||||
WifiOta::instance().m_esp32Received = 0;
|
||||
WifiOta::instance().m_esp32OtaActive = true;
|
||||
|
||||
// 清理旧状态(SHA-256 将在重启前从分区读回计算,上传期间不再计算)
|
||||
wifi_esp32_ota_cleanup_sha();
|
||||
|
||||
DEBUG_INFO("OTA", "ESP32 OTA 开始,目标分区: %s", WifiOta::instance().m_esp32UpdatePartition->label);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief HTTP POST /ota/update - 完成 ESP32 OTA 并重启
|
||||
* verify_only=true: 仅校验 SHA-256,不设置启动分区,不重启
|
||||
* verify_only=false或缺省: 完整流程(校验 + 设置启动分区 + 重启)
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
static esp_err_t esp32_ota_update_handler(httpd_req_t *req)
|
||||
{
|
||||
(void)req;
|
||||
http_set_cors_headers(req);
|
||||
if (!WifiOta::instance().m_esp32OtaActive) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA not started");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "ESP32 OTA 完成, 共 %u 字节, 设置启动分区...", WifiOta::instance().m_esp32Received);
|
||||
// 读取请求体,获取前端 SHA-256 和 verify_only 标志
|
||||
char frontend_sha_hex[65] = {0};
|
||||
bool has_frontend_sha = false;
|
||||
bool verify_only = false;
|
||||
if (req->content_len > 0 && req->content_len < 256) {
|
||||
char buf[256] = {0};
|
||||
int ret = httpd_req_recv(req, buf, req->content_len);
|
||||
if (ret > 0) {
|
||||
buf[ret] = '\0';
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
if (root) {
|
||||
cJSON *sha_item = cJSON_GetObjectItem(root, "sha256");
|
||||
if (sha_item && cJSON_IsString(sha_item)) {
|
||||
const char *sha_str = cJSON_GetStringValue(sha_item);
|
||||
if (sha_str && strlen(sha_str) == 64) {
|
||||
strncpy(frontend_sha_hex, sha_str, 64);
|
||||
frontend_sha_hex[64] = '\0';
|
||||
has_frontend_sha = true;
|
||||
strncpy(WifiOta::instance().m_frontendSha256, sha_str, 64);
|
||||
WifiOta::instance().m_frontendSha256[64] = '\0';
|
||||
WifiOta::instance().m_hasFrontendSha256 = true;
|
||||
DEBUG_INFO("OTA", "Frontend SHA-256: %s", frontend_sha_hex);
|
||||
}
|
||||
}
|
||||
cJSON *verify_item = cJSON_GetObjectItem(root, "verify_only");
|
||||
if (verify_item && cJSON_IsBool(verify_item)) {
|
||||
verify_only = cJSON_IsTrue(verify_item);
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verify_only) {
|
||||
DEBUG_INFO("OTA", "ESP32 OTA verify-only mode, %u bytes", (unsigned)WifiOta::instance().m_esp32Received);
|
||||
} else {
|
||||
DEBUG_INFO("OTA", "ESP32 OTA complete, %u bytes, setting boot partition...", (unsigned)WifiOta::instance().m_esp32Received);
|
||||
|
||||
esp_err_t err = esp_ota_end(WifiOta::instance().m_esp32OtaHandle);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "esp_ota_end 失败: %s", esp_err_to_name(err));
|
||||
DEBUG_ERR("OTA", "esp_ota_end failed: %s", esp_err_to_name(err));
|
||||
esp_ota_abort(WifiOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
WifiOta::instance().m_esp32OtaActive = false;
|
||||
WifiOta::instance().m_esp32OtaHandle = 0;
|
||||
wifi_esp32_ota_cleanup_sha();
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err));
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
err = esp_ota_set_boot_partition(WifiOta::instance().m_esp32UpdatePartition);
|
||||
// ── SHA-256 验证:从 OTA 分区读回并计算哈希 ──
|
||||
if (has_frontend_sha && WifiOta::instance().m_esp32UpdatePartition != NULL) {
|
||||
DEBUG_INFO("OTA", "Verifying: reading OTA partition for SHA-256...");
|
||||
const esp_partition_t *part = WifiOta::instance().m_esp32UpdatePartition;
|
||||
mbedtls_sha256_context verify_ctx;
|
||||
mbedtls_sha256_init(&verify_ctx);
|
||||
mbedtls_sha256_starts(&verify_ctx, 0);
|
||||
|
||||
uint8_t *read_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
|
||||
if (!read_buf) {
|
||||
DEBUG_ERR("OTA", "SHA-256 verify: malloc failed");
|
||||
mbedtls_sha256_free(&verify_ctx);
|
||||
} else {
|
||||
size_t offset = 0;
|
||||
while (offset < WifiOta::instance().m_esp32Received) {
|
||||
size_t to_read = WifiOta::instance().m_esp32Received - offset;
|
||||
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
|
||||
esp_err_t rd_err = esp_partition_read(part, offset, read_buf, to_read);
|
||||
if (rd_err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "SHA-256 verify: partition read failed at offset %u", (unsigned int)offset);
|
||||
break;
|
||||
}
|
||||
mbedtls_sha256_update(&verify_ctx, read_buf, to_read);
|
||||
offset += to_read;
|
||||
}
|
||||
free(read_buf);
|
||||
mbedtls_sha256_finish(&verify_ctx, WifiOta::instance().m_esp32Sha256);
|
||||
mbedtls_sha256_free(&verify_ctx);
|
||||
WifiOta::instance().m_esp32HashComputed = true;
|
||||
|
||||
char device_sha_hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&device_sha_hex[i * 2], sizeof(device_sha_hex) - i * 2, "%02x", WifiOta::instance().m_esp32Sha256[i]);
|
||||
}
|
||||
DEBUG_INFO("OTA", "Device SHA-256: %s", device_sha_hex);
|
||||
|
||||
bool match = true;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (tolower((unsigned char)frontend_sha_hex[i]) != tolower((unsigned char)device_sha_hex[i])) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
DEBUG_INFO("OTA", "SHA-256 verified OK");
|
||||
if (verify_only) {
|
||||
char ok_resp[256] = {0};
|
||||
snprintf(ok_resp, sizeof(ok_resp),
|
||||
"{\"status\": \"ok\", \"message\": \"SHA-256 verified\", \"sha256\": \"%s\"}",
|
||||
device_sha_hex);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, ok_resp, strlen(ok_resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
} else {
|
||||
DEBUG_ERR("OTA", "SHA-256 mismatch! frontend=%s device=%s, abort", frontend_sha_hex, device_sha_hex);
|
||||
if (!verify_only) {
|
||||
esp_ota_abort(WifiOta::instance().m_esp32OtaHandle);
|
||||
}
|
||||
wifi_esp32_ota_cleanup_sha();
|
||||
esp32_ota_mutex_release();
|
||||
WifiOta::instance().m_esp32OtaActive = false;
|
||||
WifiOta::instance().m_esp32OtaHandle = 0;
|
||||
WifiOta::instance().m_esp32UpdatePartition = NULL;
|
||||
WifiOta::instance().m_esp32Received = 0;
|
||||
char err_resp[256] = {0};
|
||||
snprintf(err_resp, sizeof(err_resp),
|
||||
"{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}",
|
||||
frontend_sha_hex, device_sha_hex);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, err_resp, strlen(err_resp));
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 非verify_only模式:设置启动分区并重启 ──
|
||||
if (verify_only) {
|
||||
// 没有前端SHA或验证被跳过,在verify_only下也返回ok
|
||||
char ok_resp[128] = {0};
|
||||
snprintf(ok_resp, sizeof(ok_resp),
|
||||
"{\"status\": \"ok\", \"message\": \"verify skipped, no SHA-256 provided\"}");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, ok_resp, strlen(ok_resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_ota_set_boot_partition(WifiOta::instance().m_esp32UpdatePartition);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("OTA", "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
|
||||
DEBUG_ERR("OTA", "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
|
||||
esp32_ota_mutex_release();
|
||||
WifiOta::instance().m_esp32OtaActive = false;
|
||||
wifi_esp32_ota_cleanup_sha();
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err));
|
||||
return ESP_FAIL;
|
||||
}
|
||||
esp32_ota_mutex_release();
|
||||
WifiOta::instance().m_esp32OtaActive = false;
|
||||
DEBUG_INFO("OTA", "ESP32 OTA 完成,重启中...");
|
||||
DEBUG_INFO("OTA", "ESP32 OTA finished, rebooting...");
|
||||
|
||||
char resp[256] = {0};
|
||||
if (WifiOta::instance().m_esp32HashComputed) {
|
||||
char device_sha_hex[65] = {0};
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
snprintf(&device_sha_hex[i * 2], sizeof(device_sha_hex) - i * 2, "%02x", WifiOta::instance().m_esp32Sha256[i]);
|
||||
}
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", \"sha256\": \"%s\"}",
|
||||
device_sha_hex);
|
||||
} else {
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\"}");
|
||||
}
|
||||
|
||||
const char resp[] = "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
|
||||
@@ -580,14 +822,22 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
|
||||
esp_err_t err = esp32_ota_begin(total_size);
|
||||
if (err != ESP_OK) {
|
||||
if (err == ESP_ERR_INVALID_STATE) {
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel");
|
||||
} else {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed");
|
||||
}
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
|
||||
// 堆分配缓冲区,避免栈溢出(HTTP server 栈仅 8KB)
|
||||
uint8_t *fw_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE);
|
||||
if (!fw_buf) {
|
||||
esp_ota_abort(WifiOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
WifiOta::instance().m_esp32OtaActive = false;
|
||||
WifiOta::instance().m_esp32OtaHandle = 0;
|
||||
wifi_esp32_ota_cleanup_sha();
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -601,15 +851,34 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
|
||||
|
||||
int ret = httpd_req_recv(req, (char *)fw_buf, to_read);
|
||||
if (ret <= 0) break;
|
||||
if (ret <= 0) {
|
||||
DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret);
|
||||
free(fw_buf);
|
||||
esp_ota_abort(WifiOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
WifiOta::instance().m_esp32OtaActive = false;
|
||||
WifiOta::instance().m_esp32OtaHandle = 0;
|
||||
wifi_esp32_ota_cleanup_sha();
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
err = esp_ota_write(WifiOta::instance().m_esp32OtaHandle, fw_buf, (size_t)ret);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("WIFI", "OTA write failed");
|
||||
break;
|
||||
free(fw_buf);
|
||||
esp_ota_abort(WifiOta::instance().m_esp32OtaHandle);
|
||||
esp32_ota_mutex_release();
|
||||
WifiOta::instance().m_esp32OtaActive = false;
|
||||
WifiOta::instance().m_esp32OtaHandle = 0;
|
||||
wifi_esp32_ota_cleanup_sha();
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
total_firmware_written += ret;
|
||||
total_received += ret;
|
||||
WifiOta::instance().m_esp32Received += ret;
|
||||
|
||||
if (total_size > 0) {
|
||||
int percent = (int)((total_firmware_written * 100) / total_size);
|
||||
@@ -645,12 +914,14 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "firmware too large or alignment overflow");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (OtaRelay::relayState != RELAY_IDLE) {
|
||||
DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::relayState);
|
||||
if (OtaRelay::getState() != RELAY_IDLE) {
|
||||
DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState());
|
||||
OtaRelay::instance().reset();
|
||||
}
|
||||
WifiOta::instance().m_gd32StorageDone = false;
|
||||
WifiOta::instance().m_relayTriggered = false;
|
||||
WifiOta::instance().stateLock();
|
||||
WifiOta::instance().setGd32StorageDone(false);
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
WifiOta::instance().stateUnlock();
|
||||
|
||||
const esp_partition_t *gd32_partition = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota");
|
||||
@@ -761,8 +1032,16 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
ota_meta_t final_meta;
|
||||
OtaRelay::metaLoad(&final_meta);
|
||||
if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) {
|
||||
WifiOta::instance().m_gd32StorageDone = true;
|
||||
DEBUG_INFO("WIFI", "GD32 firmware fully stored, auto-triggering relay");
|
||||
WifiOta::instance().stateLock();
|
||||
WifiOta::instance().setGd32StorageDone(true);
|
||||
WifiOta::instance().stateUnlock();
|
||||
DEBUG_INFO("WIFI", "GD32 firmware fully stored, computing SHA-256...");
|
||||
// 计算并存储 SHA-256 校验和
|
||||
esp_err_t sha_err = OtaRelay::metaFinalizeChecksum();
|
||||
if (sha_err != ESP_OK) {
|
||||
DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err));
|
||||
}
|
||||
DEBUG_INFO("WIFI", "auto-triggering relay");
|
||||
// 自动触发中继
|
||||
if (OtaRelay::dispatchQueue) {
|
||||
uint32_t cmd = 1; // 1 = start relay
|
||||
@@ -794,14 +1073,18 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (WifiOta::instance().m_relayTriggered && OtaRelay::relayState != RELAY_IDLE &&
|
||||
OtaRelay::relayState != RELAY_DONE && OtaRelay::relayState != RELAY_APP_VERIFY && OtaRelay::relayState != RELAY_ERROR) {
|
||||
relay_state_t curState = OtaRelay::getState();
|
||||
WifiOta::instance().stateLock();
|
||||
if (WifiOta::instance().getRelayTriggered() && curState != RELAY_IDLE &&
|
||||
curState != RELAY_DONE && curState != RELAY_APP_VERIFY && curState != RELAY_ERROR) {
|
||||
WifiOta::instance().stateUnlock();
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay already triggered");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
/* 上一轮中继已结束/失败/空闲,重置标志允许重新触发 */
|
||||
WifiOta::instance().m_relayTriggered = false;
|
||||
WifiOta::instance().m_gd32StorageDone = false;
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
WifiOta::instance().setGd32StorageDone(false);
|
||||
WifiOta::instance().stateUnlock();
|
||||
|
||||
if (!OtaRelay::dispatchQueue) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay queue not ready");
|
||||
@@ -809,7 +1092,7 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
|
||||
}
|
||||
|
||||
WifiOta::instance().signalRelay();
|
||||
if (!WifiOta::instance().m_relayTriggered) {
|
||||
if (!WifiOta::instance().getRelayTriggered()) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay trigger failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -840,18 +1123,20 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
"\"storage_done\": %s, \"relay_state\": %d, \"gd32_app_alive\": %s}",
|
||||
(unsigned long)meta.total_size, (unsigned long)meta.stored_size,
|
||||
(unsigned long)meta.last_seq,
|
||||
WifiOta::instance().m_gd32StorageDone ? "true" : "false",
|
||||
OtaRelay::relayState,
|
||||
OtaRelay::gd32AppAlive ? "true" : "false");
|
||||
WifiOta::instance().getGd32StorageDone() ? "true" : "false",
|
||||
OtaRelay::getState(),
|
||||
OtaRelay::getGd32AppAlive() ? "true" : "false");
|
||||
|
||||
gd32_version_t snapVer = {};
|
||||
OtaRelay::getGd32Version(&snapVer);
|
||||
DEBUG_INFO("WIFI", "OTA 状态: total=%lu, stored=%lu, done=%s, app_alive=%s",
|
||||
(unsigned long)meta.total_size, (unsigned long)meta.stored_size,
|
||||
WifiOta::instance().m_gd32StorageDone ? "true" : "false",
|
||||
OtaRelay::gd32AppAlive ? "true" : "false",
|
||||
OtaRelay::gd32Version.app_major, OtaRelay::gd32Version.app_minor,
|
||||
OtaRelay::gd32Version.app_patch, OtaRelay::gd32Version.app_build,
|
||||
OtaRelay::gd32Version.boot_major, OtaRelay::gd32Version.boot_minor,
|
||||
OtaRelay::gd32Version.boot_patch, OtaRelay::gd32Version.boot_build);
|
||||
WifiOta::instance().getGd32StorageDone() ? "true" : "false",
|
||||
OtaRelay::getGd32AppAlive() ? "true" : "false",
|
||||
snapVer.app_major, snapVer.app_minor,
|
||||
snapVer.app_patch, snapVer.app_build,
|
||||
snapVer.boot_major, snapVer.boot_minor,
|
||||
snapVer.boot_patch, snapVer.boot_build);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
@@ -964,7 +1249,6 @@ static esp_err_t can_send_handler(httpd_req_t *req)
|
||||
if (tx_dlc == 0 && dlc > 0) {
|
||||
tx_dlc = (dlc > CAN_MAX_DLC) ? CAN_MAX_DLC : dlc;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
|
||||
// 发送 CAN 帧到 GD32
|
||||
@@ -1051,7 +1335,6 @@ static esp_err_t spi_send_handler(httpd_req_t *req)
|
||||
|
||||
DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节",
|
||||
frame_type, payload_len);
|
||||
|
||||
cJSON_Delete(root);
|
||||
|
||||
char resp[1024];
|
||||
@@ -1114,7 +1397,7 @@ static esp_err_t wifi_status_handler(httpd_req_t *req)
|
||||
char ip_str[16] = "0.0.0.0";
|
||||
int8_t rssi = -127;
|
||||
|
||||
if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().m_staConnected) {
|
||||
if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) {
|
||||
esp_netif_ip_info_t ip_info;
|
||||
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) {
|
||||
@@ -1130,7 +1413,7 @@ static esp_err_t wifi_status_handler(httpd_req_t *req)
|
||||
"{\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}",
|
||||
WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP ? "AP" : "STA",
|
||||
ip_str, rssi,
|
||||
(WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA) ? (WifiOta::instance().m_staConnected ? "true" : "false") : "N/A");
|
||||
(WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA) ? (WifiOta::instance().getStaConnected() ? "true" : "false") : "N/A");
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
@@ -1144,7 +1427,7 @@ static esp_err_t wifi_status_handler(httpd_req_t *req)
|
||||
static esp_err_t version_handler(httpd_req_t *req)
|
||||
{
|
||||
http_set_cors_headers(req);
|
||||
gd32_version_t gv = OtaRelay::gd32Version;
|
||||
gd32_version_t gv = {}; OtaRelay::getGd32Version(&gv);
|
||||
char resp[512];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", "
|
||||
@@ -1152,7 +1435,7 @@ static esp_err_t version_handler(httpd_req_t *req)
|
||||
FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH,
|
||||
gv.app_major, gv.app_minor, gv.app_patch, gv.app_build,
|
||||
gv.boot_major, gv.boot_minor, gv.boot_patch, gv.boot_build,
|
||||
OtaRelay::gd32AppAlive ? "true" : "false");
|
||||
OtaRelay::getGd32AppAlive() ? "true" : "false");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
return ESP_OK;
|
||||
@@ -1170,7 +1453,7 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
int8_t rssi = -127;
|
||||
bool connected = false;
|
||||
|
||||
if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().m_staConnected) {
|
||||
if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) {
|
||||
esp_netif_ip_info_t ip_info;
|
||||
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) {
|
||||
@@ -1182,11 +1465,13 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
}
|
||||
connected = true;
|
||||
}
|
||||
gd32_version_t pingVer = {};
|
||||
OtaRelay::getGd32Version(&pingVer);
|
||||
|
||||
/* 若尚无 GD32 版本信息且 OTA 中继空闲,尝试一次 SPI PING 获取 */
|
||||
if (OtaRelay::gd32Version.app_major == 0 &&
|
||||
OtaRelay::gd32Version.boot_major == 0 &&
|
||||
OtaRelay::relayState == RELAY_IDLE) {
|
||||
if (pingVer.app_major == 0 &&
|
||||
pingVer.boot_major == 0 &&
|
||||
OtaRelay::getState() == RELAY_IDLE) {
|
||||
static uint32_t last_ping_ms = 0;
|
||||
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
if (now_ms - last_ping_ms > 5000) { /* 5 秒节流 */
|
||||
@@ -1194,10 +1479,10 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
uint32_t uptime = 0;
|
||||
gd32_version_t ver = {};
|
||||
if (SpiMaster::instance().appPing(500, &uptime, &ver) == ESP_OK) {
|
||||
OtaRelay::gd32AppAlive = true;
|
||||
OtaRelay::gd32Version = ver;
|
||||
OtaRelay::setGd32AppAlive(true);
|
||||
OtaRelay::setGd32Version(ver);
|
||||
} else {
|
||||
OtaRelay::gd32AppAlive = false;
|
||||
OtaRelay::setGd32AppAlive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1206,6 +1491,8 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
memset(&meta, 0, sizeof(meta));
|
||||
OtaRelay::metaLoad(&meta);
|
||||
|
||||
gd32_version_t snapVer2 = {};
|
||||
OtaRelay::getGd32Version(&snapVer2);
|
||||
char resp[1024];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, \"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}}",
|
||||
@@ -1220,15 +1507,15 @@ static esp_err_t system_status_handler(httpd_req_t *req)
|
||||
connected ? "true" : "false",
|
||||
(unsigned long)meta.total_size,
|
||||
(unsigned long)meta.stored_size,
|
||||
WifiOta::instance().m_gd32StorageDone ? "true" : "false",
|
||||
OtaRelay::relayState,
|
||||
(unsigned long)OtaRelay::relaySeq,
|
||||
(unsigned long)OtaRelay::relayTotalPackets,
|
||||
OtaRelay::gd32AppAlive ? "true" : "false",
|
||||
OtaRelay::gd32Version.app_major, OtaRelay::gd32Version.app_minor,
|
||||
OtaRelay::gd32Version.app_patch, OtaRelay::gd32Version.app_build,
|
||||
OtaRelay::gd32Version.boot_major, OtaRelay::gd32Version.boot_minor,
|
||||
OtaRelay::gd32Version.boot_patch, OtaRelay::gd32Version.boot_build);
|
||||
WifiOta::instance().getGd32StorageDone() ? "true" : "false",
|
||||
OtaRelay::getState(),
|
||||
(unsigned long)OtaRelay::getRelaySeq(),
|
||||
(unsigned long)OtaRelay::getRelayTotalPackets(),
|
||||
OtaRelay::getGd32AppAlive() ? "true" : "false",
|
||||
snapVer2.app_major, snapVer2.app_minor,
|
||||
snapVer2.app_patch, snapVer2.app_build,
|
||||
snapVer2.boot_major, snapVer2.boot_minor,
|
||||
snapVer2.boot_patch, snapVer2.boot_build);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
@@ -1247,6 +1534,8 @@ esp_err_t WifiOta::httpServerStart()
|
||||
config.server_port = HTTP_SERVER_PORT;
|
||||
config.stack_size = HTTP_SERVER_STACK_SIZE;
|
||||
config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检
|
||||
config.max_open_sockets = 4; // LWIP_MAX_SOCKETS 默认 7,HTTP 内部占 3,最多 4
|
||||
config.lru_purge_enable = true; // socket 满时自动清理最久未用的连接
|
||||
config.recv_wait_timeout = 30; // GD32 上传前会擦除 Flash,5 秒默认值过紧
|
||||
config.send_wait_timeout = 30;
|
||||
|
||||
@@ -1264,7 +1553,7 @@ esp_err_t WifiOta::httpServerStart()
|
||||
ota_meta_t boot_meta;
|
||||
if (OtaRelay::metaLoad(&boot_meta) == ESP_OK &&
|
||||
boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) {
|
||||
WifiOta::instance().m_gd32StorageDone = true;
|
||||
WifiOta::instance().setGd32StorageDone(true);
|
||||
DEBUG_INFO("WIFI", "从 NVS 恢复: 固件已就绪 (%u bytes)", boot_meta.total_size);
|
||||
}
|
||||
}
|
||||
@@ -1501,16 +1790,19 @@ void WifiOta::signalRelay()
|
||||
(unsigned long)meta.stored_size, (unsigned long)meta.total_size);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WifiOta::instance().m_relayTriggered && OtaRelay::relayState != RELAY_IDLE &&
|
||||
OtaRelay::relayState != RELAY_DONE && OtaRelay::relayState != RELAY_APP_VERIFY && OtaRelay::relayState != RELAY_ERROR) {
|
||||
DEBUG_WARN("WIFI", "转发正在执行中,状态: %d", OtaRelay::relayState);
|
||||
relay_state_t st = OtaRelay::getState();
|
||||
WifiOta::instance().stateLock();
|
||||
if (WifiOta::instance().getRelayTriggered() && st != RELAY_IDLE &&
|
||||
st != RELAY_DONE && st != RELAY_APP_VERIFY && st != RELAY_ERROR) {
|
||||
WifiOta::instance().stateUnlock();
|
||||
DEBUG_WARN("WIFI", "转发正在执行中,状态: %d", st);
|
||||
return;
|
||||
}
|
||||
/* 上一轮中继已结束/失败/空闲,重置标志 */
|
||||
WifiOta::instance().m_relayTriggered = false;
|
||||
WifiOta::instance().m_gd32StorageDone = false;
|
||||
WifiOta::instance().m_relayTriggered = true;
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
WifiOta::instance().setGd32StorageDone(false);
|
||||
WifiOta::instance().setRelayTriggered(true);
|
||||
WifiOta::instance().stateUnlock();
|
||||
|
||||
if (OtaRelay::dispatchQueue) {
|
||||
uint32_t msg = 1;
|
||||
@@ -1519,11 +1811,11 @@ void WifiOta::signalRelay()
|
||||
DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "OTA 调度信号发送失败");
|
||||
WifiOta::instance().m_relayTriggered = false;
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
}
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "OTA 中继队列尚未就绪");
|
||||
WifiOta::instance().m_relayTriggered = false;
|
||||
WifiOta::instance().setRelayTriggered(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1542,7 +1834,7 @@ ota_wifi_mode_t WifiOta::getMode()
|
||||
*/
|
||||
int8_t WifiOta::getRssi()
|
||||
{
|
||||
if (WifiOta::instance().m_wifiMode != OTA_WIFI_MODE_STA || !WifiOta::instance().m_staConnected) {
|
||||
if (WifiOta::instance().m_wifiMode != OTA_WIFI_MODE_STA || !WifiOta::instance().getStaConnected()) {
|
||||
return -127;
|
||||
}
|
||||
wifi_ap_record_t ap_info;
|
||||
@@ -1558,7 +1850,7 @@ int8_t WifiOta::getRssi()
|
||||
*/
|
||||
bool WifiOta::isConnected()
|
||||
{
|
||||
return WifiOta::instance().m_staConnected;
|
||||
return WifiOta::instance().getStaConnected();
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
@@ -1631,12 +1923,36 @@ bool WifiOta::earlyInit()
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 创建状态互斥锁(保护 WiFi 连接状态、OTA 中继标志等跨任务共享变量)
|
||||
if (WifiOta::instance().getStateMutex() == NULL) {
|
||||
SemaphoreHandle_t mtx = xSemaphoreCreateMutex();
|
||||
if (mtx) {
|
||||
// 通过临时获取再赋值(私有成员在同类内可访问)
|
||||
WifiOta::instance().m_stateMutex = mtx;
|
||||
DEBUG_INFO("WIFI", "状态互斥锁已创建");
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "创建状态互斥锁失败");
|
||||
}
|
||||
}
|
||||
|
||||
WifiOta::instance().m_staConnectedSem = xSemaphoreCreateBinary();
|
||||
WifiOta::instance().m_staGotIpSem = xSemaphoreCreateBinary();
|
||||
if (!WifiOta::instance().m_staConnectedSem || !WifiOta::instance().m_staGotIpSem) {
|
||||
DEBUG_ERR("WIFI", "创建信号量失败");
|
||||
}
|
||||
|
||||
// 创建后台重连任务:避免在系统事件任务中调用 esp_wifi_connect()/vTaskDelay 导致死锁
|
||||
if (WifiOta::instance().m_reconnectTask == nullptr) {
|
||||
BaseType_t ret = xTaskCreate(&WifiOta::reconnectTask, "wifi_reconnect",
|
||||
4096, NULL, 5,
|
||||
&WifiOta::instance().m_reconnectTask);
|
||||
if (ret != pdPASS) {
|
||||
DEBUG_ERR("WIFI", "创建重连任务失败");
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "WiFi 重连任务已创建");
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 WiFi/IP 事件回调(必须在 esp_wifi_init 之前)
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &WifiOta::wifiEventHandler, NULL));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &WifiOta::wifiEventHandler, NULL));
|
||||
@@ -1776,5 +2092,3 @@ void WifiOta::taskEntry(void *pvParameters)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+38
-7
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* wifi_ota.h - WiFi OTA 服务器
|
||||
* T4.1 - T4.5
|
||||
*
|
||||
@@ -11,7 +11,9 @@
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_event.h"
|
||||
#include <atomic>
|
||||
|
||||
// SHA-256 验证所需类型前向声明
|
||||
typedef enum {
|
||||
OTA_WIFI_MODE_AP,
|
||||
OTA_WIFI_MODE_STA
|
||||
@@ -29,28 +31,57 @@ public:
|
||||
static int8_t getRssi();
|
||||
static bool isConnected();
|
||||
|
||||
// 以下状态为 public,供 C 回调函数访问
|
||||
// ── WiFi 连接状态(WiFi 事件回调 / HTTP / reconnect 任务共享,需 mutex 保护)──
|
||||
bool getStaConnected() const { return m_staConnected.load(); }
|
||||
void setStaConnected(bool v) { m_staConnected.store(v); }
|
||||
int getStaRetryCount() const { return m_staRetryCount.load(); }
|
||||
void setStaRetryCount(int v) { m_staRetryCount.store(v); }
|
||||
bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); }
|
||||
void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); }
|
||||
bool getGd32StorageDone() const { return m_gd32StorageDone.load(); }
|
||||
void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); }
|
||||
bool getRelayTriggered() const { return m_relayTriggered.load(); }
|
||||
void setRelayTriggered(bool v) { m_relayTriggered.store(v); }
|
||||
|
||||
// ── 状态互斥锁(保护复合读-改-写操作)──
|
||||
SemaphoreHandle_t getStateMutex() const { return m_stateMutex; }
|
||||
void stateLock() { if (m_stateMutex) xSemaphoreTake(m_stateMutex, portMAX_DELAY); }
|
||||
void stateUnlock() { if (m_stateMutex) xSemaphoreGive(m_stateMutex); }
|
||||
|
||||
// ── 初始化/运行时状态(仅 OTA 任务访问,无需额外锁)──
|
||||
ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP;
|
||||
bool m_coldBoot = false;
|
||||
bool m_relayTriggered = false;
|
||||
bool m_staConnected = false;
|
||||
int m_staRetryCount = 0;
|
||||
bool m_staRetryExhausted = false;
|
||||
SemaphoreHandle_t m_staConnectedSem = NULL;
|
||||
SemaphoreHandle_t m_staGotIpSem = NULL;
|
||||
TaskHandle_t m_reconnectTask = nullptr;
|
||||
|
||||
bool m_esp32OtaActive = false;
|
||||
esp_ota_handle_t m_esp32OtaHandle = 0;
|
||||
const esp_partition_t *m_esp32UpdatePartition = NULL;
|
||||
size_t m_esp32Received = 0;
|
||||
bool m_gd32StorageDone = false;
|
||||
httpd_handle_t m_httpServer = NULL;
|
||||
|
||||
// ── SHA-256 哈希校验 ──
|
||||
uint8_t m_esp32Sha256[32] = {0}; // 重启前验证计算的SHA-256哈希值
|
||||
bool m_esp32HashComputed = false; // 是否已计算哈希
|
||||
char m_frontendSha256[65] = {0}; // 前端提供的SHA-256(上传时存储)
|
||||
bool m_hasFrontendSha256 = false; // 前端是否提供了SHA-256
|
||||
|
||||
private:
|
||||
// ── 跨任务共享状态(atomic + m_stateMutex 保护)──
|
||||
std::atomic<bool> m_staConnected{false};
|
||||
std::atomic<int> m_staRetryCount{0};
|
||||
std::atomic<bool> m_staRetryExhausted{false};
|
||||
std::atomic<bool> m_gd32StorageDone{false};
|
||||
std::atomic<bool> m_relayTriggered{false};
|
||||
SemaphoreHandle_t m_stateMutex = NULL;
|
||||
|
||||
void initAp();
|
||||
esp_err_t initSta();
|
||||
void setStaticIp();
|
||||
static void wifiEventHandler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data);
|
||||
static void reconnectTask(void *pvParameters);
|
||||
esp_err_t httpServerStart();
|
||||
esp_err_t waitStaConnected(char *ip_str, size_t ip_len);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <stdio.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "soc/spi_struct.h"
|
||||
#include "esp_err.h"
|
||||
@@ -32,7 +33,8 @@ SpiMaster& SpiMaster::instance()
|
||||
// 内部 SPI 传输
|
||||
// ============================================================
|
||||
esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len,
|
||||
uint8_t *rx_buf, size_t rx_len)
|
||||
uint8_t *rx_buf, size_t rx_len,
|
||||
uint32_t timeout_ms)
|
||||
{
|
||||
if (m_spiDevice == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
@@ -75,21 +77,33 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len,
|
||||
t.tx_buffer = tx_ptr;
|
||||
t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
|
||||
|
||||
esp_err_t ret = spi_device_transmit(m_spiDevice, &t);
|
||||
TickType_t ticks = (timeout_ms == 0U) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms);
|
||||
esp_err_t ret = spi_device_queue_trans(m_spiDevice, &t, ticks);
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_WARN("SPI", "spi_device_transmit fail: %s len=%u tx=%p rx=%p",
|
||||
esp_err_to_name(ret), (unsigned)(total_len * 8U), t.tx_buffer, t.rx_buffer);
|
||||
DEBUG_WARN("SPI", "spi_device_queue_trans fail: %s len=%u",
|
||||
esp_err_to_name(ret), (unsigned)(total_len * 8U));
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (ret == ESP_OK && rx_len > 0U && rx_ptr != rx_buf) {
|
||||
spi_transaction_t *ret_trans = NULL;
|
||||
ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, ticks);
|
||||
(void)ret_trans;
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_WARN("SPI", "spi_device_get_trans_result timeout/fail: %s len=%u tx=%p rx=%p",
|
||||
esp_err_to_name(ret), (unsigned)(total_len * 8U), t.tx_buffer, t.rx_buffer);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (rx_len > 0U && rx_ptr != rx_buf) {
|
||||
memcpy(rx_buf, rx_ptr, rx_len);
|
||||
}
|
||||
return ret;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t SpiMaster::transferRequestResponse(uint8_t cmd,
|
||||
const uint8_t *payload, size_t payload_len,
|
||||
uint8_t *rx_buf, size_t rx_len)
|
||||
uint8_t *rx_buf, size_t rx_len,
|
||||
uint32_t timeout_ms)
|
||||
{
|
||||
if ((payload_len > 0U && payload == NULL) || (rx_len > 0U && rx_buf == NULL)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -121,7 +135,8 @@ esp_err_t SpiMaster::transferRequestResponse(uint8_t cmd,
|
||||
|
||||
esp_err_t ret = transferClocked(tx_staging, total_len,
|
||||
(rx_len > 0U) ? rx_staging : NULL,
|
||||
(rx_len > 0U) ? total_len : 0U);
|
||||
(rx_len > 0U) ? total_len : 0U,
|
||||
timeout_ms);
|
||||
if (ret == ESP_OK && rx_len > 0U) {
|
||||
memcpy(rx_buf, &rx_staging[cmd_len + payload_len], rx_len);
|
||||
}
|
||||
@@ -209,6 +224,14 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
|
||||
// ============================================================
|
||||
esp_err_t SpiMaster::init()
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
// 创建 SPI 总线互斥锁(必须在 SPI 设备初始化之前)
|
||||
m_mutex = xSemaphoreCreateMutex();
|
||||
if (m_mutex == NULL) {
|
||||
DEBUG_ERR("SPI", "SPI 互斥锁创建失败");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
DEBUG_INFO("SPI", "=== 初始化 SPI2 总线 ===");
|
||||
|
||||
spi_bus_config_t bus_cfg = {};
|
||||
@@ -255,19 +278,20 @@ esp_err_t SpiMaster::syncTransfer(uint8_t cmd, const uint8_t *payload,
|
||||
size_t payload_len, uint8_t *recv_buf,
|
||||
size_t recv_len, uint32_t timeout_ms)
|
||||
{
|
||||
(void)timeout_ms;
|
||||
return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len);
|
||||
LockGuard lock(m_mutex);
|
||||
return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len, timeout_ms);
|
||||
}
|
||||
|
||||
esp_err_t SpiMaster::send(uint8_t cmd, const uint8_t *payload, size_t len)
|
||||
{
|
||||
return transferRequestResponse(cmd, payload, len, NULL, 0);
|
||||
LockGuard lock(m_mutex);
|
||||
return transferRequestResponse(cmd, payload, len, NULL, 0, 0U);
|
||||
}
|
||||
|
||||
esp_err_t SpiMaster::recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms)
|
||||
{
|
||||
(void)timeout_ms;
|
||||
return transferRequestResponse(cmd, NULL, 0, recv_buf, len);
|
||||
LockGuard lock(m_mutex);
|
||||
return transferRequestResponse(cmd, NULL, 0, recv_buf, len, timeout_ms);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -277,6 +301,7 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa
|
||||
uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
uint8_t tx_frame[SPI_OTA_FRAME_MAX];
|
||||
size_t tx_len = 0;
|
||||
if (!otaBuildFrame(type, payload, payload_len, tx_frame, &tx_len)) {
|
||||
@@ -285,17 +310,24 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa
|
||||
|
||||
DEBUG_DBG("SPI", "OTA TX: type=0x%02X len=%u first4=%02X%02X%02X%02X",
|
||||
type, (unsigned)tx_len, tx_frame[0], tx_frame[1], tx_frame[2], tx_frame[3]);
|
||||
esp_err_t ret = transferClocked(tx_frame, tx_len, NULL, 0);
|
||||
esp_err_t ret = transferClocked(tx_frame, tx_len, NULL, 0, timeout_ms);
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_WARN("SPI", "OTA 请求发送失败: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
return otaPollResponse(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
|
||||
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
|
||||
}
|
||||
|
||||
esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
|
||||
}
|
||||
|
||||
esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
|
||||
{
|
||||
constexpr size_t kPollBytes = 96U;
|
||||
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
|
||||
@@ -305,7 +337,12 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
uint8_t dummy[kPollBytes];
|
||||
memset(dummy, 0xFF, sizeof(dummy));
|
||||
|
||||
esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw));
|
||||
/* 用剩余时间作为单次 SPI 事务超时,避免总等待远超 deadline */
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
uint32_t remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U;
|
||||
if (remain_ms == 0U) break;
|
||||
|
||||
esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), remain_ms);
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_WARN("SPI", "OTA 响应读取失败: %s", esp_err_to_name(ret));
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
@@ -355,6 +392,7 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
|
||||
esp_err_t SpiMaster::appRequestBootloader()
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
DEBUG_INFO("SPI", "请求 GD32 APP 进入 Bootloader...");
|
||||
|
||||
constexpr uint32_t kMaxRetries = 3U;
|
||||
@@ -393,6 +431,7 @@ esp_err_t SpiMaster::appRequestBootloader()
|
||||
|
||||
esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
constexpr uint8_t kAppPingType = 0xA1U;
|
||||
constexpr size_t kAppPingPollBytes = 32U;
|
||||
|
||||
@@ -511,13 +550,14 @@ esp_err_t SpiMaster::transferWithRetry(uint8_t cmd, const uint8_t *payload,
|
||||
size_t payload_len, uint8_t *recv_buf,
|
||||
size_t recv_len, int max_retries)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
esp_err_t ret;
|
||||
int retry_count = 0;
|
||||
|
||||
DEBUG_INFO("SPI", "SPI 传输 (带重试): cmd=0x%02X, 最大重试=%d", cmd, max_retries);
|
||||
|
||||
while (retry_count < max_retries) {
|
||||
ret = syncTransfer(cmd, payload, payload_len,
|
||||
ret = syncTransferUnlocked(cmd, payload, payload_len,
|
||||
recv_buf, recv_len, 3000);
|
||||
if (ret == ESP_OK) {
|
||||
if (retry_count > 0) {
|
||||
@@ -544,6 +584,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id,
|
||||
uint8_t *recv_buf, uint8_t recv_max,
|
||||
uint32_t timeout_ms)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
|
||||
return -1;
|
||||
}
|
||||
@@ -559,7 +600,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id,
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
|
||||
esp_err_t ret = syncTransfer(0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms);
|
||||
esp_err_t ret = syncTransferUnlocked(0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms);
|
||||
if (ret != ESP_OK) {
|
||||
return -1;
|
||||
}
|
||||
@@ -570,6 +611,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id,
|
||||
int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
const uint8_t *data, uint8_t dlc)
|
||||
{
|
||||
LockGuard lock(m_mutex);
|
||||
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
|
||||
return -1;
|
||||
}
|
||||
@@ -585,7 +627,7 @@ int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
memcpy(&tx_buf[tx_len], data, dlc);
|
||||
tx_len += dlc;
|
||||
|
||||
esp_err_t ret = send(0, tx_buf, tx_len);
|
||||
esp_err_t ret = sendUnlocked(0, tx_buf, tx_len);
|
||||
if (ret != ESP_OK) {
|
||||
return -1;
|
||||
}
|
||||
@@ -593,4 +635,17 @@ int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id,
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 无锁内部版本实现
|
||||
// ============================================================
|
||||
esp_err_t SpiMaster::syncTransferUnlocked(uint8_t cmd, const uint8_t *payload,
|
||||
size_t payload_len, uint8_t *recv_buf,
|
||||
size_t recv_len, uint32_t timeout_ms)
|
||||
{
|
||||
return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len, timeout_ms);
|
||||
}
|
||||
|
||||
esp_err_t SpiMaster::sendUnlocked(uint8_t cmd, const uint8_t *payload, size_t len)
|
||||
{
|
||||
return transferRequestResponse(cmd, payload, len, NULL, 0, 0U);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
#include <stdint.h>
|
||||
#include "driver/spi_master.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
// SPI 配置常量
|
||||
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
|
||||
@@ -23,7 +25,7 @@
|
||||
#define SPI_OTA_CMD_DATA (0x02)
|
||||
#define SPI_OTA_CMD_CANCEL (0x03)
|
||||
#define SPI_APP_ENTER_BOOT_CMD (0xB0)
|
||||
#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5)
|
||||
#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5) // GD32 APP 固件须同步修改 ACK 字节为 0xB5
|
||||
#define SPI_OTA_RSP_ACK (0x80)
|
||||
#define SPI_OTA_RSP_WAIT (0x81)
|
||||
#define SPI_OTA_RSP_ERROR (0x82)
|
||||
@@ -140,13 +142,39 @@ private:
|
||||
SpiMaster& operator=(const SpiMaster&) = delete;
|
||||
|
||||
spi_device_handle_t m_spiDevice = NULL;
|
||||
SemaphoreHandle_t m_mutex = NULL;
|
||||
|
||||
// RAII 锁守卫
|
||||
class LockGuard {
|
||||
public:
|
||||
explicit LockGuard(SemaphoreHandle_t mutex) : m_lock(mutex) {
|
||||
if (m_lock) { xSemaphoreTake(m_lock, portMAX_DELAY); }
|
||||
}
|
||||
~LockGuard() {
|
||||
if (m_lock) { xSemaphoreGive(m_lock); }
|
||||
}
|
||||
LockGuard(const LockGuard&) = delete;
|
||||
LockGuard& operator=(const LockGuard&) = delete;
|
||||
private:
|
||||
SemaphoreHandle_t m_lock;
|
||||
};
|
||||
|
||||
// 内部 SPI 传输
|
||||
esp_err_t transferClocked(const uint8_t *tx_buf, size_t tx_len,
|
||||
uint8_t *rx_buf, size_t rx_len);
|
||||
uint8_t *rx_buf, size_t rx_len,
|
||||
uint32_t timeout_ms = 0U);
|
||||
esp_err_t transferRequestResponse(uint8_t cmd, const uint8_t *payload, size_t payload_len,
|
||||
uint8_t *rx_buf, size_t rx_len);
|
||||
uint8_t *rx_buf, size_t rx_len,
|
||||
uint32_t timeout_ms = 0U);
|
||||
|
||||
|
||||
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
|
||||
esp_err_t syncTransferUnlocked(uint8_t cmd, const uint8_t *payload,
|
||||
size_t payload_len, uint8_t *recv_buf,
|
||||
size_t recv_len, uint32_t timeout_ms);
|
||||
esp_err_t sendUnlocked(uint8_t cmd, const uint8_t *payload, size_t len);
|
||||
esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
|
||||
// OTA 帧工具(静态,无状态)
|
||||
static uint8_t otaCrc8(const uint8_t *data, size_t len);
|
||||
static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import urllib.request, json, time, sys
|
||||
|
||||
ESP_IP = "192.168.31.127"
|
||||
|
||||
# Step 1: Upload ESP32 firmware
|
||||
bin_path = r"G:\Robot_v2\DCU\ESP32_COMM\build\ESP32_COMM.bin"
|
||||
with open(bin_path, "rb") as f:
|
||||
fw_data = f.read()
|
||||
|
||||
header = json.dumps({"type": "esp32", "total_size": len(fw_data)}).encode("utf-8")
|
||||
body = header + fw_data
|
||||
print(f"[1/3] Uploading {len(fw_data)} bytes...")
|
||||
|
||||
req = urllib.request.Request(f"http://{ESP_IP}/ota/upload", data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
result = resp.read().decode()
|
||||
print(f" Upload OK: {result}")
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode()
|
||||
print(f" Upload FAILED: HTTP {e.code} - {body_text}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Trigger update
|
||||
print("[2/3] Triggering OTA update...")
|
||||
time.sleep(1)
|
||||
req2 = urllib.request.Request(f"http://{ESP_IP}/ota/update", data=b"", method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req2, timeout=10) as resp:
|
||||
result2 = resp.read().decode()
|
||||
print(f" Update OK: {result2}")
|
||||
except (ConnectionResetError, urllib.error.URLError, OSError) as e:
|
||||
print(f" Update triggered (connection reset expected): {type(e).__name__}")
|
||||
|
||||
# Step 3: Wait for ESP32 to come back
|
||||
print("[3/3] Waiting for ESP32 to reboot with new firmware...")
|
||||
for attempt in range(1, 31):
|
||||
time.sleep(5)
|
||||
try:
|
||||
req3 = urllib.request.Request(f"http://{ESP_IP}/system/status")
|
||||
with urllib.request.urlopen(req3, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
ver = data.get("version", "?")
|
||||
bld = data.get("build_time", "?")
|
||||
upt = data.get("uptime_sec", 0)
|
||||
print(f" Online! version={ver} build={bld} uptime={upt}s")
|
||||
break
|
||||
except Exception:
|
||||
if attempt % 5 == 0:
|
||||
print(f" Still waiting... ({attempt}/30)")
|
||||
else:
|
||||
print(" ESP32 did not come back within 150 seconds")
|
||||
@@ -1,12 +0,0 @@
|
||||
*** Begin Patch
|
||||
*** Update File: G:\Robot_v2\DCU\ESP32_COMM\main\transport\spi_master.h
|
||||
@@ #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
|
||||
- #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
|
||||
+ #define SPI_CLOCK_HZ (10 * 1000 * 1000) // 10MHz
|
||||
@@ #define SPI_QUEUE_SIZE (1)
|
||||
- #define SPI_QUEUE_SIZE (1)
|
||||
+ #define SPI_QUEUE_SIZE (3)
|
||||
@@ #define SPI_OTA_FRAME_MAX (96)
|
||||
- #define SPI_OTA_FRAME_MAX (96)
|
||||
+ #define SPI_OTA_FRAME_MAX (128)
|
||||
*** End Patch
|
||||
@@ -1,5 +1,6 @@
|
||||
# WiFi perf -- OTA acceleration
|
||||
CONFIG_ESP_WIFI_RX_BA_WIN=6
|
||||
CONFIG_ESP_WIFI_TX_BA_WIN=6
|
||||
CONFIG_LWIP_MAX_SOCKETS=10
|
||||
CONFIG_LWIP_TCP_WND_DEFAULT=11480
|
||||
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import serial, sys, time
|
||||
ser = serial.Serial('COM15', 115200, timeout=1)
|
||||
outfile = open('G:\\Robot_v2\\DCU\\ESP32_COMM\\serial_log.txt', 'w', encoding='utf-8')
|
||||
start = time.time()
|
||||
while time.time() - start < 300: # 最多 5 分钟
|
||||
line = ser.readline()
|
||||
if line:
|
||||
try:
|
||||
text = line.decode('utf-8', errors='replace').rstrip()
|
||||
outfile.write(text + '\n')
|
||||
outfile.flush()
|
||||
except:
|
||||
pass
|
||||
outfile.close()
|
||||
ser.close()
|
||||
+503
-184
File diff suppressed because it is too large
Load Diff
+470
-365
File diff suppressed because it is too large
Load Diff
+32
-9
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 OTA 中继控制台</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260530b">
|
||||
<link rel="stylesheet" href="style.css?v=20260614c">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -68,8 +68,11 @@
|
||||
<div class="file-meta-item"><span>类型</span><b id="fileTypeEsp32">-</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="fileSizeEsp32">-</b></div>
|
||||
</div>
|
||||
<div class="file-meta-item" style="margin-top:6px;">
|
||||
<span>SHA-256</span>
|
||||
<b id="fileSha256Esp32" style="font-size:10px;word-break:break-all;font-family:monospace;">-</b>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-primary btn-block" id="uploadEsp32Btn" onclick="startUpload('esp32')" disabled style="margin-top:10px;">
|
||||
上传并升级 ESP32
|
||||
</button>
|
||||
@@ -96,7 +99,6 @@
|
||||
<div class="file-meta-item"><span>大小</span><b id="fileSizeGd32">-</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled style="margin-top:10px;margin-bottom:8px;">
|
||||
上传固件到缓存
|
||||
</button>
|
||||
@@ -177,11 +179,32 @@
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">🔀</span> OTA 流程阶段</div>
|
||||
<div class="card-body">
|
||||
<div class="stage-flow">
|
||||
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">握手认证</div></div>
|
||||
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输准备</div></div>
|
||||
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div></div>
|
||||
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">完成跳转</div></div>
|
||||
<!-- ESP32 OTA stages -->
|
||||
<div class="stage-flow" id="esp32Stages" style="display:none;">
|
||||
<div class="stage-node" id="es1"><div class="stage-dot">1</div><div class="stage-label">固件上传</div><div class="stage-detail" id="esd1"></div></div>
|
||||
<div class="stage-node" id="es2"><div class="stage-dot">2</div><div class="stage-label">OTA 写入</div><div class="stage-detail" id="esd2"></div></div>
|
||||
<div class="stage-node" id="es3"><div class="stage-dot">3</div><div class="stage-label">SHA-256 校验</div><div class="stage-detail" id="esd3"></div></div>
|
||||
<div class="stage-node" id="es4"><div class="stage-dot">4</div><div class="stage-label">应用固件</div><div class="stage-detail" id="esd4"></div></div>
|
||||
<div class="stage-node" id="es5"><div class="stage-dot">5</div><div class="stage-label">重启中</div><div class="stage-detail" id="esd5"></div></div>
|
||||
<div class="stage-node" id="es6"><div class="stage-dot">6</div><div class="stage-label">启动完成</div><div class="stage-detail" id="esd6"></div></div>
|
||||
</div>
|
||||
<!-- GD32 OTA stages -->
|
||||
<div class="stage-flow" id="gd32Stages">
|
||||
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">认证握手</div><div class="stage-detail" id="sd1"></div></div>
|
||||
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输协商</div><div class="stage-detail" id="sd2"></div></div>
|
||||
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div><div class="stage-detail" id="sd3"></div></div>
|
||||
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">传输完成</div><div class="stage-detail" id="sd4"></div></div>
|
||||
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">OTA 刷写</div><div class="stage-detail" id="sd5"></div></div>
|
||||
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">校验验证</div><div class="stage-detail" id="sd6"></div></div>
|
||||
</div>
|
||||
<div class="stage-status-bar" id="stageStatusBar">
|
||||
<span class="ssb-dot" id="stageStatusDot"></span>
|
||||
<span class="ssb-text" id="stageStatusText">待机</span>
|
||||
</div>
|
||||
<div class="stage-verify-info" id="stageVerifyInfo" style="display:none;">
|
||||
<div class="svi-row"><span class="svi-key">前端 SHA-256</span><span class="svi-val" id="sviFrontSha">-</span></div>
|
||||
<div class="svi-row"><span class="svi-key">设备 SHA-256</span><span class="svi-val" id="sviDeviceSha">-</span></div>
|
||||
<div class="svi-row"><span class="svi-key">校验结果</span><span class="svi-val" id="sviResult">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,7 +294,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260603b"></script>
|
||||
<script src="app.js?v=20260614f"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+134
-12
@@ -321,7 +321,7 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
|
||||
/* === PRIMARY STATUS BADGES (large) === */
|
||||
.badge-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
|
||||
}
|
||||
.badge-stat {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
@@ -373,9 +373,106 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
.stage-label {
|
||||
font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3;
|
||||
}
|
||||
.stage-detail {
|
||||
font-size: 10px; color: var(--accent); text-align: center; line-height: 1.2;
|
||||
margin-top: 2px; min-height: 14px;
|
||||
font-family: var(--font-mono);
|
||||
transition: color .3s;
|
||||
}
|
||||
.stage-node.live .stage-label,
|
||||
.stage-node.done .stage-label,
|
||||
.stage-node.err .stage-label { color: var(--text); }
|
||||
.stage-node.live .stage-detail { color: var(--accent); }
|
||||
.stage-node.done .stage-detail { color: var(--green); }
|
||||
.stage-node.err .stage-detail { color: var(--red); }
|
||||
|
||||
/* Stage status bar */
|
||||
.stage-status-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-top: 14px; padding: 10px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
transition: all .3s;
|
||||
}
|
||||
.stage-status-bar .ssb-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-dim);
|
||||
transition: all .3s;
|
||||
}
|
||||
.stage-status-bar .ssb-text {
|
||||
color: var(--text-dim); font-size: 13px; font-weight: 500;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
/* idle — muted, with subtle pulse */
|
||||
.stage-status-bar .ssb-dot { animation: dotPulse 2.5s ease-in-out infinite; }
|
||||
@keyframes dotPulse {
|
||||
0%, 100% { opacity: .45; }
|
||||
50% { opacity: .8; }
|
||||
}
|
||||
/* active — accent colored */
|
||||
.stage-status-bar.active {
|
||||
border-color: rgba(34,211,238,.25);
|
||||
background: linear-gradient(135deg, rgba(34,211,238,.06), rgba(34,211,238,.02));
|
||||
}
|
||||
.stage-status-bar.active .ssb-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-dim);
|
||||
animation: dotPulseLive 1.2s ease-in-out infinite;
|
||||
}
|
||||
.stage-status-bar.active .ssb-text { color: var(--accent); font-weight: 600; }
|
||||
@keyframes dotPulseLive {
|
||||
0%, 100% { opacity: .7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.25); }
|
||||
}
|
||||
/* ok — green */
|
||||
.stage-status-bar.ok {
|
||||
border-color: rgba(34,197,94,.25);
|
||||
background: linear-gradient(135deg, rgba(34,197,94,.06), rgba(34,197,94,.02));
|
||||
}
|
||||
.stage-status-bar.ok .ssb-dot {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px rgba(34,197,94,.3);
|
||||
animation: none;
|
||||
}
|
||||
.stage-status-bar.ok .ssb-text { color: var(--green); font-weight: 600; }
|
||||
/* err — red */
|
||||
.stage-status-bar.err {
|
||||
border-color: rgba(239,68,68,.25);
|
||||
background: linear-gradient(135deg, rgba(239,68,68,.06), rgba(239,68,68,.02));
|
||||
}
|
||||
.stage-status-bar.err .ssb-dot {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px rgba(239,68,68,.3);
|
||||
animation: none;
|
||||
}
|
||||
.stage-status-bar.err .ssb-text { color: var(--red); font-weight: 600; }
|
||||
|
||||
/* Stage verification info panel */
|
||||
.stage-verify-info {
|
||||
margin-top: 12px; padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
}
|
||||
.svi-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 6px 0; font-size: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.03);
|
||||
}
|
||||
.svi-row:last-child { border-bottom: none; }
|
||||
.svi-key {
|
||||
color: var(--text-label); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .06em; font-size: 10px;
|
||||
min-width: 100px; flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.svi-val {
|
||||
color: var(--text-dim); font-family: var(--font-mono); font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.svi-val.ok { color: var(--green); font-weight: 600; }
|
||||
.svi-val.err { color: var(--red); font-weight: 600; }
|
||||
|
||||
/* === LOG PANEL === */
|
||||
.log-panel { display: flex; flex-direction: column; min-height: 380px; }
|
||||
@@ -417,6 +514,9 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
.log-line .lv.D { color: var(--text-label); }
|
||||
.log-line .lg { color: #a78bfa; flex-shrink: 0; }
|
||||
.log-line .lm { color: var(--text); word-break: break-all; }
|
||||
/* BLE log compat */
|
||||
.log-line .t { color: var(--text-label); flex-shrink: 0; }
|
||||
.log-line .tag { color: #a78bfa; flex-shrink: 0; }
|
||||
|
||||
.log-foot {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
@@ -434,19 +534,41 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
|
||||
/* === TOAST === */
|
||||
.toast {
|
||||
position: fixed; bottom: 28px; right: 28px; z-index: 2000;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 14px 20px;
|
||||
position: fixed; top: 45%; left: 50%; transform: translateX(-50%) translateY(30px);
|
||||
z-index: 9999;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
transform: translateY(120px); opacity: 0;
|
||||
transition: transform .25s ease, opacity .25s ease;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.55);
|
||||
padding: 14px 24px; border-radius: 10px;
|
||||
font-size: 14px; font-weight: 600; color: #fff; pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: all .35s cubic-bezier(.4,0,.2,1);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.35);
|
||||
}
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.ok { border-left: 3px solid var(--green); }
|
||||
.toast.err { border-left: 3px solid var(--red); }
|
||||
.toast.warn { border-left: 3px solid var(--amber); }
|
||||
.toast .toast-ico { font-size: 20px; }
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
|
||||
.toast.ok { background: #16a34a; }
|
||||
.toast.err { background: #dc2626; }
|
||||
.toast.warn { background: #d97706; }
|
||||
.toast .toast-ico { font-size: 18px; }
|
||||
|
||||
/* === GRID ROWS === */
|
||||
.row-2, .row-3, .row-4 { display: grid; gap: 10px; }
|
||||
.row-2 { grid-template-columns: 1fr 1fr; }
|
||||
.row-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.row-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
|
||||
/* === SUB ACTIONS === */
|
||||
.sub-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
|
||||
|
||||
/* === FOOTER NOTE === */
|
||||
.footer-note { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--text-dim); }
|
||||
|
||||
/* === CODE === */
|
||||
code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
|
||||
/* === FILE INFO BORDER === */
|
||||
.file-info { border: 1px solid var(--border); }
|
||||
|
||||
/* === PILL LIVE === */
|
||||
.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; }
|
||||
|
||||
/* === RESPONSIVE === */
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
Reference in New Issue
Block a user