From e014ca7704bbf9d2009b4816b7ffdd44274c0714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Tue, 26 May 2026 02:44:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(ota):=20=E5=AE=9E=E7=8E=B0GD32=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=8F=A1=E6=89=8B=E8=AE=A4=E8=AF=81=E5=92=8COTA?= =?UTF-8?q?=E4=B8=AD=E7=BB=A7=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新固件版本从0.2.0到0.2.7 - 替换默认密钥为实际密钥值 - 添加GD32芯片UID运行时获取机制 - 实现AES-128加密验证challenge响应 - 集成NVS持久化存储OTA元数据 - 改进数据包传输可靠性,添加ACK确认和重试机制 - 新增结构化错误响应解析功能 - 实现GD32应用通知进入Bootloader流程 - 添加详细函数文档注释 refactor(ble): 优化BLE OTA服务端代码结构 - 为所有关键函数添加详细的Doxygen注释 - 改进固件大小验证逻辑,支持4字节对齐计算 - 优化数据块接收和处理流程 --- .vscode/settings.json | 2 +- main/include/app_config.h | 12 +- main/monitor/debug_log.cpp | 5 + main/monitor/health_monitor.cpp | 6 + main/relay/ota_relay.cpp | 380 +++++++++-- main/relay/ota_relay.h | 5 +- main/server/ble_ota.cpp | 65 +- main/server/wifi_ota.cpp | 270 ++++++-- main/transport/protocol.cpp | 40 +- main/transport/spi_master.cpp | 314 +++++++-- main/transport/spi_master.h | 30 +- tools/app.js | 817 ++++++++++++++--------- tools/ble_ota.html | 880 ++++++++++++++++--------- tools/index.html | 458 +++++++------ tools/style.css | 1092 ++++++++++++------------------- 15 files changed, 2648 insertions(+), 1728 deletions(-) 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 @@ -ESP32 BLE OTA 刷写台 +ESP32 BLE OTA 控制台
-
-
-
BLE • OTA • Dual Target
-

ESP32 Web Bluetooth 刷写台

-

这个页面专门走 BLE GATT,不依赖 WiFi。它能扫描并连接 ESP32-OTA-RELAY,读取版本,订阅通知,把固件按协议分包写入 DATA_IN,并在完成后自动向 CTRL 写入对应命令:GD32 = 0xF7,ESP32 = 0xF8。

-
- - - - - 回 WiFi 页面 -
-
-
GD32 / ESP32双目标 BLE 刷写
-
480B默认 Payload
-
一键刷写写入完成后自动触发
-
+
+
+ + ESP32 BLE OTA 控制台
-
-
连接状态
未连接
offline
-
设备名称
-
-
-
GATT/MTU 提示
未就绪
浏览器决定
-
版本
未读取
notify off
+
+ 未连接 + - + - + 未读取
-
+ -
-
-

刷写控制台

-
- - -
-
-
- - -
-
- - -
-
- - +
+
+
+
🔗 蓝牙连接
+
+
+ 本页走 BLE GATT,不依赖 WiFi。适合现场、移动端或无法直接访问设备 IP 的场景。支持 GD32 中继 与 ESP32 自升级 两种目标。 +
+
+ + +
+
+ + 回 WiFi 页面 +
-
- 当前目标:GD32。页面会使用 cmd=0x00 发送数据,最后自动写 CTRL=0xF7。协议格式:cmd(1) + seq(2) + total_size(4) + payload + crc占位4。 -
-
-
-
- 等待选择文件 - 0% + +
+
🎯 升级目标
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ 当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。协议格式:cmd(1) + seq(2) + total_size(4) + payload + crc占位4。 +
-
- - - - + +
+
📁 固件文件
+
+
+ +
+
📤
+

点击或拖拽文件到此处

+

支持 .bin 和 .hex 格式

+
+ + +
+
+ 📄 - + +
+
+
类型-
+
大小0 B
+
总包数0
+
已写包0
+
+
+
+ +
- -
-

文件与进度

-
-
文件名
-
-
文件大小
0 B
-
总包数
0
-
已写包
0
+
+
+
📡 设备状态
+
+
+
连接状态
offline
+
GATT / MTU
未就绪
+
版本
未读取
+
OTA 状态
待机
+
+
-
- -
暂无通知
+ +
+
📊 OTA 进度
+
+
+
+
+ 等待选择文件 + 0% +
+
+
+
+ +
+
🖥️ 运行日志
+
+
+
-
- -
-

运行日志

-
-
+
diff --git a/tools/index.html b/tools/index.html index 869fa49..9d27d9e 100644 --- a/tools/index.html +++ b/tools/index.html @@ -3,277 +3,265 @@ -ESP32 OTA 中继系统 - 上位机 +ESP32 OTA 中继控制台
- -
-
-
📡
-
-

ESP32 OTA 中继系统

-

GD32 固件空中升级管理平台

-
+ + +
+
+ + ESP32 OTA 中继控制台
-
-
- - 未连接 -
-
- - 系统正常 -
-
- v-- -
+
+ 未连接 + GD32 + v-- + -- + -- + 待机 + 运行 --
- -
- -
- -
-
-
-
🔗
- 连接配置 -
-
-
- -
- - -
-
-
- - -
- - - 📶 打开 BLE 刷写页面 - -
+ +
- + +
+ +
-
-
-
📁
- 固件文件 -
-
- -
-
📤
-

点击或拖拽文件到此处

-

支持 .hex 和 .bin 格式

-
- - -
-
-
- 📄 - - +
🔗 设备连接
+
+
+ +
+ +
-
-
-
类型: -
-
大小: -
-
数据包: -
-
-
- -
-
-
-
-
- 准备上传... - 0% -
-
- -
- - -
- -
- - -
-
-
-
📊
- OTA 流程阶段 -
-
-
-
-
1️⃣
-
握手认证
-
-
-
2️⃣
-
传输准备
-
-
-
3️⃣
-
数据转发
-
-
-
4️⃣
-
完成跳转
-
-
-
-
- - -
- -
-
-
-
📈
- 实时状态 - 运行: -- -
-
-
-
-
--
-
芯片温度
-
-
-
--
-
WiFi 状态
-
-
-
--
-
信号强度
-
-
-
0
-
空闲堆
-
-
-
0
-
最低空闲堆
-
-
-
0%
-
上传进度
-
-
-
待机
-
中继状态
+
+ + 等待连接
- +
-
-
-
📨
- CAN 帧发送 -
-
-
-
- - + +
-
- - +
+ 将固件写入 ESP32 Flash,由 ESP32 通过 SPI 中继到 GD32 完成刷写。
-
- - -
-
- - -
-
-
- - -
-
- -
-
-
-
🖥️
- 调试日志 - UDP:6666 -
-
+ +
+
📁 固件文件
-
- 实时日志输出 -
- - - - +
+
📤
+

点击或拖拽文件到此处

+

支持 .hex 和 .bin 格式

+
+ + +
+
+ 📄 - + +
+
+
类型-
+
大小-
+
数据包 (8KB/包)-
-
-
- [--:--:--] - I - [系统] - 等待上位机连接... +
未选择固件。请选择 .bin 或 .hex 文件后再执行上传。
+ +
+
+
+ 准备上传... + 0%
-
+ + +
+
⚡ 执行操作
+
+ +
执行顺序:先测试连接,再确认目标芯片,最后上传固件。GD32 模式上传完成后,再触发中继。
+
+ + +
+ + + 切换到 BLE 蓝牙刷写 + +
+
+ +
+ + +
+ + +
+
📡 设备状态
+
+
+
+
离线
+
连接状态
+
+
+
GD32
+
当前模式
+
+
+
0%
+
OTA 进度
+
+
+
+
+ + +
+
📊 运行指标
+
+
+
温度
--
+
WiFi / IP
--
+
信号强度
--
+
空闲堆
--
+
最低空闲堆
--
+
固件版本
--
+
+
+
+ + +
+
🔀 OTA 流程阶段
+
+
+
1
握手认证
+
2
传输准备
+
3
数据转发
+
4
完成跳转
+
+
+
+ + +
+
📨 CAN 帧发送
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
输入十六进制数据后会自动检查字节数是否与 DLC 一致。
+
+ + +
+ +
+
+ + +
+
⚡ SPI 发送
+
+
+ + + + ms + + +
+
type(hex) + payload → 自动构建 SOF+LEN+CRC 帧
+ +
+
+ + +
+
+ 🖥️ 调试日志 + UDP :6666 +
+
+
+
+ + + + +
+ +
+
+
[--:--:--]I[系统]等待上位机连接...
+
+
+ 共 0 条 + +
+
+
+
- +
- ✓ - 操作成功 + ✓ + 操作成功
- + diff --git a/tools/style.css b/tools/style.css index f6e495c..29bbc7e 100644 --- a/tools/style.css +++ b/tools/style.css @@ -1,680 +1,412 @@ - :root { - --bg-primary: #0a0e1a; - --bg-secondary: #111827; - --bg-card: #1a2235; - --bg-card-hover: #1f2a40; - --accent: #00d4ff; - --accent-glow: rgba(0, 212, 255, 0.3); - --accent-secondary: #7c3aed; - --success: #10b981; - --warning: #f59e0b; - --danger: #ef4444; - --text-primary: #f1f5f9; - --text-secondary: #94a3b8; - --text-muted: #64748b; - --border: #1e293b; - --gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - --gradient-2: linear-gradient(135deg, #00d4ff 0%, #7c3aed 100%); - --gradient-3: linear-gradient(135deg, #0a0e1a 0%, #1a2235 100%); - } - - * { box-sizing: border-box; margin: 0; padding: 0; } - - body { - font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; - overflow-x: hidden; - } - - body::before { - content: ''; - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background: - radial-gradient(ellipse 80% 50% at 20% 20%, rgba(0, 212, 255, 0.08) 0%, transparent 50%), - radial-gradient(ellipse 60% 40% at 80% 80%, rgba(124, 58, 237, 0.08) 0%, transparent 50%); - pointer-events: none; - z-index: 0; - } - - .container { - position: relative; - z-index: 1; - max-width: 1400px; - margin: 0 auto; - padding: 30px 40px; - } - - .header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 40px; - padding-bottom: 24px; - border-bottom: 1px solid var(--border); - } - - .logo-section { - display: flex; - align-items: center; - gap: 16px; - } - - .logo-icon { - width: 56px; - height: 56px; - background: var(--gradient-2); - border-radius: 16px; - display: flex; - align-items: center; - justify-content: center; - font-size: 28px; - box-shadow: 0 8px 32px var(--accent-glow); - } - - .logo-text h1 { - font-size: 26px; - font-weight: 700; - background: var(--gradient-2); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .logo-text p { - font-size: 13px; - color: var(--text-muted); - margin-top: 4px; - } - - .status-badges { - display: flex; - gap: 12px; - } - - .badge { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 20px; - font-size: 13px; - transition: all 0.3s; - } - - .badge .dot { - width: 8px; - height: 8px; - border-radius: 50%; - animation: pulse 2s infinite; - } - - .badge.connected .dot { background: var(--success); } - .badge.disconnected .dot { background: var(--danger); animation: none; } - .badge.warning .dot { background: var(--warning); } - - @keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.2); } - } - - .main-grid { - display: grid; - grid-template-columns: 420px 1fr; - gap: 24px; - } - - .left-panel { - display: flex; - flex-direction: column; - gap: 20px; - } - - .card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 20px; - padding: 24px; - transition: all 0.3s; - } - - .card:hover { - border-color: rgba(0, 212, 255, 0.2); - box-shadow: 0 0 40px rgba(0, 212, 255, 0.05); - } - - .card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; - } - - .card-title { - display: flex; - align-items: center; - gap: 10px; - font-size: 16px; - font-weight: 600; - } - - .card-title .icon { - width: 36px; - height: 36px; - background: var(--gradient-2); - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - } - - .connection-card .form-group { - margin-bottom: 16px; - } - - .form-group label { - display: block; - font-size: 12px; - color: var(--text-muted); - margin-bottom: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .input-group { - display: flex; - gap: 8px; - } - - input[type="text"] { - flex: 1; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 10px; - padding: 12px 16px; - color: var(--text-primary); - font-size: 14px; - outline: none; - transition: all 0.3s; - } - - input[type="text"]:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); - } - - .btn { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 12px 20px; - border: none; - border-radius: 10px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.3s; - } - - .btn-primary { - background: var(--gradient-2); - color: white; - box-shadow: 0 4px 20px var(--accent-glow); - } - - .btn-primary:hover { - transform: translateY(-2px); - box-shadow: 0 8px 30px var(--accent-glow); - } - - .btn-secondary { - background: var(--bg-secondary); - color: var(--text-secondary); - border: 1px solid var(--border); - } - - .btn-secondary:hover { - background: var(--bg-card-hover); - color: var(--text-primary); - } - - .btn-success { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; - } - - .btn-danger { - background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - color: white; - } - - .btn:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none !important; - } - - .upload-zone { - border: 2px dashed var(--border); - border-radius: 16px; - padding: 40px 20px; - text-align: center; - cursor: pointer; - transition: all 0.3s; - position: relative; - overflow: hidden; - } - - .upload-zone::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(135deg, rgba(0, 212, 255, 0.05) 0%, rgba(124, 58, 237, 0.05) 100%); - opacity: 0; - transition: opacity 0.3s; - } - - .upload-zone:hover::before, - .upload-zone.dragover::before { - opacity: 1; - } - - .upload-zone:hover { - border-color: var(--accent); - } - - .upload-icon { - width: 64px; - height: 64px; - margin: 0 auto 16px; - background: var(--bg-secondary); - border-radius: 16px; - display: flex; - align-items: center; - justify-content: center; - font-size: 28px; - } - - .upload-zone h3 { - font-size: 16px; - margin-bottom: 8px; - } - - .upload-zone p { - font-size: 13px; - color: var(--text-muted); - } - - .file-input { - display: none; - } - - .file-info { - background: var(--bg-secondary); - border-radius: 12px; - padding: 16px; - display: none; - } - - .file-info.visible { - display: block; - } - - .file-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - } - - .file-name { - display: flex; - align-items: center; - gap: 10px; - font-size: 14px; - font-weight: 500; - } - - .file-name .icon { - font-size: 20px; - } - - .file-size { - font-size: 12px; - color: var(--text-muted); - } - - .file-meta { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - } - - .meta-item { - background: var(--bg-card); - padding: 8px 12px; - border-radius: 8px; - font-size: 12px; - } - - .meta-item span { - color: var(--text-muted); - } - - .progress-section { - margin-top: 20px; - display: none; - } - - .progress-section.visible { - display: block; - } - - .progress-bar-container { - background: var(--bg-secondary); - border-radius: 10px; - height: 12px; - overflow: hidden; - margin-bottom: 12px; - } - - .progress-bar { - height: 100%; - background: var(--gradient-2); - border-radius: 10px; - width: 0%; - transition: width 0.3s ease; - position: relative; - } - - .progress-bar::after { - content: ''; - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%); - animation: shimmer 2s infinite; - } - - @keyframes shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } - } - - .progress-info { - display: flex; - justify-content: space-between; - font-size: 12px; - color: var(--text-muted); - } - - .action-buttons { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - margin-top: 20px; - } - - .right-panel { - display: flex; - flex-direction: column; - gap: 20px; - } - - .status-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; - } - - .status-item { - background: var(--bg-secondary); - border-radius: 12px; - padding: 16px; - text-align: center; - } - - .status-item .value { - font-size: 24px; - font-weight: 700; - background: var(--gradient-2); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .status-item .label { - font-size: 11px; - color: var(--text-muted); - margin-top: 4px; - text-transform: uppercase; - } - - .log-panel { - flex: 1; - display: flex; - flex-direction: column; - min-height: 500px; - } - - .log-panel .card-body { - flex: 1; - display: flex; - flex-direction: column; - } - - .log-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - } - - .log-filters { - display: flex; - gap: 8px; - } - - .filter-btn { - padding: 6px 12px; - border-radius: 20px; - border: 1px solid var(--border); - background: transparent; - color: var(--text-muted); - font-size: 12px; - cursor: pointer; - transition: all 0.3s; - } - - .filter-btn:hover, - .filter-btn.active { - background: var(--accent); - color: white; - border-color: var(--accent); - } - - .log-content { - flex: 1; - background: var(--bg-secondary); - border-radius: 12px; - margin: 16px; - padding: 16px; - overflow-y: auto; - max-height: 400px; - font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; - font-size: 12px; - line-height: 1.6; - } - - .log-content::-webkit-scrollbar { - width: 6px; - } - - .log-content::-webkit-scrollbar-track { - background: var(--bg-secondary); - border-radius: 3px; - } - - .log-content::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 3px; - } - - .log-entry { - padding: 4px 0; - border-bottom: 1px solid rgba(255,255,255,0.03); - display: flex; - gap: 8px; - } - - .log-time { color: var(--text-muted); } - .log-level { - font-weight: 600; - min-width: 24px; - } - .log-level.E { color: var(--danger); } - .log-level.W { color: var(--warning); } - .log-level.I { color: var(--accent); } - .log-level.D { color: var(--text-muted); } - .log-tag { color: var(--accent-secondary); } - .log-msg { color: var(--text-primary); } - - .log-footer { - padding: 12px 20px; - border-top: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; - } - - .log-stats { - font-size: 12px; - color: var(--text-muted); - } - - .stage-indicator { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 0; - } - - .stage { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - position: relative; - flex: 1; - } - - .stage:not(:last-child)::after { - content: ''; - position: absolute; - top: 20px; - left: calc(50% + 24px); - width: calc(100% - 48px); - height: 2px; - background: var(--border); - } - - .stage.active:not(:last-child)::after, - .stage.completed:not(:last-child)::after { - background: var(--accent); - } - - .stage-icon { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--bg-secondary); - border: 2px solid var(--border); - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - transition: all 0.3s; - position: relative; - z-index: 1; - } - - .stage.active .stage-icon { - background: var(--accent); - border-color: var(--accent); - color: white; - box-shadow: 0 0 20px var(--accent-glow); - } - - .stage.completed .stage-icon { - background: var(--success); - border-color: var(--success); - color: white; - } - - .stage-label { - font-size: 11px; - color: var(--text-muted); - text-align: center; - } - - .stage.active .stage-label, - .stage.completed .stage-label { - color: var(--text-primary); - } - - .toast { - position: fixed; - bottom: 30px; - right: 30px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px 24px; - display: flex; - align-items: center; - gap: 12px; - box-shadow: 0 10px 40px rgba(0,0,0,0.5); - transform: translateY(100px); - opacity: 0; - transition: all 0.3s; - z-index: 1000; - } - - .toast.show { - transform: translateY(0); - opacity: 1; - } - - .toast.success { border-left: 4px solid var(--success); } - .toast.error { border-left: 4px solid var(--danger); } - .toast.warning { border-left: 4px solid var(--warning); } - - .toast-icon { - font-size: 24px; - } - - .toast-message { - font-size: 14px; - } - - @keyframes fadeIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } - } - - .card { - animation: fadeIn 0.5s ease forwards; - } - - .left-panel .card:nth-child(1) { animation-delay: 0.1s; } - .left-panel .card:nth-child(2) { animation-delay: 0.2s; } - .left-panel .card:nth-child(3) { animation-delay: 0.3s; } - - .right-panel .card:nth-child(1) { animation-delay: 0.15s; } - .right-panel .card:nth-child(2) { animation-delay: 0.25s; } +/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */ + +:root { + --bg-root: #090e15; + --bg-panel: #0f161f; + --bg-card: #141d28; + --bg-elevated: #1a2432; + --bg-input: #0d131b; + --border: #1d2a3a; + --border-focus: #22d3ee; + --text: #e2e8f0; + --text-dim: #7b8fa8; + --text-label: #5a6d82; + --accent: #22d3ee; + --accent-dim: rgba(34,211,238,.15); + --green: #22c55e; + --amber: #f59e0b; + --red: #ef4444; + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + --font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif; + --font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace; + --gap: 16px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background: var(--bg-root); + color: var(--text); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* subtle noise-grid background */ +body::before { + content: ""; + position: fixed; inset: 0; z-index: 0; pointer-events: none; + background-image: + linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 70%); +} + +.container { + position: relative; z-index: 1; + max-width: 1480px; margin: 0 auto; padding: 24px 32px 40px; +} + +/* === HEADER — compact status bar === */ +.header-bar { + display: flex; align-items: center; gap: 12px; + padding: 14px 20px; margin-bottom: 24px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + flex-wrap: wrap; +} +.header-brand { + display: flex; align-items: center; gap: 10px; + padding-right: 20px; border-right: 1px solid var(--border); + white-space: nowrap; +} +.header-brand .dot-mark { + width: 10px; height: 10px; border-radius: 50%; background: var(--accent); + box-shadow: 0 0 8px var(--accent-dim); + flex-shrink: 0; +} +.header-brand strong { font-size: 15px; font-weight: 700; } +.header-status { + display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; +} +.pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; + background: var(--bg-elevated); border: 1px solid var(--border); + white-space: nowrap; +} +.pill .indicator { + width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; +} +.pill.online .indicator { background: var(--green); } +.pill.offline .indicator { background: var(--red); } +.pill.processing .indicator { background: var(--amber); animation: blink 1s infinite; } +.pill.idle .indicator { background: var(--text-dim); } +@keyframes blink { 50% { opacity: .35; } } + +/* === MAIN LAYOUT === */ +.main-layout { + display: grid; + grid-template-columns: 380px 1fr; + gap: var(--gap); + align-items: start; +} + +/* === CARDS === */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.card + .card { margin-top: var(--gap); } +.card-head { + display: flex; align-items: center; gap: 8px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + font-size: 13px; font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; letter-spacing: .06em; +} +.card-head .ico { font-size: 15px; } +.card-body { padding: 16px 18px; } + +/* === FORM ELEMENTS === */ +.form-row { margin-bottom: 14px; } +.form-row:last-child { margin-bottom: 0; } +.form-label { + display: block; font-size: 11px; color: var(--text-label); + text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px; +} +.input, select, textarea { + width: 100%; padding: 10px 12px; font-size: 13px; font-family: inherit; + background: var(--bg-input); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius-sm); + outline: none; transition: border-color .15s; +} +.input:focus, select:focus, textarea:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 3px var(--accent-dim); +} +select { cursor: pointer; } +select option { background: var(--bg-panel); color: var(--text); } +.input-group { display: flex; gap: 8px; } +.input-group .input { flex: 1; } + +/* === BUTTONS === */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 9px 16px; border: none; border-radius: var(--radius-sm); + font-size: 13px; font-weight: 600; cursor: pointer; + transition: opacity .15s, transform .15s; + font-family: inherit; white-space: nowrap; +} +.btn:hover:not(:disabled) { transform: translateY(-1px); } +.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; } +.btn-primary { background: var(--accent); color: #07121c; } +.btn-primary:hover:not(:disabled) { opacity: .88; } +.btn-success { background: var(--green); color: #051a0c; } +.btn-danger { background: var(--red); color: #fff; } +.btn-outline { + background: transparent; color: var(--text-dim); + border: 1px solid var(--border); +} +.btn-outline:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); } +.btn-ghost { + background: transparent; color: var(--text-dim); +} +.btn-ghost:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); } +.btn-block { width: 100%; justify-content: center; } +.btn-sm { padding: 6px 12px; font-size: 12px; } + +/* === CONNECTION STATUS ROW === */ +.conn-indicator { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border); + font-size: 13px; font-weight: 500; +} +.conn-indicator .light { + width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; +} +.conn-indicator.ok .light { background: var(--green); } +.conn-indicator.err .light { background: var(--red); } +.conn-indicator.warn .light { background: var(--amber); } + +.helper-note { + margin-top: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); + font-size: 12px; + line-height: 1.6; + color: var(--text-dim); +} +.helper-note.ok { border-color: rgba(34,197,94,.25); color: #b8f5cb; } +.helper-note.warn { border-color: rgba(245,158,11,.25); color: #fbd38d; } +.helper-note.err { border-color: rgba(239,68,68,.25); color: #fecaca; } +.helper-note code { color: #d7fbff; font-family: var(--font-mono); } + +/* === UPLOAD ZONE === */ +.upload-zone { + border: 2px dashed var(--border); border-radius: var(--radius); + padding: 28px 16px; text-align: center; cursor: pointer; + transition: border-color .2s, background .2s; +} +.upload-zone:hover, +.upload-zone.dragover { border-color: var(--accent); background: var(--accent-dim); } +.upload-zone .upload-ico { font-size: 32px; margin-bottom: 10px; } +.upload-zone h4 { font-size: 14px; margin-bottom: 4px; } +.upload-zone p { font-size: 12px; color: var(--text-dim); } + +/* === FILE INFO === */ +.file-info { + display: none; margin-top: 12px; + background: var(--bg-elevated); border-radius: var(--radius-sm); padding: 12px 14px; +} +.file-info.show { display: block; } +.file-info-row { display: flex; justify-content: space-between; align-items: center; } +.file-info-row + .file-info-row { margin-top: 10px; } +.file-info-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; +} +.file-meta-item { + background: var(--bg-card); padding: 8px 10px; border-radius: 6px; + font-size: 12px; +} +.file-meta-item span { color: var(--text-label); display: block; font-size: 10px; margin-bottom: 2px; } +.file-meta-item b { font-weight: 600; } + +/* === PROGRESS === */ +.progress-wrap { margin-top: 14px; display: none; } +.progress-wrap.show { display: block; } +.progress-track { + width: 100%; height: 8px; border-radius: 20px; + background: var(--bg-input); overflow: hidden; +} +.progress-fill { + height: 100%; border-radius: 20px; background: var(--accent); + width: 0%; transition: width .25s ease; + position: relative; +} +.progress-fill::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.25) 50%, transparent 100%); + animation: sweep 1.8s linear infinite; +} +@keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } +.progress-text { + display: flex; justify-content: space-between; margin-top: 6px; + font-size: 12px; color: var(--text-dim); +} + +/* === BUTTON ROW === */ +.btn-row { display: flex; gap: 10px; margin-top: 16px; } +.btn-row .btn { flex: 1; } + +/* === METRIC GRID === */ +.metric-grid { + display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; +} +.metric { + background: var(--bg-elevated); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px 14px; +} +.metric .k { + font-size: 10px; color: var(--text-label); text-transform: uppercase; + letter-spacing: .1em; margin-bottom: 4px; +} +.metric .v { + font-size: 20px; font-weight: 700; word-break: break-word; +} +.metric .v.highlight { color: var(--accent); } + +/* === PRIMARY STATUS BADGES (large) === */ +.badge-grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; +} +.badge-stat { + background: var(--bg-elevated); border: 1px solid var(--border); + border-radius: var(--radius); padding: 14px 16px; text-align: center; +} +.badge-stat .v { + font-size: 22px; font-weight: 700; +} +.badge-stat .v.accent { color: var(--accent); } +.badge-stat .v.ok { color: var(--green); } +.badge-stat .v.warn { color: var(--amber); } +.badge-stat .v.err { color: var(--red); } +.badge-stat .k { + font-size: 10px; color: var(--text-label); text-transform: uppercase; + letter-spacing: .1em; margin-top: 2px; +} + +/* === OTA STAGE FLOW === */ +.stage-flow { + display: flex; align-items: flex-start; gap: 0; padding: 8px 0; +} +.stage-node { + flex: 1; display: flex; flex-direction: column; align-items: center; + gap: 6px; position: relative; +} +.stage-node:not(:last-child)::after { + content: ""; position: absolute; top: 18px; left: calc(50% + 18px); + width: calc(100% - 36px); height: 2px; + background: var(--border); transition: background .3s; +} +.stage-node.done:not(:last-child)::after, +.stage-node.live:not(:last-child)::after { background: var(--accent); } +.stage-dot { + width: 36px; height: 36px; border-radius: 50%; + background: var(--bg-input); border: 2px solid var(--border); + display: flex; align-items: center; justify-content: center; + font-size: 16px; z-index: 1; transition: all .3s; +} +.stage-node.live .stage-dot { + background: var(--accent); border-color: var(--accent); color: #07121c; + box-shadow: 0 0 12px var(--accent-dim); +} +.stage-node.done .stage-dot { + background: var(--green); border-color: var(--green); color: #fff; +} +.stage-node.err .stage-dot { + background: var(--red); border-color: var(--red); color: #fff; +} +.stage-label { + font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3; +} +.stage-node.live .stage-label, +.stage-node.done .stage-label, +.stage-node.err .stage-label { color: var(--text); } + +/* === LOG PANEL === */ +.log-panel { display: flex; flex-direction: column; min-height: 380px; } +.log-panel .card-body { flex: 1; display: flex; flex-direction: column; padding: 0; } +.log-toolbar { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 16px; border-bottom: 1px solid var(--border); + gap: 10px; flex-wrap: wrap; +} +.log-filters { display: flex; gap: 6px; } +.log-filter { + padding: 4px 10px; border-radius: 14px; font-size: 11px; font-weight: 500; + border: 1px solid var(--border); background: transparent; color: var(--text-dim); + cursor: pointer; transition: all .15s; +} +.log-filter.active { background: var(--accent); color: #07121c; border-color: var(--accent); } +.log-filter:hover:not(.active) { border-color: var(--text-dim); } + +.log-scroll { + flex: 1; overflow-y: auto; padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; line-height: 1.7; + max-height: 420px; +} +.log-scroll::-webkit-scrollbar { width: 5px; } +.log-scroll::-webkit-scrollbar-track { background: transparent; } +.log-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +.log-line { + display: flex; gap: 8px; padding: 3px 0; + border-bottom: 1px solid rgba(255,255,255,.02); +} +.log-line .lt { color: var(--text-label); flex-shrink: 0; } +.log-line .lv { + font-weight: 600; min-width: 20px; flex-shrink: 0; +} +.log-line .lv.E { color: var(--red); } +.log-line .lv.W { color: var(--amber); } +.log-line .lv.I { color: var(--accent); } +.log-line .lv.D { color: var(--text-label); } +.log-line .lg { color: #a78bfa; flex-shrink: 0; } +.log-line .lm { color: var(--text); word-break: break-all; } + +.log-foot { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 16px; border-top: 1px solid var(--border); + font-size: 11px; color: var(--text-label); +} + +/* === CAN PANEL === */ +.can-grid { + display: grid; grid-template-columns: 70px 1fr 60px 1fr; gap: 10px; +} +.spi-grid { + display: grid; grid-template-columns: 80px 1fr 80px 80px; gap: 10px; +} + +/* === TOAST === */ +.toast { + position: fixed; bottom: 28px; right: 28px; z-index: 2000; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 14px 20px; + display: flex; align-items: center; gap: 10px; + transform: translateY(120px); opacity: 0; + transition: transform .25s ease, opacity .25s ease; + box-shadow: 0 8px 30px rgba(0,0,0,.55); +} +.toast.show { transform: translateY(0); opacity: 1; } +.toast.ok { border-left: 3px solid var(--green); } +.toast.err { border-left: 3px solid var(--red); } +.toast.warn { border-left: 3px solid var(--amber); } +.toast .toast-ico { font-size: 20px; } + +/* === RESPONSIVE === */ +@media (max-width: 1100px) { + .main-layout { grid-template-columns: 1fr; } + .badge-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 640px) { + .container { padding: 16px; } + .header-bar { flex-direction: column; align-items: flex-start; } + .badge-grid, .metric-grid { grid-template-columns: 1fr; } + .can-grid { grid-template-columns: 1fr 1fr; } + .btn-row { flex-direction: column; } +}