feat(ota): 实现GD32安全握手认证和OTA中继功能

- 更新固件版本从0.2.0到0.2.7
- 替换默认密钥为实际密钥值
- 添加GD32芯片UID运行时获取机制
- 实现AES-128加密验证challenge响应
- 集成NVS持久化存储OTA元数据
- 改进数据包传输可靠性,添加ACK确认和重试机制
- 新增结构化错误响应解析功能
- 实现GD32应用通知进入Bootloader流程
- 添加详细函数文档注释

refactor(ble): 优化BLE OTA服务端代码结构

- 为所有关键函数添加详细的Doxygen注释
- 改进固件大小验证逻辑,支持4字节对齐计算
- 优化数据块接收和处理流程
This commit is contained in:
小龙虾
2026-05-26 02:44:57 +08:00
parent adb7dc65ca
commit e014ca7704
15 changed files with 2648 additions and 1728 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"idf.openOcdConfigs": [
"board/esp32c3-bridge.cfg"
],
"idf.portWin": "COM13",
"idf.portWin": "COM15",
"idf.currentSetup": "F:\\software\\.espressif\\v6.0\\esp-idf",
"idf.customExtraVars": {
"IDF_TARGET": "esp32c3"
+8 -4
View File
@@ -10,7 +10,7 @@
// ============================================================
// 固件版本
// ============================================================
inline constexpr const char *FW_VERSION = "0.2.0";
inline constexpr const char *FW_VERSION = "0.2.7";
// ============================================================
// WiFi 工作模式选择
@@ -98,8 +98,8 @@ inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; // cmd(1)+seq(2)+siz
// ⚠️ 请填入实际密钥
// ============================================================
inline constexpr uint8_t MASTER_KEY[] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10
};
inline constexpr uint8_t SCRAMBLE_CONST[] = {
@@ -107,7 +107,11 @@ inline constexpr uint8_t SCRAMBLE_CONST[] = {
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
};
// ⚠️ 请填入实际的 GD32 芯片唯一码(12字节)
// GD32_CHIP_UID 仅保留为默认占位,运行时以握手包解出的芯片地址 UID 为准。
inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U;
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU;
inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U;
inline constexpr uint8_t GD32_CHIP_UID[] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
+5
View File
@@ -67,6 +67,11 @@ int debug_log_ring_count(void)
// 读取并清空日志缓冲区,返回 JSON 数组字符串
// 调用者负责 free 返回值
/**
* @brief 读取并清空环形日志缓冲区,返回 JSON 数组字符串。
* @param out_len 输出 JSON 字符串长度(不含末尾 '\0')。
* @return 成功返回需由调用者 free 的字符串;无日志或失败返回 NULL。
*/
char *debug_log_ring_drain_json(int *out_len)
{
if (!s_log_ring_mutex) {
+6
View File
@@ -71,6 +71,12 @@ float health_get_last_temperature(void)
return s_last_temperature;
}
/**
* @brief 获取最近一次采样到的 WiFi RSSI 及其年龄。
* @param out_rssi 输出 RSSI 指针,可为 NULL。
* @param out_age_ms 输出采样距今毫秒数指针,可为 NULL。
* @return 有有效采样返回 true,否则返回 false。
*/
bool health_get_last_wifi_rssi(int8_t *out_rssi, uint32_t *out_age_ms)
{
if (s_last_wifi_rssi_ms <= 0) {
+308 -72
View File
@@ -9,7 +9,8 @@
#include "freertos/queue.h"
#include "esp_err.h"
#include "esp_partition.h"
#include "cJSON.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "aes/esp_aes.h"
#include "ota_relay.h"
@@ -26,6 +27,11 @@ static const esp_partition_t *g_gd32_partition = NULL;
static session_ctx_t g_session_ctx;
static uint8_t g_nonce_server[16] = {0};
static uint8_t g_k_session[16] = {0};
static uint8_t g_gd32_uid_runtime[12] = {0};
static constexpr size_t GD32_UID_LEN = 12;
static constexpr size_t GD32_CHALLENGE_CIPHER_LEN = 32;
static constexpr size_t GD32_CHALLENGE_PAYLOAD_WITH_SESSION = 36;
/** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */
QueueHandle_t g_ota_dispatch_queue = NULL;
@@ -62,7 +68,161 @@ static ota_result_t relay_packets_from_flash(void);
* @return 操作结果码。
*/
static ota_result_t finish_ota(void);
static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
/**
* @brief 判断响应负载是否为带序号的结构化 ACK。
* @param payload 响应负载指针。
* @param len 负载长度。
* @param expected_seq 期望的序号。
* @return 匹配返回 true,否则返回 false。
*/
static bool is_structured_ack_with_seq(const uint8_t *payload, size_t len, uint32_t expected_seq)
{
if (!payload || len < 6) return false;
if (payload[0] != 0xBA || payload[1] != 0xC0) return false;
uint32_t seq = (uint32_t)payload[2] | ((uint32_t)payload[3] << 8) |
((uint32_t)payload[4] << 16) | ((uint32_t)payload[5] << 24);
return seq == expected_seq;
}
/**
* @brief 解析结构化错误响应中的序号和错误码。
* @param payload 响应负载指针。
* @param len 负载长度。
* @param seq_out 输出错误对应序号,可为 NULL。
* @param err_out 输出错误码,可为 NULL。
* @return 解析成功返回 true,否则返回 false。
*/
static bool parse_structured_error(const uint8_t *payload, size_t len, uint32_t *seq_out, uint8_t *err_out)
{
if (!payload || len < 7) return false;
if (payload[0] != 0xBA || payload[1] != 0xC0) return false;
if (seq_out) {
*seq_out = (uint32_t)payload[2] | ((uint32_t)payload[3] << 8) |
((uint32_t)payload[4] << 16) | ((uint32_t)payload[5] << 24);
}
if (err_out) *err_out = payload[6];
return true;
}
/**
* @brief 判断缓冲区是否全部由指定字节值组成。
* @param buf 输入缓冲区。
* @param len 缓冲区长度。
* @param value 目标字节值。
* @return 全部匹配返回 true,否则返回 false。
*/
static bool bytes_all_value(const uint8_t *buf, size_t len, uint8_t value)
{
if (!buf || len == 0) return false;
for (size_t i = 0; i < len; ++i) {
if (buf[i] != value) return false;
}
return true;
}
/**
* @brief 按小端序读取 4 字节无符号整数。
* @param p 输入字节指针(至少 4 字节)。
* @return 读取出的 uint32_t 数值。
*/
static uint32_t read_u32_le(const uint8_t *p)
{
return (uint32_t)p[0] |
((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) |
((uint32_t)p[3] << 24);
}
/**
* @brief 解析并校验 GD32 Challenge 响应,提取会话密钥和 UID。
* @param resp_payload Challenge 响应数据。
* @param resp_len 响应长度。
* @param out_k_session 输出会话密钥(16 字节)。
* @param out_uid 输出 UID(GD32_UID_LEN 字节)。
* @return 校验通过返回 true,否则返回 false。
*/
static bool parse_and_verify_challenge_payload(const uint8_t *resp_payload, size_t resp_len,
uint8_t *out_k_session, uint8_t *out_uid)
{
if (!resp_payload || !out_k_session || !out_uid) return false;
const uint8_t *cipher_blob = resp_payload;
size_t cipher_len = resp_len;
if (resp_len == GD32_CHALLENGE_PAYLOAD_WITH_SESSION) {
uint32_t session_num = read_u32_le(resp_payload);
DEBUG_INFO("OTA", "CHALLENGE session_num=0x%08" PRIX32, session_num);
cipher_blob = resp_payload + 4;
cipher_len = GD32_CHALLENGE_CIPHER_LEN;
}
if (cipher_len != GD32_CHALLENGE_CIPHER_LEN) {
DEBUG_ERR("OTA", "CHALLENGE validate failed: invalid payload length=%u", (unsigned)resp_len);
return false;
}
/*
if (cipher_len != GD32_CHALLENGE_CIPHER_LEN) {
DEBUG_ERR("OTA", "CHALLENGE payload长度异常: %u", (unsigned)resp_len);
return false;
}
*/
uint8_t expected_nonce_block[16] = {0};
if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, expected_nonce_block) != 0) {
DEBUG_ERR("OTA", "CHALLENGE validate failed: nonce block encrypt failed");
return false;
}
/*
if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, expected_nonce_block) != 0) {
DEBUG_ERR("OTA", "CHALLENGE校验失败: 计算nonce加密块失败");
return false;
}
*/
if (memcmp(cipher_blob, expected_nonce_block, sizeof(expected_nonce_block)) != 0) {
DEBUG_ERR("OTA", "CHALLENGE校验失败: nonce加密块不匹配");
return false;
}
memcpy(out_k_session, expected_nonce_block, sizeof(expected_nonce_block));
uint8_t device_info[16] = {0};
if (aes128_ecb_decrypt_block_local(out_k_session, &cipher_blob[16], device_info) != 0) {
DEBUG_ERR("OTA", "CHALLENGE校验失败: device_info解密失败");
return false;
}
// device_info[0..11] maps to contiguous GD32 UID bytes from
// DEVICE_SERIAL_ADDR_LSB (0x1FFFF7E8) to 0x1FFFF7F3.
memcpy(out_uid, device_info, GD32_UID_LEN);
if (bytes_all_value(out_uid, GD32_UID_LEN, 0x00U) ||
bytes_all_value(out_uid, GD32_UID_LEN, 0xFFU)) {
DEBUG_ERR("OTA", "CHALLENGE校验失败: UID无效(全00/全FF)");
return false;
}
char uid_hex[GD32_UID_LEN * 2 + 1] = {0};
for (size_t i = 0; i < GD32_UID_LEN; ++i) {
snprintf(&uid_hex[i * 2], sizeof(uid_hex) - i * 2, "%02X", out_uid[i]);
}
DEBUG_INFO("OTA",
"GD32 UID(%uB @0x%08" PRIX32 "/0x%08" PRIX32 "/0x%08" PRIX32 "): %s",
(unsigned)GD32_UID_LEN,
DEVICE_SERIAL_ADDR_LSB, DEVICE_SERIAL_ADDR_MID, DEVICE_SERIAL_ADDR_MSB,
uid_hex);
return true;
}
/**
* @brief 使用 AES-128 ECB 加密单个 16 字节分组。
* @param key 16 字节密钥。
* @param in 输入明文分组(16 字节)。
* @param out 输出密文分组(16 字节)。
* @return 0 表示成功,非 0 表示失败。
*/
static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
esp_aes_context aes;
@@ -75,6 +235,13 @@ static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in,
return ret;
}
/**
* @brief 使用 AES-128 ECB 解密单个 16 字节分组。
* @param key 16 字节密钥。
* @param in 输入密文分组(16 字节)。
* @param out 输出明文分组(16 字节)。
* @return 0 表示成功,非 0 表示失败。
*/
static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
esp_aes_context aes;
@@ -87,6 +254,13 @@ static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in,
return ret;
}
/**
* @brief 按 3 个分组对 48 字节数据执行 AES-128 ECB 加密。
* @param key 16 字节密钥。
* @param in 输入明文(48 字节)。
* @param out 输出密文(48 字节)。
* @return 全部分组加密成功返回 true,否则返回 false。
*/
static bool aes128_ecb_encrypt_48bytes_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
for (int i = 0; i < 3; ++i) {
@@ -97,6 +271,12 @@ static bool aes128_ecb_encrypt_48bytes_local(const uint8_t *key, const uint8_t *
return true;
}
/**
* @brief 计算带 key 前缀的 CRC32(key + 48B 数据),输出小端 4 字节。
* @param key 16 字节会话密钥。
* @param data48 输入数据(48 字节)。
* @param output 输出 CRC32(4 字节)。
*/
static void calculate_crc32_with_key_local(const uint8_t *key, const uint8_t *data48, uint8_t *output)
{
uint32_t crc = 0xFFFFFFFFU;
@@ -119,6 +299,13 @@ static void calculate_crc32_with_key_local(const uint8_t *key, const uint8_t *da
output[3] = (uint8_t)((crc >> 24) & 0xFFU);
}
/**
* @brief 记录响应类型和负载十六进制摘要,便于 OTA 阶段诊断。
* @param stage 当前阶段名称。
* @param resp_type 响应类型。
* @param payload 响应负载。
* @param len 响应负载长度。
*/
static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len)
{
char hex[193] = {0};
@@ -132,6 +319,17 @@ static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t
len > n ? "..." : "");
}
/**
* @brief 当收到 WAIT 响应时进行只读轮询,直到响应变为非 WAIT 或达到轮询上限。
* @param stage 当前阶段名称(用于日志)。
* @param resp_type 输入/输出响应类型。
* @param resp_payload 输出响应负载缓冲区。
* @param resp_max 响应缓冲区大小。
* @param resp_len 输出响应长度指针,可为 NULL。
* @param timeout_ms 每次轮询的超时毫秒。
* @param max_wait_rounds 最多轮询次数。
* @return ESP_OK 或底层 SPI 轮询错误码。
*/
static esp_err_t wait_until_not_wait(const char *stage, uint8_t *resp_type,
uint8_t *resp_payload, size_t resp_max,
size_t *resp_len, uint32_t timeout_ms, uint32_t max_wait_rounds)
@@ -158,39 +356,22 @@ static esp_err_t wait_until_not_wait(const char *stage, uint8_t *resp_type,
esp_err_t meta_load(ota_meta_t *meta)
{
if (!meta) return ESP_ERR_INVALID_ARG;
memset(meta, 0, sizeof(ota_meta_t));
FILE *f = fopen(META_FILE, "r");
if (!f) {
// 文件不存在是正常状态(首次使用,未刷过 GD32)
nvs_handle_t handle;
esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READONLY, &handle);
if (err != ESP_OK) {
// NVS 中没有元数据是正常状态(首次使用)
return ESP_OK;
}
char buf[256];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
buf[n] = '\0';
fclose(f);
uint32_t val = 0;
if (nvs_get_u32(handle, "total_size", &val) == ESP_OK) meta->total_size = val;
if (nvs_get_u32(handle, "stored_size", &val) == ESP_OK) meta->stored_size = val;
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;
cJSON *json = cJSON_Parse(buf);
if (!json) {
DEBUG_ERR("OTA", "元数据 JSON 解析失败");
return ESP_ERR_INVALID_RESPONSE;
}
cJSON *item = cJSON_GetObjectItem(json, "total_size");
if (item) meta->total_size = (uint32_t)item->valueint;
item = cJSON_GetObjectItem(json, "stored_size");
if (item) meta->stored_size = (uint32_t)item->valueint;
item = cJSON_GetObjectItem(json, "last_seq");
if (item) meta->last_seq = (uint32_t)item->valueint;
item = cJSON_GetObjectItem(json, "chunk_count");
if (item) meta->chunk_count = (uint32_t)item->valueint;
cJSON_Delete(json);
nvs_close(handle);
DEBUG_INFO("OTA", "元数据已加载: total=%u, stored=%u, seq=%u",
meta->total_size, meta->stored_size, meta->last_seq);
return ESP_OK;
@@ -205,24 +386,26 @@ esp_err_t meta_save(const ota_meta_t *meta)
{
if (!meta) return ESP_ERR_INVALID_ARG;
FILE *f = fopen(META_FILE, "w");
if (!f) {
DEBUG_ERR("OTA", "无法打开元数据文件写入");
return ESP_FAIL;
nvs_handle_t handle;
esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READWRITE, &handle);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "无法打开 NVS 写入: %s", esp_err_to_name(err));
return err;
}
fprintf(f,
"{\n"
" \"total_size\": %" PRIu32 ",\n"
" \"stored_size\": %" PRIu32 ",\n"
" \"last_seq\": %" PRIu32 ",\n"
" \"chunk_count\": %" PRIu32 "\n"
"}",
meta->total_size, meta->stored_size, meta->last_seq, meta->chunk_count);
nvs_set_u32(handle, "total_size", meta->total_size);
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);
err = nvs_commit(handle);
nvs_close(handle);
fclose(f);
DEBUG_INFO("OTA", "元数据已保存到 Flash");
return ESP_OK;
if (err != ESP_OK) {
DEBUG_ERR("OTA", "NVS commit 失败: %s", esp_err_to_name(err));
} else {
DEBUG_INFO("OTA", "元数据已保存到 NVS");
}
return err;
}
/**
@@ -300,6 +483,18 @@ esp_err_t flash_read_chunk(uint32_t offset, uint8_t *data, size_t len)
* @brief 阶段1 - 发起与 GD32 的握手
* @return 操作结果码。
*/
static void request_gd32_app_enter_bootloader(void)
{
DEBUG_INFO("OTA", "尝试通知 GD32 APP 进入 Bootloader OTA 窗口");
esp_err_t err = gd32_app_request_bootloader(500);
if (err == ESP_OK) {
DEBUG_INFO("OTA", "GD32 APP 已确认进入 Bootloader,等待复位完成");
vTaskDelay(pdMS_TO_TICKS(3500));
} else {
DEBUG_WARN("OTA", "GD32 APP 未确认进入 Bootloader,继续尝试直接握手: %s", esp_err_to_name(err));
}
}
static ota_result_t send_handshake_req(void)
{
DEBUG_INFO("OTA", "=== 阶段1: 握手开始 ===");
@@ -358,7 +553,7 @@ static ota_result_t send_nonce_and_authenticate(void)
if (err == ESP_OK) {
err = wait_until_not_wait("CHALLENGE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 12);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len != 36) {
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) {
DEBUG_ERR("OTA", "CHALLENGE 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_TIMEOUT;
@@ -368,8 +563,8 @@ static ota_result_t send_nonce_and_authenticate(void)
DEBUG_INFO("OTA", "已收到 GD32 握手数据包");
DEBUG_INFO("OTA", "状态: RELAY_CHALLENGE_RESPOND");
if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, g_k_session) != 0) {
DEBUG_ERR("OTA", "计算 K_session 失败");
if (!parse_and_verify_challenge_payload(resp_payload, resp_len, g_k_session, g_gd32_uid_runtime)) {
DEBUG_ERR("OTA", "CHALLENGE 解包/校验失败");
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_AUTH;
}
@@ -440,8 +635,7 @@ static ota_result_t send_firmware_size(uint32_t size)
if (err == ESP_OK) {
err = wait_until_not_wait("SEND_SIZE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1500, 12);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
resp_payload[0] != 0xBA || resp_payload[1] != 0xC0) {
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !is_structured_ack_with_seq(resp_payload, resp_len, 0U)) {
DEBUG_ERR("OTA", "SEND_SIZE 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_TIMEOUT;
@@ -481,6 +675,7 @@ static ota_result_t relay_packets_from_flash(void)
g_relay_state = RELAY_RELAY_ACTIVE;
for (uint32_t seq = 0; seq < total_packets; seq++) {
uint32_t retry_count = 0;
if ((seq % OTA_PACKET_HEALTH_CHECK_INTERVAL) == 0 || seq == 0 || seq == total_packets - 1) {
health_status_t health = health_check_before_ota();
if (health == HEALTH_MON_SHUTDOWN || health == HEALTH_MON_CRITICAL) {
@@ -526,32 +721,48 @@ static ota_result_t relay_packets_from_flash(void)
g_relay_state = RELAY_PACKET_SENT;
resp_len = 0;
err = spi_ota_transfer(SPI_OTA_CMD_DATA, tx_buf, sizeof(tx_buf),
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
log_resp_payload("DATA_PACKET", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 4) {
DEBUG_WARN("OTA", "数据包 %u 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len);
result = OTA_RESULT_ERR_TIMEOUT;
break;
}
while (1) {
resp_len = 0;
err = spi_ota_transfer(SPI_OTA_CMD_DATA, tx_buf, sizeof(tx_buf),
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
log_resp_payload("DATA_PACKET", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) {
DEBUG_WARN("OTA", "数据包 %u 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len);
result = OTA_RESULT_ERR_TIMEOUT;
break;
}
uint32_t ack_value = (uint32_t)resp_payload[0] |
((uint32_t)resp_payload[1] << 8) |
((uint32_t)resp_payload[2] << 16) |
((uint32_t)resp_payload[3] << 24);
uint32_t expected_ack = (seq == total_packets - 1) ? 0xFFFFFFFFU : (seq + 1U);
if (ack_value != expected_ack) {
DEBUG_ERR("OTA", "包 %u ACK 不匹配: got=0x%08X expected=0x%08X", seq, ack_value, expected_ack);
uint32_t expected_ack = (seq == total_packets - 1) ? 0xFFFFFFFFU : (seq + 1U);
if (is_structured_ack_with_seq(resp_payload, resp_len, expected_ack)) {
flash_offset += (uint32_t)chunk_size;
g_relay_state = RELAY_PACKET_ACK_OK;
break;
}
uint32_t err_seq = 0;
uint8_t err_code = 0;
if (parse_structured_error(resp_payload, resp_len, &err_seq, &err_code) && err_seq == seq) {
DEBUG_WARN("OTA", "包 %u 收到结构化错误 err=0x%02X,准备重发", seq, err_code);
g_relay_state = RELAY_PACKET_ACK_RETRY;
if (++retry_count >= 3U) {
DEBUG_ERR("OTA", "包 %u 重发次数超限", seq);
result = (err_code == 0x04U) ? OTA_RESULT_ERR_CRC : OTA_RESULT_ERR_GD32;
break;
}
continue;
}
DEBUG_ERR("OTA", "包 %u ACK/ERROR 不匹配,len=%zu", seq, resp_len);
result = OTA_RESULT_ERR_GD32;
break;
}
flash_offset += (uint32_t)chunk_size;
g_relay_state = RELAY_PACKET_ACK_OK;
if (result != OTA_RESULT_OK) {
break;
}
if (seq % (total_packets / 10 + 1) == 0) {
uint32_t progress = (seq * 100U) / total_packets;
@@ -571,14 +782,34 @@ static ota_result_t relay_packets_from_flash(void)
static ota_result_t finish_ota(void)
{
DEBUG_INFO("OTA", "=== 阶段4: 结束 OTA ===");
DEBUG_INFO("OTA", "GD32 当前实现已在最后一个 DATA 包后自行 ACK 并跳转,无需额外 FINISH 命令");
DEBUG_INFO("OTA", "发送 FINISH_OTA (0x0A 0xAC),等待 GD32 确认即将跳转 APP");
uint8_t resp_type = 0;
uint8_t resp_payload[32] = {0};
size_t resp_len = 0;
uint8_t finish_payload[2] = {0x0A, 0xAC};
g_relay_state = RELAY_FINISH_SENT;
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_DATA, finish_payload, sizeof(finish_payload),
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
log_resp_payload("FINISH_OTA", resp_type, resp_payload, resp_len);
if (err == ESP_OK) {
err = wait_until_not_wait("FINISH_OTA", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
resp_payload[0] != 0x0A || resp_payload[1] != 0xAC) {
DEBUG_ERR("OTA", "FINISH_OTA 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_GD32;
}
g_relay_state = RELAY_DONE;
session_key_clear(&g_session_ctx);
memset(&g_session_ctx, 0, sizeof(session_ctx_t));
memset(g_k_session, 0, sizeof(g_k_session));
memset(g_nonce_server, 0, sizeof(g_nonce_server));
memset(g_gd32_uid_runtime, 0, sizeof(g_gd32_uid_runtime));
g_relay_state = RELAY_DONE;
DEBUG_INFO("OTA", "状态: RELAY_DONE");
DEBUG_INFO("OTA", "=== OTA 完成 ===");
@@ -594,6 +825,8 @@ ota_result_t ota_relay_start_handshake(void)
{
ota_result_t res;
request_gd32_app_enter_bootloader();
res = send_handshake_req();
if (res != OTA_RESULT_OK) return res;
@@ -627,6 +860,8 @@ ota_result_t ota_relay_forward_firmware(void)
DEBUG_INFO("OTA", "开始固件转发: %u 字节, %u 块",
meta.total_size, meta.chunk_count);
request_gd32_app_enter_bootloader();
ota_result_t res = send_handshake_req();
if (res != OTA_RESULT_OK) return res;
@@ -662,6 +897,7 @@ void ota_relay_reset(void)
/** @brief 当前 OTA 中继状态机状态。 */
g_relay_state = RELAY_IDLE;
session_key_clear(&g_session_ctx);
memset(g_gd32_uid_runtime, 0, sizeof(g_gd32_uid_runtime));
}
/**
@@ -703,4 +939,4 @@ void ota_relay_task(void *pvParameters)
ota_relay_reset();
}
}
}
}
+3 -2
View File
@@ -9,8 +9,9 @@
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
// T4.3: OTA 元数据结构,用于 Flash 存储
#define META_FILE "/spiffs/ota_meta.json"
// T4.3: OTA 元数据结构,用于 NVS 存储
#define OTA_META_NVS_NAMESPACE "ota_meta"
#define OTA_META_NVS_KEY "meta"
typedef struct {
uint32_t total_size; // 固件总大小(字节)
+62 -3
View File
@@ -79,6 +79,10 @@ static bool s_esp32_reboot_pending = false;
static uint8_t s_esp32_last_cmd = 0;
static uint16_t s_esp32_last_seq = 0xFFFFU;
/**
* @brief 异步完成 ESP32 OTA 收尾并触发重启。
* @param pvParameters 任务参数,当前未使用。
*/
static void ble_esp32_finish_and_reboot_task(void *pvParameters)
{
(void)pvParameters;
@@ -179,6 +183,11 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
return (int)copy_len;
}
/**
* @brief 初始化 ESP32 本机 OTA 写入会话并选择目标分区。
* @param total_size 固件总大小(字节)。
* @return ESP_OK 成功,其他为失败错误码。
*/
static esp_err_t ble_esp32_ota_begin(size_t total_size)
{
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
@@ -238,6 +247,12 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
return ESP_OK;
}
/**
* @brief 向 ESP32 OTA 分区写入一段固件数据。
* @param data 待写入数据指针。
* @param len 写入长度(字节)。
* @return ESP_OK 成功,其他为失败错误码。
*/
static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len)
{
if (!s_esp32_ota_active || s_esp32_update_partition == NULL) {
@@ -250,6 +265,12 @@ static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len)
return err;
}
/**
* @brief 发送窗口 ACK 通知,回报已处理的分块序号与累计字节数。
* @param cmd 对应的数据命令字节。
* @param seq 当前确认的分块序号。
* @param completed_bytes 已完成写入的累计字节数。
*/
static void ble_ota_send_window_ack(uint8_t cmd, uint16_t seq, uint32_t completed_bytes)
{
uint8_t ack[8] = {0};
@@ -272,6 +293,11 @@ static void ble_ota_send_window_ack(uint8_t cmd, uint16_t seq, uint32_t complete
}
}
/**
* @brief 发送收尾阶段事件通知(开始/完成/失败/重启等)。
* @param stage 阶段标识码。
* @param value 阶段附带值(错误码或耗时等)。
*/
static void ble_ota_send_finish_event(uint8_t stage, uint32_t value)
{
uint8_t pkt[6] = {0};
@@ -297,6 +323,14 @@ static void ble_ota_send_finish_event(uint8_t stage, uint32_t value)
* CTRL (0x0001) - Write
* 写入 0xF7 触发 OTA 中继
*/
/**
* @brief GATT CTRL 特征访问回调,处理控制命令写入。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return NimBLE ATT 错误码,0 表示成功。
*/
static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
@@ -368,6 +402,14 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
* [7..n-4] payload 固件数据
* [n-4..n] crc 校验 (预留)
*/
/**
* @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return NimBLE ATT 错误码,0 表示成功。
*/
static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
@@ -479,9 +521,10 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
// --- GD32 OTA:首个分块时创建存储元数据 ---
if (seq == 0 && total_size > 0) {
if (total_size > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR(TAG, "固件太大: %" PRIu32 " > %zu",
total_size, OTA_MAX_FIRMWARE_SIZE);
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);
return BLE_ATT_ERR_UNLIKELY;
}
@@ -558,6 +601,14 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
* VERSION (0x0004) - Read
* 返回固件版本字符串
*/
/**
* @brief GATT VERSION 特征访问回调,返回固件版本字符串。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return NimBLE ATT 错误码,0 表示成功。
*/
static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
@@ -578,6 +629,14 @@ static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
* DATA_OUT (0x0003) - Notify only
* 访问回调不做读写处理,通知由 ble_ota_notify_response() 发送
*/
/**
* @brief GATT DATA_OUT 特征访问回调(Notify-only,占位实现)。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return 0。
*/
static int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
+213 -57
View File
@@ -1,4 +1,4 @@
/*
/*
* wifi_ota.c - WiFi OTA 服务器实现
* T4.1 - T4.5
*/
@@ -88,6 +88,13 @@ static esp_err_t http_options_handler(httpd_req_t *req)
// ----------------------------------------
// WiFi/IP 事件回调(事件驱动连接)
// ----------------------------------------
/**
* @brief WiFi/IP 事件回调,处理 STA 启停、重连和获取 IP 等状态变化。
* @param arg 回调用户参数。
* @param event_base 事件基类(WIFI_EVENT 或 IP_EVENT)。
* @param event_id 具体事件 ID。
* @param event_data 事件附带数据。
*/
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
@@ -502,7 +509,7 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size)
int depth = 1; // 开头的 '{' 已被 peek 消费,从 1 开始
while (total < buf_size - 1) {
int ret = httpd_req_recv(req, buf + total, 1); // 逐字节读,精确找到 JSON 结尾
if (ret <= 0) return 0;
if (ret <= 0) return total;
total += ret;
buf[total] = '\0';
if (buf[total - 1] == '{') depth++;
@@ -511,7 +518,7 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size)
if (depth == 0) return total; // JSON 头读完
}
}
return 0; // JSON 头太长,超出缓冲区
return total; // fallback: return consumed bytes
}
// ----------------------------------------
@@ -538,6 +545,9 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
bool is_esp32_ota = false;
uint32_t total_size = 0;
int json_header_len = 0;
uint8_t pre_read_buf[256] = {0};
int pre_read_len = 1;
pre_read_buf[0] = (uint8_t)peek;
if (peek == '{') {
// JSON 模式:逐字节读完 JSON 头
@@ -547,6 +557,9 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
if (json_header_len > 0) {
json_header_len++; // 加上已读的 peek 字节
json_buf[json_header_len] = '\0';
pre_read_len = json_header_len;
if (pre_read_len > (int)sizeof(pre_read_buf)) pre_read_len = (int)sizeof(pre_read_buf);
memcpy(pre_read_buf, json_buf, pre_read_len);
cJSON *root = cJSON_Parse(json_buf);
if (root) {
@@ -623,9 +636,12 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
}
// --- GD32 OTA(原逻辑)---
if (req->content_len > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR("WIFI", "固件太大: %d > %d", req->content_len, OTA_MAX_FIRMWARE_SIZE);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "固件太大");
const uint32_t gd32_fw_size = (uint32_t)req->content_len;
const uint32_t gd32_rounded_size = (uint32_t)(((gd32_fw_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE);
if (gd32_fw_size == 0U || gd32_fw_size > OTA_MAX_FIRMWARE_SIZE || gd32_rounded_size > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR("WIFI", "固件大小非法: raw=%u rounded48=%u max=%u",
(unsigned)gd32_fw_size, (unsigned)gd32_rounded_size, (unsigned)OTA_MAX_FIRMWARE_SIZE);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "固件太大或48B对齐后越界");
return ESP_FAIL;
}
@@ -659,77 +675,73 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
int received = 0;
// peek 字节是 GD32 数据的第一字节,先放回缓冲区
uint8_t gd32_header[6];
gd32_header[0] = (uint8_t)peek;
int header_read = 1;
// 读剩余 5 字节凑齐 6 字节 GD32 头
if (req->content_len >= 6) {
int need = 6 - header_read;
int got = httpd_req_recv(req, (char *)gd32_header + header_read, need);
if (got > 0) header_read += got;
}
// GD32 路径与网页上传协议对齐:网页上传的是 raw binary(不带 6B 头)。
uint32_t expected_fw_size_raw = gd32_fw_size;
ota_meta_t meta_raw = {
.total_size = expected_fw_size_raw,
.stored_size = 0,
.last_seq = 0,
.chunk_count = (expected_fw_size_raw + OTA_BLOCK_SIZE - 1U) / OTA_BLOCK_SIZE,
.checksum = {0}
};
meta_save(&meta_raw);
DEBUG_INFO("WIFI", "GD32 raw upload start: %u bytes", expected_fw_size_raw);
uint32_t expected_fw_size = 0;
if (header_read >= 6) {
uint16_t seq = gd32_header[0] | (gd32_header[1] << 8);
uint32_t fw_size = gd32_header[2] | (gd32_header[3] << 8) | (gd32_header[4] << 16) | (gd32_header[5] << 24);
expected_fw_size = fw_size;
if (fw_size > OTA_MAX_FIRMWARE_SIZE) {
DEBUG_ERR("WIFI", "GD32 固件头声明大小超限: %u > %u", fw_size, OTA_MAX_FIRMWARE_SIZE);
int first_chunk_len = pre_read_len;
if (first_chunk_len > req->content_len) first_chunk_len = req->content_len;
if (first_chunk_len > 0) {
esp_err_t err = flash_write_chunk(0, pre_read_buf, first_chunk_len);
if (err != ESP_OK) {
DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s",
first_chunk_len, esp_err_to_name(err));
free(buf);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "GD32 固件大小超限");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed");
return ESP_FAIL;
}
if (seq == 0) {
ota_meta_t meta = { .total_size = fw_size, .stored_size = 0, .last_seq = 0,
.chunk_count = (fw_size + OTA_BLOCK_SIZE - 1) / OTA_BLOCK_SIZE, .checksum = {0} };
meta_save(&meta);
DEBUG_INFO("WIFI", "开始 GD32 固件存储: %u 字节", fw_size);
}
received = first_chunk_len;
meta_update((uint32_t)received, (uint32_t)(received / OTA_BLOCK_SIZE));
}
// 首包已经读走 6 字节头,因此后续 body 全部按 header_read 为偏移基准顺序落盘
while (received + header_read < req->content_len) {
size_t to_read = req->content_len - received - header_read;
while (received < req->content_len) {
size_t to_read = req->content_len - received;
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
int ret = httpd_req_recv(req, (char *)buf, to_read);
if (ret <= 0) break;
if (ret <= 0) {
DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d",
received, req->content_len);
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted");
return ESP_FAIL;
}
uint32_t flash_offset = (uint32_t)received;
esp_err_t err = flash_write_chunk(flash_offset, buf, ret);
if (err != ESP_OK) {
DEBUG_ERR("WIFI", "Flash 写入失败: offset=%u len=%d err=%s",
DEBUG_ERR("WIFI", "GD32 flash write failed: offset=%u len=%d err=%s",
flash_offset, ret, esp_err_to_name(err));
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "写入 gd32_ota 失败");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed");
return ESP_FAIL;
}
received += ret;
if (expected_fw_size > 0) {
meta_update((uint32_t)received, (uint32_t)(received / OTA_BLOCK_SIZE));
}
uint32_t progress = expected_fw_size > 0
? ((uint32_t)received * 100U) / expected_fw_size
: ((uint32_t)(received + header_read) * 100U) / (uint32_t)req->content_len;
if (progress % 10 == 0) {
DEBUG_INFO("WIFI", "GD32 上传进度: %d / %u (%u%%)",
received, expected_fw_size, progress);
}
meta_update((uint32_t)received, (uint32_t)(received / OTA_BLOCK_SIZE));
}
free(buf);
DEBUG_INFO("WIFI", "GD32 固件存储完成: payload=%d 字节, 头=%d 字节", received, header_read);
DEBUG_INFO("WIFI", "GD32 raw upload done: %d / %u bytes", received, expected_fw_size_raw);
ota_meta_t meta;
meta_load(&meta);
if (meta.total_size > 0 && meta.stored_size >= meta.total_size) {
/** @brief GD32 固件存储完成标志。 */
ota_meta_t final_meta;
meta_load(&final_meta);
if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) {
s_gd32_storage_done = true;
DEBUG_INFO("WIFI", "GD32 固件已存储完成,等待触发转发...");
DEBUG_INFO("WIFI", "GD32 firmware fully stored, auto-triggering relay");
// 自动触发中继
if (g_ota_dispatch_queue) {
uint32_t cmd = 1; // 1 = start relay
xQueueSend(g_ota_dispatch_queue, &cmd, 0);
}
}
const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\"}";
@@ -745,11 +757,32 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
*/
static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
{
(void)req;
http_set_cors_headers(req);
DEBUG_INFO("WIFI", "=== HTTP: POST /ota/relay ===");
DEBUG_INFO("WIFI", "收到触发 OTA 中继请求");
ota_meta_t meta;
memset(&meta, 0, sizeof(meta));
if (meta_load(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "firmware not fully stored");
return ESP_FAIL;
}
if (s_relay_triggered) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay already triggered");
return ESP_FAIL;
}
if (!g_ota_dispatch_queue) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay queue not ready");
return ESP_FAIL;
}
wifi_ota_signal_relay();
if (!s_relay_triggered) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay trigger failed");
return ESP_FAIL;
}
const char resp[] = "{\"status\": \"ok\", \"message\": \"OTA relay triggered\"}";
httpd_resp_set_type(req, "application/json");
@@ -923,6 +956,98 @@ static esp_err_t can_send_handler(httpd_req_t *req)
return ESP_OK;
}
// HTTP POST /spi/send 处理器
// 网页传 type + payload,ESP32 自动构建 SOF+TYPE+LEN(L/H)+PAYLOAD+CRC 帧
// 并通过 spi_ota_transfer 完成完整事务(发送+轮询响应帧+解析)。
//
// 请求体: {"type": "01", "data": "01020304", "timeout_ms": 1000}
// type: 帧类型 (hex 1 字节,如 01=REQ, 02=DATA, B0=BOOT)
// data: 负载数据 (hex 字符串,可选)
// timeout_ms: 超时毫秒 (可选, 默认 1000)
/**
* @brief HTTP POST /spi/send 处理器,转发上位机请求到 SPI 并返回结果。
* @param req HTTP 请求对象。
* @return ESP_OK 成功,ESP_FAIL 表示请求处理失败。
*/
static esp_err_t spi_send_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
char buf[512] = {0};
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败");
return ESP_FAIL;
}
buf[ret] = '\0';
cJSON *root = cJSON_Parse(buf);
if (!root) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败");
return ESP_FAIL;
}
cJSON *type_item = cJSON_GetObjectItem(root, "type");
cJSON *data_item = cJSON_GetObjectItem(root, "data");
cJSON *timeout_item = cJSON_GetObjectItem(root, "timeout_ms");
if (!type_item || !cJSON_IsString(type_item)) {
cJSON_Delete(root);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 type 字段");
return ESP_FAIL;
}
const char *type_hex = cJSON_GetStringValue(type_item);
uint8_t frame_type = (uint8_t)strtol(type_hex, NULL, 16);
uint32_t timeout_ms = timeout_item ? (uint32_t)cJSON_GetNumberValue(timeout_item) : 1000;
// 解析 payload (hex 字符串 → 字节数组)
uint8_t payload_data[SPI_OTA_FRAME_MAX] = {0};
size_t payload_len = 0;
if (data_item && cJSON_IsString(data_item)) {
const char *hex_str = cJSON_GetStringValue(data_item);
size_t hex_len = strlen(hex_str);
for (size_t i = 0; i < hex_len / 2 && i < SPI_OTA_FRAME_MAX; i++) {
char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' };
payload_data[i] = (uint8_t)strtol(byte_str, NULL, 16);
payload_len++;
}
}
DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节",
frame_type, payload_len);
cJSON_Delete(root);
char resp[1024];
uint8_t resp_type = 0;
uint8_t resp_payload[SPI_OTA_FRAME_MAX] = {0};
size_t resp_len = 0;
esp_err_t spi_err = spi_ota_transfer(frame_type, payload_data, payload_len,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
timeout_ms);
if (spi_err == ESP_OK) {
char resp_hex[SPI_OTA_FRAME_MAX * 2 + 1] = {0};
for (size_t i = 0; i < resp_len; i++) {
sprintf(&resp_hex[i * 2], "%02X", resp_payload[i]);
}
snprintf(resp, sizeof(resp),
"{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}",
frame_type, payload_len, resp_type, resp_len, resp_hex);
DEBUG_INFO("SPI", "WEB SPI 响应: type=0x%02X len=%zu payload=%s",
resp_type, resp_len, resp_hex);
} else {
snprintf(resp, sizeof(resp),
"{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}",
spi_err, esp_err_to_name(spi_err));
DEBUG_ERR("SPI", "WEB SPI 失败: %s", esp_err_to_name(spi_err));
}
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
/**
* @brief HTTP GET /can/status 处理器
* @param req
@@ -1051,6 +1176,16 @@ static esp_err_t http_ota_server_start(void)
return err;
}
// 从 NVS 恢复固件存储状态(重启后保留)
{
ota_meta_t boot_meta;
if (meta_load(&boot_meta) == ESP_OK &&
boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) {
s_gd32_storage_done = true;
DEBUG_INFO("WIFI", "从 NVS 恢复: 固件已就绪 (%u bytes)", boot_meta.total_size);
}
}
httpd_uri_t upload_uri = {
.uri = "/ota/upload",
.method = HTTP_POST,
@@ -1233,7 +1368,23 @@ static esp_err_t http_ota_server_start(void)
httpd_register_uri_handler(s_http_server, &can_status_options_uri);
DEBUG_INFO("WIFI", "HTTP 服务器已启动");
DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /ota/status, /ota/esp32_status, /health, /wifi/status, /system/status, /can/send, /can/status");
// SPI 直接发送
httpd_uri_t spi_send_uri = {
.uri = "/spi/send",
.method = HTTP_POST,
.handler = spi_send_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &spi_send_uri);
httpd_uri_t spi_send_options_uri = {
.uri = "/spi/send",
.method = HTTP_OPTIONS,
.handler = http_options_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(s_http_server, &spi_send_options_uri);
DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /ota/status, /ota/esp32_status, /health, /wifi/status, /system/status, /can/send, /can/status, /spi/send");
return ESP_OK;
}
@@ -1263,8 +1414,13 @@ void wifi_ota_signal_relay(void)
if (g_ota_dispatch_queue) {
uint32_t msg = 1;
xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(100));
DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
BaseType_t ret = xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(100));
if (ret == pdTRUE) {
DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
} else {
DEBUG_ERR("WIFI", "OTA 调度信号发送失败");
s_relay_triggered = false;
}
} else {
DEBUG_ERR("WIFI", "OTA 中继队列尚未就绪");
s_relay_triggered = false;
+35 -5
View File
@@ -88,7 +88,15 @@ int frame_build(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_fr
return idx;
}
// 解析 SPI 帧
/**
* @brief 解析并校验一帧 SPI 协议数据。
* @param frame 输入帧缓冲区。
* @param frame_len 输入帧长度。
* @param out_cmd 输出命令字节,可为 NULL。
* @param out_payload 输出负载缓冲区,可为 NULL。
* @param out_len 输出负载长度指针,可为 NULL。
* @return 0 成功,-1 失败。
*/
int frame_parse(const uint8_t *frame, size_t frame_len,
uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len)
{
@@ -141,7 +149,15 @@ int frame_parse(const uint8_t *frame, size_t frame_len,
return 0;
}
// AES-128 CBC 加密(使用 ESP32-C3 硬件 AES)
/**
* @brief 使用 AES-128 CBC 对明文进行加密(PKCS#7 填充)。
* @param plaintext 明文数据指针。
* @param len 明文长度。
* @param key 16 字节密钥。
* @param iv 16 字节初始向量。
* @param ciphertext_out 输出密文缓冲区。
* @return >=0 为密文长度,-1 表示失败。
*/
int protocol_aes_encrypt(const uint8_t *plaintext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *ciphertext_out)
@@ -189,7 +205,15 @@ int protocol_aes_encrypt(const uint8_t *plaintext, size_t len,
}
}
// AES-128 CBC 解密(使用 ESP32-C3 硬件 AES)
/**
* @brief 使用 AES-128 CBC 解密密文并移除 PKCS#7 填充。
* @param ciphertext 密文数据指针。
* @param len 密文长度(必须是 16 的倍数)。
* @param key 16 字节密钥。
* @param iv 16 字节初始向量。
* @param plaintext_out 输出明文缓冲区。
* @return >=0 为明文长度,-1 表示失败。
*/
int protocol_aes_decrypt(const uint8_t *ciphertext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *plaintext_out)
@@ -242,7 +266,13 @@ int protocol_aes_decrypt(const uint8_t *ciphertext, size_t len,
return orig_len;
}
// 搅伴算法 - 生成随机数
/**
* @brief 根据时间戳、地址和随机种子生成 16 字节 nonce。
* @param timestamp 时间戳输入。
* @param mem_addr 地址输入。
* @param rand_seed 随机种子输入。
* @param nonce_out 输出 nonce 缓冲区(16 字节)。
*/
void scramble_generate(uint32_t timestamp, uint32_t mem_addr,
uint32_t rand_seed, uint8_t *nonce_out)
{
@@ -357,4 +387,4 @@ int session_decrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t
void generate_random_nonce(uint8_t *nonce, size_t len)
{
esp_fill_random(nonce, len);
}
}
+267 -47
View File
@@ -15,6 +15,106 @@
#include "spi_master.h"
#include "monitor/debug_log.h"
namespace {
constexpr size_t kSpiTransferMaxBytes = 256U;
static esp_err_t spi_transfer_clocked(const uint8_t *tx_buf, size_t tx_len,
uint8_t *rx_buf, size_t rx_len)
{
if (g_spi_device == NULL) {
return ESP_ERR_INVALID_STATE;
}
if ((tx_len > 0U && tx_buf == NULL) || (rx_len > 0U && rx_buf == NULL)) {
return ESP_ERR_INVALID_ARG;
}
const size_t total_len = (tx_len > rx_len) ? tx_len : rx_len;
if (total_len == 0U) {
return ESP_OK;
}
if (total_len > kSpiTransferMaxBytes) {
return ESP_ERR_INVALID_SIZE;
}
uint8_t tx_staging[kSpiTransferMaxBytes];
uint8_t rx_staging[kSpiTransferMaxBytes];
const uint8_t *tx_ptr = tx_buf;
uint8_t *rx_ptr = rx_buf;
if (tx_len < total_len) {
memset(tx_staging, 0xFF, total_len);
if (tx_buf != NULL && tx_len > 0U) {
memcpy(tx_staging, tx_buf, tx_len);
}
tx_ptr = tx_staging;
}
if (rx_len > 0U && rx_len < total_len) {
rx_ptr = rx_staging;
}
spi_transaction_t t = {};
t.length = total_len * 8U;
t.rxlength = (rx_len > 0U) ? (total_len * 8U) : 0U;
t.tx_buffer = tx_ptr;
t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
esp_err_t ret = spi_device_transmit(g_spi_device, &t);
if (ret == ESP_OK && rx_len > 0U && rx_ptr != rx_buf) {
memcpy(rx_buf, rx_ptr, rx_len);
}
return ret;
}
static esp_err_t spi_transfer_request_response(uint8_t cmd,
const uint8_t *payload, size_t payload_len,
uint8_t *rx_buf, size_t rx_len)
{
if ((payload_len > 0U && payload == NULL) || (rx_len > 0U && rx_buf == NULL)) {
return ESP_ERR_INVALID_ARG;
}
const size_t cmd_len = (cmd != 0U) ? 1U : 0U;
const size_t total_len = cmd_len + payload_len + rx_len;
if (total_len == 0U) {
return ESP_OK;
}
if (total_len > kSpiTransferMaxBytes) {
return ESP_ERR_INVALID_SIZE;
}
uint8_t tx_staging[kSpiTransferMaxBytes];
uint8_t rx_staging[kSpiTransferMaxBytes];
size_t tx_pos = 0U;
if (cmd_len > 0U) {
tx_staging[tx_pos++] = cmd;
}
if (payload_len > 0U) {
memcpy(&tx_staging[tx_pos], payload, payload_len);
tx_pos += payload_len;
}
if (tx_pos < total_len) {
memset(&tx_staging[tx_pos], 0xFF, total_len - tx_pos);
}
esp_err_t ret = spi_transfer_clocked(tx_staging, total_len,
(rx_len > 0U) ? rx_staging : NULL,
(rx_len > 0U) ? total_len : 0U);
if (ret == ESP_OK && rx_len > 0U) {
memcpy(rx_buf, &rx_staging[cmd_len + payload_len], rx_len);
}
return ret;
}
} // namespace
/**
* @brief 计算 SPI OTA 帧头和负载的异或 CRC8。
* @param data 输入数据指针。
* @param len 输入长度。
* @return 计算得到的 CRC8 值。
*/
static uint8_t spi_ota_crc8(const uint8_t *data, size_t len)
{
uint8_t crc = 0x00;
@@ -24,6 +124,15 @@ static uint8_t spi_ota_crc8(const uint8_t *data, size_t len)
return crc;
}
/**
* @brief 构建 SPI OTA 协议帧(SOF + TYPE + LEN + PAYLOAD + CRC)。
* @param type 帧类型。
* @param payload 负载数据指针,可为 NULL。
* @param payload_len 负载长度。
* @param out 输出帧缓冲区。
* @param out_len 输出帧长度。
* @return 构建成功返回 true,否则返回 false。
*/
static bool spi_ota_build_frame(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *out, size_t *out_len)
{
@@ -42,6 +151,15 @@ static bool spi_ota_build_frame(uint8_t type, const uint8_t *payload, size_t pay
return true;
}
/**
* @brief 解析并校验一帧 SPI OTA 数据。
* @param frame 输入帧指针。
* @param frame_len 输入帧缓冲区长度。
* @param type 输出帧类型,可为 NULL。
* @param payload 输出负载指针,可为 NULL。
* @param payload_len 输出负载长度,可为 NULL。
* @return 解析并校验成功返回 true,否则返回 false。
*/
static bool spi_ota_parse_frame(const uint8_t *frame, size_t frame_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len)
{
@@ -57,6 +175,16 @@ static bool spi_ota_parse_frame(const uint8_t *frame, size_t frame_len,
return true;
}
/**
* @brief 在接收缓冲区中扫描并定位第一帧有效 SPI OTA 数据。
* @param buf 输入缓冲区。
* @param buf_len 缓冲区长度。
* @param type 输出帧类型,可为 NULL。
* @param payload 输出负载指针,可为 NULL。
* @param payload_len 输出负载长度,可为 NULL。
* @param frame_offset 输出帧起始偏移,可为 NULL。
* @return 找到有效帧返回 true,否则返回 false。
*/
static bool spi_ota_find_frame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset)
@@ -64,6 +192,15 @@ static bool spi_ota_find_frame(const uint8_t *buf, size_t buf_len,
if (!buf || buf_len < 5) return false;
for (size_t i = 0; i + 5 <= buf_len; ++i) {
if (buf[i] != SPI_OTA_SOF) continue;
size_t len_field = (size_t)buf[i+2] | ((size_t)buf[i+3] << 8);
size_t needed = 5 + len_field;
if (i + needed > buf_len) continue;
uint8_t crc = spi_ota_crc8(&buf[i], 4 + len_field);
if (crc != buf[i + 4 + len_field]) {
DEBUG_DBG("SPI", "帧@%u CRC错误: calc=0x%02X recv=0x%02X type=0x%02X len=%u",
(unsigned)i, crc, buf[i + 4 + len_field], buf[i+1], (unsigned)len_field);
continue;
}
if (spi_ota_parse_frame(&buf[i], buf_len - i, type, payload, payload_len)) {
if (frame_offset) *frame_offset = i;
return true;
@@ -84,14 +221,14 @@ esp_err_t spi_master_init(void)
DEBUG_INFO("SPI", "=== 初始化 SPI2 (VSPI) ===");
spi_bus_config_t bus_cfg = {};
bus_cfg.mosi_io_num = 8;
bus_cfg.mosi_io_num = 5;
bus_cfg.miso_io_num = 7;
bus_cfg.sclk_io_num = 6;
bus_cfg.quadwp_io_num = -1;
bus_cfg.quadhd_io_num = -1;
bus_cfg.max_transfer_sz = 256;
DEBUG_INFO("SPI", "SPI2 引脚: MOSI=GPIO8, MISO=GPIO7, SCLK=GPIO6");
DEBUG_INFO("SPI", "SPI2 引脚: MOSI=GPIO5, MISO=GPIO7, SCLK=GPIO6");
DEBUG_INFO("SPI", "SPI2 配置: CLK=%d MHz, CS=GPIO%d, 最大传输=%d 字节",
SPI_CLOCK_HZ / 1000000, SPI_MASTER_CS_GPIO, bus_cfg.max_transfer_sz);
@@ -124,13 +261,21 @@ esp_err_t spi_master_init(void)
return ESP_OK;
}
// 带超时的同步 SPI 传输
// ESP-IDF 6.0: spi_device_transmit() 不再接受超时参数
// 使用 spi_device_queue_trans() + spi_device_get_trans_result() 实现超时
/**
* @brief 执行一次同步 SPI 事务,先发送请求,再补时钟读取响应尾部。
* @param cmd SPI 命令码;为 0 时不额外插入命令字节,直接发送 payload。
* @param payload 发送数据指针。
* @param payload_len 发送数据长度。
* @param recv_buf 接收缓冲区指针。
* @param recv_len 期望接收的响应字节数。
* @param timeout_ms 预留超时参数(当前实现未直接使用)。
* @return ESP_OK 成功,其他为失败错误码。
*/
esp_err_t spi_sync_transfer(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;
if (g_spi_device == NULL) {
DEBUG_ERR("SPI", "SPI 设备未初始化");
return ESP_ERR_INVALID_STATE;
@@ -139,18 +284,7 @@ esp_err_t spi_sync_transfer(uint8_t cmd, const uint8_t *payload,
DEBUG_DBG("SPI", "SPI 传输: cmd=0x%02X, 发送=%zu 字节, 接收=%zu 字节",
cmd, payload_len, recv_len);
spi_transaction_t t = {};
t.cmd = cmd;
t.length = payload_len * 8;
t.rxlength = recv_len * 8;
t.tx_buffer = payload;
t.rx_buffer = recv_buf;
t.user = (void *)(uintptr_t)cmd;
/* ESP-IDF 6.0: 使用 spi_device_transmit() 无超时参数
* 如需超时控制,使用 spi_device_queue_trans() + spi_device_get_trans_result()
*/
esp_err_t ret = spi_device_transmit(g_spi_device, &t);
esp_err_t ret = spi_transfer_request_response(cmd, payload, payload_len, recv_buf, recv_len);
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "SPI 发送 cmd=0x%02X 失败: %s", cmd, esp_err_to_name(ret));
} else {
@@ -184,11 +318,18 @@ esp_err_t spi_send(uint8_t cmd, const uint8_t *payload, size_t len)
*/
esp_err_t spi_recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms)
{
uint8_t dummy[64] = {0};
memset(dummy, 0xFF, sizeof(dummy));
return spi_sync_transfer(cmd, dummy, len, recv_buf, len, timeout_ms);
return spi_sync_transfer(cmd, NULL, 0, recv_buf, len, timeout_ms);
}
/**
* @brief 轮询读取 SPI OTA 响应帧,不重复发送请求帧。
* @param resp_type 输出响应类型,可为 NULL。
* @param resp_payload 输出响应负载缓冲区,可为 NULL。
* @param resp_max 响应缓冲区最大长度。
* @param resp_len 输出响应长度,可为 NULL。
* @param timeout_ms 轮询超时时间(毫秒)。
* @return ESP_OK 成功,ESP_ERR_TIMEOUT 超时,其他为底层错误码。
*/
esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
@@ -204,18 +345,13 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
uint32_t attempt = 0;
while (xTaskGetTickCount() <= deadline) {
while (xTaskGetTickCount() <= deadline && attempt < 10) {
++attempt;
memset(rx_raw, 0xFF, sizeof(rx_raw));
spi_transaction_t rsp = {};
rsp.length = SPI_OTA_FRAME_MAX * 8;
rsp.rxlength = SPI_OTA_FRAME_MAX * 8;
rsp.tx_buffer = dummy;
rsp.rx_buffer = rx_raw;
esp_err_t ret = spi_device_transmit(g_spi_device, &rsp);
esp_err_t ret = spi_transfer_clocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw));
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "SPI OTA 响应读取失败[%u]: %s", attempt, esp_err_to_name(ret));
vTaskDelay(pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(1));
continue;
}
@@ -244,14 +380,30 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
break;
}
}
DEBUG_DBG("SPI", "OTA响应未就绪[%u]: %s", attempt, all_ff ? "all-FF" : "no-valid-SOF");
vTaskDelay(pdMS_TO_TICKS(10));
DEBUG_DBG("SPI", "OTA响应未就绪[%u]: %s first10=%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",
attempt, all_ff ? "all-FF" : "no-valid-SOF",
rx_raw[0], rx_raw[1], rx_raw[2], rx_raw[3],
rx_raw[4], rx_raw[5], rx_raw[6], rx_raw[7],
rx_raw[8], rx_raw[9]);
vTaskDelay(pdMS_TO_TICKS(1));
}
DEBUG_ERR("SPI", "OTA响应超时: timeout=%ums", (unsigned)timeout_ms);
return ESP_ERR_TIMEOUT;
}
/**
* @brief 执行一次 SPI OTA 请求事务并等待响应帧。
* @param type 请求帧类型。
* @param payload 请求负载。
* @param payload_len 负载长度。
* @param resp_type 输出响应类型,可为 NULL。
* @param resp_payload 输出响应负载缓冲区,可为 NULL。
* @param resp_max 响应负载缓冲区大小。
* @param resp_len 输出响应长度,可为 NULL。
* @param timeout_ms 响应等待超时时间(毫秒)。
* @return ESP_OK 成功,其他为失败错误码。
*/
esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
@@ -270,21 +422,34 @@ esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_
DEBUG_INFO("SPI", "OTA事务: req_type=0x%02X req_len=%u", type, (unsigned)payload_len);
spi_transaction_t req = {};
req.length = tx_len * 8;
req.tx_buffer = tx_frame;
esp_err_t ret = spi_device_transmit(g_spi_device, &req);
esp_err_t ret = spi_transfer_clocked(tx_frame, tx_len, NULL, 0);
if (ret != ESP_OK) {
DEBUG_ERR("SPI", "SPI OTA 请求事务失败: %s", esp_err_to_name(ret));
return ret;
}
vTaskDelay(pdMS_TO_TICKS(2));
/* 给 GD32 从机一点时间在 ISR 中处理请求帧并准备响应帧。
* GD32 在 CS 释放的 EXTI ISR 中完成处理(微秒级),
* 但 EXTI ISR 到 TBE 中断启用之间有微小延迟。
* 500µs 足够覆盖所有中断响应延迟。 */
esp_rom_delay_us(500);
return spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
}
// CANFD 帧发送
// 帧格式: [通道(1B)] [扩展帧ID(4B BE)] [数据(1~64B)]
/**
* @brief 发送一帧 CANFD 数据到 GD32,并可选等待响应。
* @param channel CAN 通道号。
* @param ext_id 扩展帧 ID(29-bit)。
* @param data 发送负载指针。
* @param dlc 发送数据长度。
* @param recv_buf 接收缓冲区,可为 NULL。
* @param recv_max 接收缓冲区最大长度。
* @param timeout_ms 等待响应超时(毫秒)。
* @return >=0 为接收字节数,<0 为错误码。
*/
static int can_frame_send(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc,
uint8_t *recv_buf, uint8_t recv_max,
@@ -319,13 +484,8 @@ static int can_frame_send(can_channel_t channel, uint32_t ext_id,
size_t tx_len = 5 + dlc;
uint8_t rx_buf[5 + CAN_MAX_DLC] = {0};
spi_transaction_t t = {};
t.tx_buffer = tx_buf;
t.rx_buffer = rx_buf;
t.length = tx_len * 8;
t.rxlength = (recv_max > 0) ? (5 + recv_max) * 8 : 0;
esp_err_t ret = spi_device_transmit(g_spi_device, &t);
const size_t rx_len = (recv_buf != NULL && recv_max > 0U) ? (size_t)(5U + recv_max) : 0U;
esp_err_t ret = spi_transfer_clocked(tx_buf, tx_len, rx_buf, rx_len);
if (ret != ESP_OK) {
DEBUG_ERR("SPI", "CAN[ch%d] ID=0x%08X 发送失败: %s",
channel, ext_id, esp_err_to_name(ret));
@@ -352,26 +512,86 @@ static int can_frame_send(can_channel_t channel, uint32_t ext_id,
return 0;
}
// 对外暴露的 GD32 CAN 发送接口,会在必要时修正默认 DLC。
/**
* @brief 对外暴露的 GD32 CAN 发送接口,必要时修正默认 DLC。
* @param channel CAN 通道。
* @param ext_id 扩展帧 ID。
* @param data 发送数据指针。
* @param dlc 数据长度。
* @param recv_buf 接收缓冲区,可为 NULL。
* @param recv_max 接收缓冲区大小。
* @param timeout_ms 等待响应超时(毫秒)。
* @return >=0 为接收字节数,<0 为错误码。
*/
int gd32_can_send(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc,
uint8_t *recv_buf, uint8_t recv_max,
uint32_t timeout_ms)
{
if (dlc == 0) {
dlc = (data != NULL) ? 64 : 0; // 避免 dlc=0 的情况
}
return can_frame_send(channel, ext_id, data, dlc, recv_buf, recv_max, timeout_ms);
}
// 对外暴露的无响应 GD32 CAN 发送接口。
/**
* @brief 发送无需响应的 GD32 CAN 帧。
* @param channel CAN 通道。
* @param ext_id 扩展帧 ID。
* @param data 发送数据指针。
* @param dlc 数据长度。
* @return 0 表示已发送完成,<0 为错误码。
*/
int gd32_can_send_no_reply(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc)
{
return can_frame_send(channel, ext_id, data, dlc, NULL, 0, 0);
}
/**
* @brief 向正在运行的 GD32 DCU_APP 发送进入 Bootloader 的短 SPI 命令。
* @param timeout_ms 等待 APP 返回确认的最长时间。
* @return ESP_OK 表示 APP 已接收命令;超时则通常表示 GD32 已不在 APP 或 SPI 未就绪。
*/
esp_err_t gd32_app_request_bootloader(uint32_t timeout_ms)
{
uint8_t cmd = SPI_APP_ENTER_BOOT_CMD;
esp_err_t ret = spi_transfer_clocked(&cmd, 1U, NULL, 0U);
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "APP进入BOOT命令发送失败: %s", esp_err_to_name(ret));
return ret;
}
uint8_t rx[8] = {0};
uint8_t dummy[8];
memset(dummy, 0xFF, sizeof(dummy));
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
while (xTaskGetTickCount() <= deadline) {
memset(rx, 0xFF, sizeof(rx));
ret = spi_transfer_clocked(dummy, sizeof(dummy), rx, sizeof(rx));
if (ret == ESP_OK) {
for (size_t i = 0; i + 3U < sizeof(rx); ++i) {
if (rx[i] == SPI_APP_ENTER_BOOT_CMD && rx[i + 1U] == SPI_APP_ENTER_BOOT_ACK_OK) {
DEBUG_INFO("SPI", "GD32 APP确认进入Bootloader OTA窗口");
return ESP_OK;
}
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
DEBUG_WARN("SPI", "GD32 APP进入Bootloader确认超时");
return ESP_ERR_TIMEOUT;
}
// 带重试的 SPI 传输
/**
* @brief 执行带重试的 SPI 同步传输。
* @param cmd SPI 命令码。
* @param payload 发送数据指针。
* @param payload_len 发送长度。
* @param recv_buf 接收缓冲区。
* @param recv_len 接收长度。
* @param max_retries 最大重试次数。
* @return ESP_OK 成功,重试耗尽返回 ESP_ERR_TIMEOUT。
*/
esp_err_t spi_transfer_with_retry(uint8_t cmd, const uint8_t *payload,
size_t payload_len, uint8_t *recv_buf,
size_t recv_len, int max_retries)
+19 -11
View File
@@ -10,7 +10,7 @@
// SPI 配置常量
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
#define SPI_CLOCK_HZ (4 * 1000 * 1000) // 先降到 4MHz 便于联调
#define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
#define SPI_MODE (0) // CPOL=0, CPHA=0
#define SPI_QUEUE_SIZE (1)
#define SPI_OTA_FRAME_MAX (96)
@@ -20,6 +20,8 @@
#define SPI_OTA_CMD_REQ (0x01)
#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_OTA_RSP_ACK (0x80)
#define SPI_OTA_RSP_WAIT (0x81)
#define SPI_OTA_RSP_ERROR (0x82)
@@ -57,7 +59,7 @@ typedef struct {
* @param channel CAN 通道(0~2)。
* @param ext_id 29 位扩展帧 ID。
* @param data 数据负载指针。
* @param dlc 数据长度,0 则自动推断。
* @param dlc 数据长度,范围 1~64。
* @param recv_buf 接收缓冲区,可为 NULL。
* @param recv_max 期望最大接收字节数。
* @param timeout_ms 超时时间(毫秒)。
@@ -73,7 +75,7 @@ int gd32_can_send(can_channel_t channel, uint32_t ext_id,
* @param channel CAN 通道(0~2)。
* @param ext_id 29 位扩展帧 ID。
* @param data 数据负载指针。
* @param dlc 数据长度。
* @param dlc 数据长度,范围 1~64。
* @return >= 0 成功,< 0 失败。
*/
int gd32_can_send_no_reply(can_channel_t channel, uint32_t ext_id,
@@ -86,12 +88,12 @@ int gd32_can_send_no_reply(can_channel_t channel, uint32_t ext_id,
esp_err_t spi_master_init(void);
/**
* @brief 执行一次同步 SPI 传输,发送命令和负载并可选接收返回数据。
* @param cmd SPI 命令码。
* @brief 执行一次同步 SPI 事务,先发送请求,再读取请求之后的响应字节。
* @param cmd SPI 命令码;为 0 时不额外插入命令字节。
* @param payload 发送数据指针。
* @param payload_len 发送数据长度。
* @param recv_buf 接收缓冲区,可为 NULL。
* @param recv_len 期望接收长度。
* @param recv_len 期望接收的响应长度。
* @param timeout_ms 超时时间(毫秒)。
* @return ESP_OK 成功,其他失败。
*/
@@ -100,8 +102,8 @@ esp_err_t spi_sync_transfer(uint8_t cmd, const uint8_t *payload,
size_t recv_len, uint32_t timeout_ms);
/**
* @brief 仅发送 SPI 数据,不关心返回内容。
* @param cmd SPI 命令码。
* @brief 仅发送 SPI 请求数据,不关心返回内容。
* @param cmd SPI 命令码;为 0 时仅发送 payload。
* @param payload 发送数据指针。
* @param len 发送数据长度。
* @return ESP_OK 成功,其他失败。
@@ -109,8 +111,8 @@ esp_err_t spi_sync_transfer(uint8_t cmd, const uint8_t *payload,
esp_err_t spi_send(uint8_t cmd, const uint8_t *payload, size_t len);
/**
* @brief 仅接收 SPI 数据,底层会发送哑数据产生时钟。
* @param cmd SPI 命令码。
* @brief 仅接收 SPI 数据,必要时先发送命令字节,再发送哑数据产生时钟。
* @param cmd SPI 命令码;为 0 时只发送哑数据。
* @param recv_buf 接收缓冲区。
* @param len 接收长度。
* @param timeout_ms 超时时间(毫秒)。
@@ -139,4 +141,10 @@ esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_
esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
#endif // SPI_MASTER_H
/**
* @brief 通知正在运行的 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。
* @return ESP_OK 表示 APP 已确认;ESP_ERR_TIMEOUT 常表示 GD32 已在 Bootloader 或通信未就绪。
*/
esp_err_t gd32_app_request_bootloader(uint32_t timeout_ms);
#endif // SPI_MASTER_H
+508 -309
View File
File diff suppressed because it is too large Load Diff
+578 -302
View File
File diff suppressed because it is too large Load Diff
+223 -235
View File
@@ -3,277 +3,265 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继系统 - 上位机</title>
<title>ESP32 OTA 中继控制台</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<!-- 顶部标题栏 -->
<header class="header">
<div class="logo-section">
<div class="logo-icon">📡</div>
<div class="logo-text">
<h1>ESP32 OTA 中继系统</h1>
<p>GD32 固件空中升级管理平台</p>
</div>
<!-- ====== HEADER: compact status bar ====== -->
<header class="header-bar">
<div class="header-brand">
<span class="dot-mark"></span>
<strong>ESP32 OTA 中继控制台</strong>
</div>
<div class="status-badges">
<div class="badge disconnected" id="connectionBadge">
<span class="dot"></span>
<span id="connectionText">未连接</span>
</div>
<div class="badge" id="healthBadge">
<span class="dot"></span>
<span id="healthText">系统正常</span>
</div>
<div class="badge">
<span id="fwVersion">v--</span>
</div>
<div class="header-status">
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">未连接</span></span>
<span class="pill idle" id="pillMode"><span class="indicator"></span><span id="pillModeTxt">GD32</span></span>
<span class="pill" id="pillVer">v--</span>
<span class="pill" id="pillIP">--</span>
<span class="pill" id="pillRSSI">--</span>
<span class="pill idle" id="pillRelay"><span class="indicator"></span><span id="pillRelayTxt">待机</span></span>
<span class="pill" id="pillUptime">运行 --</span>
</div>
</header>
<!-- 主网格布局 -->
<div class="main-grid">
<!-- 左侧面板 -->
<div class="left-panel">
<!-- 连接配置 -->
<div class="card connection-card">
<div class="card-header">
<div class="card-title">
<div class="icon">🔗</div>
连接配置
</div>
</div>
<div class="form-group">
<label>ESP32 IP 地址</label>
<div class="input-group">
<input type="text" id="espIp" value="192.168.31.127" placeholder="输入 IP 地址">
<button class="btn btn-secondary" onclick="testConnection()">测试</button>
</div>
</div>
<div class="form-group">
<label>目标芯片</label>
<select id="targetChip" onchange="onTargetChipChange()" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
<option value="gd32">GD32(Flash 中继)</option>
<option value="esp32">ESP32(自身升级)</option>
</select>
</div>
<button class="btn btn-primary" style="width:100%; margin-top:8px;" onclick="restartEsp32()">
🔄 重启 ESP32
</button>
<a class="btn btn-secondary" href="ble_ota.html" style="width:100%; margin-top:8px; text-decoration:none; display:flex; align-items:center; justify-content:center;">
📶 打开 BLE 刷写页面
</a>
</div>
<!-- ====== MAIN LAYOUT ====== -->
<div class="main-layout">
<!-- 文件上传 -->
<!-- ====== LEFT: workflow ====== -->
<div>
<!-- 1. Connection -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📁</div>
固件文件
</div>
</div>
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📤</div>
<h3>点击或拖拽文件到此处</h3>
<p>支持 .hex 和 .bin 格式</p>
</div>
<input type="file" id="fileInput" class="file-input" accept=".hex,.bin" onchange="handleFileSelect(event)">
<div class="file-info" id="fileInfo">
<div class="file-header">
<div class="file-name">
<span class="icon">📄</span>
<span id="fileName">-</span>
<div class="card-head"><span class="ico">🔗</span> 设备连接</div>
<div class="card-body">
<div class="form-row">
<label class="form-label" for="espIp">ESP32 IP 地址</label>
<div class="input-group">
<input class="input" type="text" id="espIp" value="192.168.31.127" placeholder="输入 IP 地址">
<button class="btn btn-outline" onclick="testConnection()">测试</button>
</div>
<button class="btn btn-secondary" onclick="clearFile()" style="padding:6px 12px;">✕</button>
</div>
<div class="file-meta">
<div class="meta-item"><span>类型:</span> <b id="fileType">-</b></div>
<div class="meta-item"><span>大小:</span> <b id="fileSize">-</b></div>
<div class="meta-item"><span>数据包:</span> <b id="fileChunks">-</b></div>
</div>
</div>
<div class="progress-section" id="progressSection">
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="progress-info">
<span id="progressText">准备上传...</span>
<span id="progressPercent">0%</span>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary" id="uploadBtn" onclick="startUpload()" disabled>
⬆️ 上传固件
</button>
<button class="btn btn-success" id="relayBtn" onclick="triggerRelay()">
🚀 触发 OTA
</button>
</div>
<div id="esp32Actions" style="display:none; margin-top:12px;">
</div>
</div>
<!-- OTA 阶段指示 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📊</div>
OTA 流程阶段
</div>
</div>
<div class="stage-indicator">
<div class="stage" id="stage1">
<div class="stage-icon">1️⃣</div>
<div class="stage-label">握手认证</div>
</div>
<div class="stage" id="stage2">
<div class="stage-icon">2️⃣</div>
<div class="stage-label">传输准备</div>
</div>
<div class="stage" id="stage3">
<div class="stage-icon">3️⃣</div>
<div class="stage-label">数据转发</div>
</div>
<div class="stage" id="stage4">
<div class="stage-icon">4️⃣</div>
<div class="stage-label">完成跳转</div>
</div>
</div>
</div>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<!-- 实时状态 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📈</div>
实时状态
<span style="font-size:12px;color:var(--text-muted);font-weight:normal;margin-left:8px;">运行: <span id="statUptime">--</span></span>
</div>
</div>
<div class="status-grid">
<div class="status-item">
<div class="value" id="statTemperature">--</div>
<div class="label">芯片温度</div>
</div>
<div class="status-item">
<div class="value" id="statWifi">--</div>
<div class="label">WiFi 状态</div>
</div>
<div class="status-item">
<div class="value" id="statSignal">--</div>
<div class="label">信号强度</div>
</div>
<div class="status-item">
<div class="value" id="statHeap">0</div>
<div class="label">空闲堆</div>
</div>
<div class="status-item">
<div class="value" id="statMinHeap">0</div>
<div class="label">最低空闲堆</div>
</div>
<div class="status-item">
<div class="value" id="statProgress">0%</div>
<div class="label">上传进度</div>
</div>
<div class="status-item">
<div class="value" id="statRelayState">待机</div>
<div class="label">中继状态</div>
<div class="conn-indicator err" id="connStatus">
<span class="light"></span>
<span id="connStatusTxt">等待连接</span>
</div>
</div>
</div>
<!-- CAN 帧发送面板 -->
<!-- 2. Target -->
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="icon">📨</div>
CAN 帧发送
</div>
</div>
<div style="display:grid; grid-template-columns:80px 1fr 80px 1fr; gap:12px; margin-bottom:16px;">
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">通道</label>
<select id="canChannel" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
<div class="card-head"><span class="ico">🎯</span> 升级目标</div>
<div class="card-body">
<div class="form-row">
<select class="input" id="targetChip" onchange="onTargetChipChange()">
<option value="gd32">GD32(Flash SPI 中继)</option>
<option value="esp32">ESP32(自身 OTA 升级)</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">扩展帧 ID</label>
<input type="text" id="canId" value="0x18DA00F1" placeholder="例如: 0x18DA00F1" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
<div class="conn-indicator" id="targetHint" style="font-size:12px;font-weight:400;color:var(--text-dim);">
将固件写入 ESP32 Flash,由 ESP32 通过 SPI 中继到 GD32 完成刷写。
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">DLC</label>
<input type="number" id="canDlc" value="8" min="1" max="64" style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:11px;color:var(--text-muted);margin-bottom:6px;display:block;">数据 (HEX)</label>
<input type="text" id="canData" value="0102030405060708" placeholder="01020304..." style="width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;color:var(--text-primary);font-size:13px;">
</div>
</div>
<div style="display:flex;gap:12px;">
<button class="btn btn-primary" style="flex:1;" onclick="sendCanFrame()">📤 发送</button>
<button class="btn btn-secondary" onclick="clearCanFields()">清空</button>
</div>
<div id="canResponse" style="margin-top:16px;background:var(--bg-secondary);border-radius:10px;padding:12px 16px;font-family:monospace;font-size:12px;display:none;">
<div style="color:var(--text-muted);margin-bottom:6px;">响应:</div>
<div id="canRespContent" style="color:var(--accent);"></div>
</div>
</div>
<!-- 调试日志 -->
<div class="card log-panel">
<div class="card-header">
<div class="card-title">
<div class="icon">🖥️</div>
调试日志
<span style="font-size:12px;color:var(--text-muted);font-weight:normal;margin-left:8px;">UDP:6666</span>
</div>
</div>
<!-- 3. Firmware -->
<div class="card">
<div class="card-head"><span class="ico">📁</span> 固件文件</div>
<div class="card-body">
<div class="log-header">
<span style="font-size:13px;color:var(--text-muted);">实时日志输出</span>
<div class="log-filters">
<button class="filter-btn active" onclick="toggleFilter('all', this)">全部</button>
<button class="filter-btn" onclick="toggleFilter('E', this)">错误</button>
<button class="filter-btn" onclick="toggleFilter('W', this)">警告</button>
<button class="filter-btn" onclick="toggleFilter('I', this)">信息</button>
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="upload-ico">📤</div>
<h4>点击或拖拽文件到此处</h4>
<p>支持 .hex 和 .bin 格式</p>
</div>
<input type="file" id="fileInput" accept=".hex,.bin" onchange="handleFileSelect(event)" style="display:none;">
<div class="file-info" id="fileInfo">
<div class="file-info-row">
<span style="font-weight:600;">📄 <span id="fileName">-</span></span>
<button class="btn btn-ghost btn-sm" onclick="clearFile()">清除</button>
</div>
<div class="file-info-grid">
<div class="file-meta-item"><span>类型</span><b id="fileType">-</b></div>
<div class="file-meta-item"><span>大小</span><b id="fileSize">-</b></div>
<div class="file-meta-item"><span>数据包 (8KB/包)</span><b id="fileChunks">-</b></div>
</div>
</div>
<div class="log-content" id="logContent">
<div class="log-entry">
<span class="log-time">[--:--:--]</span>
<span class="log-level I">I</span>
<span class="log-tag">[系统]</span>
<span class="log-msg">等待上位机连接...</span>
<div class="helper-note warn" id="fileHint">未选择固件。请选择 <code>.bin</code> 或 <code>.hex</code> 文件后再执行上传。</div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-text">
<span id="progressTxt">准备上传...</span>
<span id="progressPct">0%</span>
</div>
</div>
<div class="log-footer">
<span class="log-stats"><span id="logCount">0</span> 条日志</span>
<button class="btn btn-secondary" onclick="clearLogs()" style="padding:6px 12px;font-size:12px;">清除日志</button>
</div>
</div>
</div>
<!-- 4. Execute -->
<div class="card">
<div class="card-head"><span class="ico">⚡</span> 执行操作</div>
<div class="card-body">
<button class="btn btn-primary btn-block" id="uploadBtn" onclick="startUpload()" disabled>
上传固件到 ESP32
</button>
<div class="helper-note" id="actionHint">执行顺序:先测试连接,再确认目标芯片,最后上传固件。GD32 模式上传完成后,再触发中继。</div>
<div class="btn-row">
<button class="btn btn-success" id="relayBtn" onclick="triggerRelay()">
触发 GD32 OTA 中继
</button>
<button class="btn btn-outline" onclick="restartEsp32()">
重启 ESP32
</button>
</div>
<div id="esp32Actions" style="display:none;margin-top:12px;"></div>
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260509-0046" style="margin-top:10px;text-decoration:none;">
切换到 BLE 蓝牙刷写
</a>
</div>
</div>
</div>
<!-- ====== RIGHT: status + tools ====== -->
<div>
<!-- Primary status -->
<div class="card">
<div class="card-head"><span class="ico">📡</span> 设备状态</div>
<div class="card-body">
<div class="badge-grid">
<div class="badge-stat">
<div class="v err" id="bConn">离线</div>
<div class="k">连接状态</div>
</div>
<div class="badge-stat">
<div class="v" id="bMode">GD32</div>
<div class="k">当前模式</div>
</div>
<div class="badge-stat">
<div class="v warn" id="bOTAProgress">0%</div>
<div class="k">OTA 进度</div>
</div>
</div>
</div>
</div>
<!-- Secondary metrics -->
<div class="card">
<div class="card-head"><span class="ico">📊</span> 运行指标</div>
<div class="card-body">
<div class="metric-grid">
<div class="metric"><div class="k">温度</div><div class="v" id="mTemp">--</div></div>
<div class="metric"><div class="k">WiFi / IP</div><div class="v" id="mWifi" style="font-size:14px;">--</div></div>
<div class="metric"><div class="k">信号强度</div><div class="v" id="mRSSI">--</div></div>
<div class="metric"><div class="k">空闲堆</div><div class="v" id="mHeap" style="font-size:16px;">--</div></div>
<div class="metric"><div class="k">最低空闲堆</div><div class="v" id="mMinHeap" style="font-size:16px;">--</div></div>
<div class="metric"><div class="k">固件版本</div><div class="v" id="mVersion" style="font-size:16px;">--</div></div>
</div>
</div>
</div>
<!-- OTA stage flow -->
<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>
</div>
</div>
</div>
<!-- CAN panel -->
<div class="card">
<div class="card-head"><span class="ico">📨</span> CAN 帧发送</div>
<div class="card-body">
<div class="can-grid">
<div class="form-row" style="margin:0"><label class="form-label">通道</label>
<select class="input" id="canChannel"><option value="0">CH0</option><option value="1">CH1</option><option value="2">CH2</option></select>
</div>
<div class="form-row" style="margin:0"><label class="form-label">扩展帧 ID</label>
<input class="input" type="text" id="canId" value="0x18DA00F1">
</div>
<div class="form-row" style="margin:0"><label class="form-label">DLC</label>
<input class="input" type="number" id="canDlc" value="8" min="1" max="64">
</div>
<div class="form-row" style="margin:0"><label class="form-label">数据 (HEX)</label>
<input class="input" type="text" id="canData" value="0102030405060708" placeholder="01020304...">
</div>
</div>
<div class="helper-note" id="canHint">输入十六进制数据后会自动检查字节数是否与 DLC 一致。</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="sendCanFrame()">发送</button>
<button class="btn btn-outline" onclick="clearCanFields()">清空</button>
</div>
<div id="canResp" style="display:none;margin-top:12px;padding:12px;background:var(--bg-elevated);border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:12px;">
<div style="color:var(--text-label);margin-bottom:4px;">响应</div>
<div id="canRespText" style="color:var(--accent);"></div>
</div>
</div>
</div>
<!-- SPI panel -->
<div class="card">
<div class="card-head"><span class="ico">⚡</span> SPI 发送</div>
<div class="card-body">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<input class="input" type="text" id="spiType" value="01" maxlength="2" placeholder="type" title="帧类型 (hex)" style="width:52px;flex:none;">
<input class="input" type="text" id="spiData" value="" placeholder="payload hex..." style="flex:1;min-width:100px;">
<input class="input" type="number" id="spiTimeout" value="1000" min="100" max="10000" title="超时 ms" style="width:68px;flex:none;">
<span style="font-size:11px;color:var(--text-dim);">ms</span>
<button class="btn btn-primary" onclick="sendSpiFrame()" style="padding:4px 14px;">发送</button>
<button class="btn btn-outline" onclick="clearSpiFields()" style="padding:4px 10px;">清空</button>
</div>
<div class="helper-note" id="spiHint">type(hex) + payload → 自动构建 SOF+LEN+CRC 帧</div>
<div id="spiResp" style="display:none;margin-top:8px;padding:8px 10px;background:var(--bg-elevated);border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:12px;">
<span id="spiRespText" style="color:var(--accent);"></span>
</div>
</div>
</div>
<!-- Log panel -->
<div class="card log-panel">
<div class="card-head" style="justify-content:space-between;">
<span><span class="ico">🖥️</span> 调试日志</span>
<span style="font-size:11px;font-weight:400;color:var(--text-label);text-transform:none;letter-spacing:0;">UDP :6666</span>
</div>
<div class="card-body">
<div class="log-toolbar">
<div class="log-filters">
<button class="log-filter active" onclick="setLogFilter('all', this)">全部</button>
<button class="log-filter" onclick="setLogFilter('E', this)">错误</button>
<button class="log-filter" onclick="setLogFilter('W', this)">警告</button>
<button class="log-filter" onclick="setLogFilter('I', this)">信息</button>
</div>
<button class="btn btn-ghost btn-sm" onclick="toggleAutoScroll()" id="autoScrollBtn" title="切换自动滚动">📌 自动滚动</button>
</div>
<div class="log-scroll" id="logScroll">
<div class="log-line"><span class="lt">[--:--:--]</span><span class="lv I">I</span><span class="lg">[系统]</span><span class="lm">等待上位机连接...</span></div>
</div>
<div class="log-foot">
<span>共 <span id="logCount">0</span> 条</span>
<button class="btn btn-ghost btn-sm" onclick="clearLogs()">清除日志</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast 提示 -->
<!-- Toast -->
<div class="toast" id="toast">
<span class="toast-icon" id="toastIcon">✓</span>
<span class="toast-message" id="toastMessage">操作成功</span>
<span class="toast-ico" id="toastIco">✓</span>
<span id="toastMsg">操作成功</span>
</div>
<script src="app.js"></script>
<script src="app.js?v=20260525"></script>
</body>
</html>
+412 -680
View File
File diff suppressed because it is too large Load Diff