feat(core): 移除状态监控任务并优化系统资源
移除原有的周期性状态监控任务,减少系统负载和内存占用。 perf(relay): 优化OTA中继挑战响应验证逻辑 重构挑战响应验证流程,统一响应长度定义,并改进验证算法。 fix(wifi): 修复OTA中继重复触发问题 确保中继状态正确重置,防止在非空闲状态下重复触发中继。 perf(transport): 优化SPI通信时序参数 调整CS片选信号的前后延时参数,提高SPI通信稳定性。 refactor(ui): 分离ESP32和GD32文件上传界面 重构前端界面,为ESP32和GD32分别提供独立的文件上传区域。
This commit is contained in:
+1
-105
@@ -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 <stdio.h>
|
||||
#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));
|
||||
}
|
||||
}
|
||||
+41
-43
@@ -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);
|
||||
return false;
|
||||
}
|
||||
|
||||
* 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)
|
||||
*/
|
||||
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加密块失败");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
*/
|
||||
if (memcmp(cipher_blob, expected_nonce_block, sizeof(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;
|
||||
}
|
||||
|
||||
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解密失败");
|
||||
// 验证 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;
|
||||
}
|
||||
|
||||
// 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 (memcmp(encrypted_ksession, expected_encrypted, 16) != 0) {
|
||||
DEBUG_ERR("OTA", "CHALLENGE校验失败: encrypted_Ksession不匹配");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 校验通过,输出 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+127
-90
@@ -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', '未选择固件。请选择 <code>.bin</code> 或 <code>.hex</code> 文件后再执行上传。', 'warn');
|
||||
return;
|
||||
}
|
||||
var chip = document.getElementById('targetChip').value;
|
||||
var targetText = chip === 'esp32' ? 'ESP32 自升级' : 'GD32 中继升级';
|
||||
setHelper('fileHint', '文件已就绪:<code>' + currentFile.name + '</code>,当前目标:<b>' + targetText + '</b>。可以开始上传。', 'ok');
|
||||
}
|
||||
|
||||
function updateActionHint() {
|
||||
var chip = document.getElementById('targetChip').value;
|
||||
if (chip === 'esp32') {
|
||||
setHelper('actionHint', 'ESP32 模式:上传后设备会执行自身 OTA。上传成功后会自动调用 <code>/ota/update</code> 并重启。', 'warn');
|
||||
} else {
|
||||
setHelper('actionHint', 'GD32 模式:先把固件上传到 ESP32 中继缓存,再点击“触发 GD32 OTA 中继”。阶段流会根据真实 <code>relay_state</code> 更新。', '');
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
function setupDropZone(zoneId, chip) {
|
||||
var zone = document.getElementById(zoneId);
|
||||
if (!zone) return;
|
||||
zone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault(); e.currentTarget.classList.add('dragover');
|
||||
});
|
||||
document.getElementById('uploadZone').addEventListener('dragleave', function (e) {
|
||||
zone.addEventListener('dragleave', function (e) {
|
||||
e.currentTarget.classList.remove('dragover');
|
||||
});
|
||||
document.getElementById('uploadZone').addEventListener('drop', function (e) {
|
||||
zone.addEventListener('drop', function (e) {
|
||||
e.preventDefault(); e.currentTarget.classList.remove('dragover');
|
||||
var file = e.dataTransfer.files[0];
|
||||
if (file) processFile(file);
|
||||
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) {
|
||||
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();
|
||||
|
||||
+52
-56
@@ -17,7 +17,6 @@
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">未连接</span></span>
|
||||
<span class="pill idle" id="pillMode"><span class="indicator"></span><span id="pillModeTxt">GD32</span></span>
|
||||
<span class="pill" id="pillVer">v--</span>
|
||||
<span class="pill" id="pillIP">--</span>
|
||||
<span class="pill" id="pillRSSI">--</span>
|
||||
@@ -50,46 +49,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Target -->
|
||||
<!-- 2. ESP32 OTA -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">🎯</span> 升级目标</div>
|
||||
<div class="card-head"><span class="ico">📦</span> ESP32 自身升级</div>
|
||||
<div class="card-body">
|
||||
<div class="form-row">
|
||||
<select class="input" id="targetChip" onchange="onTargetChipChange()">
|
||||
<option value="gd32">GD32(Flash SPI 中继)</option>
|
||||
<option value="esp32">ESP32(自身 OTA 升级)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="conn-indicator" id="targetHint" style="font-size:12px;font-weight:400;color:var(--text-dim);">
|
||||
将固件写入 ESP32 Flash,由 ESP32 通过 SPI 中继到 GD32 完成刷写。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Firmware -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">📁</span> 固件文件</div>
|
||||
<div class="card-body">
|
||||
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;">上传固件并执行 OTA,设备将自动重启</div>
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneEsp32" onclick="document.getElementById('fileInputEsp32').click()">
|
||||
<div class="upload-ico">📤</div>
|
||||
<h4>点击或拖拽文件到此处</h4>
|
||||
<p>支持 .hex 和 .bin 格式</p>
|
||||
<h4>点击或拖拽 ESP32 固件</h4>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept=".hex,.bin" onchange="handleFileSelect(event)" style="display:none;">
|
||||
|
||||
<div class="file-info" id="fileInfo">
|
||||
<input type="file" id="fileInputEsp32" accept=".hex,.bin" onchange="handleFileSelect(event, 'esp32')" style="display:none;">
|
||||
<div class="file-info" id="fileInfoEsp32">
|
||||
<div class="file-info-row">
|
||||
<span style="font-weight:600;">📄 <span id="fileName">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearFile()">清除</button>
|
||||
<span style="font-weight:600;">📄 <span id="fileNameEsp32">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearFile('esp32')">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>类型</span><b id="fileType">-</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="fileSize">-</b></div>
|
||||
<div class="file-meta-item"><span>数据包 (8KB/包)</span><b id="fileChunks">-</b></div>
|
||||
<div class="file-meta-item"><span>类型</span><b id="fileTypeEsp32">-</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="fileSizeEsp32">-</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" id="uploadEsp32Btn" onclick="startUpload('esp32')" disabled style="margin-top:10px;">
|
||||
上传并升级 ESP32
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="helper-note warn" id="fileHint">未选择固件。请选择 <code>.bin</code> 或 <code>.hex</code> 文件后再执行上传。</div>
|
||||
|
||||
<!-- 3. GD32 OTA -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">📦</span> GD32 SPI 中继</div>
|
||||
<div class="card-body">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;">上传固件到缓存,再触发 SPI 中继刷写</div>
|
||||
<div class="upload-zone upload-zone-sm" id="uploadZoneGd32" onclick="document.getElementById('fileInputGd32').click()">
|
||||
<div class="upload-ico">📤</div>
|
||||
<h4>点击或拖拽 GD32 固件</h4>
|
||||
</div>
|
||||
<input type="file" id="fileInputGd32" accept=".hex,.bin" onchange="handleFileSelect(event, 'gd32')" style="display:none;">
|
||||
<div class="file-info" id="fileInfoGd32">
|
||||
<div class="file-info-row">
|
||||
<span style="font-weight:600;">📄 <span id="fileNameGd32">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearFile('gd32')">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>类型</span><b id="fileTypeGd32">-</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="fileSizeGd32">-</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled style="margin-top:10px;margin-bottom:8px;">
|
||||
上传固件到缓存
|
||||
</button>
|
||||
<button class="btn btn-outline btn-block" id="relayBtn" onclick="triggerRelay()">
|
||||
触发 GD32 OTA 中继
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Common -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">⚡</span> 通用操作</div>
|
||||
<div class="card-body">
|
||||
<div class="progress-wrap" id="progressWrap">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<div class="progress-text">
|
||||
@@ -97,26 +115,7 @@
|
||||
<span id="progressPct">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Execute -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="ico">⚡</span> 执行操作</div>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-primary btn-block" id="uploadBtn" onclick="startUpload()" disabled>
|
||||
上传固件到 ESP32
|
||||
</button>
|
||||
<div class="helper-note" id="actionHint">执行顺序:先测试连接,再确认目标芯片,最后上传固件。GD32 模式上传完成后,再触发中继。</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-success" id="relayBtn" onclick="triggerRelay()">
|
||||
触发 GD32 OTA 中继
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick="restartEsp32()">
|
||||
重启 ESP32
|
||||
</button>
|
||||
</div>
|
||||
<div id="esp32Actions" style="display:none;margin-top:12px;"></div>
|
||||
<button class="btn btn-outline btn-block" onclick="restartEsp32()">重启 ESP32</button>
|
||||
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260509-0046" style="margin-top:10px;text-decoration:none;">
|
||||
切换到 BLE 蓝牙刷写
|
||||
</a>
|
||||
@@ -137,10 +136,6 @@
|
||||
<div class="v err" id="bConn">离线</div>
|
||||
<div class="k">连接状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v" id="bMode">GD32</div>
|
||||
<div class="k">当前模式</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v warn" id="bOTAProgress">0%</div>
|
||||
<div class="k">OTA 进度</div>
|
||||
@@ -160,6 +155,7 @@
|
||||
<div class="metric"><div class="k">空闲堆</div><div class="v" id="mHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">最低空闲堆</div><div class="v" id="mMinHeap" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">固件版本</div><div class="v" id="mVersion" style="font-size:16px;">--</div></div>
|
||||
<div class="metric"><div class="k">OTA 缓存</div><div class="v" id="mOtaCache" style="font-size:13px;">--</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,6 +258,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260525"></script>
|
||||
<script src="app.js?v=20260528"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user