点击或拖拽文件到此处
-支持 .hex 和 .bin 格式
-diff --git a/.vscode/settings.json b/.vscode/settings.json
index 8621c2d..839f82b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,7 +3,7 @@
"idf.openOcdConfigs": [
"board/esp32c3-bridge.cfg"
],
- "idf.portWin": "COM13",
+ "idf.portWin": "COM15",
"idf.currentSetup": "F:\\software\\.espressif\\v6.0\\esp-idf",
"idf.customExtraVars": {
"IDF_TARGET": "esp32c3"
diff --git a/main/include/app_config.h b/main/include/app_config.h
index 7500375..97d6af0 100644
--- a/main/include/app_config.h
+++ b/main/include/app_config.h
@@ -10,7 +10,7 @@
// ============================================================
// 固件版本
// ============================================================
-inline constexpr const char *FW_VERSION = "0.2.0";
+inline constexpr const char *FW_VERSION = "0.2.7";
// ============================================================
// WiFi 工作模式选择
@@ -98,8 +98,8 @@ inline constexpr size_t BLE_OTA_PACKET_OVERHEAD = 11; // cmd(1)+seq(2)+siz
// ⚠️ 请填入实际密钥
// ============================================================
inline constexpr uint8_t MASTER_KEY[] = {
- 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
- 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F
+ 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
+ 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10
};
inline constexpr uint8_t SCRAMBLE_CONST[] = {
@@ -107,7 +107,11 @@ inline constexpr uint8_t SCRAMBLE_CONST[] = {
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
};
-// ⚠️ 请填入实际的 GD32 芯片唯一码(12字节)
+// GD32_CHIP_UID 仅保留为默认占位,运行时以握手包解出的芯片地址 UID 为准。
+inline constexpr uint32_t DEVICE_SERIAL_ADDR_LSB = 0x1FFFF7E8U;
+inline constexpr uint32_t DEVICE_SERIAL_ADDR_MID = 0x1FFFF7ECU;
+inline constexpr uint32_t DEVICE_SERIAL_ADDR_MSB = 0x1FFFF7F0U;
+
inline constexpr uint8_t GD32_CHIP_UID[] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp
index c1ceab1..74abed2 100644
--- a/main/monitor/debug_log.cpp
+++ b/main/monitor/debug_log.cpp
@@ -67,6 +67,11 @@ int debug_log_ring_count(void)
// 读取并清空日志缓冲区,返回 JSON 数组字符串
// 调用者负责 free 返回值
+/**
+ * @brief 读取并清空环形日志缓冲区,返回 JSON 数组字符串。
+ * @param out_len 输出 JSON 字符串长度(不含末尾 '\0')。
+ * @return 成功返回需由调用者 free 的字符串;无日志或失败返回 NULL。
+ */
char *debug_log_ring_drain_json(int *out_len)
{
if (!s_log_ring_mutex) {
diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp
index 1ba9634..1513295 100644
--- a/main/monitor/health_monitor.cpp
+++ b/main/monitor/health_monitor.cpp
@@ -71,6 +71,12 @@ float health_get_last_temperature(void)
return s_last_temperature;
}
+/**
+ * @brief 获取最近一次采样到的 WiFi RSSI 及其年龄。
+ * @param out_rssi 输出 RSSI 指针,可为 NULL。
+ * @param out_age_ms 输出采样距今毫秒数指针,可为 NULL。
+ * @return 有有效采样返回 true,否则返回 false。
+ */
bool health_get_last_wifi_rssi(int8_t *out_rssi, uint32_t *out_age_ms)
{
if (s_last_wifi_rssi_ms <= 0) {
diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp
index b609e78..90a5c3e 100644
--- a/main/relay/ota_relay.cpp
+++ b/main/relay/ota_relay.cpp
@@ -9,7 +9,8 @@
#include "freertos/queue.h"
#include "esp_err.h"
#include "esp_partition.h"
-#include "cJSON.h"
+#include "nvs_flash.h"
+#include "nvs.h"
#include "aes/esp_aes.h"
#include "ota_relay.h"
@@ -26,6 +27,11 @@ static const esp_partition_t *g_gd32_partition = NULL;
static session_ctx_t g_session_ctx;
static uint8_t g_nonce_server[16] = {0};
static uint8_t g_k_session[16] = {0};
+static uint8_t g_gd32_uid_runtime[12] = {0};
+
+static constexpr size_t GD32_UID_LEN = 12;
+static constexpr size_t GD32_CHALLENGE_CIPHER_LEN = 32;
+static constexpr size_t GD32_CHALLENGE_PAYLOAD_WITH_SESSION = 36;
/** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */
QueueHandle_t g_ota_dispatch_queue = NULL;
@@ -62,7 +68,161 @@ static ota_result_t relay_packets_from_flash(void);
* @return 操作结果码。
*/
static ota_result_t finish_ota(void);
+static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
+static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out);
+/**
+ * @brief 判断响应负载是否为带序号的结构化 ACK。
+ * @param payload 响应负载指针。
+ * @param len 负载长度。
+ * @param expected_seq 期望的序号。
+ * @return 匹配返回 true,否则返回 false。
+ */
+static bool is_structured_ack_with_seq(const uint8_t *payload, size_t len, uint32_t expected_seq)
+{
+ if (!payload || len < 6) return false;
+ if (payload[0] != 0xBA || payload[1] != 0xC0) return false;
+ uint32_t seq = (uint32_t)payload[2] | ((uint32_t)payload[3] << 8) |
+ ((uint32_t)payload[4] << 16) | ((uint32_t)payload[5] << 24);
+ return seq == expected_seq;
+}
+
+/**
+ * @brief 解析结构化错误响应中的序号和错误码。
+ * @param payload 响应负载指针。
+ * @param len 负载长度。
+ * @param seq_out 输出错误对应序号,可为 NULL。
+ * @param err_out 输出错误码,可为 NULL。
+ * @return 解析成功返回 true,否则返回 false。
+ */
+static bool parse_structured_error(const uint8_t *payload, size_t len, uint32_t *seq_out, uint8_t *err_out)
+{
+ if (!payload || len < 7) return false;
+ if (payload[0] != 0xBA || payload[1] != 0xC0) return false;
+ if (seq_out) {
+ *seq_out = (uint32_t)payload[2] | ((uint32_t)payload[3] << 8) |
+ ((uint32_t)payload[4] << 16) | ((uint32_t)payload[5] << 24);
+ }
+ if (err_out) *err_out = payload[6];
+ return true;
+}
+
+/**
+ * @brief 判断缓冲区是否全部由指定字节值组成。
+ * @param buf 输入缓冲区。
+ * @param len 缓冲区长度。
+ * @param value 目标字节值。
+ * @return 全部匹配返回 true,否则返回 false。
+ */
+static bool bytes_all_value(const uint8_t *buf, size_t len, uint8_t value)
+{
+ if (!buf || len == 0) return false;
+ for (size_t i = 0; i < len; ++i) {
+ if (buf[i] != value) return false;
+ }
+ return true;
+}
+
+/**
+ * @brief 按小端序读取 4 字节无符号整数。
+ * @param p 输入字节指针(至少 4 字节)。
+ * @return 读取出的 uint32_t 数值。
+ */
+static uint32_t read_u32_le(const uint8_t *p)
+{
+ return (uint32_t)p[0] |
+ ((uint32_t)p[1] << 8) |
+ ((uint32_t)p[2] << 16) |
+ ((uint32_t)p[3] << 24);
+}
+
+/**
+ * @brief 解析并校验 GD32 Challenge 响应,提取会话密钥和 UID。
+ * @param resp_payload Challenge 响应数据。
+ * @param resp_len 响应长度。
+ * @param out_k_session 输出会话密钥(16 字节)。
+ * @param out_uid 输出 UID(GD32_UID_LEN 字节)。
+ * @return 校验通过返回 true,否则返回 false。
+ */
+static bool parse_and_verify_challenge_payload(const uint8_t *resp_payload, size_t resp_len,
+ uint8_t *out_k_session, uint8_t *out_uid)
+{
+ if (!resp_payload || !out_k_session || !out_uid) return false;
+
+ const uint8_t *cipher_blob = resp_payload;
+ size_t cipher_len = resp_len;
+ if (resp_len == GD32_CHALLENGE_PAYLOAD_WITH_SESSION) {
+ uint32_t session_num = read_u32_le(resp_payload);
+ DEBUG_INFO("OTA", "CHALLENGE session_num=0x%08" PRIX32, session_num);
+ cipher_blob = resp_payload + 4;
+ cipher_len = GD32_CHALLENGE_CIPHER_LEN;
+ }
+
+ if (cipher_len != GD32_CHALLENGE_CIPHER_LEN) {
+ DEBUG_ERR("OTA", "CHALLENGE validate failed: invalid payload length=%u", (unsigned)resp_len);
+ return false;
+ }
+/*
+ if (cipher_len != GD32_CHALLENGE_CIPHER_LEN) {
+ DEBUG_ERR("OTA", "CHALLENGE payload长度异常: %u", (unsigned)resp_len);
+ return false;
+ }
+
+*/
+ uint8_t expected_nonce_block[16] = {0};
+ if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, expected_nonce_block) != 0) {
+ DEBUG_ERR("OTA", "CHALLENGE validate failed: nonce block encrypt failed");
+ return false;
+ }
+/*
+ if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, expected_nonce_block) != 0) {
+ DEBUG_ERR("OTA", "CHALLENGE校验失败: 计算nonce加密块失败");
+ return false;
+ }
+
+*/
+ if (memcmp(cipher_blob, expected_nonce_block, sizeof(expected_nonce_block)) != 0) {
+ DEBUG_ERR("OTA", "CHALLENGE校验失败: nonce加密块不匹配");
+ return false;
+ }
+
+ memcpy(out_k_session, expected_nonce_block, sizeof(expected_nonce_block));
+
+ uint8_t device_info[16] = {0};
+ if (aes128_ecb_decrypt_block_local(out_k_session, &cipher_blob[16], device_info) != 0) {
+ DEBUG_ERR("OTA", "CHALLENGE校验失败: device_info解密失败");
+ return false;
+ }
+
+ // device_info[0..11] maps to contiguous GD32 UID bytes from
+ // DEVICE_SERIAL_ADDR_LSB (0x1FFFF7E8) to 0x1FFFF7F3.
+ memcpy(out_uid, device_info, GD32_UID_LEN);
+ if (bytes_all_value(out_uid, GD32_UID_LEN, 0x00U) ||
+ bytes_all_value(out_uid, GD32_UID_LEN, 0xFFU)) {
+ DEBUG_ERR("OTA", "CHALLENGE校验失败: UID无效(全00/全FF)");
+ return false;
+ }
+
+ char uid_hex[GD32_UID_LEN * 2 + 1] = {0};
+ for (size_t i = 0; i < GD32_UID_LEN; ++i) {
+ snprintf(&uid_hex[i * 2], sizeof(uid_hex) - i * 2, "%02X", out_uid[i]);
+ }
+ DEBUG_INFO("OTA",
+ "GD32 UID(%uB @0x%08" PRIX32 "/0x%08" PRIX32 "/0x%08" PRIX32 "): %s",
+ (unsigned)GD32_UID_LEN,
+ DEVICE_SERIAL_ADDR_LSB, DEVICE_SERIAL_ADDR_MID, DEVICE_SERIAL_ADDR_MSB,
+ uid_hex);
+
+ return true;
+}
+
+/**
+ * @brief 使用 AES-128 ECB 加密单个 16 字节分组。
+ * @param key 16 字节密钥。
+ * @param in 输入明文分组(16 字节)。
+ * @param out 输出密文分组(16 字节)。
+ * @return 0 表示成功,非 0 表示失败。
+ */
static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
esp_aes_context aes;
@@ -75,6 +235,13 @@ static int aes128_ecb_encrypt_block_local(const uint8_t *key, const uint8_t *in,
return ret;
}
+/**
+ * @brief 使用 AES-128 ECB 解密单个 16 字节分组。
+ * @param key 16 字节密钥。
+ * @param in 输入密文分组(16 字节)。
+ * @param out 输出明文分组(16 字节)。
+ * @return 0 表示成功,非 0 表示失败。
+ */
static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
esp_aes_context aes;
@@ -87,6 +254,13 @@ static int aes128_ecb_decrypt_block_local(const uint8_t *key, const uint8_t *in,
return ret;
}
+/**
+ * @brief 按 3 个分组对 48 字节数据执行 AES-128 ECB 加密。
+ * @param key 16 字节密钥。
+ * @param in 输入明文(48 字节)。
+ * @param out 输出密文(48 字节)。
+ * @return 全部分组加密成功返回 true,否则返回 false。
+ */
static bool aes128_ecb_encrypt_48bytes_local(const uint8_t *key, const uint8_t *in, uint8_t *out)
{
for (int i = 0; i < 3; ++i) {
@@ -97,6 +271,12 @@ static bool aes128_ecb_encrypt_48bytes_local(const uint8_t *key, const uint8_t *
return true;
}
+/**
+ * @brief 计算带 key 前缀的 CRC32(key + 48B 数据),输出小端 4 字节。
+ * @param key 16 字节会话密钥。
+ * @param data48 输入数据(48 字节)。
+ * @param output 输出 CRC32(4 字节)。
+ */
static void calculate_crc32_with_key_local(const uint8_t *key, const uint8_t *data48, uint8_t *output)
{
uint32_t crc = 0xFFFFFFFFU;
@@ -119,6 +299,13 @@ static void calculate_crc32_with_key_local(const uint8_t *key, const uint8_t *da
output[3] = (uint8_t)((crc >> 24) & 0xFFU);
}
+/**
+ * @brief 记录响应类型和负载十六进制摘要,便于 OTA 阶段诊断。
+ * @param stage 当前阶段名称。
+ * @param resp_type 响应类型。
+ * @param payload 响应负载。
+ * @param len 响应负载长度。
+ */
static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t *payload, size_t len)
{
char hex[193] = {0};
@@ -132,6 +319,17 @@ static void log_resp_payload(const char *stage, uint8_t resp_type, const uint8_t
len > n ? "..." : "");
}
+/**
+ * @brief 当收到 WAIT 响应时进行只读轮询,直到响应变为非 WAIT 或达到轮询上限。
+ * @param stage 当前阶段名称(用于日志)。
+ * @param resp_type 输入/输出响应类型。
+ * @param resp_payload 输出响应负载缓冲区。
+ * @param resp_max 响应缓冲区大小。
+ * @param resp_len 输出响应长度指针,可为 NULL。
+ * @param timeout_ms 每次轮询的超时毫秒。
+ * @param max_wait_rounds 最多轮询次数。
+ * @return ESP_OK 或底层 SPI 轮询错误码。
+ */
static esp_err_t wait_until_not_wait(const char *stage, uint8_t *resp_type,
uint8_t *resp_payload, size_t resp_max,
size_t *resp_len, uint32_t timeout_ms, uint32_t max_wait_rounds)
@@ -158,39 +356,22 @@ static esp_err_t wait_until_not_wait(const char *stage, uint8_t *resp_type,
esp_err_t meta_load(ota_meta_t *meta)
{
if (!meta) return ESP_ERR_INVALID_ARG;
-
memset(meta, 0, sizeof(ota_meta_t));
- FILE *f = fopen(META_FILE, "r");
- if (!f) {
- // 文件不存在是正常状态(首次使用,未刷过 GD32)
+ nvs_handle_t handle;
+ esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READONLY, &handle);
+ if (err != ESP_OK) {
+ // NVS 中没有元数据是正常状态(首次使用)
return ESP_OK;
}
- char buf[256];
- size_t n = fread(buf, 1, sizeof(buf) - 1, f);
- buf[n] = '\0';
- fclose(f);
+ uint32_t val = 0;
+ if (nvs_get_u32(handle, "total_size", &val) == ESP_OK) meta->total_size = val;
+ if (nvs_get_u32(handle, "stored_size", &val) == ESP_OK) meta->stored_size = val;
+ if (nvs_get_u32(handle, "last_seq", &val) == ESP_OK) meta->last_seq = val;
+ if (nvs_get_u32(handle, "chunk_count", &val) == ESP_OK) meta->chunk_count = val;
- cJSON *json = cJSON_Parse(buf);
- if (!json) {
- DEBUG_ERR("OTA", "元数据 JSON 解析失败");
- return ESP_ERR_INVALID_RESPONSE;
- }
-
- cJSON *item = cJSON_GetObjectItem(json, "total_size");
- if (item) meta->total_size = (uint32_t)item->valueint;
-
- item = cJSON_GetObjectItem(json, "stored_size");
- if (item) meta->stored_size = (uint32_t)item->valueint;
-
- item = cJSON_GetObjectItem(json, "last_seq");
- if (item) meta->last_seq = (uint32_t)item->valueint;
-
- item = cJSON_GetObjectItem(json, "chunk_count");
- if (item) meta->chunk_count = (uint32_t)item->valueint;
-
- cJSON_Delete(json);
+ nvs_close(handle);
DEBUG_INFO("OTA", "元数据已加载: total=%u, stored=%u, seq=%u",
meta->total_size, meta->stored_size, meta->last_seq);
return ESP_OK;
@@ -205,24 +386,26 @@ esp_err_t meta_save(const ota_meta_t *meta)
{
if (!meta) return ESP_ERR_INVALID_ARG;
- FILE *f = fopen(META_FILE, "w");
- if (!f) {
- DEBUG_ERR("OTA", "无法打开元数据文件写入");
- return ESP_FAIL;
+ nvs_handle_t handle;
+ esp_err_t err = nvs_open(OTA_META_NVS_NAMESPACE, NVS_READWRITE, &handle);
+ if (err != ESP_OK) {
+ DEBUG_ERR("OTA", "无法打开 NVS 写入: %s", esp_err_to_name(err));
+ return err;
}
- fprintf(f,
- "{\n"
- " \"total_size\": %" PRIu32 ",\n"
- " \"stored_size\": %" PRIu32 ",\n"
- " \"last_seq\": %" PRIu32 ",\n"
- " \"chunk_count\": %" PRIu32 "\n"
- "}",
- meta->total_size, meta->stored_size, meta->last_seq, meta->chunk_count);
+ nvs_set_u32(handle, "total_size", meta->total_size);
+ nvs_set_u32(handle, "stored_size", meta->stored_size);
+ nvs_set_u32(handle, "last_seq", meta->last_seq);
+ nvs_set_u32(handle, "chunk_count", meta->chunk_count);
+ err = nvs_commit(handle);
+ nvs_close(handle);
- fclose(f);
- DEBUG_INFO("OTA", "元数据已保存到 Flash");
- return ESP_OK;
+ if (err != ESP_OK) {
+ DEBUG_ERR("OTA", "NVS commit 失败: %s", esp_err_to_name(err));
+ } else {
+ DEBUG_INFO("OTA", "元数据已保存到 NVS");
+ }
+ return err;
}
/**
@@ -300,6 +483,18 @@ esp_err_t flash_read_chunk(uint32_t offset, uint8_t *data, size_t len)
* @brief 阶段1 - 发起与 GD32 的握手
* @return 操作结果码。
*/
+static void request_gd32_app_enter_bootloader(void)
+{
+ DEBUG_INFO("OTA", "尝试通知 GD32 APP 进入 Bootloader OTA 窗口");
+ esp_err_t err = gd32_app_request_bootloader(500);
+ if (err == ESP_OK) {
+ DEBUG_INFO("OTA", "GD32 APP 已确认进入 Bootloader,等待复位完成");
+ vTaskDelay(pdMS_TO_TICKS(3500));
+ } else {
+ DEBUG_WARN("OTA", "GD32 APP 未确认进入 Bootloader,继续尝试直接握手: %s", esp_err_to_name(err));
+ }
+}
+
static ota_result_t send_handshake_req(void)
{
DEBUG_INFO("OTA", "=== 阶段1: 握手开始 ===");
@@ -358,7 +553,7 @@ static ota_result_t send_nonce_and_authenticate(void)
if (err == ESP_OK) {
err = wait_until_not_wait("CHALLENGE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 12);
}
- if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len != 36) {
+ if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) {
DEBUG_ERR("OTA", "CHALLENGE 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_TIMEOUT;
@@ -368,8 +563,8 @@ static ota_result_t send_nonce_and_authenticate(void)
DEBUG_INFO("OTA", "已收到 GD32 握手数据包");
DEBUG_INFO("OTA", "状态: RELAY_CHALLENGE_RESPOND");
- if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, g_k_session) != 0) {
- DEBUG_ERR("OTA", "计算 K_session 失败");
+ if (!parse_and_verify_challenge_payload(resp_payload, resp_len, g_k_session, g_gd32_uid_runtime)) {
+ DEBUG_ERR("OTA", "CHALLENGE 解包/校验失败");
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_AUTH;
}
@@ -440,8 +635,7 @@ static ota_result_t send_firmware_size(uint32_t size)
if (err == ESP_OK) {
err = wait_until_not_wait("SEND_SIZE", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1500, 12);
}
- if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
- resp_payload[0] != 0xBA || resp_payload[1] != 0xC0) {
+ if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !is_structured_ack_with_seq(resp_payload, resp_len, 0U)) {
DEBUG_ERR("OTA", "SEND_SIZE 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
g_relay_state = RELAY_ERROR;
return OTA_RESULT_ERR_TIMEOUT;
@@ -481,6 +675,7 @@ static ota_result_t relay_packets_from_flash(void)
g_relay_state = RELAY_RELAY_ACTIVE;
for (uint32_t seq = 0; seq < total_packets; seq++) {
+ uint32_t retry_count = 0;
if ((seq % OTA_PACKET_HEALTH_CHECK_INTERVAL) == 0 || seq == 0 || seq == total_packets - 1) {
health_status_t health = health_check_before_ota();
if (health == HEALTH_MON_SHUTDOWN || health == HEALTH_MON_CRITICAL) {
@@ -526,32 +721,48 @@ static ota_result_t relay_packets_from_flash(void)
g_relay_state = RELAY_PACKET_SENT;
- resp_len = 0;
- err = spi_ota_transfer(SPI_OTA_CMD_DATA, tx_buf, sizeof(tx_buf),
- &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
- log_resp_payload("DATA_PACKET", resp_type, resp_payload, resp_len);
- if (err == ESP_OK) {
- err = wait_until_not_wait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
- }
- if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 4) {
- DEBUG_WARN("OTA", "数据包 %u 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len);
- result = OTA_RESULT_ERR_TIMEOUT;
- break;
- }
+ while (1) {
+ resp_len = 0;
+ err = spi_ota_transfer(SPI_OTA_CMD_DATA, tx_buf, sizeof(tx_buf),
+ &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
+ log_resp_payload("DATA_PACKET", resp_type, resp_payload, resp_len);
+ if (err == ESP_OK) {
+ err = wait_until_not_wait("DATA_PACKET", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
+ }
+ if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK) {
+ DEBUG_WARN("OTA", "数据包 %u 发送失败, err=%d, resp_type=0x%02X, resp_len=%zu", seq, err, resp_type, resp_len);
+ result = OTA_RESULT_ERR_TIMEOUT;
+ break;
+ }
- uint32_t ack_value = (uint32_t)resp_payload[0] |
- ((uint32_t)resp_payload[1] << 8) |
- ((uint32_t)resp_payload[2] << 16) |
- ((uint32_t)resp_payload[3] << 24);
- uint32_t expected_ack = (seq == total_packets - 1) ? 0xFFFFFFFFU : (seq + 1U);
- if (ack_value != expected_ack) {
- DEBUG_ERR("OTA", "包 %u ACK 不匹配: got=0x%08X expected=0x%08X", seq, ack_value, expected_ack);
+ uint32_t expected_ack = (seq == total_packets - 1) ? 0xFFFFFFFFU : (seq + 1U);
+ if (is_structured_ack_with_seq(resp_payload, resp_len, expected_ack)) {
+ flash_offset += (uint32_t)chunk_size;
+ g_relay_state = RELAY_PACKET_ACK_OK;
+ break;
+ }
+
+ uint32_t err_seq = 0;
+ uint8_t err_code = 0;
+ if (parse_structured_error(resp_payload, resp_len, &err_seq, &err_code) && err_seq == seq) {
+ DEBUG_WARN("OTA", "包 %u 收到结构化错误 err=0x%02X,准备重发", seq, err_code);
+ g_relay_state = RELAY_PACKET_ACK_RETRY;
+ if (++retry_count >= 3U) {
+ DEBUG_ERR("OTA", "包 %u 重发次数超限", seq);
+ result = (err_code == 0x04U) ? OTA_RESULT_ERR_CRC : OTA_RESULT_ERR_GD32;
+ break;
+ }
+ continue;
+ }
+
+ DEBUG_ERR("OTA", "包 %u ACK/ERROR 不匹配,len=%zu", seq, resp_len);
result = OTA_RESULT_ERR_GD32;
break;
}
- flash_offset += (uint32_t)chunk_size;
- g_relay_state = RELAY_PACKET_ACK_OK;
+ if (result != OTA_RESULT_OK) {
+ break;
+ }
if (seq % (total_packets / 10 + 1) == 0) {
uint32_t progress = (seq * 100U) / total_packets;
@@ -571,14 +782,34 @@ static ota_result_t relay_packets_from_flash(void)
static ota_result_t finish_ota(void)
{
DEBUG_INFO("OTA", "=== 阶段4: 结束 OTA ===");
- DEBUG_INFO("OTA", "GD32 当前实现已在最后一个 DATA 包后自行 ACK 并跳转,无需额外 FINISH 命令");
+ DEBUG_INFO("OTA", "发送 FINISH_OTA (0x0A 0xAC),等待 GD32 确认即将跳转 APP");
+ uint8_t resp_type = 0;
+ uint8_t resp_payload[32] = {0};
+ size_t resp_len = 0;
+ uint8_t finish_payload[2] = {0x0A, 0xAC};
+
+ g_relay_state = RELAY_FINISH_SENT;
+ esp_err_t err = spi_ota_transfer(SPI_OTA_CMD_DATA, finish_payload, sizeof(finish_payload),
+ &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000);
+ log_resp_payload("FINISH_OTA", resp_type, resp_payload, resp_len);
+ if (err == ESP_OK) {
+ err = wait_until_not_wait("FINISH_OTA", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 10);
+ }
+ if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 ||
+ resp_payload[0] != 0x0A || resp_payload[1] != 0xAC) {
+ DEBUG_ERR("OTA", "FINISH_OTA 失败, err=%d, resp_type=0x%02X, resp_len=%zu", err, resp_type, resp_len);
+ g_relay_state = RELAY_ERROR;
+ return OTA_RESULT_ERR_GD32;
+ }
+
+ g_relay_state = RELAY_DONE;
session_key_clear(&g_session_ctx);
memset(&g_session_ctx, 0, sizeof(session_ctx_t));
memset(g_k_session, 0, sizeof(g_k_session));
memset(g_nonce_server, 0, sizeof(g_nonce_server));
+ memset(g_gd32_uid_runtime, 0, sizeof(g_gd32_uid_runtime));
- g_relay_state = RELAY_DONE;
DEBUG_INFO("OTA", "状态: RELAY_DONE");
DEBUG_INFO("OTA", "=== OTA 完成 ===");
@@ -594,6 +825,8 @@ ota_result_t ota_relay_start_handshake(void)
{
ota_result_t res;
+ request_gd32_app_enter_bootloader();
+
res = send_handshake_req();
if (res != OTA_RESULT_OK) return res;
@@ -627,6 +860,8 @@ ota_result_t ota_relay_forward_firmware(void)
DEBUG_INFO("OTA", "开始固件转发: %u 字节, %u 块",
meta.total_size, meta.chunk_count);
+ request_gd32_app_enter_bootloader();
+
ota_result_t res = send_handshake_req();
if (res != OTA_RESULT_OK) return res;
@@ -662,6 +897,7 @@ void ota_relay_reset(void)
/** @brief 当前 OTA 中继状态机状态。 */
g_relay_state = RELAY_IDLE;
session_key_clear(&g_session_ctx);
+ memset(g_gd32_uid_runtime, 0, sizeof(g_gd32_uid_runtime));
}
/**
@@ -703,4 +939,4 @@ void ota_relay_task(void *pvParameters)
ota_relay_reset();
}
}
-}
\ No newline at end of file
+}
diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h
index a87b2f3..fee7ed6 100644
--- a/main/relay/ota_relay.h
+++ b/main/relay/ota_relay.h
@@ -9,8 +9,9 @@
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
-// T4.3: OTA 元数据结构,用于 Flash 存储
-#define META_FILE "/spiffs/ota_meta.json"
+// T4.3: OTA 元数据结构,用于 NVS 存储
+#define OTA_META_NVS_NAMESPACE "ota_meta"
+#define OTA_META_NVS_KEY "meta"
typedef struct {
uint32_t total_size; // 固件总大小(字节)
diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp
index e6b26f3..5be4bb5 100644
--- a/main/server/ble_ota.cpp
+++ b/main/server/ble_ota.cpp
@@ -79,6 +79,10 @@ static bool s_esp32_reboot_pending = false;
static uint8_t s_esp32_last_cmd = 0;
static uint16_t s_esp32_last_seq = 0xFFFFU;
+/**
+ * @brief 异步完成 ESP32 OTA 收尾并触发重启。
+ * @param pvParameters 任务参数,当前未使用。
+ */
static void ble_esp32_finish_and_reboot_task(void *pvParameters)
{
(void)pvParameters;
@@ -179,6 +183,11 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len)
return (int)copy_len;
}
+/**
+ * @brief 初始化 ESP32 本机 OTA 写入会话并选择目标分区。
+ * @param total_size 固件总大小(字节)。
+ * @return ESP_OK 成功,其他为失败错误码。
+ */
static esp_err_t ble_esp32_ota_begin(size_t total_size)
{
const esp_partition_t *boot_partition = esp_ota_get_running_partition();
@@ -238,6 +247,12 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
return ESP_OK;
}
+/**
+ * @brief 向 ESP32 OTA 分区写入一段固件数据。
+ * @param data 待写入数据指针。
+ * @param len 写入长度(字节)。
+ * @return ESP_OK 成功,其他为失败错误码。
+ */
static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len)
{
if (!s_esp32_ota_active || s_esp32_update_partition == NULL) {
@@ -250,6 +265,12 @@ static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len)
return err;
}
+/**
+ * @brief 发送窗口 ACK 通知,回报已处理的分块序号与累计字节数。
+ * @param cmd 对应的数据命令字节。
+ * @param seq 当前确认的分块序号。
+ * @param completed_bytes 已完成写入的累计字节数。
+ */
static void ble_ota_send_window_ack(uint8_t cmd, uint16_t seq, uint32_t completed_bytes)
{
uint8_t ack[8] = {0};
@@ -272,6 +293,11 @@ static void ble_ota_send_window_ack(uint8_t cmd, uint16_t seq, uint32_t complete
}
}
+/**
+ * @brief 发送收尾阶段事件通知(开始/完成/失败/重启等)。
+ * @param stage 阶段标识码。
+ * @param value 阶段附带值(错误码或耗时等)。
+ */
static void ble_ota_send_finish_event(uint8_t stage, uint32_t value)
{
uint8_t pkt[6] = {0};
@@ -297,6 +323,14 @@ static void ble_ota_send_finish_event(uint8_t stage, uint32_t value)
* CTRL (0x0001) - Write
* 写入 0xF7 触发 OTA 中继
*/
+/**
+ * @brief GATT CTRL 特征访问回调,处理控制命令写入。
+ * @param conn_handle BLE 连接句柄。
+ * @param attr_handle GATT 属性句柄。
+ * @param ctxt GATT 访问上下文。
+ * @param arg 回调用户参数。
+ * @return NimBLE ATT 错误码,0 表示成功。
+ */
static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
@@ -368,6 +402,14 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
* [7..n-4] payload 固件数据
* [n-4..n] crc 校验 (预留)
*/
+/**
+ * @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。
+ * @param conn_handle BLE 连接句柄。
+ * @param attr_handle GATT 属性句柄。
+ * @param ctxt GATT 访问上下文。
+ * @param arg 回调用户参数。
+ * @return NimBLE ATT 错误码,0 表示成功。
+ */
static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
@@ -479,9 +521,10 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
// --- GD32 OTA:首个分块时创建存储元数据 ---
if (seq == 0 && total_size > 0) {
- if (total_size > OTA_MAX_FIRMWARE_SIZE) {
- DEBUG_ERR(TAG, "固件太大: %" PRIu32 " > %zu",
- total_size, OTA_MAX_FIRMWARE_SIZE);
+ uint32_t rounded_size = (uint32_t)(((total_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE);
+ if (total_size == 0U || total_size > OTA_MAX_FIRMWARE_SIZE || rounded_size > OTA_MAX_FIRMWARE_SIZE) {
+ DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " rounded48=%" PRIu32 " max=%zu",
+ total_size, rounded_size, OTA_MAX_FIRMWARE_SIZE);
return BLE_ATT_ERR_UNLIKELY;
}
@@ -558,6 +601,14 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
* VERSION (0x0004) - Read
* 返回固件版本字符串
*/
+/**
+ * @brief GATT VERSION 特征访问回调,返回固件版本字符串。
+ * @param conn_handle BLE 连接句柄。
+ * @param attr_handle GATT 属性句柄。
+ * @param ctxt GATT 访问上下文。
+ * @param arg 回调用户参数。
+ * @return NimBLE ATT 错误码,0 表示成功。
+ */
static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
@@ -578,6 +629,14 @@ static int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
* DATA_OUT (0x0003) - Notify only
* 访问回调不做读写处理,通知由 ble_ota_notify_response() 发送
*/
+/**
+ * @brief GATT DATA_OUT 特征访问回调(Notify-only,占位实现)。
+ * @param conn_handle BLE 连接句柄。
+ * @param attr_handle GATT 属性句柄。
+ * @param ctxt GATT 访问上下文。
+ * @param arg 回调用户参数。
+ * @return 0。
+ */
static int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp
index 6343a43..2a2e115 100644
--- a/main/server/wifi_ota.cpp
+++ b/main/server/wifi_ota.cpp
@@ -1,4 +1,4 @@
-/*
+/*
* wifi_ota.c - WiFi OTA 服务器实现
* T4.1 - T4.5
*/
@@ -88,6 +88,13 @@ static esp_err_t http_options_handler(httpd_req_t *req)
// ----------------------------------------
// WiFi/IP 事件回调(事件驱动连接)
// ----------------------------------------
+/**
+ * @brief WiFi/IP 事件回调,处理 STA 启停、重连和获取 IP 等状态变化。
+ * @param arg 回调用户参数。
+ * @param event_base 事件基类(WIFI_EVENT 或 IP_EVENT)。
+ * @param event_id 具体事件 ID。
+ * @param event_data 事件附带数据。
+ */
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
@@ -502,7 +509,7 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size)
int depth = 1; // 开头的 '{' 已被 peek 消费,从 1 开始
while (total < buf_size - 1) {
int ret = httpd_req_recv(req, buf + total, 1); // 逐字节读,精确找到 JSON 结尾
- if (ret <= 0) return 0;
+ if (ret <= 0) return total;
total += ret;
buf[total] = '\0';
if (buf[total - 1] == '{') depth++;
@@ -511,7 +518,7 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size)
if (depth == 0) return total; // JSON 头读完
}
}
- return 0; // JSON 头太长,超出缓冲区
+ return total; // fallback: return consumed bytes
}
// ----------------------------------------
@@ -538,6 +545,9 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
bool is_esp32_ota = false;
uint32_t total_size = 0;
int json_header_len = 0;
+ uint8_t pre_read_buf[256] = {0};
+ int pre_read_len = 1;
+ pre_read_buf[0] = (uint8_t)peek;
if (peek == '{') {
// JSON 模式:逐字节读完 JSON 头
@@ -547,6 +557,9 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
if (json_header_len > 0) {
json_header_len++; // 加上已读的 peek 字节
json_buf[json_header_len] = '\0';
+ pre_read_len = json_header_len;
+ if (pre_read_len > (int)sizeof(pre_read_buf)) pre_read_len = (int)sizeof(pre_read_buf);
+ memcpy(pre_read_buf, json_buf, pre_read_len);
cJSON *root = cJSON_Parse(json_buf);
if (root) {
@@ -623,9 +636,12 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
}
// --- GD32 OTA(原逻辑)---
- if (req->content_len > OTA_MAX_FIRMWARE_SIZE) {
- DEBUG_ERR("WIFI", "固件太大: %d > %d", req->content_len, OTA_MAX_FIRMWARE_SIZE);
- httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "固件太大");
+ const uint32_t gd32_fw_size = (uint32_t)req->content_len;
+ const uint32_t gd32_rounded_size = (uint32_t)(((gd32_fw_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE);
+ if (gd32_fw_size == 0U || gd32_fw_size > OTA_MAX_FIRMWARE_SIZE || gd32_rounded_size > OTA_MAX_FIRMWARE_SIZE) {
+ DEBUG_ERR("WIFI", "固件大小非法: raw=%u rounded48=%u max=%u",
+ (unsigned)gd32_fw_size, (unsigned)gd32_rounded_size, (unsigned)OTA_MAX_FIRMWARE_SIZE);
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "固件太大或48B对齐后越界");
return ESP_FAIL;
}
@@ -659,77 +675,73 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
int received = 0;
- // peek 字节是 GD32 数据的第一字节,先放回缓冲区
- uint8_t gd32_header[6];
- gd32_header[0] = (uint8_t)peek;
- int header_read = 1;
- // 读剩余 5 字节凑齐 6 字节 GD32 头
- if (req->content_len >= 6) {
- int need = 6 - header_read;
- int got = httpd_req_recv(req, (char *)gd32_header + header_read, need);
- if (got > 0) header_read += got;
- }
+ // GD32 路径与网页上传协议对齐:网页上传的是 raw binary(不带 6B 头)。
+ uint32_t expected_fw_size_raw = gd32_fw_size;
+ ota_meta_t meta_raw = {
+ .total_size = expected_fw_size_raw,
+ .stored_size = 0,
+ .last_seq = 0,
+ .chunk_count = (expected_fw_size_raw + OTA_BLOCK_SIZE - 1U) / OTA_BLOCK_SIZE,
+ .checksum = {0}
+ };
+ meta_save(&meta_raw);
+ DEBUG_INFO("WIFI", "GD32 raw upload start: %u bytes", expected_fw_size_raw);
- uint32_t expected_fw_size = 0;
- if (header_read >= 6) {
- uint16_t seq = gd32_header[0] | (gd32_header[1] << 8);
- uint32_t fw_size = gd32_header[2] | (gd32_header[3] << 8) | (gd32_header[4] << 16) | (gd32_header[5] << 24);
- expected_fw_size = fw_size;
- if (fw_size > OTA_MAX_FIRMWARE_SIZE) {
- DEBUG_ERR("WIFI", "GD32 固件头声明大小超限: %u > %u", fw_size, OTA_MAX_FIRMWARE_SIZE);
+ int first_chunk_len = pre_read_len;
+ if (first_chunk_len > req->content_len) first_chunk_len = req->content_len;
+ if (first_chunk_len > 0) {
+ esp_err_t err = flash_write_chunk(0, pre_read_buf, first_chunk_len);
+ if (err != ESP_OK) {
+ DEBUG_ERR("WIFI", "GD32 first chunk write failed: len=%d err=%s",
+ first_chunk_len, esp_err_to_name(err));
free(buf);
- httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "GD32 固件大小超限");
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed");
return ESP_FAIL;
}
- if (seq == 0) {
- ota_meta_t meta = { .total_size = fw_size, .stored_size = 0, .last_seq = 0,
- .chunk_count = (fw_size + OTA_BLOCK_SIZE - 1) / OTA_BLOCK_SIZE, .checksum = {0} };
- meta_save(&meta);
- DEBUG_INFO("WIFI", "开始 GD32 固件存储: %u 字节", fw_size);
- }
+ received = first_chunk_len;
+ meta_update((uint32_t)received, (uint32_t)(received / OTA_BLOCK_SIZE));
}
- // 首包已经读走 6 字节头,因此后续 body 全部按 header_read 为偏移基准顺序落盘
- while (received + header_read < req->content_len) {
- size_t to_read = req->content_len - received - header_read;
+ while (received < req->content_len) {
+ size_t to_read = req->content_len - received;
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
int ret = httpd_req_recv(req, (char *)buf, to_read);
- if (ret <= 0) break;
+ if (ret <= 0) {
+ DEBUG_ERR("WIFI", "GD32 upload interrupted: received=%d expected=%d",
+ received, req->content_len);
+ free(buf);
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "upload interrupted");
+ return ESP_FAIL;
+ }
uint32_t flash_offset = (uint32_t)received;
esp_err_t err = flash_write_chunk(flash_offset, buf, ret);
if (err != ESP_OK) {
- DEBUG_ERR("WIFI", "Flash 写入失败: offset=%u len=%d err=%s",
+ DEBUG_ERR("WIFI", "GD32 flash write failed: offset=%u len=%d err=%s",
flash_offset, ret, esp_err_to_name(err));
free(buf);
- httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "写入 gd32_ota 失败");
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "write gd32_ota failed");
return ESP_FAIL;
}
received += ret;
- if (expected_fw_size > 0) {
- meta_update((uint32_t)received, (uint32_t)(received / OTA_BLOCK_SIZE));
- }
-
- uint32_t progress = expected_fw_size > 0
- ? ((uint32_t)received * 100U) / expected_fw_size
- : ((uint32_t)(received + header_read) * 100U) / (uint32_t)req->content_len;
- if (progress % 10 == 0) {
- DEBUG_INFO("WIFI", "GD32 上传进度: %d / %u (%u%%)",
- received, expected_fw_size, progress);
- }
+ meta_update((uint32_t)received, (uint32_t)(received / OTA_BLOCK_SIZE));
}
free(buf);
- DEBUG_INFO("WIFI", "GD32 固件存储完成: payload=%d 字节, 头=%d 字节", received, header_read);
+ DEBUG_INFO("WIFI", "GD32 raw upload done: %d / %u bytes", received, expected_fw_size_raw);
- ota_meta_t meta;
- meta_load(&meta);
- if (meta.total_size > 0 && meta.stored_size >= meta.total_size) {
- /** @brief GD32 固件存储完成标志。 */
+ ota_meta_t final_meta;
+ meta_load(&final_meta);
+ if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) {
s_gd32_storage_done = true;
- DEBUG_INFO("WIFI", "GD32 固件已存储完成,等待触发转发...");
+ DEBUG_INFO("WIFI", "GD32 firmware fully stored, auto-triggering relay");
+ // 自动触发中继
+ if (g_ota_dispatch_queue) {
+ uint32_t cmd = 1; // 1 = start relay
+ xQueueSend(g_ota_dispatch_queue, &cmd, 0);
+ }
}
const char resp[] = "{\"status\": \"ok\", \"progress\": 100, \"target\": \"gd32\"}";
@@ -745,11 +757,32 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
*/
static esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
{
- (void)req;
http_set_cors_headers(req);
DEBUG_INFO("WIFI", "=== HTTP: POST /ota/relay ===");
DEBUG_INFO("WIFI", "收到触发 OTA 中继请求");
+
+ ota_meta_t meta;
+ memset(&meta, 0, sizeof(meta));
+ if (meta_load(&meta) != ESP_OK || meta.total_size == 0 || meta.stored_size < meta.total_size) {
+ httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "firmware not fully stored");
+ return ESP_FAIL;
+ }
+
+ if (s_relay_triggered) {
+ httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay already triggered");
+ return ESP_FAIL;
+ }
+
+ if (!g_ota_dispatch_queue) {
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay queue not ready");
+ return ESP_FAIL;
+ }
+
wifi_ota_signal_relay();
+ if (!s_relay_triggered) {
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay trigger failed");
+ return ESP_FAIL;
+ }
const char resp[] = "{\"status\": \"ok\", \"message\": \"OTA relay triggered\"}";
httpd_resp_set_type(req, "application/json");
@@ -923,6 +956,98 @@ static esp_err_t can_send_handler(httpd_req_t *req)
return ESP_OK;
}
+// HTTP POST /spi/send 处理器
+// 网页传 type + payload,ESP32 自动构建 SOF+TYPE+LEN(L/H)+PAYLOAD+CRC 帧
+// 并通过 spi_ota_transfer 完成完整事务(发送+轮询响应帧+解析)。
+//
+// 请求体: {"type": "01", "data": "01020304", "timeout_ms": 1000}
+// type: 帧类型 (hex 1 字节,如 01=REQ, 02=DATA, B0=BOOT)
+// data: 负载数据 (hex 字符串,可选)
+// timeout_ms: 超时毫秒 (可选, 默认 1000)
+/**
+ * @brief HTTP POST /spi/send 处理器,转发上位机请求到 SPI 并返回结果。
+ * @param req HTTP 请求对象。
+ * @return ESP_OK 成功,ESP_FAIL 表示请求处理失败。
+ */
+static esp_err_t spi_send_handler(httpd_req_t *req)
+{
+ http_set_cors_headers(req);
+ char buf[512] = {0};
+ int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
+ if (ret <= 0) {
+ httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败");
+ return ESP_FAIL;
+ }
+ buf[ret] = '\0';
+
+ cJSON *root = cJSON_Parse(buf);
+ if (!root) {
+ httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败");
+ return ESP_FAIL;
+ }
+
+ cJSON *type_item = cJSON_GetObjectItem(root, "type");
+ cJSON *data_item = cJSON_GetObjectItem(root, "data");
+ cJSON *timeout_item = cJSON_GetObjectItem(root, "timeout_ms");
+
+ if (!type_item || !cJSON_IsString(type_item)) {
+ cJSON_Delete(root);
+ httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 type 字段");
+ return ESP_FAIL;
+ }
+ const char *type_hex = cJSON_GetStringValue(type_item);
+ uint8_t frame_type = (uint8_t)strtol(type_hex, NULL, 16);
+
+ uint32_t timeout_ms = timeout_item ? (uint32_t)cJSON_GetNumberValue(timeout_item) : 1000;
+
+ // 解析 payload (hex 字符串 → 字节数组)
+ uint8_t payload_data[SPI_OTA_FRAME_MAX] = {0};
+ size_t payload_len = 0;
+ if (data_item && cJSON_IsString(data_item)) {
+ const char *hex_str = cJSON_GetStringValue(data_item);
+ size_t hex_len = strlen(hex_str);
+ for (size_t i = 0; i < hex_len / 2 && i < SPI_OTA_FRAME_MAX; i++) {
+ char byte_str[3] = { hex_str[i * 2], hex_str[i * 2 + 1], '\0' };
+ payload_data[i] = (uint8_t)strtol(byte_str, NULL, 16);
+ payload_len++;
+ }
+ }
+
+ DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节",
+ frame_type, payload_len);
+
+ cJSON_Delete(root);
+
+ char resp[1024];
+ uint8_t resp_type = 0;
+ uint8_t resp_payload[SPI_OTA_FRAME_MAX] = {0};
+ size_t resp_len = 0;
+ esp_err_t spi_err = spi_ota_transfer(frame_type, payload_data, payload_len,
+ &resp_type, resp_payload, sizeof(resp_payload), &resp_len,
+ timeout_ms);
+
+ if (spi_err == ESP_OK) {
+ char resp_hex[SPI_OTA_FRAME_MAX * 2 + 1] = {0};
+ for (size_t i = 0; i < resp_len; i++) {
+ sprintf(&resp_hex[i * 2], "%02X", resp_payload[i]);
+ }
+ snprintf(resp, sizeof(resp),
+ "{\"status\": \"ok\", \"type\": \"%02X\", \"payload_len\": %zu, \"resp_type\": \"%02X\", \"resp_len\": %zu, \"recv\": \"%s\"}",
+ frame_type, payload_len, resp_type, resp_len, resp_hex);
+ DEBUG_INFO("SPI", "WEB SPI 响应: type=0x%02X len=%zu payload=%s",
+ resp_type, resp_len, resp_hex);
+ } else {
+ snprintf(resp, sizeof(resp),
+ "{\"status\": \"error\", \"code\": %d, \"message\": \"%s\"}",
+ spi_err, esp_err_to_name(spi_err));
+ DEBUG_ERR("SPI", "WEB SPI 失败: %s", esp_err_to_name(spi_err));
+ }
+
+ httpd_resp_set_type(req, "application/json");
+ httpd_resp_send(req, resp, strlen(resp));
+ return ESP_OK;
+}
+
/**
* @brief HTTP GET /can/status 处理器
* @param req
@@ -1051,6 +1176,16 @@ static esp_err_t http_ota_server_start(void)
return err;
}
+ // 从 NVS 恢复固件存储状态(重启后保留)
+ {
+ ota_meta_t boot_meta;
+ if (meta_load(&boot_meta) == ESP_OK &&
+ boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) {
+ s_gd32_storage_done = true;
+ DEBUG_INFO("WIFI", "从 NVS 恢复: 固件已就绪 (%u bytes)", boot_meta.total_size);
+ }
+ }
+
httpd_uri_t upload_uri = {
.uri = "/ota/upload",
.method = HTTP_POST,
@@ -1233,7 +1368,23 @@ static esp_err_t http_ota_server_start(void)
httpd_register_uri_handler(s_http_server, &can_status_options_uri);
DEBUG_INFO("WIFI", "HTTP 服务器已启动");
- DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /ota/status, /ota/esp32_status, /health, /wifi/status, /system/status, /can/send, /can/status");
+ // SPI 直接发送
+ httpd_uri_t spi_send_uri = {
+ .uri = "/spi/send",
+ .method = HTTP_POST,
+ .handler = spi_send_handler,
+ .user_ctx = NULL
+ };
+ httpd_register_uri_handler(s_http_server, &spi_send_uri);
+ httpd_uri_t spi_send_options_uri = {
+ .uri = "/spi/send",
+ .method = HTTP_OPTIONS,
+ .handler = http_options_handler,
+ .user_ctx = NULL
+ };
+ httpd_register_uri_handler(s_http_server, &spi_send_options_uri);
+
+ DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /ota/status, /ota/esp32_status, /health, /wifi/status, /system/status, /can/send, /can/status, /spi/send");
return ESP_OK;
}
@@ -1263,8 +1414,13 @@ void wifi_ota_signal_relay(void)
if (g_ota_dispatch_queue) {
uint32_t msg = 1;
- xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(100));
- DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
+ BaseType_t ret = xQueueSend(g_ota_dispatch_queue, &msg, pdMS_TO_TICKS(100));
+ if (ret == pdTRUE) {
+ DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务");
+ } else {
+ DEBUG_ERR("WIFI", "OTA 调度信号发送失败");
+ s_relay_triggered = false;
+ }
} else {
DEBUG_ERR("WIFI", "OTA 中继队列尚未就绪");
s_relay_triggered = false;
diff --git a/main/transport/protocol.cpp b/main/transport/protocol.cpp
index 91270b4..e8601a1 100644
--- a/main/transport/protocol.cpp
+++ b/main/transport/protocol.cpp
@@ -88,7 +88,15 @@ int frame_build(uint8_t cmd, const uint8_t *payload, size_t len, uint8_t *out_fr
return idx;
}
-// 解析 SPI 帧
+/**
+ * @brief 解析并校验一帧 SPI 协议数据。
+ * @param frame 输入帧缓冲区。
+ * @param frame_len 输入帧长度。
+ * @param out_cmd 输出命令字节,可为 NULL。
+ * @param out_payload 输出负载缓冲区,可为 NULL。
+ * @param out_len 输出负载长度指针,可为 NULL。
+ * @return 0 成功,-1 失败。
+ */
int frame_parse(const uint8_t *frame, size_t frame_len,
uint8_t *out_cmd, uint8_t *out_payload, size_t *out_len)
{
@@ -141,7 +149,15 @@ int frame_parse(const uint8_t *frame, size_t frame_len,
return 0;
}
-// AES-128 CBC 加密(使用 ESP32-C3 硬件 AES)
+/**
+ * @brief 使用 AES-128 CBC 对明文进行加密(PKCS#7 填充)。
+ * @param plaintext 明文数据指针。
+ * @param len 明文长度。
+ * @param key 16 字节密钥。
+ * @param iv 16 字节初始向量。
+ * @param ciphertext_out 输出密文缓冲区。
+ * @return >=0 为密文长度,-1 表示失败。
+ */
int protocol_aes_encrypt(const uint8_t *plaintext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *ciphertext_out)
@@ -189,7 +205,15 @@ int protocol_aes_encrypt(const uint8_t *plaintext, size_t len,
}
}
-// AES-128 CBC 解密(使用 ESP32-C3 硬件 AES)
+/**
+ * @brief 使用 AES-128 CBC 解密密文并移除 PKCS#7 填充。
+ * @param ciphertext 密文数据指针。
+ * @param len 密文长度(必须是 16 的倍数)。
+ * @param key 16 字节密钥。
+ * @param iv 16 字节初始向量。
+ * @param plaintext_out 输出明文缓冲区。
+ * @return >=0 为明文长度,-1 表示失败。
+ */
int protocol_aes_decrypt(const uint8_t *ciphertext, size_t len,
const uint8_t *key, const uint8_t *iv,
uint8_t *plaintext_out)
@@ -242,7 +266,13 @@ int protocol_aes_decrypt(const uint8_t *ciphertext, size_t len,
return orig_len;
}
-// 搅伴算法 - 生成随机数
+/**
+ * @brief 根据时间戳、地址和随机种子生成 16 字节 nonce。
+ * @param timestamp 时间戳输入。
+ * @param mem_addr 地址输入。
+ * @param rand_seed 随机种子输入。
+ * @param nonce_out 输出 nonce 缓冲区(16 字节)。
+ */
void scramble_generate(uint32_t timestamp, uint32_t mem_addr,
uint32_t rand_seed, uint8_t *nonce_out)
{
@@ -357,4 +387,4 @@ int session_decrypt(session_ctx_t *sess, const uint8_t *in, size_t len, uint8_t
void generate_random_nonce(uint8_t *nonce, size_t len)
{
esp_fill_random(nonce, len);
-}
\ No newline at end of file
+}
diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp
index 15b1bbb..114a9a8 100644
--- a/main/transport/spi_master.cpp
+++ b/main/transport/spi_master.cpp
@@ -15,6 +15,106 @@
#include "spi_master.h"
#include "monitor/debug_log.h"
+namespace {
+constexpr size_t kSpiTransferMaxBytes = 256U;
+
+static esp_err_t spi_transfer_clocked(const uint8_t *tx_buf, size_t tx_len,
+ uint8_t *rx_buf, size_t rx_len)
+{
+ if (g_spi_device == NULL) {
+ return ESP_ERR_INVALID_STATE;
+ }
+
+ if ((tx_len > 0U && tx_buf == NULL) || (rx_len > 0U && rx_buf == NULL)) {
+ return ESP_ERR_INVALID_ARG;
+ }
+
+ const size_t total_len = (tx_len > rx_len) ? tx_len : rx_len;
+ if (total_len == 0U) {
+ return ESP_OK;
+ }
+ if (total_len > kSpiTransferMaxBytes) {
+ return ESP_ERR_INVALID_SIZE;
+ }
+
+ uint8_t tx_staging[kSpiTransferMaxBytes];
+ uint8_t rx_staging[kSpiTransferMaxBytes];
+ const uint8_t *tx_ptr = tx_buf;
+ uint8_t *rx_ptr = rx_buf;
+
+ if (tx_len < total_len) {
+ memset(tx_staging, 0xFF, total_len);
+ if (tx_buf != NULL && tx_len > 0U) {
+ memcpy(tx_staging, tx_buf, tx_len);
+ }
+ tx_ptr = tx_staging;
+ }
+
+ if (rx_len > 0U && rx_len < total_len) {
+ rx_ptr = rx_staging;
+ }
+
+ spi_transaction_t t = {};
+ t.length = total_len * 8U;
+ t.rxlength = (rx_len > 0U) ? (total_len * 8U) : 0U;
+ t.tx_buffer = tx_ptr;
+ t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
+
+ esp_err_t ret = spi_device_transmit(g_spi_device, &t);
+ if (ret == ESP_OK && rx_len > 0U && rx_ptr != rx_buf) {
+ memcpy(rx_buf, rx_ptr, rx_len);
+ }
+ return ret;
+}
+
+static esp_err_t spi_transfer_request_response(uint8_t cmd,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *rx_buf, size_t rx_len)
+{
+ if ((payload_len > 0U && payload == NULL) || (rx_len > 0U && rx_buf == NULL)) {
+ return ESP_ERR_INVALID_ARG;
+ }
+
+ const size_t cmd_len = (cmd != 0U) ? 1U : 0U;
+ const size_t total_len = cmd_len + payload_len + rx_len;
+ if (total_len == 0U) {
+ return ESP_OK;
+ }
+ if (total_len > kSpiTransferMaxBytes) {
+ return ESP_ERR_INVALID_SIZE;
+ }
+
+ uint8_t tx_staging[kSpiTransferMaxBytes];
+ uint8_t rx_staging[kSpiTransferMaxBytes];
+ size_t tx_pos = 0U;
+
+ if (cmd_len > 0U) {
+ tx_staging[tx_pos++] = cmd;
+ }
+ if (payload_len > 0U) {
+ memcpy(&tx_staging[tx_pos], payload, payload_len);
+ tx_pos += payload_len;
+ }
+ if (tx_pos < total_len) {
+ memset(&tx_staging[tx_pos], 0xFF, total_len - tx_pos);
+ }
+
+ esp_err_t ret = spi_transfer_clocked(tx_staging, total_len,
+ (rx_len > 0U) ? rx_staging : NULL,
+ (rx_len > 0U) ? total_len : 0U);
+ if (ret == ESP_OK && rx_len > 0U) {
+ memcpy(rx_buf, &rx_staging[cmd_len + payload_len], rx_len);
+ }
+ return ret;
+}
+} // namespace
+
+/**
+ * @brief 计算 SPI OTA 帧头和负载的异或 CRC8。
+ * @param data 输入数据指针。
+ * @param len 输入长度。
+ * @return 计算得到的 CRC8 值。
+ */
static uint8_t spi_ota_crc8(const uint8_t *data, size_t len)
{
uint8_t crc = 0x00;
@@ -24,6 +124,15 @@ static uint8_t spi_ota_crc8(const uint8_t *data, size_t len)
return crc;
}
+/**
+ * @brief 构建 SPI OTA 协议帧(SOF + TYPE + LEN + PAYLOAD + CRC)。
+ * @param type 帧类型。
+ * @param payload 负载数据指针,可为 NULL。
+ * @param payload_len 负载长度。
+ * @param out 输出帧缓冲区。
+ * @param out_len 输出帧长度。
+ * @return 构建成功返回 true,否则返回 false。
+ */
static bool spi_ota_build_frame(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *out, size_t *out_len)
{
@@ -42,6 +151,15 @@ static bool spi_ota_build_frame(uint8_t type, const uint8_t *payload, size_t pay
return true;
}
+/**
+ * @brief 解析并校验一帧 SPI OTA 数据。
+ * @param frame 输入帧指针。
+ * @param frame_len 输入帧缓冲区长度。
+ * @param type 输出帧类型,可为 NULL。
+ * @param payload 输出负载指针,可为 NULL。
+ * @param payload_len 输出负载长度,可为 NULL。
+ * @return 解析并校验成功返回 true,否则返回 false。
+ */
static bool spi_ota_parse_frame(const uint8_t *frame, size_t frame_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len)
{
@@ -57,6 +175,16 @@ static bool spi_ota_parse_frame(const uint8_t *frame, size_t frame_len,
return true;
}
+/**
+ * @brief 在接收缓冲区中扫描并定位第一帧有效 SPI OTA 数据。
+ * @param buf 输入缓冲区。
+ * @param buf_len 缓冲区长度。
+ * @param type 输出帧类型,可为 NULL。
+ * @param payload 输出负载指针,可为 NULL。
+ * @param payload_len 输出负载长度,可为 NULL。
+ * @param frame_offset 输出帧起始偏移,可为 NULL。
+ * @return 找到有效帧返回 true,否则返回 false。
+ */
static bool spi_ota_find_frame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset)
@@ -64,6 +192,15 @@ static bool spi_ota_find_frame(const uint8_t *buf, size_t buf_len,
if (!buf || buf_len < 5) return false;
for (size_t i = 0; i + 5 <= buf_len; ++i) {
if (buf[i] != SPI_OTA_SOF) continue;
+ size_t len_field = (size_t)buf[i+2] | ((size_t)buf[i+3] << 8);
+ size_t needed = 5 + len_field;
+ if (i + needed > buf_len) continue;
+ uint8_t crc = spi_ota_crc8(&buf[i], 4 + len_field);
+ if (crc != buf[i + 4 + len_field]) {
+ DEBUG_DBG("SPI", "帧@%u CRC错误: calc=0x%02X recv=0x%02X type=0x%02X len=%u",
+ (unsigned)i, crc, buf[i + 4 + len_field], buf[i+1], (unsigned)len_field);
+ continue;
+ }
if (spi_ota_parse_frame(&buf[i], buf_len - i, type, payload, payload_len)) {
if (frame_offset) *frame_offset = i;
return true;
@@ -84,14 +221,14 @@ esp_err_t spi_master_init(void)
DEBUG_INFO("SPI", "=== 初始化 SPI2 (VSPI) ===");
spi_bus_config_t bus_cfg = {};
- bus_cfg.mosi_io_num = 8;
+ bus_cfg.mosi_io_num = 5;
bus_cfg.miso_io_num = 7;
bus_cfg.sclk_io_num = 6;
bus_cfg.quadwp_io_num = -1;
bus_cfg.quadhd_io_num = -1;
bus_cfg.max_transfer_sz = 256;
- DEBUG_INFO("SPI", "SPI2 引脚: MOSI=GPIO8, MISO=GPIO7, SCLK=GPIO6");
+ DEBUG_INFO("SPI", "SPI2 引脚: MOSI=GPIO5, MISO=GPIO7, SCLK=GPIO6");
DEBUG_INFO("SPI", "SPI2 配置: CLK=%d MHz, CS=GPIO%d, 最大传输=%d 字节",
SPI_CLOCK_HZ / 1000000, SPI_MASTER_CS_GPIO, bus_cfg.max_transfer_sz);
@@ -124,13 +261,21 @@ esp_err_t spi_master_init(void)
return ESP_OK;
}
-// 带超时的同步 SPI 传输
-// ESP-IDF 6.0: spi_device_transmit() 不再接受超时参数
-// 使用 spi_device_queue_trans() + spi_device_get_trans_result() 实现超时
+/**
+ * @brief 执行一次同步 SPI 事务,先发送请求,再补时钟读取响应尾部。
+ * @param cmd SPI 命令码;为 0 时不额外插入命令字节,直接发送 payload。
+ * @param payload 发送数据指针。
+ * @param payload_len 发送数据长度。
+ * @param recv_buf 接收缓冲区指针。
+ * @param recv_len 期望接收的响应字节数。
+ * @param timeout_ms 预留超时参数(当前实现未直接使用)。
+ * @return ESP_OK 成功,其他为失败错误码。
+ */
esp_err_t spi_sync_transfer(uint8_t cmd, const uint8_t *payload,
size_t payload_len, uint8_t *recv_buf,
size_t recv_len, uint32_t timeout_ms)
{
+ (void)timeout_ms;
if (g_spi_device == NULL) {
DEBUG_ERR("SPI", "SPI 设备未初始化");
return ESP_ERR_INVALID_STATE;
@@ -139,18 +284,7 @@ esp_err_t spi_sync_transfer(uint8_t cmd, const uint8_t *payload,
DEBUG_DBG("SPI", "SPI 传输: cmd=0x%02X, 发送=%zu 字节, 接收=%zu 字节",
cmd, payload_len, recv_len);
- spi_transaction_t t = {};
- t.cmd = cmd;
- t.length = payload_len * 8;
- t.rxlength = recv_len * 8;
- t.tx_buffer = payload;
- t.rx_buffer = recv_buf;
- t.user = (void *)(uintptr_t)cmd;
-
- /* ESP-IDF 6.0: 使用 spi_device_transmit() 无超时参数
- * 如需超时控制,使用 spi_device_queue_trans() + spi_device_get_trans_result()
- */
- esp_err_t ret = spi_device_transmit(g_spi_device, &t);
+ esp_err_t ret = spi_transfer_request_response(cmd, payload, payload_len, recv_buf, recv_len);
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "SPI 发送 cmd=0x%02X 失败: %s", cmd, esp_err_to_name(ret));
} else {
@@ -184,11 +318,18 @@ esp_err_t spi_send(uint8_t cmd, const uint8_t *payload, size_t len)
*/
esp_err_t spi_recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms)
{
- uint8_t dummy[64] = {0};
- memset(dummy, 0xFF, sizeof(dummy));
- return spi_sync_transfer(cmd, dummy, len, recv_buf, len, timeout_ms);
+ return spi_sync_transfer(cmd, NULL, 0, recv_buf, len, timeout_ms);
}
+/**
+ * @brief 轮询读取 SPI OTA 响应帧,不重复发送请求帧。
+ * @param resp_type 输出响应类型,可为 NULL。
+ * @param resp_payload 输出响应负载缓冲区,可为 NULL。
+ * @param resp_max 响应缓冲区最大长度。
+ * @param resp_len 输出响应长度,可为 NULL。
+ * @param timeout_ms 轮询超时时间(毫秒)。
+ * @return ESP_OK 成功,ESP_ERR_TIMEOUT 超时,其他为底层错误码。
+ */
esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
{
@@ -204,18 +345,13 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
uint32_t attempt = 0;
- while (xTaskGetTickCount() <= deadline) {
+ while (xTaskGetTickCount() <= deadline && attempt < 10) {
++attempt;
memset(rx_raw, 0xFF, sizeof(rx_raw));
- spi_transaction_t rsp = {};
- rsp.length = SPI_OTA_FRAME_MAX * 8;
- rsp.rxlength = SPI_OTA_FRAME_MAX * 8;
- rsp.tx_buffer = dummy;
- rsp.rx_buffer = rx_raw;
- esp_err_t ret = spi_device_transmit(g_spi_device, &rsp);
+ esp_err_t ret = spi_transfer_clocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw));
if (ret != ESP_OK) {
DEBUG_WARN("SPI", "SPI OTA 响应读取失败[%u]: %s", attempt, esp_err_to_name(ret));
- vTaskDelay(pdMS_TO_TICKS(10));
+ vTaskDelay(pdMS_TO_TICKS(1));
continue;
}
@@ -244,14 +380,30 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
break;
}
}
- DEBUG_DBG("SPI", "OTA响应未就绪[%u]: %s", attempt, all_ff ? "all-FF" : "no-valid-SOF");
- vTaskDelay(pdMS_TO_TICKS(10));
+ DEBUG_DBG("SPI", "OTA响应未就绪[%u]: %s first10=%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",
+ attempt, all_ff ? "all-FF" : "no-valid-SOF",
+ rx_raw[0], rx_raw[1], rx_raw[2], rx_raw[3],
+ rx_raw[4], rx_raw[5], rx_raw[6], rx_raw[7],
+ rx_raw[8], rx_raw[9]);
+ vTaskDelay(pdMS_TO_TICKS(1));
}
DEBUG_ERR("SPI", "OTA响应超时: timeout=%ums", (unsigned)timeout_ms);
return ESP_ERR_TIMEOUT;
}
+/**
+ * @brief 执行一次 SPI OTA 请求事务并等待响应帧。
+ * @param type 请求帧类型。
+ * @param payload 请求负载。
+ * @param payload_len 负载长度。
+ * @param resp_type 输出响应类型,可为 NULL。
+ * @param resp_payload 输出响应负载缓冲区,可为 NULL。
+ * @param resp_max 响应负载缓冲区大小。
+ * @param resp_len 输出响应长度,可为 NULL。
+ * @param timeout_ms 响应等待超时时间(毫秒)。
+ * @return ESP_OK 成功,其他为失败错误码。
+ */
esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_len,
uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms)
@@ -270,21 +422,34 @@ esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_
DEBUG_INFO("SPI", "OTA事务: req_type=0x%02X req_len=%u", type, (unsigned)payload_len);
- spi_transaction_t req = {};
- req.length = tx_len * 8;
- req.tx_buffer = tx_frame;
- esp_err_t ret = spi_device_transmit(g_spi_device, &req);
+ esp_err_t ret = spi_transfer_clocked(tx_frame, tx_len, NULL, 0);
if (ret != ESP_OK) {
DEBUG_ERR("SPI", "SPI OTA 请求事务失败: %s", esp_err_to_name(ret));
return ret;
}
- vTaskDelay(pdMS_TO_TICKS(2));
+ /* 给 GD32 从机一点时间在 ISR 中处理请求帧并准备响应帧。
+ * GD32 在 CS 释放的 EXTI ISR 中完成处理(微秒级),
+ * 但 EXTI ISR 到 TBE 中断启用之间有微小延迟。
+ * 500µs 足够覆盖所有中断响应延迟。 */
+ esp_rom_delay_us(500);
+
return spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, timeout_ms);
}
// CANFD 帧发送
// 帧格式: [通道(1B)] [扩展帧ID(4B BE)] [数据(1~64B)]
+/**
+ * @brief 发送一帧 CANFD 数据到 GD32,并可选等待响应。
+ * @param channel CAN 通道号。
+ * @param ext_id 扩展帧 ID(29-bit)。
+ * @param data 发送负载指针。
+ * @param dlc 发送数据长度。
+ * @param recv_buf 接收缓冲区,可为 NULL。
+ * @param recv_max 接收缓冲区最大长度。
+ * @param timeout_ms 等待响应超时(毫秒)。
+ * @return >=0 为接收字节数,<0 为错误码。
+ */
static int can_frame_send(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc,
uint8_t *recv_buf, uint8_t recv_max,
@@ -319,13 +484,8 @@ static int can_frame_send(can_channel_t channel, uint32_t ext_id,
size_t tx_len = 5 + dlc;
uint8_t rx_buf[5 + CAN_MAX_DLC] = {0};
- spi_transaction_t t = {};
- t.tx_buffer = tx_buf;
- t.rx_buffer = rx_buf;
- t.length = tx_len * 8;
- t.rxlength = (recv_max > 0) ? (5 + recv_max) * 8 : 0;
-
- esp_err_t ret = spi_device_transmit(g_spi_device, &t);
+ const size_t rx_len = (recv_buf != NULL && recv_max > 0U) ? (size_t)(5U + recv_max) : 0U;
+ esp_err_t ret = spi_transfer_clocked(tx_buf, tx_len, rx_buf, rx_len);
if (ret != ESP_OK) {
DEBUG_ERR("SPI", "CAN[ch%d] ID=0x%08X 发送失败: %s",
channel, ext_id, esp_err_to_name(ret));
@@ -352,26 +512,86 @@ static int can_frame_send(can_channel_t channel, uint32_t ext_id,
return 0;
}
-// 对外暴露的 GD32 CAN 发送接口,会在必要时修正默认 DLC。
+/**
+ * @brief 对外暴露的 GD32 CAN 发送接口,必要时修正默认 DLC。
+ * @param channel CAN 通道。
+ * @param ext_id 扩展帧 ID。
+ * @param data 发送数据指针。
+ * @param dlc 数据长度。
+ * @param recv_buf 接收缓冲区,可为 NULL。
+ * @param recv_max 接收缓冲区大小。
+ * @param timeout_ms 等待响应超时(毫秒)。
+ * @return >=0 为接收字节数,<0 为错误码。
+ */
int gd32_can_send(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc,
uint8_t *recv_buf, uint8_t recv_max,
uint32_t timeout_ms)
{
- if (dlc == 0) {
- dlc = (data != NULL) ? 64 : 0; // 避免 dlc=0 的情况
- }
return can_frame_send(channel, ext_id, data, dlc, recv_buf, recv_max, timeout_ms);
}
-// 对外暴露的无响应 GD32 CAN 发送接口。
+/**
+ * @brief 发送无需响应的 GD32 CAN 帧。
+ * @param channel CAN 通道。
+ * @param ext_id 扩展帧 ID。
+ * @param data 发送数据指针。
+ * @param dlc 数据长度。
+ * @return 0 表示已发送完成,<0 为错误码。
+ */
int gd32_can_send_no_reply(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc)
{
return can_frame_send(channel, ext_id, data, dlc, NULL, 0, 0);
}
+/**
+ * @brief 向正在运行的 GD32 DCU_APP 发送进入 Bootloader 的短 SPI 命令。
+ * @param timeout_ms 等待 APP 返回确认的最长时间。
+ * @return ESP_OK 表示 APP 已接收命令;超时则通常表示 GD32 已不在 APP 或 SPI 未就绪。
+ */
+esp_err_t gd32_app_request_bootloader(uint32_t timeout_ms)
+{
+ uint8_t cmd = SPI_APP_ENTER_BOOT_CMD;
+ esp_err_t ret = spi_transfer_clocked(&cmd, 1U, NULL, 0U);
+ if (ret != ESP_OK) {
+ DEBUG_WARN("SPI", "APP进入BOOT命令发送失败: %s", esp_err_to_name(ret));
+ return ret;
+ }
+
+ uint8_t rx[8] = {0};
+ uint8_t dummy[8];
+ memset(dummy, 0xFF, sizeof(dummy));
+ TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
+ while (xTaskGetTickCount() <= deadline) {
+ memset(rx, 0xFF, sizeof(rx));
+ ret = spi_transfer_clocked(dummy, sizeof(dummy), rx, sizeof(rx));
+ if (ret == ESP_OK) {
+ for (size_t i = 0; i + 3U < sizeof(rx); ++i) {
+ if (rx[i] == SPI_APP_ENTER_BOOT_CMD && rx[i + 1U] == SPI_APP_ENTER_BOOT_ACK_OK) {
+ DEBUG_INFO("SPI", "GD32 APP确认进入Bootloader OTA窗口");
+ return ESP_OK;
+ }
+ }
+ }
+ vTaskDelay(pdMS_TO_TICKS(10));
+ }
+
+ DEBUG_WARN("SPI", "GD32 APP进入Bootloader确认超时");
+ return ESP_ERR_TIMEOUT;
+}
+
// 带重试的 SPI 传输
+/**
+ * @brief 执行带重试的 SPI 同步传输。
+ * @param cmd SPI 命令码。
+ * @param payload 发送数据指针。
+ * @param payload_len 发送长度。
+ * @param recv_buf 接收缓冲区。
+ * @param recv_len 接收长度。
+ * @param max_retries 最大重试次数。
+ * @return ESP_OK 成功,重试耗尽返回 ESP_ERR_TIMEOUT。
+ */
esp_err_t spi_transfer_with_retry(uint8_t cmd, const uint8_t *payload,
size_t payload_len, uint8_t *recv_buf,
size_t recv_len, int max_retries)
diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h
index 9db61b7..d1f30a2 100644
--- a/main/transport/spi_master.h
+++ b/main/transport/spi_master.h
@@ -10,7 +10,7 @@
// SPI 配置常量
#define SPI_MASTER_CS_GPIO (4) // CS 片选引脚
-#define SPI_CLOCK_HZ (4 * 1000 * 1000) // 先降到 4MHz 便于联调
+#define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz
#define SPI_MODE (0) // CPOL=0, CPHA=0
#define SPI_QUEUE_SIZE (1)
#define SPI_OTA_FRAME_MAX (96)
@@ -20,6 +20,8 @@
#define SPI_OTA_CMD_REQ (0x01)
#define SPI_OTA_CMD_DATA (0x02)
#define SPI_OTA_CMD_CANCEL (0x03)
+#define SPI_APP_ENTER_BOOT_CMD (0xB0)
+#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5)
#define SPI_OTA_RSP_ACK (0x80)
#define SPI_OTA_RSP_WAIT (0x81)
#define SPI_OTA_RSP_ERROR (0x82)
@@ -57,7 +59,7 @@ typedef struct {
* @param channel CAN 通道(0~2)。
* @param ext_id 29 位扩展帧 ID。
* @param data 数据负载指针。
- * @param dlc 数据长度,0 则自动推断。
+ * @param dlc 数据长度,范围 1~64。
* @param recv_buf 接收缓冲区,可为 NULL。
* @param recv_max 期望最大接收字节数。
* @param timeout_ms 超时时间(毫秒)。
@@ -73,7 +75,7 @@ int gd32_can_send(can_channel_t channel, uint32_t ext_id,
* @param channel CAN 通道(0~2)。
* @param ext_id 29 位扩展帧 ID。
* @param data 数据负载指针。
- * @param dlc 数据长度。
+ * @param dlc 数据长度,范围 1~64。
* @return >= 0 成功,< 0 失败。
*/
int gd32_can_send_no_reply(can_channel_t channel, uint32_t ext_id,
@@ -86,12 +88,12 @@ int gd32_can_send_no_reply(can_channel_t channel, uint32_t ext_id,
esp_err_t spi_master_init(void);
/**
- * @brief 执行一次同步 SPI 传输,发送命令和负载并可选接收返回数据。
- * @param cmd SPI 命令码。
+ * @brief 执行一次同步 SPI 事务,先发送请求,再读取请求之后的响应字节。
+ * @param cmd SPI 命令码;为 0 时不额外插入命令字节。
* @param payload 发送数据指针。
* @param payload_len 发送数据长度。
* @param recv_buf 接收缓冲区,可为 NULL。
- * @param recv_len 期望接收长度。
+ * @param recv_len 期望接收的响应长度。
* @param timeout_ms 超时时间(毫秒)。
* @return ESP_OK 成功,其他失败。
*/
@@ -100,8 +102,8 @@ esp_err_t spi_sync_transfer(uint8_t cmd, const uint8_t *payload,
size_t recv_len, uint32_t timeout_ms);
/**
- * @brief 仅发送 SPI 数据,不关心返回内容。
- * @param cmd SPI 命令码。
+ * @brief 仅发送 SPI 请求数据,不关心返回内容。
+ * @param cmd SPI 命令码;为 0 时仅发送 payload。
* @param payload 发送数据指针。
* @param len 发送数据长度。
* @return ESP_OK 成功,其他失败。
@@ -109,8 +111,8 @@ esp_err_t spi_sync_transfer(uint8_t cmd, const uint8_t *payload,
esp_err_t spi_send(uint8_t cmd, const uint8_t *payload, size_t len);
/**
- * @brief 仅接收 SPI 数据,底层会发送哑数据产生时钟。
- * @param cmd SPI 命令码。
+ * @brief 仅接收 SPI 数据,必要时先发送命令字节,再发送哑数据产生时钟。
+ * @param cmd SPI 命令码;为 0 时只发送哑数据。
* @param recv_buf 接收缓冲区。
* @param len 接收长度。
* @param timeout_ms 超时时间(毫秒)。
@@ -139,4 +141,10 @@ esp_err_t spi_ota_transfer(uint8_t type, const uint8_t *payload, size_t payload_
esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload,
size_t resp_max, size_t *resp_len, uint32_t timeout_ms);
-#endif // SPI_MASTER_H
\ No newline at end of file
+/**
+ * @brief 通知正在运行的 GD32 DCU_APP 设置 OTA 标志并软复位进入 Bootloader。
+ * @return ESP_OK 表示 APP 已确认;ESP_ERR_TIMEOUT 常表示 GD32 已在 Bootloader 或通信未就绪。
+ */
+esp_err_t gd32_app_request_bootloader(uint32_t timeout_ms);
+
+#endif // SPI_MASTER_H
diff --git a/tools/app.js b/tools/app.js
index 4c307da..67eb7e9 100644
--- a/tools/app.js
+++ b/tools/app.js
@@ -4,6 +4,8 @@ let uploadProgress = 0;
let isUploading = false;
let logFilter = 'all';
let logEntries = [];
+let lastLogHash = null;
+let autoScroll = true;
let logSocket = null;
let latestSystemStatus = null;
@@ -15,7 +17,11 @@ function formatBytes(bytes) {
}
function formatTime(date) {
- return date.toTimeString().split(' ')[0];
+ var h = String(date.getHours()).padStart(2, '0');
+ var m = String(date.getMinutes()).padStart(2, '0');
+ var s = String(date.getSeconds()).padStart(2, '0');
+ var ms = String(date.getMilliseconds()).padStart(3, '0');
+ return h + ':' + m + ':' + s + '.' + ms;
}
function formatUptime(sec) {
@@ -24,22 +30,21 @@ function formatUptime(sec) {
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
- if (d > 0) return `${d}d ${h}h ${m}m`;
- if (h > 0) return `${h}h ${m}m ${s}s`;
- if (m > 0) return `${m}m ${s}s`;
- return `${s}s`;
+ if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
+ if (h > 0) return h + 'h ' + m + 'm ' + s + 's';
+ if (m > 0) return m + 'm ' + s + 's';
+ return s + 's';
}
-function showToast(message, type = 'success') {
+function showToast(message, type) {
+ type = type || 'success';
const toast = document.getElementById('toast');
- const icon = document.getElementById('toastIcon');
- const msg = document.getElementById('toastMessage');
-
- toast.className = `toast ${type} show`;
- icon.textContent = type === 'success' ? '✓' : type === 'error' ? '✕' : '⚠';
+ const ico = document.getElementById('toastIco');
+ const msg = document.getElementById('toastMsg');
+ toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
+ ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
msg.textContent = message;
-
- setTimeout(() => toast.classList.remove('show'), 3000);
+ setTimeout(function () { toast.classList.remove('show'); }, 3000);
}
function getEspIp() {
@@ -47,133 +52,290 @@ function getEspIp() {
}
function getBaseUrl() {
- return `http://${getEspIp()}`;
+ return 'http://' + getEspIp();
}
-// ==================== 日志系统 ====================
-function addLog(level, tag, message) {
- const logContent = document.getElementById('logContent');
- const time = formatTime(new Date());
+// ==================== 日志系统 (带去重 + 自动滚动) ====================
+function makeLogHash(level, tag, message) {
+ return level + '|' + tag + '|' + message;
+}
- const entry = { time, level, tag, message };
+function addLog(level, tag, message) {
+ var scroll = document.getElementById('logScroll');
+ var time = formatTime(new Date());
+ var hash = makeLogHash(level, tag, message);
+ if (hash === lastLogHash) return; // 去重
+ lastLogHash = hash;
+
+ var entry = { time: time, level: level, tag: tag, message: message };
logEntries.push(entry);
- if (logFilter !== 'all' && level !== logFilter) {
- return;
- }
+ if (logFilter !== 'all' && level !== logFilter) return;
- const div = document.createElement('div');
- div.className = 'log-entry';
- div.innerHTML = `
- [${time}]
- ${level}
- [${tag}]
- ${message}
- `;
+ var div = document.createElement('div');
+ div.className = 'log-line';
+ div.innerHTML =
+ '[' + time + ']' +
+ '' + level + '' +
+ '[' + tag + ']' +
+ '' + message + '';
+ scroll.appendChild(div);
- logContent.appendChild(div);
- logContent.scrollTop = logContent.scrollHeight;
+ if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
document.getElementById('logCount').textContent = logEntries.length;
}
function clearLogs() {
- document.getElementById('logContent').innerHTML = '';
+ document.getElementById('logScroll').innerHTML = '';
logEntries = [];
+ lastLogHash = null;
document.getElementById('logCount').textContent = '0';
}
-function toggleFilter(level, btn) {
- document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+function setLogFilter(level, btn) {
+ document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
logFilter = level;
-
- const logContent = document.getElementById('logContent');
- logContent.innerHTML = '';
- logEntries.forEach(entry => {
- if (level === 'all' || entry.level === level) {
- const div = document.createElement('div');
- div.className = 'log-entry';
- div.innerHTML = `
- [${entry.time}]
- ${entry.level}
- [${entry.tag}]
- ${entry.message}
- `;
- logContent.appendChild(div);
+ var scroll = document.getElementById('logScroll');
+ scroll.innerHTML = '';
+ logEntries.forEach(function (e) {
+ if (level === 'all' || e.level === level) {
+ var div = document.createElement('div');
+ div.className = 'log-line';
+ div.innerHTML =
+ '[' + e.time + ']' +
+ '' + e.level + '' +
+ '[' + e.tag + ']' +
+ '' + e.message + '';
+ scroll.appendChild(div);
}
});
- logContent.scrollTop = logContent.scrollHeight;
+ if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
+}
+
+function toggleAutoScroll() {
+ autoScroll = !autoScroll;
+ var btn = document.getElementById('autoScrollBtn');
+ btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
+}
+
+function updateUploadButtonLabel() {
+ var chip = document.getElementById('targetChip').value;
+ var uploadBtn = document.getElementById('uploadBtn');
+ if (!uploadBtn) return;
+ uploadBtn.textContent = chip === 'esp32' ? '上传固件到 ESP32' : '上传固件到中继缓存';
+}
+
+function setHelper(id, text, type) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.className = 'helper-note ' + (type || '');
+ el.innerHTML = text;
+}
+
+function updateFileHint() {
+ if (!currentFile) {
+ setHelper('fileHint', '未选择固件。请选择 .bin 或 .hex 文件后再执行上传。', 'warn');
+ return;
+ }
+ var chip = document.getElementById('targetChip').value;
+ var targetText = chip === 'esp32' ? 'ESP32 自升级' : 'GD32 中继升级';
+ setHelper('fileHint', '文件已就绪:' + currentFile.name + ',当前目标:' + targetText + '。可以开始上传。', 'ok');
+}
+
+function updateActionHint() {
+ var chip = document.getElementById('targetChip').value;
+ if (chip === 'esp32') {
+ setHelper('actionHint', 'ESP32 模式:上传后设备会执行自身 OTA。上传成功后会自动调用 /ota/update 并重启。', 'warn');
+ } else {
+ setHelper('actionHint', 'GD32 模式:先把固件上传到 ESP32 中继缓存,再点击“触发 GD32 OTA 中继”。阶段流会根据真实 relay_state 更新。', '');
+ }
+}
+
+function updateCanHint() {
+ var dlc = parseInt(document.getElementById('canDlc').value) || 0;
+ var dataHex = document.getElementById('canData').value.trim().replace(/\s/g, '');
+ if (!dataHex) {
+ setHelper('canHint', '输入十六进制数据后会自动检查字节数是否与 DLC 一致。', '');
+ return;
+ }
+ if (!/^[0-9a-fA-F]*$/.test(dataHex)) {
+ setHelper('canHint', '数据包含非 HEX 字符,请只输入 0-9 A-F。', 'err');
+ return;
+ }
+ if (dataHex.length % 2 !== 0) {
+ setHelper('canHint', 'HEX 长度必须为偶数,当前还差半个字节。', 'err');
+ return;
+ }
+ var bytes = dataHex.length / 2;
+ if (dlc && bytes !== dlc) {
+ setHelper('canHint', '当前数据为 ' + bytes + ' 字节,但 DLC = ' + dlc + '。发送前建议对齐。', 'warn');
+ return;
+ }
+ setHelper('canHint', 'HEX 数据合法,当前长度 ' + bytes + ' 字节,与 DLC 一致。', 'ok');
+}
+
+function resetStageFlow() {
+ ['s1', 's2', 's3', 's4'].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) el.className = 'stage-node';
+ });
+}
+
+function applyDisconnectedRuntimeState() {
+ document.getElementById('pillIP').textContent = '--';
+ document.getElementById('pillRSSI').textContent = '--';
+ document.getElementById('pillRelayTxt').textContent = '离线';
+ document.getElementById('pillRelay').className = 'pill offline';
+ document.getElementById('mWifi').textContent = '未连接';
+ document.getElementById('mRSSI').textContent = '--';
+ document.getElementById('bOTAProgress').textContent = '--';
+ document.getElementById('bOTAProgress').className = 'v err';
+ resetStageFlow();
}
// ==================== 连接管理 ====================
-function updateConnectionUi(connected, text = null) {
- const badge = document.getElementById('connectionBadge');
- const label = document.getElementById('connectionText');
- badge.className = connected ? 'badge connected' : 'badge disconnected';
- label.textContent = text || (connected ? '已连接' : '未连接');
+function updateConnectionUi(connected, text) {
+ // header pill
+ var pc = document.getElementById('pillConn');
+ var pct = document.getElementById('pillConnTxt');
+ pc.className = connected ? 'pill online' : 'pill offline';
+ pct.textContent = text || (connected ? '已连接' : '未连接');
+
+ // connection card indicator
+ var cs = document.getElementById('connStatus');
+ var cst = document.getElementById('connStatusTxt');
+ cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
+ cst.textContent = connected ? (text || '已连接') : '未连接';
+
+ // primary badge
+ var bc = document.getElementById('bConn');
+ bc.textContent = connected ? '在线' : '离线';
+ bc.className = 'v ' + (connected ? 'ok' : 'err');
}
function updateHealthUi(healthCode, freeHeap) {
- const healthNames = ['正常', '警告', '危险', '关机'];
- const health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
- document.getElementById('healthText').textContent = `系统${health}`;
- document.getElementById('healthBadge').className = healthCode === 0 ? 'badge connected' : 'badge warning';
+ var healthNames = ['正常', '警告', '危险', '关机'];
+ var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
+ var connStatusTxt = document.getElementById('connStatusTxt');
+ if (connStatusTxt && latestSystemStatus && latestSystemStatus.wifi) {
+ connStatusTxt.textContent = (latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
+ }
if (freeHeap !== undefined) {
- document.getElementById('statHeap').textContent = formatBytes(freeHeap);
+ document.getElementById('mHeap').textContent = formatBytes(freeHeap);
+ }
+}
+
+// ==================== OTA 阶段驱动 (真实状态) ====================
+function updateStagesFromRelayState(rs) {
+ var s1 = document.getElementById('s1');
+ var s2 = document.getElementById('s2');
+ var s3 = document.getElementById('s3');
+ var s4 = document.getElementById('s4');
+ var all = [s1, s2, s3, s4];
+
+ // reset all
+ all.forEach(function (s) { s.className = 'stage-node'; });
+
+ if (rs === undefined || rs === null) return;
+
+ if (rs === 255) {
+ s1.className = 'stage-node err';
+ s2.className = 'stage-node err';
+ s3.className = 'stage-node err';
+ s4.className = 'stage-node err';
+ return;
+ }
+
+ var activeStage = 0;
+ if (rs >= 1 && rs <= 6) activeStage = 1;
+ else if (rs >= 7 && rs <= 8) activeStage = 2;
+ else if (rs >= 9 && rs <= 15) activeStage = 3;
+ else if (rs >= 16 && rs <= 17) activeStage = 4;
+
+ for (var i = 0; i < activeStage - 1; i++) {
+ all[i].className = 'stage-node done';
+ }
+ if (activeStage >= 1 && activeStage <= 4) {
+ all[activeStage - 1].className = 'stage-node live';
}
}
function updateSystemStatusUi(data) {
if (!data) return;
-
latestSystemStatus = data;
- const wifi = data.wifi || {};
- const ota = data.ota || {};
- const healthCode = data.health;
- const relayStates = {
- 0: '待机',
- 1: '等待握手',
- 2: '握手已发',
- 3: '挑战已发',
- 4: '收到握手响应',
- 5: '会话密钥已发',
- 6: '认证完成',
- 7: '大小已发',
- 8: '大小确认',
- 9: '接收中',
- 10: '转发中',
- 11: '数据包已发',
- 12: 'ACK正常',
- 13: '等待重发',
- 14: '接收完成',
- 15: '传输完成',
- 16: '结束已发',
- 17: 'OTA完成',
+ var wifi = data.wifi || {};
+ var ota = data.ota || {};
+ var healthCode = data.health;
+
+ var relayStates = {
+ 0: '待机', 1: '等待握手', 2: '握手已发', 3: '挑战已发',
+ 4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成',
+ 7: '大小已发', 8: '大小确认', 9: '接收中', 10: '转发中',
+ 11: '数据包已发', 12: 'ACK正常', 13: '等待重发',
+ 14: '接收完成', 15: '传输完成', 16: '结束已发', 17: 'OTA完成',
255: '错误'
};
- updateConnectionUi(!!wifi.connected, wifi.connected ? `${wifi.mode || 'STA'} 已连接` : `${wifi.mode || 'STA'} 未连接`);
+ var connected = !!wifi.connected;
+ var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
+
+ updateConnectionUi(connected, connText);
updateHealthUi(healthCode, data.free_heap);
- // 显示固件版本
+ // version
if (data.version) {
- document.getElementById('fwVersion').textContent = `v${data.version}`;
+ document.getElementById('pillVer').textContent = 'v' + data.version;
+ document.getElementById('mVersion').textContent = 'v' + data.version;
}
- document.getElementById('statTemperature').textContent = Number.isFinite(data.temperature) ? `${data.temperature.toFixed(1)}°C` : '--';
- const uptimeEl = document.getElementById('statUptime');
- if (uptimeEl) uptimeEl.textContent = formatUptime(data.uptime_sec);
- document.getElementById('statWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接';
- document.getElementById('statSignal').textContent = (wifi.rssi !== undefined && wifi.rssi !== -127) ? `${wifi.rssi} dBm` : '--';
- document.getElementById('statHeap').textContent = data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
- document.getElementById('statMinHeap').textContent = data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
+ // temperature
+ document.getElementById('mTemp').textContent =
+ Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
- const total = ota.total_size || 0;
- const stored = ota.stored_size || 0;
- const progress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
- document.getElementById('statProgress').textContent = `${progress}%`;
- document.getElementById('statRelayState').textContent = relayStates[ota.relay_state] ?? '待机';
+ // uptime
+ var uptimeEl = document.getElementById('pillUptime');
+ if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
+
+ // wifi / signal
+ document.getElementById('mWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接';
+ document.getElementById('pillIP').textContent = wifi.ip || '--';
+ var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
+ document.getElementById('mRSSI').textContent = rssi;
+ document.getElementById('pillRSSI').textContent = rssi;
+
+ // heap
+ document.getElementById('mHeap').textContent =
+ data.free_heap !== undefined ? formatBytes(data.free_heap) : '--';
+ document.getElementById('mMinHeap').textContent =
+ data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--';
+
+ // relay state
+ var rs = ota.relay_state;
+ var relayText = relayStates[rs] || '待机';
+ document.getElementById('pillRelayTxt').textContent = relayText;
+ var pr = document.getElementById('pillRelay');
+ if (rs === 255) pr.className = 'pill offline';
+ else if (rs > 0 && rs < 17) pr.className = 'pill processing';
+ else if (rs === 17) pr.className = 'pill online';
+ else pr.className = 'pill idle';
+
+ // OTA progress
+ var total = ota.total_size || 0;
+ var stored = ota.stored_size || 0;
+ var progress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
+ var bp = document.getElementById('bOTAProgress');
+ bp.textContent = progress + '%';
+ bp.className = 'v ' + (progress === 100 ? 'ok' : progress > 0 ? 'warn' : '');
+
+ // DUT mode pill
+ var currentMode = document.getElementById('targetChip').value === 'esp32' ? 'ESP32' : 'GD32';
+ document.getElementById('pillModeTxt').textContent = currentMode;
+ document.getElementById('bMode').textContent = currentMode;
+
+ // stage flow from real state
+ updateStagesFromRelayState(rs);
}
async function testConnection() {
@@ -182,86 +344,62 @@ async function testConnection() {
showToast('暂无同步数据,请稍等 1 秒', 'warning');
return;
}
-
- const wifi = latestSystemStatus.wifi || {};
- const connected = !!wifi.connected;
+ var wifi = latestSystemStatus.wifi || {};
+ var connected = !!wifi.connected;
updateSystemStatusUi(latestSystemStatus);
addLog('I', '连接', connected
- ? `当前已连接:${wifi.mode || 'STA'} ${wifi.ip || ''}`.trim()
- : `当前未连接:${wifi.mode || 'STA'}`);
- showToast(connected ? 'ESP32 当前在线' : 'ESP32 当前未连接', connected ? 'success' : 'warning');
+ ? '当前已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
+ : '当前未连接:' + (wifi.mode || 'STA'));
+ showToast(connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
+ connected ? 'success' : 'warning');
}
-
// ==================== CAN 帧发送 ====================
async function sendCanFrame() {
- const ch = parseInt(document.getElementById('canChannel').value);
- const idStr = document.getElementById('canId').value.trim();
- const dlc = parseInt(document.getElementById('canDlc').value) || 8;
- const dataStr = document.getElementById('canData').value.trim();
+ var ch = parseInt(document.getElementById('canChannel').value);
+ var idStr = document.getElementById('canId').value.trim();
+ var dlc = parseInt(document.getElementById('canDlc').value) || 8;
+ var dataStr = document.getElementById('canData').value.trim();
- let extId = 0;
- if (idStr.startsWith('0x') || idStr.startsWith('0X')) {
- extId = parseInt(idStr, 16);
- } else {
- extId = parseInt(idStr, 10);
- if (isNaN(extId)) extId = parseInt(idStr, 16);
- }
+ var extId = 0;
+ if (idStr.toLowerCase().indexOf('0x') === 0) extId = parseInt(idStr, 16);
+ else { extId = parseInt(idStr, 10); if (isNaN(extId)) extId = parseInt(idStr, 16); }
- if (isNaN(extId)) {
- showToast('ID 格式错误', 'error');
- return;
- }
+ if (isNaN(extId)) { showToast('ID 格式错误', 'error'); return; }
- let dataHex = dataStr.replace(/\s/g, '');
- if (dataHex.length % 2 !== 0) {
- showToast('数据长度必须为偶数', 'error');
- return;
- }
+ var dataHex = dataStr.replace(/\s/g, '');
+ if (dataHex.length % 2 !== 0) { showToast('数据长度必须为偶数', 'error'); return; }
- addLog('I', 'CAN', `发送 CH${ch} ID=0x${extId.toString(16).toUpperCase()} DLC=${dlc}`);
+ addLog('I', 'CAN', '发送 CH' + ch + ' ID=0x' + extId.toString(16).toUpperCase() + ' DLC=' + dlc);
try {
- const response = await fetch(`${getBaseUrl()}/can/send`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- ch: ch,
- id: extId,
- dlc: dlc,
- data: dataHex
- }),
+ var resp = await fetch(getBaseUrl() + '/can/send', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ch: ch, id: extId, dlc: dlc, data: dataHex }),
signal: AbortSignal.timeout(5000)
});
-
- const result = await response.json();
-
+ var result = await resp.json();
if (result.status === 'ok') {
- addLog('I', 'CAN', `响应: ${result.recv || '(无数据)'} (${result.recv_len || 0}B)`);
-
- const respDiv = document.getElementById('canResponse');
- const respContent = document.getElementById('canRespContent');
- respDiv.style.display = 'block';
- respContent.textContent = result.recv
- ? `0x${result.recv} (${result.recv_len} 字节)`
- : '无响应数据';
-
- showToast(`CH${ch} 发送成功`, 'success');
+ addLog('I', 'CAN', '响应: ' + (result.recv || '(无数据)') + ' (' + (result.recv_len || 0) + 'B)');
+ var rd = document.getElementById('canResp');
+ rd.style.display = 'block';
+ document.getElementById('canRespText').textContent =
+ result.recv ? '0x' + result.recv + ' (' + result.recv_len + ' 字节)' : '无响应数据';
+ showToast('CH' + ch + ' 发送成功', 'success');
} else {
- addLog('E', 'CAN', `发送失败: code=${result.code}`);
- showToast(`发送失败: code=${result.code}`, 'error');
+ addLog('E', 'CAN', '发送失败: code=' + result.code);
+ showToast('发送失败: code=' + result.code, 'error');
}
} catch (e) {
- addLog('E', 'CAN', `发送失败: ${e.message}`);
+ addLog('E', 'CAN', '发送失败: ' + e.message);
showToast('发送失败', 'error');
}
}
function onTargetChipChange() {
- const chip = document.getElementById('targetChip').value;
- const relayBtn = document.getElementById('relayBtn');
- const esp32Actions = document.getElementById('esp32Actions');
-
+ var chip = document.getElementById('targetChip').value;
+ var relayBtn = document.getElementById('relayBtn');
+ var esp32Actions = document.getElementById('esp32Actions');
if (chip === 'esp32') {
relayBtn.style.display = 'none';
esp32Actions.style.display = 'block';
@@ -269,140 +407,232 @@ function onTargetChipChange() {
relayBtn.style.display = '';
esp32Actions.style.display = 'none';
}
- addLog('I', '目标', `切换到: ${chip === 'esp32' ? 'ESP32 自身升级' : 'GD32 Flash 中继'}`);
+ updateUploadButtonLabel();
+ updateActionHint();
+ document.getElementById('pillModeTxt').textContent = chip === 'esp32' ? 'ESP32' : 'GD32';
+ document.getElementById('bMode').textContent = chip === 'esp32' ? 'ESP32' : 'GD32';
+ var th = document.getElementById('targetHint');
+ th.innerHTML = chip === 'esp32'
+ ? '直接将固件写入 ESP32 自身 Flash,写入完成后 OTA 升级并自动重启。'
+ : '将固件写入 ESP32 Flash,由 ESP32 通过 SPI 中继到 GD32 完成刷写。';
+ addLog('I', '目标', '切换到: ' + (chip === 'esp32' ? 'ESP32 自身升级' : 'GD32 Flash 中继'));
}
function clearCanFields() {
document.getElementById('canId').value = '0x18DA00F1';
document.getElementById('canDlc').value = '8';
document.getElementById('canData').value = '0102030405060708';
- document.getElementById('canResponse').style.display = 'none';
+ document.getElementById('canResp').style.display = 'none';
+ updateCanHint();
+}
+
+// ==================== SPI 发送 ====================
+function updateSpiHint() {
+ var typeHex = document.getElementById('spiType').value.trim();
+ var dataHex = document.getElementById('spiData').value.trim().replace(/\s/g, '');
+ var hint = document.getElementById('spiHint');
+
+ if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
+ hint.className = 'helper-note err';
+ hint.innerHTML = 'type 需填 1 字节 hex(如 01, B0)。';
+ return;
+ }
+ if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) {
+ hint.className = 'helper-note err';
+ hint.innerHTML = '数据包含非 HEX 字符。';
+ return;
+ }
+ if (dataHex && dataHex.length % 2 !== 0) {
+ hint.className = 'helper-note err';
+ hint.innerHTML = 'HEX 长度必须为偶数。';
+ return;
+ }
+ var bytes = dataHex ? dataHex.length / 2 : 0;
+ var totalOnWire = 5 + bytes;
+ hint.className = 'helper-note ok';
+ hint.innerHTML = 'type=0x' + typeHex.toUpperCase() + '' +
+ (bytes > 0 ? ',payload: ' + bytes + 'B' : ',无 payload') +
+ ',线上 ' + totalOnWire + 'B(SOF+TYPE+LEN+PAYLOAD+CRC)';
+}
+
+async function sendSpiFrame() {
+ var typeHex = document.getElementById('spiType').value.trim();
+ var dataStr = document.getElementById('spiData').value.trim();
+ var timeout = parseInt(document.getElementById('spiTimeout').value) || 1000;
+
+ if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
+ showToast('type 需填 1 字节 hex', 'error');
+ return;
+ }
+
+ var dataHex = dataStr.replace(/\s/g, '');
+ if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) {
+ showToast('数据包含非 HEX 字符', 'error');
+ return;
+ }
+ if (dataHex && dataHex.length % 2 !== 0) {
+ showToast('HEX 数据长度必须为偶数', 'error');
+ return;
+ }
+
+ var txBytes = dataHex ? dataHex.length / 2 : 0;
+
+ addLog('I', 'SPI', 'type=0x' + typeHex.toUpperCase() +
+ (txBytes > 0 ? ', payload=' + dataHex.toUpperCase() : ', 无负载'));
+
+ var body = { type: typeHex, data: dataHex.toUpperCase(), timeout_ms: timeout };
+
+ try {
+ var resp = await fetch(getBaseUrl() + '/spi/send', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeout + 2000)
+ });
+ var result = await resp.json();
+
+ if (result.status === 'ok') {
+ var recvHex = result.recv || '';
+ var respTypeName = {'80': 'ACK', '81': 'WAIT', '82': 'ERROR'};
+ var respTypeLabel = respTypeName[result.resp_type] || ('0x' + result.resp_type);
+ addLog('I', 'SPI', '→ ' + respTypeLabel +
+ (recvHex ? ' 0x' + recvHex : '') + ' (' + (result.resp_len || 0) + 'B)');
+ var rd = document.getElementById('spiResp');
+ rd.style.display = 'block';
+ document.getElementById('spiRespText').textContent =
+ 'type=0x' + result.type + ' payload=' + result.payload_len + 'B → ' + respTypeLabel +
+ (recvHex ? ' 0x' + recvHex : '') +
+ ' (' + (result.resp_len || 0) + 'B)';
+ showToast('SPI 发送成功', 'success');
+ } else {
+ addLog('E', 'SPI', '发送失败: code=' + result.code + ' ' + (result.message || ''));
+ var rd = document.getElementById('spiResp');
+ rd.style.display = 'block';
+ document.getElementById('spiRespText').textContent =
+ '错误: code=' + result.code + ' ' + (result.message || '');
+ showToast('SPI 发送失败', 'error');
+ }
+ } catch (e) {
+ addLog('E', 'SPI', '发送失败: ' + e.message);
+ showToast('SPI 发送失败: ' + e.message, 'error');
+ }
+}
+
+function clearSpiFields() {
+ document.getElementById('spiType').value = '01';
+ document.getElementById('spiData').value = '';
+ document.getElementById('spiTimeout').value = '1000';
+ document.getElementById('spiResp').style.display = 'none';
+ updateSpiHint();
}
// ==================== 文件处理 ====================
function handleFileSelect(event) {
- const file = event.target.files[0];
+ var file = event.target.files[0];
if (file) processFile(file);
}
-document.getElementById('uploadZone').addEventListener('dragover', (e) => {
- e.preventDefault();
- e.currentTarget.classList.add('dragover');
+document.getElementById('uploadZone').addEventListener('dragover', function (e) {
+ e.preventDefault(); e.currentTarget.classList.add('dragover');
});
-
-document.getElementById('uploadZone').addEventListener('dragleave', (e) => {
+document.getElementById('uploadZone').addEventListener('dragleave', function (e) {
e.currentTarget.classList.remove('dragover');
});
-
-document.getElementById('uploadZone').addEventListener('drop', (e) => {
- e.preventDefault();
- e.currentTarget.classList.remove('dragover');
- const file = e.dataTransfer.files[0];
+document.getElementById('uploadZone').addEventListener('drop', function (e) {
+ e.preventDefault(); e.currentTarget.classList.remove('dragover');
+ var file = e.dataTransfer.files[0];
if (file) processFile(file);
});
+document.getElementById('canData').addEventListener('input', updateCanHint);
+document.getElementById('canDlc').addEventListener('input', updateCanHint);
+
+document.getElementById('spiType').addEventListener('input', updateSpiHint);
+document.getElementById('spiData').addEventListener('input', updateSpiHint);
+
function processFile(file) {
currentFile = file;
- const fileInfo = document.getElementById('fileInfo');
- const uploadBtn = document.getElementById('uploadBtn');
-
+ document.getElementById('fileInfo').classList.add('show');
+ document.getElementById('uploadBtn').disabled = false;
document.getElementById('fileName').textContent = file.name;
- document.getElementById('fileType').textContent = file.name.endsWith('.hex') ? 'Intel HEX' : 'Binary';
+ document.getElementById('fileType').textContent =
+ file.name.toLowerCase().indexOf('.hex') >= 0 ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize').textContent = formatBytes(file.size);
-
- const chunks = Math.ceil(file.size / 8192);
+ var chunks = Math.ceil(file.size / 8192);
document.getElementById('fileChunks').textContent = chunks + ' 包';
-
- fileInfo.classList.add('visible');
- uploadBtn.disabled = false;
-
- addLog('I', '文件', `已选择: ${file.name} (${formatBytes(file.size)})`);
+ updateFileHint();
+ addLog('I', '文件', '已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
showToast('文件已选择', 'success');
}
function clearFile() {
currentFile = null;
document.getElementById('fileInput').value = '';
- document.getElementById('fileInfo').classList.remove('visible');
+ document.getElementById('fileInfo').classList.remove('show');
document.getElementById('uploadBtn').disabled = true;
- document.getElementById('progressSection').classList.remove('visible');
+ document.getElementById('progressWrap').classList.remove('show');
+ updateFileHint();
addLog('I', '文件', '文件已清除');
}
// ==================== 上传固件 ====================
async function startUpload() {
if (!currentFile || isUploading) return;
-
- const chip = document.getElementById('targetChip').value;
+ var chip = document.getElementById('targetChip').value;
isUploading = true;
- const uploadBtn = document.getElementById('uploadBtn');
+ var uploadBtn = document.getElementById('uploadBtn');
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
-
- const progressSection = document.getElementById('progressSection');
- progressSection.classList.add('visible');
-
- addLog('I', '上传', `开始上传: ${currentFile.name} → ${chip.toUpperCase()}`);
- showToast(`开始上传固件到 ${chip.toUpperCase()}`, 'success');
+ document.getElementById('progressWrap').classList.add('show');
+ addLog('I', '上传', '开始上传: ' + currentFile.name + ' \u2192 ' + chip.toUpperCase());
+ showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
try {
- const arrayBuffer = await currentFile.arrayBuffer();
- const fileSize = currentFile.size;
-
- let body;
- let url = `${getBaseUrl()}/ota/upload`;
+ var arrayBuffer = await currentFile.arrayBuffer();
+ var fileSize = currentFile.size;
+ var body, url = getBaseUrl() + '/ota/upload';
if (chip === 'esp32') {
- const headerJson = JSON.stringify({
- type: 'esp32',
- total_size: fileSize
- });
- const encoder = new TextEncoder();
- const headerBytes = encoder.encode(headerJson);
- const fileBytes = new Uint8Array(arrayBuffer);
-
- const combined = new Uint8Array(headerBytes.length + fileSize);
+ var headerJson = JSON.stringify({ type: 'esp32', total_size: fileSize });
+ var encoder = new TextEncoder();
+ var headerBytes = encoder.encode(headerJson);
+ var fileBytes = new Uint8Array(arrayBuffer);
+ var combined = new Uint8Array(headerBytes.length + fileSize);
combined.set(headerBytes, 0);
combined.set(fileBytes, headerBytes.length);
-
body = combined.buffer;
} else {
body = arrayBuffer;
}
- const xhr = new XMLHttpRequest();
-
- xhr.upload.addEventListener('progress', (e) => {
+ var xhr = new XMLHttpRequest();
+ xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
- const percent = Math.round((e.loaded / e.total) * 100);
- uploadProgress = percent;
- document.getElementById('progressBar').style.width = percent + '%';
- document.getElementById('progressText').textContent =
- `已上传 ${formatBytes(e.loaded)} / ${formatBytes(e.total)}`;
- document.getElementById('progressPercent').textContent = percent + '%';
- document.getElementById('statProgress').textContent = percent + '%';
+ var pct = Math.round((e.loaded / e.total) * 100);
+ uploadProgress = pct;
+ document.getElementById('progressFill').style.width = pct + '%';
+ document.getElementById('progressTxt').textContent =
+ '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total);
+ document.getElementById('progressPct').textContent = pct + '%';
+ document.getElementById('bOTAProgress').textContent = pct + '%';
}
});
- xhr.addEventListener('load', () => {
+ xhr.addEventListener('load', function () {
isUploading = false;
uploadBtn.disabled = false;
- uploadBtn.textContent = '⬆️ 上传固件';
-
+ updateUploadButtonLabel();
if (xhr.status === 200) {
addLog('I', '上传', '固件上传完成!');
showToast('固件上传成功', 'success');
-
if (chip === 'esp32') {
document.getElementById('esp32Actions').style.display = 'block';
- // 安全间隔:3 秒后自动调用刷写,留时间确认状态
- let countdown = 3;
- addLog('I', 'OTA', `${countdown} 秒后自动调用 POST /ota/update 完成升级并重启...`);
- const timer = setInterval(() => {
+ var countdown = 3;
+ addLog('I', 'OTA', countdown + ' 秒后自动调用 POST /ota/update 完成升级并重启...');
+ var timer = setInterval(function () {
countdown--;
- if (countdown > 0) {
- addLog('I', 'OTA', `${countdown} 秒后开始刷写...`);
- } else {
+ if (countdown > 0) addLog('I', 'OTA', countdown + ' 秒后开始刷写...');
+ else {
clearInterval(timer);
addLog('I', 'OTA', '开始刷写!');
triggerEsp32Update();
@@ -410,28 +640,25 @@ async function startUpload() {
}, 1000);
}
} else {
- addLog('E', '上传', `上传失败: HTTP ${xhr.status}`);
+ addLog('E', '上传', '上传失败: HTTP ' + xhr.status);
showToast('上传失败', 'error');
}
});
- xhr.addEventListener('error', () => {
- isUploading = false;
- uploadBtn.disabled = false;
- uploadBtn.textContent = '⬆️ 上传固件';
+ xhr.addEventListener('error', function () {
+ isUploading = false; uploadBtn.disabled = false;
+ updateUploadButtonLabel();
addLog('E', '上传', '上传过程发生错误');
showToast('上传出错', 'error');
});
xhr.open('POST', url);
xhr.send(body);
-
} catch (e) {
- addLog('E', '上传', `上传失败: ${e.message}`);
+ addLog('E', '上传', '上传失败: ' + e.message);
showToast('上传失败', 'error');
- isUploading = false;
- uploadBtn.disabled = false;
- uploadBtn.textContent = '⬆️ 上传固件';
+ isUploading = false; uploadBtn.disabled = false;
+ updateUploadButtonLabel();
}
}
@@ -439,140 +666,112 @@ async function startUpload() {
async function triggerEsp32Update() {
addLog('I', 'OTA', '=== ESP32 OTA 完成,开始重启 ===');
showToast('ESP32 正在重启...', 'success');
-
try {
- const response = await fetch(`${getBaseUrl()}/ota/update`, {
- method: 'POST',
- signal: AbortSignal.timeout(5000)
+ var resp = await fetch(getBaseUrl() + '/ota/update', {
+ method: 'POST', signal: AbortSignal.timeout(5000)
});
-
- if (response.ok) {
- const data = await response.json();
- addLog('I', 'OTA', `升级完成: ${data.message}`);
+ if (resp.ok) {
+ var data = await resp.json();
+ addLog('I', 'OTA', '升级完成: ' + data.message);
showToast('ESP32 重启中...', 'success');
}
} catch (e) {
- addLog('E', 'OTA', `升级失败: ${e.message}`);
+ addLog('E', 'OTA', '升级失败: ' + e.message);
showToast('升级失败', 'error');
}
}
async function restartEsp32() {
if (!confirm('确定要重启 ESP32 吗?')) return;
-
addLog('I', '系统', '=== 请求重启 ESP32 ===');
showToast('ESP32 正在重启...', 'success');
-
try {
- const response = await fetch(`${getBaseUrl()}/restart`, {
- method: 'POST',
- signal: AbortSignal.timeout(5000)
+ var resp = await fetch(getBaseUrl() + '/restart', {
+ method: 'POST', signal: AbortSignal.timeout(5000)
});
-
- if (response.ok) {
- const data = await response.json();
- addLog('I', '系统', `重启已触发: ${data.message}`);
+ if (resp.ok) {
+ var data = await resp.json();
+ addLog('I', '系统', '重启已触发: ' + data.message);
showToast('ESP32 重启中,页面将自动恢复...', 'success');
}
} catch (e) {
- addLog('E', '系统', `重启请求失败: ${e.message}`);
+ addLog('E', '系统', '重启请求失败: ' + e.message);
showToast('重启失败', 'error');
}
}
-// ==================== 触发 OTA 中继 ====================
+// ==================== 触发 OTA 中继 (真实状态驱动) ====================
async function triggerRelay() {
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
showToast('正在触发 OTA 中继', 'success');
- document.getElementById('stage1').className = 'stage active';
- document.getElementById('statRelayState').textContent = '进行中';
-
try {
- const response = await fetch(`${getBaseUrl()}/ota/relay`, {
- method: 'POST',
- signal: AbortSignal.timeout(5000)
+ var resp = await fetch(getBaseUrl() + '/ota/relay', {
+ method: 'POST', signal: AbortSignal.timeout(5000)
});
-
- if (response.ok) {
- addLog('I', 'OTA', 'OTA 中继已触发,正在等待 GD32 响应...');
-
- setTimeout(() => {
- document.getElementById('stage1').className = 'stage completed';
- document.getElementById('stage2').className = 'stage active';
- }, 2000);
-
- setTimeout(() => {
- document.getElementById('stage2').className = 'stage completed';
- document.getElementById('stage3').className = 'stage active';
- }, 4000);
-
- setTimeout(() => {
- document.getElementById('stage3').className = 'stage completed';
- document.getElementById('stage4').className = 'stage active';
- }, 6000);
-
- setTimeout(() => {
- document.getElementById('stage4').className = 'stage completed';
- document.getElementById('statRelayState').textContent = '完成';
- addLog('I', 'OTA', '=== OTA 中继完成 ===');
- showToast('OTA 流程完成!', 'success');
- }, 8000);
-
+ if (resp.ok) {
+ addLog('I', 'OTA', 'OTA 中继已触发,等待 GD32 响应...');
+ // 不再使用假的 setTimeout 推进阶段。
+ // pollStatus 的 updateSystemStatusUi 会从 ota.relay_state
+ // 自动更新 stage flow 和 header 状态。
+ showToast('中继已触发,等待设备响应', 'success');
} else {
- addLog('E', 'OTA', `触发失败: HTTP ${response.status}`);
+ addLog('E', 'OTA', '触发失败: HTTP ' + resp.status);
showToast('触发失败', 'error');
}
} catch (e) {
- addLog('E', 'OTA', `触发失败: ${e.message}`);
+ addLog('E', 'OTA', '触发失败: ' + e.message);
showToast('触发失败', 'error');
- document.getElementById('statRelayState').textContent = '失败';
}
}
// ==================== 轮询状态 ====================
async function pollStatus() {
try {
- const response = await fetch(`${getBaseUrl()}/system/status`, {
+ var resp = await fetch(getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(3000)
});
- if (response.ok) {
- const data = await response.json();
+ if (resp.ok) {
+ var data = await resp.json();
updateSystemStatusUi(data);
}
} catch (e) {
updateConnectionUi(false, '未连接');
+ applyDisconnectedRuntimeState();
}
}
-
setInterval(pollStatus, 1000);
-// ==================== ESP32 实时日志拉取 ====================
+// ==================== ESP32 实时日志拉取 (增量) ====================
+var lastLogTimestamp = 0;
async function pollLogs() {
try {
- const response = await fetch(`${getBaseUrl()}/logs`, {
+ var resp = await fetch(getBaseUrl() + '/logs', {
signal: AbortSignal.timeout(2000)
});
- if (!response.ok) return;
- const logs = await response.json();
+ if (!resp.ok) return;
+ var logs = await resp.json();
if (!Array.isArray(logs) || logs.length === 0) return;
- // 解析 [I][WIFI] xxx 格式
- const levelMap = { 'E': 'E', 'W': 'W', 'I': 'I', 'D': 'D' };
- logs.forEach(line => {
- // 格式: [I][WIFI] message 或 [E][OTA] message
- const m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
+ var levelMap = { E: 'E', W: 'W', I: 'I', D: 'D' };
+ logs.forEach(function (line) {
+ var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
if (m) {
addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd());
} else {
addLog('I', 'ESP32', line.trimEnd());
}
});
- } catch (e) {
- // 静默失败,不打扰用户
- }
+ } catch (e) { /* 静默 */ }
}
-
setInterval(pollLogs, 1000);
-setTimeout(() => addLog('I', '系统', '上位机初始化完成,等待固件文件...'), 500);
+setTimeout(function () {
+ onTargetChipChange();
+ updateUploadButtonLabel();
+ updateFileHint();
+ updateActionHint();
+ updateCanHint();
+ addLog('I', '系统', '控制台初始化完成,等待固件文件...');
+ updateSpiHint();
+}, 500);
diff --git a/tools/ble_ota.html b/tools/ble_ota.html
index 2e0835b..640db87 100644
--- a/tools/ble_ota.html
+++ b/tools/ble_ota.html
@@ -3,249 +3,469 @@
这个页面专门走 BLE GATT,不依赖 WiFi。它能扫描并连接 ESP32-OTA-RELAY,读取版本,订阅通知,把固件按协议分包写入 DATA_IN,并在完成后自动向 CTRL 写入对应命令:GD32 = 0xF7,ESP32 = 0xF8。
-