From 6f4be9189cf184a957e6d92b84b27bc967acfd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Sat, 30 May 2026 20:04:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(relay):=20OTA=E5=8F=AF=E7=94=A8=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E6=B7=BB=E5=8A=A0OTA=E4=B8=AD=E7=BB=A7?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E5=92=8C?= =?UTF-8?q?APP=E5=AD=98=E6=B4=BB=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD=20-?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9Ehealth=5Fcheck=5Fbefore=5Fota=5Frelay?= =?UTF-8?q?=E5=87=BD=E6=95=B0=EF=BC=8C=E7=94=A8=E4=BA=8EOTA=E4=B8=AD?= =?UTF-8?q?=E7=BB=A7=E8=BD=AC=E5=8F=91=E9=98=B6=E6=AE=B5=E7=9A=84=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E6=A3=80=E6=9F=A5=EF=BC=8C=20=20=20=E8=B7=B3=E8=BF=87?= =?UTF-8?q?WiFi=E7=9B=B8=E5=85=B3=E6=A3=80=E6=9F=A5=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E6=B8=A9=E5=BA=A6=E7=AD=89=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81=20-=20=E6=B7=BB=E5=8A=A0APP=E5=AD=98?= =?UTF-8?q?=E6=B4=BB=E9=AA=8C=E8=AF=81=E9=98=B6=E6=AE=B5=EF=BC=8COTA?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=90=8E=E7=AD=89=E5=BE=85GD32=20APP?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=B9=B6=E9=80=9A=E8=BF=87ping=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=AD=98=E6=B4=BB=20-=20=E5=AE=9E=E7=8E=B0=E6=9B=B4?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E7=9A=84=E5=93=8D=E5=BA=94=E5=B8=A7=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=8C=B9=E9=85=8D=E5=92=8C=E8=BD=AE=E8=AF=A2=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E6=8F=90=E9=AB=98=E5=8D=8F=E8=AE=AE=E5=8F=AF?= =?UTF-8?q?=E9=9D=A0=E6=80=A7=20-=20=E6=B7=BB=E5=8A=A0=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E7=9A=84=E5=93=8D=E5=BA=94=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=99=A8(is=5Fhandshake=5Fecho=5Fack=E3=80=81is=5Fchallenge=5F?= =?UTF-8?q?payload=5Fack=E7=AD=89)=20-=20=E4=BC=98=E5=8C=96poll=5Funtil=5F?= =?UTF-8?q?expected=5Fack=5Fshape=E5=87=BD=E6=95=B0=EF=BC=8C=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=AE=8B=E7=95=99ACK=E5=92=8C=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=83=85=E5=86=B5=20-=20=E5=B0=86=E8=B0=83=E8=AF=95=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=BA=A7=E5=88=AB=E4=BB=8EINFO=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B8=BADBG=EF=BC=8C=E5=87=8F=E5=B0=91=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=BE=93=E5=87=BA=20-=20=E4=BF=AE=E5=A4=8Dhandshake=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E9=87=8D=E8=AF=95=E6=9C=BA=E5=88=B6=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=9C=80=E5=A4=A7=E9=87=8D=E8=AF=95=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=E9=99=90=E5=88=B6=20-=20=E4=BC=98=E5=8C=96Flash=E6=93=A6?= =?UTF-8?q?=E9=99=A4=E6=9C=9F=E9=97=B4=E7=9A=84WATI=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=A4=84=E7=90=86=EF=BC=8C=E4=BD=BF=E7=94=A8=E7=9F=AD=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=81=BF=E5=85=8D=E9=95=BF=E9=98=BB=E5=A1=9E=20-=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AE=8C=E6=95=B4=E7=9A=84=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=B8=85=E7=90=86=EF=BC=8C=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=B1=A1=E6=9F=93=20-=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E7=9A=84=E6=B3=A8=E9=87=8A=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=90=84=E9=98=B6=E6=AE=B5=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/include/app_config.h | 4 +- main/monitor/debug_log.cpp | 2 +- main/monitor/health_monitor.cpp | 32 ++- main/monitor/health_monitor.h | 11 +- main/relay/ota_relay.cpp | 388 ++++++++++++++++++++++++++------ main/relay/ota_relay.h | 6 +- main/server/wifi_ota.cpp | 172 ++++++++++++-- main/transport/spi_master.cpp | 92 +++++++- main/transport/spi_master.h | 8 + patch.txt | 12 + tools/app.js | 235 ++++++++++++++++++- tools/ble_ota.html | 60 ++++- tools/index.html | 12 +- 13 files changed, 923 insertions(+), 111 deletions(-) create mode 100644 patch.txt diff --git a/main/include/app_config.h b/main/include/app_config.h index 97d6af0..7fc09b9 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -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 工作模式选择 diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index 74abed2..b2d988b 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -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; diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 1513295..3a16c13 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -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)。 diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index 0aa851b..c68e11e 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -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 事件名称字符串。 diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index eb4915c..cc43d8c 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -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 : "", 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(); diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index fee7ed6..eb9a0c4 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -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; diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index db06d6f..0bbf1c2 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -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(); diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 1c65061..c5ae550 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -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 同步传输。 diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index d1f30a2..ce3d54f 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -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 diff --git a/patch.txt b/patch.txt new file mode 100644 index 0000000..bd76464 --- /dev/null +++ b/patch.txt @@ -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 diff --git a/tools/app.js b/tools/app.js index 6fe83a2..c858097 100644 --- a/tools/app.js +++ b/tools/app.js @@ -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'); } }); diff --git a/tools/ble_ota.html b/tools/ble_ota.html index 640db87..83ec302 100644 --- a/tools/ble_ota.html +++ b/tools/ble_ota.html @@ -1,4 +1,4 @@ - + @@ -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'); diff --git a/tools/index.html b/tools/index.html index ba5e8c0..8952b1a 100644 --- a/tools/index.html +++ b/tools/index.html @@ -1,4 +1,4 @@ - + @@ -54,11 +54,11 @@
📦 ESP32 自身升级
上传固件并执行 OTA,设备将自动重启
-
+
📤

点击或拖拽 ESP32 固件

- +
📄 - @@ -79,8 +79,8 @@
📦 GD32 SPI 中继
-
上传固件到缓存,再触发 SPI 中继刷写
-
+
上传固件到缓存,再触发 SPI 中继刷写
支持 .bin 和 .hex(自动转换)
+
📤

点击或拖拽 GD32 固件

@@ -258,6 +258,6 @@ 操作成功
- +