diff --git a/main/core/main.cpp b/main/core/main.cpp index 37e4c40..cfd9861 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -1,8 +1,6 @@ /* * ESP32-C3FH4 OTA 中继 - 程序入口 - * 四个 FreeRTOS 任务:System Monitor / OTA Relay / WiFi Server / BLE GATT - * - * 新增:周期性状态监控任务,每 10 秒打印系统状态 + * 三个 FreeRTOS 任务:OTA Relay / WiFi Server / BLE GATT */ #include #include "freertos/FreeRTOS.h" @@ -20,12 +18,6 @@ #include "transport/protocol.h" #include "monitor/debug_log.h" -/** - * @brief 状态监控任务 - * @param pvParameters - */ -static void status_monitor_task(void *pvParameters); - /** * @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。 */ @@ -130,22 +122,6 @@ extern "C" void app_main(void) DEBUG_INFO("MAIN", "BLE OTA 任务已创建"); } - // 状态监控任务(低优先级,周期性打印系统状态) - xRet = xTaskCreatePinnedToCore( - status_monitor_task, - "status_mon", - 4096, - NULL, - configMAX_PRIORITIES - 5, - NULL, - 0 - ); - if (xRet != pdPASS) { - DEBUG_ERR("MAIN", "创建 status_monitor_task 失败"); - } else { - DEBUG_INFO("MAIN", "状态监控任务已创建"); - } - DEBUG_INFO("MAIN", "========================================"); DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中"); DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)", @@ -153,83 +129,3 @@ extern "C" void app_main(void) DEBUG_INFO("MAIN", "========================================"); } -// ============================================================ -// 状态监控任务 - 每 10 秒打印系统状态 -/** - * @brief 周期性打印系统运行状态,包括内存、WiFi 和 OTA 中继信息。 - * @param pvParameters - */ -static void status_monitor_task(void *pvParameters) -{ - (void)pvParameters; - DEBUG_INFO("STAT", "状态监控任务启动"); - - uint32_t loop_count = 0; - - while (1) { - loop_count++; - - uint32_t free_heap = esp_get_free_heap_size(); - uint32_t min_free_heap = esp_get_minimum_free_heap_size(); - health_state_t health = health_get_current(); - relay_state_t relay = ota_relay_get_state(); - int8_t rssi = wifi_get_rssi(); - bool wifi_conn = wifi_is_connected(); - - // 打印状态头 - DEBUG_INFO("STAT", "========== 系统状态 #%lu ==========", (unsigned long)loop_count); - - // 内存状态 - DEBUG_INFO("STAT", "内存: 空闲=%lu 字节, 最低=%lu 字节", - (unsigned long)free_heap, (unsigned long)min_free_heap); - - // 健康状态 - const char *health_str; - switch (health) { - case SYS_NORMAL: health_str = "正常"; break; - case SYS_WARNING: health_str = "警告"; break; - case SYS_CRITICAL: health_str = "危险"; break; - case SYS_SAFE_SHUTDOWN: health_str = "安全关机"; break; - default: health_str = "未知"; break; - } - DEBUG_INFO("STAT", "健康状态: %s (%d)", health_str, health); - - // WiFi 状态 - if (wifi_conn) { - DEBUG_INFO("STAT", "WiFi: 已连接, RSSI=%d dBm", rssi); - } else { - DEBUG_INFO("STAT", "WiFi: 未连接"); - } - - // OTA 中继状态 - const char *relay_str; - switch (relay) { - case RELAY_IDLE: relay_str = "空闲"; break; - case RELAY_HANDSHAKE_IDLE: relay_str = "等待握手"; break; - case RELAY_HANDSHAKE_SENT: relay_str = "握手请求已发送"; break; - case RELAY_CHALLENGE_SENT: relay_str = "挑战已发送"; break; - case RELAY_CHALLENGE_RESPOND: relay_str = "收到握手响应"; break; - case RELAY_SESSION_KEY_SENT: relay_str = "会话密钥已发送"; break; - case RELAY_AUTH_COMPLETE: relay_str = "认证完成"; break; - case RELAY_SIZE_SENT: relay_str = "大小已发送"; break; - case RELAY_SIZE_ACK: relay_str = "大小确认"; break; - case RELAY_RECEIVING: relay_str = "接收中"; break; - case RELAY_RELAY_ACTIVE: relay_str = "转发中"; break; - case RELAY_PACKET_SENT: relay_str = "数据包已发送"; break; - case RELAY_PACKET_ACK_OK: relay_str = "ACK 正常"; break; - case RELAY_PACKET_ACK_RETRY: relay_str = "需要重试"; break; - case RELAY_RECEIVE_DONE: relay_str = "接收完成"; break; - case RELAY_TRANSFER_DONE: relay_str = "传输完成"; break; - case RELAY_FINISH_SENT: relay_str = "结束已发送"; break; - case RELAY_DONE: relay_str = "OTA 完成"; break; - case RELAY_ERROR: relay_str = "错误"; break; - default: relay_str = "未知"; break; - } - DEBUG_INFO("STAT", "OTA 中继: %s (%d)", relay_str, relay); - - DEBUG_INFO("STAT", "======================================"); - - // 每 10 秒打印一次 - vTaskDelay(pdMS_TO_TICKS(10000)); - } -} \ No newline at end of file diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 90a5c3e..eb4915c 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -30,8 +30,7 @@ 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; +static constexpr size_t GD32_CHALLENGE_RESPONSE_LEN = 36; /** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */ QueueHandle_t g_ota_dispatch_queue = NULL; @@ -149,54 +148,53 @@ static bool parse_and_verify_challenge_payload(const uint8_t *resp_payload, size { 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) { + if (resp_len != GD32_CHALLENGE_RESPONSE_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); + + /* + * GD32 响应格式 (36 bytes): + * [0..11] = UID (12 bytes) + * [12..19] = nonce_server[8:16] (8 bytes, nonce 后半段) + * [20..35] = encrypted_Ksession (16 bytes) = AES(K_master, K_session) + * + * 其中 K_session = AES(K_master, nonce_server) + */ + + const uint8_t *uid = resp_payload; + const uint8_t *nonce_frag = resp_payload + 12; + const uint8_t *encrypted_ksession = resp_payload + 20; + + // 校验 nonce 片段: 应与 nonce_server[8:16] 一致 + if (memcmp(nonce_frag, g_nonce_server + 8, 8) != 0) { + DEBUG_ERR("OTA", "CHALLENGE校验失败: nonce片段不匹配"); 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加密块失败"); + // 派生 K_session = AES(K_master, nonce_server) + uint8_t K_session[16] = {0}; + if (aes128_ecb_encrypt_block_local(MASTER_KEY, g_nonce_server, K_session) != 0) { + DEBUG_ERR("OTA", "CHALLENGE校验失败: 派生K_session失败"); return false; } -*/ - if (memcmp(cipher_blob, expected_nonce_block, sizeof(expected_nonce_block)) != 0) { - DEBUG_ERR("OTA", "CHALLENGE校验失败: nonce加密块不匹配"); + // 验证 encrypted_Ksession: AES(K_master, K_session) 应匹配 + uint8_t expected_encrypted[16] = {0}; + if (aes128_ecb_encrypt_block_local(MASTER_KEY, K_session, expected_encrypted) != 0) { + DEBUG_ERR("OTA", "CHALLENGE校验失败: 加密K_session失败"); 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解密失败"); + if (memcmp(encrypted_ksession, expected_encrypted, 16) != 0) { + DEBUG_ERR("OTA", "CHALLENGE校验失败: encrypted_Ksession不匹配"); 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); + // 校验通过,输出 K_session 和 UID + memcpy(out_k_session, K_session, 16); + memcpy(out_uid, uid, 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)"); @@ -372,8 +370,6 @@ esp_err_t meta_load(ota_meta_t *meta) if (nvs_get_u32(handle, "chunk_count", &val) == ESP_OK) meta->chunk_count = val; nvs_close(handle); - DEBUG_INFO("OTA", "元数据已加载: total=%u, stored=%u, seq=%u", - meta->total_size, meta->stored_size, meta->last_seq); return ESP_OK; } @@ -402,8 +398,6 @@ esp_err_t meta_save(const ota_meta_t *meta) if (err != ESP_OK) { DEBUG_ERR("OTA", "NVS commit 失败: %s", esp_err_to_name(err)); - } else { - DEBUG_INFO("OTA", "元数据已保存到 NVS"); } return err; } @@ -488,8 +482,12 @@ 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)); + /* 收到确认:可能来自 APP(即将跳转 BOOT)或 BOOT 本身(已在 BOOT)。 + * 如果来自 APP,GD32 需要复位并重新初始化 SPI 外设,需等 ~1-2s; + * 如果已在 BOOT,SPI 已经就绪,200ms 足够。 + * 等待 1000ms 给 GD32 足够时间完成系统复位和 Bootloader 初始化。 */ + DEBUG_INFO("OTA", "GD32 已确认进入 Bootloader,等待就绪"); + vTaskDelay(pdMS_TO_TICKS(1000)); } else { DEBUG_WARN("OTA", "GD32 APP 未确认进入 Bootloader,继续尝试直接握手: %s", esp_err_to_name(err)); } @@ -513,7 +511,7 @@ static ota_result_t send_handshake_req(void) &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 3000); log_resp_payload("HANDSHAKE_REQ", resp_type, resp_payload, resp_len); if (err == ESP_OK) { - err = wait_until_not_wait("HANDSHAKE_REQ", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 8); + err = wait_until_not_wait("HANDSHAKE_REQ", &resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1000, 15); } if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 2 || resp_payload[0] != 0x00 || resp_payload[1] != 0xAC) { diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 2a2e115..db06d6f 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -768,10 +768,13 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) return ESP_FAIL; } - if (s_relay_triggered) { + if (s_relay_triggered && g_relay_state != RELAY_IDLE && + g_relay_state != RELAY_DONE && g_relay_state != RELAY_ERROR) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay already triggered"); return ESP_FAIL; } + /* 上一轮中继已结束/失败/空闲,重置标志允许重新触发 */ + s_relay_triggered = false; if (!g_ota_dispatch_queue) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay queue not ready"); @@ -1404,10 +1407,13 @@ void wifi_ota_signal_relay(void) return; } - if (s_relay_triggered) { - DEBUG_WARN("WIFI", "转发已经触发过"); + if (s_relay_triggered && g_relay_state != RELAY_IDLE && + g_relay_state != RELAY_DONE && g_relay_state != RELAY_ERROR) { + DEBUG_WARN("WIFI", "转发正在执行中,状态: %d", g_relay_state); return; } + /* 上一轮中继已结束/失败/空闲,重置标志 */ + s_relay_triggered = false; /** @brief OTA 中继是否已触发标志。 */ s_relay_triggered = true; diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 114a9a8..1c65061 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -246,8 +246,36 @@ esp_err_t spi_master_init(void) dev_cfg.command_bits = 0; dev_cfg.address_bits = 0; dev_cfg.dummy_bits = 0; - dev_cfg.cs_ena_posttrans = 0; - dev_cfg.cs_ena_pretrans = 0; + /* CS↑ 后保持 CS 高的时间(SPI 时钟周期数)。 + * + * 0 = 最小 CS 高时间(仅满足 SPI 时序)。 + * 设大一点让 GD32 EXTI ISR 有足够时间完成 onCsRelease() 处理: + * 切 internal NSS HIGH + 清 RBNE FIFO + 帧解析 + 准备响应帧。 + * + * 10 × 250ns = 2.5µs,加上 SPI 驱动本身的 CS 间隮(~1µs), + * GD32 实际有 ~3.5µs 的 CS 高窗口。足够完成关键操作。 + */ + dev_cfg.cs_ena_posttrans = 10; + /* CS↓ 后等待 20 个 SPI 时钟周期再开始发 SCK。 + * + * 4MHz SPI → 20×250ns = 5µs 延时。 + * + * GD32 SPI 从机在 CS↓ 时需要 EXTI ISR 执行 onCsAssert(): + * 设置 s_csActive=true → 清空接收缓冲 → 切 internal NSS LOW → 配置 DMA。 + * + * 但 SPI0 RBNE 中断优先级 (1,0) 高于 EXTI (1,1), + * 首字节 RBNE 会打断正在执行的 EXTI ISR → + * s_csActive 还没设为 true → s_rxStreamLen 还没清零 → + * 首字节存到旧位置 → EXTI 返回后清零 → 首字节丢失。 + * + * cs_ena_pretrans=20 让 ESP32 在 CS↓ 后多等 5µs, + * GD32 EXTI ISR 只需 ~1µs 就能完成关键初始化, + * 5µs 足够让 onCsAssert() 执行完 s_csActive=true + s_rxStreamLen=0U。 + * + * 代价:每次 SPI 事务多 5µs 延时。 + * 一次 OTA 事务 = 1次请求 + ~10次轮询 = ~55µs 额外开销,可接受。 + */ + dev_cfg.cs_ena_pretrans = 20; ret = spi_bus_add_device(SPI2_HOST, &dev_cfg, &g_spi_device); if (ret != ESP_OK) { @@ -351,7 +379,7 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload, 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(1)); + vTaskDelay(pdMS_TO_TICKS(2)); continue; } @@ -385,7 +413,7 @@ esp_err_t spi_ota_poll_response(uint8_t *resp_type, uint8_t *resp_payload, rx_raw[0], rx_raw[1], rx_raw[2], rx_raw[3], rx_raw[4], rx_raw[5], rx_raw[6], rx_raw[7], rx_raw[8], rx_raw[9]); - vTaskDelay(pdMS_TO_TICKS(1)); + vTaskDelay(pdMS_TO_TICKS(2)); } DEBUG_ERR("SPI", "OTA响应超时: timeout=%ums", (unsigned)timeout_ms); @@ -422,18 +450,43 @@ 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); + /* ============================================================ + * 第1次 CS 周期:发送请求帧(只发不收) + * + * ESP32 发完请求帧后 CS↑,GD32 在 CS↑ 的 onCsRelease 中 + * 或 RBNE 中断的 tryProcessCompleteFrame 中处理帧, + * 将 ACK 写入 s_txBuf 并启动 DMA。 + * + * 注意:不能在同一个 CS 周期内发请求+读响应,因为: + * 1. SPI 全双工:ESP32 发请求时 MISO 同时在输出旧数据 + * 2. GD32 收到请求帧后才能准备 ACK,需重启 DMA + * 3. DMA 重启期间 MISO 会输出 0x00/0xFF,导致帧断裂 + * ============================================================ */ 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)); + DEBUG_ERR("SPI", "SPI OTA 请求发送失败: %s", esp_err_to_name(ret)); return ret; } - /* 给 GD32 从机一点时间在 ISR 中处理请求帧并准备响应帧。 - * GD32 在 CS 释放的 EXTI ISR 中完成处理(微秒级), - * 但 EXTI ISR 到 TBE 中断启用之间有微小延迟。 - * 500µs 足够覆盖所有中断响应延迟。 */ + /* 等 GD32 处理帧 + 准备响应。 + * + * GD32 在 CS↑ 的 onCsRelease() 中处理帧: + * isrProcessFrame() → OTA handler 回调 → queueTxFrame() → DMA 启动 + * 总耗时约 10-20µs(ISR 内执行,无调度延迟)。 + * + * 但 EXTI ISR 可能被更高优先级的 RBNE 中断短暂抢占, + * 实际完成时间可能到 30-50µs。 + * + * 500µs 提供了 10x 以上的安全余量, + * 同时对 OTA 事务延迟影响可忽略(一次 OTA 事务 ~1ms 级别)。 */ esp_rom_delay_us(500); + /* ============================================================ + * 第2次 CS 周期:发 0xFF dummy 产生时钟,读 MISO 上的响应帧 + * + * GD32 的 onCsAssert 会重启 DMA,将 s_txBuf(ACK帧) + * 推到 MISO,ESP32 在 96 字节中扫描定位响应帧。 + * ============================================================ */ return spi_ota_poll_response(resp_type, resp_payload, resp_max, resp_len, timeout_ms); } diff --git a/tools/app.js b/tools/app.js index 67eb7e9..6fe83a2 100644 --- a/tools/app.js +++ b/tools/app.js @@ -1,5 +1,6 @@ // ==================== 全局状态 ==================== -let currentFile = null; +let esp32File = null; +let gd32File = null; let uploadProgress = 0; let isUploading = false; let logFilter = 'all'; @@ -8,6 +9,8 @@ let lastLogHash = null; let autoScroll = true; let logSocket = null; let latestSystemStatus = null; +let isEsp32Rebooting = false; +let rebootStartTime = 0; // ==================== 工具函数 ==================== function formatBytes(bytes) { @@ -120,10 +123,10 @@ function toggleAutoScroll() { } function updateUploadButtonLabel() { - var chip = document.getElementById('targetChip').value; - var uploadBtn = document.getElementById('uploadBtn'); - if (!uploadBtn) return; - uploadBtn.textContent = chip === 'esp32' ? '上传固件到 ESP32' : '上传固件到中继缓存'; + var esp32Btn = document.getElementById('uploadEsp32Btn'); + var gd32Btn = document.getElementById('uploadGd32Btn'); + if (esp32Btn) esp32Btn.disabled = !esp32File || isUploading; + if (gd32Btn) gd32Btn.disabled = !gd32File || isUploading; } function setHelper(id, text, type) { @@ -133,25 +136,6 @@ function setHelper(id, text, 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, ''); @@ -194,6 +178,42 @@ function applyDisconnectedRuntimeState() { resetStageFlow(); } +function applyRebootingRuntimeState() { + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + pc.className = 'pill processing'; + var elapsed = Math.round((Date.now() - rebootStartTime) / 1000); + pct.textContent = '重启中 ' + elapsed + 's'; + + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + cs.className = 'conn-indicator warn'; + cst.textContent = 'ESP32 正在重启...'; + + var bc = document.getElementById('bConn'); + bc.textContent = '重启中'; + bc.className = 'v warn'; + + document.getElementById('pillRelayTxt').textContent = '重启中'; + document.getElementById('pillRelay').className = 'pill processing'; +} + +function applyUploadingRuntimeState() { + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + pc.className = 'pill processing'; + pct.textContent = '上传中'; + + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + cs.className = 'conn-indicator warn'; + cst.textContent = '固件上传中,设备忙...'; + + var bc = document.getElementById('bConn'); + bc.textContent = '上传中'; + bc.className = 'v warn'; +} + // ==================== 连接管理 ==================== function updateConnectionUi(connected, text) { // header pill @@ -311,6 +331,19 @@ function updateSystemStatusUi(data) { document.getElementById('mMinHeap').textContent = data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--'; + // ota cache + var otaStored = ota.stored_size || 0; + var mOtaCache = document.getElementById('mOtaCache'); + if (mOtaCache) { + if (otaStored > 0) { + mOtaCache.textContent = formatBytes(otaStored) + ' / 216 KB'; + mOtaCache.className = 'v ok'; + } else { + mOtaCache.textContent = '0 / 216 KB'; + mOtaCache.className = 'v'; + } + } + // relay state var rs = ota.relay_state; var relayText = relayStates[rs] || '待机'; @@ -329,11 +362,6 @@ function updateSystemStatusUi(data) { 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); } @@ -396,28 +424,6 @@ async function sendCanFrame() { } } -function onTargetChipChange() { - 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'; - } else { - relayBtn.style.display = ''; - esp32Actions.style.display = 'none'; - } - 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'; @@ -527,22 +533,28 @@ function clearSpiFields() { } // ==================== 文件处理 ==================== -function handleFileSelect(event) { +function handleFileSelect(event, chip) { var file = event.target.files[0]; - if (file) processFile(file); + if (file) processFile(file, chip); } -document.getElementById('uploadZone').addEventListener('dragover', function (e) { - e.preventDefault(); e.currentTarget.classList.add('dragover'); -}); -document.getElementById('uploadZone').addEventListener('dragleave', function (e) { - e.currentTarget.classList.remove('dragover'); -}); -document.getElementById('uploadZone').addEventListener('drop', function (e) { - e.preventDefault(); e.currentTarget.classList.remove('dragover'); - var file = e.dataTransfer.files[0]; - if (file) processFile(file); -}); +function setupDropZone(zoneId, chip) { + var zone = document.getElementById(zoneId); + if (!zone) return; + zone.addEventListener('dragover', function (e) { + e.preventDefault(); e.currentTarget.classList.add('dragover'); + }); + zone.addEventListener('dragleave', function (e) { + e.currentTarget.classList.remove('dragover'); + }); + zone.addEventListener('drop', function (e) { + e.preventDefault(); e.currentTarget.classList.remove('dragover'); + var file = e.dataTransfer.files[0]; + if (file) processFile(file, chip); + }); +} +setupDropZone('uploadZoneEsp32', 'esp32'); +setupDropZone('uploadZoneGd32', 'gd32'); document.getElementById('canData').addEventListener('input', updateCanHint); document.getElementById('canDlc').addEventListener('input', updateCanHint); @@ -550,46 +562,46 @@ document.getElementById('canDlc').addEventListener('input', updateCanHint); document.getElementById('spiType').addEventListener('input', updateSpiHint); document.getElementById('spiData').addEventListener('input', updateSpiHint); -function processFile(file) { - currentFile = file; - document.getElementById('fileInfo').classList.add('show'); - document.getElementById('uploadBtn').disabled = false; - document.getElementById('fileName').textContent = file.name; - document.getElementById('fileType').textContent = +function processFile(file, chip) { + if (chip === 'esp32') esp32File = file; + else gd32File = file; + var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; + document.getElementById('fileInfo' + suffix).classList.add('show'); + document.getElementById('fileName' + suffix).textContent = file.name; + document.getElementById('fileType' + suffix).textContent = file.name.toLowerCase().indexOf('.hex') >= 0 ? 'Intel HEX' : 'Binary'; - document.getElementById('fileSize').textContent = formatBytes(file.size); - var chunks = Math.ceil(file.size / 8192); - document.getElementById('fileChunks').textContent = chunks + ' 包'; - updateFileHint(); - addLog('I', '文件', '已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); - showToast('文件已选择', 'success'); + document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); + updateUploadButtonLabel(); + addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); + showToast(chip.toUpperCase() + ' 文件已选择', 'success'); } -function clearFile() { - currentFile = null; - document.getElementById('fileInput').value = ''; - document.getElementById('fileInfo').classList.remove('show'); - document.getElementById('uploadBtn').disabled = true; - document.getElementById('progressWrap').classList.remove('show'); - updateFileHint(); - addLog('I', '文件', '文件已清除'); +function clearFile(chip) { + var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; + if (chip === 'esp32') esp32File = null; + else gd32File = null; + document.getElementById('fileInput' + suffix).value = ''; + document.getElementById('fileInfo' + suffix).classList.remove('show'); + updateUploadButtonLabel(); + addLog('I', '文件', chip.toUpperCase() + ' 文件已清除'); } // ==================== 上传固件 ==================== -async function startUpload() { - if (!currentFile || isUploading) return; - var chip = document.getElementById('targetChip').value; +async function startUpload(chip) { + var file = chip === 'esp32' ? esp32File : gd32File; + if (!file || isUploading) return; isUploading = true; - var uploadBtn = document.getElementById('uploadBtn'); + var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn'); + var origText = uploadBtn.textContent; uploadBtn.disabled = true; uploadBtn.textContent = '上传中...'; document.getElementById('progressWrap').classList.add('show'); - addLog('I', '上传', '开始上传: ' + currentFile.name + ' \u2192 ' + chip.toUpperCase()); + addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase()); showToast('开始上传固件到 ' + chip.toUpperCase(), 'success'); try { - var arrayBuffer = await currentFile.arrayBuffer(); - var fileSize = currentFile.size; + var arrayBuffer = await file.arrayBuffer(); + var fileSize = file.size; var body, url = getBaseUrl() + '/ota/upload'; if (chip === 'esp32') { @@ -621,12 +633,12 @@ async function startUpload() { xhr.addEventListener('load', function () { isUploading = false; uploadBtn.disabled = false; + uploadBtn.textContent = origText; updateUploadButtonLabel(); if (xhr.status === 200) { addLog('I', '上传', '固件上传完成!'); showToast('固件上传成功', 'success'); if (chip === 'esp32') { - document.getElementById('esp32Actions').style.display = 'block'; var countdown = 3; addLog('I', 'OTA', countdown + ' 秒后自动调用 POST /ota/update 完成升级并重启...'); var timer = setInterval(function () { @@ -646,7 +658,9 @@ async function startUpload() { }); xhr.addEventListener('error', function () { - isUploading = false; uploadBtn.disabled = false; + isUploading = false; + uploadBtn.disabled = false; + uploadBtn.textContent = origText; updateUploadButtonLabel(); addLog('E', '上传', '上传过程发生错误'); showToast('上传出错', 'error'); @@ -657,7 +671,9 @@ async function startUpload() { } catch (e) { addLog('E', '上传', '上传失败: ' + e.message); showToast('上传失败', 'error'); - isUploading = false; uploadBtn.disabled = false; + isUploading = false; + uploadBtn.disabled = false; + uploadBtn.textContent = origText; updateUploadButtonLabel(); } } @@ -674,6 +690,8 @@ async function triggerEsp32Update() { var data = await resp.json(); addLog('I', 'OTA', '升级完成: ' + data.message); showToast('ESP32 重启中...', 'success'); + isEsp32Rebooting = true; + rebootStartTime = Date.now(); } } catch (e) { addLog('E', 'OTA', '升级失败: ' + e.message); @@ -693,6 +711,8 @@ async function restartEsp32() { var data = await resp.json(); addLog('I', '系统', '重启已触发: ' + data.message); showToast('ESP32 重启中,页面将自动恢复...', 'success'); + isEsp32Rebooting = true; + rebootStartTime = Date.now(); } } catch (e) { addLog('E', '系统', '重启请求失败: ' + e.message); @@ -733,11 +753,31 @@ async function pollStatus() { }); if (resp.ok) { var data = await resp.json(); + if (isEsp32Rebooting) { + isEsp32Rebooting = false; + addLog('I', '系统', 'ESP32 已恢复在线'); + showToast('ESP32 已恢复在线', 'success'); + } updateSystemStatusUi(data); } } catch (e) { - updateConnectionUi(false, '未连接'); - applyDisconnectedRuntimeState(); + if (isUploading) { + applyUploadingRuntimeState(); + } else if (isEsp32Rebooting) { + var elapsed = Math.round((Date.now() - rebootStartTime) / 1000); + if (elapsed > 60) { + isEsp32Rebooting = false; + addLog('E', '系统', 'ESP32 重启超时,设备未恢复'); + showToast('ESP32 重启超时,请检查设备', 'error'); + updateConnectionUi(false, '未连接'); + applyDisconnectedRuntimeState(); + } else { + applyRebootingRuntimeState(); + } + } else { + updateConnectionUi(false, '未连接'); + applyDisconnectedRuntimeState(); + } } } setInterval(pollStatus, 1000); @@ -767,10 +807,7 @@ async function pollLogs() { setInterval(pollLogs, 1000); setTimeout(function () { - onTargetChipChange(); updateUploadButtonLabel(); - updateFileHint(); - updateActionHint(); updateCanHint(); addLog('I', '系统', '控制台初始化完成,等待固件文件...'); updateSpiHint(); diff --git a/tools/index.html b/tools/index.html index 9d27d9e..ba5e8c0 100644 --- a/tools/index.html +++ b/tools/index.html @@ -17,7 +17,6 @@
未连接 - GD32 v-- -- -- @@ -50,46 +49,65 @@
- +
-
🎯 升级目标
+
📦 ESP32 自身升级
-
- +
上传固件并执行 OTA,设备将自动重启
+
+
📤
+

点击或拖拽 ESP32 固件

-
- 将固件写入 ESP32 Flash,由 ESP32 通过 SPI 中继到 GD32 完成刷写。 + +
+
+ 📄 - + +
+
+
类型-
+
大小-
+
+
- +
-
📁 固件文件
+
📦 GD32 SPI 中继
-
+
上传固件到缓存,再触发 SPI 中继刷写
+
📤
-

点击或拖拽文件到此处

-

支持 .hex 和 .bin 格式

+

点击或拖拽 GD32 固件

- - -
+ +
- 📄 - - + 📄 - +
-
类型-
-
大小-
-
数据包 (8KB/包)-
+
类型-
+
大小-
-
未选择固件。请选择 .bin 或 .hex 文件后再执行上传。
+ + +
+
+ +
+
⚡ 通用操作
+
@@ -97,26 +115,7 @@ 0%
-
-
- - -
-
⚡ 执行操作
-
- -
执行顺序:先测试连接,再确认目标芯片,最后上传固件。GD32 模式上传完成后,再触发中继。
-
- - -
- + 切换到 BLE 蓝牙刷写 @@ -137,10 +136,6 @@
离线
连接状态
-
-
GD32
-
当前模式
-
0%
OTA 进度
@@ -160,6 +155,7 @@
空闲堆
--
最低空闲堆
--
固件版本
--
+
OTA 缓存
--
@@ -262,6 +258,6 @@ 操作成功
- + diff --git a/tools/style.css b/tools/style.css index 29bbc7e..ed10ce5 100644 --- a/tools/style.css +++ b/tools/style.css @@ -206,6 +206,9 @@ select option { background: var(--bg-panel); color: var(--text); } .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); } +.upload-zone-sm { padding: 16px 12px; } +.upload-zone-sm .upload-ico { font-size: 24px; margin-bottom: 6px; } +.upload-zone-sm h4 { font-size: 13px; } /* === FILE INFO === */ .file-info {