feat(ble): 完善BLE OTA架构并优化性能

- 实现BLE OTA数据路径分离:添加ble_ota_data.cpp处理重排序/工作线程/DATA_IN,
  和ble_ota_host.cpp处理GATT/GAP/广播/主机功能
- 将滑动ACK窗口从32增大到64,实测Windows WebBT环境下固件传输速度提升约10%
  (从40KB/s提升到44KB/s),内存消耗约30KB重排序缓冲区
- 优化调试日志级别,将OTA相关信息从INFO调整为DBG以减少日志冗余

refactor(can): 改进CAN反馈监听机制

- 将CAN反馈任务从1Hz主动轮询模式改为被动事件监听模式,
  使用SpiMaster::takeEvent()替代主动发送0xC1轮询请求
- 更新相关头文件注释说明架构变更,将"周期性轮询"改为"被动接收推送"
- 修改调试日志级别,将暂停/恢复状态日志从INFO调整为DBG
This commit is contained in:
小龙虾
2026-07-18 01:16:06 +08:00
parent 1b10f89d8c
commit b807645a20
47 changed files with 4361 additions and 2890 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
## 优化 toast.js + polling.js(不改用户可见行为,不破坏时序)
### A. `tools/js/common/toast.js`
1. **空值保护**:`showToast` 开头加 `var toast = document.getElementById('toast'); if (!toast) return;`,避免 toast 元素未挂载时调用方崩。其余 `toastIco` / `toastMsg` 用 `var ico = ...; var msg = ...;` 取出后判空再用。
2. **`info` 类型语义化**:保持视觉与现状一致,但修正注释。文件头注释从「支持 info / success / warning / error」改为如实描述「支持 success / error / warning(info 等同 warning)」,避免注释误导。不新增 CSS(无 caller 用 info,引入新色只会造成不一致)。
### B. `tools/js/common/polling.js`
5. **`pollLogs` 改递归 `setTimeout` + 在途保护**(修复 socket 抢占):
- 新增模块级 `logsInFlight` 标志和 `logsTimerId`(已有)。
- 抽出 `scheduleLogsPoll(delayMs)`(仿 `scheduleStatusPoll`)。
- `pollLogs` 体内首行 `if (logsInFlight) return;`;`finally` 内 `logsInFlight=false` + `scheduleLogsPoll(statusPollIntervalMs)`。
- `startPolling` / `resumePollingAfterUpload` / `visibilitychange` 改为 `scheduleLogsPoll(0)` 或保留 `pollLogs()` 直接触发(in-flight 守卫会跳过重叠)。
- 注意:`stages.js`、`upload-gd32.js` 的手动 `window.pollLogs()` 调用保持不变,in-flight 守卫下若正飞则跳过、由下一轮定时补上,行为安全。
### C. `tools/js/app.js`
6. **新增 `applyDeviceBusyRuntimeState(text)` helper**(放在 `applyUploadingRuntimeState` 之后,第 148 行后):
- 仿现有三兄弟的 DOM 模式:`#pillConn`/`#pillConnTxt`/`#connStatus`/`#connStatusTxt`/`#bConn`。
- 第 225 行后追加 `window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState;`。
### D. 改动后行为一致性自检(写进 commit message,不写测试)
- 暂停上传:`pollStatus.finally` 不再重挂轮询 → 修正竞态,符合既有注释意图。
- `pollLogs` 重叠:被 in-flight 守卫挡掉 → 减少与 `/ota/upload` 抢 socket,符合既有 `pollStatus` 的设计意图。
- 重启恢复:两处统一走 `markEsp32RebootComplete`,重复 toast/log 由 `isEsp32Rebooting` 幂等守卫消除。
- 设备忙 UI:从 polling.js 内联 DOM 改为调 helper,视觉完全一致(同样的 class、同样的文案)。
- toast `info`:视觉不变(仍为 warn 色),仅注释修正。
### 不做的事
- 不改后端协议、不改 `/system/status`、`/logs` URL 或请求字段。
- 不改 `pollStatus` 的成功分支业务逻辑(重启恢复仅抽出函数,条件不变)。
- 不新增 toast CSS / 不引入 `info` 专属配色。
- 不动 `connection.js`、`log.js`、`ota/*`、`stages.js`、`upload-gd32.js`(仅 app.js 加一个 helper)。
- 不改 `scheduleStatusPoll` 公共签名、不改导出列表。
### 验证
- `git diff` 人工核对 polling.js 重构后两处重启恢复分支与原逻辑逐行等价。
- 浏览器加载 `tools/index.html`,走一遍 ESP32 OTA(上传→中继→重启→恢复),确认:
- 上传期间无 `/system/status` 请求(DevTools Network)。
- `/logs` 不出现重叠并发请求。
- 重启恢复只弹一次「ESP32 已恢复在线」。
- 设备忙时连接胶囊显示「设备忙」,与改前一致。
- 跑一次 GD32 OTA(中继期 400ms 轮询),确认 `setStatusPollIntervalMs` 路径未受影响。
- 无单元测试框架,不新增测试。
### 影响面
- 修改文件:`tools/js/common/toast.js`、`tools/js/common/polling.js`、`tools/js/app.js`(仅 +1 helper + 1 window 挂载)。
- 风险点:`pollLogs` 改递归 setTimeout 后,若某轮抛出未走 finally 会停转——`pollLogs` 全程 `try/catch/finally`,与 `pollStatus` 同模式,风险等价。
+4 -2
View File
@@ -9,10 +9,12 @@ idf_component_register(
"storage/gd32_flash_store.cpp" # GD32 Flash 存储抽象层(H1 提取) "storage/gd32_flash_store.cpp" # GD32 Flash 存储抽象层(H1 提取)
"server/wifi_ota.cpp" # WiFi HTTP OTA 服务器 "server/wifi_ota.cpp" # WiFi HTTP OTA 服务器
"server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取) "server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取)
"server/ble_ota.cpp" # BLE GATT OTA 服务器 "server/ble_ota.cpp" # BLE OTA 公开 API / 收尾 / 共存
"server/ble_ota_data.cpp" # BLE OTA 数据路径(reorder/worker/DATA_IN)
"server/ble_ota_host.cpp" # BLE OTA GATT/GAP/广播/Host
"monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检) "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存/栈水位主动巡检)
"monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务 "monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务
"monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1) "monitor/can_feedback_task.cpp" # CAN 反馈被动监听(0xC1 takeEvent)
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
"monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享) "monitor/task_stack_check.cpp" # 任务栈水位指标收集(health_mon 巡检 + /system/status 共享)
"monitor/stack_overflow_hook.cpp" # FreeRTOS 栈溢出钩子(canary 触发时 UART 硬写告警) "monitor/stack_overflow_hook.cpp" # FreeRTOS 栈溢出钩子(canary 触发时 UART 硬写告警)
+7 -3
View File
@@ -60,7 +60,7 @@ inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker
inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.2KB inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.2KB
inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~2KB inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~2KB
inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:peak~1KB inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:peak~1KB
inline constexpr uint32_t TASK_STACK_CAN_FB = 3072; ///< can_fb:1Hz 轮询 inline constexpr uint32_t TASK_STACK_CAN_FB = 3072; ///< can_fb:被动 takeEvent(0xC1)
inline constexpr uint32_t TASK_STACK_BLE_FINISH = 4096; ///< 按需:SHA finalize(保持) inline constexpr uint32_t TASK_STACK_BLE_FINISH = 4096; ///< 按需:SHA finalize(保持)
inline constexpr uint32_t TASK_STACK_BLE_GD32_WAIT = 3072; ///< 按需:GD32 中继等待 inline constexpr uint32_t TASK_STACK_BLE_GD32_WAIT = 3072; ///< 按需:GD32 中继等待
inline constexpr uint32_t TASK_STACK_BLE_RESTART = 2048; ///< 按需:延时重启 inline constexpr uint32_t TASK_STACK_BLE_RESTART = 2048; ///< 按需:延时重启
@@ -130,9 +130,13 @@ inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; ///< BLE 数
inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; ///< BLE 数据通道:ESP32 固件包标识 inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; ///< BLE 数据通道:ESP32 固件包标识
inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; ///< BLE 单包数据载荷大小(字节) inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; ///< BLE 单包数据载荷大小(字节)
// 滑动 ACK 窗口(须与前端 tools/js/ble/constants.js BLE_OTA_ACK_WINDOW 保持一致) // 滑动 ACK 窗口(须与前端 tools/js/ble/constants.js BLE_OTA_ACK_WINDOW 保持一致)
inline constexpr size_t BLE_OTA_ACK_WINDOW = 32; // ★ 64:实测 Windows WebBT 窗口内单包间隔 0.4-2.9ms(2M PHY 多包/事件),
// 瓶颈在每窗口的 ACK 往返。窗口 32→64 提速 ~10%(40→44 KB/s),再试 96 无收益
// (ACK 往返在 64 已摊薄,稳态瓶颈是 Windows GATT 每包 ~10.6ms resolve 延迟)。
// 内存:64×480B ≈ 30KB reorder buffer,分配失败有 fallback 丢弃策略兜底。
inline constexpr size_t BLE_OTA_ACK_WINDOW = 64;
// Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW) // Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW)
inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32; inline constexpr size_t BLE_OTA_REORDER_WINDOW = 64;
// 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批 // 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批
inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 16); inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 16);
+4 -4
View File
@@ -4,11 +4,11 @@
// 固件版本 // 固件版本
#define FW_VERSION_MAJOR 0 #define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3 #define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 50 #define FW_VERSION_PATCH 51
#define FW_VERSION_STRING "0.3.50" #define FW_VERSION_STRING "0.3.51"
// 编译时间 // 编译时间
#define BUILD_TIME "2026-07-17 02:02:16" #define BUILD_TIME "2026-07-18 00:40:04"
// Git 信息 // Git 信息
#define GIT_HASH "6364eac" #define GIT_HASH "1b10f89"
+6 -6
View File
@@ -3,10 +3,10 @@
* *
* 架构(GD32 主动推送,ESP32 被动接收): * 架构(GD32 主动推送,ESP32 被动接收):
* GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页, * GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页,
* 通过 requestEsp32Read(0xC1) 下拉 SPIready 主动按铃,将每页反馈 payload * 通过 requestEsp32Read(0xC1) 下拉 DATA_READY 主动按铃,将每页反馈 payload
* 推送给 ESP32。ESP32 本任务调用 checkGd32Request() 阻塞等待按铃,读到 * 推送给 ESP32。ESP32 本任务 SpiMaster::takeEvent() 阻塞等待按铃/事件,
* 一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再 * 读到一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再
* publish() 到 s_latest[channel] 供 Web/WebSocket 读取。 * publish() 到 s_latest[channel] 供 Web 读取。
* *
* 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。 * 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。
* *
@@ -161,9 +161,9 @@ void pauseCanFeedbackTask(bool pause)
if (s_paused == pause) return; // 幂等:状态未变时不重复日志 if (s_paused == pause) return; // 幂等:状态未变时不重复日志
s_paused = pause; s_paused = pause;
if (pause) { if (pause) {
DEBUG_INFO(kTag, "CAN 监听已暂停(OTA 进行中)"); DEBUG_DBG(kTag, "CAN 监听已暂停(OTA 进行中)");
} else { } else {
DEBUG_INFO(kTag, "CAN 监听已恢复"); DEBUG_DBG(kTag, "CAN 监听已恢复");
} }
} }
+6 -4
View File
@@ -1,5 +1,7 @@
/* /*
* can_feedback_task.h - 周期性轮询 GD32 CAN 反馈快照的任务接口 * can_feedback_task.h - 被动接收 GD32 主动推送的 CAN 反馈快照
*
* ESP32 侧不主动 1Hz 轮询 0xC1;由 GD32 按铃推送,本任务 takeEvent 接收。
*/ */
#ifndef CAN_FEEDBACK_TASK_H #ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H #define CAN_FEEDBACK_TASK_H
@@ -7,7 +9,7 @@
#include <stdint.h> #include <stdint.h>
#include "transport/canfd_bridge.h" #include "transport/canfd_bridge.h"
/** @brief 暂停/恢复 CAN 反馈轮询(OTA 期间调用 pause=true)。 */ /** @brief 暂停/恢复 CAN 反馈监听(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause); void pauseCanFeedbackTask(bool pause);
#ifdef __cplusplus #ifdef __cplusplus
@@ -15,7 +17,7 @@ extern "C" {
#endif #endif
/** /**
* @brief 启动 1Hz CAN 反馈轮询任务(幂等)。 * @brief 启动 CAN 反馈被动监听任务(幂等)。
* @return true 表示任务已存在或创建成功。 * @return true 表示任务已存在或创建成功。
*/ */
bool startCanFeedbackTask(); bool startCanFeedbackTask();
+8 -8
View File
@@ -144,7 +144,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
} }
const esp_partition_t *boot_partition = esp_ota_get_running_partition(); const esp_partition_t *boot_partition = esp_ota_get_running_partition();
DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx", DEBUG_DBG(TAG, "当前启动分区: %s, size=%u, address=0x%lx",
boot_partition->label, boot_partition->size, boot_partition->address); boot_partition->label, boot_partition->size, boot_partition->address);
// 选择非当前启动的 OTA 分区(OTA_0 → OTA_1 fallback) // 选择非当前启动的 OTA 分区(OTA_0 → OTA_1 fallback)
@@ -161,7 +161,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
return beginCleanupAndReturn(ESP_FAIL); return beginCleanupAndReturn(ESP_FAIL);
} }
DEBUG_INFO(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx", DEBUG_DBG(TAG, "OTA 目标分区: %s, size=%u, address=0x%lx",
m_partition->label, m_partition->size, m_partition->address); m_partition->label, m_partition->size, m_partition->address);
if (total_size > m_partition->size) { if (total_size > m_partition->size) {
@@ -191,7 +191,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
esp_err_to_name(err), (long long)((t1 - t0) / 1000)); esp_err_to_name(err), (long long)((t1 - t0) / 1000));
return beginCleanupAndReturn(err); return beginCleanupAndReturn(err);
} }
DEBUG_INFO(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu", DEBUG_DBG(TAG, "esp_ota_begin(SEQUENTIAL) 完成 耗时 %lld ms free=%lu",
(long long)((t1 - t0) / 1000), (long long)((t1 - t0) / 1000),
(unsigned long)esp_get_free_heap_size()); (unsigned long)esp_get_free_heap_size());
@@ -201,7 +201,7 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
m_ended = false; m_ended = false;
memset(m_sha256, 0, sizeof(m_sha256)); memset(m_sha256, 0, sizeof(m_sha256));
DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase-on-write)", DEBUG_DBG(TAG, "ESP32 OTA 开始,目标分区: %s, 声明大小: %u 字节 (sequential erase-on-write)",
m_partition->label, (unsigned)total_size); m_partition->label, (unsigned)total_size);
return ESP_OK; return ESP_OK;
} }
@@ -241,7 +241,7 @@ esp_err_t Esp32OtaCore::computeShaFromPartition()
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
DEBUG_INFO(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received); DEBUG_DBG(TAG, "从 OTA 分区读回计算 SHA-256 (%u 字节)...", (unsigned)m_received);
/* static context + 小读缓冲:勿占 httpd 栈,也勿再静态吃 4KB BSS */ /* static context + 小读缓冲:勿占 httpd 栈,也勿再静态吃 4KB BSS */
static mbedtls_sha256_context s_sha_ctx; static mbedtls_sha256_context s_sha_ctx;
@@ -279,7 +279,7 @@ esp_err_t Esp32OtaCore::computeShaFromPartition()
for (int i = 0; i < 32; ++i) { for (int i = 0; i < 32; ++i) {
snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02x", m_sha256[i]); snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02x", m_sha256[i]);
} }
DEBUG_INFO(TAG, "设备端 SHA-256: %s", hex); DEBUG_DBG(TAG, "设备端 SHA-256: %s", hex);
return ESP_OK; return ESP_OK;
} }
@@ -324,7 +324,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
// ── 先读回分区计算 SHA-256,并与前端文件 SHA 比对 ── // ── 先读回分区计算 SHA-256,并与前端文件 SHA 比对 ──
if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) { if (frontend_sha_hex != nullptr && strlen(frontend_sha_hex) == 64) {
DEBUG_INFO(TAG, "ESP32 OTA 校验 SHA-256..."); DEBUG_DBG(TAG, "ESP32 OTA 校验 SHA-256...");
esp_err_t sha_err = computeShaFromPartition(); esp_err_t sha_err = computeShaFromPartition();
if (sha_err != ESP_OK) { if (sha_err != ESP_OK) {
DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err)); DEBUG_ERR(TAG, "SHA 计算失败: %s,中止", esp_err_to_name(sha_err));
@@ -351,7 +351,7 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
} }
if (sha_match) { if (sha_match) {
DEBUG_INFO(TAG, "SHA-256 校验通过"); DEBUG_DBG(TAG, "SHA-256 校验通过");
} else { } else {
DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s", DEBUG_ERR(TAG, "SHA-256 不匹配: frontend=%s device=%s",
frontend_sha_hex, device_hex); frontend_sha_hex, device_hex);
+5 -5
View File
@@ -182,7 +182,7 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
m_sysState = OTA_SYS_GD32_STORING; m_sysState = OTA_SYS_GD32_STORING;
m_gd32StorageDone = false; m_gd32StorageDone = false;
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻 m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
DEBUG_INFO(TAG, "GD32 固件存储开始: %" PRIu32 " 字节, 系统状态 → GD32_STORING (epoch=%u, channel=%d)", DEBUG_DBG(TAG, "GD32 固件存储开始: %" PRIu32 " 字节, 系统状态 → GD32_STORING (epoch=%u, channel=%d)",
total_size, m_otaEpoch, (int)channel); total_size, m_otaEpoch, (int)channel);
} }
unlock(h); unlock(h);
@@ -248,7 +248,7 @@ void OtaCoordinator::completeGd32Storage()
m_gd32StorageDone = true; m_gd32StorageDone = true;
m_busySinceTick = 0; // ★ STORED 是非忙状态 m_busySinceTick = 0; // ★ STORED 是非忙状态
resume_ble = (m_otaChannel == OTA_CHANNEL_WIFI); resume_ble = (m_otaChannel == OTA_CHANNEL_WIFI);
DEBUG_INFO(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED"); DEBUG_DBG(TAG, "GD32 固件存储完成, 系统状态 → GD32_STORED");
} else if (h) { } else if (h) {
DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState); DEBUG_WARN(TAG, "completeGd32Storage 在非预期状态: %d", (int)m_sysState);
} }
@@ -324,7 +324,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay()
m_sysState = OTA_SYS_GD32_RELAYING; m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount(); m_busySinceTick = xTaskGetTickCount();
DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING"); DEBUG_DBG(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING");
unlock(h); unlock(h);
return ESP_OK; return ESP_OK;
} }
@@ -337,7 +337,7 @@ void OtaCoordinator::notifyRelayStarted()
if (m_sysState != OTA_SYS_GD32_RELAYING) { if (m_sysState != OTA_SYS_GD32_RELAYING) {
m_sysState = OTA_SYS_GD32_RELAYING; m_sysState = OTA_SYS_GD32_RELAYING;
m_busySinceTick = xTaskGetTickCount(); m_busySinceTick = xTaskGetTickCount();
DEBUG_INFO(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING"); DEBUG_DBG(TAG, "中继已开始(兜底), 系统状态 → GD32_RELAYING");
} }
pauseCanFeedbackTask(true); pauseCanFeedbackTask(true);
} }
@@ -521,7 +521,7 @@ void OtaCoordinator::setEsp32StateLocked(ota_system_state_t s, const char *dbg_m
} else if (s == OTA_SYS_ESP32_FINALIZING && m_busySinceTick == 0) { } else if (s == OTA_SYS_ESP32_FINALIZING && m_busySinceTick == 0) {
m_busySinceTick = xTaskGetTickCount(); m_busySinceTick = xTaskGetTickCount();
} }
DEBUG_INFO(TAG, "%s", dbg_msg); DEBUG_DBG(TAG, "%s", dbg_msg);
} }
/** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */ /** @brief 通知协调器:ESP32 固件接收完毕,状态转为 FINALIZING。 */
+20 -1797
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+701
View File
@@ -0,0 +1,701 @@
/*
* ble_ota_host.cpp - BLE OTA GATT/GAP/广播/NimBLE Host
*/
#include "ble_ota_priv.h"
uint16_t s_data_out_val_handle = 0;
int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)arg;
if (BleOta::instance().isSuspendedForWifiOta()) {
return BLE_ATT_ERR_WRITE_NOT_PERMITTED;
}
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
uint8_t buf[36] = {0};
int len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf));
if (len < 1) {
DEBUG_WARN(TAG, "CTRL 数据为空");
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
DEBUG_DBG(TAG, "CTRL 写入: cmd=0x%02X len=%d", buf[0], len);
if (buf[0] == BLE_OTA_CTRL_TRIGGER_GD32) {
uint16_t session_id = 0;
if (len >= 35) {
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
} else if (len >= 3) {
session_id = (uint16_t)(buf[1] | (buf[2] << 8));
}
if (len < 35) {
DEBUG_WARN(TAG, "CTRL 0xF7 数据长度不足 35(前端 SHA-256 必传),拒绝触发 GD32 中继");
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
packBleFinishValue(session_id, 1));
return 0;
}
// 检查固件是否存储完毕 → 通过协调器触发 GD32 OTA 中继
ota_meta_t meta = {};
esp_err_t err = OtaCoordinator::instance().loadGd32Meta(&meta);
if (err == ESP_OK &&
meta.total_size > 0 &&
meta.stored_size >= meta.total_size) {
const uint8_t *frontend_sha = &buf[1];
char frontend_sha_hex[65] = {0};
char device_sha_hex[65] = {0};
shaBytesToHexUpper(frontend_sha, frontend_sha_hex);
// ★ 统一走共享 helper:计算设备端 SHA → 比对前端 SHA
uint8_t device_sha[32] = {0};
err = OtaCoordinator::instance().verifyGd32Firmware(frontend_sha, device_sha);
shaBytesToHexUpper(device_sha, device_sha_hex);
sendSha256Notify(device_sha, session_id);
DEBUG_DBG(TAG, "GD32 前端 SHA-256: %s, session=%u", frontend_sha_hex, session_id);
DEBUG_DBG(TAG, "GD32 缓存 SHA-256: %s", device_sha_hex);
if (err == ESP_ERR_INVALID_CRC) {
DEBUG_ERR(TAG, "GD32 缓存 SHA-256 与前端文件不匹配,拒绝触发 SPI 中继");
sendFinishEvent(BLE_OTA_FINISH_ERR_SHA_MISMATCH,
packBleFinishValue(session_id, 0));
return 0;
} else if (err != ESP_OK) {
DEBUG_ERR(TAG, "GD32 SHA-256 计算/读取失败: %s", esp_err_to_name(err));
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 3));
return 0;
}
DEBUG_INFO(TAG, "GD32 固件就绪且 SHA-256 匹配 (%" PRIu32 " / %" PRIu32 " 字节),通过协调器触发中继",
meta.stored_size, meta.total_size);
if (OtaRelay::getResultQueue()) {
uint32_t stale = 0;
while (xQueueReceive(OtaRelay::getResultQueue(), &stale, 0) == pdTRUE) {
DEBUG_WARN(TAG, "清理旧 GD32 中继结果: %" PRIu32, stale);
}
}
esp_err_t trig_err = OtaCoordinator::instance().triggerGd32Relay();
if (trig_err != ESP_OK) {
DEBUG_ERR(TAG, "GD32 中继触发失败: %s", esp_err_to_name(trig_err));
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 1));
return 0;
}
sendFinishEvent(BLE_OTA_FINISH_GD32_RELAY_START,
packBleFinishValue(session_id, 0));
DEBUG_INFO(TAG, "已通知前端: GD32 中继已开始 (stage=0x10, session=%u)", session_id);
BleGd32RelayWaitCtx *ctx = (BleGd32RelayWaitCtx *)malloc(sizeof(BleGd32RelayWaitCtx));
if (!ctx) {
DEBUG_ERR(TAG, "GD32 中继等待任务上下文分配失败");
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 2));
return 0;
}
ctx->session_id = session_id;
BaseType_t ok = xTaskCreate(ble_gd32_relay_wait_task,
"ble_gd32_wait",
TASK_STACK_BLE_GD32_WAIT,
ctx,
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 GD32 中继等待任务失败");
free(ctx);
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 2));
}
} else {
DEBUG_WARN(TAG, "GD32 固件未就绪 (stored=%" PRIu32 ", total=%" PRIu32 ")",
meta.stored_size, meta.total_size);
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 1));
}
} else if (buf[0] == BLE_OTA_CTRL_TRIGGER_ESP32) {
uint16_t session_id = 0;
if (len >= 35) {
session_id = (uint16_t)(buf[33] | (buf[34] << 8));
}
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "ESP32 OTA 未激活,无法触发升级");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, 1));
return 0;
}
if (BleOta::instance().m_esp32RebootPending) {
DEBUG_WARN(TAG, "ESP32 OTA 收尾已在进行中,忽略重复 CTRL");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, 1));
return 0;
}
// 强制要求 32B 前端 SHA-256:与 WiFi 流程对齐,设备端读回分区计算并比对,
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH。
if (len < 33) {
DEBUG_WARN(TAG, "CTRL 0xF8 数据长度不足 33(前端 SHA-256 必传),拒绝触发");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, (uint32_t)ESP_ERR_INVALID_ARG);
return 0;
}
BleEsp32FinishCtx *finish_ctx = (BleEsp32FinishCtx *)malloc(sizeof(BleEsp32FinishCtx));
if (!finish_ctx) {
DEBUG_ERR(TAG, "CTRL 0xF8 申请内存失败");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM));
return 0;
}
for (int i = 0; i < 32; ++i) {
snprintf(&finish_ctx->sha_hex[i * 2], 3, "%02x", buf[1 + i]);
}
finish_ctx->sha_hex[64] = '\0';
finish_ctx->session_id = session_id;
DEBUG_DBG(TAG, "前端 SHA-256: %s, session=%u", finish_ctx->sha_hex, session_id);
BleOta::instance().m_esp32RebootPending = true;
BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task,
"ble_ota_finish",
TASK_STACK_BLE_FINISH,
finish_ctx, // heap context; task 负责 free
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败");
free(finish_ctx);
BleOta::instance().m_esp32RebootPending = false;
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, (uint16_t)ESP_ERR_NO_MEM));
} else {
DEBUG_DBG(TAG, "ESP32 OTA 收尾任务已创建(前端 SHA 已传入)");
}
} else if (buf[0] == BLE_OTA_CTRL_RESTART_ESP32) {
// 重启 ESP32:GATT 回调中仅推送 FINISH,延时 + 重启交给独立 task,避免卡 NimBLE host
DEBUG_INFO(TAG, "收到 0xFA 重启请求,创建 restart task(500ms 后 esp_restart())");
if (BleOta::instance().m_esp32RebootPending) {
DEBUG_WARN(TAG, "0xFA 重启已在进行中,忽略重复 CTRL");
return 0;
}
BleOta::instance().m_esp32RebootPending = true;
BaseType_t ok = xTaskCreate(ble_esp32_restart_task,
"ble_ota_restart",
TASK_STACK_BLE_RESTART,
NULL,
5,
NULL);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "创建 0xFA 重启任务失败");
BleOta::instance().m_esp32RebootPending = false;
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE, 0);
}
} else if (buf[0] == BLE_OTA_CTRL_ABORT_OTA) {
// 0xFB: 中止当前 OTA(保持 BLE 连接)
// - 数据接收阶段(GD32_STORING / ESP32_RECEIVING):立即中止 + 清队列 + 回复 ABORTED
// - 中继/收尾阶段(GD32_RELAYING / ESP32_FINALIZING):拒绝中止(SPI 转发不可中断)
// - IDLE/已完成状态:幂等回复 ABORTED
uint16_t session_id = 0;
if (len >= 3) {
session_id = (uint16_t)(buf[1] | (buf[2] << 8));
}
ota_system_state_t cur_state = OtaCoordinator::instance().getSystemState();
DEBUG_WARN(TAG, "收到 0xFB 中止请求: state=%s session=%u",
OtaCoordinator::stateToString(cur_state), session_id);
if (cur_state == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "GD32 SPI 中继进行中,无法中止(请等待完成或断开 BLE)");
sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_RELAY,
packBleFinishValue(session_id, 2));
return 0;
}
if (cur_state == OTA_SYS_ESP32_FINALIZING) {
DEBUG_WARN(TAG, "ESP32 OTA 收尾进行中,无法中止");
sendFinishEvent(BLE_OTA_FINISH_ERR_FINALIZE,
packBleFinishValue(session_id, 2));
return 0;
}
// ── 可中止状态:清队列 + abort core + 复位协调器 ──
if (s_otaDataQueue) {
BleOtaDataBatch stale;
size_t dropped = 0;
while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
free(stale.data);
dropped++;
}
if (dropped > 0) {
DEBUG_INFO(TAG, "中止时清理 OTA 数据队列: %zu 项", dropped);
}
}
// abortCurrent 内部按状态分发:GD32_STORING→metaClear, ESP32_RECEIVING→core.abort, IDLE→空操作
// ble_ota_session_reset() 统一负责 abortCurrent + core.abort 兜底 + 会话状态复位
// (m_esp32LastSeq/m_esp32LastCmd/m_esp32RebootPending/reorder/s_gd32_ooo_count)
ble_ota_session_reset();
ble_ota_restore_wifi_ps();
DEBUG_INFO(TAG, "OTA 已中止(BLE 保持连接),通知前端 (stage=0x20)");
sendFinishEvent(BLE_OTA_FINISH_ABORTED,
packBleFinishValue(session_id, 0));
}
// 0xF7 = 触发 GD32 中继
// 0xF8 = 触发 ESP32 本机 OTA 收尾:设备端读 OTA 分区算 SHA 并与前端 32B SHA 比对,
// 一致走 end+set_boot+reboot,不一致返回 0x85 ERR_SHA_MISMATCH(不重启)
// 0xFA = 重启 ESP32(配套前端 expectedReboot 自动重连)
// 0xFB = 中止当前 OTA(不断开 BLE):仅数据接收阶段有效,中继/收尾阶段拒绝
return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0
}
/*
* DATA_IN (0x0002) - Write
* 数据块格式:
* [0] cmd (0x00=数据)
* [1-2] seq uint16 LE - 分块序号
* [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效)
* [7..n] payload 固件数据
* 注: 已移除CRC字段(BLE链路层已有CRC校验)
*/
/**
* @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return NimBLE ATT 错误码,0 表示成功。
*/
int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)arg;
if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) {
return BLE_ATT_ERR_REQ_NOT_SUPPORTED;
}
const char *version = FW_VERSION;
// 版本字符串: "vX.Y.Z YYYY-MM-DD HH:MM:SS"
char ver_buf[64];
int ver_len = snprintf(ver_buf, sizeof(ver_buf), "%s %s", FW_VERSION, FW_BUILD_TIME);
if (ver_len < 0 || ver_len >= (int)sizeof(ver_buf)) {
ver_len = strlen(version);
int rc = os_mbuf_append(ctxt->om, version, ver_len);
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
}
int rc = os_mbuf_append(ctxt->om, ver_buf, ver_len);
return (rc == 0) ? 0 : BLE_ATT_ERR_UNLIKELY;
}
/*
* DATA_OUT (0x0003) - Notify only
* 访问回调不做读写处理,通知由 BleOta::instance().notifyResponse() 发送
*/
/**
* @brief GATT DATA_OUT 特征访问回调(Notify-only,占位实现)。
* @param conn_handle BLE 连接句柄。
* @param attr_handle GATT 属性句柄。
* @param ctxt GATT 访问上下文。
* @param arg 回调用户参数。
* @return 0。
*/
int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
(void)conn_handle;
(void)attr_handle;
(void)ctxt;
(void)arg;
return 0;
}
// ============================================================
// GATT 服务定义
// ============================================================
// BLE_UUID16_DECLARE 在 C++ 中产生 "taking address of rvalue" 编译错误。
// 改用 static ble_uuid16_t 变量,再取 .u 地址。
/** @brief OTA GATT 服务 UUID */
const ble_uuid16_t GATT_SVC_UUID = BLE_UUID16_INIT(BLE_OTA_SERVICE_UUID);
/** @brief CTRL 特征 UUID,写入 0xF7 触发中继 */
const ble_uuid16_t GATT_CTRL_UUID = BLE_UUID16_INIT(BLE_OTA_CTRL_UUID);
/** @brief DATA_IN 特征 UUID,接收固件数据块 */
const ble_uuid16_t GATT_DATA_IN_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_IN_UUID);
/** @brief DATA_OUT 特征 UUID,Notify 推送 GD32 响应 */
const ble_uuid16_t GATT_DATA_OUT_UUID = BLE_UUID16_INIT(BLE_OTA_DATA_OUT_UUID);
/** @brief VERSION 特征 UUID,返回固件版本字符串 */
const ble_uuid16_t GATT_VERSION_UUID = BLE_UUID16_INIT(BLE_OTA_VERSION_UUID);
// NimBLE C 结构体在 C++ designated initializer 不能显式初始化全部字段。
// 用 pragma 局部关闭 -Wmissing-field-initializers 警告。
#ifdef __cplusplus
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
#endif
const struct ble_gatt_chr_def gatt_ota_chrs[] = {
{
.uuid = &GATT_CTRL_UUID.u,
.access_cb = ble_ota_ctrl_access,
.flags = BLE_GATT_CHR_F_WRITE,
},
{
.uuid = &GATT_DATA_IN_UUID.u,
.access_cb = ble_ota_data_in_access,
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
},
{
.uuid = &GATT_DATA_OUT_UUID.u,
.access_cb = ble_ota_data_out_access,
.flags = BLE_GATT_CHR_F_NOTIFY,
.val_handle = &s_data_out_val_handle,
},
{
.uuid = &GATT_VERSION_UUID.u,
.access_cb = ble_ota_version_access,
.flags = BLE_GATT_CHR_F_READ,
},
{} // 终止符
};
const struct ble_gatt_svc_def gatt_ota_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &GATT_SVC_UUID.u,
.characteristics = (struct ble_gatt_chr_def *)gatt_ota_chrs,
},
{} // 终止符
};
#ifdef __cplusplus
#pragma GCC diagnostic pop
#endif
// ============================================================
// GAP 事件回调
// ============================================================
/**
* @brief BLE GAP 事件回调,处理连接和断开事件。
* @param event
* @param arg
* @return 0 成功。
*/
int ble_ota_gap_event(struct ble_gap_event *event, void *arg)
{
(void)arg;
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
if (BleOta::instance().isSuspendedForWifiOta()) {
/* WiFi OTA 中拒绝 BLE 连接,立刻踢掉 */
DEBUG_WARN(TAG, "WiFi OTA 中,拒绝 BLE 连接 handle=%d",
event->connect.conn_handle);
(void)ble_gap_terminate(event->connect.conn_handle,
BLE_ERR_REM_USER_CONN_TERM);
BleOta::instance().m_bleConnected = false;
return 0;
}
// 连接成功
BleOta::instance().m_bleConnected = true;
BleOta::instance().m_connHandle = event->connect.conn_handle;
s_conn_fallback_requested = false;
DEBUG_INFO(TAG, "BLE 客户端已连接: handle=%d", BleOta::instance().m_connHandle);
ble_ota_log_conn_params(BleOta::instance().m_connHandle, "初始");
int rc = ble_ota_request_conn_params(BleOta::instance().m_connHandle, false);
if (rc != 0) {
s_conn_fallback_requested = true;
ble_ota_request_conn_params(BleOta::instance().m_connHandle, true);
}
/* 优先 LE 2M PHY(空中速率约翻倍);对端不支持则保持 1M,失败仅打日志 */
{
const uint8_t phy_mask = BLE_GAP_LE_PHY_1M_MASK | BLE_GAP_LE_PHY_2M_MASK;
int phy_rc = ble_gap_set_prefered_le_phy(
BleOta::instance().m_connHandle, phy_mask, phy_mask,
BLE_GAP_LE_PHY_CODED_ANY);
DEBUG_DBG(TAG, "请求 LE PHY 1M|2M: rc=%d", phy_rc);
}
} else {
DEBUG_WARN(TAG, "BLE 连接失败: status=%d", event->connect.status);
BleOta::instance().m_bleConnected = false;
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
DEBUG_INFO(TAG, "BLE 客户端已断开: reason=%d",
event->disconnect.reason);
// ★ 断开时清空数据队列,丢弃属于旧会话的残留包
if (s_otaDataQueue) {
drainOtaDataQueue();
}
BleOta::instance().m_bleConnected = false;
BleOta::instance().m_connHandle = BLE_HS_CONN_HANDLE_NONE;
s_conn_fallback_requested = false;
/* WiFi OTA 软挂起期间:主动踢连触发的 disconnect 不得 abort WiFi OTA,也不得再开广播 */
if (BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_INFO(TAG, "WiFi OTA 挂起中:忽略 BLE 断开对 OTA 状态的影响");
return 0;
}
// ★ BLE 断开时恢复 WiFi 射频
ble_ota_restore_wifi_ps();
// ★ 断开时清理"BLE 数据接收阶段"残留的 OTA 会话状态,避免下次刷写被
// 系统 busy 假拒绝。注意区分可中止 / 不可中止状态:
// - GD32_STORING / ESP32_RECEIVING / ESP32_FINALIZING:BLE 是唯一数据源,
// 断开即等价于中止,必须 abortCurrent() + 清理底层 core。
// - GD32_RELAYING / ESP32_REBOOTING:SPI 中继/重启不依赖 BLE,不能打断。
// 这些状态保留,等中继/重启自然结束。
// - IDLE / STORED / COMPLETE / ERROR:abortCurrent 幂等安全,直接调即可。
{
ota_system_state_t cur = OtaCoordinator::instance().getSystemState();
if (cur == OTA_SYS_GD32_RELAYING || cur == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "BLE 断开但 %s 进行中,保留状态等待自然结束",
OtaCoordinator::stateToString(cur));
} else if (cur == OTA_SYS_ESP32_RECEIVING ||
cur == OTA_SYS_ESP32_FINALIZING ||
cur == OTA_SYS_GD32_STORING) {
/* 仅当当前 OTA 通道是 BLE 时才因断连中止;WiFi 通道的 RECEIVING 不能被误杀 */
ota_channel_t ch = OTA_CHANNEL_WIFI;
{
/* get channel via snapshot if available */
OtaSnapshot snap = OtaCoordinator::instance().getSnapshot();
ch = snap.channel;
}
if (ch == OTA_CHANNEL_BLE) {
DEBUG_WARN(TAG, "BLE 断开,中止 BLE 通道 OTA (state=%s)",
OtaCoordinator::stateToString(cur));
ble_ota_session_reset();
} else {
DEBUG_INFO(TAG, "BLE 断开但当前为 WiFi OTA,不中止会话");
}
} else {
ble_ota_session_reset();
}
}
// 恢复广播以接受新连接
ble_ota_start_advertising();
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
if (BleOta::instance().isSuspendedForWifiOta()) {
return 0; /* WiFi OTA 中不要自动再开广播 */
}
DEBUG_INFO(TAG, "广播周期结束,重新开始");
ble_ota_start_advertising();
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
DEBUG_INFO(TAG, "CCC 描述符更新: conn=%d attr=%d notify=%d indicate=%d",
event->subscribe.conn_handle,
event->subscribe.attr_handle,
event->subscribe.cur_notify,
event->subscribe.cur_indicate);
if (event->subscribe.cur_notify) {
ble_ota_log_conn_params(event->subscribe.conn_handle, "订阅时");
}
return 0;
case BLE_GAP_EVENT_CONN_UPDATE: {
DEBUG_DBG(TAG, "BLE 连接参数更新完成: status=%d", event->conn_update.status);
uint16_t interval = ble_ota_log_conn_params(event->conn_update.conn_handle, "更新后");
/* 回退阈值:高速分支目标是 itvl ≤ 8(≤10ms)。若协商失败或落在 > 8(>10ms),
* 说明高速区间被对端拒绝/降级,尝试 Windows 兼容回退路径(10..12.5ms)。
* 原阈值 > 16(>20ms)过宽松,收窄后让回退更早触发、避免停在 15ms 这种"慢但不算失败"的区间。 */
if (!s_conn_fallback_requested &&
(event->conn_update.status != 0 || interval == 0 || interval > 8)) {
s_conn_fallback_requested = true;
ble_ota_request_conn_params(event->conn_update.conn_handle, true);
}
return 0;
}
case BLE_GAP_EVENT_MTU:
DEBUG_INFO(TAG, "MTU 协商: conn=%d mtu=%d",
event->mtu.conn_handle, event->mtu.value);
return 0;
#ifdef BLE_GAP_EVENT_PHY_UPDATE_COMPLETE
case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: {
/* NimBLE: event type 名 *_COMPLETE,union 字段 phy_updated(非 phy_update)。
* 2M 生效才能翻倍空中速率;Chrome/Win 常仍停在 1M。 */
const uint8_t rx = event->phy_updated.rx_phy;
const uint8_t tx = event->phy_updated.tx_phy;
const char *rx_phy = (rx == BLE_GAP_LE_PHY_2M) ? "2M" :
(rx == BLE_GAP_LE_PHY_1M) ? "1M" :
(rx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?";
const char *tx_phy = (tx == BLE_GAP_LE_PHY_2M) ? "2M" :
(tx == BLE_GAP_LE_PHY_1M) ? "1M" :
(tx == BLE_GAP_LE_PHY_CODED) ? "CODED" : "?";
DEBUG_INFO(TAG, "PHY 更新: status=%d rx=%s tx=%s",
event->phy_updated.status, rx_phy, tx_phy);
return 0;
}
#endif
default:
return 0;
}
}
// ============================================================
// BLE 广播
// ============================================================
/**
* @brief 启动 BLE 广播,使设备可被发现和连接。
*/
void ble_ota_start_advertising(void) // kept as free function for BLE callback compatibility
{
if (BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_DBG(TAG, "WiFi OTA 挂起中,跳过广播");
return;
}
int rc;
// 自动推断可用地址类型(优先 Public,不可用则生成 Random)
uint8_t own_addr_type;
rc = ble_hs_id_infer_auto(0, &own_addr_type);
if (rc != 0) {
DEBUG_ERR(TAG, "推断地址类型失败: rc=%d, 尝试生成随机地址", rc);
// 生成不可解析随机地址作为 fallback
ble_addr_t rnd_addr;
rc = ble_hs_id_gen_rnd(0, &rnd_addr);
if (rc != 0) {
DEBUG_ERR(TAG, "生成随机地址也失败: rc=%d, BLE 广播无法启动", rc);
return;
}
own_addr_type = rnd_addr.type;
}
DEBUG_INFO(TAG, "使用地址类型: %s", own_addr_type == BLE_OWN_ADDR_PUBLIC ? "Public" : "Random");
// --- 构建广播数据 ---
uint8_t adv_data[31] = {0};
int adv_pos = 0;
// AD Flags: LE General Discoverable + BR/EDR Not Supported
adv_data[adv_pos++] = 0x02; // 长度
adv_data[adv_pos++] = 0x01; // 类型 = Flags
adv_data[adv_pos++] = 0x06; // 值
// Complete Local Name
const char *name = BLE_DEVICE_NAME;
size_t name_len = strlen(name);
if (name_len > 29 - adv_pos - 2) {
name_len = 29 - adv_pos - 2; // 截断以适配广播包
}
adv_data[adv_pos++] = (uint8_t)(name_len + 1);
adv_data[adv_pos++] = 0x09; // 类型 = Complete Local Name
memcpy(&adv_data[adv_pos], name, name_len);
adv_pos += name_len;
rc = ble_gap_adv_set_data(adv_data, adv_pos);
if (rc != 0) {
DEBUG_ERR(TAG, "设置广播数据失败: %d", rc);
return;
}
// 扫描响应: 包含 Service UUID 以帮助发现
uint8_t scan_rsp[31] = {0};
int rsp_pos = 0;
scan_rsp[rsp_pos++] = 0x11; // 长度 (2 + 15)
scan_rsp[rsp_pos++] = 0x09; // 类型 = Complete Local Name
name_len = strlen(name);
if (name_len > (size_t)(31 - rsp_pos)) {
name_len = 31 - rsp_pos;
}
memcpy(&scan_rsp[rsp_pos], name, name_len);
rsp_pos += name_len;
rc = ble_gap_adv_rsp_set_data(scan_rsp, rsp_pos);
if (rc != 0) {
DEBUG_WARN(TAG, "设置扫描响应数据失败: %d", rc);
}
// 开始广播
struct ble_gap_adv_params adv_params = {};
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND;
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN;
// own_addr_type 已在上面由 ble_hs_id_infer_auto 推断
rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
&adv_params, ble_ota_gap_event, NULL);
if (rc != 0) {
DEBUG_ERR(TAG, "启动广播失败: %d", rc);
} else {
DEBUG_INFO(TAG, "BLE 广播已启动: \"%s\"", name);
}
}
// ============================================================
// NimBLE 同步/重置回调
// ============================================================
/**
* @brief NimBLE 协议栈同步完成回调,初始化广播。
*/
void ble_ota_on_sync(void)
{
DEBUG_INFO(TAG, "NimBLE 协议栈同步完成");
// GATT 服务已在 serverStart() 中注册,不再重复注册
// 重复调用 ble_gatts_count_cfg / ble_gatts_add_svcs 会导致 BLE_HS_EBUSY
DEBUG_INFO(TAG, "DATA_OUT 值句柄: %u", s_data_out_val_handle);
if (s_data_out_val_handle == 0) {
DEBUG_WARN(TAG, "DATA_OUT 值句柄为 0,通知功能不可用");
}
BleOta::instance().m_bleReady = true;
ble_ota_start_advertising();
}
/**
* @brief NimBLE 协议栈重置回调。
* @param reason
*/
void ble_ota_on_reset(int reason)
{
DEBUG_WARN(TAG, "NimBLE 协议栈重置: reason=%d", reason);
BleOta::instance().m_bleReady = false;
}
// ============================================================
// NimBLE Host 任务(独立 FreeRTOS 任务)
// ============================================================
/**
* @brief NimBLE 主机任务入口,处理协议栈事件循环。
* @param param
*/
void ble_ota_nimble_host_task(void *param)
{
(void)param;
DEBUG_INFO(TAG, "NimBLE Host 任务启动");
nimble_port_run();
// 正常情况下不会到达这里
DEBUG_INFO(TAG, "NimBLE Host 任务退出");
}
// ============================================================
// 公开 API
// ============================================================
/**
* @brief 启动 BLE GATT 服务器并注册 OTA 服务。
* @return ESP_OK 成功,其他失败。
*/
+120
View File
@@ -0,0 +1,120 @@
/*
* ble_ota_priv.h - BLE OTA 内部共享声明(仅 server/ble_ota*.cpp 使用)
*
* 拆分:
* ble_ota.cpp — 单例/会话/收尾/公开 API
* ble_ota_data.cpp — 乱序缓冲、数据写路径、worker
* ble_ota_host.cpp — GATT/GAP/广播/Host
*/
#pragma once
#include <string.h>
#include <inttypes.h>
#include <stdlib.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "freertos/queue.h"
#include "esp_err.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/ble_gap.h"
#include "nimble/ble.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "host/ble_att.h"
#include "app_config.h"
#include "version.h"
#include "ble_ota.h"
#include "esp32_ota_core.h"
#include "ota_coordinator.h"
#include "ota_relay.h"
#include "storage/gd32_flash_store.h"
#include "health_monitor.h"
#include "monitor/debug_log.h"
#define TAG "BLE_OTA"
#define BLE_OTA_NOTIFY_ACK 0xA1
#define BLE_OTA_NOTIFY_FINISH 0xB1
#define BLE_OTA_NOTIFY_SHA256 0xC1
#define BLE_OTA_FINISH_DONE 0x02
#define BLE_OTA_FINISH_REBOOT_NOW 0x07
#define BLE_OTA_FINISH_GD32_RELAY_START 0x10
#define BLE_OTA_FINISH_GD32_RELAY_DONE 0x11
#define BLE_OTA_FINISH_ABORTED 0x20
#define BLE_ATT_ERR_OTA_BUSY 0x80
#define BLE_OTA_FINISH_ERR_FINALIZE 0x81
#define BLE_OTA_FINISH_ERR_MUTEX 0x83
#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84
#define BLE_OTA_FINISH_ERR_SHA_MISMATCH 0x85
#define BLE_OTA_FINISH_ERR_GD32_RELAY 0x86
struct BleOtaDataBatch {
uint8_t *data;
size_t len;
};
struct BleGd32RelayWaitCtx {
uint16_t session_id;
};
struct BleEsp32FinishCtx {
char sha_hex[65];
uint16_t session_id;
};
struct BleFinishCtxDeleter {
BleEsp32FinishCtx *p;
explicit BleFinishCtxDeleter(BleEsp32FinishCtx *ctx) : p(ctx) {}
~BleFinishCtxDeleter() { if (p) { free(p); p = nullptr; } }
BleFinishCtxDeleter(const BleFinishCtxDeleter&) = delete;
BleFinishCtxDeleter& operator=(const BleFinishCtxDeleter&) = delete;
};
extern QueueHandle_t s_otaDataQueue;
extern uint16_t s_data_out_val_handle;
extern bool s_wifi_ps_boosted;
/** GAP 连接参数回退请求标志(定义在 ble_ota_data.cpp,host 连接事件读写) */
extern bool s_conn_fallback_requested;
void drainOtaDataQueue();
void ble_ota_start_advertising(void);
void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
void sendFinishEvent(uint8_t stage, uint32_t value);
uint32_t packBleFinishValue(uint16_t session_id, uint16_t code);
void shaBytesToHexUpper(const uint8_t sha[32], char out_hex[65]);
void sendSha256Notify(const uint8_t sha[32], uint16_t session_id);
void esp32_reorder_free();
void ble_ota_session_reset(void);
void ble_ota_restore_wifi_ps(void);
void ble_ota_boost_wifi_ps(void);
void ble_ota_data_worker_task(void *pvParameters);
void ble_gd32_relay_wait_task(void *pvParameters);
int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len);
void ble_ota_on_sync(void);
void ble_ota_on_reset(int reason);
void ble_ota_nimble_host_task(void *param);
int ble_ota_gap_event(struct ble_gap_event *event, void *arg);
uint16_t ble_ota_log_conn_params(uint16_t conn_handle, const char *phase);
int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compatible);
void ble_esp32_restart_task(void *pvParameters);
void ble_esp32_finish_and_reboot_task(void *pvParameters);
int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
int ble_ota_data_out_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
int ble_ota_version_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
extern const struct ble_gatt_svc_def gatt_ota_svcs[];
+1 -1
View File
@@ -630,7 +630,7 @@ esp_err_t logs_handler(httpd_req_t *req)
// ============================================================ // ============================================================
// GET /can/feedback — 三路 CAN 1Hz 反馈快照 // GET /can/feedback — 三路 CAN 反馈快照(GD32 推送,ESP32 缓存)
// ============================================================ // ============================================================
esp_err_t can_feedback_handler(httpd_req_t *req) esp_err_t can_feedback_handler(httpd_req_t *req)
{ {
+1 -1
View File
@@ -22,7 +22,7 @@ esp_err_t spi_send_handler(httpd_req_t *req);
esp_err_t system_status_handler(httpd_req_t *req); esp_err_t system_status_handler(httpd_req_t *req);
esp_err_t system_probe_handler(httpd_req_t *req); esp_err_t system_probe_handler(httpd_req_t *req);
// CAN 1Hz 反馈快照与接收模式 // CAN 反馈快照(被动 0xC1)与接收模式
esp_err_t can_feedback_handler(httpd_req_t *req); esp_err_t can_feedback_handler(httpd_req_t *req);
esp_err_t can_mode_handler(httpd_req_t *req); esp_err_t can_mode_handler(httpd_req_t *req);
+2 -2
View File
@@ -77,7 +77,7 @@ typedef struct {
} id_snapshot_entry_t; } id_snapshot_entry_t;
// ============================================================ // ============================================================
// CAN 接收过滤模式 & 1Hz 反馈快照 // CAN 接收过滤模式 & 反馈快照(GD32 约 1Hz 刷新;ESP32 被动收 0xC1)
// 与 GD32 DCU_APP SPI 协议帧 0xC1/0xC2 配套 // 与 GD32 DCU_APP SPI 协议帧 0xC1/0xC2 配套
// ============================================================ // ============================================================
typedef enum { typedef enum {
@@ -87,7 +87,7 @@ typedef enum {
} can_rx_mode_t; } can_rx_mode_t;
/** /**
* @brief 单通道 1Hz 反馈统计。 * @brief 单通道 CAN 反馈统计(快照字段;非 ESP32 侧 1Hz 轮询)。
*/ */
typedef struct { typedef struct {
uint8_t channel; uint8_t channel;
+10 -2
View File
@@ -409,9 +409,13 @@ void SpiMaster::workerLoop()
/** /**
* @brief GPIO 边沿中断回调(DATA_READY / PB6)。 * @brief GPIO 边沿中断回调(DATA_READY / PB6)。
* *
* 仅处理下降沿:GD32 有待读数据(按铃)。若本端 IDLE 则置 * 仅处理下降沿:GD32 有待读数据(按铃)。上升沿不再使用(旧的"等上升沿
* m_gd32RequestPending 并唤醒 SPI worker。上升沿不再使用(旧 waitSpiReady 已删)。 * ready"逻辑已删除,现由 waitSpiDataReady 做电平轮询)。
* ISR 内仅做标志翻转 + 任务通知,不做 SPI 传输。 * ISR 内仅做标志翻转 + 任务通知,不做 SPI 传输。
*
* 本端忙时(acceptRing()==false):记下 pending 但不 notify,避免抢断
* 当前 SPI 事务;事务结束 worker 下一轮会立即消费此标志,省去最多 25ms
* 的回扫延迟。PB6 在被读走前保持低、不会产生新下降沿,不记 pending 即丢唤醒。
*/ */
void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg) void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg)
{ {
@@ -426,6 +430,10 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg)
return; return;
} }
if (!self->m_linkState.acceptRing()) { if (!self->m_linkState.acceptRing()) {
/* 本端忙:记下 pending,不 notify 抢断当前事务;
* 事务结束 worker 下一轮会消费此标志(PB6 保持低、无新下降沿,
* 不记则只能等 25ms 回扫兜底)。 */
self->m_gd32RequestPending = true;
return; return;
} }
self->m_gd32RequestPending = true; self->m_gd32RequestPending = true;
+1 -1
View File
@@ -318,6 +318,6 @@
<span id="toastMsg">操作成功</span> <span id="toastMsg">操作成功</span>
</div> </div>
<script type="module" src="js/can.js?v=20260717x"></script> <script type="module" src="js/can.js?v=20260719b"></script>
</body> </body>
</html> </html>
-4
View File
@@ -142,10 +142,6 @@
letter-spacing: .12em; letter-spacing: .12em;
text-transform: uppercase; text-transform: uppercase;
} }
.spi-grid {
display: grid; grid-template-columns: 80px 1fr 80px 80px; gap: 10px;
}
/* === CAN WORKSPACE PAGE === */ /* === CAN WORKSPACE PAGE === */
/* 右侧监控抽屉 translate 出屏时,防止 body 被撑出横向滚动条 */ /* 右侧监控抽屉 translate 出屏时,防止 body 被撑出横向滚动条 */
body.can-page { body.can-page {
+7 -7
View File
@@ -12,7 +12,7 @@
<link rel="stylesheet" href="css/base.css?v=20260718b"> <link rel="stylesheet" href="css/base.css?v=20260718b">
<link rel="stylesheet" href="css/layout.css?v=20260718b"> <link rel="stylesheet" href="css/layout.css?v=20260718b">
<link rel="stylesheet" href="css/components.css?v=20260718b"> <link rel="stylesheet" href="css/components.css?v=20260718b">
<link rel="stylesheet" href="css/can.css?v=20260718b"> <link rel="stylesheet" href="css/can.css?v=20260718f">
<link rel="stylesheet" href="css/responsive.css?v=20260718b"> <link rel="stylesheet" href="css/responsive.css?v=20260718b">
</head> </head>
<body> <body>
@@ -211,9 +211,9 @@
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">认证握手</div><div class="stage-detail" id="sd1"></div></div> <div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">认证握手</div><div class="stage-detail" id="sd1"></div></div>
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输协商</div><div class="stage-detail" id="sd2"></div></div> <div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输协商</div><div class="stage-detail" id="sd2"></div></div>
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div><div class="stage-detail" id="sd3"></div></div> <div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div><div class="stage-detail" id="sd3"></div></div>
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">传输完成</div><div class="stage-detail" id="sd4"></div></div> <div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">收尾验证</div><div class="stage-detail" id="sd4"></div></div>
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">OTA 刷写</div><div class="stage-detail" id="sd5"></div></div> <div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">APP 启动</div><div class="stage-detail" id="sd5"></div></div>
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">校验验证</div><div class="stage-detail" id="sd6"></div></div> <div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">OTA完成</div><div class="stage-detail" id="sd6"></div></div>
</div> </div>
<div class="stage-status-bar" id="stageStatusBar"> <div class="stage-status-bar" id="stageStatusBar">
<span class="ssb-dot" id="stageStatusDot"></span> <span class="ssb-dot" id="stageStatusDot"></span>
@@ -436,8 +436,8 @@
<span id="toastMsg">操作成功</span> <span id="toastMsg">操作成功</span>
</div> </div>
<script type="module" src="js/app.js?v=20260718b"></script> <script type="module" src="js/app.js?v=20260719b"></script>
<script type="module" src="js/ota/console-mode.js?v=20260718b"></script> <script type="module" src="js/ota/console-mode.js?v=20260719b"></script>
<script type="module" src="js/ble_ota.js?v=20260718d"></script> <script type="module" src="js/ble_ota.js?v=20260719b"></script>
</body> </body>
</html> </html>
+23 -25
View File
@@ -49,24 +49,6 @@ function formatPercent(value) {
if (!Number.isFinite(value)) return '--'; if (!Number.isFinite(value)) return '--';
return value.toFixed(1).replace(/\.0$/, '') + '%'; return value.toFixed(1).replace(/\.0$/, '') + '%';
} }
function formatHttpErrorDetail(text) {
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
return detail;
}
function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase();
return status === 403 ||
status === 409 ||
status === 423 ||
text.indexOf('busy') >= 0 ||
text.indexOf('occupied') >= 0 ||
text.indexOf('another ota') >= 0 ||
text.indexOf('invalid state') >= 0 ||
text.indexOf('占用') >= 0 ||
text.indexOf('互斥') >= 0 ||
text.indexOf('系统忙') >= 0;
}
function getEspIp() { function getEspIp() {
var el = document.getElementById('espIp'); var el = document.getElementById('espIp');
var ip = el && el.value ? el.value.trim() : ''; var ip = el && el.value ? el.value.trim() : '';
@@ -116,9 +98,11 @@ function applyDisconnectedRuntimeState() {
function applyRebootingRuntimeState() { function applyRebootingRuntimeState() {
var pc = document.getElementById('pillConn'); var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt'); var pct = document.getElementById('pillConnTxt');
pc.className = 'pill processing'; if (pc) pc.className = 'pill processing';
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); if (pct) {
pct.textContent = '重启中 ' + elapsed + 's'; var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
}
var cs = document.getElementById('connStatus'); var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt'); var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn'; if (cs) cs.className = 'conn-indicator warn';
@@ -136,8 +120,8 @@ function applyUploadingRuntimeState() {
} }
var pc = document.getElementById('pillConn'); var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt'); var pct = document.getElementById('pillConnTxt');
pc.className = 'pill processing'; if (pc) pc.className = 'pill processing';
pct.textContent = '上传中'; if (pct) pct.textContent = '上传中';
var cs = document.getElementById('connStatus'); var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt'); var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn'; if (cs) cs.className = 'conn-indicator warn';
@@ -146,6 +130,21 @@ function applyUploadingRuntimeState() {
if (bc) bc.textContent = '上传中'; if (bc) bc.textContent = '上传中';
if (bc) bc.className = 'v warn'; if (bc) bc.className = 'v warn';
} }
/** 设备忙(OTA/中继/重启期间不响应轮询)的连接胶囊/状态条运行时态。
* 与上面三兄弟同模式,区别是不改进度/阶段,只标记连接胶囊为 processing + warn。 */
function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = pillText || '设备忙';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
if (cst) cst.textContent = statusText || '设备忙(OTA/重启中),保持 1s 轮询';
var bc = document.getElementById('bConn');
if (bc) bc.textContent = badgeText || '设备忙';
if (bc) bc.className = 'v warn';
}
// ==================== 挂载 ota/* 函数到 window(供其他模块通过 window.* 调用) ==================== // ==================== 挂载 ota/* 函数到 window(供其他模块通过 window.* 调用) ====================
window.OtaSessionState = OtaSessionState; window.OtaSessionState = OtaSessionState;
@@ -223,8 +222,7 @@ window.resetStageFlow = resetStageFlow;
window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState; window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState;
window.applyRebootingRuntimeState = applyRebootingRuntimeState; window.applyRebootingRuntimeState = applyRebootingRuntimeState;
window.applyUploadingRuntimeState = applyUploadingRuntimeState; window.applyUploadingRuntimeState = applyUploadingRuntimeState;
window.formatHttpErrorDetail = formatHttpErrorDetail; window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState;
window.isOtaBusyError = isOtaBusyError;
// ==================== 顶层副作用(原 app.js 末尾的初始化) ==================== // ==================== 顶层副作用(原 app.js 末尾的初始化) ====================
setupDropZone('uploadZoneEsp32', 'esp32'); setupDropZone('uploadZoneEsp32', 'esp32');
+4 -2
View File
@@ -10,11 +10,13 @@ export const UUIDS = {
version: 0x0004, version: 0x0004,
}; };
export const UI_BUILD = '2026-07-18-ble-fix'; export const UI_BUILD = '2026-07-18-ble-log-trim';
export const BLE_OTA_NOTIFY_ACK = 0xA1; export const BLE_OTA_NOTIFY_ACK = 0xA1;
export const BLE_OTA_NOTIFY_FINISH = 0xB1; export const BLE_OTA_NOTIFY_FINISH = 0xB1;
export const BLE_OTA_NOTIFY_SHA256 = 0xC1; export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
export const BLE_OTA_ACK_WINDOW = 32; // ★ 须与后端 main/include/app_config.h BLE_OTA_ACK_WINDOW 保持一致
// 64:实测 Windows 瓶颈在每窗口 ACK 往返,窗口 32→64 提速 ~10%,再试 96 无收益。
export const BLE_OTA_ACK_WINDOW = 64;
export const BLE_OTA_FINISH_ESP32_DONE = 0x02; export const BLE_OTA_FINISH_ESP32_DONE = 0x02;
export const BLE_OTA_FINISH_REBOOT_NOW = 0x07; export const BLE_OTA_FINISH_REBOOT_NOW = 0x07;
export const BLE_OTA_FINISH_GD32_RELAY_START = 0x10; export const BLE_OTA_FINISH_GD32_RELAY_START = 0x10;
+68 -60
View File
@@ -1,17 +1,27 @@
// ==================== BLE OTA 文件准备与分包 ==================== // ==================== BLE OTA 文件准备与分包 ====================
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState } from './state.js'; //
// ★ 上传中拦截策略(全文件统一):file.js 是 BLE 通道文件操作的唯一入口,
// transfer.js / state.js / session.js / 外部 bleOta.notifyFileChange 都汇聚到这里。
// isBleUploading()(state.js)= state.uploading || state.autoFlashing;
// window.isUploading = WiFi 通道 OTA 会话是否处于 UPLOADING(ota/session.js 维护)。
// 两者任一为真即视为上传中,禁止重置进度条/阶段/清空文件,避免覆盖进行中的 OTA。
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState, isBleUploading } from './state.js';
import { setBleStage, setBleStageText, resetBleStages } from './stages.js'; import { setBleStage, setBleStageText, resetBleStages } from './stages.js';
import { setProgress } from './transfer.js'; import { setProgress } from './transfer.js';
import { hideBleVerifyInfo } from './verify.js'; import { hideBleVerifyInfo } from './verify.js';
import { formatBytes } from '../common/utils.js'; import { formatBytes, bytesToHex } from '../common/utils.js';
import { showToast } from '../common/toast.js'; import { showToast } from '../common/toast.js';
/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */
function isAnyChannelUploading() {
return isBleUploading() || (typeof window.isUploading !== 'undefined' && !!window.isUploading);
}
export function updateFileInfo(file) { export function updateFileInfo(file) {
// ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget // ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget
// 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...) // 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...)
// 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。 // 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。
var isUploading = state.uploading || state.autoFlashing; if (isBleUploading() && file && state.selectedFile) {
if (isUploading && file && state.selectedFile) {
// 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段 // 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段
bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D'); bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D');
return Promise.resolve(); return Promise.resolve();
@@ -33,13 +43,12 @@ export function updateFileInfo(file) {
else els.fileInfo.classList.remove('show'); else els.fileInfo.classList.remove('show');
} }
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 convertHexFileToBin // ★ HEX → BIN 自动转换:复用 app.js 公共函数 window.convertHexFileToBin
if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') { if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') {
return convertHexFileToBin(file).then(function(result) { return convertHexFileToBin(file).then(function(result) {
if (!result.converted) { if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W'); bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeFileInfo(file); finalizeAndHash(file);
computeFileSha256(file);
return; return;
} }
state.selectedFile = result.file; state.selectedFile = result.file;
@@ -52,8 +61,7 @@ export function updateFileInfo(file) {
els.fileSize.textContent = formatBytes(result.file.size); els.fileSize.textContent = formatBytes(result.file.size);
var newPackets = Math.ceil(result.file.size / payloadSize); var newPackets = Math.ceil(result.file.size / payloadSize);
els.packetCount.textContent = String(newPackets); els.packetCount.textContent = String(newPackets);
finalizeFileInfo(result.file); finalizeAndHash(result.file);
computeFileSha256(result.file);
}).catch(function(err) { }).catch(function(err) {
bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E'); bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E');
showToast('HEX 文件解析失败: ' + err.message, 'error'); showToast('HEX 文件解析失败: ' + err.message, 'error');
@@ -65,16 +73,20 @@ export function updateFileInfo(file) {
} }
// 非 HEX 文件直接完成 // 非 HEX 文件直接完成
finalizeAndHash(file);
return Promise.resolve();
}
/** finalizeFileInfo + computeFileSha256 的统一出口:HEX 转换完成分支与非 HEX 分支共用。 */
function finalizeAndHash(file) {
finalizeFileInfo(file); finalizeFileInfo(file);
computeFileSha256(file); computeFileSha256(file);
return Promise.resolve();
} }
function finalizeFileInfo(file) { function finalizeFileInfo(file) {
// ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间 // ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间
// 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。 // 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。
var isUploading = state.uploading || state.autoFlashing; if (isBleUploading()) {
if (isUploading) {
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D'); bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
return; return;
} }
@@ -82,10 +94,6 @@ function finalizeFileInfo(file) {
if (file) { if (file) {
setProgress(0, `已选择 ${file.name}`); setProgress(0, `已选择 ${file.name}`);
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`); bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
} else { } else {
setProgress(0, '等待选择文件'); setProgress(0, '等待选择文件');
if (state.server) { if (state.server) {
@@ -96,42 +104,51 @@ function finalizeFileInfo(file) {
} }
} }
/**
* 计算文件 SHA-256 并推进 BLE stage 流。
* stage 推进逻辑收敛在此函数内,避免与 finalizeFileInfo 隐式耦合:
* - 已连接且 file 非空:进入「计算中」态(stage 3 active + 状态栏文案)
* - 哈希算完:stage 3 done + 推进 stage 4 + 状态栏「文件就绪」
* - file 为空 / 未连接:不碰 stage(由 finalizeFileInfo 的 else 分支 + clearSelectedFile 负责)
*/
function computeFileSha256(file) { function computeFileSha256(file) {
var shaElem = document.getElementById('fileSha256'); var shaElem = document.getElementById('fileSha256');
if (file && shaElem) { if (!file || !shaElem) {
shaElem.textContent = '计算中...'; if (shaElem) shaElem.textContent = '-';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
shaElem.textContent = hashHex.toUpperCase();
state.fileSha256 = hashHex.toUpperCase();
bleLog('FILE', 'SHA-256: ' + hashHex.toUpperCase());
// If connected, advance to stage 3 done
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
state.fileSha256 = null;
});
} else if (shaElem) {
shaElem.textContent = '-';
state.fileSha256 = null; state.fileSha256 = null;
return;
} }
// 已连接:进入「计算中」态(旧逻辑放在 finalizeFileInfo 里,现收敛至此)
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
shaElem.textContent = '计算中...';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
shaElem.textContent = hashHex;
state.fileSha256 = hashHex;
bleLog('FILE', 'SHA-256: ' + hashHex);
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
state.fileSha256 = null;
});
} }
export async function syncSelectedFileFromInput() { export async function syncSelectedFileFromInput() {
// ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间 // ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间
// 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。 // 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。
// ★ 必须同时拦截 WiFi 通道上传 (app.js 全局 isUploading):focus 事件不分通道, // ★ 必须同时拦截 WiFi 通道上传:focus 事件不分通道,WiFi 刷写时点击网页/切标签
// WiFi 刷写时点击网页/切标签回来会触发本函数 → finalizeFileInfo → setProgress(0), // 回来会触发本函数 → finalizeFileInfo → setProgress(0),把进度"闪一下"归零,
// 把进度"闪一下"归零,直到下一次 XHR progress 事件才恢复。 // 直到下一次 XHR progress 事件才恢复。
if (state.uploading || state.autoFlashing) return; if (isAnyChannelUploading()) return;
if (typeof isUploading !== 'undefined' && isUploading) return;
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空 // FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
if (state.selectedFileHandle) return; if (state.selectedFileHandle) return;
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null; const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
@@ -174,7 +191,7 @@ export async function pickFileViaFSAPI() {
export function clearSelectedFile() { export function clearSelectedFile() {
// ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用, // ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用,
// 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。 // 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。
if (state.uploading || state.autoFlashing) { if (isBleUploading()) {
bleLog('FILE', '上传进行中,拒绝清除文件', 'W'); bleLog('FILE', '上传进行中,拒绝清除文件', 'W');
return; return;
} }
@@ -184,32 +201,23 @@ export function clearSelectedFile() {
state.fileSha256 = null; state.fileSha256 = null;
state.deviceSha256 = null; state.deviceSha256 = null;
if (els.firmwareFile) els.firmwareFile.value = ''; if (els.firmwareFile) els.firmwareFile.value = '';
updateFileInfo(null); updateFileInfo(null); // 内部 finalizeFileInfo(null) 已重置进度条并恢复「连接就绪」stage
hideBleVerifyInfo(); hideBleVerifyInfo();
// Reset stages back to connection-ready state
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
bleLog('FILE', '文件已清除'); bleLog('FILE', '文件已清除');
} }
// 别名导出,与 window.bleOta.clearFile 对齐 // 别名导出,与 window.bleOta.clearFile 对齐
export { clearSelectedFile as clearFile }; export { clearSelectedFile as clearFile };
/**
* 外部文件变更通知(index.html 拖拽 / console-mode 切换等)。
* ★ 守卫集中在此:file.js 是 BLE 文件操作唯一入口,外部包装层(ble_ota.js)不再重复守卫。
*/
export async function notifyFileChange(file, handle) { export async function notifyFileChange(file, handle) {
// ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget if (isAnyChannelUploading()) {
// 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。
if (state.uploading || state.autoFlashing) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D'); bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return; return;
} }
// ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度
if (typeof isUploading !== 'undefined' && isUploading) {
bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) { if (file) {
if (handle) state.selectedFileHandle = handle; if (handle) state.selectedFileHandle = handle;
await updateFileInfo(file); await updateFileInfo(file);
+33 -38
View File
@@ -29,7 +29,7 @@ import {
currentTarget, currentTarget,
sleep, sleep,
} from './state.js'; } from './state.js';
import { setBleStage, setBleStageError, setBleStageText, resetBleStages } from './stages.js'; import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
import { setProgress, updateProgressFromAck } from './transfer.js'; import { setProgress, updateProgressFromAck } from './transfer.js';
import { showBleVerifyInfo } from './verify.js'; import { showBleVerifyInfo } from './verify.js';
import { import {
@@ -44,6 +44,23 @@ import {
import { formatBytes } from '../common/utils.js'; import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js'; import { showToast } from '../common/toast.js';
/**
* @description 中止当前 ACK 等待 Promise,并复位重连状态。
* 用于所有"错误类 FINISH"和"用户中止"路径,避免 ackWaiter 悬挂。
*/
function abortAckWaiter(reason) {
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
state.abortRequested = true;
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
}
/** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */ /** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */
export async function connectBle() { export async function connectBle() {
if (!navigator.bluetooth) { if (!navigator.bluetooth) {
@@ -199,9 +216,10 @@ export function handleNotify(event) {
updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes); updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes);
} }
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程 */ /* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程。
* ★ 降为 D 级:成功路径默认不显示,需要时切过滤级查看。 */
if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) { if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) {
bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`); bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`, 'D');
} }
// ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK // ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK
@@ -269,10 +287,7 @@ export function handleNotify(event) {
if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota'); if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota');
setProgress(100, '[GD32] OTA 转发完成,APP 已启动'); setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true }); showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true });
var gd32Bar = document.getElementById('stageStatusBar'); setBleStageStatus('ok', 'GD32 刷写完成');
var gd32Text = document.getElementById('stageStatusText');
if (gd32Bar) gd32Bar.className = 'stage-status-bar ok';
if (gd32Text) gd32Text.textContent = 'GD32 刷写完成';
state.lastUploadStatus = 'success'; state.lastUploadStatus = 'success';
showToast('GD32 OTA 刷写完成', 'success'); showToast('GD32 OTA 刷写完成', 'success');
resolveOtaResult('success', { stage: stage, value: value }); resolveOtaResult('success', { stage: stage, value: value });
@@ -283,21 +298,11 @@ export function handleNotify(event) {
// 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发, // 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发,
// 也可能是设备端因错误自动中止。两种情况都走同一清理路径。 // 也可能是设备端因错误自动中止。两种情况都走同一清理路径。
if (stage === BLE_OTA_FINISH_ABORTED) { if (stage === BLE_OTA_FINISH_ABORTED) {
state.abortRequested = true;
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
state.lastUploadStatus = 'aborted'; state.lastUploadStatus = 'aborted';
setProgress(0, 'OTA 已中止'); setProgress(0, 'OTA 已中止');
setBleStageError('OTA 已中止'); setBleStageError('OTA 已中止');
showToast('OTA 已中止(BLE 保持连接)', 'warning'); showToast('OTA 已中止(BLE 保持连接)', 'warning');
// 解除任何等待 ACK / OTA 结果的 Promise abortAckWaiter('OTA 已中止');
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error('OTA 已中止'));
}
rejectOtaResult('OTA 已中止', { stage: stage, value: value }); rejectOtaResult('OTA 已中止', { stage: stage, value: value });
return; return;
} }
@@ -328,10 +333,7 @@ export function handleNotify(event) {
state.expectedReboot = true; state.expectedReboot = true;
setBleStage(finalizeStage, '设备重启', null); setBleStage(finalizeStage, '设备重启', null);
// ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝 // ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝
var rebootBar = document.getElementById('stageStatusBar'); setBleStageStatus('ok', 'ESP32 刷写完成,等待重启');
var rebootBarTxt = document.getElementById('stageStatusText');
if (rebootBar) rebootBar.className = 'stage-status-bar ok';
if (rebootBarTxt) rebootBarTxt.textContent = 'ESP32 刷写完成,等待重启';
if (typeof markPhaseDone === 'function') { if (typeof markPhaseDone === 'function') {
markPhaseDone('esp32Ota'); markPhaseDone('esp32Ota');
} }
@@ -393,16 +395,7 @@ export function handleNotify(event) {
} }
// 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环 // 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环
if (stage >= 0x80) { if (stage >= 0x80) {
state.abortRequested = true; // ★ 阻止 fire-and-forget 继续发包 abortAckWaiter(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`);
state.expectedReboot = false; // ★ 刷写失败,取消任何重连退避
state.reconnecting = false;
state.reconnectAttempts = 0;
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`));
}
} }
return; return;
} }
@@ -421,7 +414,7 @@ export function handleNotify(event) {
return b.toString(16).padStart(2, '0'); return b.toString(16).padStart(2, '0');
}).join('').toUpperCase(); }).join('').toUpperCase();
state.deviceSha256 = devSha; state.deviceSha256 = devSha;
bleLog('VERIFY', '设备端 SHA-256: ' + devSha); bleLog('VERIFY', '设备端 SHA-256: ' + devSha, 'D');
// 设备端上报 SHA-256 后,前端仅做展示与状态同步。 // 设备端上报 SHA-256 后,前端仅做展示与状态同步。
var frontSha = state.fileSha256; var frontSha = state.fileSha256;
@@ -431,10 +424,7 @@ export function handleNotify(event) {
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true }); showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
setBleStage(verifyStage, 'SHA-256 ✓', null); setBleStage(verifyStage, 'SHA-256 ✓', null);
// ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进 // ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进
var okBar = document.getElementById('stageStatusBar'); setBleStageStatus('ok', '刷写成功 — SHA-256 校验通过');
var okTxt = document.getElementById('stageStatusText');
if (okBar) okBar.className = 'stage-status-bar ok';
if (okTxt) okTxt.textContent = '刷写成功 — SHA-256 校验通过';
if (typeof markPhaseDone === 'function') { if (typeof markPhaseDone === 'function') {
markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota'); markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota');
} }
@@ -471,9 +461,14 @@ export async function tryReconnect() {
state.reconnecting = true; state.reconnecting = true;
state.reconnectAttempts += 1; state.reconnectAttempts += 1;
var attempt = state.reconnectAttempts; var attempt = state.reconnectAttempts;
// 退避:1s, 2s, 4s, 8s, 上限 10s // 退避:约 1s / 1.6s / 2.6s / 4.1s / 6.6s / 10.5s…,上限 10s
var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1)); var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1));
if (backoff > 200) await sleep(backoff); if (backoff > 200) await sleep(backoff);
// ★ 退避期间用户可能已手动断开(disconnectBle 会清 expectedReboot),需二次校验避免幽灵重连
if (!state.expectedReboot || !state.device) {
state.reconnecting = false;
return;
}
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connState) els.connState.textContent = '重连中(' + attempt + ')'; if (els.connState) els.connState.textContent = '重连中(' + attempt + ')';
if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing'; if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing';
+78 -24
View File
@@ -4,11 +4,37 @@ export var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6'];
export var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6']; export var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6'];
export var bleStageLabelIds = ['bsl5', 'bsl6']; export var bleStageLabelIds = ['bsl5', 'bsl6'];
/* 状态栏 DOM 元素懒缓存(BLE OTA 高频调用路径用,避免每次 getElementById)。
* DOM 未就绪或节点被外部重建时回退到 getElementById(与 ota/progress.js 的 setProgress 同款模式)。 */
var stageStatusBarEl = null;
var stageStatusTextEl = null;
/* 当前激活阶段序号(1-based,0=无激活)。由 setBleStage 维护,供 setBleStageError
* 直接定位错误节点,避免依赖 className 字符串匹配(脆弱且易被外部改动打破)。 */
var currentBleStageIndex = 0;
function getStageStatusBar() {
if (!stageStatusBarEl || !stageStatusBarEl.isConnected) {
stageStatusBarEl = document.getElementById('stageStatusBar');
}
return stageStatusBarEl;
}
function getStageStatusText() {
if (!stageStatusTextEl || !stageStatusTextEl.isConnected) {
stageStatusTextEl = document.getElementById('stageStatusText');
}
return stageStatusTextEl;
}
export function updateBleStageLabels(cfg) { export function updateBleStageLabels(cfg) {
/* JS 兜底默认值与 constants.js TARGETS.esp32/gd32 的 stage5Label/stage6Label 对齐:
* esp32: stage5Label='校验比对', stage6Label='完成触发'
* gd32: stage5Label='完成触发', stage6Label='校验比对'
* 实际 cfg 由 currentTarget() 返回,始终带 label,此分支仅为防御性兜底。 */
var stage5 = document.getElementById(bleStageLabelIds[0]); var stage5 = document.getElementById(bleStageLabelIds[0]);
var stage6 = document.getElementById(bleStageLabelIds[1]); var stage6 = document.getElementById(bleStageLabelIds[1]);
if (stage5) stage5.textContent = cfg.stage5Label || '完成触发'; if (stage5) stage5.textContent = cfg.stage5Label || '校验比对';
if (stage6) stage6.textContent = cfg.stage6Label || '校验比对'; if (stage6) stage6.textContent = cfg.stage6Label || '完成触发';
} }
export function resetBleStages() { export function resetBleStages() {
@@ -20,15 +46,34 @@ export function resetBleStages() {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.textContent = ''; if (el) el.textContent = '';
}); });
var bar = document.getElementById('stageStatusBar'); currentBleStageIndex = 0;
if (bar) { bar.className = 'stage-status-bar'; bar.innerHTML = '<span class="ssb-dot"></span><span class="ssb-text" id="stageStatusText">待机</span>'; } /* ★ 只改 className + textContent,不重写 innerHTML:
* 1) 保留 #stageStatusDot(CSS dotPulse 动画依赖该节点,重写后 id 丢失);
* 2) 保留 #stageStatusText 节点引用,避免外部缓存引用失效;
* 3) 与 ota/progress.js、ota/stages.js、app.js 的状态栏操作约定一致。 */
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar';
var txt = getStageStatusText();
if (txt) txt.textContent = '待机';
} }
export function setBleStage(stageNum, detail, barState) { export function setBleStage(stageNum, detail, barState) {
// stageNum: 1-6, detail: sub-step text // stageNum: 1-6, detail: sub-step text
// 终态 stageNum===6:全部 done(绿);中间步当前为 live(蓝) // 终态 stageNum===末步:全部 done(绿);中间步当前为 live(蓝)
var finished = stageNum >= 6; var stageCount = bleStageIds.length;
for (var i = 0; i < bleStageIds.length; i++) { // 边界守卫:stageNum<1 直接清空所有节点(不推进 currentBleStageIndex),避免误判 active
if (!Number.isFinite(stageNum) || stageNum < 1) {
bleStageIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
currentBleStageIndex = 0;
return;
}
if (stageNum > stageCount) stageNum = stageCount;
currentBleStageIndex = stageNum;
var finished = stageNum >= stageCount;
for (var i = 0; i < stageCount; i++) {
var el = document.getElementById(bleStageIds[i]); var el = document.getElementById(bleStageIds[i]);
if (!el) continue; if (!el) continue;
if (i + 1 < stageNum) el.className = 'stage-node done'; if (i + 1 < stageNum) el.className = 'stage-node done';
@@ -36,34 +81,43 @@ export function setBleStage(stageNum, detail, barState) {
else el.className = 'stage-node'; else el.className = 'stage-node';
} }
// Set detail text // Set detail text
if (stageNum >= 1 && stageNum <= 6) { if (stageNum >= 1 && stageNum <= stageCount) {
var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]); var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]);
if (detailEl && detail !== undefined) detailEl.textContent = detail; if (detailEl && detail !== undefined) detailEl.textContent = detail;
} }
// Update status bar // Update status bar
var bar = document.getElementById('stageStatusBar'); var bar = getStageStatusBar();
if (bar) { if (bar) {
var cls = barState || (finished ? 'ok' : (stageNum > 0 ? 'active' : '')); var cls = barState || (finished ? 'ok' : 'active');
bar.className = 'stage-status-bar' + (cls ? ' ' + cls : ''); bar.className = 'stage-status-bar' + (cls ? ' ' + cls : '');
} }
} }
export function setBleStageText(text) { export function setBleStageText(text) {
var txt = document.getElementById('stageStatusText'); var txt = getStageStatusText();
if (txt) txt.textContent = text; if (txt) txt.textContent = text;
} }
export function setBleStageError(detail) { /** @description 一次性更新状态栏的样式与文本(避免每次通知都重复 getElementById)。 */
// Mark current active stage as error export function setBleStageStatus(state, text) {
for (var i = 0; i < bleStageIds.length; i++) { var bar = getStageStatusBar();
var el = document.getElementById(bleStageIds[i]); if (bar) bar.className = 'stage-status-bar' + (state ? ' ' + state : '');
if (el && el.className === 'stage-node live') { var txt = getStageStatusText();
el.className = 'stage-node err'; if (txt && text !== undefined) txt.textContent = text;
var detailEl = document.getElementById(bleStageDetailIds[i]);
if (detailEl && detail) detailEl.textContent = detail;
}
}
var bar = document.getElementById('stageStatusBar');
if (bar) bar.className = 'stage-status-bar err';
setBleStageText('错误: ' + (detail || ''));
} }
export function setBleStageError(detail) {
// 直接用 currentBleStageIndex 定位错误节点(由 setBleStage 维护),
// 不再依赖 className === 'stage-node live' 字符串匹配——后者在 className
// 顺序变化或被外部改动时会漏标错误阶段。
var stageCount = bleStageIds.length;
if (currentBleStageIndex >= 1 && currentBleStageIndex <= stageCount) {
var el = document.getElementById(bleStageIds[currentBleStageIndex - 1]);
if (el) el.className = 'stage-node err';
var detailEl = document.getElementById(bleStageDetailIds[currentBleStageIndex - 1]);
if (detailEl && detail) detailEl.textContent = detail;
}
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar err';
setBleStageText('错误: ' + (detail || '未知错误'));
}
+17 -15
View File
@@ -71,6 +71,11 @@ export function currentTarget() {
return TARGETS[window.flashTarget] || TARGETS.esp32; return TARGETS[window.flashTarget] || TARGETS.esp32;
} }
/** BLE 通道是否正在上传/自动刷写。集中判定,避免各处重复 state.uploading || state.autoFlashing。 */
export function isBleUploading() {
return !!(state.uploading || state.autoFlashing);
}
export function currentPayloadSize() { export function currentPayloadSize() {
const cfg = currentTarget(); const cfg = currentTarget();
return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480)); return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480));
@@ -110,26 +115,23 @@ export function setReadyHint(html) {
export function setConnectionState(connected) { export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading; const idle = connected && state.selectedFile && !state.uploading;
// 仅在 BLE 模式下更新共享连接指示器,避免覆盖 WiFi 轮询的状态 // 仅在 BLE 模式下更新共享连接指示器与 BLE 专用徽章,避免覆盖 WiFi 轮询的状态
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接'; if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
} if (els.connState) {
// BLE 专用连接徽章 — 仅在 BLE 模式下更新,避免覆盖 WiFi 状态 els.connState.textContent = connected ? '已连接' : '未连接';
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { if (els.connState.parentElement) {
if (els.connState) els.connState.textContent = connected ? '已连接' : '未连接'; els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
if (els.connState && els.connState.parentElement) { }
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
} }
els.connBadge.textContent = connected ? 'online' : 'offline'; if (els.connBadge) {
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`; els.connBadge.textContent = connected ? 'online' : 'offline';
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪'; els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
} }
if (connected) { if (els.gattState) els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
els.connectBtn.disabled = true;
} else {
els.connectBtn.disabled = false;
} }
els.connectBtn.disabled = connected;
els.disconnectBtn.disabled = !connected; els.disconnectBtn.disabled = !connected;
els.autoFlashBtn.disabled = !idle; els.autoFlashBtn.disabled = !idle;
els.abortBtn.disabled = !state.uploading; els.abortBtn.disabled = !state.uploading;
+22 -19
View File
@@ -55,6 +55,16 @@ function buildPacket(seq, totalSize, payload, cmd) {
return packet; return packet;
} }
/** @description 将 64 字符十六进制 SHA-256 字符串转为 32 字节数组。 */
function sha256HexToBytes(shaHex) {
const hex = shaHex.toUpperCase();
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) { export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
// ★ 中止快速退出:若 0x20 ABORTED 已先于本调用到达(ackWaiter 当时为 null), // ★ 中止快速退出:若 0x20 ABORTED 已先于本调用到达(ackWaiter 当时为 null),
// 这里立即 reject,避免在已中止的会话上盲等超时。 // 这里立即 reject,避免在已中止的会话上盲等超时。
@@ -101,7 +111,7 @@ export async function uploadFirmware() {
const freshFile = await state.selectedFileHandle.getFile(); const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile); await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile; var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
} catch (e) { } catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
} }
@@ -151,13 +161,13 @@ export async function uploadFirmware() {
throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式'); throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式');
} }
bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`); bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`, 'D');
setProgress(0, `[${cfg.label}] 正在读取固件文件...`); setProgress(0, `[${cfg.label}] 正在读取固件文件...`);
setBleStage(4, '读取固件...', null); setBleStage(4, '读取固件...', null);
const fileBytes = new Uint8Array(await file.arrayBuffer()); const fileBytes = new Uint8Array(await file.arrayBuffer());
const totalSize = fileBytes.length; const totalSize = fileBytes.length;
const packetTotal = Math.ceil(totalSize / payloadSize); const packetTotal = Math.ceil(totalSize / payloadSize);
bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`); bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`, 'D');
if (totalSize !== file.size) { if (totalSize !== file.size) {
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W'); bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
} }
@@ -169,7 +179,7 @@ export async function uploadFirmware() {
fileSha256 = state.fileSha256.toLowerCase(); fileSha256 = state.fileSha256.toLowerCase();
} else { } else {
// 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置) // 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置)
bleLog('OTA', '正在计算文件 SHA-256 哈希...'); bleLog('OTA', '正在计算文件 SHA-256 哈希...', 'D');
const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer); const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashArray = Array.from(new Uint8Array(hashBuffer));
fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
@@ -186,7 +196,7 @@ export async function uploadFirmware() {
state.activePacketTotal = packetTotal; state.activePacketTotal = packetTotal;
els.sentCount.textContent = '0'; els.sentCount.textContent = '0';
setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`); setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`);
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`); bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`, 'D');
const MAX_WINDOW_RETRIES = 3; const MAX_WINDOW_RETRIES = 3;
// 整分区预擦可长达数十秒;首包 ACK 超时必须覆盖 begin/erase // 整分区预擦可长达数十秒;首包 ACK 超时必须覆盖 begin/erase
@@ -214,7 +224,7 @@ export async function uploadFirmware() {
if (seq === 0) { if (seq === 0) {
try { try {
await state.dataInChar.writeValueWithResponse(packet); await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'I'); bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'D');
setBleStageText('设备预擦 Flash 中,请等待…'); setBleStageText('设备预擦 Flash 中,请等待…');
} catch (e) { } catch (e) {
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E'); bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
@@ -233,7 +243,7 @@ export async function uploadFirmware() {
if (state.lastAckBytes < firstBytes) { if (state.lastAckBytes < firstBytes) {
throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes); throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes);
} }
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'I'); bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'D');
setBleStageText('正在写入 ' + packetTotal + ' 包数据...'); setBleStageText('正在写入 ' + packetTotal + ' 包数据...');
continue; continue;
} }
@@ -315,7 +325,6 @@ export async function uploadFirmware() {
} catch (e) { } catch (e) {
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W'); bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
} }
expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
} }
if (state.lastAckBytes < expectedBytes) { if (state.lastAckBytes < expectedBytes) {
@@ -340,10 +349,7 @@ export async function uploadFirmware() {
if (cfg.label === 'ESP32') { if (cfg.label === 'ESP32') {
state.deviceSha256 = null; state.deviceSha256 = null;
const frontSha = fileSha256.toUpperCase(); const frontSha = fileSha256.toUpperCase();
const frontShaBytes = new Uint8Array(32); const frontShaBytes = sha256HexToBytes(fileSha256);
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
const trigger = new Uint8Array(35); const trigger = new Uint8Array(35);
trigger[0] = 0xF8; trigger[0] = 0xF8;
trigger.set(frontShaBytes, 1); trigger.set(frontShaBytes, 1);
@@ -355,7 +361,7 @@ export async function uploadFirmware() {
setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...'); setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...');
try { try {
await state.ctrlChar.writeValue(trigger); await state.ctrlChar.writeValue(trigger);
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验'); bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验', 'D');
} catch (e) { } catch (e) {
clearOtaResultWaiter(); clearOtaResultWaiter();
state.lastUploadStatus = 'failed'; state.lastUploadStatus = 'failed';
@@ -381,10 +387,7 @@ export async function uploadFirmware() {
// GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result. // GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result.
const frontSha = fileSha256.toUpperCase(); const frontSha = fileSha256.toUpperCase();
const frontShaBytes = new Uint8Array(32); const frontShaBytes = sha256HexToBytes(fileSha256);
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
state.deviceSha256 = null; state.deviceSha256 = null;
setBleStage(5, '发送触发...', 'active'); setBleStage(5, '发送触发...', 'active');
setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...'); setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...');
@@ -402,7 +405,7 @@ export async function uploadFirmware() {
await state.ctrlChar.writeValue(cmd); await state.ctrlChar.writeValue(cmd);
setBleStage(5, '已触发', null); setBleStage(5, '已触发', null);
setBleStage(6, '等待设备端校验', 'active'); setBleStage(6, '等待设备端校验', 'active');
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`); bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`, 'D');
} catch (_) { } catch (_) {
clearOtaResultWaiter(); clearOtaResultWaiter();
state.lastUploadStatus = 'failed'; state.lastUploadStatus = 'failed';
@@ -478,7 +481,7 @@ export async function autoFlash() {
if (changed) { if (changed) {
await updateFileInfo(freshFile); await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile; var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')'); bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
} }
} catch (e) { } catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W'); bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
+10 -10
View File
@@ -4,18 +4,18 @@ export function showBleVerifyInfo(frontSha, devSha, result) {
var panel = document.getElementById('stageVerifyInfo'); var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; } if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
if (frontSha !== undefined) { if (frontSha !== undefined) {
var el = document.getElementById('sviFrontSha'); var frontEl = document.getElementById('sviFrontSha');
if (el) el.textContent = frontSha || '-'; if (frontEl) frontEl.textContent = frontSha || '-';
} }
if (devSha !== undefined) { if (devSha !== undefined) {
var el = document.getElementById('sviDeviceSha'); var devEl = document.getElementById('sviDeviceSha');
if (el) el.textContent = devSha || '-'; if (devEl) devEl.textContent = devSha || '-';
} }
if (result !== undefined) { if (result) {
var el = document.getElementById('sviResult'); var resultEl = document.getElementById('sviResult');
if (el) { if (resultEl) {
el.textContent = result.text || '-'; resultEl.textContent = result.text || '-';
el.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : ''); resultEl.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
} }
} }
} }
@@ -23,4 +23,4 @@ export function showBleVerifyInfo(frontSha, devSha, result) {
export function hideBleVerifyInfo() { export function hideBleVerifyInfo() {
var panel = document.getElementById('stageVerifyInfo'); var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; } if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; }
} }
+1 -17
View File
@@ -26,23 +26,7 @@ window.bleOta = {
}, },
disconnect: disconnectBle, disconnect: disconnectBle,
showToast: showToast, showToast: showToast,
notifyFileChange: async function(file, handle) { notifyFileChange: notifyFileChange,
// ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget
// 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。
if (state.uploading || state.autoFlashing) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
// ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度
if (typeof window.isUploading !== 'undefined' && window.isUploading) {
bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) {
if (handle) state.selectedFileHandle = handle;
await notifyFileChange(file, handle);
}
},
clearFile: clearSelectedFile, clearFile: clearSelectedFile,
refreshButtons: function() { refreshButtons: function() {
setConnectionState(!!state.server); setConnectionState(!!state.server);
+18 -9
View File
@@ -108,7 +108,11 @@ function setConnectionState(connected, text, buildVersion, buildTime, wifi, upti
async function testConnection() { async function testConnection() {
var baseUrl = getBaseUrl(); var baseUrl = getBaseUrl();
try { try {
var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) }); /* 状态端点禁用 HTTP 缓存,避免命中旧响应 */
var resp = await fetch(baseUrl + '/system/status', {
signal: AbortSignal.timeout(3000),
cache: 'no-store'
});
if (!resp.ok) { if (!resp.ok) {
setConnectionState(false, 'HTTP ' + resp.status, '', '', {}, null); setConnectionState(false, 'HTTP ' + resp.status, '', '', {}, null);
return; return;
@@ -209,7 +213,11 @@ async function handleDbcFiles(fileList) {
} }
} }
afterDbcSourcesChanged({ resetSelection: true }); /* 仅在确有新增/覆盖时才 reset 并刷新 UI;
* 全部解析失败时保留用户当前选中/勾选/信号值,只弹错误提示。 */
if (added || replaced) {
afterDbcSourcesChanged({ resetSelection: true });
}
if (added || replaced) { if (added || replaced) {
var msg = 'DBC:'; var msg = 'DBC:';
@@ -317,10 +325,10 @@ function bindEvents() {
scheduleCanPersist(); scheduleCanPersist();
}); });
} }
el.sendSelectedBtn.addEventListener('click', sendSelectedMessage); if (el.sendSelectedBtn) el.sendSelectedBtn.addEventListener('click', sendSelectedMessage);
el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual); if (el.loadPreviewToRawBtn) el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual);
el.sendManualBtn.addEventListener('click', sendManualFrame); if (el.sendManualBtn) el.sendManualBtn.addEventListener('click', sendManualFrame);
el.resetManualBtn.addEventListener('click', function () { if (el.resetManualBtn) el.resetManualBtn.addEventListener('click', function () {
resetManualFields(); resetManualFields();
scheduleCanPersist(); scheduleCanPersist();
}); });
@@ -328,9 +336,10 @@ function bindEvents() {
refreshManualHint(); refreshManualHint();
scheduleCanPersist(); scheduleCanPersist();
} }
el.manualData.addEventListener('input', onManualFieldChange); /* 手动帧三字段统一守卫,避免任一元素缺失时后续绑定被中断 */
el.manualDlc.addEventListener('input', onManualFieldChange); ['manualData', 'manualDlc', 'manualId'].forEach(function (id) {
el.manualId.addEventListener('input', onManualFieldChange); if (el[id]) el[id].addEventListener('input', onManualFieldChange);
});
if (el.manualChannel) el.manualChannel.addEventListener('change', scheduleCanPersist); if (el.manualChannel) el.manualChannel.addEventListener('change', scheduleCanPersist);
if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages); if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages);
if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () { if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () {
+13 -2
View File
@@ -88,7 +88,8 @@ export function bytesToHex(bytes) {
/** @description 转义 CSS 选择器中的特殊字符。 */ /** @description 转义 CSS 选择器中的特殊字符。 */
export function cssEscape(value) { export function cssEscape(value) {
if (window.CSS && CSS.escape) return CSS.escape(value); if (window.CSS && CSS.escape) return CSS.escape(value);
return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\\\' + ch; }); /* fallback:每个非安全字符前加单个反斜杠(CSS 转义),双引号属性值内可安全使用 */
return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\' + ch; });
} }
/** @description 去除回车并 trim,用于 DBC 搜索等输入归一化。 */ /** @description 去除回车并 trim,用于 DBC 搜索等输入归一化。 */
@@ -232,7 +233,7 @@ export function parseDbc(text) {
signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || ''; signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || '';
signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {}; signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {};
var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name]; var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name];
signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? dv : null; signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? Number(dv) : null;
}); });
}); });
@@ -251,6 +252,16 @@ export function findSignal(message, signalName) {
return null; return null;
} }
/**
* @description 按 key 在当前合并 DBC 中查找报文(O(1),走 messageMap)。
* messageMap 由 rebuildMergedDbc() 维护,key 格式与 message.key / state.selectedMessageKey 一致。
* 无 DBC 或 key 不存在时返回 null。
*/
export function findMessageByKey(key) {
if (!state.dbc || !state.dbc.messageMap || !key) return null;
return state.dbc.messageMap[key] || null;
}
/** @description 解析 SG_ 行的 multiplex token(M / mN / 空)。 */ /** @description 解析 SG_ 行的 multiplex token(M / mN / 空)。 */
export function parseMultiplexToken(token) { export function parseMultiplexToken(token) {
if (!token) return { role: null, value: null }; if (!token) return { role: null, value: null };
+29 -39
View File
@@ -7,14 +7,13 @@
* common/utils.js(escapeHtml/formatBytes)、 * common/utils.js(escapeHtml/formatBytes)、
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。 * signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。
*/ */
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717x'; import { state, el, padHex, normalizeWhitespace, cssEscape, findMessageByKey } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717x'; import { escapeHtml, formatBytes } from '../common/utils.js?v=20260717x';
import { renderInlineSignalGrid, ensureMessageDefaults, updateEncodedPreview } from './signals.js?v=20260717x'; import { renderInlineSignalGrid, ensureMessageDefaults, updateEncodedPreview } from './signals.js?v=20260717x';
import { scheduleCanPersist } from './persist.js?v=20260717x'; import { scheduleCanPersist } from './persist.js?v=20260717x';
// ==================== DBC 文件信息 ==================== // ==================== DBC 文件信息 ====================
/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */ /** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小),并刷新多 DBC 摘要条与文件 chip 列表。 */
/** @description 刷新多 DBC 摘要条与文件 chip 列表。 */
export function updateDbcFileInfo() { export function updateDbcFileInfo() {
var sources = state.dbcSources || []; var sources = state.dbcSources || [];
var has = sources.length > 0; var has = sources.length > 0;
@@ -54,9 +53,9 @@ export function updateDbcFileInfo() {
el.dbcFileChips.innerHTML = sources.map(function (s) { el.dbcFileChips.innerHTML = sources.map(function (s) {
var nMsg = (s.dbc && s.dbc.messages) ? s.dbc.messages.length : 0; var nMsg = (s.dbc && s.dbc.messages) ? s.dbc.messages.length : 0;
return '' + return '' +
'<span class="dbc-file-chip" data-source-id="' + sanitizeHtml(s.id) + '" title="' + sanitizeHtml(s.name) + ' · ' + nMsg + ' 报文">' + '<span class="dbc-file-chip" data-source-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(s.name) + ' · ' + nMsg + ' 报文">' +
'<span class="dbc-file-chip-name">' + sanitizeHtml(s.name) + '</span>' + '<span class="dbc-file-chip-name">' + escapeHtml(s.name) + '</span>' +
'<button type="button" class="dbc-file-chip-remove" data-remove-id="' + sanitizeHtml(s.id) + '" title="移除此文件">×</button>' + '<button type="button" class="dbc-file-chip-remove" data-remove-id="' + escapeHtml(s.id) + '" title="移除此文件">×</button>' +
'</span>'; '</span>';
}).join(''); }).join('');
} }
@@ -93,7 +92,7 @@ export function filterMessages() {
// ==================== 发送节点过滤 ==================== // ==================== 发送节点过滤 ====================
/** @description 收集 DBC 中所有发送节点(去重 + 排序)。 */ /** @description 收集 DBC 中所有发送节点(去重 + 排序)。 */
export function collectTransmitters() { function collectTransmitters() {
var set = Object.create(null); var set = Object.create(null);
var list = []; var list = [];
if (!state.dbc) return list; if (!state.dbc) return list;
@@ -106,10 +105,10 @@ export function collectTransmitters() {
} }
/** @description 渲染发送节点过滤面板的复选框列表。 */ /** @description 渲染发送节点过滤面板的复选框列表。 */
export function renderNodeFilterList() { function renderNodeFilterList() {
if (!el.nodeFilterList) return; if (!el.nodeFilterList) return;
var txs = collectTransmitters(); var txs = collectTransmitters();
var kw = (el.nodeFilterSearch && el.nodeFilterSearch.value || '').trim().toLowerCase(); var kw = (el.nodeFilterSearch ? (el.nodeFilterSearch.value || '') : '').trim().toLowerCase();
if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; }); if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; });
if (!txs.length) { if (!txs.length) {
el.nodeFilterList.innerHTML = '<div class="status-empty">无匹配节点</div>'; el.nodeFilterList.innerHTML = '<div class="status-empty">无匹配节点</div>';
@@ -125,8 +124,8 @@ export function renderNodeFilterList() {
el.nodeFilterList.innerHTML = txs.map(function (name) { el.nodeFilterList.innerHTML = txs.map(function (name) {
var checked = !!state.transmitterFilter[name] ? ' checked' : ''; var checked = !!state.transmitterFilter[name] ? ' checked' : '';
return '<label class="can-node-filter-item">' + return '<label class="can-node-filter-item">' +
'<input type="checkbox" data-node="' + sanitizeHtml(name) + '"' + checked + '>' + '<input type="checkbox" data-node="' + escapeHtml(name) + '"' + checked + '>' +
'<span class="can-node-filter-name">' + sanitizeHtml(name) + '</span>' + '<span class="can-node-filter-name">' + escapeHtml(name) + '</span>' +
'<span class="can-node-filter-count">' + (counts[name] || 0) + '</span>' + '<span class="can-node-filter-count">' + (counts[name] || 0) + '</span>' +
'</label>'; '</label>';
}).join(''); }).join('');
@@ -167,7 +166,7 @@ export function updateNodeFilterLabel() {
} }
/** @description 打开/关闭发送节点过滤面板。 */ /** @description 打开/关闭发送节点过滤面板。 */
export function setNodeFilterPanelOpen(open) { function setNodeFilterPanelOpen(open) {
if (!el.nodeFilterPanel || !el.nodeFilterBtn) return; if (!el.nodeFilterPanel || !el.nodeFilterBtn) return;
if (open) { if (open) {
renderNodeFilterList(); renderNodeFilterList();
@@ -245,23 +244,23 @@ export function renderMessageList() {
var comment = msg.comment || ''; var comment = msg.comment || '';
if (comment.length > 36) comment = comment.slice(0, 36) + '...'; if (comment.length > 36) comment = comment.slice(0, 36) + '...';
var rowHtml = '' + var rowHtml = '' +
'<div class="message-row' + active + '" data-key="' + sanitizeHtml(msg.key) + '" role="button" tabindex="0">' + '<div class="message-row' + active + '" data-key="' + escapeHtml(msg.key) + '" role="button" tabindex="0">' +
'<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + sanitizeHtml(msg.key) + '"' + checked + '></span>' + '<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + escapeHtml(msg.key) + '"' + checked + '></span>' +
'<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + sanitizeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' + '<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + escapeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' +
'<span class="message-col message-col-index">' + (index + 1) + '</span>' + '<span class="message-col message-col-index">' + (index + 1) + '</span>' +
'<span class="message-col message-col-dbc" title="' + sanitizeHtml(msg.sourceName || '-') + '">' + '<span class="message-col message-col-dbc" title="' + escapeHtml(msg.sourceName || '-') + '">' +
sanitizeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '</span>' + escapeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '</span>' +
'<span class="message-col message-col-name" title="' + sanitizeHtml(msg.name) + '">' + '<span class="message-col message-col-name" title="' + escapeHtml(msg.name) + '">' +
sanitizeHtml(msg.name) + '</span>' + escapeHtml(msg.name) + '</span>' +
'<span class="message-col message-col-id">' + sanitizeHtml(idHex) + '</span>' + '<span class="message-col message-col-id">' + escapeHtml(idHex) + '</span>' +
'<span class="message-col message-col-channel">CH' + sanitizeHtml(channelTxt) + '</span>' + '<span class="message-col message-col-channel">CH' + escapeHtml(channelTxt) + '</span>' +
'<span class="message-col message-col-type">' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' + '<span class="message-col message-col-type">' + escapeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' +
'<span class="message-col message-col-dlc">' + sanitizeHtml(msg.dlc) + '</span>' + '<span class="message-col message-col-dlc">' + escapeHtml(msg.dlc) + '</span>' +
'<span class="message-col message-col-signal">' + sanitizeHtml(msg.signals.length) + '</span>' + '<span class="message-col message-col-signal">' + escapeHtml(msg.signals.length) + '</span>' +
'<span class="message-col message-col-node" title="' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '">' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '</span>' + '<span class="message-col message-col-node" title="' + escapeHtml(msg.transmitter || 'Vector__XXX') + '">' + escapeHtml(msg.transmitter || 'Vector__XXX') + '</span>' +
'<span class="message-col message-col-comment" title="' + sanitizeHtml(msg.comment || '') + '">' + sanitizeHtml(comment || '-') + '</span>' + '<span class="message-col message-col-comment" title="' + escapeHtml(msg.comment || '') + '">' + escapeHtml(comment || '-') + '</span>' +
'</div>'; '</div>';
var expandHtml = '<div class="message-row-expand" data-key="' + sanitizeHtml(msg.key) + '"' + (expanded ? '' : ' hidden') + '>' + var expandHtml = '<div class="message-row-expand" data-key="' + escapeHtml(msg.key) + '"' + (expanded ? '' : ' hidden') + '>' +
'<div class="signal-table-host"></div>' + '<div class="signal-table-host"></div>' +
'</div>'; '</div>';
return rowHtml + expandHtml; return rowHtml + expandHtml;
@@ -331,10 +330,7 @@ function handleMessageRowClick(event) {
function toggleMessageExpand(key) { function toggleMessageExpand(key) {
if (!state.dbc) return; if (!state.dbc) return;
var msg = null; var msg = findMessageByKey(key);
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === key) { msg = state.dbc.messages[i]; break; }
}
if (!msg) return; if (!msg) return;
var willExpand = !state.expandedKeys[key]; var willExpand = !state.expandedKeys[key];
if (willExpand) { if (willExpand) {
@@ -384,13 +380,7 @@ export function updateBatchUi() {
/** @description 选中指定 key 的报文,并刷新列表/信号编辑/编码预览。 */ /** @description 选中指定 key 的报文,并刷新列表/信号编辑/编码预览。 */
export function selectMessage(key) { export function selectMessage(key) {
if (!state.dbc) return; if (!state.dbc) return;
var msg = null; var msg = findMessageByKey(key);
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === key) {
msg = state.dbc.messages[i];
break;
}
}
if (!msg) return; if (!msg) return;
state.selectedMessageKey = key; state.selectedMessageKey = key;
ensureMessageDefaults(msg); ensureMessageDefaults(msg);
+3 -8
View File
@@ -25,9 +25,7 @@ function safeJsonParse(raw) {
function transmitterFilterToPlain() { function transmitterFilterToPlain() {
var out = {}; var out = {};
for (var k in state.transmitterFilter) { for (var k in state.transmitterFilter) {
if (Object.prototype.hasOwnProperty.call(state.transmitterFilter, k) || state.transmitterFilter[k]) { if (state.transmitterFilter[k]) out[k] = true;
out[k] = !!state.transmitterFilter[k];
}
} }
return out; return out;
} }
@@ -196,11 +194,8 @@ export function loadCanSession() {
state.transmitterFilterEmpty = !anyTx; state.transmitterFilterEmpty = !anyTx;
/* 选中键若不存在则清空,交由调用方选第一条 */ /* 选中键若不存在则清空,交由调用方选第一条 */
if (state.selectedMessageKey && state.dbc && state.dbc.messageMap) { if (state.selectedMessageKey &&
if (!state.dbc.messageMap[state.selectedMessageKey]) { (!state.dbc || !state.dbc.messageMap[state.selectedMessageKey])) {
state.selectedMessageKey = '';
}
} else if (state.selectedMessageKey && (!state.dbc || !state.dbc.messages.length)) {
state.selectedMessageKey = ''; state.selectedMessageKey = '';
} }
+144 -157
View File
@@ -13,7 +13,7 @@
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。 * 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。
*/ */
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717x'; import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x'; import { escapeHtml } from '../common/utils.js?v=20260717x';
import { showToast } from '../common/toast.js?v=20260717x'; import { showToast } from '../common/toast.js?v=20260717x';
import { updateBatchUi } from './messages.js?v=20260717x'; import { updateBatchUi } from './messages.js?v=20260717x';
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717x'; import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717x';
@@ -29,13 +29,13 @@ function formatTime(date) {
} }
// ==================== 发送错误码描述 ==================== // ==================== 发送错误码描述 ====================
export var CAN_SEND_ERR_DESC = { var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)', '-1': '参数错误(通道/ID/DLC 无效)',
'-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)', '-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)',
'-3': 'GD32 CAN 总线拒绝', '-3': 'GD32 CAN 总线拒绝',
}; };
export var GD32_ERR_DESC = { var GD32_ERR_DESC = {
0xE0: 'payload 过短', 0xE0: 'payload 过短',
0xE1: '通道号非法', 0xE1: '通道号非法',
0xE2: 'BUS_OFF(总线离线,检查接线/终端电阻/对端上电)', 0xE2: 'BUS_OFF(总线离线,检查接线/终端电阻/对端上电)',
@@ -46,7 +46,7 @@ export var GD32_ERR_DESC = {
}; };
/** @description 构建发送响应的可读文本(成功显示 recv,失败拼接 CAN_SEND_ERR_DESC + GD32_ERR_DESC)。 */ /** @description 构建发送响应的可读文本(成功显示 recv,失败拼接 CAN_SEND_ERR_DESC + GD32_ERR_DESC)。 */
export function buildSendResponseText(result) { function buildSendResponseText(result) {
if (result.status === 'ok') { if (result.status === 'ok') {
return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据'; return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据';
} }
@@ -133,11 +133,27 @@ function updateResponseBox(box, textEl, result, mode) {
el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err'); el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err');
el.manualHint.innerHTML = result.status === 'ok' el.manualHint.innerHTML = result.status === 'ok'
? '原始帧已发送到设备,若有响应则显示在下方。' ? '原始帧已发送到设备,若有响应则显示在下方。'
: '设备返回错误码 <b>' + sanitizeHtml(result.code) + '</b>。'; : '设备返回错误码 <b>' + escapeHtml(result.code) + '</b>。';
} }
} }
// ==================== 发送历史 ==================== // ==================== 发送历史 ====================
/**
* 构造一条历史条目。集中字段,避免多处重复拼对象。
*/
function makeHistoryEntry(opts) {
return {
time: formatTime(new Date()),
mode: opts.mode,
ok: opts.ok,
channel: opts.channel,
idHex: opts.idHex,
dlc: opts.dlc,
dataHex: opts.dataHex || '',
resultText: opts.resultText
};
}
/** /**
* @param {object} entry * @param {object} entry
* @param {{ silent?: boolean }} [opts] silent=true 时不立刻重绘/落盘(批量用) * @param {{ silent?: boolean }} [opts] silent=true 时不立刻重绘/落盘(批量用)
@@ -162,11 +178,11 @@ export function renderHistory() {
var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc; var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc;
return '' + return '' +
'<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' + '<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' +
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + sanitizeHtml(item.mode) + '</span>' + '<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + escapeHtml(item.mode) + '</span>' +
'<span class="history-item-time">' + sanitizeHtml(item.time) + '</span>' + '<span class="history-item-time">' + escapeHtml(item.time) + '</span>' +
'<span class="history-item-main" title="' + sanitizeHtml(meta) + '">' + sanitizeHtml(meta) + '</span>' + '<span class="history-item-main" title="' + escapeHtml(meta) + '">' + escapeHtml(meta) + '</span>' +
'<span class="history-item-hex" title="' + sanitizeHtml(item.dataHex || '') + '">' + sanitizeHtml(item.dataHex || '--') + '</span>' + '<span class="history-item-hex" title="' + escapeHtml(item.dataHex || '') + '">' + escapeHtml(item.dataHex || '--') + '</span>' +
'<span class="history-item-foot" title="' + sanitizeHtml(item.resultText || '') + '">' + sanitizeHtml(item.resultText) + '</span>' + '<span class="history-item-foot" title="' + escapeHtml(item.resultText || '') + '">' + escapeHtml(item.resultText) + '</span>' +
'</div>'; '</div>';
}).join(''); }).join('');
} }
@@ -191,128 +207,115 @@ export async function sendCheckedMessages() {
var ok = 0, fail = 0; var ok = 0, fail = 0;
var silent = { silent: true }; var silent = { silent: true };
/* 先全部编码,避免发到一半才因编码失败打断节奏 */ /* 按 key 建索引,避免勾选数百条时 O(n²) 扫描 messages */
var prepared = []; var msgByKey = Object.create(null);
for (var i = 0; i < keys.length; i++) { for (var mi = 0; mi < state.dbc.messages.length; mi++) {
var key = keys[i]; var mm = state.dbc.messages[mi];
var msg = null; msgByKey[mm.key] = mm;
for (var m = 0; m < state.dbc.messages.length; m++) {
if (state.dbc.messages[m].key === key) { msg = state.dbc.messages[m]; break; }
}
if (!msg) continue;
ensureMessageDefaults(msg);
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
var dataBytes;
try {
dataBytes = encodeMessageData(msg, getSignalValueMap(msg));
} catch (err) {
fail++;
addHistoryEntry({
time: formatTime(new Date()),
mode: 'BATCH',
ok: false,
channel: channel,
idHex: idHex,
dlc: msg.dlc,
dataHex: '',
resultText: msg.name + ' · 编码失败: ' + err.message
}, silent);
continue;
}
var dataHex = bytesToHex(dataBytes);
var dlc = (typeof msg.dlc === 'number') ? msg.dlc : dataBytes.length;
prepared.push({
msg: msg,
idHex: idHex,
dlc: dlc,
dataHex: dataHex,
frame: { id: msg.id, dlc: dlc, data: dataHex }
});
} }
/* 分块批量接口;不支持时整段回退为无间隔单帧 */ try {
var useBatchApi = true; /* 先全部编码,避免发到一半才因编码失败打断节奏 */
for (var off = 0; off < prepared.length; ) { var prepared = [];
var chunk = prepared.slice(off, off + BATCH_CHUNK); for (var i = 0; i < keys.length; i++) {
off += chunk.length; var key = keys[i];
var frames = chunk.map(function (p) { return p.frame; }); var msg = msgByKey[key];
var results = null; if (!msg) continue;
ensureMessageDefaults(msg);
if (useBatchApi) { var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
var dataBytes;
try { try {
results = await sendCanBatchRequest(channel, frames); dataBytes = encodeMessageData(msg, getSignalValueMap(msg));
if (results.length !== chunk.length) {
throw new Error('results 长度不匹配');
}
} catch (err) {
useBatchApi = false;
results = null;
/* 本 chunk 改单帧紧接 */
}
}
if (results) {
for (var r = 0; r < chunk.length; r++) {
var item = chunk[r];
var result = results[r] || { status: 'error', code: -3 };
if (result.status === 'ok') ok++; else fail++;
addHistoryEntry({
time: formatTime(new Date()),
mode: 'BATCH',
ok: result.status === 'ok',
channel: channel,
idHex: item.idHex,
dlc: item.dlc,
dataHex: item.dataHex || '',
resultText: item.msg.name + ' · ' + buildSendResponseText(result)
}, silent);
}
continue;
}
/* 回退:逐帧 HTTP,帧间无 sleep、无重试 */
for (var s = 0; s < chunk.length; s++) {
var one = chunk[s];
try {
var oneResult = await sendCanRequest({
ch: channel,
id: one.frame.id,
dlc: one.frame.dlc,
data: one.frame.data
});
if (oneResult.status === 'ok') ok++; else fail++;
addHistoryEntry({
time: formatTime(new Date()),
mode: 'BATCH',
ok: oneResult.status === 'ok',
channel: channel,
idHex: one.idHex,
dlc: one.dlc,
dataHex: one.dataHex || '',
resultText: one.msg.name + ' · ' + buildSendResponseText(oneResult)
}, silent);
} catch (err) { } catch (err) {
fail++; fail++;
addHistoryEntry({ addHistoryEntry(makeHistoryEntry({
time: formatTime(new Date()), mode: 'BATCH', ok: false, channel: channel, idHex: idHex,
mode: 'BATCH', dlc: msg.dlc, dataHex: '',
ok: false, resultText: msg.name + ' · 编码失败: ' + err.message
channel: channel, }), silent);
idHex: one.idHex, continue;
dlc: one.dlc, }
dataHex: one.dataHex || '', var dataHex = bytesToHex(dataBytes);
resultText: one.msg.name + ' · ' + err.message var dlc = (typeof msg.dlc === 'number') ? msg.dlc : dataBytes.length;
}, silent); prepared.push({
msg: msg,
idHex: idHex,
dlc: dlc,
dataHex: dataHex,
frame: { id: msg.id, dlc: dlc, data: dataHex }
});
}
/* 分块批量接口;不支持时整段回退为无间隔单帧 */
var useBatchApi = true;
for (var off = 0; off < prepared.length; ) {
var chunk = prepared.slice(off, off + BATCH_CHUNK);
off += chunk.length;
var frames = chunk.map(function (p) { return p.frame; });
var results = null;
if (useBatchApi) {
try {
results = await sendCanBatchRequest(channel, frames);
if (results.length !== chunk.length) {
throw new Error('results 长度不匹配');
}
} catch (err) {
useBatchApi = false;
results = null;
/* 本 chunk 改单帧紧接 */
}
}
if (results) {
for (var r = 0; r < chunk.length; r++) {
var item = chunk[r];
var result = results[r] || { status: 'error', code: -3 };
if (result.status === 'ok') ok++; else fail++;
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: result.status === 'ok', channel: channel,
idHex: item.idHex, dlc: item.dlc, dataHex: item.dataHex,
resultText: item.msg.name + ' · ' + buildSendResponseText(result)
}), silent);
}
continue;
}
/* 回退:逐帧 HTTP,帧间无 sleep、无重试 */
for (var s = 0; s < chunk.length; s++) {
var one = chunk[s];
try {
var oneResult = await sendCanRequest({
ch: channel,
id: one.frame.id,
dlc: one.frame.dlc,
data: one.frame.data
});
if (oneResult.status === 'ok') ok++; else fail++;
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: oneResult.status === 'ok', channel: channel,
idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex,
resultText: one.msg.name + ' · ' + buildSendResponseText(oneResult)
}), silent);
} catch (err) {
fail++;
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: false, channel: channel,
idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex,
resultText: one.msg.name + ' · ' + err.message
}), silent);
}
} }
} }
}
renderHistory(); renderHistory();
scheduleCanPersist(); scheduleCanPersist();
state.batchInFlight = false; var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
updateBatchUi(); showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning'); } finally {
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone); state.batchInFlight = false;
updateBatchUi();
}
} }
// ==================== 选中报文发送 ==================== // ==================== 选中报文发送 ====================
@@ -338,16 +341,12 @@ export async function sendSelectedMessage() {
try { try {
var result = await sendCanRequest(payload); var result = await sendCanRequest(payload);
updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc'); updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc');
addHistoryEntry({ addHistoryEntry(makeHistoryEntry({
time: formatTime(new Date()), mode: 'DBC', ok: result.status === 'ok', channel: channel,
mode: 'DBC',
ok: result.status === 'ok',
channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dlc: msg.dlc, dataHex: bytesToHex(state.generatedData),
dataHex: bytesToHex(state.generatedData),
resultText: buildSendResponseText(result) resultText: buildSendResponseText(result)
}); }));
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) { } catch (err) {
if (el.dbcResp) { if (el.dbcResp) {
@@ -355,16 +354,12 @@ export async function sendSelectedMessage() {
el.dbcResp.className = 'can-response err'; el.dbcResp.className = 'can-response err';
el.dbcRespText.textContent = err.message; el.dbcRespText.textContent = err.message;
} }
addHistoryEntry({ addHistoryEntry(makeHistoryEntry({
time: formatTime(new Date()), mode: 'DBC', ok: false, channel: channel,
mode: 'DBC',
ok: false,
channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3), idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dlc: msg.dlc, dataHex: bytesToHex(state.generatedData),
dataHex: bytesToHex(state.generatedData),
resultText: err.message resultText: err.message
}); }));
showToast('发送失败: ' + err.message, 'error'); showToast('发送失败: ' + err.message, 'error');
} }
} }
@@ -397,7 +392,7 @@ export function refreshManualHint() {
el.manualHint.innerHTML = '原始 HEX 合法,当前长度 <b>' + (dataHex.length / 2) + '</b> 字节。'; el.manualHint.innerHTML = '原始 HEX 合法,当前长度 <b>' + (dataHex.length / 2) + '</b> 字节。';
} catch (err) { } catch (err) {
el.manualHint.className = 'helper-note err'; el.manualHint.className = 'helper-note err';
el.manualHint.innerHTML = sanitizeHtml(err.message); el.manualHint.innerHTML = escapeHtml(err.message);
} }
} }
@@ -427,16 +422,12 @@ export async function sendManualFrame() {
data: dataHex data: dataHex
}); });
updateResponseBox(el.manualResp, el.manualRespText, result, 'manual'); updateResponseBox(el.manualResp, el.manualRespText, result, 'manual');
addHistoryEntry({ addHistoryEntry(makeHistoryEntry({
time: formatTime(new Date()), mode: 'RAW', ok: result.status === 'ok', channel: channel,
mode: 'RAW',
ok: result.status === 'ok',
channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc, dlc: dlc, dataHex: dataHex,
dataHex: dataHex,
resultText: buildSendResponseText(result) resultText: buildSendResponseText(result)
}); }));
showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error'); showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) { } catch (err) {
if (el.manualResp) { if (el.manualResp) {
@@ -444,16 +435,12 @@ export async function sendManualFrame() {
el.manualResp.className = 'can-response err'; el.manualResp.className = 'can-response err';
el.manualRespText.textContent = err.message; el.manualRespText.textContent = err.message;
} }
addHistoryEntry({ addHistoryEntry(makeHistoryEntry({
time: formatTime(new Date()), mode: 'RAW', ok: false, channel: channel,
mode: 'RAW',
ok: false,
channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3), idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc, dlc: dlc, dataHex: dataHex,
dataHex: dataHex,
resultText: err.message resultText: err.message
}); }));
showToast('发送失败: ' + err.message, 'error'); showToast('发送失败: ' + err.message, 'error');
} }
} }
+53 -19
View File
@@ -5,7 +5,7 @@
* *
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。 * 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
*/ */
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717x'; import { state, el, padHex, bytesToHex, cssEscape, findSignal, findMessageByKey } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x'; import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x';
import { scheduleCanPersist } from './persist.js?v=20260717x'; import { scheduleCanPersist } from './persist.js?v=20260717x';
@@ -53,10 +53,7 @@ function getDefaultSignalValue(signal) {
/** @description 获取当前选中的报文对象(无选中返回 null)。 */ /** @description 获取当前选中的报文对象(无选中返回 null)。 */
export function getSelectedMessage() { export function getSelectedMessage() {
if (!state.dbc || !state.selectedMessageKey) return null; if (!state.dbc || !state.selectedMessageKey) return null;
for (var i = 0; i < state.dbc.messages.length; i++) { return findMessageByKey(state.selectedMessageKey);
if (state.dbc.messages[i].key === state.selectedMessageKey) return state.dbc.messages[i];
}
return null;
} }
function getMultiplexerSignal(message) { function getMultiplexerSignal(message) {
@@ -111,6 +108,7 @@ export function renderInlineSignalGrid(message, host) {
function renderSignalRow(message, signal, index) { function renderSignalRow(message, signal, index) {
var bag = getSignalValueMap(message); var bag = getSignalValueMap(message);
var enumKeys = Object.keys(signal.enumMap || {}); var enumKeys = Object.keys(signal.enumMap || {});
var enumCount = enumKeys.length;
var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola'); var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola');
var rangeText = ''; var rangeText = '';
if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) { if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) {
@@ -123,7 +121,7 @@ function renderSignalRow(message, signal, index) {
var comment = signal.comment || '-'; var comment = signal.comment || '-';
if (comment.length > 48) comment = comment.slice(0, 48) + '...'; if (comment.length > 48) comment = comment.slice(0, 48) + '...';
var inputHtml = ''; var inputHtml = '';
if (enumKeys.length) { if (enumCount) {
inputHtml = '<select class="input signal-input signal-input-compact" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) + '">' + inputHtml = '<select class="input signal-input signal-input-compact" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) + '">' +
enumKeys.map(function (key) { enumKeys.map(function (key) {
var label = signal.enumMap[key]; var label = signal.enumMap[key];
@@ -145,7 +143,7 @@ function renderSignalRow(message, signal, index) {
'<span class="signal-cell signal-cell-value">' + inputHtml + '</span>' + '<span class="signal-cell signal-cell-value">' + inputHtml + '</span>' +
'<span class="signal-cell signal-cell-range">' + '<span class="signal-cell signal-cell-range">' +
'<b>' + sanitizeHtml(rangeText) + '</b>' + '<b>' + sanitizeHtml(rangeText) + '</b>' +
(enumKeys.length ? '<small>' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumKeys.length > 3 ? ' ...' : '') + '</small>' : '<small>' + sanitizeHtml(factorText) + '</small>') + (enumCount ? '<small>' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumCount > 3 ? ' ...' : '') + '</small>' : '<small>' + sanitizeHtml(factorText) + '</small>') +
'</span>' + '</span>' +
'<span class="signal-cell signal-cell-meta">' + '<span class="signal-cell signal-cell-meta">' +
'<b>' + sanitizeHtml(bitRange) + '</b>' + '<b>' + sanitizeHtml(bitRange) + '</b>' +
@@ -160,15 +158,16 @@ function handleSignalInputEvent(evt) {
var key = input.getAttribute('data-msg'); var key = input.getAttribute('data-msg');
var name = input.getAttribute('data-signal'); var name = input.getAttribute('data-signal');
if (!key || !name || !state.dbc) return; if (!key || !name || !state.dbc) return;
var message = null; var message = findMessageByKey(key);
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === key) { message = state.dbc.messages[i]; break; }
}
if (!message) return; if (!message) return;
setSignalValue(message, name, input.value); setSignalValue(message, name, input.value);
var signal = findSignal(message, name); var signal = findSignal(message, name);
if (signal && signal.multiplexRole === 'mux') { if (signal && signal.multiplexRole === 'mux') {
var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host'); /* MUX 值变更后需重建信号表格(激活信号集变化)。
* 限定在 el.messageList 作用域内查找,避免多列表/多 DBC 场景匹配到错误节点
*(与 messages.js 中 toggleMessageExpand 的作用域策略一致)。 */
var root = el.messageList || document;
var inlineGrid = root.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host');
if (inlineGrid) renderInlineSignalGrid(message, inlineGrid); if (inlineGrid) renderInlineSignalGrid(message, inlineGrid);
} }
if (key === state.selectedMessageKey) updateEncodedPreview(); if (key === state.selectedMessageKey) updateEncodedPreview();
@@ -256,7 +255,7 @@ export function encodeMessageData(message, signalValues) {
return bytes; return bytes;
} }
/** @description 粗判信号位域是否至少有一部分落在 dlc 字节内(越界整段则跳过)。 */ /** @description 粗判信号位域是否完全落在 dlc 字节内(越界整段则跳过)。 */
function signalFitsInDlc(signal, dlc) { function signalFitsInDlc(signal, dlc) {
if (!signal || dlc <= 0) return false; if (!signal || dlc <= 0) return false;
var maxBit = dlc * 8 - 1; var maxBit = dlc * 8 - 1;
@@ -264,9 +263,22 @@ function signalFitsInDlc(signal, dlc) {
var end = signal.startBit + signal.length - 1; var end = signal.startBit + signal.length - 1;
return signal.startBit >= 0 && signal.startBit <= maxBit && end <= maxBit; return signal.startBit >= 0 && signal.startBit <= maxBit && end <= maxBit;
} }
/* Motorola:用起始位所在字节粗判;更严的检查仍在 setBitByIndex */ /* float:要求整段字节落在 DLC 内(startBit 必须字节对齐,由 writeFloatSignal 另校验) */
var startByte = Math.floor(signal.startBit / 8); if (signal.valueType === 'float' || signal.valueType === 'double') {
return startByte >= 0 && startByte < dlc && signal.length > 0; var startByte = signal.startBit / 8;
var byteLen = signal.length / 8;
return startByte >= 0 && Number.isInteger(startByte) &&
startByte + byteLen <= dlc;
}
/* Motorola:用 buildMotorolaBitPositions 计算实际占用位序,检查最大位是否落在 DLC 内。
* 此前仅判 startByte,长跨字节信号末位可能越界 → 会被 setBitByIndex 抛错而非跳过,
* 与 little 分支"越界整段跳过"语义不一致;这里统一为"越界即跳过"。 */
if (signal.length <= 0 || signal.length > 64) return false;
var positions = buildMotorolaBitPositions(signal.startBit, signal.length);
for (var j = 0; j < positions.length; j++) {
if (positions[j] < 0 || positions[j] > maxBit) return false;
}
return true;
} }
function parseSignalInput(signal, inputValue) { function parseSignalInput(signal, inputValue) {
@@ -274,6 +286,16 @@ function parseSignalInput(signal, inputValue) {
if (value === '' || value == null) value = getDefaultSignalValue(signal); if (value === '' || value == null) value = getDefaultSignalValue(signal);
var num = Number(value); var num = Number(value);
if (!Number.isFinite(num)) throw new Error(signal.name + ' 不是合法数字'); if (!Number.isFinite(num)) throw new Error(signal.name + ' 不是合法数字');
/* enum 信号:取值是离散枚举 key(数字字符串),DBC 的 min/max 是物理连续值范围,
* 对 enum 不适用 —— 合法 enum 值可能落在 [min,max] 之外(例如 fault 枚举)。
* 因此 enum 信号只做"值必须 ∈ enumMap keys"的校验,跳过 min/max。 */
var enumKeys = Object.keys(signal.enumMap || {});
if (enumKeys.length) {
if (enumKeys.indexOf(String(value)) < 0) {
throw new Error(signal.name + ' 不是合法枚举值:' + value);
}
return { numeric: num, text: String(value).trim() };
}
if (Number.isFinite(signal.min) && num < signal.min) { if (Number.isFinite(signal.min) && num < signal.min) {
throw new Error(signal.name + ' 低于最小值 ' + signal.min); throw new Error(signal.name + ' 低于最小值 ' + signal.min);
} }
@@ -296,6 +318,10 @@ function physicalToRawBigInt(signal, parsedValue) {
isIntegerLike(parsedValue.text)) { isIntegerLike(parsedValue.text)) {
return BigInt(parsedValue.text); return BigInt(parsedValue.text);
} }
/* factor=0 是 DBC 异常情况(规范上 factor 不应为 0),此处为防御性兜底:
* - 有 offset:物理值直接减 offset 作为 raw(把 offset 视为"物理=raw 的偏移基线")
* - 无 offset:物理值即 raw
* 实际 DBC 不应走到此分支,但避免除零导致 NaN。 */
var rawNumber = signal.factor === 0 var rawNumber = signal.factor === 0
? Math.round(signal.offset ? (parsedValue.numeric - signal.offset) : parsedValue.numeric) ? Math.round(signal.offset ? (parsedValue.numeric - signal.offset) : parsedValue.numeric)
: Math.round((parsedValue.numeric - signal.offset) / signal.factor); : Math.round((parsedValue.numeric - signal.offset) / signal.factor);
@@ -367,13 +393,21 @@ function writeFloatSignal(bytes, signal, physicalValue) {
if (signal.startBit % 8 !== 0) { if (signal.startBit % 8 !== 0) {
throw new Error(signal.name + ' 浮点信号起始位必须按字节对齐'); throw new Error(signal.name + ' 浮点信号起始位必须按字节对齐');
} }
var buffer = new ArrayBuffer(signal.length / 8); var startByte = signal.startBit / 8;
var byteLen = signal.length / 8;
/* 越界防御:signalFitsInDlc 已在 encodeMessageData 入口过滤,这里二次断言,
* 避免任何直接调用方(如未来新增的 decode 路径)漏检导致写越界。 */
if (startByte + byteLen > bytes.length) {
throw new Error(signal.name + ' 浮点信号写入越界:startByte=' + startByte +
' + length=' + byteLen + ' > 缓冲区 ' + bytes.length + ' 字节');
}
var buffer = new ArrayBuffer(byteLen);
var view = new DataView(buffer); var view = new DataView(buffer);
if (signal.length === 32) view.setFloat32(0, physicalValue, true); if (signal.length === 32) view.setFloat32(0, physicalValue, true);
else view.setFloat64(0, physicalValue, true); else view.setFloat64(0, physicalValue, true);
var rawBytes = new Uint8Array(buffer); var rawBytes = new Uint8Array(buffer);
for (var i = 0; i < rawBytes.length; i++) { for (var i = 0; i < rawBytes.length; i++) {
bytes[(signal.startBit / 8) + i] = rawBytes[i]; bytes[startByte + i] = rawBytes[i];
} }
} }
@@ -393,7 +427,7 @@ function setBitByIndex(bytes, bitIndex, bitValue) {
var bitInByte = bitIndex % 8; var bitInByte = bitIndex % 8;
if (byteIndex < 0 || byteIndex >= bytes.length) { if (byteIndex < 0 || byteIndex >= bytes.length) {
throw new Error('信号写入越界,bit=' + bitIndex + throw new Error('信号写入越界,bit=' + bitIndex +
'(缓冲区 ' + bytes.length + ' 字节 / DLC=' + bytes.length + ')'); '(缓冲区 ' + bytes.length + ' 字节)');
} }
if (bitValue) bytes[byteIndex] |= (1 << bitInByte); if (bitValue) bytes[byteIndex] |= (1 << bitInByte);
else bytes[byteIndex] &= ~(1 << bitInByte); else bytes[byteIndex] &= ~(1 << bitInByte);
+26 -24
View File
@@ -2,8 +2,9 @@
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用) * can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/ */
import { showToast } from './common/toast.js?v=20260717x'; import { showToast } from './common/toast.js?v=20260717x';
import { escapeHtml } from './common/utils.js';
var state = { fbTimer: null, fbAbortCtrl: null }; var state = { fbTimer: null, fbInFlight: false };
var el = {}; var el = {};
@@ -27,11 +28,6 @@ function cacheElements() {
ids.forEach(function (id) { el[id] = document.getElementById(id); }); ids.forEach(function (id) { el[id] = document.getElementById(id); });
} }
function sanitize(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getBaseUrl() { function getBaseUrl() {
var ip = ''; var ip = '';
if (el.monIp && el.monIp.value) { if (el.monIp && el.monIp.value) {
@@ -63,15 +59,18 @@ function setConnState(ok, text) {
// ── 1Hz 轮询 /can/feedback ───────────────────────────────── // ── 1Hz 轮询 /can/feedback ─────────────────────────────────
async function pollFeedback() { async function pollFeedback() {
if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch (_e) {} } // 防重入:上一次 fetch 未完成则跳过本次 tick,避免并发请求叠加
state.fbAbortCtrl = new AbortController(); if (state.fbInFlight) return;
var fbPollTimer = setTimeout(function () { try { state.fbAbortCtrl.abort(); } catch (_e) {} }, 3000); state.fbInFlight = true;
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, 3000);
try { try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal }); var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: ac.signal });
if (!resp.ok) return; if (!resp.ok) return;
var data = await resp.json(); var data = await resp.json();
var channels = data.channels || []; var channels = data.channels || [];
var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value, 10) : 0; var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value, 10) : 0;
if (!Number.isFinite(selectedCh)) selectedCh = 0;
for (var i = 0; i < 3; i++) { for (var i = 0; i < 3; i++) {
var ch = channels[i]; var ch = channels[i];
@@ -84,7 +83,7 @@ async function pollFeedback() {
var sel = channels[selectedCh]; var sel = channels[selectedCh];
if (sel && sel.valid && sel.entries) { if (sel && sel.valid && sel.entries) {
renderIdTable(sel.entries, sel.count || sel.entries.length); renderIdTable(sel.entries);
if (el.idsnapStatus) { if (el.idsnapStatus) {
el.idsnapStatus.textContent = (sel.entries.length) + ' 个 ID'; el.idsnapStatus.textContent = (sel.entries.length) + ' 个 ID';
el.idsnapStatus.className = 'pill online'; el.idsnapStatus.className = 'pill online';
@@ -98,15 +97,16 @@ async function pollFeedback() {
el.fbPollStatus.className = 'pill online'; el.fbPollStatus.className = 'pill online';
} }
setConnState(true, '已连接'); setConnState(true, '已连接');
clearTimeout(fbPollTimer);
} catch (e) { } catch (e) {
clearTimeout(fbPollTimer);
if (e && e.name === 'AbortError') return; if (e && e.name === 'AbortError') return;
if (el.fbPollStatus) { if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 离线'; el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline'; el.fbPollStatus.className = 'pill offline';
} }
setConnState(false, '连接失败'); setConnState(false, '连接失败');
} finally {
clearTimeout(timer);
state.fbInFlight = false;
} }
} }
@@ -183,20 +183,22 @@ function renderIdTable(entries) {
tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
return; return;
} }
var html = ''; var frag = document.createDocumentFragment();
for (var i = 0; i < entries.length; i++) { for (var i = 0; i < entries.length; i++) {
var e = entries[i]; var e = entries[i];
html += '<tr>'; var tr = document.createElement('tr');
html += '<td>' + (i + 1) + '</td>'; tr.innerHTML =
html += '<td class="mono">' + sanitize(e.id) + '</td>'; '<td>' + (i + 1) + '</td>' +
html += '<td>' + e.dlc + '</td>'; '<td class="mono">' + escapeHtml(e.id) + '</td>' +
html += '<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>'; '<td>' + escapeHtml(e.dlc) + '</td>' +
html += '<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>'; '<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>' +
html += '<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>'; '<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>' +
html += '<td class="idsnap-data mono">' + sanitize(e.data || '--') + '</td>'; '<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>' +
html += '</tr>'; '<td class="idsnap-data mono">' + escapeHtml(e.data || '--') + '</td>';
frag.appendChild(tr);
} }
tbody.innerHTML = html; tbody.innerHTML = '';
tbody.appendChild(frag);
} }
function renderIdTableEmpty() { function renderIdTableEmpty() {
+59 -51
View File
@@ -18,20 +18,25 @@ import { showToast } from './toast.js';
// ==================== 连接管理 ==================== // ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) { export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接');
// header pill // header pill
var pc = document.getElementById('pillConn'); var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt'); var pct = document.getElementById('pillConnTxt');
pc.className = connected ? 'pill online' : 'pill offline'; if (pc) {
pct.textContent = text || (connected ? '已连接' : '未连接'); pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = label;
}
// connection card indicator // connection card indicator
var cs = document.getElementById('connStatus'); var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt'); var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
if (cst) cst.textContent = connected ? (text || '已连接') : '未连接'; if (cst) cst.textContent = connected ? label : '未连接';
// primary badge // primary badge
var bc = document.getElementById('bConn'); var bc = document.getElementById('bConn');
bc.textContent = connected ? '在线' : '离线'; if (bc) {
bc.className = 'v ' + (connected ? 'ok' : 'err'); bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
}
} }
export function updateHealthUi(healthCode, freeHeap) { export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机']; var healthNames = ['正常', '警告', '危险', '关机'];
@@ -141,15 +146,14 @@ function updateBootInitUi(data) {
if (!window._bootInitLogged) { if (!window._bootInitLogged) {
window._bootInitLogged = true; window._bootInitLogged = true;
var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : ''); var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : '');
try { addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
} catch (_) {}
} }
ensureHealthBootToggleBound(); ensureHealthBootToggleBound();
} }
export function updateHeapAndTaskStatusUi(data) { export function updateHeapAndTaskStatusUi(data) {
if (!data) return; if (!data) return;
var formatPercent = window.formatPercent;
var ota = data.ota || {}; var ota = data.ota || {};
var setText = function (id, value) { var setText = function (id, value) {
var el = document.getElementById(id); var el = document.getElementById(id);
@@ -214,9 +218,9 @@ export function updateHeapAndTaskStatusUi(data) {
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0); return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
}); });
setText('cpuUsageText', 'CPU 已用 ' + window.formatPercent(usagePct)); setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
setText('cpuUsagePct', window.formatPercent(usagePct)); setText('cpuUsagePct', formatPercent(usagePct));
setText('cpuIdlePct', window.formatPercent(idlePct)); setText('cpuIdlePct', formatPercent(idlePct));
setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--'); setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--');
setText('cpuTaskCount', String(tasks.length)); setText('cpuTaskCount', String(tasks.length));
setCpuFill(usagePct); setCpuFill(usagePct);
@@ -234,13 +238,13 @@ export function updateHeapAndTaskStatusUi(data) {
'<div class="status-task cpu-task">' + '<div class="status-task cpu-task">' +
'<div class="status-task-head">' + '<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' + '<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(pct)) + '</div>' + '<div class="status-task-ratio">' + escapeHtml(formatPercent(pct)) + '</div>' +
'</div>' + '</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' + '<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' +
'<div class="status-task-meta">' + '<div class="status-task-meta">' +
'<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' + '<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' + '<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
'<div><span>占比</span><b>' + escapeHtml(window.formatPercent(pct)) + '</b></div>' + '<div><span>占比</span><b>' + escapeHtml(formatPercent(pct)) + '</b></div>' +
'</div>' + '</div>' +
'</div>'; '</div>';
}).join(''); }).join('');
@@ -294,7 +298,7 @@ export function updateHeapAndTaskStatusUi(data) {
'<div class="status-task">' + '<div class="status-task">' +
'<div class="status-task-head">' + '<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(name) + '</div>' + '<div class="status-task-name">' + escapeHtml(name) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(usagePct)) + '</div>' + '<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
'</div>' + '</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' + '<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
'<div class="status-task-meta">' + '<div class="status-task-meta">' +
@@ -351,38 +355,50 @@ export async function diagnoseNetwork() {
return result.join(';'); return result.join(';');
} }
/**
* 统一的 /system/status 抓取器:独立 AbortController,超时后中断,
* 避免与递归调度的 pollStatus 互相干扰。
* 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。
*/
async function fetchSystemStatus(timeoutMs) {
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal });
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.status = resp.status;
throw err;
}
return await resp.json();
} finally {
clearTimeout(timer);
}
}
/** @description 测试与 ESP32 的连接(GET /status)。 */ /** @description 测试与 ESP32 的连接(GET /status)。 */
export async function testConnection() { export async function testConnection() {
// 立即发一次请求检测
// 手动检测使用独立的 AbortController,避免打断递归调度的 pollStatus
var testAbort = new AbortController();
var testTimer = setTimeout(function() { try { testAbort.abort(); } catch(_e) {} }, 3000);
try { try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { var data = await fetchSystemStatus(3000);
signal: testAbort.signal window.latestSystemStatus = data;
}); window.updateSystemStatusUi(data);
if (resp.ok) { var wifi = data.wifi || {};
var data = await resp.json(); addLog('I', '连接', wifi.connected
window.latestSystemStatus = data; ? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
window.updateSystemStatusUi(data); : '未连接:' + (wifi.mode || 'STA'));
var wifi = data.wifi || {}; showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
addLog('I', '连接', wifi.connected wifi.connected ? 'success' : 'warning');
? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
: '未连接:' + (wifi.mode || 'STA'));
showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
wifi.connected ? 'success' : 'warning');
} else {
addLog('E', '连接', 'HTTP ' + resp.status);
showToast('设备响应异常: HTTP ' + resp.status, 'error');
}
} catch (e) { } catch (e) {
if (e && e.status) {
addLog('E', '连接', 'HTTP ' + e.status);
showToast('设备响应异常: HTTP ' + e.status, 'error');
return;
}
addLog('E', '连接', '连接失败: ' + e.message); addLog('E', '连接', '连接失败: ' + e.message);
// 连接失败时自动诊断 // 连接失败时自动诊断
var diag = await diagnoseNetwork(); var diag = await diagnoseNetwork();
addLog('W', '诊断', diag); addLog('W', '诊断', diag);
showToast('无法连接: ' + diag, 'error'); showToast('无法连接: ' + diag, 'error');
} finally {
clearTimeout(testTimer);
} }
} }
export async function refreshSystemStatusNow(event) { export async function refreshSystemStatusNow(event) {
@@ -390,27 +406,19 @@ export async function refreshSystemStatusNow(event) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
// 手动刷新使用独立的 AbortController,避免与递归调度的 pollStatus 互相干扰
var manualAbort = new AbortController();
var manualTimer = setTimeout(function() { try { manualAbort.abort(); } catch(_e) {} }, 3000);
try { try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { var data = await fetchSystemStatus(3000);
signal: manualAbort.signal
});
if (!resp.ok) {
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
showToast('状态刷新失败: HTTP ' + resp.status, 'error');
return;
}
var data = await resp.json();
window.latestSystemStatus = data; window.latestSystemStatus = data;
window.updateSystemStatusUi(data); window.updateSystemStatusUi(data);
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
addLog('I', '状态', '已手动刷新 /system/status'); addLog('I', '状态', '已手动刷新 /system/status');
} catch (e) { } catch (e) {
if (e && e.status) {
addLog('E', '状态', '刷新失败: HTTP ' + e.status);
showToast('状态刷新失败: HTTP ' + e.status, 'error');
return;
}
addLog('E', '状态', '刷新失败: ' + e.message); addLog('E', '状态', '刷新失败: ' + e.message);
showToast('状态刷新失败', 'error'); showToast('状态刷新失败', 'error');
} finally {
clearTimeout(manualTimer);
} }
} }
+69 -38
View File
@@ -1,70 +1,101 @@
/** /**
* log.js - 日志系统(带去重 + 自动滚动) * log.js - 日志系统(带去重 + 自动滚动 + 上限裁剪)
* *
* 日志环形缓冲、去重、过滤、自动滚动逻辑。 * 日志环形缓冲、去重、过滤、自动滚动逻辑。
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。 * 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
*/ */
import { formatTime } from './utils.js'; import { formatTime, escapeHtml } from './utils.js';
// ==================== 日志系统状态 ==================== // ==================== 日志系统状态 ====================
let logFilter = 'all'; var MAX_LOGS = 1000; // 缓冲上限,超出后丢弃最旧条目
let logEntries = []; var logFilter = 'all';
let lastLogHash = null; var logEntries = [];
let autoScroll = true; var lastLogHash = null;
var autoScroll = true;
// 缓存 DOM 引用,避免高频日志下重复 getElementById
var scrollEl = document.getElementById('logScroll');
var countEl = document.getElementById('logCount');
// ==================== 日志系统 (带去重 + 自动滚动) ==================== // ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) { function makeLogHash(level, tag, message) {
return level + '|' + tag + '|' + message; return level + '|' + tag + '|' + message;
} }
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */ /** 构造一条日志 DOM 节点(tag/message 经 HTML 转义,防 XSS)。 */
export function addLog(level, tag, message) { function createLogLine(entry) {
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;
var div = document.createElement('div'); var div = document.createElement('div');
div.className = 'log-line'; div.className = 'log-line';
div.innerHTML = div.innerHTML =
'<span class="lt">[' + time + ']</span>' + '<span class="lt">[' + escapeHtml(entry.time) + ']</span>' +
'<span class="lv ' + level + '">' + level + '</span>' + '<span class="lv ' + escapeHtml(entry.level) + '">' + escapeHtml(entry.level) + '</span>' +
'<span class="lg">[' + tag + ']</span>' + '<span class="lg">[' + escapeHtml(entry.tag) + ']</span>' +
'<span class="lm">' + message + '</span>'; '<span class="lm">' + escapeHtml(entry.message) + '</span>';
scroll.appendChild(div); return div;
if (autoScroll) scroll.scrollTop = scroll.scrollHeight; }
document.getElementById('logCount').textContent = logEntries.length; /** 同步 #logCount 为当前可见条目数。 */
function syncCount() {
if (countEl) countEl.textContent = logEntries.length;
}
/** 超过上限时丢弃最旧条目,并同步移除其 DOM 节点。 */
function trimIfNeeded() {
while (logEntries.length > MAX_LOGS) {
var old = logEntries.shift();
if (old && old.div && old.div.parentNode) old.div.parentNode.removeChild(old.div);
}
}
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
export function addLog(level, tag, message) {
var hash = makeLogHash(level, tag, message);
if (hash === lastLogHash) return; // 去重
lastLogHash = hash;
var entry = {
time: formatTime(new Date()),
level: level,
tag: tag,
message: message,
div: null
};
logEntries.push(entry);
trimIfNeeded();
if (logFilter !== 'all' && level !== logFilter) {
syncCount();
return;
}
var div = createLogLine(entry);
entry.div = div;
if (scrollEl) {
scrollEl.appendChild(div);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
}
syncCount();
} }
export function clearLogs() { export function clearLogs() {
document.getElementById('logScroll').innerHTML = ''; if (scrollEl) scrollEl.innerHTML = '';
logEntries = []; logEntries = [];
lastLogHash = null; lastLogHash = null;
document.getElementById('logCount').textContent = '0'; syncCount();
} }
export function setLogFilter(level, btn) { export function setLogFilter(level, btn) {
document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); }); document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active'); if (btn) btn.classList.add('active');
logFilter = level; logFilter = level;
var scroll = document.getElementById('logScroll'); if (!scrollEl) return;
scroll.innerHTML = ''; var frag = document.createDocumentFragment();
logEntries.forEach(function (e) { logEntries.forEach(function (e) {
if (level === 'all' || e.level === level) { if (level === 'all' || e.level === level) {
var div = document.createElement('div'); var div = createLogLine(e);
div.className = 'log-line'; e.div = div;
div.innerHTML = frag.appendChild(div);
'<span class="lt">[' + e.time + ']</span>' + } else {
'<span class="lv ' + e.level + '">' + e.level + '</span>' + e.div = null;
'<span class="lg">[' + e.tag + ']</span>' +
'<span class="lm">' + e.message + '</span>';
scroll.appendChild(div);
} }
}); });
if (autoScroll) scroll.scrollTop = scroll.scrollHeight; scrollEl.innerHTML = '';
scrollEl.appendChild(frag);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
} }
export function toggleAutoScroll() { export function toggleAutoScroll() {
autoScroll = !autoScroll; autoScroll = !autoScroll;
var btn = document.getElementById('autoScrollBtn'); var btn = document.getElementById('autoScrollBtn');
btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动'; if (btn) btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
} }
+91 -71
View File
@@ -1,11 +1,11 @@
/** /**
* polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取 * polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取
* *
* 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。 * 递归 setTimeout 调度 /system/status + 递归 setTimeout 调度 /logs,避免 setInterval 慢响应被 abort / 重叠抢 socket。
* *
* 模块级状态(仅本模块使用,不导出): * 模块级状态(仅本模块使用,不导出):
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs * statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / logsInFlight / statusPollIntervalMs
* uploadPollChip / lastLogTimestamp / pageVisible * uploadPollChip / pageVisible
* *
* 共享状态(通过 window.* 引用): * 共享状态(通过 window.* 引用):
* window.pollingPausedForUpload — 上传期间暂停轮询标志 * window.pollingPausedForUpload — 上传期间暂停轮询标志
@@ -24,6 +24,7 @@
* window.getEspIp / window.getBaseUrl * window.getEspIp / window.getBaseUrl
* window.updateSystemStatusUi / window.updateEsp32StagesFromPhase * window.updateSystemStatusUi / window.updateEsp32StagesFromPhase
* window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState * window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState
* window.applyDeviceBusyRuntimeState
* window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset * window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset
* window.updateUploadButtonLabel * window.updateUploadButtonLabel
*/ */
@@ -31,6 +32,17 @@ import { addLog } from './log.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { updateConnectionUi, diagnoseNetwork } from './connection.js'; import { updateConnectionUi, diagnoseNetwork } from './connection.js';
// ==================== 状态码常量(与后端 ota_coordinator.h / ota_relay.h 枚举对齐) ====================
// sys_state_code
const SYS_GD32_STORING = 1;
const SYS_GD32_RELAYING = 3;
const SYS_ESP32_RECEIVING = 5;
const SYS_ESP32_FINALIZING = 6;
const SYS_ESP32_REBOOTING = 7;
// relay_state(1~4 为握手/协商/传输/收尾,非 IDLE)
const RELAY_ACTIVE_MIN = 1;
const RELAY_ACTIVE_MAX = 4;
// ==================== 轮询状态 ==================== // ==================== 轮询状态 ====================
// 共享状态(初始化到 window,供 connection.js 等其他模块读写) // 共享状态(初始化到 window,供 connection.js 等其他模块读写)
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
@@ -40,9 +52,10 @@ window.lastRelayState = 0; // 上次成功的 relay_state
// 模块级状态(仅本模块使用) // 模块级状态(仅本模块使用)
var statusTimerId = null; var statusTimerId = null;
var logsTimerId = null; var logsTimerId = null;
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用) var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(5s 超时用)
var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms) var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms)
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠 var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
@@ -62,8 +75,11 @@ export function setStatusPollIntervalMs(ms) {
* 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval, * 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval,
* 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。 * 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。
* 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。 * 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。
* ★ 上传暂停期间(window.pollingPausedForUpload)不重挂定时器,
* 避免在途请求的 finally 把刚清掉的轮询又挂回来。
*/ */
export function scheduleStatusPoll(delayMs) { export function scheduleStatusPoll(delayMs) {
if (window.pollingPausedForUpload) return;
if (statusTimerId) clearTimeout(statusTimerId); if (statusTimerId) clearTimeout(statusTimerId);
var interval = statusPollIntervalMs; var interval = statusPollIntervalMs;
statusTimerId = setTimeout(function() { statusTimerId = setTimeout(function() {
@@ -72,15 +88,24 @@ export function scheduleStatusPoll(delayMs) {
}, delayMs !== undefined ? delayMs : interval); }, delayMs !== undefined ? delayMs : interval);
} }
/** @description 调度下一次 /logs 轮询(递归 setTimeout,仿 scheduleStatusPoll)。 */
function scheduleLogsPoll(delayMs) {
if (logsTimerId) clearTimeout(logsTimerId);
logsTimerId = setTimeout(function() {
logsTimerId = null;
pollLogs();
}, delayMs !== undefined ? delayMs : statusPollIntervalMs);
}
/** @description 启动定时轮询(状态 + 日志)。 */ /** @description 启动定时轮询(状态 + 日志)。 */
export function startPolling() { export function startPolling() {
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
statusPollIntervalMs = 1000; statusPollIntervalMs = 1000;
if (statusTimerId) clearTimeout(statusTimerId); if (statusTimerId) clearTimeout(statusTimerId);
if (logsTimerId) clearInterval(logsTimerId); if (logsTimerId) clearTimeout(logsTimerId);
scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度 scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度
logsTimerId = setInterval(pollLogs, 1000); scheduleLogsPoll(0);
} }
export function pausePollingForUpload() { export function pausePollingForUpload() {
if (window.pollingPausedForUpload) return; if (window.pollingPausedForUpload) return;
@@ -96,7 +121,7 @@ export function pausePollingForUpload() {
statusTimerId = null; statusTimerId = null;
} }
if (logsTimerId) { if (logsTimerId) {
clearInterval(logsTimerId); clearTimeout(logsTimerId);
logsTimerId = null; logsTimerId = null;
} }
if (statusAbortCtrl) { if (statusAbortCtrl) {
@@ -111,16 +136,48 @@ export function resumePollingAfterUpload() {
uploadPollChip = null; uploadPollChip = null;
window.consecutivePollFailures = 0; window.consecutivePollFailures = 0;
statusPollIntervalMs = 1000; statusPollIntervalMs = 1000;
if (logsTimerId) clearInterval(logsTimerId); if (logsTimerId) clearTimeout(logsTimerId);
logsTimerId = setInterval(pollLogs, statusPollIntervalMs);
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续 // 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
if (statusPollInFlight) { if (statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId); if (statusTimerId) clearTimeout(statusTimerId);
} else { } else {
scheduleStatusPoll(0); scheduleStatusPoll(0);
} }
pollLogs(); scheduleLogsPoll(0);
} }
/**
* @description 统一的 ESP32 重启完成处理:清重启标志 → 推进阶段 6 → 状态机转 DONE → 刷新按钮。
* 抽出来供 pollStatus 成功分支与 pollLogs 启动 banner 分支复用,避免两处逻辑漂移。
* 以 isEsp32Rebooting 作幂等守卫:已处理过的重启不再重复弹 toast / 写日志 / 重复转 DONE。
* @param {string} source 日志来源描述(如 '启动 banner 收到'),用于区分日志文案。
*/
function markEsp32RebootComplete(source) {
if (!window.isEsp32Rebooting) return;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
// ★ 状态机:ESP32 重启完成 → DONE
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
window.otaSessionTransition(window.OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
}
/** @description 判断设备最近一次成功轮询时是否处于「忙」状态(OTA 存储/中继/接收/收尾/重启 或 relay 握手~收尾)。 */
function isLastDeviceBusy() {
var s = window.lastSysStateCode;
var r = window.lastRelayState;
return s === SYS_GD32_STORING || s === SYS_GD32_RELAYING ||
s === SYS_ESP32_RECEIVING || s === SYS_ESP32_FINALIZING ||
s === SYS_ESP32_REBOOTING ||
(r >= RELAY_ACTIVE_MIN && r <= RELAY_ACTIVE_MAX);
}
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */ /** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
export async function pollStatus() { export async function pollStatus() {
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求 // 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
@@ -153,25 +210,12 @@ export async function pollStatus() {
if (window.isEsp32Rebooting || if (window.isEsp32Rebooting ||
window.otaSessionState === window.OtaSessionState.REBOOTING) { window.otaSessionState === window.OtaSessionState.REBOOTING) {
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000); var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
if (!window.rebootObservedDown && rbElapsed < 10) { // pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询,
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询 // 保持"重启中"状态,不清除标志,不推进阶段。
// 保持"重启中"状态,不清除标志,不推进阶段 // ★ 真正的"重启完成"由 pollLogs 收到启动 banner 触发;
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner(MAIN: ESP32-C3 OTA Relay v1.0 启动)触发 // 这里只在已观察到下线(rebootObservedDown)或超 10s 安全阈值时接受恢复。
} else { if (window.rebootObservedDown || rbElapsed >= 10) {
// 设备已重启并恢复在线(rebootObservedDown=true) markEsp32RebootComplete();
// 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
// ★ 状态机:ESP32 重启完成 → DONE
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
window.otaSessionTransition(window.OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
} }
} }
try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); } try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
@@ -188,15 +232,7 @@ export async function pollStatus() {
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次, // 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。 // 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
if (pageVisible) window.consecutivePollFailures++; if (pageVisible) window.consecutivePollFailures++;
// 判断设备是否处于"已知忙/重启"状态(依据最近一次成功轮询的 sys_state_code): var lastDeviceBusy = isLastDeviceBusy();
// 1=GD32_STORING, 3=GD32_RELAYING, 5=ESP32_RECEIVING,
// 6=ESP32_FINALIZING, 7=ESP32_REBOOTING
// 也包含 relay_state 非 IDLE(1-4 表示握手/协商/传输/收尾)
var lastDeviceBusy =
window.lastSysStateCode === 1 || window.lastSysStateCode === 3 ||
window.lastSysStateCode === 5 || window.lastSysStateCode === 6 ||
window.lastSysStateCode === 7 ||
(window.lastRelayState >= 1 && window.lastRelayState <= 4);
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的) // OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈 // 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。 // ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
@@ -216,14 +252,7 @@ export async function pollStatus() {
if (sessionFailed) { if (sessionFailed) {
if (lastDeviceBusy) { if (lastDeviceBusy) {
// 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段 // 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段
var pcF = document.getElementById('pillConn'); window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA 后台运行中),等待回 IDLE', '设备忙');
var pctF = document.getElementById('pillConnTxt');
if (pcF) pcF.className = 'pill processing';
if (pctF) pctF.textContent = '设备忙';
var csF = document.getElementById('connStatus');
var cstF = document.getElementById('connStatusTxt');
if (csF) csF.className = 'conn-indicator warn';
if (cstF) cstF.textContent = '设备忙(OTA 后台运行中),等待回 IDLE';
updateConnectionUi(true, '设备忙'); updateConnectionUi(true, '设备忙');
} else { } else {
// 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留 // 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留
@@ -253,14 +282,7 @@ export async function pollStatus() {
} else if (sessionActive || lastDeviceBusy) { } else if (sessionActive || lastDeviceBusy) {
// 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙" // 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙"
// 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导) // 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导)
var pc = document.getElementById('pillConn'); window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA/重启中),保持 1s 轮询', '设备忙');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '设备忙';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询';
updateConnectionUi(true, '设备忙'); updateConnectionUi(true, '设备忙');
} else { } else {
updateConnectionUi(false, '未连接'); updateConnectionUi(false, '未连接');
@@ -271,7 +293,9 @@ export async function pollStatus() {
statusAbortCtrl = null; statusAbortCtrl = null;
/* 固定周期调度:interval 从本轮开始计时扣减请求耗时。 /* 固定周期调度:interval 从本轮开始计时扣减请求耗时。
* 旧逻辑「完成后 + 1000ms」→ 窗口稳定 1100+;现 sample_ms 贴近 1000/400。 * 旧逻辑「完成后 + 1000ms」→ 窗口稳定 1100+;现 sample_ms 贴近 1000/400。
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。 */ * 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。
* ★ 上传暂停期间不重挂(由 scheduleStatusPoll 内部判断),避免在途请求 abort 后
* 的 finally 把刚清掉的轮询又挂回来,导致上传中途再打 /system/status 抢 socket。 */
var elapsed = Date.now() - pollStartedAt; var elapsed = Date.now() - pollStartedAt;
var delay = Math.max(0, statusPollIntervalMs - elapsed); var delay = Math.max(0, statusPollIntervalMs - elapsed);
scheduleStatusPoll(delay); scheduleStatusPoll(delay);
@@ -297,10 +321,11 @@ document.addEventListener('visibilitychange', function () {
pollLogs(); pollLogs();
}); });
// ==================== ESP32 实时日志拉取 (增量) ==================== // ==================== ESP32 实时日志拉取 (增量) ====================
var lastLogTimestamp = 0;
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */ /** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
export async function pollLogs() { export async function pollLogs() {
// ★ ESP32 上传期间也允许日志轮询 // ★ ESP32 上传期间也允许日志轮询;in-flight 守卫避免与 /system/status、/ota/upload 抢 socket
if (logsInFlight) return;
logsInFlight = true;
try { try {
var resp = await fetch(window.getBaseUrl() + '/logs', { var resp = await fetch(window.getBaseUrl() + '/logs', {
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
@@ -308,28 +333,17 @@ export async function pollLogs() {
if (!resp.ok) return; if (!resp.ok) return;
var logs = await resp.json(); var logs = await resp.json();
if (!Array.isArray(logs) || logs.length === 0) return; if (!Array.isArray(logs) || logs.length === 0) return;
var levelMap = { E: 'E', W: 'W', I: 'I', D: 'D' };
logs.forEach(function (line) { logs.forEach(function (line) {
var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/); var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
if (m) { if (m) {
addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd()); addLog(m[1], m[2], m[3].trimEnd());
// ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。 // ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。
// esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断 // esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断
// "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动, // "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动,
// 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。 // 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。
/* 与 main 启动 banner 对齐:ESP32-C3 OTA Relay <FW_VERSION> 启动(勿写死 v1.0) */ /* 与 main 启动 banner 对齐:ESP32-C3 OTA Relay <FW_VERSION> 启动(勿写死 v1.0) */
if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay .*启动/.test(m[3])) { if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay .*启动/.test(m[3])) {
window.isEsp32Rebooting = false; markEsp32RebootComplete('启动 banner 收到');
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', 'ESP32 已恢复在线(启动 banner 收到)');
showToast('ESP32 已恢复在线', 'success');
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
window.otaSessionTransition(window.OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
} }
/* /*
* 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。 * 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。
@@ -347,7 +361,13 @@ export async function pollLogs() {
addLog('I', 'ESP32', line.trimEnd()); addLog('I', 'ESP32', line.trimEnd());
} }
}); });
} catch (e) { /* 静默 */ } } catch (e) { /* 静默 */ } finally {
logsInFlight = false;
// 递归调度下一轮:固定周期扣减本轮耗时,避免慢响应时重叠抢 socket
if (!window.pollingPausedForUpload) {
scheduleLogsPoll(statusPollIntervalMs);
}
}
} }
setTimeout(function () { setTimeout(function () {
window.updateUploadButtonLabel(); window.updateUploadButtonLabel();
+14 -9
View File
@@ -2,32 +2,37 @@
* toast.js - Toast 提示组件 * toast.js - Toast 提示组件
* *
* showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。 * showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。
* 支持 info / success / warning / error 类型,内置 5s 去重防抖。 * 支持 success / error / warning 三类;info 走 warning 同款样式(历史兼容,无专属配色)。
* 内置 5s 去重防抖,相同 (type|message) 5s 内不重复弹出。
*/ */
/** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */ /** @description 显示 Toast 提示消息,支持 success/error/warning(info 同 warning)。 */
const TOAST_DEDUPE_MS = 5000;
const TOAST_AUTOHIDE_MS = 3000;
var _toastHideTimer = null; var _toastHideTimer = null;
var _toastLastKey = ''; var _toastLastKey = '';
var _toastLastAt = 0; var _toastLastAt = 0;
export function showToast(message, type) { export function showToast(message, type) {
type = type || 'success'; type = type || 'success';
var toast = document.getElementById('toast');
if (!toast) return;
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」 // 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
var key = type + '|' + message; var key = type + '|' + message;
var now = Date.now(); var now = Date.now();
if (key === _toastLastKey && (now - _toastLastAt) < 5000) { if (key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
return; return;
} }
_toastLastKey = key; _toastLastKey = key;
_toastLastAt = now; _toastLastAt = now;
const toast = document.getElementById('toast'); var ico = document.getElementById('toastIco');
const ico = document.getElementById('toastIco'); var msg = document.getElementById('toastMsg');
const msg = document.getElementById('toastMsg');
toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show'; toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0'; if (ico) ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
msg.textContent = message; if (msg) msg.textContent = message;
if (_toastHideTimer) clearTimeout(_toastHideTimer); if (_toastHideTimer) clearTimeout(_toastHideTimer);
_toastHideTimer = setTimeout(function () { _toastHideTimer = setTimeout(function () {
toast.classList.remove('show'); toast.classList.remove('show');
_toastHideTimer = null; _toastHideTimer = null;
}, 3000); }, TOAST_AUTOHIDE_MS);
} }
+16
View File
@@ -42,3 +42,19 @@ export function escapeHtml(text) {
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
/**
* @description 将字节数组(Uint8Array 或 ArrayBuffer)转为小写 hex 字符串。
* SHA-256 等哈希显示前需要 toUpperCase()。
* @param {Uint8Array|ArrayBuffer} bytes
* @returns {string}
*/
export function bytesToHex(bytes) {
var arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
var out = '';
for (var i = 0; i < arr.length; i++) {
var h = arr[i].toString(16);
if (h.length < 2) h = '0' + h;
out += h;
}
return out;
}
+75 -66
View File
@@ -10,7 +10,7 @@
* 复用的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.updateUploadButtonLabel / window.showToast / window.addLog * window.updateUploadButtonLabel / window.showToast / window.addLog
*/ */
import { formatBytes } from '../common/utils.js'; import { formatBytes, bytesToHex } from '../common/utils.js';
import { showToast } from '../common/toast.js'; import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js'; import { addLog } from '../common/log.js';
@@ -33,24 +33,31 @@ var IDB_STORE = 'file_handles';
* 刷新后权限常为 prompt,requestPermission 必须在用户手势里才成功。 * 刷新后权限常为 prompt,requestPermission 必须在用户手势里才成功。
* 此处暂存待授权 handle,点击上传区时再 requestPermission 并直接 getFile。 * 此处暂存待授权 handle,点击上传区时再 requestPermission 并直接 getFile。
*/ */
window._pendingFwHandles = window._pendingFwHandles || { esp32: null, gd32: null }; window._pendingFwHandles = { esp32: null, gd32: null };
/** 模块级 IndexedDB 连接缓存,避免每次 put/get 都重开 */
var _idbDbPromise = null;
function openHandleDb() { function openHandleDb() {
return new Promise(function (resolve, reject) { if (!_idbDbPromise) {
if (!window.indexedDB) { _idbDbPromise = new Promise(function (resolve, reject) {
reject(new Error('indexedDB unavailable')); if (!window.indexedDB) {
return; reject(new Error('indexedDB unavailable'));
} return;
var req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
} }
}; var req = indexedDB.open(IDB_NAME, 1);
req.onsuccess = function () { resolve(req.result); }; req.onupgradeneeded = function () {
req.onerror = function () { reject(req.error || new Error('idb open failed')); }; var db = req.result;
}); if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
});
// 失败时清空缓存,下次允许重试
_idbDbPromise.catch(function () { _idbDbPromise = null; });
}
return _idbDbPromise;
} }
function idbPutHandle(chip, handle) { function idbPutHandle(chip, handle) {
@@ -97,8 +104,8 @@ export function loadPersistedFlashTarget() {
} }
} }
/** 保存 FSAPI handle,刷新后可默认恢复该文件 */ /** 保存 FSAPI handle,刷新后可默认恢复该文件(仅模块内使用,不导出) */
export function persistFileHandle(chip, handle) { function persistFileHandle(chip, handle) {
if (chip !== 'esp32' && chip !== 'gd32') return Promise.resolve(); if (chip !== 'esp32' && chip !== 'gd32') return Promise.resolve();
return idbPutHandle(chip, handle || null); return idbPutHandle(chip, handle || null);
} }
@@ -136,6 +143,13 @@ function loadFileMeta(chip) {
return map[chip] || null; return map[chip] || null;
} }
/** 安全调用 window.updateUploadButtonLabel(恢复路径可能在 app.js 初始化前触发) */
function safeUpdateUploadButtonLabel() {
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
}
/** 仅 query,不 request(刷新无用户手势时 request 会失败) */ /** 仅 query,不 request(刷新无用户手势时 request 会失败) */
async function queryHandlePermission(handle) { async function queryHandlePermission(handle) {
if (!handle || typeof handle.getFile !== 'function') return 'denied'; if (!handle || typeof handle.getFile !== 'function') return 'denied';
@@ -157,7 +171,7 @@ async function queryHandlePermission(handle) {
*/ */
export async function resumePersistedFirmwareWithGesture(chip) { export async function resumePersistedFirmwareWithGesture(chip) {
if (chip !== 'esp32' && chip !== 'gd32') return false; if (chip !== 'esp32' && chip !== 'gd32') return false;
var handle = (window._pendingFwHandles && window._pendingFwHandles[chip]) || var handle = (window._pendingFwHandles[chip]) ||
(chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle) || (chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle) ||
null; null;
if (!handle) { if (!handle) {
@@ -183,7 +197,7 @@ export async function resumePersistedFirmwareWithGesture(chip) {
} }
var file = await handle.getFile(); var file = await handle.getFile();
applyRestoredFile(chip, handle, file, { silent: true }); applyRestoredFile(chip, handle, file, { silent: true });
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name); addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success'); showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
return true; return true;
@@ -208,16 +222,14 @@ function applyRestoredFile(chip, handle, file, options) {
if (typeof window.syncBleFileFromSelectedTarget === 'function') { if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget(); window.syncBleFileFromSelectedTarget();
} }
if (typeof window.updateUploadButtonLabel === 'function') { safeUpdateUploadButtonLabel();
window.updateUploadButtonLabel();
}
} }
/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */ /** 无权限时仍展示上次文件名,提示点上传区一键恢复 */
function showPendingReauthUi(chip, handle, meta) { function showPendingReauthUi(chip, handle, meta) {
if (chip === 'esp32') window.esp32FileHandle = handle; if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle; else window.gd32FileHandle = handle;
if (window._pendingFwHandles) window._pendingFwHandles[chip] = handle; window._pendingFwHandles[chip] = handle;
if (window.sharedFirmwareFiles) { if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: null, handle: handle }; window.sharedFirmwareFiles[chip] = { file: null, handle: handle };
} }
@@ -234,9 +246,7 @@ function showPendingReauthUi(chip, handle, meta) {
if (sizeEl) sizeEl.textContent = meta && meta.size ? formatBytes(meta.size) : '--'; if (sizeEl) sizeEl.textContent = meta && meta.size ? formatBytes(meta.size) : '--';
var shaEl = document.getElementById('fileSha256' + suffix); var shaEl = document.getElementById('fileSha256' + suffix);
if (shaEl) shaEl.textContent = '刷新后需点击授权'; if (shaEl) shaEl.textContent = '刷新后需点击授权';
if (typeof window.updateUploadButtonLabel === 'function') { safeUpdateUploadButtonLabel();
window.updateUploadButtonLabel();
}
} }
/** /**
@@ -260,7 +270,7 @@ export async function restorePersistedFirmwareSelection() {
if (perm === 'granted') { if (perm === 'granted') {
var file = await handle.getFile(); var file = await handle.getFile();
applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true }); applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name); addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
continue; continue;
} }
@@ -281,9 +291,7 @@ export async function restorePersistedFirmwareSelection() {
if (typeof window.syncBleFileFromSelectedTarget === 'function') { if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget(); window.syncBleFileFromSelectedTarget();
} }
if (typeof window.updateUploadButtonLabel === 'function') { safeUpdateUploadButtonLabel();
window.updateUploadButtonLabel();
}
} }
export function handleFileSelect(event, chip) { export function handleFileSelect(event, chip) {
@@ -292,7 +300,7 @@ export function handleFileSelect(event, chip) {
/* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */ /* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */
if (chip === 'esp32') window.esp32FileHandle = null; if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null; else window.gd32FileHandle = null;
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null); persistFileHandle(chip, null);
processFile(file, chip); processFile(file, chip);
} }
@@ -303,7 +311,7 @@ export async function pickFileViaFSAPI(chip) {
* 仅在「刷新后待授权」或「尚无已加载 File」时尝试一键恢复。 * 仅在「刷新后待授权」或「尚无已加载 File」时尝试一键恢复。
* 已有可用文件时再点上传区 → 打开选文件对话框(可换文件)。 * 已有可用文件时再点上传区 → 打开选文件对话框(可换文件)。
*/ */
var pending = window._pendingFwHandles && window._pendingFwHandles[chip]; var pending = window._pendingFwHandles[chip];
var hasLoadedFile = !!(chip === 'esp32' ? window.esp32File : window.gd32File); var hasLoadedFile = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
if (pending || !hasLoadedFile) { if (pending || !hasLoadedFile) {
var resumed = await resumePersistedFirmwareWithGesture(chip); var resumed = await resumePersistedFirmwareWithGesture(chip);
@@ -321,7 +329,7 @@ export async function pickFileViaFSAPI(chip) {
? { 'application/octet-stream': ['.bin'] } ? { 'application/octet-stream': ['.bin'] }
: { 'application/octet-stream': ['.bin', '.hex'] }; : { 'application/octet-stream': ['.bin', '.hex'] };
var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle; var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (!prevHandle && window._pendingFwHandles) { if (!prevHandle) {
prevHandle = window._pendingFwHandles[chip] || null; prevHandle = window._pendingFwHandles[chip] || null;
} }
var opts = { var opts = {
@@ -339,8 +347,8 @@ export async function pickFileViaFSAPI(chip) {
var file = await handle.getFile(); var file = await handle.getFile();
if (chip === 'esp32') window.esp32FileHandle = handle; if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle; else window.gd32FileHandle = handle;
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
processFile(file, chip); await processFile(file, chip);
persistFileHandle(chip, handle); persistFileHandle(chip, handle);
persistFileMeta(chip, file); persistFileMeta(chip, file);
persistFlashTarget(chip); persistFlashTarget(chip);
@@ -382,7 +390,7 @@ export function setupDropZone(zoneId, chip) {
* @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }} * @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }}
*/ */
export function parseIntelHex(hexText) { export function parseIntelHex(hexText) {
var baseAddr = 0; // 当前扩展地址 var baseAddr = 0; // 当前扩展地址(uint32)
var segments = []; // { offset, data } 列表 var segments = []; // { offset, data } 列表
var minAddr = 0xFFFFFFFF; var minAddr = 0xFFFFFFFF;
var maxAddr = 0; var maxAddr = 0;
@@ -391,13 +399,13 @@ export function parseIntelHex(hexText) {
var line = lines[i].trim(); var line = lines[i].trim();
if (!line || line[0] !== ':') continue; if (!line || line[0] !== ':') continue;
// 解析行: :LLAAAATT[DD...]CC // 解析行: :LLAAAATT[DD...]CC
var byteCount = parseInt(line.substr(1, 2), 16); var byteCount = parseInt(line.slice(1, 3), 16);
var addr16 = parseInt(line.substr(3, 4), 16); var addr16 = parseInt(line.slice(3, 7), 16);
var recType = parseInt(line.substr(7, 2), 16); var recType = parseInt(line.slice(7, 9), 16);
// 校验和验证 // 校验和验证
var sum = 0; var sum = 0;
for (var j = 1; j < line.length; j += 2) { for (var j = 1; j < line.length; j += 2) {
sum += parseInt(line.substr(j, 2), 16); sum += parseInt(line.slice(j, j + 2), 16);
} }
if ((sum & 0xFF) !== 0) { if ((sum & 0xFF) !== 0) {
throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line); throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line);
@@ -406,17 +414,17 @@ export function parseIntelHex(hexText) {
// EOF // EOF
break; break;
} else if (recType === 0x04) { } else if (recType === 0x04) {
// 扩展线性地址 (upper 16 bits) // 扩展线性地址 (upper 16 bits),>>>0 归整为 uint32
baseAddr = parseInt(line.substr(9, 4), 16) << 16; baseAddr = (parseInt(line.slice(9, 13), 16) << 16) >>> 0;
} else if (recType === 0x02) { } else if (recType === 0x02) {
// 扩展段地址 // 扩展段地址:seg << 4,不加掩码(避免截断 0x10FFEF 等合法地址)
baseAddr = (parseInt(line.substr(9, 4), 16) << 4) & 0xFFFFF; baseAddr = (parseInt(line.slice(9, 13), 16) << 4) >>> 0;
} else if (recType === 0x00) { } else if (recType === 0x00) {
// 数据记录 // 数据记录
var absAddr = baseAddr + addr16; var absAddr = (baseAddr + addr16) >>> 0;
var data = new Uint8Array(byteCount); var data = new Uint8Array(byteCount);
for (var k = 0; k < byteCount; k++) { for (var k = 0; k < byteCount; k++) {
data[k] = parseInt(line.substr(9 + k * 2, 2), 16); data[k] = parseInt(line.slice(9 + k * 2, 11 + k * 2), 16);
} }
segments.push({ offset: absAddr, data: data }); segments.push({ offset: absAddr, data: data });
if (absAddr < minAddr) minAddr = absAddr; if (absAddr < minAddr) minAddr = absAddr;
@@ -459,15 +467,15 @@ export function isIntelHexText(text) {
* 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。 * 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。
* BLE / WiFi 共用此函数,避免转换逻辑重复。 * BLE / WiFi 共用此函数,避免转换逻辑重复。
* @param {File} file 原始文件 * @param {File} file 原始文件
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null}>} * @returns {Promise<{file: File, converted: boolean, baseAddr: number|null, endAddr: number|null}>}
*/ */
export async function convertHexFileToBin(file) { export async function convertHexFileToBin(file) {
if (!file || !file.name.toLowerCase().endsWith('.hex')) { if (!file || !file.name.toLowerCase().endsWith('.hex')) {
return { file: file, converted: false, baseAddr: null }; return { file: file, converted: false, baseAddr: null, endAddr: null };
} }
var text = await file.text(); var text = await file.text();
if (!isIntelHexText(text)) { if (!isIntelHexText(text)) {
return { file: file, converted: false, baseAddr: null }; return { file: file, converted: false, baseAddr: null, endAddr: null };
} }
var result = parseIntelHex(text); var result = parseIntelHex(text);
var binName = file.name.replace(/\.hex$/i, '.bin'); var binName = file.name.replace(/\.hex$/i, '.bin');
@@ -498,8 +506,7 @@ function _computeAndCacheFileSha(file, chip, optArrayBuffer) {
return bufPromise.then(function(buf) { return bufPromise.then(function(buf) {
return crypto.subtle.digest('SHA-256', buf); return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) { }).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer)); var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase();
if (shaElem) shaElem.textContent = hashHex; if (shaElem) shaElem.textContent = hashHex;
var meta = { size: file.size, lastModified: file.lastModified, name: file.name }; var meta = { size: file.size, lastModified: file.lastModified, name: file.name };
if (chip === 'esp32') { if (chip === 'esp32') {
@@ -546,10 +553,13 @@ export function ensureFileShaCached(file, chip, optArrayBuffer) {
// ==================== 文件处理 ==================== // ==================== 文件处理 ====================
/** /**
* @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 * @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。
* 对 GD32 .hex 走异步转换;返回 Promise 以便调用方 await
* (避免上游用 setTimeout 硬等,见 upload-esp32.js)。
* @param {File} file * @param {File} file
* @param {'esp32'|'gd32'} chip * @param {'esp32'|'gd32'} chip
* @param {{silent?: boolean, skipHandlePersist?: boolean}=} options * @param {{silent?: boolean, skipHandlePersist?: boolean}=} options
* silent: 恢复场景不弹 toast;skipHandlePersist: 已从 IDB 读出时不再写回 * silent: 恢复场景不弹 toast;skipHandlePersist: 已从 IDB 读出时不再写回
* @returns {Promise<void>}
*/ */
export function processFile(file, chip, options) { export function processFile(file, chip, options) {
var opts = options || {}; var opts = options || {};
@@ -565,7 +575,7 @@ export function processFile(file, chip, options) {
} }
if (chip === 'gd32' && isHexByName) { if (chip === 'gd32' && isHexByName) {
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象 // GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
convertHexFileToBin(file).then(function(result) { return convertHexFileToBin(file).then(function(result) {
if (!result.converted) { if (!result.converted) {
// 文件名 .hex 但内容不像,直接用原始文件 // 文件名 .hex 但内容不像,直接用原始文件
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理'); addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
@@ -574,9 +584,8 @@ export function processFile(file, chip, options) {
document.getElementById('fileName' + suffix).textContent = file.name; document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent = 'Binary(?)'; document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
window.updateUploadButtonLabel(); safeUpdateUploadButtonLabel();
ensureFileShaCached(file, 'gd32'); return ensureFileShaCached(file, 'gd32');
return;
} }
window.gd32File = result.file; window.gd32File = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
@@ -587,16 +596,16 @@ export function processFile(file, chip, options) {
document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)'; document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size); document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size);
window.updateUploadButtonLabel(); safeUpdateUploadButtonLabel();
ensureFileShaCached(result.file, 'gd32'); return ensureFileShaCached(result.file, 'gd32');
}).catch(function(err) { }).catch(function(err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message); addLog('E', '文件', 'HEX 解析失败: ' + err.message);
if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error'); if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error');
window.gd32File = null; window.gd32File = null;
document.getElementById('fileInfo' + suffix).classList.remove('show'); document.getElementById('fileInfo' + suffix).classList.remove('show');
window.updateUploadButtonLabel(); safeUpdateUploadButtonLabel();
throw err; // 让上游 await 感知失败
}); });
return; // 异步,回调里设置 gd32File
} }
// .bin 文件或 ESP32 文件: 直接使用 // .bin 文件或 ESP32 文件: 直接使用
if (chip === 'esp32') window.esp32File = file; if (chip === 'esp32') window.esp32File = file;
@@ -606,7 +615,7 @@ export function processFile(file, chip, options) {
document.getElementById('fileType' + suffix).textContent = document.getElementById('fileType' + suffix).textContent =
isHexByName ? 'Intel HEX' : 'Binary'; isHexByName ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size); document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
window.updateUploadButtonLabel(); safeUpdateUploadButtonLabel();
if (silent) { if (silent) {
addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')'); addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')');
} else { } else {
@@ -615,7 +624,7 @@ export function processFile(file, chip, options) {
} }
// 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算), // 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算),
// 上传时若文件未变 ensureFileShaCached 命中直接复用。 // 上传时若文件未变 ensureFileShaCached 命中直接复用。
ensureFileShaCached(file, chip); return Promise.resolve(ensureFileShaCached(file, chip));
} }
/** @description 清除已选固件文件并重置对应 UI 状态。 */ /** @description 清除已选固件文件并重置对应 UI 状态。 */
@@ -628,13 +637,13 @@ export function clearFile(chip) {
window.gd32File = null; window.gd32FileHandle = null; window.gd32File = null; window.gd32FileHandle = null;
window._gd32FileSha256 = null; window._gd32FileShaMeta = null; window._gd32FileSha256 = null; window._gd32FileShaMeta = null;
} }
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null); persistFileHandle(chip, null);
persistFileMeta(chip, null); persistFileMeta(chip, null);
document.getElementById('fileInput' + suffix).value = ''; document.getElementById('fileInput' + suffix).value = '';
document.getElementById('fileInfo' + suffix).classList.remove('show'); document.getElementById('fileInfo' + suffix).classList.remove('show');
var shaElem = document.getElementById('fileSha256' + suffix); var shaEl = document.getElementById('fileSha256' + suffix);
if (shaElem) shaElem.textContent = '-'; if (shaEl) shaEl.textContent = '-';
window.updateUploadButtonLabel(); safeUpdateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除'); addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
} }
+64 -34
View File
@@ -12,6 +12,17 @@
*/ */
import { addLog } from '../common/log.js'; import { addLog } from '../common/log.js';
// ==================== 常量 ====================
// ESP32 OTA 阶段 DOM 节点 id(es1..es6 + esd1..esd6)
var ESP32_STAGE_IDS = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var ESP32_DETAIL_IDS = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
// 顶部步骤 pill → phase 名映射
var STEP_MAP = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
// 进度条 DOM 元素懒缓存(首次访问后复用,DOM 未就绪时回退到 getElementById)
var progressFillEl = null;
var progressTxtEl = null;
var progressPctEl = null;
// ==================== 折叠面板 ==================== // ==================== 折叠面板 ====================
export function toggleCollapse(headEl) { export function toggleCollapse(headEl) {
headEl.closest('.card').classList.toggle('collapsed'); headEl.closest('.card').classList.toggle('collapsed');
@@ -29,25 +40,37 @@ export function switchStageFlow(target) {
if (target === 'esp32') { if (target === 'esp32') {
if (esp32El) esp32El.style.display = ''; if (esp32El) esp32El.style.display = '';
if (gd32El) gd32El.style.display = 'none'; if (gd32El) gd32El.style.display = 'none';
} else { } else if (target === 'gd32') {
if (esp32El) esp32El.style.display = 'none'; if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = ''; if (gd32El) gd32El.style.display = '';
} else {
// null / 'ble' / 未知:隐藏两套面板(reset 语义,避免残留显示)
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
} }
// 重置 ESP32 阶段节点 resetEsp32StageNodes();
['es1','es2','es3','es4','es5','es6'].forEach(function(id) { resetGd32StageNodes();
}
/** @description 把 ESP32 阶段节点(es1..es6 / esd1..esd6)复位到初始态。 */
function resetEsp32StageNodes() {
ESP32_STAGE_IDS.forEach(function (id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.className = 'stage-node'; if (el) el.className = 'stage-node';
}); });
['esd1','esd2','esd3','esd4','esd5','esd6'].forEach(function(id) { ESP32_DETAIL_IDS.forEach(function (id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.textContent = ''; if (el) el.textContent = '';
}); });
// 重置 GD32 阶段节点 }
['s1','s2','s3','s4','s5','s6'].forEach(function(id) {
/** @description 把 GD32 阶段节点(s1..s6 / sd1..sd6)复位到初始态。 */
function resetGd32StageNodes() {
['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.className = 'stage-node'; if (el) el.className = 'stage-node';
}); });
['sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) { ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.textContent = ''; if (el) el.textContent = '';
}); });
@@ -59,10 +82,8 @@ export const esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应
export function updateEsp32StagesFromPhase(phase) { export function updateEsp32StagesFromPhase(phase) {
window.esp32OtaPhase = phase; window.esp32OtaPhase = phase;
var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6']; var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var detailIds = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6']; var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; }); all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; }); details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar'); var statusBar = document.getElementById('stageStatusBar');
@@ -78,7 +99,7 @@ export function updateEsp32StagesFromPhase(phase) {
if (!all[i]) continue; if (!all[i]) continue;
if (i < phase - 1) { if (i < phase - 1) {
all[i].className = 'stage-node done'; all[i].className = 'stage-node done';
} else if (i === phase - 1 && phase >= 1) { } else if (i === phase - 1) {
all[i].className = (phase >= 6) ? 'stage-node done' : 'stage-node live'; all[i].className = (phase >= 6) ? 'stage-node done' : 'stage-node live';
if (details[i]) details[i].textContent = esp32PhaseLabels[phase] || ''; if (details[i]) details[i].textContent = esp32PhaseLabels[phase] || '';
} }
@@ -95,9 +116,11 @@ export function updateEsp32StagesFromPhase(phase) {
} }
export function updateEsp32StagesError() { export function updateEsp32StagesError() {
var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6']; var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var all = ids.map(function(id) { return document.getElementById(id); }); var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; }); all.forEach(function (s) { if (s) s.className = 'stage-node'; });
// ★ 失败态:阶段下方文案也要清空,避免残留"上传中/校验中"与红色样式并存
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar'); var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText'); var statusText = document.getElementById('stageStatusText');
if (statusBar) statusBar.className = 'stage-status-bar err'; if (statusBar) statusBar.className = 'stage-status-bar err';
@@ -117,7 +140,14 @@ export function updateEsp32StagesError() {
export function cleanupAfterOtaFailure(reason) { export function cleanupAfterOtaFailure(reason) {
// 幂等:已在 FAILED 且无活跃 XHR 时只刷新文案,避免 abort 事件二次递归清状态 // 幂等:已在 FAILED 且无活跃 XHR 时只刷新文案,避免 abort 事件二次递归清状态
var alreadyFailed = window.otaSessionState === window.OtaSessionState.FAILED; var alreadyFailed = window.otaSessionState === window.OtaSessionState.FAILED;
var chip = window.activeUploadChip || window.otaSessionTarget || window.currentOtaTarget || window.flashTarget || 'esp32'; /* chip 判定优先级(权威来源顺序):
* activeUploadChip — XHR 上传开始时由 startUpload 写入,WiFi 路径权威
* otaSessionTarget — OTA 会话状态机目标(仅 WiFi 走 OtaSessionState,BLE 不走)
* currentOtaTarget — switchStageFlow 设置的当前面板目标
* flashTarget — 用户选择的目标(BLE 路径未设上述两者时兜底)
* 'esp32' — 最终默认值 */
var chip = window.activeUploadChip || window.otaSessionTarget ||
window.currentOtaTarget || window.flashTarget || 'esp32';
// 0. 状态机:转入 FAILED(先于标志重置) // 0. 状态机:转入 FAILED(先于标志重置)
window.otaSessionTransition(window.OtaSessionState.FAILED, reason); window.otaSessionTransition(window.OtaSessionState.FAILED, reason);
@@ -142,16 +172,12 @@ export function cleanupAfterOtaFailure(reason) {
setActivePhase(null); setActivePhase(null);
window.currentOtaTarget = null; window.currentOtaTarget = null;
// 4. 恢复上传按钮(两枚都解禁,避免 chip 判错时另一侧仍灰) // 4. 恢复上传按钮(两枚都解禁并恢复原文案,避免 chip 判错或一侧残留"上传中")
['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) { ['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) {
var btn = document.getElementById(btnId); var btn = document.getElementById(btnId);
if (!btn) return; if (!btn) return;
btn.disabled = false; btn.disabled = false;
if (window.uploadBtnOrigText && if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText;
((chip === 'gd32' && btnId === 'uploadGd32Btn') ||
(chip !== 'gd32' && btnId === 'uploadEsp32Btn'))) {
btn.textContent = window.uploadBtnOrigText;
}
}); });
window.activeUploadChip = null; window.activeUploadChip = null;
window.otaSessionTarget = null; window.otaSessionTarget = null;
@@ -175,9 +201,8 @@ export function cleanupAfterOtaFailure(reason) {
export function setActivePhase(phase) { export function setActivePhase(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
window.activePhase = phase; window.activePhase = phase;
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' }; Object.keys(STEP_MAP).forEach(function (p) {
Object.keys(stepMap).forEach(function(p) { var el = document.getElementById(STEP_MAP[p]);
var el = document.getElementById(stepMap[p]);
if (!el) return; if (!el) return;
el.classList.remove('active', 'done'); el.classList.remove('active', 'done');
if (p === phase) el.classList.add('active'); if (p === phase) el.classList.add('active');
@@ -190,8 +215,7 @@ export function setActivePhase(phase) {
/** @description 标记指定阶段为完成,更新进度条颜色。 */ /** @description 标记指定阶段为完成,更新进度条颜色。 */
export function markPhaseDone(phase) { export function markPhaseDone(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' }; var el = document.getElementById(STEP_MAP[phase]);
var el = document.getElementById(stepMap[phase]);
if (el) { el.classList.remove('active'); el.classList.add('done'); } if (el) { el.classList.remove('active'); el.classList.add('done'); }
// All done? turn progress bar green // All done? turn progress bar green
if (phase === 'gd32Ota' || phase === 'esp32Ota') { if (phase === 'gd32Ota' || phase === 'esp32Ota') {
@@ -200,14 +224,20 @@ export function markPhaseDone(phase) {
} }
} }
/** @description 更新进度条百分比和文本显示。 */ /** @description 更新进度条百分比和文本显示。对 pct 做边界校验,过滤 NaN/undefined。 */
export function setProgress(pct, text) { export function setProgress(pct, text) {
var fillEl = document.getElementById('progressFill'); // 数值校验:BLE ACK 高频调用路径偶尔传入 undefined/NaN,需兜底避免出现 "undefined%"
var txtEl = document.getElementById('progressTxt'); var n = Number(pct);
var pctEl = document.getElementById('progressPct'); if (!Number.isFinite(n)) n = 0;
if (fillEl) fillEl.style.width = pct + '%'; if (n < 0) n = 0;
if (txtEl) txtEl.textContent = text; else if (n > 100) n = 100;
if (pctEl) pctEl.textContent = pct + '%'; // 懒缓存 DOM 元素引用;DOM 未就绪时回退到 getElementById(兼容早期调用)
if (!progressFillEl || !progressFillEl.isConnected) progressFillEl = document.getElementById('progressFill');
if (!progressTxtEl || !progressTxtEl.isConnected) progressTxtEl = document.getElementById('progressTxt');
if (!progressPctEl || !progressPctEl.isConnected) progressPctEl = document.getElementById('progressPct');
if (progressFillEl) progressFillEl.style.width = n + '%';
if (progressTxtEl) progressTxtEl.textContent = text;
if (progressPctEl) progressPctEl.textContent = n + '%';
} }
/** @description 重置进度条和阶段状态到初始值。 */ /** @description 重置进度条和阶段状态到初始值。 */
@@ -216,7 +246,7 @@ export function resetProgress() {
window.activePhase = null; window.activePhase = null;
window.esp32OtaPhase = 0; window.esp32OtaPhase = 0;
switchStageFlow(null); switchStageFlow(null);
['ppEsp32', 'ppGd32Ota'].forEach(function(id) { ['ppEsp32', 'ppGd32Ota'].forEach(function (id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done'); if (el) el.classList.remove('active', 'done');
}); });
-104
View File
@@ -1,104 +0,0 @@
/**
* spi.js - SPI 帧构建与发送
*
* 操作 DOM: #spiType / #spiData / #spiTimeout / #spiHint / #spiResp / #spiRespText
*
* 复用的函数(通过 window.* 引用):
* window.getBaseUrl / window.showToast / window.addLog
*/
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
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=<code>0x' + typeHex.toUpperCase() + '</code>' +
(bytes > 0 ? ',payload: <b>' + bytes + '</b>B' : ',无 payload') +
',线上 <b>' + totalOnWire + '</b>B(SOF+TYPE+LEN+PAYLOAD+CRC)';
}
export 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(window.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');
}
}
export function clearSpiFields() {
document.getElementById('spiType').value = '01';
document.getElementById('spiData').value = '';
document.getElementById('spiTimeout').value = '1000';
document.getElementById('spiResp').style.display = 'none';
updateSpiHint();
}
// 导出 updateSpiHint 供入口挂载 window.updateSpiHint
export { updateSpiHint };
+131 -87
View File
@@ -4,28 +4,31 @@
* 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI, * 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI,
* 并驱动 updateSystemStatusUi 综合 UI 更新。 * 并驱动 updateSystemStatusUi 综合 UI 更新。
* *
* 结构:
* applyDashboardUi — 堆/连接/版本/温湿度等常驻面板
* applyGd32OtaSessionUi — GD32 会话进度/完成/失败(唯一 UI 应用路径)
* updateSystemStatusUi — 入口:仪表盘 →(非 BLE/非 ESP32-OTA)会话
*
* 共享状态(通过 window.* 引用): * 共享状态(通过 window.* 引用):
* window.latestSystemStatus / window.currentOtaTarget / window.esp32OtaPhase * window.latestSystemStatus / window.currentOtaTarget / window.esp32OtaPhase
* window.otaSessionState / window.otaSessionTarget / window.isUploading * window.otaSessionState / window.otaSessionTarget / window.isUploading
* window.gd32CompleteAnnounced / window.gd32RelaySessionLive * window.gd32CompleteAnnounced / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState
* *
* 复用的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.updateHeapAndTaskStatusUi / window.updateConnectionUi / window.updateHealthUi * window.setActivePhase / window.setProgress
* window.updateStagesFromRelayState / window.setActivePhase / window.setProgress
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset * window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.formatBytes / window.formatUptime * window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress
*/ */
import { formatBytes, formatUptime } from '../common/utils.js'; import { formatBytes, formatUptime } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js'; import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { setActivePhase, setProgress, markPhaseDone } from './progress.js'; import { setActivePhase, setProgress } from './progress.js';
import { import { otaSessionReset, OtaSessionState } from './session.js';
otaSessionActive, otaSessionReset, otaSessionTransition, OtaSessionState
} from './session.js';
// Relay state comes from relay_state_t in main/relay/ota_relay.h: // Relay state comes from relay_state_t in main/relay/ota_relay.h:
// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING, // 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING,
// 4=FINISHING, 5=DONE, 255=ERROR. // 4=FINISHING, 5=DONE, 255=ERROR.
// 与 index.html #gd32Stages s1..s6 一一对应(rs→节点序号):
// 1→s1, 2→s2, 3→s3, 4→s4, 5→s1..s6 全 done。
const relayStateDetailMap = { const relayStateDetailMap = {
0: '待机', 0: '待机',
1: '认证握手', 1: '认证握手',
@@ -36,48 +39,56 @@ const relayStateDetailMap = {
255: '错误' 255: '错误'
}; };
/** 上次刷到阶段条的 displayRelayState,未变则跳过 DOM 重建 */
var lastDisplayedRelayState = undefined;
/**
* 将 relay_state 映射到 GD32 阶段节点 (s1..s6)。
* @param {number|null|undefined} rs
*/
export function updateStagesFromRelayState(rs) { export function updateStagesFromRelayState(rs) {
var ids = ['s1', 's2', 's3', 's4', 's5', 's6']; var ids = ['s1', 's2', 's3', 's4', 's5', 's6'];
var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6']; var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'];
var all = ids.map(function(id) { return document.getElementById(id); }); var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); }); var details = detailIds.map(function(id) { return document.getElementById(id); });
// reset all
all.forEach(function (s) { if (s) s.className = 'stage-node'; }); all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; }); details.forEach(function (d) { if (d) d.textContent = ''; });
// status bar
var statusBar = document.getElementById('stageStatusBar'); var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText'); var statusText = document.getElementById('stageStatusText');
if (rs === undefined || rs === null) { if (rs === undefined || rs === null) {
if (statusBar) statusBar.className = 'stage-status-bar'; if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机'; if (statusText) statusText.textContent = '待机';
lastDisplayedRelayState = rs;
return; return;
} }
if (rs === 255) { if (rs === 255) {
all.forEach(function (s) { if (s) s.className = 'stage-node err'; }); all.forEach(function (s) { if (s) s.className = 'stage-node err'; });
if (statusBar) statusBar.className = 'stage-status-bar err'; if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '错误'; if (statusText) statusText.textContent = '错误';
lastDisplayedRelayState = 255;
return; return;
} }
/* rs → 节点:1..4 直接对应 s1..s4;rs=5 DONE → 点亮完成链(s1..s6 全 done) */
var activeStage = 0; var activeStage = 0;
if (rs === 1) activeStage = 1; if (rs === 1) activeStage = 1;
else if (rs === 2) activeStage = 2; else if (rs === 2) activeStage = 2;
else if (rs === 3) activeStage = 3; else if (rs === 3) activeStage = 3;
else if (rs === 4) activeStage = 5; else if (rs === 4) activeStage = 4;
else if (rs === 5) activeStage = 6; else if (rs === 5) activeStage = 6;
for (var i = 0; i < activeStage - 1; i++) { for (var i = 0; i < activeStage - 1; i++) {
if (all[i]) all[i].className = 'stage-node done'; if (all[i]) all[i].className = 'stage-node done';
} }
if (rs === 5) { if (rs === 5) {
/* DONE:s1..s5 已在上方标 done,s5/s6 一并完成 */
if (all[4]) all[4].className = 'stage-node done';
if (all[5]) all[5].className = 'stage-node done'; if (all[5]) all[5].className = 'stage-node done';
if (details[5]) details[5].textContent = relayStateDetailMap[5];
} else if (activeStage >= 1 && activeStage <= 6) { } else if (activeStage >= 1 && activeStage <= 6) {
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live'; if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
if (details[activeStage - 1]) {
details[activeStage - 1].textContent = relayStateDetailMap[rs] || '';
}
} }
// Show detail text for the active stage
var detailText = relayStateDetailMap[rs] || '';
if (activeStage >= 1 && activeStage <= 6 && details[activeStage - 1]) {
details[activeStage - 1].textContent = detailText;
}
// Update status bar
if (statusBar && statusText) { if (statusBar && statusText) {
if (rs === 0) { if (rs === 0) {
statusBar.className = 'stage-status-bar'; statusBar.className = 'stage-status-bar';
@@ -87,27 +98,33 @@ export function updateStagesFromRelayState(rs) {
statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')'; statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')';
} }
} }
lastDisplayedRelayState = rs;
} }
export function updateSystemStatusUi(data) { function setDisplayedRelayState(rs) {
if (!data) return; if (lastDisplayedRelayState === rs) return;
window.latestSystemStatus = data; updateStagesFromRelayState(rs);
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温湿度、IP/RSSI、OTA cache。
* @param {object} data /system/status JSON
* @param {object} ota data.ota
* @param {object} wifi data.wifi
*/
function applyDashboardUi(data, ota, wifi) {
updateHeapAndTaskStatusUi(data); updateHeapAndTaskStatusUi(data);
var wifi = data.wifi || {};
var ota = data.ota || {};
var healthCode = data.health;
// Use global relayStateDetailMap defined in updateStagesFromRelayState section
var connected = !!wifi.connected; var connected = !!wifi.connected;
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接'; var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText); updateConnectionUi(connected, connText);
updateHealthUi(healthCode, data.free_heap); updateHealthUi(data.health, data.free_heap);
if (data.version) { if (data.version) {
var pillVerEl = document.getElementById('pillVer'); var pillVerEl = document.getElementById('pillVer');
if (pillVerEl) { if (pillVerEl) {
pillVerEl.textContent = 'v' + data.version; pillVerEl.textContent = 'v' + data.version;
pillVerEl.dataset.tooltip = data.build_time ? ("编译: " + data.build_time) : ''; pillVerEl.dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
} }
// GD32 版本
var mGd32App = document.getElementById('mGd32App'); var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot'); var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) { if (mGd32App) {
@@ -124,24 +141,26 @@ export function updateSystemStatusUi(data) {
mGd32Boot.textContent = ota.gd32_boot; mGd32Boot.textContent = ota.gd32_boot;
mGd32Boot.className = 'v ok'; mGd32Boot.className = 'v ok';
} else { } else {
mGd32Boot.textContent = ota.gd32_app_alive ? '未知' : '离线'; /* BOOT 版本与 APP alive 无直接关系,无版本时统一「未知」 */
mGd32Boot.textContent = '未知';
mGd32Boot.className = 'v'; mGd32Boot.className = 'v';
} }
} }
} }
// temperature
var mTempEl = document.getElementById('mTemp'); var mTempEl = document.getElementById('mTemp');
if (mTempEl) mTempEl.textContent = if (mTempEl) {
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--'; mTempEl.textContent =
// uptime Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
}
var uptimeEl = document.getElementById('pillUptime'); var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec); if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
// wifi / signal
var pillIPEl = document.getElementById('pillIP'); var pillIPEl = document.getElementById('pillIP');
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--'; if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--'; var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI'); var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) pillRSSIEl.textContent = rssi; if (pillRSSIEl) pillRSSIEl.textContent = rssi;
var mOtaCache = document.getElementById('mOtaCache'); var mOtaCache = document.getElementById('mOtaCache');
var otaStored = ota.stored_size || 0; var otaStored = ota.stored_size || 0;
if (mOtaCache) { if (mOtaCache) {
@@ -153,19 +172,14 @@ export function updateSystemStatusUi(data) {
mOtaCache.className = 'v'; mOtaCache.className = 'v';
} }
} }
// 共享进度条按当前连接模式归属:BLE 模式下由 ble_ota.js 的设备 ACK }
// 驱动包数、阶段和完成状态,HTTP 状态轮询不能覆盖这套实时进度。
if (window.connectionMode === 'ble') { /**
return; * GD32 OTA 会话 UI:阶段节点 + 进度条 + 完成/失败。
} * 门控逻辑保持与历史实现一致,勿随意放宽完成条件。
// relay state - skip GD32 relay UI when ESP32 OTA is active * @param {object} ota data.ota
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) { */
return; function applyGd32OtaSessionUi(ota) {
}
// ★ 状态机守卫:本会话已 FAILED 时,禁止后端状态覆盖失败 UI。
// 后端可能仍在跑 GD32 中继(网络断开不会停止设备端流程),
// 若不拦截,会把进度条/阶段从"网络错误"错误地刷回"数据转发"。
// 只有设备真正回 IDLE 才允许自动复位状态机(见下方 else 分支)。
var rs = ota.relay_state; var rs = ota.relay_state;
var sysState = ota.sys_state || ''; var sysState = ota.sys_state || '';
var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1; var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
@@ -173,42 +187,41 @@ export function updateSystemStatusUi(data) {
var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3; var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4; var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255; var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
// OTA cache progress (GD32 upload to ESP32 flash)
var total = ota.total_size || 0; var total = ota.total_size || 0;
var stored = ota.stored_size || 0; var stored = ota.stored_size || 0;
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0; var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
// GD32 relay forwarding progress — 以 /system/status 的 relay_seq 为准,不依赖日志
var relaySeq = ota.relay_seq || 0; var relaySeq = ota.relay_seq || 0;
var relayTotal = ota.relay_total_packets || 0; var relayTotal = ota.relay_total_packets || 0;
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0; var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
var gd32AppAlive = ota.gd32_app_alive; var gd32AppAlive = ota.gd32_app_alive;
/* ★ FAILED 守卫:禁止后端状态覆盖失败 UI;设备回 IDLE 才自动复位 */
if (window.otaSessionState === OtaSessionState.FAILED) { if (window.otaSessionState === OtaSessionState.FAILED) {
var _deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) && var deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) &&
(rs === 0 || rs === 5); (rs === 0 || rs === 5);
if (!_deviceIdle) { if (!deviceIdle) {
// 会话已失败,但设备可能仍在中继:继续用 relay_seq 刷进度,避免“假卡死”
if ((rs === 3 || isGd32Relaying) && relayTotal > 0) { if ((rs === 3 || isGd32Relaying) && relayTotal > 0) {
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
var failRelayOverall = 60 + Math.round(relayProgress * 0.30); var failRelayOverall = 60 + Math.round(relayProgress * 0.30);
setProgress(failRelayOverall, setProgress(failRelayOverall,
'已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)'); '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)');
updateStagesFromRelayState(3); setDisplayedRelayState(3);
} else { } else {
updateStagesFromRelayState(255); setDisplayedRelayState(255);
} }
return; return;
} }
// 设备已回 IDLE —— 自动复位状态机,恢复正常 UI
otaSessionReset(); otaSessionReset();
window.stopRelayProgressWatch(); window.stopRelayProgressWatch();
// 落到下方正常分支刷新 UI /* 落到下方正常分支刷新 UI */
} }
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs); var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
if (window.currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); } if (window.currentOtaTarget !== 'esp32') {
setDisplayedRelayState(displayRelayState);
}
/* 本会话正在 GD32 路径(含仅 RELAYING 而 target 被清的情况) */
var gd32Session = var gd32Session =
window.otaSessionState === OtaSessionState.RELAYING || window.otaSessionState === OtaSessionState.RELAYING ||
window.currentOtaTarget === 'gd32' || window.currentOtaTarget === 'gd32' ||
@@ -225,25 +238,28 @@ export function updateSystemStatusUi(data) {
window.cleanupAfterOtaFailure(failDetail); window.cleanupAfterOtaFailure(failDetail);
window.pollLogs(); window.pollLogs();
return; return;
} else if (isGd32Error) { }
// 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理
updateStagesFromRelayState(255); if (isGd32Error) {
setDisplayedRelayState(255);
var sbErr = document.getElementById('stageStatusBar'); var sbErr = document.getElementById('stageStatusBar');
var stErr = document.getElementById('stageStatusText'); var stErr = document.getElementById('stageStatusText');
if (sbErr) sbErr.className = 'stage-status-bar err'; if (sbErr) sbErr.className = 'stage-status-bar err';
if (stErr) stErr.textContent = 'GD32 错误(待机)'; if (stErr) stErr.textContent = 'GD32 错误(待机)';
} else if (window.otaSessionState === OtaSessionState.UPLOADING) { return;
}
if (window.otaSessionState === OtaSessionState.UPLOADING) {
/* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */ /* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */
} else if (window.otaSessionState === OtaSessionState.RELAYING) { return;
/* 必须先看到本会话真实中继(HANDSHAKE~FINISHING),才允许 DONE; }
* 禁止一进 RELAYING 就吃掉上一次的 rs=5 / app_alive。 */
if (window.otaSessionState === OtaSessionState.RELAYING) {
/* 必须先看到本会话真实中继,才允许 DONE */
if (isGd32Relaying || (rs >= 1 && rs <= 4)) { if (isGd32Relaying || (rs >= 1 && rs <= 4)) {
window.gd32RelaySessionLive = true; window.gd32RelaySessionLive = true;
} }
/*
* 设备侧 ERROR 常被 status 软清为 IDLE,前端收不到 code=8。
* 已见真实中继、设备已回 IDLE、且未宣布完成 → 按失败收尾,释放会话。
*/
var deviceIdleAfterRelay = var deviceIdleAfterRelay =
(sysStateCode === 0 || sysStateCode === 2) && (sysStateCode === 0 || sysStateCode === 2) &&
(rs === 0 || rs === 5) && (rs === 0 || rs === 5) &&
@@ -257,7 +273,11 @@ export function updateSystemStatusUi(data) {
window.pollLogs(); window.pollLogs();
return; return;
} }
/* 包进度:进入 FINISHING/COMPLETE 时先补 100%,再走完成/收尾 UI */
/*
* 包进度/完成:统一走 emitRelayProgress + markGd32OtaUiComplete。
* FINISHING 时 force100 后只改文案,禁止 setProgress(95) 回跳。
*/
if (window.gd32RelaySessionLive && relayTotal > 0 && if (window.gd32RelaySessionLive && relayTotal > 0 &&
(relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) { (relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) {
if (typeof window.emitRelayProgress === 'function') { if (typeof window.emitRelayProgress === 'function') {
@@ -267,32 +287,29 @@ export function updateSystemStatusUi(data) {
typeof window.emitRelayProgress === 'function') { typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relaySeq, relayTotal); window.emitRelayProgress(relaySeq, relayTotal);
} }
if (window.gd32RelaySessionLive && if (window.gd32RelaySessionLive &&
(isGd32Complete || rs === 5 || (isGd32Complete || rs === 5 ||
(gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) { (gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) {
if (!window.gd32CompleteAnnounced) { if (!window.gd32CompleteAnnounced) {
window.markGd32OtaUiComplete('GD32 刷写完成'); window.markGd32OtaUiComplete('GD32 刷写完成');
} }
} else if (rs === 1) { return;
// HANDSHAKING }
if (rs === 1) {
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
setProgress(50, '握手与认证中...'); setProgress(50, '握手与认证中...');
} else if (rs === 4) { } else if (rs === 4) {
// FINISHING:转发已 100%,进度条进入收尾段(勿再落回中间百分比) /* 收尾:保持 100%,只更新文案(避免 100→95 回跳) */
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
if (!window.gd32CompleteAnnounced) { if (!window.gd32CompleteAnnounced) {
setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...'); setProgress(100, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
} }
} else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5 && rs !== 4)) { } else if (rs === 3 && relaySeq <= 0) {
// TRANSFERRING(不含 FINISHING)
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
var relayOverall = 60 + Math.round(relayProgress * 0.30); setProgress(60, '数据包传输中...');
var relayLabel = relayTotal > 0
? ('已转发 ' + relaySeq + ' / ' + relayTotal + ' 包 (' + relayProgress + '%)')
: '数据包传输中...';
setProgress(relayOverall, relayLabel);
} else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) { } else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) {
// SIZE_NEG
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
setProgress(55, relayTotal > 0 setProgress(55, relayTotal > 0
? ('大小协商中(共 ' + relayTotal + ' 包)') ? ('大小协商中(共 ' + relayTotal + ' 包)')
@@ -301,13 +318,40 @@ export function updateSystemStatusUi(data) {
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
setProgress(48, '已触发中继,等待设备进入握手…'); setProgress(48, '已触发中继,等待设备进入握手…');
} }
} else if (isGd32Storing || (total > 0 && stored < total && !window.isUploading && return;
window.otaSessionState !== OtaSessionState.DONE && }
window.otaSessionState !== OtaSessionState.IDLE)) {
if (isGd32Storing || (total > 0 && stored < total && !window.isUploading &&
window.otaSessionState !== OtaSessionState.DONE &&
window.otaSessionState !== OtaSessionState.IDLE)) {
setActivePhase('gd32Ota'); setActivePhase('gd32Ota');
var storeOverall = Math.min(45, Math.round(storageProgress * 0.45)); var storeOverall = Math.min(45, Math.round(storageProgress * 0.45));
if (!window.isUploading) { if (!window.isUploading) {
setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total) + ''); setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total));
} }
} }
} }
/**
* /system/status → UI 总入口。
* @param {object} data
*/
export function updateSystemStatusUi(data) {
if (!data) return;
window.latestSystemStatus = data;
var wifi = data.wifi || {};
var ota = data.ota || {};
applyDashboardUi(data, ota, wifi);
/* BLE:仪表盘可更新,进度/阶段由 ble 路径独占 */
if (window.connectionMode === 'ble') {
return;
}
/* ESP32 OTA 进行中:勿用 GD32 relay 覆盖阶段条 */
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) {
return;
}
applyGd32OtaSessionUi(ota);
}
+3 -2
View File
@@ -162,8 +162,9 @@ export async function startUpload(chip) {
file = freshFile; file = freshFile;
} else { } else {
if (freshFile.name.toLowerCase().endsWith('.hex')) { if (freshFile.name.toLowerCase().endsWith('.hex')) {
window.processFile(freshFile, 'gd32'); // processFile 现已返回 Promise,await 即可拿到转换后的 gd32File,
await new Promise(function(resolve) { setTimeout(resolve, 200); }); // 不再用 setTimeout 硬等(避免 HEX 稍大时拿到 null)。
await window.processFile(freshFile, 'gd32');
file = window.gd32File; file = window.gd32File;
} else { } else {
window.gd32File = freshFile; window.gd32File = freshFile;
+34 -113
View File
@@ -1,7 +1,11 @@
/** /**
* upload-gd32.js - GD32 中继 OTA 触发 + relay 态跟踪 * upload-gd32.js - GD32 中继 OTA 触发 + 进度辅助
* *
* 包含:relay 进度观察定时器、markGd32OtaUiComplete、triggerGd32Relay。 * 进度/完成/失败 UI 的唯一应用路径:pollStatus → updateSystemStatusUi(stages.js)。
* 本文件只负责:
* - 触发 /ota/relay
* - 中继期加速 status 轮询(400ms)
* - emitRelayProgress / markGd32OtaUiComplete 等被 stages 调用的辅助函数
* *
* 共享状态(通过 window.* 引用): * 共享状态(通过 window.* 引用):
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive * window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
@@ -9,7 +13,7 @@
* window.otaSessionState / window.lastSysStateCode / window.lastRelayState * window.otaSessionState / window.lastSysStateCode / window.lastRelayState
* *
* 复用的函数(通过 window.* 引用): * 复用的函数(通过 window.* 引用):
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionActive * window.otaSessionTransition / window.OtaSessionState
* window.markPhaseDone / window.setProgress / window.updateSystemStatusUi * window.markPhaseDone / window.setProgress / window.updateSystemStatusUi
* window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs * window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs
* window.resumePollingAfterUpload / window.getBaseUrl / window.showToast / window.addLog * window.resumePollingAfterUpload / window.getBaseUrl / window.showToast / window.addLog
@@ -17,28 +21,22 @@
import { showToast } from '../common/toast.js'; import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js'; import { addLog } from '../common/log.js';
import { import {
otaSessionTransition, otaSessionActive, OtaSessionState otaSessionTransition, OtaSessionState
} from './session.js'; } from './session.js';
import { markPhaseDone, setProgress } from './progress.js'; import { markPhaseDone, setProgress } from './progress.js';
/** /** 中继期 status 轮询间隔(ms);结束后由 stopRelayProgressWatch 恢复 1000 */
* GD32 中继进度观察:以 /system/status 为唯一真相源。 const RELAY_STATUS_POLL_MS = 400;
* 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间—— const NORMAL_STATUS_POLL_MS = 1000;
* 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。
*
* 时序约定(避免「设备已 FINISH / 完成,UI 还在 94%」):
* 1) 包进度变化先刷进度条 + 日志;
* 2) 一旦 rseq>=rtot 或 rs>=4(FINISHING),先强制打出 100% 再进收尾/完成;
* 3) markGd32OtaUiComplete 前再兜底一次 100%。
*/
let relayWatchTimerId = null;
window.gd32CompleteAnnounced = false; window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1; window.relayWatchLastSeq = -1;
/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */ /** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */
window.gd32RelaySessionLive = false; window.gd32RelaySessionLive = false;
/** /**
* 包进度变更时更新进度条并写一条「转发」日志(同 seq 不重复)。 * 包进度变更时更新进度条(同 seq 不重复)。
* 由 stages.js 在 status 轮询路径调用;勿在本文件再起独立定时器刷进度。
* @param {number} rseq * @param {number} rseq
* @param {number} rtot * @param {number} rtot
* @param {{force100?: boolean}} [opts] * @param {{force100?: boolean}} [opts]
@@ -79,7 +77,7 @@ export function ensureRelayProgressComplete() {
} }
export function markGd32OtaUiComplete(msg) { export function markGd32OtaUiComplete(msg) {
// 已公告过:禁止再改 className / setProgress / toast,否则定时器重触发动效会「疯狂跳 ✓」 // 已公告过:禁止再改 className / setProgress / toast,否则 status 重触发动效会「疯狂跳 ✓」
if (window.gd32CompleteAnnounced) { if (window.gd32CompleteAnnounced) {
return; return;
} }
@@ -111,106 +109,33 @@ export function markGd32OtaUiComplete(msg) {
showToast(msg || 'GD32 刷写完成', 'success'); showToast(msg || 'GD32 刷写完成', 'success');
} }
/**
* 结束中继加速轮询,恢复常态 1s status 间隔。
* 不再持有独立 setInterval(进度/完成/失败统一由 stages 在 pollStatus 路径处理)。
*/
export function stopRelayProgressWatch() { export function stopRelayProgressWatch() {
if (relayWatchTimerId) {
clearInterval(relayWatchTimerId);
relayWatchTimerId = null;
}
/* 中继结束:恢复常态 1s status 轮询 */
if (typeof window.setStatusPollIntervalMs === 'function') { if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(1000); window.setStatusPollIntervalMs(NORMAL_STATUS_POLL_MS);
} }
} }
/** /**
* 中继进度观察:只读 pollStatus 写入的 latestSystemStatus,不再另起 /system/status。 * 中继进度跟踪:只加速 /system/status 轮询,不再另起 400ms 定时器。
* 中继期 status 加速到 400ms,使「转发 N/M」更贴近设备真实进度。 * UI 真相源仍是 pollStatus → updateSystemStatusUi(stages.js)。
*/ */
export function startRelayProgressWatch() { export function startRelayProgressWatch() {
stopRelayProgressWatch();
if (window.pollingPausedForUpload) { if (window.pollingPausedForUpload) {
window.resumePollingAfterUpload(); window.resumePollingAfterUpload();
} }
/* 中继期加速 status,缩小 UI 进度相对设备日志的滞后 */
if (typeof window.setStatusPollIntervalMs === 'function') { if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(400); window.setStatusPollIntervalMs(RELAY_STATUS_POLL_MS);
}
if (typeof window.scheduleStatusPoll === 'function') {
window.scheduleStatusPoll(0);
}
if (typeof window.pollLogs === 'function') {
window.pollLogs();
} }
window.scheduleStatusPoll(0);
window.pollLogs();
addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志');
relayWatchTimerId = setInterval(function () {
var active = window.otaSessionState === OtaSessionState.RELAYING ||
(window.otaSessionState === OtaSessionState.FAILED &&
(window.lastSysStateCode === 3 || window.lastRelayState >= 1 && window.lastRelayState <= 4));
if (!active) {
if (window.otaSessionState === OtaSessionState.DONE) {
stopRelayProgressWatch();
}
return;
}
var data = window.latestSystemStatus;
if (!data || !data.ota) return;
var o = data.ota || {};
var code = Number(o.sys_state_code);
var rstate = Number(o.relay_state);
var alive = !!o.gd32_app_alive;
var rseq = Number(o.relay_seq) || 0;
var rtot = Number(o.relay_total_packets) || 0;
if (rstate >= 1 && rstate <= 4) {
window.gd32RelaySessionLive = true;
}
/* 中继失败:必须把前端会话从 RELAYING 拉到 FAILED,否则无法新开上传 */
if (code === 8 || rstate === 255) {
stopRelayProgressWatch();
window.pollLogs();
if (window.otaSessionState === OtaSessionState.RELAYING ||
window.otaSessionState === OtaSessionState.UPLOADING) {
var failMsg = 'GD32 中继失败(relay_state=' + rstate +
', sys=' + (o.sys_state || code) + ')';
if (typeof window.cleanupAfterOtaFailure === 'function') {
window.cleanupAfterOtaFailure(failMsg);
} else {
otaSessionTransition(OtaSessionState.FAILED, failMsg);
}
}
return;
}
/*
* ERROR 可能在 status 路径被软清为 IDLE,前端收不到 code=8。
* 本会话已见过真实中继,设备却已回 IDLE 且未宣布完成 → 按失败清理。
*/
var idleAfterRelay = (code === 0 || code === 2) &&
(rstate === 0 || rstate === 5) &&
window.gd32RelaySessionLive &&
!window.gd32CompleteAnnounced &&
window.otaSessionState === OtaSessionState.RELAYING;
if (idleAfterRelay) {
stopRelayProgressWatch();
window.pollLogs();
if (typeof window.cleanupAfterOtaFailure === 'function') {
window.cleanupAfterOtaFailure('GD32 中继已结束但未成功(设备已回 IDLE)');
} else {
otaSessionTransition(OtaSessionState.FAILED, 'GD32 中继已结束但未成功');
}
return;
}
/*
* 顺序:先刷包进度(含强制 100%),再判定完成。
* FINISHING/DONE 时若 status 滞后未到 total,仍按 total 打 100%。
*/
var transferDone = rtot > 0 && (rseq >= rtot || rstate >= 4 || code === 4);
if (transferDone) {
emitRelayProgress(rtot, rtot, { force100: true });
} else if (rtot > 0 && rseq > 0) {
emitRelayProgress(rseq, rtot);
}
if (window.gd32RelaySessionLive &&
(code === 4 || rstate === 5 ||
(alive && rtot > 0 && rseq >= rtot && rstate >= 3))) {
markGd32OtaUiComplete('GD32 刷写完成');
window.pollLogs();
}
}, 400);
} }
/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */ /** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */
@@ -222,8 +147,7 @@ export async function triggerGd32Relay() {
window.cleanupAfterOtaFailure('SHA-256 缺失'); window.cleanupAfterOtaFailure('SHA-256 缺失');
return; return;
} }
addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...'); addLog('I', 'OTA', '触发 GD32 中继…');
showToast('正在触发 GD32 OTA', 'success');
setProgress(48, '校验 SHA 并触发 GD32 中继…'); setProgress(48, '校验 SHA 并触发 GD32 中继…');
try { try {
// 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒 // 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒
@@ -235,11 +159,7 @@ export async function triggerGd32Relay() {
}); });
var text = await resp.text(); var text = await resp.text();
if (resp.ok) { if (resp.ok) {
try { addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数');
var data = JSON.parse(text);
if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256);
} catch (_) {}
addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数(勿只看日志时间戳)');
showToast('GD32 OTA 已触发', 'success'); showToast('GD32 OTA 已触发', 'success');
setProgress(50, '中继已启动,同步进度…'); setProgress(50, '中继已启动,同步进度…');
window.gd32CompleteAnnounced = false; window.gd32CompleteAnnounced = false;
@@ -250,7 +170,8 @@ export async function triggerGd32Relay() {
try { try {
var err = JSON.parse(text); var err = JSON.parse(text);
if (err.device) { if (err.device) {
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device + ' 前端=' + sha256); addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device +
' 前端=' + sha256);
showToast('SHA-256 不匹配', 'error'); showToast('SHA-256 不匹配', 'error');
window.cleanupAfterOtaFailure('SHA-256 不匹配'); window.cleanupAfterOtaFailure('SHA-256 不匹配');
return; return;
@@ -287,4 +208,4 @@ export async function triggerGd32Relay() {
showToast('GD32 触发失败', 'error'); showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发异常'); window.cleanupAfterOtaFailure('触发异常');
} }
} }