feat(relay): OTA可用版本,添加OTA中继阶段健康检查和APP存活验证功能
- 新增health_check_before_ota_relay函数,用于OTA中继转发阶段的健康检查, 跳过WiFi相关检查,只检查温度等本地状态 - 添加APP存活验证阶段,OTA完成后等待GD32 APP启动并通过ping确认存活 - 实现更精确的响应帧类型匹配和轮询机制,提高协议可靠性 - 添加多个阶段的响应匹配器(is_handshake_echo_ack、is_challenge_payload_ack等) - 优化poll_until_expected_ack_shape函数,处理残留ACK和超时情况 - 将调试日志级别从INFO降级为DBG,减少日志输出 - 修复handshake阶段重试机制,添加最大重试次数限制 - 优化Flash擦除期间的WATI状态处理,使用短超时避免长阻塞 - 添加完整的会话数据清理,防止状态污染 - 增加详细的注释说明各阶段的处理逻辑
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* app_config.h - 统一配置文件(C++ 版本)
|
||||
* ESP32-C3 OTA 中继项目
|
||||
*
|
||||
@@ -10,7 +10,7 @@
|
||||
// ============================================================
|
||||
// 固件版本
|
||||
// ============================================================
|
||||
inline constexpr const char *FW_VERSION = "0.2.7";
|
||||
inline constexpr const char *FW_VERSION = "0.2.10";
|
||||
|
||||
// ============================================================
|
||||
// WiFi 工作模式选择
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
static const char *TAG = "DEBUG_LOG";
|
||||
|
||||
/** @brief 当前调试日志过滤级别,运行时可动态调整。 */
|
||||
debug_log_level_t g_debug_log_level = DEBUG_LOG_ALL;
|
||||
debug_log_level_t g_debug_log_level = DEBUG_LOG_INFO;
|
||||
|
||||
/** @brief UDP 调试日志 socket 句柄,首次发送时延迟创建。 */
|
||||
static int s_udp_sock = -1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* health_monitor.cpp - 系统健康监控
|
||||
* 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控
|
||||
*/
|
||||
@@ -239,6 +239,36 @@ health_status_t health_check_before_ota(void)
|
||||
return status;
|
||||
}
|
||||
|
||||
health_status_t health_check_before_ota_relay(void)
|
||||
{
|
||||
health_status_t status = HEALTH_MON_OK;
|
||||
|
||||
if (g_health_state >= SYS_SAFE_SHUTDOWN) {
|
||||
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_SAFE_SHUTDOWN,禁止传输");
|
||||
return HEALTH_MON_SHUTDOWN;
|
||||
} else if (g_health_state >= SYS_CRITICAL) {
|
||||
/* SYS_CRITICAL 可能是由 WiFi RSSI 过低导致的,
|
||||
* 但 SPI 中继阶段不依赖 WiFi,只检查温度/硬件等本地状态。
|
||||
* 温度达到 TEMP_CRITICAL(100°C) 时 g_health_state 会被设为
|
||||
* SYS_SAFE_SHUTDOWN,在上面的分支已经拦截。
|
||||
* 所以 SYS_CRITICAL 在中继阶段只可能是 WiFi 导致的,允许继续。 */
|
||||
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_CRITICAL (WiFi 相关),SPI 中继允许继续");
|
||||
} else if (g_health_state >= SYS_WARNING) {
|
||||
status = HEALTH_MON_WARNING;
|
||||
DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_WARNING,继续传输");
|
||||
}
|
||||
|
||||
/* 跳过 WiFi 连接状态和 RSSI 检查:
|
||||
* SPI 中继阶段固件已存储在 ESP32 Flash 中,
|
||||
* 不依赖 WiFi 连接,无需检查 WiFi 状态。 */
|
||||
|
||||
if (status == HEALTH_MON_OK) {
|
||||
DEBUG_DBG("HEALTH", "OTA 中继检查: 正常");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 根据 WiFi RSSI 更新健康状态并返回检查结果。
|
||||
* @param rssi 当前 WiFi 信号强度(dBm)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* health_monitor.h - 系统健康监控头文件
|
||||
*/
|
||||
#pragma once
|
||||
@@ -45,6 +45,15 @@ void health_monitor_task(void *pvParameters);
|
||||
*/
|
||||
health_status_t health_check_before_ota(void);
|
||||
|
||||
/**
|
||||
* @brief 在 OTA 中继转发阶段执行健康检查,跳过 WiFi 相关检查。
|
||||
*
|
||||
* SPI 中继阶段固件已存储在 ESP32 Flash 中,不依赖 WiFi 连接,
|
||||
* 因此跳过 WiFi 连接状态和 RSSI 检查,只检查温度等本地状态。
|
||||
* @return 健康检查结果,决定是否允许 OTA。
|
||||
*/
|
||||
health_status_t health_check_before_ota_relay(void);
|
||||
|
||||
/**
|
||||
* @brief 记录一条健康相关事件日志,便于故障追踪与状态回溯。
|
||||
* @param event 事件名称字符串。
|
||||
|
||||
+322
-66
@@ -21,6 +21,7 @@
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
relay_state_t g_relay_state = RELAY_IDLE;
|
||||
volatile bool g_gd32_app_alive = false;
|
||||
|
||||
static const esp_partition_t *g_gd32_partition = NULL;
|
||||
/** @brief OTA 认证会话上下文,包含会话密钥和 IV。 */
|
||||
@@ -69,6 +70,11 @@ static ota_result_t relay_packets_from_flash(void);
|
||||
static ota_result_t finish_ota(void);
|
||||
static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
|
||||
static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len);
|
||||
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);
|
||||
|
||||
/**
|
||||
* @brief 判断响应负载是否为带序号的结构化 ACK。
|
||||
@@ -113,6 +119,54 @@ static bool parse_structured_error(const uint8_t *payload, size_t len, uint32_t
|
||||
* @param value 目标字节值。
|
||||
* @return 全部匹配返回 true,否则返回 false。
|
||||
*/
|
||||
static bool is_handshake_echo_ack(const uint8_t *payload, size_t len)
|
||||
{
|
||||
return payload && len >= 2U && payload[0] == 0x00U && payload[1] == 0xACU;
|
||||
}
|
||||
|
||||
static bool is_challenge_payload_ack(const uint8_t *payload, size_t len)
|
||||
{
|
||||
return payload && len == GD32_CHALLENGE_RESPONSE_LEN;
|
||||
}
|
||||
|
||||
static bool is_confirm_plain_ack(const uint8_t *payload, size_t len)
|
||||
{
|
||||
return payload && len == 2U && payload[0] == 0xBAU && payload[1] == 0xC0U;
|
||||
}
|
||||
|
||||
static bool is_size_seq0_ack(const uint8_t *payload, size_t len)
|
||||
{
|
||||
return is_structured_ack_with_seq(payload, len, 0U);
|
||||
}
|
||||
|
||||
static bool is_finish_plain_ack(const uint8_t *payload, size_t len)
|
||||
{
|
||||
return payload && len >= 2U && payload[0] == 0x0AU && payload[1] == 0xACU;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief FINISH 阶段匹配器:接受 0x0A 0xAC 明文 ACK 或结构化错误帧。
|
||||
*
|
||||
* BOOT 在 FINISH 成功时回 2B 明文 ACK {0x0A, 0xAC};
|
||||
* 失败时回 7B 结构化错误帧 {0xBA,0xC0, seq(4B), err_code}。
|
||||
* 两种都需要识别,否则 ESP32 会把错误帧当成残留 ACK 继续轮询。
|
||||
*/
|
||||
static bool is_finish_ack_or_error(const uint8_t *payload, size_t len)
|
||||
{
|
||||
if (!payload) return false;
|
||||
/* 明文 ACK: 0x0A 0xAC */
|
||||
if (len >= 2U && payload[0] == 0x0AU && payload[1] == 0xACU) return true;
|
||||
/* 结构化错误帧: 0xBA 0xC0 + seq(4B) + err_code */
|
||||
if (len >= 7U && payload[0] == 0xBAU && payload[1] == 0xC0U) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @brief 按小端序读取 4 字节无符号整数。
|
||||
* @param p 输入字节指针(至少 4 字节)。
|
||||
* @return 读取出的 uint32_t 数值。
|
||||
*/
|
||||
static bool bytes_all_value(const uint8_t *buf, size_t len, uint8_t value)
|
||||
{
|
||||
if (!buf || len == 0) return false;
|
||||
@@ -123,16 +177,62 @@ static bool bytes_all_value(const uint8_t *buf, size_t len, uint8_t value)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 按小端序读取 4 字节无符号整数。
|
||||
* @param p 输入字节指针(至少 4 字节)。
|
||||
* @return 读取出的 uint32_t 数值。
|
||||
* @brief 继续轮询,直到拿到符合当前阶段预期形状的 ACK,或遇到结构化错误/超时。
|
||||
*/
|
||||
static uint32_t read_u32_le(const uint8_t *p)
|
||||
static esp_err_t poll_until_expected_ack_shape(const char *stage,
|
||||
uint8_t *resp_type,
|
||||
uint8_t *resp_payload,
|
||||
size_t resp_max,
|
||||
size_t *resp_len,
|
||||
uint32_t poll_timeout_ms,
|
||||
uint32_t wait_rounds,
|
||||
uint32_t maxExtraPolls,
|
||||
bool (*matcher)(const uint8_t *, size_t))
|
||||
{
|
||||
return (uint32_t)p[0] |
|
||||
((uint32_t)p[1] << 8) |
|
||||
((uint32_t)p[2] << 16) |
|
||||
((uint32_t)p[3] << 24);
|
||||
if (!stage || !resp_type || !resp_payload || !resp_len || !matcher) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_err_t err = ESP_OK;
|
||||
if (*resp_type == SPI_OTA_RSP_WAIT) {
|
||||
err = wait_until_not_wait(stage, resp_type, resp_payload, resp_max, resp_len,
|
||||
poll_timeout_ms, wait_rounds);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
for (uint32_t extra = 0; extra < maxExtraPolls; ++extra) {
|
||||
if (*resp_type != SPI_OTA_RSP_ACK) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (matcher(resp_payload, *resp_len)) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint32_t err_seq = 0;
|
||||
uint8_t err_code = 0;
|
||||
if (parse_structured_error(resp_payload, *resp_len, &err_seq, &err_code)) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
DEBUG_WARN("OTA", "%s 收到上一阶段残留ACK/非目标ACK,继续轮询: len=%u", stage, (unsigned)*resp_len);
|
||||
*resp_len = 0;
|
||||
err = spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, poll_timeout_ms);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
log_resp_payload(stage, *resp_type, resp_payload, *resp_len);
|
||||
if (*resp_type == SPI_OTA_RSP_WAIT) {
|
||||
err = wait_until_not_wait(stage, resp_type, resp_payload, resp_max, resp_len,
|
||||
poll_timeout_ms, wait_rounds);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,7 +411,7 @@ static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", payload[i]);
|
||||
}
|
||||
DEBUG_INFO("OTA", "%s 响应: type=0x%02X len=%u payload[%u]=%s%s",
|
||||
DEBUG_DBG("OTA", "%s 响应: type=0x%02X len=%u payload[%u]=%s%s",
|
||||
stage, resp_type, (unsigned)len, (unsigned)n,
|
||||
n > 0 ? hex : "<empty>",
|
||||
len > n ? "..." : "");
|
||||
@@ -337,7 +437,22 @@ static esp_err_t wait_until_not_wait(const char *stage, uint8_t *resp_type,
|
||||
while (*resp_type == SPI_OTA_RSP_WAIT && round < max_wait_rounds) {
|
||||
++round;
|
||||
DEBUG_WARN("OTA", "%s 收到 WAIT,开始第 %u 次只读轮询...", stage, round);
|
||||
esp_err_t err = spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
|
||||
/* GD32 擦除 Flash 期间 (__disable_irq) 无法响应 SPI,
|
||||
* ESP32 的 poll 会持续拿到 0xFF 或 WAIT 帧残留字节,
|
||||
* 导致 spi_ota_poll_response 超时返回 ESP_ERR_TIMEOUT。
|
||||
*
|
||||
* 这是正常现象——只要还在 WAIT 轮询范围内,超时不代表协议失败,
|
||||
* 只是 GD32 还在忙。继续重试即可。
|
||||
*
|
||||
* 用 100ms 短超时做每次 poll,避免擦除期间长阻塞。
|
||||
* 300 轮 × 100ms = 30s,远超 2-3 秒的 Flash 擦除时间。
|
||||
* 实际上擦除完成后 ACK 会立即返回,不会真的等 30s。 */
|
||||
esp_err_t err = spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, 100);
|
||||
if (err == ESP_ERR_TIMEOUT) {
|
||||
/* 擦除期间 SPI 无响应 → 保持 WAIT 状态继续轮询 */
|
||||
*resp_type = SPI_OTA_RSP_WAIT;
|
||||
continue;
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
@@ -499,29 +614,58 @@ static ota_result_t send_handshake_req(void)
|
||||
DEBUG_INFO("OTA", "发送 HANDSHAKE_REQ (0x00 0xAC)");
|
||||
|
||||
uint8_t payload[] = {0x00, 0xAC};
|
||||
uint8_t resp_type = 0;
|
||||
uint8_t resp_payload[64] = {0};
|
||||
size_t resp_len = 0;
|
||||
uint8_t last_resp_type = 0U;
|
||||
size_t last_resp_len = 0U;
|
||||
esp_err_t last_err = ESP_OK;
|
||||
constexpr uint32_t kHandshakeAttempts = 10U;
|
||||
constexpr uint32_t kHandshakeReqTimeoutMs = 200U;
|
||||
constexpr uint32_t kHandshakePollTimeoutMs = 200U;
|
||||
constexpr uint32_t kHandshakeRetryGapMs = 100U;
|
||||
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_HANDSHAKE_SENT;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_HANDSHAKE_SENT");
|
||||
|
||||
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_REQ, payload, sizeof(payload),
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
|
||||
log_resp_payload("HANDSHAKE_REQ", resp_type, resp_payload, resp_len);
|
||||
if (err == ESP_OK) {
|
||||
err = wait_until_not_wait("HANDSHAKE_REQ", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 15);
|
||||
}
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
|
||||
resp_payload[0] != 0x00 || resp_payload[1] != 0xAC) {
|
||||
DEBUG_ERR("OTA", "HANDSHAKE_REQ 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
for (uint32_t attempt = 0U; attempt < kHandshakeAttempts; ++attempt) {
|
||||
uint8_t resp_type = 0U;
|
||||
uint8_t resp_payload[64] = {0};
|
||||
size_t resp_len = 0U;
|
||||
|
||||
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_REQ, payload, sizeof(payload),
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||
kHandshakeReqTimeoutMs);
|
||||
last_err = err;
|
||||
last_resp_type = resp_type;
|
||||
last_resp_len = resp_len;
|
||||
|
||||
if (err == ESP_OK) {
|
||||
log_resp_payload("HANDSHAKE_REQ", resp_type, resp_payload, resp_len);
|
||||
err = poll_until_expected_ack_shape("HANDSHAKE_REQ", &resp_type, resp_payload,
|
||||
sizeof(resp_payload), &resp_len,
|
||||
kHandshakePollTimeoutMs, 2, 2, is_handshake_echo_ack);
|
||||
last_err = err;
|
||||
last_resp_type = resp_type;
|
||||
last_resp_len = resp_len;
|
||||
|
||||
if (err == ESP_OK && resp_type == SPI_OTA_RSP_ACK && is_handshake_echo_ack(resp_payload, resp_len)) {
|
||||
DEBUG_INFO("OTA", "握手请求已确认 (attempt=%lu)", (unsigned long)(attempt + 1U));
|
||||
return OTA_RESULT_OK;
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_WARN("OTA", "HANDSHAKE_REQ 未就绪,重试 %lu/%lu: err=%d resp_type=0x%02X resp_len=%u",
|
||||
(unsigned long)(attempt + 1U),
|
||||
(unsigned long)kHandshakeAttempts,
|
||||
last_err,
|
||||
last_resp_type,
|
||||
(unsigned)last_resp_len);
|
||||
vTaskDelay(pdMS_TO_TICKS(kHandshakeRetryGapMs));
|
||||
}
|
||||
|
||||
DEBUG_INFO("OTA", "握手请求已确认");
|
||||
return OTA_RESULT_OK;
|
||||
DEBUG_ERR("OTA", "HANDSHAKE_REQ 失败, err=%d, resp_type=0x%02X, resp_len=%zu",
|
||||
last_err, last_resp_type, last_resp_len);
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -548,10 +692,10 @@ static ota_result_t send_nonce_and_authenticate(void)
|
||||
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_REQ, g_nonce_server, 16,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 5000);
|
||||
log_resp_payload("CHALLENGE", resp_type, resp_payload, resp_len);
|
||||
if (err == ESP_OK) {
|
||||
err = wait_until_not_wait("CHALLENGE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 12);
|
||||
}
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) {
|
||||
err = poll_until_expected_ack_shape("CHALLENGE", &resp_type, resp_payload,
|
||||
sizeof(resp_payload), &resp_len,
|
||||
1000, 12, 8, is_challenge_payload_ack);
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !is_challenge_payload_ack(resp_payload, resp_len)) {
|
||||
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;
|
||||
@@ -586,16 +730,25 @@ static ota_result_t send_nonce_and_authenticate(void)
|
||||
err = spi_ota_transfer(SPI_OTA_CMD_REQ, encrypted_confirm, 16,
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
|
||||
log_resp_payload("CONFIRM", resp_type, resp_payload, resp_len);
|
||||
if (err == ESP_OK) {
|
||||
err = wait_until_not_wait("CONFIRM", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 8);
|
||||
}
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
|
||||
resp_payload[0] != 0xBA || resp_payload[1] != 0xC0) {
|
||||
err = poll_until_expected_ack_shape("CONFIRM", &resp_type, resp_payload,
|
||||
sizeof(resp_payload), &resp_len,
|
||||
1000, 8, 8, is_confirm_plain_ack);
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !is_confirm_plain_ack(resp_payload, resp_len)) {
|
||||
DEBUG_ERR("OTA", "SESSION_KEY/CONFIRM 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
/* 诊断: 打印 confirm 响应完整内容,确认是 2B 还是意外读到了 36B challenge 响应 */
|
||||
{
|
||||
char hex[73] = {0};
|
||||
size_t n = (resp_len > 36U) ? 36U : resp_len;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", resp_payload[i]);
|
||||
}
|
||||
DEBUG_INFO("OTA", "CONFIRM 验证通过, resp_len=%zu, payload=%s", resp_len, hex);
|
||||
}
|
||||
|
||||
g_relay_state = RELAY_AUTH_COMPLETE;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_AUTH_COMPLETE");
|
||||
DEBUG_INFO("OTA", "=== 阶段1 认证完成: 安全通道已建立 ===");
|
||||
@@ -630,10 +783,11 @@ static ota_result_t send_firmware_size(uint32_t size)
|
||||
esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_DATA, size_payload, sizeof(size_payload),
|
||||
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
|
||||
log_resp_payload("SEND_SIZE", resp_type, resp_payload, resp_len);
|
||||
if (err == ESP_OK) {
|
||||
err = wait_until_not_wait("SEND_SIZE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1500, 12);
|
||||
}
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !is_structured_ack_with_seq(resp_payload, resp_len, 0U)) {
|
||||
DEBUG_WARN("OTA", "SEND_SIZE poll config: poll_timeout_ms=2000 wait_rounds=300 maxExtraPolls=24");
|
||||
err = poll_until_expected_ack_shape("SEND_SIZE", &resp_type, resp_payload,
|
||||
sizeof(resp_payload), &resp_len,
|
||||
2000, 300, 24, is_size_seq0_ack);
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !is_size_seq0_ack(resp_payload, resp_len)) {
|
||||
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;
|
||||
@@ -675,7 +829,7 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
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();
|
||||
health_status_t health = health_check_before_ota_relay();
|
||||
if (health == HEALTH_MON_SHUTDOWN || health == HEALTH_MON_CRITICAL) {
|
||||
DEBUG_ERR("OTA", "健康检查危险,中止 OTA");
|
||||
return OTA_RESULT_ERR_HEALTH;
|
||||
@@ -713,7 +867,7 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
tx_buf[59] = (uint8_t)((if_end >> 24) & 0xFFU);
|
||||
calculate_crc32_with_key_local(g_k_session, plain48, &tx_buf[60]);
|
||||
|
||||
DEBUG_INFO("OTA", "包 %u: chunk=%u if_end=0x%08X crc32=%02X%02X%02X%02X",
|
||||
DEBUG_DBG("OTA", "包 %u: chunk=%u if_end=0x%08X crc32=%02X%02X%02X%02X",
|
||||
seq, (unsigned)chunk_size, (unsigned)if_end,
|
||||
tx_buf[60], tx_buf[61], tx_buf[62], tx_buf[63]);
|
||||
|
||||
@@ -734,27 +888,75 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/* 跳过上一阶段残留的、seq 不匹配的结构化 ACK。
|
||||
*
|
||||
* 典型场景:SIZE 阶段的 ACK(seq=0) 仍挂在 DMA 上,
|
||||
* 包 0 发出后第一次 poll 读到 seq=0 而非期望的 seq=1。
|
||||
* 继续轮询直到拿到正确的 ACK 或超时。 */
|
||||
if (!is_structured_ack_with_seq(resp_payload, resp_len, expected_ack)) {
|
||||
uint32_t err_seq = 0;
|
||||
uint8_t err_code = 0;
|
||||
if (parse_structured_error(resp_payload, resp_len, &err_seq, &err_code)) {
|
||||
if (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; /* 重发本包 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 非期望 ACK,也非错误帧 → 可能是残留 ACK,循环 poll 直到拿到正确的 */
|
||||
bool got_expected = false;
|
||||
for (uint32_t retry = 0; retry < 5; ++retry) {
|
||||
DEBUG_WARN("OTA", "包 %u 收到非目标ACK(seq不匹配),继续轮询(%u/5)...", seq, retry + 1);
|
||||
err = spi_ota_poll_response(&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 2000);
|
||||
if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) {
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
if (is_structured_ack_with_seq(resp_payload, resp_len, expected_ack)) {
|
||||
got_expected = true;
|
||||
break;
|
||||
}
|
||||
if (parse_structured_error(resp_payload, resp_len, &err_seq, &err_code) && err_seq == seq) {
|
||||
break; /* 下面统一处理 */
|
||||
}
|
||||
}
|
||||
|
||||
if (got_expected) {
|
||||
/* 匹配成功 */
|
||||
flash_offset += (uint32_t)chunk_size;
|
||||
g_relay_state = RELAY_PACKET_ACK_OK;
|
||||
break;
|
||||
}
|
||||
|
||||
/* 最终检查是否为错误帧 */
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
/* 匹配成功 */
|
||||
flash_offset += (uint32_t)chunk_size;
|
||||
g_relay_state = RELAY_PACKET_ACK_OK;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -769,7 +971,7 @@ static ota_result_t relay_packets_from_flash(void)
|
||||
}
|
||||
|
||||
g_relay_state = RELAY_RECEIVE_DONE;
|
||||
DEBUG_INFO("OTA", "状态: RELAY_RECEIVE_DONE");
|
||||
DEBUG_DBG("OTA", "状态: RELAY_RECEIVE_DONE");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -791,16 +993,37 @@ static ota_result_t finish_ota(void)
|
||||
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) {
|
||||
err = poll_until_expected_ack_shape("FINISH_OTA", &resp_type, resp_payload,
|
||||
sizeof(resp_payload), &resp_len,
|
||||
1000, 10, 8, is_finish_ack_or_error);
|
||||
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK ||
|
||||
!is_finish_ack_or_error(resp_payload, resp_len)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 检查是否为结构化错误帧(BOOT validateApplication 失败等) */
|
||||
uint32_t err_seq = 0;
|
||||
uint8_t err_code = 0;
|
||||
if (parse_structured_error(resp_payload, resp_len, &err_seq, &err_code)) {
|
||||
DEBUG_ERR("OTA", "FINISH_OTA BOOT 返回错误: seq=0x%08lX err=0x%02X (%s)",
|
||||
(unsigned long)err_seq, err_code,
|
||||
err_code == 0x02U ? "FLASH_ERROR/validateApp失败" :
|
||||
err_code == 0x04U ? "INVALID_PACKET" :
|
||||
err_code == 0x03U ? "INVALID_KEY" :
|
||||
"未知错误");
|
||||
g_relay_state = RELAY_ERROR;
|
||||
return OTA_RESULT_ERR_GD32;
|
||||
}
|
||||
|
||||
/* 正常明文 ACK: 0x0A 0xAC */
|
||||
if (!is_finish_plain_ack(resp_payload, resp_len)) {
|
||||
DEBUG_ERR("OTA", "FINISH_OTA 响应格式异常: len=%zu payload[0]=0x%02X", resp_len, resp_payload[0]);
|
||||
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));
|
||||
@@ -809,9 +1032,38 @@ static ota_result_t finish_ota(void)
|
||||
memset(g_gd32_uid_runtime, 0, sizeof(g_gd32_uid_runtime));
|
||||
|
||||
DEBUG_INFO("OTA", "状态: RELAY_DONE");
|
||||
DEBUG_INFO("OTA", "=== OTA 完成 ===");
|
||||
DEBUG_INFO("OTA", "=== OTA 完成,等待 GD32 APP 启动 ===");
|
||||
|
||||
meta_clear();
|
||||
|
||||
/* ============================================================
|
||||
* 阶段5: APP 存活验证
|
||||
*
|
||||
* GD32 BOOT 处理完 FINISH_OTA 后会跳转到 APP。
|
||||
* APP 启动需要初始化外设和 SPI,大约需要 1-2 秒。
|
||||
* 我们以 500ms 间隔反复 ping APP,最多等 15 秒。
|
||||
* 收到 APP_ALIVE 回复才认为 OTA 真正成功。
|
||||
* ============================================================ */
|
||||
g_relay_state = RELAY_APP_VERIFY;
|
||||
g_gd32_app_alive = false;
|
||||
|
||||
for (uint32_t attempt = 0; attempt < 30; ++attempt) {
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
uint32_t uptime = 0;
|
||||
esp_err_t ping_err = gd32_app_ping(1000, &uptime);
|
||||
if (ping_err == ESP_OK) {
|
||||
g_gd32_app_alive = true;
|
||||
DEBUG_INFO("OTA", "GD32 APP 存活确认: uptime=%lu sec", (unsigned long)uptime);
|
||||
DEBUG_INFO("OTA", "=== OTA 全流程完成 (APP 已启动) ===");
|
||||
break;
|
||||
}
|
||||
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%u/30)", attempt + 1);
|
||||
}
|
||||
|
||||
if (!g_gd32_app_alive) {
|
||||
DEBUG_WARN("OTA", "GD32 APP 存活确认超时,OTA 传输已完成但 APP 未启动");
|
||||
}
|
||||
|
||||
return OTA_RESULT_OK;
|
||||
}
|
||||
|
||||
@@ -895,6 +1147,9 @@ void ota_relay_reset(void)
|
||||
/** @brief 当前 OTA 中继状态机状态。 */
|
||||
g_relay_state = RELAY_IDLE;
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -932,6 +1187,7 @@ void ota_relay_task(void *pvParameters)
|
||||
|
||||
if (result == OTA_RESULT_OK) {
|
||||
DEBUG_INFO("OTA", "OTA 转发成功完成");
|
||||
ota_relay_reset();
|
||||
} else {
|
||||
DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result);
|
||||
ota_relay_reset();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* ota_relay.h - OTA 中继核心状态机
|
||||
* T2.3, T3.1 - T3.7
|
||||
*/
|
||||
@@ -50,6 +50,7 @@ typedef enum {
|
||||
RELAY_TRANSFER_DONE, // 已收到 0xBA C0 + FF FF FF FF
|
||||
RELAY_FINISH_SENT, // 已发送 0x0A AC,等待 GD32 确认
|
||||
RELAY_DONE, // GD32 回复 0x0A AC,OTA 完成
|
||||
RELAY_APP_VERIFY, // 等待 GD32 APP 启动并回复存活确认
|
||||
|
||||
// 错误状态
|
||||
RELAY_ERROR = 0xFF
|
||||
@@ -146,6 +147,9 @@ esp_err_t meta_update(uint32_t stored_size, uint32_t last_seq);
|
||||
*/
|
||||
esp_err_t meta_clear(void);
|
||||
|
||||
// GD32 APP 存活标志(OTA 完成后由 APP PING 确认)
|
||||
extern volatile bool g_gd32_app_alive;
|
||||
|
||||
// 当前中继状态(供健康监控外部访问)
|
||||
extern relay_state_t g_relay_state;
|
||||
|
||||
|
||||
+154
-18
@@ -30,12 +30,16 @@
|
||||
|
||||
/** @brief 当前 WiFi 工作模式(AP 或 STA)。 */
|
||||
static ota_wifi_mode_t s_wifi_mode = OTA_WIFI_MODE_AP;
|
||||
/** @brief 冷启动标志,上电复位或掉电复位时为 true。 */
|
||||
static bool s_cold_boot = false;
|
||||
/** @brief OTA 中继是否已触发标志。 */
|
||||
static bool s_relay_triggered = false;
|
||||
/** @brief STA 模式连接状态。 */
|
||||
static bool s_sta_connected = false;
|
||||
/** @brief STA 连接重试次数。 */
|
||||
static int s_sta_retry_count = 0;
|
||||
/** @brief STA 重试已耗尽标志,用于加速 wifi_ota_task 中的 AP 切换。 */
|
||||
static bool s_sta_retry_exhausted = false;
|
||||
/** @brief STA 连接成功信号量。 */
|
||||
static SemaphoreHandle_t s_sta_connected_sem = NULL;
|
||||
/** @brief STA 获取 IP 信号量。 */
|
||||
@@ -55,6 +59,13 @@ static bool s_gd32_storage_done = false;
|
||||
/** @brief HTTP 服务器句柄。 */
|
||||
static httpd_handle_t s_http_server = NULL;
|
||||
|
||||
static constexpr uint32_t GD32_OTA_FLASH_SECTOR_SIZE = 4096U;
|
||||
|
||||
static uint32_t align_up_u32(uint32_t value, uint32_t alignment)
|
||||
{
|
||||
return ((value + alignment - 1U) / alignment) * alignment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 预先声明:为 STA 模式配置静态 IP、网关和子网掩码。
|
||||
*/
|
||||
@@ -111,6 +122,7 @@ static void wifi_event_handler(void* arg, esp_event_base_t event_base,
|
||||
s_sta_connected = true;
|
||||
/** @brief STA 连接重试次数。 */
|
||||
s_sta_retry_count = 0;
|
||||
s_sta_retry_exhausted = false; // ★ 连接成功,重置耗尽标志
|
||||
if (s_sta_connected_sem) {
|
||||
xSemaphoreGive(s_sta_connected_sem);
|
||||
}
|
||||
@@ -124,10 +136,21 @@ static void wifi_event_handler(void* arg, esp_event_base_t event_base,
|
||||
|
||||
if (s_sta_retry_count < WIFI_STA_MAX_RETRY) {
|
||||
s_sta_retry_count++;
|
||||
DEBUG_INFO("WIFI", "重连第 %d 次...", s_sta_retry_count);
|
||||
// ★ 冷启动首次连接失败时增加退避延迟
|
||||
// 长时间断电后路由器可能还在初始化(ARP/DHCP/信道选择)
|
||||
// reason 201=NO_AP_FOUND, 15=AUTH_FAIL, 2=AUTH_EXPIRE, 204=HANDSHAKE_TIMEOUT
|
||||
if (s_cold_boot && s_sta_retry_count <= 3) {
|
||||
int delay_ms = 2000 * s_sta_retry_count; // 2s, 4s, 6s 递增退避
|
||||
DEBUG_INFO("WIFI", "冷启动退避重连第 %d 次 (reason=%d, 等 %dms)...",
|
||||
s_sta_retry_count, disconn->reason, delay_ms);
|
||||
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "重连第 %d 次...", s_sta_retry_count);
|
||||
}
|
||||
esp_wifi_connect();
|
||||
} else {
|
||||
DEBUG_ERR("WIFI", "STA 重连失败 %d 次,放弃", s_sta_retry_count);
|
||||
s_sta_retry_exhausted = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -233,6 +256,8 @@ static esp_err_t wifi_init_sta(void)
|
||||
s_wifi_mode = OTA_WIFI_MODE_STA;
|
||||
/** @brief STA 连接重试次数。 */
|
||||
s_sta_retry_count = 0;
|
||||
/** @brief STA 重试已耗尽标志。 */
|
||||
s_sta_retry_exhausted = false;
|
||||
|
||||
// ★ 设置国家代码:中国,允许信道 1-13
|
||||
// ESP32 默认国家是 US(只允许 1-11),中国路由可能用 12/13
|
||||
@@ -260,6 +285,15 @@ static esp_err_t wifi_init_sta(void)
|
||||
|
||||
wifi_set_static_ip();
|
||||
|
||||
// ★ 冷启动时在 wifi_start 前增加延迟
|
||||
// 长时间断电后首次上电,WiFi PHY 校准需要额外时间
|
||||
// esp_wifi_start() 内部会触发 PHY 初始化和校准
|
||||
// 如果校准未完成就启动射频,可能导致连接失败
|
||||
if (s_cold_boot) {
|
||||
DEBUG_INFO("WIFI", "冷启动: 等待 PHY 就绪...");
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
}
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
|
||||
@@ -284,19 +318,36 @@ static esp_err_t wifi_wait_sta_connected(char *ip_str, size_t len)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// ★ 使用轮询等待,检查 s_sta_retry_exhausted 标志加速退出
|
||||
// 不再一次性等完整超时,而是每秒检查一次重试耗尽状态
|
||||
TickType_t timeout_ticks = pdMS_TO_TICKS(WIFI_STA_CONNECT_TIMEOUT_MS);
|
||||
if (xSemaphoreTake(wait_sem, timeout_ticks) == pdTRUE) {
|
||||
esp_netif_ip_info_t ip_info;
|
||||
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
if (netif != NULL && esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) {
|
||||
if (ip_str) {
|
||||
snprintf(ip_str, len, IPSTR, IP2STR(&ip_info.ip));
|
||||
TickType_t start_tick = xTaskGetTickCount();
|
||||
TickType_t deadline = start_tick + timeout_ticks;
|
||||
|
||||
while (xTaskGetTickCount() < deadline) {
|
||||
// 每次最多等 1 秒
|
||||
TickType_t wait_until = xTaskGetTickCount() + pdMS_TO_TICKS(1000);
|
||||
if (wait_until > deadline) wait_until = deadline;
|
||||
|
||||
if (xSemaphoreTake(wait_sem, wait_until - xTaskGetTickCount()) == pdTRUE) {
|
||||
esp_netif_ip_info_t ip_info;
|
||||
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
|
||||
if (netif != NULL && esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) {
|
||||
if (ip_str) {
|
||||
snprintf(ip_str, len, IPSTR, IP2STR(&ip_info.ip));
|
||||
}
|
||||
DEBUG_INFO("WIFI", "%s 等待成功, 当前 IP: " IPSTR, wait_name, IP2STR(&ip_info.ip));
|
||||
}
|
||||
DEBUG_INFO("WIFI", "%s 等待成功, 当前 IP: " IPSTR, wait_name, IP2STR(&ip_info.ip));
|
||||
/** @brief STA 模式连接状态。 */
|
||||
s_sta_connected = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ★ 重试已耗尽,不再等待
|
||||
if (s_sta_retry_exhausted) {
|
||||
DEBUG_ERR("WIFI", "STA 重试已耗尽 (%d 次),立即切换 AP", s_sta_retry_count);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
/** @brief STA 模式连接状态。 */
|
||||
s_sta_connected = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
DEBUG_ERR("WIFI", "等待 %s 超时 (%d ms), 重连次数: %d", wait_name, WIFI_STA_CONNECT_TIMEOUT_MS, s_sta_retry_count);
|
||||
@@ -659,10 +710,22 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "找不到 gd32_ota 分区");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
esp_err_t erase_err = esp_partition_erase_range(gd32_partition, 0, gd32_partition->size);
|
||||
const uint32_t gd32_erase_size = align_up_u32(gd32_fw_size, GD32_OTA_FLASH_SECTOR_SIZE);
|
||||
if (gd32_erase_size > gd32_partition->size) {
|
||||
DEBUG_ERR("WIFI", "gd32_ota 分区空间不足: erase=%u partition=%u",
|
||||
(unsigned)gd32_erase_size, (unsigned)gd32_partition->size);
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "gd32_ota partition too small");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
DEBUG_INFO("WIFI", "擦除 gd32_ota 分区: %u bytes (fw=%u, partition=%u)",
|
||||
(unsigned)gd32_erase_size, (unsigned)gd32_fw_size, (unsigned)gd32_partition->size);
|
||||
esp_err_t erase_err = esp_partition_erase_range(gd32_partition, 0, gd32_erase_size);
|
||||
if (erase_err != ESP_OK) {
|
||||
DEBUG_ERR("WIFI", "擦除 gd32_ota 分区失败: %s", esp_err_to_name(erase_err));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "擦除分区失败");
|
||||
char err_msg[96];
|
||||
snprintf(err_msg, sizeof(err_msg), "erase gd32_ota failed: %s", esp_err_to_name(erase_err));
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
meta_clear();
|
||||
@@ -775,6 +838,7 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
|
||||
}
|
||||
/* 上一轮中继已结束/失败/空闲,重置标志允许重新触发 */
|
||||
s_relay_triggered = false;
|
||||
s_gd32_storage_done = false;
|
||||
|
||||
if (!g_ota_dispatch_queue) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay queue not ready");
|
||||
@@ -810,15 +874,17 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
char resp[256];
|
||||
int len = snprintf(resp, sizeof(resp),
|
||||
"{\"total_size\": %lu, \"stored_size\": %lu, \"last_seq\": %lu, "
|
||||
"\"storage_done\": %s, \"relay_state\": %d}",
|
||||
"\"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,
|
||||
s_gd32_storage_done ? "true" : "false",
|
||||
g_relay_state);
|
||||
g_relay_state,
|
||||
g_gd32_app_alive ? "true" : "false");
|
||||
|
||||
DEBUG_INFO("WIFI", "OTA 状态: total=%lu, stored=%lu, done=%s",
|
||||
DEBUG_INFO("WIFI", "OTA 状态: total=%lu, stored=%lu, done=%s, app_alive=%s",
|
||||
(unsigned long)meta.total_size, (unsigned long)meta.stored_size,
|
||||
s_gd32_storage_done ? "true" : "false");
|
||||
s_gd32_storage_done ? "true" : "false",
|
||||
g_gd32_app_alive ? "true" : "false");
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, len);
|
||||
@@ -1169,6 +1235,8 @@ static esp_err_t http_ota_server_start(void)
|
||||
config.server_port = HTTP_SERVER_PORT;
|
||||
config.stack_size = HTTP_SERVER_STACK_SIZE;
|
||||
config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检
|
||||
config.recv_wait_timeout = 30; // GD32 上传前会擦除 Flash,5 秒默认值过紧
|
||||
config.send_wait_timeout = 30;
|
||||
|
||||
DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ===");
|
||||
DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port);
|
||||
@@ -1414,6 +1482,7 @@ void wifi_ota_signal_relay(void)
|
||||
}
|
||||
/* 上一轮中继已结束/失败/空闲,重置标志 */
|
||||
s_relay_triggered = false;
|
||||
s_gd32_storage_done = false;
|
||||
|
||||
/** @brief OTA 中继是否已触发标志。 */
|
||||
s_relay_triggered = true;
|
||||
@@ -1481,19 +1550,64 @@ bool wifi_early_init(void)
|
||||
{
|
||||
DEBUG_INFO("WIFI", "=== WiFi 早期初始化 (调度器启动前) ===");
|
||||
|
||||
// ★ 冷启动检测:判断是否为上电复位
|
||||
esp_reset_reason_t reset_reason = esp_reset_reason();
|
||||
bool is_cold_boot = (reset_reason == ESP_RST_POWERON || reset_reason == ESP_RST_BROWNOUT);
|
||||
s_cold_boot = is_cold_boot; // 保存到模块级标志,供事件回调使用
|
||||
DEBUG_INFO("WIFI", "复位原因: %d (%s), 冷启动: %s",
|
||||
reset_reason,
|
||||
(reset_reason == ESP_RST_POWERON) ? "POWER_ON" :
|
||||
(reset_reason == ESP_RST_BROWNOUT) ? "BROWNOUT" :
|
||||
(reset_reason == ESP_RST_SW) ? "SOFTWARE" :
|
||||
(reset_reason == ESP_RST_PANIC) ? "PANIC" :
|
||||
(reset_reason == ESP_RST_INT_WDT) ? "INT_WDT" :
|
||||
(reset_reason == ESP_RST_TASK_WDT) ? "TASK_WDT" :
|
||||
(reset_reason == ESP_RST_DEEPSLEEP) ? "DEEP_SLEEP" :
|
||||
"OTHER",
|
||||
is_cold_boot ? "是" : "否");
|
||||
|
||||
esp_err_t err = nvs_flash_init();
|
||||
bool nvs_erased = false;
|
||||
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
// NVS 分区损坏或版本不匹配,擦除后重试
|
||||
DEBUG_WARN("WIFI", "NVS 分区异常 (%s),擦除后重试", esp_err_to_name(err));
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
nvs_erased = true;
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_ERR("WIFI", "NVS 初始化失败: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
if (nvs_erased) {
|
||||
DEBUG_WARN("WIFI", "NVS 已擦除重建,WiFi PHY 校准数据已清除,将重新校准");
|
||||
}
|
||||
DEBUG_INFO("WIFI", "NVS 初始化成功");
|
||||
}
|
||||
|
||||
// ★ 冷启动时强制清除 WiFi NVS 缓存,避免残留脏数据
|
||||
// 长时间断电后 NVS 中的 WiFi 配置缓存可能不一致
|
||||
if (is_cold_boot) {
|
||||
nvs_handle_t nvs_handle;
|
||||
err = nvs_open("nvs.net80211", NVS_READWRITE, &nvs_handle);
|
||||
if (err == ESP_OK) {
|
||||
// 清除 WiFi 驱动内部 NVS 缓存键
|
||||
nvs_erase_all(nvs_handle);
|
||||
nvs_commit(nvs_handle);
|
||||
nvs_close(nvs_handle);
|
||||
DEBUG_INFO("WIFI", "冷启动: 已清除 WiFi NVS 缓存 (nvs.net80211)");
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "冷启动: 无 WiFi NVS 缓存分区,跳过清除");
|
||||
}
|
||||
// 同样清除 PHY 校准数据分区,强制下次全量校准
|
||||
err = nvs_open("phy", NVS_READWRITE, &nvs_handle);
|
||||
if (err == ESP_OK) {
|
||||
nvs_erase_all(nvs_handle);
|
||||
nvs_commit(nvs_handle);
|
||||
nvs_close(nvs_handle);
|
||||
DEBUG_INFO("WIFI", "冷启动: 已清除 PHY 校准缓存,将重新校准");
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief STA 连接成功信号量。 */
|
||||
s_sta_connected_sem = xSemaphoreCreateBinary();
|
||||
/** @brief STA 获取 IP 信号量。 */
|
||||
@@ -1563,7 +1677,29 @@ void wifi_ota_task(void *pvParameters)
|
||||
DEBUG_INFO("WIFI", "STA 已连接, IP: %s", ip_str);
|
||||
} else {
|
||||
DEBUG_WARN("WIFI", "STA 连接超时,切换到 AP 模式...");
|
||||
esp_wifi_stop(); // 先停 STA,再切 AP
|
||||
// ★ 完整的 STA→AP 切换流程:
|
||||
// 1. 停止 WiFi
|
||||
// 2. 反初始化 WiFi 驱动(释放内部资源,包括 NVS 缓存)
|
||||
// 3. 重新初始化 WiFi 驱动
|
||||
// 4. 重新注册事件回调
|
||||
// 5. 以 AP 模式启动
|
||||
esp_wifi_stop();
|
||||
esp_err_t deinit_err = esp_wifi_deinit();
|
||||
if (deinit_err != ESP_OK) {
|
||||
DEBUG_WARN("WIFI", "WiFi deinit 失败: %s,仅 stop 后切 AP", esp_err_to_name(deinit_err));
|
||||
} else {
|
||||
DEBUG_INFO("WIFI", "WiFi 驱动已反初始化,重新初始化...");
|
||||
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
deinit_err = esp_wifi_init(&wifi_cfg);
|
||||
if (deinit_err != ESP_OK) {
|
||||
DEBUG_ERR("WIFI", "WiFi 驱动重新初始化失败: %s", esp_err_to_name(deinit_err));
|
||||
} else {
|
||||
// ★ deinit 后事件回调会丢失,必须重新注册
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL);
|
||||
esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL);
|
||||
DEBUG_INFO("WIFI", "WiFi/IP 事件回调已重新注册");
|
||||
}
|
||||
}
|
||||
/** @brief 当前 WiFi 工作模式(AP 或 STA)。 */
|
||||
s_wifi_mode = OTA_WIFI_MODE_AP;
|
||||
wifi_init_ap();
|
||||
|
||||
@@ -373,13 +373,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 && attempt < 10) {
|
||||
while (xTaskGetTickCount() <= deadline) {
|
||||
++attempt;
|
||||
memset(rx_raw, 0xFF, sizeof(rx_raw));
|
||||
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(2));
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
size_t parsed_len = 0;
|
||||
size_t frame_offset = 0;
|
||||
if (spi_ota_find_frame(rx_raw, sizeof(rx_raw), &parsed_type, &parsed_payload, &parsed_len, &frame_offset)) {
|
||||
DEBUG_INFO("SPI", "OTA响应: type=0x%02X len=%u offset=%u attempt=%u",
|
||||
DEBUG_DBG("SPI", "OTA响应: type=0x%02X len=%u offset=%u attempt=%u",
|
||||
parsed_type, (unsigned)parsed_len, (unsigned)frame_offset, (unsigned)attempt);
|
||||
if (resp_type) *resp_type = parsed_type;
|
||||
if (resp_payload && resp_max > 0) {
|
||||
@@ -413,10 +413,10 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
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(2));
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
}
|
||||
|
||||
DEBUG_ERR("SPI", "OTA响应超时: timeout=%ums", (unsigned)timeout_ms);
|
||||
DEBUG_DBG("SPI", "OTA响应超时: timeout=%ums", (unsigned)timeout_ms);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
@@ -448,7 +448,7 @@ esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
DEBUG_INFO("SPI", "OTA事务: req_type=0x%02X req_len=%u", type, (unsigned)payload_len);
|
||||
DEBUG_DBG("SPI", "OTA事务: req_type=0x%02X req_len=%u", type, (unsigned)payload_len);
|
||||
|
||||
/* ============================================================
|
||||
* 第1次 CS 周期:发送请求帧(只发不收)
|
||||
@@ -634,6 +634,86 @@ esp_err_t gd32_app_request_bootloader(uint32_t timeout_ms)
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t gd32_app_ping(uint32_t timeout_ms, uint32_t *uptime_sec)
|
||||
{
|
||||
/* APP 侧 SPI 从机当前是中断逐字节收发,不适合像 BOOT 那样一次读 96B 长轮询。
|
||||
* 这里改为:每次都完整重发一帧 PING 请求,然后只读一个短窗口寻找 ACK。
|
||||
* 这样即便某次响应窗口错过,也会在下一次重发时重新生成 APP_ALIVE。 */
|
||||
constexpr uint8_t kAppPingType = 0xA1U;
|
||||
constexpr size_t kAppPingPollBytes = 32U;
|
||||
|
||||
uint8_t tx_frame[8] = {0};
|
||||
size_t tx_len = 0U;
|
||||
if (!spi_ota_build_frame(kAppPingType, NULL, 0U, tx_frame, &tx_len)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
|
||||
uint32_t attempt = 0U;
|
||||
|
||||
while (xTaskGetTickCount() <= deadline) {
|
||||
++attempt;
|
||||
|
||||
esp_err_t err = spi_transfer_clocked(tx_frame, tx_len, NULL, 0U);
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_DBG("SPI", "APP PING 请求发送失败[%lu]: %s",
|
||||
(unsigned long)attempt, esp_err_to_name(err));
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
esp_rom_delay_us(200);
|
||||
|
||||
uint8_t rx_raw[kAppPingPollBytes];
|
||||
uint8_t dummy[kAppPingPollBytes];
|
||||
memset(dummy, 0xFF, sizeof(dummy));
|
||||
memset(rx_raw, 0xFF, sizeof(rx_raw));
|
||||
|
||||
err = spi_transfer_clocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw));
|
||||
if (err != ESP_OK) {
|
||||
DEBUG_DBG("SPI", "APP PING 响应读取失败[%lu]: %s",
|
||||
(unsigned long)attempt, esp_err_to_name(err));
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
uint8_t resp_type = 0U;
|
||||
const uint8_t *resp_payload = nullptr;
|
||||
size_t resp_len = 0U;
|
||||
size_t frame_offset = 0U;
|
||||
if (spi_ota_find_frame(rx_raw, sizeof(rx_raw),
|
||||
&resp_type, &resp_payload, &resp_len, &frame_offset)) {
|
||||
if (resp_type == SPI_OTA_RSP_ACK && resp_len >= 3U && resp_payload[0] == kAppPingType) {
|
||||
if (uptime_sec) {
|
||||
*uptime_sec = (uint32_t)resp_payload[1] | ((uint32_t)resp_payload[2] << 8);
|
||||
}
|
||||
DEBUG_INFO("SPI", "APP PING 成功: uptime=%lu sec, attempt=%lu, offset=%u",
|
||||
uptime_sec ? (unsigned long)*uptime_sec : 0UL,
|
||||
(unsigned long)attempt,
|
||||
(unsigned)frame_offset);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
DEBUG_DBG("SPI", "APP PING 收到无效帧[%lu]: type=0x%02X len=%u offset=%u payload0=0x%02X",
|
||||
(unsigned long)attempt,
|
||||
resp_type,
|
||||
(unsigned)resp_len,
|
||||
(unsigned)frame_offset,
|
||||
resp_len > 0U ? resp_payload[0] : 0xFFU);
|
||||
} else {
|
||||
DEBUG_DBG("SPI", "APP PING 未找到有效帧[%lu]: first8=%02X%02X%02X%02X%02X%02X%02X%02X",
|
||||
(unsigned long)attempt,
|
||||
rx_raw[0], rx_raw[1], rx_raw[2], rx_raw[3],
|
||||
rx_raw[4], rx_raw[5], rx_raw[6], rx_raw[7]);
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
|
||||
DEBUG_DBG("SPI", "APP PING 超时: timeout=%ums", (unsigned)timeout_ms);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
// 带重试的 SPI 传输
|
||||
/**
|
||||
* @brief 执行带重试的 SPI 同步传输。
|
||||
|
||||
@@ -147,4 +147,12 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
|
||||
*/
|
||||
esp_err_t gd32_app_request_bootloader(uint32_t timeout_ms);
|
||||
|
||||
/**
|
||||
* @brief 向 GD32 APP 发送存活探测命令 (TYPE=0xA1),等待 APP 回复 ALIVE ACK。
|
||||
* @param timeout_ms 超时时间(毫秒)。
|
||||
* @param uptime_sec 输出 APP 运行秒数,可为 NULL。
|
||||
* @return ESP_OK APP 存活并回复;ESP_ERR_TIMEOUT 超时无响应。
|
||||
*/
|
||||
esp_err_t gd32_app_ping(uint32_t timeout_ms, uint32_t *uptime_sec);
|
||||
|
||||
#endif // SPI_MASTER_H
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
*** 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
|
||||
+228
-7
@@ -1,6 +1,8 @@
|
||||
// ==================== 全局状态 ====================
|
||||
// ==================== 全局状态 ====================
|
||||
let esp32File = null;
|
||||
let gd32File = null;
|
||||
let esp32FileHandle = null; // FileSystemFileHandle for hot reload
|
||||
let gd32FileHandle = null; // FileSystemFileHandle for hot reload
|
||||
let uploadProgress = 0;
|
||||
let isUploading = false;
|
||||
let logFilter = 'all';
|
||||
@@ -39,6 +41,12 @@ function formatUptime(sec) {
|
||||
return s + 's';
|
||||
}
|
||||
|
||||
function formatHttpErrorDetail(text) {
|
||||
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
|
||||
return detail;
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
type = type || 'success';
|
||||
const toast = document.getElementById('toast');
|
||||
@@ -538,6 +546,37 @@ function handleFileSelect(event, chip) {
|
||||
if (file) processFile(file, chip);
|
||||
}
|
||||
|
||||
// 通过 File System Access API 选择文件(支持热重载)
|
||||
async function pickFileViaFSAPI(chip) {
|
||||
if (!window.showOpenFilePicker) {
|
||||
addLog('W', '文件', '当前浏览器不支持 File System Access API,请手动重新选择文件');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
var acceptMap = chip === 'esp32'
|
||||
? { 'application/octet-stream': ['.bin'] }
|
||||
: { 'application/octet-stream': ['.bin', '.hex'] };
|
||||
var handles = await window.showOpenFilePicker({
|
||||
types: [{ description: '固件文件', accept: acceptMap }],
|
||||
multiple: false
|
||||
});
|
||||
if (handles && handles.length > 0) {
|
||||
var handle = handles[0];
|
||||
var file = await handle.getFile();
|
||||
if (chip === 'esp32') esp32FileHandle = handle;
|
||||
else gd32FileHandle = handle;
|
||||
processFile(file, chip);
|
||||
addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
addLog('W', '文件', 'FSAPI 选择失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setupDropZone(zoneId, chip) {
|
||||
var zone = document.getElementById(zoneId);
|
||||
if (!zone) return;
|
||||
@@ -562,14 +601,157 @@ document.getElementById('canDlc').addEventListener('input', updateCanHint);
|
||||
document.getElementById('spiType').addEventListener('input', updateSpiHint);
|
||||
document.getElementById('spiData').addEventListener('input', updateSpiHint);
|
||||
|
||||
// ==================== Intel HEX → BIN 转换 ====================
|
||||
|
||||
/**
|
||||
* 将 Intel HEX 字符串解析为原始 BIN 字节数组。
|
||||
*
|
||||
* 支持记录类型: 00(数据), 01(EOF), 02(扩展段地址), 04(扩展线性地址)。
|
||||
* 地址间隙填充 0xFF(Flash 擦除态)。
|
||||
*
|
||||
* @param {string} hexText Intel HEX 文件的完整文本内容。
|
||||
* @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }}
|
||||
*/
|
||||
function parseIntelHex(hexText) {
|
||||
var baseAddr = 0; // 当前扩展地址
|
||||
var segments = []; // { offset, data } 列表
|
||||
var minAddr = 0xFFFFFFFF;
|
||||
var maxAddr = 0;
|
||||
|
||||
var lines = hexText.split(/\r?\n/);
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line || line[0] !== ':') continue;
|
||||
|
||||
// 解析行: :LLAAAATT[DD...]CC
|
||||
var byteCount = parseInt(line.substr(1, 2), 16);
|
||||
var addr16 = parseInt(line.substr(3, 4), 16);
|
||||
var recType = parseInt(line.substr(7, 2), 16);
|
||||
|
||||
// 校验和验证
|
||||
var sum = 0;
|
||||
for (var j = 1; j < line.length; j += 2) {
|
||||
sum += parseInt(line.substr(j, 2), 16);
|
||||
}
|
||||
if ((sum & 0xFF) !== 0) {
|
||||
throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line);
|
||||
}
|
||||
|
||||
if (recType === 0x01) {
|
||||
// EOF
|
||||
break;
|
||||
} else if (recType === 0x04) {
|
||||
// 扩展线性地址 (upper 16 bits)
|
||||
baseAddr = parseInt(line.substr(9, 4), 16) << 16;
|
||||
} else if (recType === 0x02) {
|
||||
// 扩展段地址
|
||||
baseAddr = (parseInt(line.substr(9, 4), 16) << 4) & 0xFFFFF;
|
||||
} else if (recType === 0x00) {
|
||||
// 数据记录
|
||||
var absAddr = baseAddr + addr16;
|
||||
var data = new Uint8Array(byteCount);
|
||||
for (var k = 0; k < byteCount; k++) {
|
||||
data[k] = parseInt(line.substr(9 + k * 2, 2), 16);
|
||||
}
|
||||
segments.push({ offset: absAddr, data: data });
|
||||
if (absAddr < minAddr) minAddr = absAddr;
|
||||
var endAddr = absAddr + byteCount;
|
||||
if (endAddr > maxAddr) maxAddr = endAddr;
|
||||
}
|
||||
// 其他记录类型 (03, 05 等) 忽略
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
throw new Error('HEX 文件中没有有效数据记录');
|
||||
}
|
||||
|
||||
// 合并为连续 bin,间隙填 0xFF
|
||||
var totalSize = maxAddr - minAddr;
|
||||
var bin = new Uint8Array(totalSize);
|
||||
// 先填充 0xFF
|
||||
for (var m = 0; m < totalSize; m++) bin[m] = 0xFF;
|
||||
for (var s = 0; s < segments.length; s++) {
|
||||
var seg = segments[s];
|
||||
var off = seg.offset - minAddr;
|
||||
bin.set(seg.data, off);
|
||||
}
|
||||
|
||||
return { data: bin, baseAddr: minAddr, endAddr: maxAddr };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文件内容是否为 Intel HEX 格式。
|
||||
* 看前几个非空行是否以 ':' 开头。
|
||||
*/
|
||||
function isIntelHexText(text) {
|
||||
var lines = text.split(/\r?\n/);
|
||||
for (var i = 0; i < Math.min(lines.length, 5); i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
return line[0] === ':';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== 文件处理 ====================
|
||||
|
||||
function processFile(file, chip) {
|
||||
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
||||
var isHexByName = file.name.toLowerCase().endsWith('.hex');
|
||||
|
||||
if (chip === 'gd32' && isHexByName) {
|
||||
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
try {
|
||||
var text = e.target.result;
|
||||
// 二次确认: 内容也像 HEX
|
||||
if (!isIntelHexText(text)) {
|
||||
// 文件名 .hex 但内容不像,直接用原始文件
|
||||
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
|
||||
gd32File = file;
|
||||
document.getElementById('fileInfo' + suffix).classList.add('show');
|
||||
document.getElementById('fileName' + suffix).textContent = file.name;
|
||||
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
|
||||
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
|
||||
updateUploadButtonLabel();
|
||||
return;
|
||||
}
|
||||
var result = parseIntelHex(text);
|
||||
var binName = file.name.replace(/\.hex$/i, '.bin');
|
||||
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
|
||||
var binFile = new File([binBlob], binName, { type: 'application/octet-stream' });
|
||||
gd32File = binFile;
|
||||
|
||||
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
|
||||
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + binName);
|
||||
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.data.length));
|
||||
showToast('HEX 已自动转换为 BIN', 'success');
|
||||
|
||||
document.getElementById('fileInfo' + suffix).classList.add('show');
|
||||
document.getElementById('fileName' + suffix).textContent = binName + ' (由 ' + file.name + ' 转换)';
|
||||
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
|
||||
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.data.length);
|
||||
updateUploadButtonLabel();
|
||||
} catch (err) {
|
||||
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
|
||||
showToast('HEX 文件解析失败: ' + err.message, 'error');
|
||||
gd32File = null;
|
||||
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
||||
updateUploadButtonLabel();
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return; // 异步,回调里设置 gd32File
|
||||
}
|
||||
|
||||
// .bin 文件或 ESP32 文件: 直接使用
|
||||
if (chip === 'esp32') esp32File = file;
|
||||
else gd32File = file;
|
||||
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
||||
document.getElementById('fileInfo' + suffix).classList.add('show');
|
||||
document.getElementById('fileName' + suffix).textContent = file.name;
|
||||
document.getElementById('fileType' + suffix).textContent =
|
||||
file.name.toLowerCase().indexOf('.hex') >= 0 ? 'Intel HEX' : 'Binary';
|
||||
isHexByName ? 'Intel HEX' : 'Binary';
|
||||
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
|
||||
updateUploadButtonLabel();
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
|
||||
@@ -578,8 +760,8 @@ function processFile(file, chip) {
|
||||
|
||||
function clearFile(chip) {
|
||||
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
||||
if (chip === 'esp32') esp32File = null;
|
||||
else gd32File = null;
|
||||
if (chip === 'esp32') { esp32File = null; esp32FileHandle = null; }
|
||||
else { gd32File = null; gd32FileHandle = null; }
|
||||
document.getElementById('fileInput' + suffix).value = '';
|
||||
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
||||
updateUploadButtonLabel();
|
||||
@@ -590,6 +772,43 @@ function clearFile(chip) {
|
||||
async function startUpload(chip) {
|
||||
var file = chip === 'esp32' ? esp32File : gd32File;
|
||||
if (!file || isUploading) return;
|
||||
|
||||
// 每次上传前尝试重新从本地读取文件
|
||||
var handle = chip === 'esp32' ? esp32FileHandle : gd32FileHandle;
|
||||
if (handle) {
|
||||
// 有 FileSystemFileHandle,调用 getFile() 获取最新内容
|
||||
try {
|
||||
var freshFile = await handle.getFile();
|
||||
if (chip === 'esp32') {
|
||||
esp32File = freshFile;
|
||||
file = freshFile;
|
||||
} else {
|
||||
if (freshFile.name.toLowerCase().endsWith('.hex')) {
|
||||
processFile(freshFile, 'gd32');
|
||||
await new Promise(function(resolve) { setTimeout(resolve, 200); });
|
||||
file = gd32File;
|
||||
} else {
|
||||
gd32File = freshFile;
|
||||
file = freshFile;
|
||||
}
|
||||
}
|
||||
addLog('I', '文件', '已重新读取本地文件: ' + file.name + ' (' + formatBytes(file.size) + ')');
|
||||
} catch (e) {
|
||||
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
|
||||
}
|
||||
} else {
|
||||
// 没有 handle,尝试直接读取当前 File 对象
|
||||
try {
|
||||
await file.arrayBuffer();
|
||||
} catch (e) {
|
||||
// File 引用已失效,提示用户重新选择
|
||||
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
|
||||
showToast('文件已变更,请重新选择', 'error');
|
||||
clearFile(chip);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
|
||||
var origText = uploadBtn.textContent;
|
||||
@@ -652,8 +871,10 @@ async function startUpload(chip) {
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
addLog('E', '上传', '上传失败: HTTP ' + xhr.status);
|
||||
showToast('上传失败', 'error');
|
||||
var detail = formatHttpErrorDetail(xhr.responseText);
|
||||
var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : '');
|
||||
addLog('E', '上传', message);
|
||||
showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+58
-2
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -514,6 +514,7 @@
|
||||
selectedFile: null,
|
||||
selectedFileBytes: null,
|
||||
selectedFileKey: '',
|
||||
selectedFileHandle: null, // FileSystemFileHandle for hot reload
|
||||
abortRequested: false,
|
||||
uploading: false,
|
||||
autoFlashing: false,
|
||||
@@ -895,10 +896,38 @@
|
||||
updateFileInfo(file);
|
||||
}
|
||||
|
||||
// 通过 File System Access API 选择文件(支持热重载)
|
||||
async function pickFileViaFSAPI() {
|
||||
if (!window.showOpenFilePicker) {
|
||||
log('FILE', '当前浏览器不支持 File System Access API', 'W');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const handles = await window.showOpenFilePicker({
|
||||
types: [{ description: '固件文件', accept: { 'application/octet-stream': ['.bin', '.hex'] } }],
|
||||
multiple: false
|
||||
});
|
||||
if (handles && handles.length > 0) {
|
||||
const handle = handles[0];
|
||||
const file = await handle.getFile();
|
||||
state.selectedFileHandle = handle;
|
||||
updateFileInfo(file);
|
||||
log('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
log('FILE', 'FSAPI 选择失败: ' + e.message, 'W');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function clearSelectedFile() {
|
||||
state.selectedFile = null;
|
||||
state.selectedFileBytes = null;
|
||||
state.selectedFileKey = '';
|
||||
state.selectedFileHandle = null;
|
||||
state.lastUploadStatus = 'idle';
|
||||
if (els.firmwareFile) els.firmwareFile.value = '';
|
||||
updateFileInfo(null);
|
||||
@@ -940,6 +969,18 @@
|
||||
}
|
||||
|
||||
async function uploadFirmware() {
|
||||
// 每次刷写前尝试重新从本地读取文件
|
||||
if (state.selectedFileHandle) {
|
||||
try {
|
||||
const freshFile = await state.selectedFileHandle.getFile();
|
||||
updateFileInfo(freshFile);
|
||||
log('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
|
||||
} catch (e) {
|
||||
log('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||||
}
|
||||
} else {
|
||||
syncSelectedFileFromInput();
|
||||
}
|
||||
if (!state.selectedFile || !state.dataInChar) return false;
|
||||
|
||||
const cfg = currentTarget();
|
||||
@@ -1040,6 +1081,18 @@
|
||||
}
|
||||
|
||||
async function autoFlash() {
|
||||
// 每次刷写前尝试重新从本地读取文件
|
||||
if (state.selectedFileHandle) {
|
||||
try {
|
||||
const freshFile = await state.selectedFileHandle.getFile();
|
||||
updateFileInfo(freshFile);
|
||||
log('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
|
||||
} catch (e) {
|
||||
log('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||||
}
|
||||
} else {
|
||||
syncSelectedFileFromInput();
|
||||
}
|
||||
if (!state.selectedFile || !state.server) return;
|
||||
const cfg = currentTarget();
|
||||
state.autoFlashing = true;
|
||||
@@ -1077,7 +1130,10 @@
|
||||
els.autoFlashBtn.addEventListener('click', autoFlash);
|
||||
els.abortBtn.addEventListener('click', abortTask);
|
||||
els.clearLogBtn.addEventListener('click', clearLogs);
|
||||
els.uploadZone.addEventListener('click', () => els.firmwareFile.click());
|
||||
els.uploadZone.addEventListener('click', () => {
|
||||
if (window.showOpenFilePicker) { pickFileViaFSAPI(); }
|
||||
else { els.firmwareFile.click(); }
|
||||
});
|
||||
els.uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.add('dragover');
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -54,11 +54,11 @@
|
||||
<div class="card-head"><span class="ico">📦</span> ESP32 自身升级</div>
|
||||
<div class="card-body">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;">上传固件并执行 OTA,设备将自动重启</div>
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneEsp32" onclick="document.getElementById('fileInputEsp32').click()">
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneEsp32" onclick="if(window.showOpenFilePicker){pickFileViaFSAPI('esp32')}else{document.getElementById('fileInputEsp32').click()}">
|
||||
<div class="upload-ico">📤</div>
|
||||
<h4>点击或拖拽 ESP32 固件</h4>
|
||||
</div>
|
||||
<input type="file" id="fileInputEsp32" accept=".hex,.bin" onchange="handleFileSelect(event, 'esp32')" style="display:none;">
|
||||
<input type="file" id="fileInputEsp32" accept=".bin" onchange="handleFileSelect(event, 'esp32')" style="display:none;">
|
||||
<div class="file-info" id="fileInfoEsp32">
|
||||
<div class="file-info-row">
|
||||
<span style="font-weight:600;">📄 <span id="fileNameEsp32">-</span></span>
|
||||
@@ -79,8 +79,8 @@
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">📦</span> GD32 SPI 中继</div>
|
||||
<div class="card-body">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;">上传固件到缓存,再触发 SPI 中继刷写</div>
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneGd32" onclick="document.getElementById('fileInputGd32').click()">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;">上传固件到缓存,再触发 SPI 中继刷写<br><span style="color:var(--accent);">支持 .bin 和 .hex(自动转换)</span></div>
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneGd32" onclick="if(window.showOpenFilePicker){pickFileViaFSAPI('gd32')}else{document.getElementById('fileInputGd32').click()}">
|
||||
<div class="upload-ico">📤</div>
|
||||
<h4>点击或拖拽 GD32 固件</h4>
|
||||
</div>
|
||||
@@ -258,6 +258,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260528"></script>
|
||||
<script src="app.js?v=20260529"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user