feat(esp32): 添加多电机OTA中继功能并优化调试日志系统

- 新增多电机状态、版本和OTA中继监控模块(monitor/motor_state.cpp,
  monitor/motor_version.cpp, monitor/motor_ota_relay.cpp)
- 在CAN反馈任务中添加0xC5/0xC6/0xD2类型消息处理,支持多电机状态推送
- 重构调试日志系统,将一次性JSON分配改为512B栈缓冲流式发送,降低内存峰值
- 调试UART波特率从115200提升至30000
- 修改版本管理方式,从自动生version.h改为手动维护
- 更新VSCode设置以支持ESP-IDF开发环境配置
- 在OTA协调器中添加电机OTA互斥机制,防止并发冲突
- 完善中继状态机异常处理,添加远程取消通知机制
This commit is contained in:
小龙虾
2026-07-26 20:40:16 +08:00
parent 770ae14156
commit 2138a572ee
47 changed files with 6469 additions and 799 deletions
+2
View File
@@ -5,6 +5,8 @@
],
"idf.portWin": "COM15",
"idf.currentSetup": "F:\\software\\.espressif\\v6.0\\esp-idf",
"idf.toolsPath": "C:\\Espressif",
"idf.customExtraPaths": "C:\\Espressif\\tools\\xtensa-esp-elf-gdb\\16.3_20250913\\xtensa-esp-elf-gdb\\bin;C:\\Espressif\\tools\\riscv32-esp-elf-gdb\\16.3_20250913\\riscv32-esp-elf-gdb\\bin;C:\\Espressif\\tools\\xtensa-esp-elf\\esp-15.2.0_20251204\\xtensa-esp-elf\\bin;C:\\Espressif\\tools\\esp-clang\\esp-20.1.1_20250829\\esp-clang\\bin;C:\\Espressif\\tools\\riscv32-esp-elf\\esp-15.2.0_20251204\\riscv32-esp-elf\\bin;C:\\Espressif\\tools\\esp32ulp-elf\\2.38_20240113\\esp32ulp-elf\\bin;C:\\Espressif\\tools\\cmake\\4.0.3\\bin;C:\\Espressif\\tools\\openocd-esp32\\v0.12.0-esp32-20251215\\openocd-esp32\\bin;C:\\Espressif\\tools\\ninja\\1.12.1;C:\\Espressif\\tools\\idf-exe\\1.0.3;C:\\Espressif\\tools\\ccache\\4.12.1\\ccache-4.12.1-windows-x86_64;C:\\Espressif\\tools\\dfu-util\\0.11\\dfu-util-0.11-win64",
"idf.customExtraVars": {
"IDF_TARGET": "esp32c3"
},
@@ -0,0 +1,77 @@
## 目标
把电机 OTA 从"收固件+触发喂数据"一步式,改成 BLE 刷 GD32 那样的两段式:**先收完固件写 flash,再单独触发 SPI START + 喂数据**。拆分后收固件阶段不碰 SPI,避免与 GD32 按铃推送竞态;触发阶段只发一个短小的 SPI 0xD0。
## 改动点(共 4 个文件)
### 1. `main/server/http_handlers.cpp` — 拆分上传 + 新增 relay 触发
**`motor_ota_upload_handler`(line 2057)改:**
- 保留:互斥检查、`beginWriteSession`、流式收固件写 flash(`flashWriteChunk` 循环)、`beginWriteSession` 复位
- **删除**:末尾的 `motorOtaStart(...)` 调用(line 2128)
- 收完后写 meta:`metaSave({total_size=content_len, stored_size=received})`,让 relay 端点能确认固件完整
- 返回改为 `{"status":"stored","message":"call POST /motor/ota/relay?ch=X to trigger","channel":ch,"size":received}`
**新增 `motor_ota_relay_handler`:**
- 从 query 读 `ch`(和上传端点一致)
- `Gd32FlashStore::metaLoad` 确认 `stored_size >= total_size`,否则返回 400 `firmware not fully stored`
- `motorOtaIsActive()` 检查,已在跑则 409
- 调 `motorOtaStart(meta.total_size, ch)`:
- 0 → `{"status":"ok","message":"OTA started"}`
- -2 → 409 `already in progress`
- -3 → 500 `SPI start failed`(保留原日志 DEBUG_ERR)
- 成功触发后**清 meta**(`metaClear`),固件已交给 feedTask,避免残留
### 2. `main/server/wifi_ota.cpp:482` — 注册新路由
路由表加一行:
```cpp
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
```
(紧挨现有三个 motor/ota 路由,CORS preflight 复用现有循环自动处理)
### 3. `main/server/http_handlers.h` — 声明新 handler
加 `esp_err_t motor_ota_relay_handler(httpd_req_t *req);`
### 4. `tools/motor.html` — 前端两段式调用
**`startOta`(line 2986-3001)改:**
```
// 步骤2:POST /motor/ota 只收固件
const upResp = await fetch("/motor/ota?ch=${ch}", { POST, body: ota.bytes, timeout 15s });
const upJson = await upResp.json();
if (!upResp.ok) throw new Error(upJson.error);
otaLog("[上传] 成功,触发 GD32 转发…");
// 步3:POST /motor/ota/relay 触发喂数据
const rResp = await fetch("/motor/ota/relay?ch=${ch}", { POST, timeout 5000 });
const rJson = await rResp.json();
if (!rResp.ok) throw new Error(rJson.error);
otaLog("[转发] GD32 开始握手…");
startOtaPoll(); // 轮询不变
```
超时:上传 15s(48KB),触发 5s(一个 SPI 事务)。
**`cancelOta`:** 不用改。`/motor/ota/cancel` 两阶段都有效:触发前 motorOtaCancel 是空操作(feedTask 没跑),但仍清 GD32 状态;可顺便让 cancel handler 也清 meta(见下)。
### 5.(附带)`motor_ota_cancel_handler` 加清 meta
cancel 时 `motorOtaCancel()` 后补一行 `Gd32FlashStore::instance().metaClear()`,覆盖"上传完未触发就取消"场景,清掉残留固件 meta。触发后取消也清 meta 无害(feedTask 已停)。
## 状态机与轮询(不变)
- `/motor/ota/status` 复用,返回 GD32 pushStatus 的 state/packets_acked
- 触发前 status = IDLE;触发后进 HANDSHAKE/TRANSFER/FINISHED/ERROR
- 前端在 relay 请求成功后才 `startOtaPoll`,避免触发前空轮询
## 关键收益
- **消除竞态**:收固件纯 HTTP 流式写 flash,不碰 SPI/MISO 缓冲,不与 GD32 按铃推送争抢;触发阶段只发一个短 SPI 0xD0,窗口极小
- **语义清晰**:和 BLE 刷 GD32 的 `prepare+收流 → /ota/relay` 两段式对齐
- **改动最小**:feedTask、GD32 侧、状态轮询全部不动;只拆 HTTP 端点和前端调用顺序
## 风险
- 前端两次请求间若用户取消:cancel 已覆盖两阶段
- 上传成功但触发失败:meta 仍存,用户可重试 relay(relay 端点先检查 `motorOtaIsActive` 再触发);或 cancel 清 meta 重来
- 固件占 flash 未触发:metaClear 在 cancel/relay 成功后执行,残留风险低
## 验证
- 编译 ESP32_COMM(idf.py build)
- 手动测:选固件→开始,看日志先"[上传] 成功"再"[转发] GD32 开始握手",确认两次 HTTP 分开
- 观察 SPI 按铃竞态是否消失(原"SPI start failed"概率应降到极低)
- feedTask 终态判定(之前加的)继续生效
+5 -8
View File
@@ -60,13 +60,11 @@ ESP32_COMM/
├── sdkconfig.defaults # SDK 配置默认值 (WiFi RX/TX BA=6)
├── config/
│ └── partitions.csv # 4MB Flash 分区表
├── cmake/
│ └── gen_version_script.cmake # 构建时自动生成 version.h
├── main/
│ ├── CMakeLists.txt # 注册 12 个源文件 + 依赖
│ ├── include/
│ │ ├── app_config.h # 统一配置 (constexpr: WiFi/BLE/OTA/阈值/密钥)
│ │ ├── version.h # 自动生成版本信息 (v0.3.12)
│ │ ├── version.h # 手动维护的版本信息 (v0.3.56)
│ │ └── ota_mutex.h # BLE/WiFi OTA 全局互斥
│ ├── core/main.cpp # app_main 入口,4 个 FreeRTOS 任务
│ ├── relay/ota_relay.{h,cpp} # GD32 OTA 中继事件驱动状态机
@@ -626,8 +624,7 @@ CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520
## 固件版本管理
位于 `cmake/gen_version_script.cmake`,在每次 `idf.py build` 时自动执行:
- **版本号**: `0.3.12`(手动修改 CMake 文件中的 MAJOR/MINOR/PATCH)
- **构建时间**: 自动写入 `%Y-%m-%d %H:%M:%S`
- **Git Hash**: 自动从 `git rev-parse --short HEAD` 获取 (当前 `5b2c5f8`)
- 输出到 `main/include/version.h`,仅在内容变化时写入以避免不必要的重编译
版本信息位于 `main/include/version.h`,**手动维护**,不再由构建脚本自动生成:
- **版本号**: `0.3.56`(发版时手动递增 `FW_VERSION_MAJOR/MINOR/PATCH` 及 `FW_VERSION_STRING`)
- **构建时间**: `BUILD_TIME`,每次发版编译时手动填写
- **Git Hash**: `GIT_HASH`,手动填写对应提交的 `git rev-parse --short HEAD` 结果
-71
View File
@@ -1,71 +0,0 @@
# gen_version_script.cmake - 构建时执行的独立脚本
# 通过 add_custom_target 在每次 build 时调用,确保版本信息始终最新
#
# 需要传入的参数:
# SOURCE_DIR - 项目根目录 (ESP32_COMM)
# OUTPUT_FILE - 输出的 version.h 路径
# 主/次版本号(手动修改,大版本时改)
set(FW_VERSION_MAJOR 0)
set(FW_VERSION_MINOR 3)
# PATCH 自动递增:git 总提交次数,每次新提交 +1
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-list --count HEAD
WORKING_DIRECTORY ${SOURCE_DIR}
OUTPUT_VARIABLE FW_VERSION_PATCH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
else()
set(FW_VERSION_PATCH 0)
endif()
# 编译时间
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")
# Git 短 hash
find_package(Git QUIET)
if(GIT_FOUND)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD
WORKING_DIRECTORY ${SOURCE_DIR}
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
else()
set(GIT_HASH "unknown")
endif()
# 生成版本头文件
set(VERSION_HEADER_CONTENT
"// Auto-generated version info - DO NOT EDIT
#pragma once
// 固件版本
#define FW_VERSION_MAJOR ${FW_VERSION_MAJOR}
#define FW_VERSION_MINOR ${FW_VERSION_MINOR}
#define FW_VERSION_PATCH ${FW_VERSION_PATCH}
#define FW_VERSION_STRING \"${FW_VERSION_MAJOR}.${FW_VERSION_MINOR}.${FW_VERSION_PATCH}\"
// 编译时间
#define BUILD_TIME \"${BUILD_TIME}\"
// Git 信息
#define GIT_HASH \"${GIT_HASH}\"
")
# 仅在内容变化时写入,避免不必要的重编译
if(EXISTS ${OUTPUT_FILE})
file(READ ${OUTPUT_FILE} EXISTING_CONTENT)
if(NOT "${EXISTING_CONTENT}" STREQUAL "${VERSION_HEADER_CONTENT}")
file(WRITE ${OUTPUT_FILE} "${VERSION_HEADER_CONTENT}")
message(STATUS "Version header updated: v${FW_VERSION_MAJOR}.${FW_VERSION_MINOR}.${FW_VERSION_PATCH} (${GIT_HASH})")
endif()
else()
file(WRITE ${OUTPUT_FILE} "${VERSION_HEADER_CONTENT}")
message(STATUS "Version header generated: v${FW_VERSION_MAJOR}.${FW_VERSION_MINOR}.${FW_VERSION_PATCH} (${GIT_HASH})")
endif()
+3 -9
View File
@@ -16,6 +16,9 @@ idf_component_register(
"monitor/cpu_stats.cpp" # 1Hz FreeRTOS CPU 占用采样任务
"monitor/can_feedback_task.cpp" # CAN 反馈被动监听(0xC1 takeEvent)+ 分发 0xC4
"monitor/gd32_adc_feedback.cpp" # GD32 ADC 调试反馈缓存(0xC4)
"monitor/motor_state.cpp" # GD32 多电机状态缓存(0xC5)
"monitor/motor_version.cpp" # GD32 多电机版本缓存(0xC6,事件驱动)
"monitor/motor_ota_relay.cpp" # 电机 OTA relay(0xD0~0xD2)
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
"monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子
INCLUDE_DIRS
@@ -46,12 +49,3 @@ idf_component_register(
bt # NimBLE BLE 协议栈
)
# 每次构建时自动刷新 version.h(BUILD_TIME / GIT_HASH)
add_custom_target(version_header ALL
COMMAND ${CMAKE_COMMAND}
-DSOURCE_DIR=${CMAKE_SOURCE_DIR}
-DOUTPUT_FILE=${CMAKE_CURRENT_SOURCE_DIR}/include/version.h
-P ${CMAKE_SOURCE_DIR}/cmake/gen_version_script.cmake
COMMENT "Refreshing version.h"
VERBATIM
)
+3
View File
@@ -34,6 +34,7 @@
#include "app_config.h"
#include "ota/esp32_ota_core.h"
#include "monitor/can_feedback_task.h"
#include "monitor/motor_ota_relay.h"
/** @brief 创建关键任务;成功返回 true。 */
static bool createCriticalTask(TaskFunction_t entry, const char *name,
@@ -125,6 +126,8 @@ extern "C" void app_main(void)
SystemBoot::mark(BOOT_CAN_FB_TASK);
}
motorOtaRelayInit();
if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) {
SystemBoot::mark(BOOT_OTA_VALID);
}
+5 -5
View File
@@ -3,7 +3,7 @@
* ESP32-C3 OTA 中继项目
*
* 使用 constexpr 代替 #define,提供类型安全
* 版本号和编译时间由 version.h 自动生成
* 版本号和编译时间在 version.h 中手动维护
*/
#pragma once
#include <cstdint>
@@ -17,11 +17,11 @@
inline constexpr uint8_t CONFIG_WIFI_MODE = 1;
// ============================================================
// 固件版本(由 version.h 自动生成,此处提供 C++ constexpr 访问)
// 固件版本(在 version.h 中手动维护,此处提供 C++ constexpr 访问)
// ============================================================
inline constexpr const char *FW_VERSION = FW_VERSION_STRING; ///< 固件版本字符串(自动生成)
inline constexpr const char *FW_BUILD_TIME = BUILD_TIME; ///< 编译时间字符串(自动生成)
inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(自动生成)
inline constexpr const char *FW_VERSION = FW_VERSION_STRING; ///< 固件版本字符串(手动维护)
inline constexpr const char *FW_BUILD_TIME = BUILD_TIME; ///< 编译时间字符串(手动维护)
inline constexpr const char *FW_GIT_HASH = GIT_HASH; ///< Git 提交哈希(手动维护)
// ============================================================
// WiFi STA 模式配置(连接路由器)
+15 -7
View File
@@ -1,14 +1,22 @@
// Auto-generated version info - DO NOT EDIT
/*
* version.h - 固件版本信息(手动维护)
*
* 注意:本文件不再由构建脚本自动生成。如需修改版本号、编译时间或
* Git Hash,请直接编辑本文件。
* - FW_VERSION_MAJOR / MINOR / PATCH:发版时手动递增
* - BUILD_TIME:每次发版编译时手动填写
* - GIT_HASH:手动填写对应提交的 git short hash
*/
#pragma once
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 53
#define FW_VERSION_STRING "0.3.53"
#define FW_VERSION_PATCH 71
#define FW_VERSION_STRING "0.3.71"
// 编译时间
#define BUILD_TIME "2026-07-21 00:23:44"
// 编译时间(手动填写)
#define BUILD_TIME "2026-07-26 20:25:00"
// Git 信息
#define GIT_HASH "00b716c"
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
+24
View File
@@ -22,6 +22,9 @@
#include "monitor/debug_log.h"
#include "monitor/can_feedback_task.h"
#include "monitor/gd32_adc_feedback.h"
#include "monitor/motor_state.h"
#include "monitor/motor_version.h"
#include "monitor/motor_ota_relay.h"
#include "app_config.h"
#include "esp_system.h"
@@ -33,6 +36,9 @@ constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态推送
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送
constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
@@ -102,6 +108,24 @@ static void taskLoop(void *)
continue;
}
/* 0xC5 多电机状态推送:GD32 250ms 分页推送,ESP32 解析入缓存 */
if (ring_type == kSpiMotorStateType) {
(void)onMotorStateEvent(ring_payload, ring_len);
continue;
}
/* 0xC6 多电机版本推送:GD32 收到电机版本应答(0x00FF)后事件驱动推送 */
if (ring_type == kSpiMotorVersionType) {
(void)onMotorVersionEvent(ring_payload, ring_len);
continue;
}
/* 0xD2 电机 OTA relay 进度推送 */
if (ring_type == kSpiMotorOtaStatusType) {
onMotorOtaStatusPush(ring_payload, ring_len);
continue;
}
if (ring_type != kSpiCanFeedbackType || ring_len < 28U) {
continue;
}
+49 -96
View File
@@ -6,7 +6,7 @@
* 2. UDP 单播到指定上位机(独立 UDP 调试接收端可选)
* 3. 内部环形缓冲区(供 HTTP /logs 拉取,WebUI 实际使用这一条)
*
* UART0: TX=GPIO21, RX=GPIO20, 115200 波特率
* UART0: TX=GPIO21, RX=GPIO20, 3000000 波特率
* UDP: <DEBUG_LOG_IP>:<DEBUG_LOG_PORT>
*
* 时序说明:
@@ -68,17 +68,6 @@ bool DebugLog::isKeepAliveTag(const char *tag)
return false;
}
char *DebugLog::allocEmptyJson(int *out_len)
{
/* 返回 nullptr 让 caller 走"[]"字面量分支,避免空环时每秒 malloc(3)/free(3)。
* ringDrainJson 的所有"返空"路径都调本函数,caller logs_handler 见 nullptr
* 后直接发 "[]" 字面量,零分配。 */
if (out_len) {
*out_len = 0;
}
return nullptr;
}
/**
* JSON 字符串内容转义写入 buf[pos..];返回新 pos。
* 控制字符除 \n\r\t 外写成 \u00XX;空间不足时截断并尽量写 "..."。
@@ -384,97 +373,62 @@ void DebugLog::ringPush(const char *data, int len)
}
/**
* 单次持锁:peek 元数据 →(可选)按堆缩小条数 → pop+转义拼 JSON。
* capacity 按真实 raw 长度估算,避免 48×320 虚高导致 OTA 期返空。
* 流式导出:peek 快照条数 → 逐条 pop(仅 pop 期间持锁)转义拼 JSON →
* 攒满 512B 栈缓冲即经 sink 发一个 chunk。替代原 ~4KB 一次性 malloc,
* 把 /logs 发送路径的应用层瞬时峰值从几 KB 降到栈缓冲级别。
*/
char *DebugLog::ringDrainJson(int *out_len)
int DebugLog::ringDrainJsonStream(LogJsonSink sink, void *ctx)
{
if (!out_len) {
return nullptr;
if (!sink) {
return 0;
}
*out_len = 0;
const uint32_t free_now = esp_get_free_heap_size();
if (free_now < kDrainMinFree || !m_logRingMutex) {
return allocEmptyJson(out_len);
return 0; /* 低堆/未就绪:caller 一次性发 "[]" */
}
/* 先 peek 快照条数:无内容则让 caller 走 "[]" 一次性响应,不进 chunked 编码。 */
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
int total_raw = 0;
int drain_n = ringPeekDrainMetaLocked(LOG_DRAIN_MAX, &total_raw);
xSemaphoreGive(m_logRingMutex);
if (drain_n <= 0) {
xSemaphoreGive(m_logRingMutex);
return allocEmptyJson(out_len);
return 0;
}
/* 转义最坏 ~2x + 每条 "" 与逗号 + 数组括号 */
auto calc_capacity = [](int n, int raw) -> int {
int cap = raw * 2 + n * 4 + 16;
if (cap < 16) {
cap = 16;
}
return cap;
};
int capacity = calc_capacity(drain_n, total_raw);
const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT);
/* 堆不够则减条数重估(仍持锁,peek 只读;减 n 时从最旧开始仍一致) */
while (drain_n > 0 && largest < (size_t)capacity) {
drain_n = drain_n / 2;
if (drain_n == 0) {
break;
}
total_raw = 0;
drain_n = ringPeekDrainMetaLocked(drain_n, &total_raw);
capacity = calc_capacity(drain_n, total_raw);
}
if (drain_n <= 0 || largest < (size_t)capacity) {
xSemaphoreGive(m_logRingMutex);
return allocEmptyJson(out_len);
}
/* 持锁期间尽量短:只 pop 到栈上临时区会爆栈;改为直接写入 malloc 缓冲。
* 先释放锁再 malloc,再二次持锁 pop——中间可能插入新日志,只 drain 最旧 N 条即可。 */
xSemaphoreGive(m_logRingMutex);
char *buf = (char *)malloc((size_t)capacity);
if (!buf) {
return allocEmptyJson(out_len);
}
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
/* 重新核对:ring 可能变少 */
if (m_logRingCount <= 0) {
xSemaphoreGive(m_logRingMutex);
free(buf);
return allocEmptyJson(out_len);
}
if (drain_n > m_logRingCount) {
drain_n = m_logRingCount;
}
if (drain_n > LOG_DRAIN_MAX) {
drain_n = LOG_DRAIN_MAX;
}
/* 512B 栈缓冲替代原 ~4KB malloc。单条转义最坏 2×LOG_RING_SIZE,
* flush 后空缓冲须能容纳单条 + 括号余量(见 static_assert)。 */
static constexpr int STREAM_BUF_CAP = 512;
static_assert(STREAM_BUF_CAP >= LOG_RING_SIZE * 2 + 8,
"stream buf must hold one worst-case escaped entry");
char buf[STREAM_BUF_CAP];
char entry[LOG_RING_SIZE];
int pos = 0;
buf[pos++] = '[';
bool first = true;
char entry[LOG_RING_SIZE];
for (int k = 0; k < drain_n; ++k) {
/* 先估空间再 pop,避免 pop 后写不下导致丢条 */
const int need_min = first ? 4 : 5; /* ,"x" 或 "x" 量级 + 闭合余量 */
if (pos + need_min >= capacity - 2) {
break;
/* 逐条 pop:仅在 pop 期间持锁,绝不在 sink(网络 I/O)时持锁,
* 避免阻塞其它任务写日志。 */
int entry_len = 0;
xSemaphoreTake(m_logRingMutex, portMAX_DELAY);
bool popped = ringPopOldestLocked(entry, sizeof(entry), &entry_len) && entry_len > 0;
xSemaphoreGive(m_logRingMutex);
if (!popped) {
break; /* 环已空(并发被别处 drain) */
}
int entry_len = 0;
if (!ringPopOldestLocked(entry, sizeof(entry), &entry_len) || entry_len <= 0) {
break;
/* 预留单条转义最坏占用再决定是否 flush:jsonEscapeAppend 内部把单条封顶在
* LOG_RING_SIZE*2(控制符 \u00XX 会 6× 膨胀),故按该上限 + 逗号/引号/收尾预留,
* 与 entry_len 无关;放不下先 flush,flush 后空缓冲必能完整容纳单条不被截断。 */
const int need = LOG_RING_SIZE * 2 + 8;
if (pos + need > STREAM_BUF_CAP) {
if (!sink(ctx, buf, pos)) {
return -1;
}
pos = 0;
}
if (!first) {
@@ -482,27 +436,26 @@ char *DebugLog::ringDrainJson(int *out_len)
}
first = false;
buf[pos++] = '"';
pos = jsonEscapeAppend(buf, pos, capacity, entry, entry_len);
if (pos < capacity - 1) {
pos = jsonEscapeAppend(buf, pos, STREAM_BUF_CAP, entry, entry_len);
if (pos < STREAM_BUF_CAP - 1) {
buf[pos++] = '"';
} else if (pos < capacity) {
buf[pos - 1] = '"'; /* 极端:覆盖末字节闭合 */
} else {
buf[pos - 1] = '"'; /* 极端兜底:覆盖末字节闭合 */
}
}
if (pos + 1 < capacity) {
/* 收尾 ']':缓冲放不下先 flush,再单独发闭合。 */
if (pos + 1 > STREAM_BUF_CAP) {
if (!sink(ctx, buf, pos)) {
return -1;
}
pos = 0;
}
buf[pos++] = ']';
buf[pos] = '\0';
} else if (capacity >= 3) {
buf[capacity - 2] = ']';
buf[capacity - 1] = '\0';
pos = capacity - 1;
if (!sink(ctx, buf, pos)) {
return -1;
}
xSemaphoreGive(m_logRingMutex);
*out_len = pos;
return buf;
return 1;
}
// ============================================================
+17 -8
View File
@@ -9,7 +9,7 @@
* 注意:当前 tools/WebUI 日志面板走的是 HTTP /logs,
* 不是直接监听 UDP 6666。
*
* UART0: TX=GPIO21, RX=GPIO20, 115200 波特率
* UART0: TX=GPIO21, RX=GPIO20, 3000000 波特率
*
* 线程模型:DEBUG_* / log() / send() 仅允许在任务上下文调用
* (内部使用 portMAX_DELAY 互斥锁,禁止 ISR)。
@@ -31,7 +31,7 @@
// UART0 调试配置
#define UART_DEBUG_TX_GPIO 21 ///< UART0 TX GPIO 引脚号
#define UART_DEBUG_RX_GPIO 20 ///< UART0 RX GPIO 引脚号
#define UART_DEBUG_BAUD_RATE 115200 ///< UART0 波特率
#define UART_DEBUG_BAUD_RATE 3000000 ///< UART0 波特率
// 日志级别
typedef enum {
@@ -41,6 +41,12 @@ typedef enum {
DEBUG_LOG_DBG = 4 // 调试
} debug_log_level_t;
/**
* @brief /logs 流式发送 sink:每个 chunk 回调一次;返回 false 表示发送失败需中止。
* @param ctx 透传上下文(如 httpd_req_t*)。
*/
typedef bool (*LogJsonSink)(void *ctx, const char *data, int len);
/**
* @brief 调试日志类(单例)
*
@@ -79,11 +85,15 @@ public:
bool ringPopOldest(char *out, size_t out_size, int *out_len);
/**
* @brief 将环形缓冲区日志导出为 JSON 数组字符串。
* @param out_len 输出 JSON 字符串长度。
* @return 动态分配的 JSON 字符串,调用者须 free();失败返回 NULL。
* @brief 将环形日志以 chunked JSON 数组流式发送,消除一次性大块 malloc。
* 按条 pop(短持锁),512B 栈缓冲攒满即经 sink 发一个 chunk;
* 峰值从原 ~4KB malloc 降为栈缓冲 + 小 chunk。
* @param sink 每个 chunk 的发送回调;返回 false 表示发送失败。
* @param ctx 透传给 sink 的上下文。
* @return 1=已流式发送完整数组(调用方需补发终止 chunk);
* 0=无日志/堆门控(调用方一次性发 "[]");-1=中途发送失败。
*/
char* ringDrainJson(int *out_len);
int ringDrainJsonStream(LogJsonSink sink, void *ctx);
void setLogLevel(debug_log_level_t level) { m_logLevel = level; }
debug_log_level_t getLogLevel() const { return m_logLevel; }
@@ -129,7 +139,7 @@ private:
* free < kDropInfoFree → 非 keep-alive 丢 INFO/DBG;keep-alive 保留 INFO
* free < kDropWarnFree → 全局丢 DBG
* free < kSkipUdpFree → 不 sendto(仍 UART+ring)
* free < kDrainMinFree → /logs 返 "[]" 避免大块 malloc
* free < kDrainMinFree → /logs 返 "[]"(低堆时连流式小 chunk 的 pbuf 也省)
* keep-alive tag:OTA 前缀、SPI、WIFI */
static constexpr uint32_t kDropWarnFree = 6U * 1024U;
static constexpr uint32_t kDropInfoFree = 10U * 1024U;
@@ -151,7 +161,6 @@ private:
int ringPeekDrainMetaLocked(int max_n, int *total_raw_len) const;
static bool isKeepAliveTag(const char *tag);
static char *allocEmptyJson(int *out_len);
static int jsonEscapeAppend(char *buf, int pos, int capacity, const char *src, int src_len);
};
+458
View File
@@ -0,0 +1,458 @@
/*
* motor_ota_relay.cpp - 电机 OTA relay ESP32 侧实现
*/
#include "monitor/motor_ota_relay.h"
#include "monitor/debug_log.h"
#include "transport/spi_master.h"
#include "storage/gd32_flash_store.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include <string.h>
static const char *TAG = "MOTOR_OTA";
/* SPI 类型常量(与 GD32 app_spi_command_defs.h 对齐) */
static constexpr uint8_t kSpiMotorOtaCmd = 0xD0U;
static constexpr uint8_t kSpiMotorOtaData = 0xD1U;
static constexpr uint8_t kSpiMotorOtaStatus = 0xD2U;
static constexpr uint8_t kRspAck = 0x80U;
static constexpr uint8_t kRspWait = 0x81U;
/* 内部状态 */
static SemaphoreHandle_t s_mutex = nullptr;
static motor_ota_status_t s_status = {};
/* 固件不再整体缓冲在堆里:HTTP handler 流式写入 gd32_ota flash 分区,
* feedTask 经 flashReadChunk 分块读取(见 feedTask)。 */
static uint32_t s_fw_size = 0;
static uint32_t s_fw_offset = 0; /* 已喂给 GD32 的偏移 */
static TaskHandle_t s_feed_task = nullptr;
static volatile bool s_feed_running = false;
static int64_t nowMs() {
return esp_timer_get_time() / 1000;
}
/* ═══════════════════ 喂数据 task ═══════════════════ */
static void feedTask(void *arg)
{
(void)arg;
uint8_t resp_payload[16];
uint8_t resp_type = 0;
size_t resp_len = 0;
/* 栈上 chunk:[offset:u32 LE][data…],避免整固件堆缓冲 */
uint8_t chunk_buf[MOTOR_OTA_CHUNK_SIZE];
/* 整体超时保护:feedTask 最长存活 120s,超时自动退出。
* 防止 GD32 侧不回 ACK 或持续回 WAIT 时,task 永久卡在循环里,
* 导致 motorOtaIsActive() 一直为 true、阻塞其他 OTA。 */
const int64_t deadline_ms = nowMs() + 120000LL;
/* 启动宽限期:GD32 需要时间完成 kPreCancel(800ms 残帧窗口) + 握手(~1s)。
* 在这个窗口内不检查终态,避免上一次 OTA 残留的 ERROR 状态推送
* (0xD2 ring) 抢先覆写 s_status 导致 feedTask 误判退出。
* 原 3s 过保守:GD32 进握手后不再发 ERROR push,1500ms 足够覆盖
* kPreCancel + 开始握手(kBootSettleMs 已降到 800ms)。 */
const int64_t grace_end_ms = nowMs() + 1500LL;
/* 重新启用 suppressRingService:ring service 抢占 SPI 带宽导致吐量从 ~5 chunks/s 降到 ~1.5 chunks/s。
* 进度显示由 feedTask 自己基于 s_fw_offset 更新(近似值),不依赖 GD32 push。 */
SpiMaster::instance().setSuppressRingService(true);
/* suppress 期间 s_status 不被 0xD2 push 更新(worker 不读 ring),
* feedTask 无法靠 motorOtaGetStatus() 检测 GD32 是否已进 ERROR/FINISHED 终态。
* 连续 SPI 失败计数:超过阈值时主动 QUERY GD32 真实状态,避免盲重试空转到 120s。 */
uint32_t consec_fail = 0U;
constexpr uint32_t kQueryAfterFails = 8U; /* 连续 8 次失败(~2s)后 QUERY 一次 */
uint32_t ack_n = 0U; /* 成功 ACK 计数,用于降频写 s_status */
while (s_feed_running && s_fw_offset < s_fw_size) {
if (nowMs() >= deadline_ms) {
ESP_LOGE(TAG, "feedTask 整体超时 (120s),强制退出 offset=%u/%u",
(unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
/* GD32 已进终态:停止喂,避免盲重试空转到 120s 超时。
* 启动宽限期(3s)内跳过终态检查:避免上一次 OTA 残留的 ERROR 状态推送
* (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。
* GD32 需 kPreCancel(500ms 沉淀)+握手(~1s) 才能进入传输态。 */
const motor_ota_status_t st = motorOtaGetStatus();
if (nowMs() >= grace_end_ms) {
if (st.state == MOTOR_OTA_IDLE || st.state == MOTOR_OTA_FINISHED ||
st.state == MOTOR_OTA_ERROR) {
ESP_LOGW(TAG, "GD32 终态 0x%02X,停止喂数据 offset=%u/%u",
(int)st.state, (unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
}
/* 注:旧版有 "GD32 回到握手阶段→重置 offset" 的逻辑,
* 但 v1.4.38+ 已移除握手重试(0x05 直接中止不重握手),
* 且 v1.4.58 feedData 在握手期间就接受数据(预填充缓冲)。
* 若在此重置 offset,会导致缓冲中数据重复。已移除。 */
const uint32_t remaining = s_fw_size - s_fw_offset;
const uint32_t data_len = (remaining < MOTOR_OTA_CHUNK_DATA)
? remaining : MOTOR_OTA_CHUNK_DATA;
const uint32_t frame_len = MOTOR_OTA_CHUNK_HDR + data_len;
/* [0..3]=绝对 offset LE,[4..]=固件数据。重传同 offset 时 GD32 幂等。 */
chunk_buf[0] = static_cast<uint8_t>(s_fw_offset & 0xFFU);
chunk_buf[1] = static_cast<uint8_t>((s_fw_offset >> 8) & 0xFFU);
chunk_buf[2] = static_cast<uint8_t>((s_fw_offset >> 16) & 0xFFU);
chunk_buf[3] = static_cast<uint8_t>((s_fw_offset >> 24) & 0xFFU);
esp_err_t rd = Gd32FlashStore::instance().flashReadChunk(
s_fw_offset, chunk_buf + MOTOR_OTA_CHUNK_HDR, data_len);
if (rd != ESP_OK) {
ESP_LOGE(TAG, "flash read fail @%u: %s", (unsigned)s_fw_offset, esp_err_to_name(rd));
break;
}
esp_err_t ret = SpiMaster::instance().transact(
kSpiMotorOtaData,
chunk_buf, frame_len,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
200U); /* 200ms: v1.4.59成功时的确切值。poll有~95ms 做多次盲读 */
if (ret != ESP_OK) {
ESP_LOGW(TAG, "SPI data fail: %s offset=%u", esp_err_to_name(ret), (unsigned)s_fw_offset);
vTaskDelay(pdMS_TO_TICKS(10)); /* 快速重试(原 100ms 太慢导致吐量低) */
/* suppress 期间 s_status 不更新,连续失败时主动 QUERY GD32 真实状态,
* 避免 GD32 已进 ERROR 终态但 feedTask 不知道、盲重试空转到 120s 超时。 */
if (++consec_fail >= kQueryAfterFails) {
consec_fail = 0U;
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
&& qt == kRspAck && ql >= 1U) {
motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED ||
qst == MOTOR_OTA_ERROR) {
ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u",
(int)qst, (unsigned)qp[1],
(unsigned)s_fw_offset, (unsigned)s_fw_size);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qp[1];
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
break;
}
}
}
continue; /* 不推进 offset,同 offset 重发 */
}
consec_fail = 0U;
if (resp_type == kRspAck) {
s_fw_offset += data_len;
/* 进度:每 8 chunk 写 s_status;state 在 suppress 下不会收 0xD2,
* 喂数一开始就标 TRANSFER,避免网页卡在「开始握手」。 */
const bool touch_status = ((++ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size);
if (touch_status || ack_n == 1U) {
const uint16_t fed_pkts = static_cast<uint16_t>((s_fw_offset + 47U) / 48U);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
if (s_status.state < MOTOR_OTA_TRANSFER ||
s_status.state == MOTOR_OTA_PRE_CANCEL ||
s_status.state == MOTOR_OTA_HANDSHAKE_REQ ||
s_status.state == MOTOR_OTA_HANDSHAKE_NONCE ||
s_status.state == MOTOR_OTA_HANDSHAKE_CONFIRM ||
s_status.state == MOTOR_OTA_ERASE) {
s_status.state = MOTOR_OTA_TRANSFER;
}
s_status.packets_acked = fed_pkts;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
}
} else if (resp_type == kRspWait) {
/* 缓冲满:不推进 offset,稍候用新 TX_SEQ 再发同一 offset。
* WAIT 路径 SPI 层已推进 seq(见 otaPollResponseUnlocked),
* 避免同 seq 重发被 GD32 当重复请求死锁。 */
vTaskDelay(pdMS_TO_TICKS(2));
} else {
/* 非 ACK/WAIT(含 ERROR/陈旧 type):不 break 整次 OTA。
* 曾因 poll 读到 EVENT/错 type 直接 break → fed 卡首 chunk。 */
ESP_LOGW(TAG, "SPI data unexpected type=0x%02X offset=%u,重试",
resp_type, (unsigned)s_fw_offset);
vTaskDelay(pdMS_TO_TICKS(5));
}
}
/* 恢复 ring service,让后续 GD32 push(FINISHED/ERROR) 正常到达 */
SpiMaster::instance().setSuppressRingService(false);
/* feedTask 字节喂完 ≠ GD32/电机已完成。suppress 期间 0xD2 被丢;
* 末包 CAN ACK / 跳转 APP 还要数百 ms。只 QUERY 一次会把前端卡在 kTransfer。
* 退出后轮询 QUERY,直到 GD32 进 FINISHED/ERROR/IDLE,或最多 ~8s。 */
{
constexpr uint32_t kExitPollMs = 8000U;
constexpr uint32_t kExitGapMs = 150U;
const uint32_t exit_deadline = nowMs() + kExitPollMs;
bool got_terminal = false;
while (nowMs() < exit_deadline) {
/* 优先吃 ring 推送(已恢复 suppress) */
{
const motor_ota_status_t st = motorOtaGetStatus();
if (st.state == MOTOR_OTA_FINISHED || st.state == MOTOR_OTA_ERROR ||
st.state == MOTOR_OTA_IDLE) {
ESP_LOGI(TAG, "feedTask 退出,ring 终态: state=%u err=%u pkt=%u/%u",
(unsigned)st.state, (unsigned)st.error_code,
(unsigned)st.packets_acked, (unsigned)st.total_packets);
got_terminal = true;
break;
}
}
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qresp_type = 0;
uint8_t qresp[16] = {0};
size_t qresp_len = 0;
esp_err_t qr = SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1,
&qresp_type, qresp, sizeof(qresp), &qresp_len, 300U);
if (qr == ESP_OK && qresp_type == kRspAck && qresp_len >= 10U) {
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qresp[0]);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qresp[1];
s_status.packets_acked = static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8);
s_status.fw_size = static_cast<uint32_t>(qresp[6])
| (static_cast<uint32_t>(qresp[7]) << 8)
| (static_cast<uint32_t>(qresp[8]) << 16)
| (static_cast<uint32_t>(qresp[9]) << 24);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
if (qst == MOTOR_OTA_FINISHED || qst == MOTOR_OTA_ERROR ||
qst == MOTOR_OTA_IDLE) {
ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u",
(unsigned)qst, (unsigned)qresp[1],
(unsigned)(static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8)),
(unsigned)(static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8)));
got_terminal = true;
break;
}
}
vTaskDelay(pdMS_TO_TICKS(kExitGapMs));
}
if (!got_terminal) {
ESP_LOGW(TAG, "feedTask 退出轮询超时,前端可能仍显示传输中 offset=%u/%u",
(unsigned)s_fw_offset, (unsigned)s_fw_size);
}
}
s_feed_running = false;
s_feed_task = nullptr;
vTaskDelete(NULL);
}
/* ═══════════════════ 公开接口 ═══════════════════ */
void motorOtaRelayInit(void)
{
if (!s_mutex) {
s_mutex = xSemaphoreCreateMutex();
}
}
void onMotorOtaStatusPush(const uint8_t *payload, size_t len)
{
if (len < 12U || !s_mutex) return;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) != pdTRUE) return;
s_status.state = static_cast<motor_ota_state_t>(payload[0]);
s_status.error_code = payload[1];
s_status.packets_acked = static_cast<uint16_t>(payload[2]) | (static_cast<uint16_t>(payload[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(payload[4]) | (static_cast<uint16_t>(payload[5]) << 8);
s_status.fw_size = static_cast<uint32_t>(payload[6])
| (static_cast<uint32_t>(payload[7]) << 8)
| (static_cast<uint32_t>(payload[8]) << 16)
| (static_cast<uint32_t>(payload[9]) << 24);
s_status.buf_used = static_cast<uint16_t>(payload[10]) | (static_cast<uint16_t>(payload[11]) << 8);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
int motorOtaStart(uint32_t fw_size, uint8_t channel)
{
if (fw_size == 0 || fw_size > MOTOR_OTA_MAX_FW_SIZE || channel > 2) {
return -1;
}
if (s_feed_running) {
return -2;
}
/* 固件已由 HTTP handler 流式写入 gd32_ota 分区;此处不再堆缓冲 */
s_fw_size = fw_size;
s_fw_offset = 0;
/* 发 SPI start 命令(best-effort,不阻塞等 ACK)。
* SPI "延迟一拍"协议下,START 的 ACK 偶发被 send 阶段当作 stale 吃掉、
* poll 反读空 → transact 报 TIMEOUT,但 GD32 实际已收到 START 进握手
* (GD32 日志 "SPI 0xD0 START recv" + "START v150" 每次稳定出现)。
* 重发 START 会被 GD32 拒(已在进行中→0xE5),QUERY 的 ACK 同样受
* delayed-by-one 影响不可靠。改为 fire-and-forget:命令发出即继续,
* 由 feedTask 靠 GD32 push 的状态 + 超时保护自决去留。 */
uint8_t cmd[6];
cmd[0] = 0U; /* cmd=START */
cmd[1] = channel;
cmd[2] = static_cast<uint8_t>(fw_size & 0xFF);
cmd[3] = static_cast<uint8_t>((fw_size >> 8) & 0xFF);
cmd[4] = static_cast<uint8_t>((fw_size >> 16) & 0xFF);
cmd[5] = static_cast<uint8_t>((fw_size >> 24) & 0xFF);
uint8_t resp_type = 0;
uint8_t resp_payload[16];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 6,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
300U); /* 300ms: fire-and-forget,让 worker 有足够时间完成 START。
* 原 500ms 偏保守;GD32 收到 START 立即进 kPreCancel,<50ms 响应。 */
if (ret != ESP_OK || resp_type != kRspAck) {
DEBUG_WARN(TAG, "START SPI 未确认 ACK (ret=%s resp=0x%02X)——fire-and-forget 继续",
esp_err_to_name(ret), resp_type);
}
/* 初始化状态 */
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
memset(&s_status, 0, sizeof(s_status));
s_status.state = MOTOR_OTA_HANDSHAKE_REQ;
s_status.fw_size = fw_size;
s_status.total_packets = static_cast<uint16_t>((fw_size + 47U) / 48U);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
/* 启动喂数据 task。prio=7 > spi_worker(6) > httpd(5):
* 传输期优先抢 SPI 队列;实际总线访问仍经 worker 串行。 */
s_feed_running = true;
BaseType_t ok = xTaskCreate(feedTask, "ota_feed", 4096, nullptr, 7, &s_feed_task);
if (ok != pdPASS) {
s_feed_running = false;
return -3;
}
ESP_LOGI(TAG, "Motor OTA started: %lu B, ch=%u", (unsigned long)fw_size, channel);
return 0;
}
void motorOtaCancel(void)
{
DEBUG_WARN(TAG, "cancel: feed_running=%d task=%p offset=%u/%u",
(int)s_feed_running, s_feed_task,
(unsigned)s_fw_offset, (unsigned)s_fw_size);
/* 立即标记 false,让 feedTask 在下次循环检查时退出。
* motorOtaIsActive() 依赖此标志,设 false 后后续 OTA 不再被阻塞。 */
s_feed_running = false;
/* 标记状态为 IDLE,让前端 /motor/ota/status 立即看到空闲 */
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_status.state = MOTOR_OTA_IDLE;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
/* 通知 GD32 取消 relay 状态机 + 等 feedTask 退出是耗时操作
*(SPI transact 最多 1s + feedTask 退出最多 2s),不能在 httpd 线程
* 同步阻塞,否则 cancel HTTP 请求本身会超时("Failed to fetch"),
* 且 httpd 单线程会饿死其他端点。
* 用独立 task 异步执行,HTTP handler 立即返回。 */
static TaskHandle_t cancel_task = nullptr;
if (cancel_task != nullptr) {
/* 上一次 cancel 还在跑,不重复创建 */
DEBUG_WARN(TAG, "cancel: previous cancel task still running, skip");
return;
}
auto do_cancel = [](void *) {
/* 通知 GD32 取消 relay 状态机 */
uint8_t cmd[1] = {1U}; /* cmd=CANCEL */
uint8_t resp_type = 0;
uint8_t resp_payload[4];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 1,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1000U);
DEBUG_WARN(TAG, "cancel: GD32 resp ret=%s type=0x%02X", esp_err_to_name(ret), resp_type);
/* 等待 feedTask 真正退出(最多 2s),避免 s_feed_running 已清但 task
* 仍卡在 SpiMaster::transact 阻塞中,导致 motorOtaIsActive() 瞬时仍 true。 */
TaskHandle_t t = s_feed_task;
if (t) {
for (int i = 0; i < 200 && s_feed_task != nullptr; i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (s_feed_task != nullptr) {
DEBUG_ERR(TAG, "cancel: feedTask 未在 2s 内退出,强制删除");
vTaskDelete(s_feed_task);
s_feed_task = nullptr;
}
}
s_feed_running = false; /* 双保险 */
cancel_task = nullptr;
vTaskDelete(NULL);
};
BaseType_t ok = xTaskCreate(do_cancel, "ota_cancel", 3072, nullptr, 4, &cancel_task);
if (ok != pdPASS) {
DEBUG_ERR(TAG, "cancel: failed to create cancel task, doing sync cancel");
cancel_task = nullptr;
/* 降级:同步执行(极端 OOM 情况) */
uint8_t cmd[1] = {1U};
uint8_t resp_type = 0;
uint8_t resp_payload[4];
size_t resp_len = 0;
(void)SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 1,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1000U);
TaskHandle_t t = s_feed_task;
if (t) {
for (int i = 0; i < 200 && s_feed_task != nullptr; i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (s_feed_task != nullptr) {
vTaskDelete(s_feed_task);
s_feed_task = nullptr;
}
}
s_feed_running = false;
}
DEBUG_WARN(TAG, "cancel: dispatched feed_running=%d", (int)s_feed_running);
}
bool motorOtaIsActive(void)
{
return s_feed_running;
}
motor_ota_status_t motorOtaGetStatus(void)
{
motor_ota_status_t out = {};
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
out = s_status;
xSemaphoreGive(s_mutex);
}
return out;
}
+97
View File
@@ -0,0 +1,97 @@
/*
* motor_ota_relay.h - 电机 OTA relay ESP32 侧(固件缓冲 + SPI 喂数据 + 进度缓存)
*
* 两段式流程(与 BLE 刷 GD32 对齐,避免收固件期间与 GD32 按铃争抢单缓冲 MISO):
* 1. 前端 POST /motor/ota 上传固件 + channel → 流式写 gd32_ota flash 分区,写 meta
* 2. 前端 POST /motor/ota/relay?ch=X → motorOtaStart 发 SPI 0xD0(start) + 启动 feedTask
* 3. feedTask 从 flash 分块读,经 SPI 0xD1(data) 喂 GD32
* 4. GD32 通过 0xD2 ring push 报进度,ESP32 缓存
* 5. 前端 GET /motor/ota/status 轮询进度
*/
#ifndef MOTOR_OTA_RELAY_H
#define MOTOR_OTA_RELAY_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define MOTOR_OTA_MAX_FW_SIZE 0xC000U /* 48 KB */
/* SPI payload 上限 236 = 256 - V2_OVERHEAD(20)。前 4B 为绝对 offset LE,数据最多 232B。
* 无 offset 时 SPI 超时重传会被 GD32 重复追加(BLF:前 236B 循环 → 刷坏)。 */
#define MOTOR_OTA_CHUNK_HDR 4U
#define MOTOR_OTA_CHUNK_DATA 232U
#define MOTOR_OTA_CHUNK_SIZE (MOTOR_OTA_CHUNK_HDR + MOTOR_OTA_CHUNK_DATA)
typedef enum {
MOTOR_OTA_IDLE = 0,
MOTOR_OTA_HANDSHAKE_REQ,
MOTOR_OTA_HANDSHAKE_NONCE,
MOTOR_OTA_HANDSHAKE_CONFIRM,
MOTOR_OTA_ERASE,
MOTOR_OTA_TRANSFER,
MOTOR_OTA_FINISH_OTA, /* 收到 0xF0F0F0F0,已发 FINISH_OTA,等跳转 ACK */
MOTOR_OTA_FINISHED,
MOTOR_OTA_ERROR,
MOTOR_OTA_PRE_CANCEL = 9 /* 握手前预取消:发 CANCEL 清理电机残留会话(GD32 侧过渡态) */
} motor_ota_state_t;
typedef struct {
motor_ota_state_t state;
uint8_t error_code;
uint16_t packets_acked;
uint16_t total_packets;
uint32_t fw_size;
uint16_t buf_used; /* GD32 侧缓冲占用 */
int64_t last_update_ms; /* ESP32 收到状态的时间 */
} motor_ota_status_t;
/**
* @brief 处理 GD32 0xD2 ring push(由 can_feedback_task 分发)。
*/
void onMotorOtaStatusPush(const uint8_t *payload, size_t len);
/**
* @brief 启动电机 OTA:发 SPI start + 启动喂数据 task。
*
* 固件须已由调用方(HTTP handler)流式写入 gd32_ota flash 分区
* (Gd32FlashStore::flashWriteChunk,offset 0 起)。喂数据 task 从
* flash 分块读取并经 SPI 0xD1 喂给 GD32,不在堆上缓冲整个固件
* (48KB 整块 malloc 会超出 ESP32-C3 的 largest_free_block,导致 OOM)。
*
* @param fw_size 固件大小 (≤48KB),须与已写入 flash 的大小一致
* @param channel CAN 通道 (0-2)
* @return 0 成功, -1 参数错误, -2 已在进行中, -3 SPI 失败
*/
int motorOtaStart(uint32_t fw_size, uint8_t channel);
/**
* @brief 电机 OTA 是否正在进行(喂数据 task 存活)。
* 供 OtaCoordinator 在 beginGd32Storage/beginEsp32Ota 中检查:
* 电机喂数期间禁止新的 GD32/ESP32 OTA,避免 gd32_ota 分区被并发覆写。
*/
bool motorOtaIsActive(void);
/**
* @brief 取消电机 OTA。
*/
void motorOtaCancel(void);
/**
* @brief 获取最新状态(线程安全)。
*/
motor_ota_status_t motorOtaGetStatus(void);
/**
* @brief 模块初始化(创建 mutex 等)。
*/
void motorOtaRelayInit(void);
#ifdef __cplusplus
}
#endif
#endif /* MOTOR_OTA_RELAY_H */
+137
View File
@@ -0,0 +1,137 @@
/*
* motor_state.cpp - GD32 多电机状态推送解析与缓存 (SPI TYPE 0xC5)
*/
#include "monitor/motor_state.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include <string.h>
namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_state_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorStateEvent(const uint8_t *payload, size_t len)
{
if (payload == nullptr || len < MOTOR_PAGE_HEADER) {
return false;
}
const uint8_t page = payload[0];
const uint8_t total_pages = payload[1];
const uint8_t entry_count = payload[2];
const uint8_t active_cnt = payload[3];
if (page >= MOTOR_TOTAL_PAGES || total_pages == 0 || total_pages > MOTOR_TOTAL_PAGES) {
return false;
}
/* 验证长度足够容纳声明的 entry 数 */
const size_t expected = MOTOR_PAGE_HEADER + static_cast<size_t>(entry_count) * MOTOR_ENTRY_SIZE;
if (len < expected) {
return false;
}
ensureMux();
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
/* page 0 时刷新全局字段。
* active 字节 bit7 = boot_active(总线上有电机在 BOOT,0x103 心跳),
* 低 7 位才是真实在线电机数。 */
if (page == 0) {
s_table.active_count = static_cast<uint8_t>(active_cnt & 0x7FU);
s_table.boot_active = (active_cnt & 0x80U) ? 1U : 0U;
}
/* 逐条解析 entry */
size_t offset = MOTOR_PAGE_HEADER;
for (uint8_t i = 0; i < entry_count; ++i) {
const uint8_t *e = &payload[offset];
const uint8_t node = e[0];
if (node < 1 || node > MOTOR_MAX_NODES) {
offset += MOTOR_ENTRY_SIZE;
continue;
}
motor_slot_t &slot = s_table.slots[node - 1];
slot.node_id = node;
slot.channel = e[1];
slot.online = e[2];
slot.dlc = e[3];
slot.rx_count = rdU32Le(&e[4]);
slot.seq = rdU32Le(&e[8]);
memcpy(slot.data, &e[12], MOTOR_STATUS_DLC);
offset += MOTOR_ENTRY_SIZE;
}
/* 统计有效槽位 */
uint8_t valid = 0;
for (uint8_t i = 0; i < MOTOR_MAX_NODES; ++i) {
if (s_table.slots[i].node_id != 0) valid++;
}
s_table.valid_slots = valid;
s_table.recv_ms = esp_timer_get_time() / 1000;
s_table.have_data = true;
xSemaphoreGive(s_mux);
return true;
}
bool getLatestMotorState(motor_state_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
if (s_mux == nullptr) return false;
bool ok = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
if (s_table.have_data) {
*out = s_table;
ok = true;
}
xSemaphoreGive(s_mux);
}
return ok;
}
uint32_t motorStateAgeMs(void)
{
ensureMux();
if (s_mux == nullptr) return UINT32_MAX;
int64_t recv = 0;
bool have = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
have = s_table.have_data;
recv = s_table.recv_ms;
xSemaphoreGive(s_mux);
}
if (!have || recv <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv) return 0U;
const int64_t age = now - recv;
return (age > static_cast<int64_t>(UINT32_MAX)) ? UINT32_MAX
: static_cast<uint32_t>(age);
}
+72
View File
@@ -0,0 +1,72 @@
/*
* motor_state.h - 被动接收 GD32 多电机状态推送(SPI TYPE 0xC5)
*
* 与 CAN 0xC1 / ADC 0xC4 同模型:GD32 250ms 主动按铃推送(独立任务位),ESP32 takeEvent 侧解析入库。
* 是否推送由 GD32 CONFIG_MOTOR_STATE_ENABLE 决定;ESP32 无本地开关,有帧即解析。
*
* payload 格式(每页):
* [0] page, [1] total_pages, [2] entry_count, [3] active_count
* [4..] 每个 entry 44B:
* [0] node_id, [1] channel, [2] online, [3] dlc,
* [4..7] rx_count (LE), [8..11] seq (LE),
* [12..43] data[32] (原始 STATUS payload)
*/
#ifndef MOTOR_STATE_H
#define MOTOR_STATE_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define MOTOR_MAX_NODES 9
#define MOTOR_STATUS_DLC 32
#define MOTOR_ENTRY_SIZE 44
#define MOTOR_PAGE_HEADER 4
#define MOTOR_TOTAL_PAGES 2
/** @brief 单电机状态快照。 */
typedef struct {
uint8_t node_id; ///< 1-9, 0=空槽
uint8_t channel; ///< CAN 通道 (0/1/2)
uint8_t online; ///< 1=在线, 0=离线
uint8_t dlc; ///< STATUS 帧 DLC (正常=32)
uint32_t rx_count; ///< 累计收到帧数
uint32_t seq; ///< GD32 侧更新序号
uint8_t data[MOTOR_STATUS_DLC]; ///< 原始 STATUS payload
} motor_slot_t;
/** @brief 全局电机状态表。 */
typedef struct {
motor_slot_t slots[MOTOR_MAX_NODES]; ///< 按 node_id-1 索引
uint8_t active_count; ///< 在线电机数
uint8_t boot_active; ///< 1=总线上有电机在 BOOT(0x103 心跳,来自 0xC5 头 bit7)
uint8_t valid_slots; ///< 有效槽位数(node_id!=0)
int64_t recv_ms; ///< ESP32 最近收到时 esp_timer ms
bool have_data; ///< 至少收到过一帧
} motor_state_table_t;
/**
* @brief 处理一帧 0xC5 EVENT payload(由 can_fb 任务 takeEvent 分发)。
* @return true 表示已接受并更新。
*/
bool onMotorStateEvent(const uint8_t *payload, size_t len);
/**
* @brief 拷贝最新电机状态表(线程安全)。
* @param[out] out 输出缓冲
* @return true 表示至少收到过一帧
*/
bool getLatestMotorState(motor_state_table_t *out);
/** @brief 距离上次收到的毫秒数;无数据返回 UINT32_MAX。 */
uint32_t motorStateAgeMs(void);
#ifdef __cplusplus
}
#endif
#endif /* MOTOR_STATE_H */
+84
View File
@@ -0,0 +1,84 @@
/*
* motor_version.cpp - GD32 多电机版本推送解析与缓存 (SPI TYPE 0xC6)
*
* 镜像 motor_state.cpp 的互斥保护缓存模型。
*/
#include "monitor/motor_version.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include <string.h>
namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_version_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorVersionEvent(const uint8_t *payload, size_t len)
{
if (payload == nullptr || len < MOTORVER_HEADER_LEN) {
return false;
}
const uint8_t count = payload[0];
ensureMux();
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
size_t offset = MOTORVER_HEADER_LEN;
for (uint8_t i = 0; i < count; ++i) {
if (offset + MOTORVER_ENTRY_SIZE > len) {
break;
}
const uint8_t node = payload[offset];
if (node >= 1 && node <= MOTORVER_MAX_NODES) {
motor_version_slot_t &slot = s_table.slots[node - 1];
slot.node_id = node;
slot.seq = rdU32Le(&payload[offset + 1]);
memcpy(slot.version, &payload[offset + 5], MOTORVER_DLC);
slot.recv_ms = esp_timer_get_time() / 1000;
slot.valid = true;
s_table.have_data = true;
}
offset += MOTORVER_ENTRY_SIZE;
}
xSemaphoreGive(s_mux);
return true;
}
bool getLatestMotorVersion(motor_version_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
if (s_mux == nullptr) return false;
bool ok = false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) == pdTRUE) {
if (s_table.have_data) {
*out = s_table;
ok = true;
}
xSemaphoreGive(s_mux);
}
return ok;
}
+62
View File
@@ -0,0 +1,62 @@
/*
* motor_version.h - 被动接收 GD32 多电机版本推送(SPI TYPE 0xC6)
*
* 与 0xC5 电机状态同模型:GD32 收到电机版本应答(0x00FF)后事件驱动按铃推送,
* ESP32 takeEvent 侧解析入库。版本为按需查询的低频数据,独立于 0xC5 周期推送。
* 是否推送由 GD32 CONFIG_MOTOR_VERSION_ENABLE 决定;ESP32 无本地开关,有帧即解析。
*
* payload 格式(单帧,Header 2B + 每 entry 29B):
* [0] count 本帧节点数
* [1] rsvd 保留
* [2..] 每个 entry 29B:
* [0] node_id, [1..4] seq (LE), [5..28] version[24] (原始版本 payload)
*/
#ifndef MOTOR_VERSION_H
#define MOTOR_VERSION_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define MOTORVER_MAX_NODES 9
#define MOTORVER_DLC 24
#define MOTORVER_ENTRY_SIZE 29 /* node(1) + seq(4) + version(24) */
#define MOTORVER_HEADER_LEN 2
/** @brief 单电机版本快照。 */
typedef struct {
uint8_t node_id; ///< 1-9, 0=空槽
uint32_t seq; ///< GD32 侧更新序号(判新鲜用)
uint8_t version[MOTORVER_DLC]; ///< 原始版本 payload
int64_t recv_ms; ///< ESP32 收到时 esp_timer ms
bool valid; ///< 该槽已收到过版本
} motor_version_slot_t;
/** @brief 全局电机版本表。 */
typedef struct {
motor_version_slot_t slots[MOTORVER_MAX_NODES]; ///< 按 node_id-1 索引
bool have_data; ///< 至少收到过一帧
} motor_version_table_t;
/**
* @brief 处理一帧 0xC6 EVENT payload(由 can_fb 任务 takeEvent 分发)。
* @return true 表示已接受并更新。
*/
bool onMotorVersionEvent(const uint8_t *payload, size_t len);
/**
* @brief 拷贝最新电机版本表(线程安全)。
* @param[out] out 输出缓冲
* @return true 表示至少收到过一帧
*/
bool getLatestMotorVersion(motor_version_table_t *out);
#ifdef __cplusplus
}
#endif
#endif /* MOTOR_VERSION_H */
+28 -1
View File
@@ -6,6 +6,7 @@
*/
#include "ota_coordinator.h"
#include "monitor/can_feedback_task.h"
#include "monitor/motor_ota_relay.h"
#include "esp32_ota_core.h"
#include "relay/ota_relay.h"
#include "storage/gd32_flash_store.h"
@@ -142,6 +143,14 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
// ★ 先做卡死自愈检查:若之前忙状态卡死,自动转 ERROR 继续后续流程
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数任务与 GD32 固件共用 gd32_ota 分区,
// 电机喂数期间禁止新的 GD32 存储,避免分区被并发覆写。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 GD32 存储");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// ★ 同类型重启:若上一次 GD32 存储仍在进行中(STORING)且来自同一通道,
// 自动中止旧存储并重新开始。覆盖"上传过程中刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
@@ -412,6 +421,14 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// ★ 卡死自愈检查
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:ESP32 自身 OTA 会重启设备,中断正在进行的电机喂数,
// 电机 OTA 期间拒绝 ESP32 OTA。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 ESP32 OTA");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
@@ -499,6 +516,15 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe
} else {
DEBUG_WARN(TAG, "ESP32拒但空闲,判锁残留,强制清后重试");
}
/* ★ 若 motor OTA 的 feedTask 残留活跃(s_feed_running 仍 true,
* 常见于 cancel 请求未送达或 feedTask 卡在 SPI transact),
* 必须先 motorOtaCancel() 让 feedTask 退出,否则 forceCleanup
* 只清 ESP32 锁、motorOtaIsActive() 仍 true,重试时又被拒,
* 形成日志里"强制清后重试→电机 OTA 进行中→再拒"的死循环。 */
if (motorOtaIsActive()) {
DEBUG_WARN(TAG, "强制清前先取消残留的电机 OTA feedTask");
motorOtaCancel();
}
Esp32OtaCore::instance().forceCleanup();
err = beginEsp32Ota(total_size, channel);
}
@@ -597,7 +623,8 @@ void OtaCoordinator::doAbortLocked()
if (m_sysState == OTA_SYS_GD32_STORING) {
Gd32FlashStore::instance().metaClear();
} else if (m_sysState == OTA_SYS_GD32_RELAYING) {
OtaRelay::instance().reset();
/* 中继中止必须 CANCEL 远端,否则 GD32 停在 WAITING_* 半会话 */
OtaRelay::instance().abortRemoteAndReset(true);
} else if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING) {
Esp32OtaCore::instance().abort();
+49 -7
View File
@@ -458,7 +458,7 @@ ota_result_t OtaRelay::forwardFirmware()
if (meta.total_size == 0U || meta.stored_size < meta.total_size) {
DEBUG_ERR("OTA", "固件未完整存储 stored=%" PRIu32 " total=%" PRIu32,
meta.stored_size, meta.total_size);
reset();
abortRemoteAndReset(true); /* 顺带清上次远端半会话 */
return OTA_RESULT_ERR_STORAGE;
}
{
@@ -468,7 +468,7 @@ ota_result_t OtaRelay::forwardFirmware()
esp_err_t verify_err = Gd32FlashStore::instance().metaVerifyChecksum();
if (verify_err != ESP_OK) {
DEBUG_ERR("OTA", "固件完整性校验失败,中止转发");
reset();
abortRemoteAndReset(true);
return OTA_RESULT_ERR_CRC;
}
} else {
@@ -498,7 +498,8 @@ ota_result_t OtaRelay::forwardFirmware()
if (res != OTA_RESULT_OK) {
DEBUG_ERR("OTA", "中继失败: result=%d, state=%d", (int)res, (int)getState());
reset();
/* 必须通知 GD32 CANCEL,否则远端停在 WAITING_* 半会话 */
abortRemoteAndReset(true);
} else {
/* 成功路径:解除按铃抑制(reset 也会做,成功时状态机可能停在 DONE) */
SpiMaster::instance().setSuppressRingService(false);
@@ -547,6 +548,39 @@ void OtaRelay::reset()
DEBUG_INFO("OTA", "状态机已复位 → IDLE");
}
/**
* @brief 向 GD32 发送 OTA CANCEL,驱动远端 resetToIdle(含 CREDIT/缓冲清理)。
*
* 失败路径必须调用:本端 reset() 只清 ESP32 状态机,GD32 仍可能停在
* WAITING_RANDOM / WAITING_CONFIRM 等半会话,导致下次握手/进度错乱。
*/
esp_err_t OtaRelay::sendRemoteCancel()
{
uint8_t resp_type = 0;
uint8_t resp_payload[8] = {0};
size_t resp_len = 0;
/* payload 可空;GD32 CANCEL 分支无条件 resetToIdle 并 ACK 0x03 */
esp_err_t err = SpiMaster::instance().transact(
SPI_OTA_CMD_CANCEL, nullptr, 0,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1500U);
if (err != ESP_OK) {
DEBUG_WARN("OTA", "远端 CANCEL 发送失败: %s(本端仍复位)", esp_err_to_name(err));
return err;
}
DEBUG_INFO("OTA", "远端 CANCEL 已发送, resp t=0x%02X n=%zu", resp_type, resp_len);
return ESP_OK;
}
void OtaRelay::abortRemoteAndReset(bool notify_remote)
{
if (notify_remote) {
/* 中继失败时 suppress 可能仍为 true,CANCEL 需能走 SPI 事务 */
(void)sendRemoteCancel();
}
reset();
}
// ============================================================
// 内部工具函数(不变)
// ============================================================
@@ -965,8 +999,12 @@ ota_result_t OtaRelay::sendAndWaitAckShape(const char *stage, uint8_t cmd,
esp_err_t err = SpiMaster::instance().transact(cmd, payload, payload_len,
&resp_type, resp_payload, resp_max, resp_len, transact_timeout_ms);
logRespPayload(stage, resp_type, resp_payload, *resp_len);
/* transact 已超时/失败时勿再 pollUntil:否则 resp_type 仍为 0,会刷
* "unexpected type=0x00" 并二次空等,掩盖真实超时。 */
if (err == ESP_OK) {
err = pollUntilExpectedAckShape(stage, &resp_type, resp_payload, resp_max, resp_len,
poll_timeout_ms, wait_rounds, max_extra_polls, matcher);
}
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || !matcher(resp_payload, *resp_len)) {
DEBUG_ERR("OTA", "%s fail err=%d t=0x%02X n=%zu",
stage, err, resp_type, *resp_len);
@@ -1473,7 +1511,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
if (!getGd32AppAlive()) {
DEBUG_ERR("OTA", "GD32 APP 启动确认超时,OTA 失败");
reset();
/* APP 未起来时 CANCEL 可能无效,仍尽力清理 BOOT 半会话 */
abortRemoteAndReset(true);
return OTA_RESULT_ERR_GD32;
}
@@ -1581,16 +1620,19 @@ void OtaRelay::taskEntry(void *pvParameters)
xQueueSend(resultQueue, &result_msg, pdMS_TO_TICKS(100));
if (result == OTA_RESULT_OK) {
// 成功日志已在 doWaitForAppAlive 输出,此处不再重复
vTaskDelay(pdMS_TO_TICKS(3000));
// 成功日志已在 doWaitForAppAlive 输出,立即通知协调器置完成态
instance().reset(); /* 本端清会话;GD32 已跳 APP */
} else if (result == OTA_RESULT_ERR_TIMEOUT) {
DEBUG_ERR("OTA", "OTA超时中止 TIMEOUT=%d", (int)result);
/* forwardFirmware 失败路径已 abortRemoteAndReset;再调一次无害(二次 CANCEL) */
instance().abortRemoteAndReset(true);
} else if (result == OTA_RESULT_ERR_USER_ABORT) {
DEBUG_ERR("OTA", "OTA 用户中止 (USER_ABORT=%d)", (int)result);
instance().abortRemoteAndReset(true);
} else {
DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", (int)result);
instance().abortRemoteAndReset(true);
}
instance().reset();
// 通过 IRelayListener 接口回调通知协调器中继结束
listener = getRelayListener();
if (listener) listener->onRelayComplete(result == OTA_RESULT_OK);
+14 -1
View File
@@ -177,10 +177,18 @@ public:
static TaskHandle_t getTaskHandle();
/**
* @brief 重置状态机到 IDLE.
* @brief 重置本端中继状态机到 IDLE(不主动通知 GD32)。
* 中继失败/中止时请优先用 abortRemoteAndReset()。
*/
void reset();
/**
* @brief 通知 GD32 取消 OTA 会话(SPI CANCEL),再复位本端到 IDLE。
* 失败路径必须调用,否则 GD32 可能停在 WAITING_RANDOM/CONFIRM 等半会话。
* @param notify_remote true 时尽力发送 CANCEL(默认 true)。
*/
void abortRemoteAndReset(bool notify_remote = true);
// ── 中继事件监听器 ──
/**
* @brief 注册中继事件监听器(由 OtaCoordinator 在 init 时调用)。
@@ -313,6 +321,11 @@ private:
* @return OTA_RESULT_OK 成功,OTA_RESULT_ERR_AUTH 认证失败。
*/
ota_result_t doChallengeAndAuth();
/**
* @brief 向 GD32 发送 OTA CANCEL(0x03),请求远端完整 resetToIdle。
* @return ESP_OK 已发出并读到响应(或可忽略的链路错误已记录)。
*/
esp_err_t sendRemoteCancel();
/**
* @brief 发送固件总大小.
* @param size 固件总大小(字节)。
+653 -15
View File
@@ -35,6 +35,9 @@
#include "canfd_bridge.h"
#include "monitor/can_feedback_task.h"
#include "monitor/gd32_adc_feedback.h"
#include "monitor/motor_state.h"
#include "monitor/motor_version.h"
#include "monitor/motor_ota_relay.h"
#include "monitor/task_stack.h"
#include "ble_ota.h"
#include "system_boot.h"
@@ -96,6 +99,19 @@ void http_set_cors_headers(httpd_req_t *req)
httpd_resp_set_hdr(req, "Access-Control-Max-Age", "600");
}
/* 显式声明 keep-alive,让浏览器明确复用 TCP 连接。
* ESP-IDF httpd 默认 HTTP/1.1 持久连接,但不发此头时部分客户端/代理
* 按 HTTP/1.0 语义关闭。4Hz motor 轮询每次新建 TCP 握手 ~10~30ms
* 纯开销;keep-alive 后复用同一 TCP,省全部握手时间。
*
* 不放在 http_set_cors_headers 里:OTA 上传/relay handler 需要 Connection: close
* (设备即将重启),它们仍调 cors_headers 但不应加 keep-alive,否则会与
* httpd_resp_set_hdr("Connection","close") 产生重复头。 */
void http_set_keepalive_hdr(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Connection", "keep-alive");
}
esp_err_t http_options_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
@@ -407,7 +423,8 @@ esp_err_t ota_upload_handler(httpd_req_t *req)
}
if (OtaRelay::getState() != RELAY_IDLE) {
DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState());
OtaRelay::instance().reset();
/* 上次失败可能残留本端状态;同时尽力 CANCEL 远端半会话 */
OtaRelay::instance().abortRemoteAndReset(true);
}
// 与 BLE 共用 prepareGd32Storage(内含 validateGd32Size + begin + erase + meta)
@@ -614,22 +631,32 @@ esp_err_t ota_relay_trigger_handler(httpd_req_t *req)
return ESP_OK;
}
/* /logs 分块流式 sink:每个 chunk 经 httpd_resp_send_chunk 发出。 */
static bool logs_chunk_sink(void *ctx, const char *data, int len)
{
httpd_req_t *req = static_cast<httpd_req_t *>(ctx);
return httpd_resp_send_chunk(req, data, len) == ESP_OK;
}
esp_err_t logs_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
int json_len = 0;
char *json = DebugLog::instance().ringDrainJson(&json_len);
if (json && json_len > 0) {
http_set_keepalive_hdr(req);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, json, json_len);
free(json);
} else {
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "[]", 2);
/* 流式发送:消除原 ~4KB 一次性 malloc,改为 512B 栈缓冲 + 分块。
* lwip TX pbuf 峰值与应用堆峰值同时下降。 */
int r = DebugLog::instance().ringDrainJsonStream(logs_chunk_sink, req);
if (r == 1) {
/* 各数据 chunk 已发完,补发终止空 chunk 结束 chunked 响应。 */
return httpd_resp_send_chunk(req, NULL, 0);
}
return ESP_OK;
if (r == 0) {
/* 无日志:一次性小响应(不进 chunked 编码)。 */
return httpd_resp_send(req, "[]", 2);
}
/* r < 0:中途 send 失败,连接多半已断。 */
return ESP_FAIL;
}
@@ -660,8 +687,12 @@ static SemaphoreHandle_t httpJsonWorkMutex(void)
// 分块发送:复用 s_http_json_work,一次 HTTP 内按通道 send_chunk;
// 前端仍一次 fetch 收完整 JSON。
// ============================================================
/** 监控预览:data hex 最多 8 字节(16 字符),避免 FD 64B 撑爆分块 */
static constexpr uint8_t FEEDBACK_DATA_HEX_MAX = 8;
/**
* 监控/业务共用:entry data hex 上限。
* 原 8B 只够 STATUS 预览,会截掉 GET_VERSION(0x00FF) 的 hard/boot(需 ≥22B)。
* 提到 64 与 CANFD_ID_SNAPSHOT_DATA_MAX 对齐,完整透出 FD 帧。
*/
static constexpr uint8_t FEEDBACK_DATA_HEX_MAX = 64;
static int feedback_append(char *buf, size_t cap, int off, const char *fmt, ...)
{
@@ -684,6 +715,22 @@ static int feedback_append(char *buf, size_t cap, int off, const char *fmt, ...)
esp_err_t can_feedback_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
http_set_keepalive_hdr(req);
/* ?lite=1:轻量路径,跳过 entries 数组构造(~3KB → ~200B)。
* motor.html 的 onFeedback 只用 ch/enabled/valid/rx/tx/err/tec/rec/bus_state,
* 不需要 entries(CAN 帧快照,index.html/can.html 才用)。
* 跳过 entries 省掉 N×snprintf+hex 编码 + chunked 发送,handler 从 ~10ms 降到 <1ms。 */
bool want_lite = false;
{
char query[32];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
char val[4] = {};
if (httpd_query_key_value(query, "lite", val, sizeof(val)) == ESP_OK) {
want_lite = (val[0] == '1' || val[0] == 'y' || val[0] == 't');
}
}
}
can_feedback_result_t results[CAN_CHANNEL_MAX] = {};
bool valid[CAN_CHANNEL_MAX] = {};
@@ -702,6 +749,80 @@ esp_err_t can_feedback_handler(httpd_req_t *req)
? (unsigned)((feat & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT)
: 0x7U;
/* lite 路径:一次性小响应,不走 chunked,不需要 httpJsonWorkMutex(用栈缓冲即可)。
* 体积上限:3 通道 × ~80B + header ~60B ≈ 300B,远小于 1024 栈预算。 */
if (want_lite) {
char resp[512];
int off = snprintf(resp, sizeof(resp),
"{\"feat_known\":%s,\"can_ch_mask\":%u,\"can_brs\":%s,\"channels\":[",
feat_known ? "true" : "false",
ch_mask,
(feat_known && (feat & V2_FEAT_CAN_BRS)) ? "true" : "false");
if (off < 0 || (size_t)off >= sizeof(resp)) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite hdr");
return ESP_FAIL;
}
for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) {
const can_feedback_result_t &r = results[i];
const char *stats_fmt;
if (valid[i] && ch_en[i]) {
stats_fmt = ",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu,"
"\"drop\":%lu,\"err\":%lu,\"tec\":%u,\"rec\":%u,"
"\"bus_state\":%u,\"count\":%u";
} else {
stats_fmt = "";
}
/* entries 省略,count 仍报真实 total,前端可据此判断有无快照 */
int n = snprintf(resp + off, sizeof(resp) - (size_t)off,
"%s{\"ch\":%u,\"enabled\":%s,\"valid\":%s",
i == 0 ? "" : ",",
(unsigned)i,
ch_en[i] ? "true" : "false",
valid[i] ? "true" : "false");
if (n < 0 || off + n >= (int)sizeof(resp)) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite ch");
return ESP_FAIL;
}
off += n;
if (valid[i] && ch_en[i]) {
n = snprintf(resp + off, sizeof(resp) - (size_t)off,
stats_fmt,
(unsigned)r.stats.mode,
(unsigned long)r.stats.snapshot_seq,
(unsigned long)r.stats.rx_count,
(unsigned long)r.stats.tx_count,
(unsigned long)r.stats.rx_drop_count,
(unsigned long)r.stats.error_count,
(unsigned)r.stats.tec,
(unsigned)r.stats.rec,
(unsigned)r.stats.bus_state,
(unsigned)r.total_count);
if (n < 0 || off + n >= (int)sizeof(resp)) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite stats");
return ESP_FAIL;
}
off += n;
}
/* 闭合 channel 对象 */
n = snprintf(resp + off, sizeof(resp) - (size_t)off, "}");
if (n < 0 || off + n >= (int)sizeof(resp)) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite close");
return ESP_FAIL;
}
off += n;
}
int n = snprintf(resp + off, sizeof(resp) - (size_t)off, "]}");
if (n < 0 || off + n >= (int)sizeof(resp)) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb lite end");
return ESP_FAIL;
}
off += n;
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, resp, off);
}
/* 完整路径(含 entries):保留给 index.html / can.html 的 CAN 监控页 */
SemaphoreHandle_t mux = httpJsonWorkMutex();
if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "fb busy");
@@ -837,6 +958,7 @@ esp_err_t can_mode_handler(httpd_req_t *req)
http_set_cors_headers(req);
if (req->method == HTTP_GET) {
http_set_keepalive_hdr(req); /* GET 轮询复用 TCP;POST 设置模式不复用 */
can_feedback_result_t fb{};
if (getLatestCanFeedback(0, &fb)) {
char resp[64];
@@ -913,7 +1035,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
constexpr int kMaxBody = 49152;
constexpr int kMaxFrames = 128;
constexpr uint32_t kPerFrameTimeoutMs = 80U;
constexpr uint32_t kSingleFrameTimeoutMs = 2000U;
constexpr uint32_t kSingleFrameTimeoutMs = 1200U; /* 正常 <20ms;httpd 单线程,此值决定其它端点最坏阻塞 */
constexpr uint32_t kInterFrameDelayMs = 5U;
int content_len = req->content_len;
@@ -1239,6 +1361,55 @@ static int append_cpu_status_json(char* buf, size_t cap, int offset, const CpuUs
esp_err_t system_status_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
http_set_keepalive_hdr(req);
/* ?lite=1:轻量路径,只返回 motor.html 高频轮询需要的 4 个字段
* (wifi.ip/mode/connected + gd32_features)。不调 heap_caps_get_info /
* collectTaskStackMetrics / OtaCoordinator::getSnapshot / SystemBoot::appendStatusJson
* 等重活,避免在 httpd 单线程里阻塞几十~上百 ms 拖慢 motor/feedback 请求。
* 完整 status 由前端 5s 拉一次。 */
bool want_lite = false;
{
char query[32];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
char val[4] = {};
if (httpd_query_key_value(query, "lite", val, sizeof(val)) == ESP_OK) {
want_lite = (val[0] == '1' || val[0] == 'y' || val[0] == 't');
}
}
}
if (want_lite) {
char ip_str[16] = "0.0.0.0";
const char *mode_str = "AP";
bool connected = false;
if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) {
esp_netif_ip_info_t ip_info;
esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) {
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
}
mode_str = "STA";
connected = true;
} else if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP) {
connected = true;
}
const bool feat_known = SpiMaster::instance().gd32FeatKnown();
const uint32_t fl = SpiMaster::instance().gd32FeatFlags();
char resp[256];
int n = snprintf(resp, sizeof(resp),
"{\"wifi\":{\"mode\":\"%s\",\"ip\":\"%s\",\"connected\":%s},"
"\"gd32_features\":{\"known\":%s,\"flags\":%lu,"
"\"can_ch0\":%s,\"can_ch1\":%s,\"can_ch2\":%s,\"can_ch_mask\":%u}}",
mode_str, ip_str, connected ? "true" : "false",
feat_known ? "true" : "false",
(unsigned long)fl,
(feat_known && (fl & V2_FEAT_CAN_CH0)) ? "true" : "false",
(feat_known && (fl & V2_FEAT_CAN_CH1)) ? "true" : "false",
(feat_known && (fl & V2_FEAT_CAN_CH2)) ? "true" : "false",
feat_known ? (unsigned)((fl & V2_FEAT_CAN_CH_MASK) >> V2_FEAT_CAN_CH_SHIFT) : 7U);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, resp, n > 0 ? n : 0);
}
/* layout=1:附带链接期静态布局(IRAM/.data/.bss/堆容量等),前端缓存后轮询不再要。
* 默认只下发 memory 动态字段,减 status JSON 体积。 */
@@ -1618,6 +1789,7 @@ esp_err_t system_status_handler(httpd_req_t *req)
esp_err_t system_probe_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
http_set_keepalive_hdr(req);
gd32_version_t ver = {};
uint32_t uptime = 0;
@@ -1673,4 +1845,470 @@ esp_err_t system_heap_low_log_handler(httpd_req_t *req)
return err;
}
/* ────────────────────────────────────────────────────────────
* GET /motor/state — 多电机状态表(来自 GD32 0xC5 推送)
* ──────────────────────────────────────────────────────────── */
esp_err_t motor_state_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
http_set_keepalive_hdr(req);
motor_state_table_t tbl = {};
const bool have = getLatestMotorState(&tbl);
const uint32_t age = motorStateAgeMs();
/* 二进制模式:?bin=1(前端默认走此路径,体积比 JSON 省 66%)。
* 协议(LE,Header 12B + 每节点 Entry 44B):
* Header: magic u16 = 0x4D53 ('MS')
* ver u8 = 1
* flags u8 = 0
* age_ms u32 (0xFFFFFFFF = 无数据)
* active u8 (在线电机数)
* count u8 (有效 entry 数)
* rsvd u16 = 0
* Entry: node u8, ch u8, online u8, dlc u8,
* rx_count u32, seq u32, data[32]
* 9 节点最大 12 + 9*44 = 408B。
* 无 have_data 时仍发 header(count=0),让前端区分「无数据」与「空总线」。 */
bool want_bin = false;
{
char query[32];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
char val[4] = {};
if (httpd_query_key_value(query, "bin", val, sizeof(val)) == ESP_OK) {
want_bin = (val[0] == '1' || val[0] == 'y' || val[0] == 't');
}
}
}
if (want_bin) {
httpd_resp_set_type(req, "application/octet-stream");
/* 静态栈缓冲:12 + 9*44 = 408B,远小于常见任务栈 */
uint8_t buf[12 + MOTOR_MAX_NODES * MOTOR_ENTRY_SIZE];
size_t off = 0;
/* magic 'MS' (LE: 0x53, 0x4D) */
buf[off++] = 0x53U; buf[off++] = 0x4DU;
buf[off++] = 1U; /* version */
/* flags bit0 = boot_active(总线有电机在 BOOT,0x103 心跳) */
buf[off++] = (have && tbl.boot_active) ? 0x01U : 0x00U;
/* age_ms LE u32 */
const uint32_t age_send = (age == UINT32_MAX) ? 0xFFFFFFFFU : age;
buf[off++] = (uint8_t)(age_send & 0xFF);
buf[off++] = (uint8_t)((age_send >> 8) & 0xFF);
buf[off++] = (uint8_t)((age_send >> 16) & 0xFF);
buf[off++] = (uint8_t)((age_send >> 24) & 0xFF);
buf[off++] = have ? tbl.active_count : 0U; /* active */
const size_t count_pos = off; /* count,稍后回填 */
off += 3U; /* count u8 + rsvd u16 */
/* off 现在应该是 12 */
assert(off == 12);
uint8_t count = 0;
if (have) {
for (uint8_t i = 0; i < MOTOR_MAX_NODES; ++i) {
const motor_slot_t &s = tbl.slots[i];
if (s.node_id == 0) continue;
buf[off++] = s.node_id;
buf[off++] = s.channel;
buf[off++] = s.online;
buf[off++] = s.dlc;
/* rx_count LE */
uint32_t r = s.rx_count;
buf[off++] = (uint8_t)(r & 0xFF);
buf[off++] = (uint8_t)((r >> 8) & 0xFF);
buf[off++] = (uint8_t)((r >> 16) & 0xFF);
buf[off++] = (uint8_t)((r >> 24) & 0xFF);
/* seq LE */
uint32_t sq = s.seq;
buf[off++] = (uint8_t)(sq & 0xFF);
buf[off++] = (uint8_t)((sq >> 8) & 0xFF);
buf[off++] = (uint8_t)((sq >> 16) & 0xFF);
buf[off++] = (uint8_t)((sq >> 24) & 0xFF);
memcpy(&buf[off], s.data, MOTOR_STATUS_DLC);
off += MOTOR_STATUS_DLC;
count++;
}
}
buf[count_pos] = count;
return httpd_resp_send(req, (const char *)buf, off);
}
/* JSON 兼容路径(保留旧消费者,?bin 缺省时走此分支) */
httpd_resp_set_type(req, "application/json");
if (!have) {
httpd_resp_sendstr(req, "{\"have_data\":false,\"active_count\":0,\"motors\":[]}");
return ESP_OK;
}
SemaphoreHandle_t mux = httpJsonWorkMutex();
if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "motor busy");
return ESP_FAIL;
}
char *w = s_http_json_work;
int off = snprintf(w, HTTP_JSON_WORK_CAP,
"{\"have_data\":true,\"active_count\":%u,\"boot_active\":%s,\"age_ms\":%lu,\"motors\":[",
(unsigned)tbl.active_count, tbl.boot_active ? "true" : "false", (unsigned long)age);
bool first = true;
for (uint8_t i = 0; i < MOTOR_MAX_NODES && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) {
const motor_slot_t &s = tbl.slots[i];
if (s.node_id == 0) continue;
/* data hex */
char hex[MOTOR_STATUS_DLC * 2 + 1];
for (uint8_t b = 0; b < MOTOR_STATUS_DLC; ++b) {
snprintf(&hex[b * 2], 3, "%02X", s.data[b]);
}
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off,
"%s{\"node\":%u,\"ch\":%u,\"online\":%s,\"dlc\":%u,"
"\"rx_count\":%lu,\"seq\":%lu,\"data\":\"%s\"}",
first ? "" : ",",
(unsigned)s.node_id, (unsigned)s.channel,
s.online ? "true" : "false",
(unsigned)s.dlc,
(unsigned long)s.rx_count,
(unsigned long)s.seq,
hex);
first = false;
}
if (off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 2) {
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, "]}");
}
esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0);
if (mux) {
xSemaphoreGive(mux);
}
return ret;
}
/**
* @brief /motor/version — 返回 GD32 缓存的电机版本(0xC6 事件推送)。
*
* 版本为按需查询的低频数据:前端发 0x00FF 请求触发电机应答,GD32 缓存后经
* 0xC6 推给 ESP32,本端点从缓存读取。前端按 seq 判断是否为本次查询的新应答。
*
* JSON: {"have_data":bool,"nodes":[{"node":N,"seq":N,"age_ms":N,"version":"HEX(24B)"}]}
*/
esp_err_t motor_version_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
http_set_keepalive_hdr(req);
motor_version_table_t tbl = {};
const bool have = getLatestMotorVersion(&tbl);
httpd_resp_set_type(req, "application/json");
if (!have) {
httpd_resp_sendstr(req, "{\"have_data\":false,\"nodes\":[]}");
return ESP_OK;
}
SemaphoreHandle_t mux = httpJsonWorkMutex();
if (mux && xSemaphoreTake(mux, pdMS_TO_TICKS(200)) != pdTRUE) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "motor version busy");
return ESP_FAIL;
}
const int64_t now_ms = esp_timer_get_time() / 1000;
char *w = s_http_json_work;
int off = snprintf(w, HTTP_JSON_WORK_CAP, "{\"have_data\":true,\"nodes\":[");
bool first = true;
for (uint8_t i = 0; i < MOTORVER_MAX_NODES && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) {
const motor_version_slot_t &s = tbl.slots[i];
if (!s.valid || s.node_id == 0) continue;
char hex[MOTORVER_DLC * 2 + 1];
for (uint8_t b = 0; b < MOTORVER_DLC; ++b) {
snprintf(&hex[b * 2], 3, "%02X", s.version[b]);
}
const long age = (s.recv_ms > 0 && now_ms >= s.recv_ms)
? static_cast<long>(now_ms - s.recv_ms) : 0L;
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off,
"%s{\"node\":%u,\"seq\":%lu,\"age_ms\":%ld,\"version\":\"%s\"}",
first ? "" : ",",
(unsigned)s.node_id,
(unsigned long)s.seq,
age,
hex);
first = false;
}
if (off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 2) {
off += snprintf(w + off, HTTP_JSON_WORK_CAP - off, "]}");
}
esp_err_t ret = httpd_resp_send(req, w, off > 0 ? off : 0);
if (mux) {
xSemaphoreGive(mux);
}
return ret;
}
/* ═══════════════════ 电机 OTA relay ═══════════════════ */
esp_err_t motor_ota_upload_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
char query[16] = {};
uint8_t channel = 0;
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
char val[4] = {};
if (httpd_query_key_value(query, "ch", val, sizeof(val)) == ESP_OK) {
channel = static_cast<uint8_t>(atoi(val));
}
}
const int content_len = req->content_len;
if (content_len <= 0 || content_len > (int)MOTOR_OTA_MAX_FW_SIZE) {
httpd_resp_set_status(req, "400 Bad Request");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"error\":\"firmware too large or empty\"}");
return ESP_FAIL;
}
/* ── 协调:电机固件与 GD32E508 固件共用 gd32_ota 分区,先互斥 + 安全清理 ── */
OtaCoordinator &coord = OtaCoordinator::instance();
/* 有活跃 OTA(GD32 STORING/RELAYING、ESP32_* 等)时拒绝 */
if (!coord.isIdleState(coord.getSystemState())) {
httpd_resp_set_status(req, "409 Conflict");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"error\":\"system busy with another OTA\"}");
return ESP_FAIL;
}
if (motorOtaIsActive()) {
httpd_resp_set_status(req, "409 Conflict");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"error\":\"motor OTA already in progress\"}");
return ESP_FAIL;
}
/* 即将覆写 gd32_ota 分区:先失效已暂存的 GD32 固件并复位协调器到 IDLE,
避免 /ota/relay 误把电机固件当成 GD32 固件中继刷进主控 */
coord.abortCurrent();
(void)coord.clearGd32Meta();
/* ── 流式收固件写 flash(无整固件堆缓冲;复用 OTA 静态收块)──
* ★ full_erase=true:强制擦除整个 gd32_ota 分区(252KB ~200ms),
* 杜绝上次固件残留 + 擦除位错误导致的单 bit 翻转(0xAC20 处 0x19→0x18 根因)。 */
Gd32FlashStore::instance().beginWriteSession(true);
int received = 0;
while (received < content_len) {
size_t to_read = (size_t)content_len - received;
if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE;
otaUploadPaceIfHeapTight();
int r = httpd_req_recv(req, reinterpret_cast<char *>(s_ota_upload_recv), to_read);
if (r <= 0) {
DEBUG_ERR("MOTOR_OTA", "upload interrupted: received=%d expected=%d", received, content_len);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "recv failed");
return ESP_FAIL;
}
esp_err_t werr = Gd32FlashStore::instance().flashWriteChunk(
(uint32_t)received, s_ota_upload_recv, (size_t)r);
if (werr != ESP_OK) {
DEBUG_ERR("MOTOR_OTA", "flash write fail @%d: %s", received, esp_err_to_name(werr));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "flash write failed");
return ESP_FAIL;
}
received += r;
(void)esp_task_wdt_reset();
}
/* ── 收完固件写 meta(供 /motor/ota/relay 确认固件完整)──
* 不在此处触发 SPI START + 喂数据:拆分两段式,与 BLE 刷 GD32 的
* "prepare+收流 → /ota/relay" 对齐。收固件阶段纯写 flash,不碰 SPI,
* 避免与 GD32 按铃推送争抢单缓冲 MISO 产生的 "SPI start failed" 竞态。 */
ota_meta_t meta = {};
meta.total_size = static_cast<uint32_t>(content_len);
meta.stored_size = static_cast<uint32_t>(received);
meta.last_seq = 0;
esp_err_t merr = Gd32FlashStore::instance().metaSave(&meta);
if (merr != ESP_OK) {
DEBUG_ERR("MOTOR_OTA", "meta save fail after upload: %s", esp_err_to_name(merr));
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"error\":\"meta save failed\"}");
return ESP_FAIL;
}
/* ★ 上传收尾计算 flash SHA-256 并存入 meta.checksum。
* 电机 OTA 之前完全没有完整性校验:HTTP 流式写 flash → metaSave → relay 直接刷。
* 一旦 flash 写入错位/损坏,ESP32 会把"一致的垃圾"喂给 GD32(CAN CRC 全过但 APP 跑飞)。
* 这里在收流结束立即算 SHA-256,relay 触发时 metaVerifyChecksum 复核,从源头堵住坏数据。 */
esp_err_t sha_err = Gd32FlashStore::instance().metaFinalizeChecksum();
if (sha_err != ESP_OK) {
DEBUG_ERR("MOTOR_OTA", "SHA-256 finalize fail after upload: %s", esp_err_to_name(sha_err));
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"error\":\"sha256 compute failed\"}");
return ESP_FAIL;
}
httpd_resp_set_type(req, "application/json");
char resp_buf[160];
snprintf(resp_buf, sizeof(resp_buf),
"{\"status\":\"stored\",\"message\":\"call POST /motor/ota/relay?ch=%u to trigger\","
"\"channel\":%u,\"size\":%d}",
(unsigned)channel, (unsigned)channel, received);
httpd_resp_sendstr(req, resp_buf);
return ESP_OK;
}
esp_err_t motor_ota_relay_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
http_set_keepalive_hdr(req);
DEBUG_INFO("MOTOR_OTA", "=== HTTP: POST /motor/ota/relay ===");
char query[16] = {};
uint8_t channel = 0;
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
char val[4] = {};
if (httpd_query_key_value(query, "ch", val, sizeof(val)) == ESP_OK) {
channel = static_cast<uint8_t>(atoi(val));
}
}
/* 确认固件已完整存储 */
ota_meta_t meta = {};
if (Gd32FlashStore::instance().metaLoad(&meta) != ESP_OK ||
meta.total_size == 0 || meta.stored_size < meta.total_size) {
return http_send_json_error(req, "400 Bad Request",
"firmware_not_stored", "firmware not fully stored");
}
/* ★ 接收前端发送的固件 SHA-256(JSON body: {"sha256":"<hex64>"})。
* 前端在 relay 触发前用 Web Crypto 算固件 bytes 的 SHA-256,
* ESP32 与自己算的 flash SHA 比对,从源头确认"前端发的 = ESP32 flash 存的"。
* 之前 motor OTA 没有这一层比对,flash 写入位错误无法被发现,
* 直到刷进电机 APP 跑飞才暴露。 */
uint8_t frontend_sha[32] = {0};
bool has_frontend_sha = false;
if (req->content_len > 0 && req->content_len < 512) {
char body_buf[512] = {0};
int rret = httpd_req_recv(req, body_buf, req->content_len);
if (rret > 0) {
body_buf[rret] = '\0';
cJSON *root = cJSON_Parse(body_buf);
if (root) {
cJSON *sha_item = cJSON_GetObjectItem(root, "sha256");
if (sha_item && cJSON_IsString(sha_item) &&
strlen(cJSON_GetStringValue(sha_item)) == 64) {
const char *hex = cJSON_GetStringValue(sha_item);
for (int i = 0; i < 32; ++i) {
char hb[3] = { hex[i*2], hex[i*2+1], 0 };
frontend_sha[i] = (uint8_t)strtoul(hb, nullptr, 16);
}
has_frontend_sha = true;
}
cJSON_Delete(root);
}
}
}
if (!has_frontend_sha) {
return http_send_json_error(req, "400 Bad Request",
"missing_sha256", "missing sha256 in body (frontend SHA-256 required)");
}
/* 三方比对:前端 SHA vs flash 实际 SHA vs meta.checksum。
* verifyFrontendSha 内部重算 flash SHA,与 frontend_sha 比对,
* 同时也验证与 meta.checksum(上传收尾存的)一致。
* 任何一项不符都拒绝刷写,从源头堵住 flash 存储环节位错误。 */
uint8_t device_sha[32] = {0};
esp_err_t verify_err = Gd32FlashStore::instance().verifyFrontendSha(frontend_sha, device_sha);
if (verify_err != ESP_OK) {
char dev_hex[65] = {0}, front_hex[65] = {0};
for (int i = 0; i < 32; ++i) {
snprintf(&dev_hex[i*2], 3, "%02X", device_sha[i]);
snprintf(&front_hex[i*2], 3, "%02X", frontend_sha[i]);
}
DEBUG_ERR("MOTOR_OTA", "relay 前 SHA 比对失败: front=%s device=%s err=%s",
front_hex, dev_hex, esp_err_to_name(verify_err));
(void)Gd32FlashStore::instance().metaClear();
char err_msg[224]; /* 固定文字 ~55 + 2×64 hex = 需 >183,给 224 余量 */
snprintf(err_msg, sizeof(err_msg),
"frontend SHA != flash SHA (front=%s device=%s), re-upload required",
front_hex, dev_hex);
return http_send_json_error(req, "400 Bad Request",
"firmware_checksum_mismatch", err_msg);
}
DEBUG_INFO("MOTOR_OTA", "relay 前三方 SHA 比对通过 (%lu B)", (unsigned long)meta.total_size);
if (motorOtaIsActive()) {
httpd_resp_set_status(req, "409 Conflict");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"error\":\"motor OTA already in progress\"}");
return ESP_FAIL;
}
/* 触发 SPI START + 启动 feedTask(从 flash 分块读取喂 GD32)*/
const int result = motorOtaStart(meta.total_size, channel);
httpd_resp_set_type(req, "application/json");
if (result == 0) {
/* 成功触发后清 meta:固件已交给 feedTask,避免残留导致下次误判 */
(void)Gd32FlashStore::instance().metaClear();
httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"OTA started\"}");
} else if (result == -2) {
httpd_resp_set_status(req, "409 Conflict");
httpd_resp_sendstr(req, "{\"error\":\"OTA already in progress\"}");
} else {
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_sendstr(req, "{\"error\":\"SPI start failed\"}");
}
return ESP_OK;
}
esp_err_t motor_ota_status_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
http_set_keepalive_hdr(req);
const motor_ota_status_t st = motorOtaGetStatus();
static const char *state_names[] = {
"idle", "handshake_req", "handshake_nonce", "handshake_confirm",
"erase", "transfer", "finish_ota", "finished", "error",
"pre_cancel"
};
const int si = (st.state <= MOTOR_OTA_PRE_CANCEL) ? (int)st.state : 0;
char buf[256];
snprintf(buf, sizeof(buf),
"{\"state\":\"%s\",\"error\":%u,\"packets_acked\":%u,\"total_packets\":%u,"
"\"fw_size\":%lu,\"buf_used\":%u}",
state_names[si], st.error_code,
st.packets_acked, st.total_packets,
(unsigned long)st.fw_size, st.buf_used);
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, buf);
return ESP_OK;
}
esp_err_t motor_ota_cancel_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
DEBUG_WARN("MOTOR_OTA", "cancel HTTP request received, calling motorOtaCancel()");
motorOtaCancel();
/* 两阶段都可取消:触发前未跑 feedTask 时 motorOtaCancel 是空操作,
* 但仍清 GD32 侧状态 + 清 flash meta,避免残留固件占分区/被误判为已存。
* 触发后取消也清 meta 无害(feedTask 已停)。 */
(void)Gd32FlashStore::instance().metaClear();
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"cancelled\"}");
return ESP_OK;
}
+13
View File
@@ -5,6 +5,9 @@
// HTTP CORS and options
void http_set_cors_headers(httpd_req_t *req);
/** @brief 给 GET 轮询接口加 Connection: keep-alive,让浏览器复用 TCP 连接,
* 省 4Hz 轮询的握手开销。OTA/relay 等需 close 的 handler 不要调用。 */
void http_set_keepalive_hdr(httpd_req_t *req);
esp_err_t http_options_handler(httpd_req_t *req);
// OTA handlers
@@ -28,4 +31,14 @@ esp_err_t system_heap_low_log_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);
// 多电机状态(被动 0xC5)
esp_err_t motor_state_handler(httpd_req_t *req);
// 多电机版本(被动 0xC6,事件驱动)
esp_err_t motor_version_handler(httpd_req_t *req);
esp_err_t motor_ota_upload_handler(httpd_req_t *req);
esp_err_t motor_ota_relay_handler(httpd_req_t *req);
esp_err_t motor_ota_status_handler(httpd_req_t *req);
esp_err_t motor_ota_cancel_handler(httpd_req_t *req);
#endif // HTTP_HANDLERS_H
+47 -14
View File
@@ -396,31 +396,48 @@ esp_err_t WifiOta::httpServerStart()
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = HTTP_SERVER_PORT;
config.stack_size = HTTP_SERVER_STACK_SIZE;
config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检
/* Socket 预算(无法“合并” httpd 内部 3 个 fd:listen + ctrl 对):
/* 路由上限:当前 18 个 main handler + 17 个唯一 path 的 OPTIONS = 35 次注册。
* max_uri_handlers 必须 ≥ 该总数,否则超出部分被 httpd 静默丢弃
*(httpd_register_uri_handler 返回 ESP_ERR_HTTPD_HANDLERS_FULL,下方已检查并报错)。
* 曾因 =32 导致按注册顺序排在末尾的 /motor/ota/cancel(POST) 未注册,
* 取消请求恒返回 404、httpd 其它读取被在途 SPI 事务短暂饿死后才回 404。
* 取 48 留足后续新增端点余量(每槽仅一个指针,内存开销可忽略)。 */
config.max_uri_handlers = 48;
/* Socket 预算(无法“合并”httpd 内部 3 个 fd:listen + ctrl 对):
*
* LWIP 总池 CONFIG_LWIP_MAX_SOCKETS
* ├─ httpd 内部固定 3
* LWIP 总池 CONFIG_LWIP_MAX_SOCKETS = 12
* ├─ httpd 内部固定 3(listen + ctrl socket 对)
* ├─ max_open_sockets = 业务并发(下面 want)
* └─ 余量:UDP 调试日志 1 + 偶发其它
*
* 约束:max_open_sockets <= LWIP_MAX_SOCKETS - 3
* 约束:max_open_sockets <= LWIP_MAX_SOCKETS - 3 = 9
*
* 业务并发压到 4(tools 单浏览器已足够):
* 1× /system/status 轮询 + 1× /logs 轮询 + 1× 操作(OTA/CAN) + 1× 余量
* OTA 大上传期间前端 pausePolling,只占 1 条长连接,不与轮询抢槽。
* 业务并发需要 6(前端三链路 motor/feedback/status + OTA 上传 + 操作 + 余量):
* - motor.html 独立 3 条轮询链路(已剥离互不阻塞)
* - OTA 大上传占 1 条长连接
* - 双 tab / 偶发脚本 1 条
* - 余量 1
* 4 太紧会触发 lru_purge 踢连接,表现为「请求挂起 + age 不动」。
* lru_purge:满槽时踢最久空闲连接,避免 EMFILE 卡死。
* 硬编码 7 在 LWIP=8 时会 ESP_ERR_INVALID_ARG → HTTP 起不来。 */
{
const int lwip_cap = CONFIG_LWIP_MAX_SOCKETS - 3; // httpd 硬上限
const int want = 4; // 合并并发需求后的业务槽位(原 7 偏松)
const int want = 6; // 前端 3 链路 + OTA + 操作 + 余量
config.max_open_sockets = (lwip_cap < want) ? ((lwip_cap > 1) ? lwip_cap : 1) : want;
}
/* 满连接时淘汰最久未用的空闲 socket(相当于“复用/腾挪”槽位) */
config.lru_purge_enable = true;
/* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉 */
config.recv_wait_timeout = 10;
config.send_wait_timeout = 10;
/* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉。
* 缩到 5s:WiFi 暴断时挂死连接更快释放槽位,避免长时占满 6 槽导致卡顿。 */
config.recv_wait_timeout = 5;
config.send_wait_timeout = 5;
/* TCP keep-alive 探针:5s idle 无数据 → 每 1s 探测 3 次失败立即踢。
* 不依赖 recv_wait_timeout(要等 socket 主动读失败),WiFi 半挂/暴断时
* 探针能更快感知,槽位早释放。原默认 false 会让断连占槽到 5s 超时。 */
config.keep_alive_enable = true;
config.keep_alive_idle = 5;
config.keep_alive_interval = 1;
config.keep_alive_count = 3;
DEBUG_INFO("WIFI", "=== 启动 HTTP OTA 服务器 ===");
DEBUG_INFO("WIFI", "HTTP 服务器端口: %d", config.server_port);
@@ -460,6 +477,12 @@ esp_err_t WifiOta::httpServerStart()
{"/can/mode", HTTP_GET, can_mode_handler},
{"/can/mode", HTTP_POST, can_mode_handler},
{"/spi/send", HTTP_POST, spi_send_handler},
{"/motor/state", HTTP_GET, motor_state_handler},
{"/motor/version", HTTP_GET, motor_version_handler},
{"/motor/ota", HTTP_POST, motor_ota_upload_handler},
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
{"/motor/ota/status", HTTP_GET, motor_ota_status_handler},
{"/motor/ota/cancel", HTTP_POST, motor_ota_cancel_handler},
};
/* 同一 path 可有多 method(如 GET+POST);OPTIONS 每个 path 只注册一次,
@@ -471,7 +494,13 @@ esp_err_t WifiOta::httpServerStart()
main_uri.method = r.method;
main_uri.handler = r.handler;
main_uri.user_ctx = NULL;
httpd_register_uri_handler(WifiOta::instance().m_httpServer, &main_uri);
esp_err_t reg_err = httpd_register_uri_handler(WifiOta::instance().m_httpServer, &main_uri);
if (reg_err != ESP_OK) {
/* 静默丢弃会导致对应端点 404(如曾经的 /motor/ota/cancel)。
* 明确报错,提示提高 max_uri_handlers。 */
DEBUG_ERR("WIFI", "URI 注册失败 %s method=%d: %s(提高 max_uri_handlers)",
r.uri, (int)r.method, esp_err_to_name(reg_err));
}
bool need_opts = true;
for (size_t j = 0; j < i; ++j) {
@@ -486,7 +515,11 @@ esp_err_t WifiOta::httpServerStart()
opts_uri.method = HTTP_OPTIONS;
opts_uri.handler = http_options_handler;
opts_uri.user_ctx = NULL;
httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri);
esp_err_t opts_err = httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri);
if (opts_err != ESP_OK) {
DEBUG_ERR("WIFI", "OPTIONS 注册失败 %s: %s(提高 max_uri_handlers)",
r.uri, esp_err_to_name(opts_err));
}
}
}
+29 -1
View File
@@ -75,9 +75,37 @@ const esp_partition_t* Gd32FlashStore::getPartition()
// Flash 分区读写
// ============================================================
void Gd32FlashStore::beginWriteSession()
void Gd32FlashStore::beginWriteSession(bool full_erase)
{
m_erasedUpTo = 0;
if (!full_erase) {
return; // GD32 OTA:保持原优化,由 flashWriteChunk 按需擦除与收流交织
}
/* ★ motor OTA:强制擦除整个 gd32_ota 分区,而不是只重置水位。
* 之前只重置 m_erasedUpTo=0,依赖 flashWriteChunk 按需擦除覆盖到的扇区,
* 导致两类隐患:
* 1. 上次刷的更大固件在高位扇区残留,本次若擦除位错误会与残留 AND 产生位翻转
* (正是 0xAC20 处 0x19→0x18 单 bit 翻转的根因)。
* 2. ensureErasedForWrite 按水位推进,若某次擦除返回 OK 但物理失败,
* 旧数据残留 + AND 语义写入 = 静默位错误。
* 强制全擦(252KB ~200ms)确保每次上传从全 0xFF 干净状态开始,
* 即使新固件只覆盖前 44KB,剩余高位扇区也回到擦除态,杜绝残留干扰。
* 擦除失败立刻报错,不让坏状态继续。 */
const esp_partition_t *part = getPartition();
if (!part) {
DEBUG_ERR("OTA", "beginWriteSession: 分区未找到,无法预擦除");
return;
}
esp_err_t err = erasePartition(part->size);
if (err != ESP_OK) {
DEBUG_ERR("OTA", "beginWriteSession: 预擦除整个分区失败: %s", esp_err_to_name(err));
/* 不 return(void 函数);后续 flashWriteChunk 的 ensureErasedForWrite
* 仍会尝试擦除目标扇区,给数据写入多一次机会。 */
} else {
m_erasedUpTo = part->size; /* 全擦完成,后续 ensureErasedForWrite 全部跳过 */
DEBUG_INFO("OTA", "beginWriteSession: 已预擦除整个 gd32_ota 分区 (%lu B)",
(unsigned long)part->size);
}
}
/** @brief 确保 [offset, offset+len) 覆盖的扇区已擦除(从 m_erasedUpTo 顺序推进)。 */
+3 -2
View File
@@ -72,9 +72,10 @@ public:
/**
* @brief 新一轮 GD32 固件写入会话开始时调用:复位擦除水位。
* 后续 flashWriteChunk 经 ensureErasedForWrite 按需擦扇区。
* @param full_erase true=强制擦除整个分区(motor OTA 用,杜绝旧固件残留导致的位错误);
* false=只重置水位,由 flashWriteChunk 按需擦扇区(GD32 OTA 用,与收流交织)。
*/
void beginWriteSession();
void beginWriteSession(bool full_erase = false);
// ── NVS 元数据持久化 ──
+17 -1
View File
@@ -14,6 +14,8 @@
#include "canfd_bridge.h"
#include "spi_master.h"
#include "monitor/debug_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
namespace {
@@ -58,10 +60,24 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = SpiMaster::instance().transact(
esp_err_t ret = ESP_OK;
/* CAN 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
* 喂数(0xD1→WAIT)残留在 SPI 响应管道里的陈旧帧(0xC0 命令本身永不产生 WAIT)。
* 陈旧 WAIT 会被下方误判为 ERROR → resp_len=0 → gd32_err=0xFF → 无声拒绝发送(
* 表现为“失败一次就再也发不出去”)。此处最多重试几次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。 */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
ret = SpiMaster::instance().transact(
kSpiCanFrameType,
tx_buf, tx_len,
&resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms);
if (ret != ESP_OK || resp_type != SPI_OTA_RSP_WAIT) {
break; /* SPI 失败或已拿到真正响应(ACK/ERROR) */
}
vTaskDelay(pdMS_TO_TICKS(3)); /* 让 GD32 更新响应,再读一次排空陈旧 WAIT */
}
if (ret != ESP_OK) {
/* 同类错误节流日志,降低高频失败时的日志开销 */
+88 -19
View File
@@ -54,9 +54,9 @@ static size_t spiXferBytes(size_t frame_len)
/**
* @brief 读响应时单 CS 时钟预算(按最近 HELLO 的 GD32 role 分支)。
* - BOOT:TX DMA 满缓冲 128 → 读 128,避免过长时钟 underrun
* - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断 CRC
* unknown 偏 APP:宁长勿短(BOOT 短帧在 256 时钟下尾部 0xFF,仍可 parse)。
* - BOOT:TX DMA 满缓冲 128 → 读 128(与 Git 稳定路径一致,避免 BOOT underrun)
* - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断
* role 粘滞 boot 时靠 isAppOnlySpiType 纠正为 APP 后再走 256。
*/
static size_t spiRespReadBudget(void)
{
@@ -67,6 +67,24 @@ static size_t spiRespReadBudget(void)
return SPI_APP_TX_BUF_SIZE;
}
/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type)
{
switch (type) {
case 0xC1U: /* CAN feedback */
case 0xC3U: /* ID snapshot */
case 0xC4U: /* ADC */
case 0xC5U: /* motor state */
case 0xC6U: /* motor version */
case 0xD0U: /* motor OTA cmd(APP relay) */
case 0xD1U:
case 0xD2U:
return true;
default:
return false;
}
}
/** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。
* 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。
*/
@@ -252,6 +270,12 @@ esp_err_t SpiMaster::serviceRingOnce()
}
{
LockGuard lock(m_mutex);
/* APP 独有事件 → 纠正粘滞的 boot role(OTA 后 HELLO 未刷新时常见) */
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
(void)pushEvent(found_type, found_payload, found_len);
}
return ESP_OK;
@@ -787,7 +811,7 @@ bool SpiMaster::otaBuildFrame(uint8_t type, const uint8_t *payload, size_t paylo
*/
bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset)
size_t *frame_offset, uint8_t *out_flags)
{
if (!buf) return false;
@@ -807,10 +831,13 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
if (hdr.session_id != self.m_sessionId) {
self.m_stats.seq_errors++;
const bool is_event = (hdr.flags & V2_FLAG_EVENT) != 0U;
/* EVENT 按铃帧在 GD32 session 未激活时 session_id=0(见 requestEsp32Read)。
* 若此处直接拒绝:按铃被读走却解析失败 → 狂刷 WARN,CAN 反馈全丢。
* 策略:EVENT 一律放行;REQUEST/RESPONSE 仍拒绝错 session。 */
if (!is_event) {
/* session_id==0:GD32 重启后 g_v2Active=false,响应帧 session 填 0
* (见 GD32 spi_build_frame)。典型场景:刷完 GD32 固件、ESP32 未重启。
* 若拒收:GD32 明明在回 ACK,ESP32 全丢 → OTA/命令全超时死循环
* (日志特征:v2 SES mismatch got=00000000 刷屏 + fed 卡首 chunk)。
* 放行并按 stale 语义交给上层(ack_seq 归属照常判定)。 */
const bool is_sess0 = (hdr.session_id == 0U);
if (!is_event && !is_sess0) {
static uint32_t s_sess_mm_logs = 0U;
if ((++s_sess_mm_logs % 32U) == 1U) {
DEBUG_WARN("SPI", "v2 SES mismatch got=%08lX exp=%08lX t=%02X #%lu",
@@ -819,8 +846,15 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
}
return false;
}
if (is_sess0 && !is_event) {
static uint32_t s_sess0_logs = 0U;
if ((++s_sess0_logs % 64U) == 1U) {
DEBUG_WARN("SPI", "GD32 session=0 (对端已重启?) 放行 RESPONSE t=0x%02X #%lu",
hdr.type, (unsigned long)s_sess0_logs);
}
}
static uint32_t s_evt_sess0_logs = 0U;
if ((++s_evt_sess0_logs % 64U) == 1U) {
if (is_event && (++s_evt_sess0_logs % 64U) == 1U) {
DEBUG_DBG("SPI", "EVENT session mismatch accepted: got=0x%08lX exp=0x%08lX type=0x%02X",
(unsigned long)hdr.session_id, (unsigned long)self.m_sessionId, hdr.type);
}
@@ -858,6 +892,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
if (payload) *payload = pload;
if (payload_len) *payload_len = hdr.payload_len;
if (frame_offset) *frame_offset = hdr.frame_offset;
if (out_flags) *out_flags = hdr.flags;
return true;
}
@@ -1080,11 +1115,17 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
(void)otaFindFrame(drain, drain_len, nullptr, nullptr, nullptr, nullptr);
}
} else {
vTaskDelay(pdMS_TO_TICKS(5));
/* PB6 高 = GD32 无待发数据 = 响应缓冲已释放、可接收新命令。
* 此时 remoteCredit 仍为 0 是陈旧值:credit 只随帧头传递,GD32 转空闲后
* 没有帧能把 credit=1 送回主机(典型为电机 OTA 取消后:一次瞬时 credit=0
* 被锁存,GD32 转空闲再不发帧)→ 死锁,所有发送被拒。
* PB6 高即证明对端可收,直接放行打破死锁;真忙时 PB6 会低、走上面 drain。 */
m_remoteCredit = 1U;
break;
}
}
if (m_remoteCredit == 0U) {
DEBUG_WARN("SPI", "remoteCredit=0, refuse TX type=0x%02X", type);
DEBUG_WARN("SPI", "remoteCredit=0 且 PB6 持续低, refuse TX type=0x%02X", type);
return ESP_ERR_INVALID_STATE;
}
}
@@ -1118,6 +1159,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
const uint8_t *found_payload = nullptr;
size_t found_len = 0;
size_t frame_offset = 0;
/* 与 Git 稳定路径一致:step1 陈旧帧原样解析即可(otaSendAndPoll 传 nullptr 丢弃)。
* 勿在此处过滤 LINK/EVENT,否则改变 delayed-by-one 语义。 */
if (otaFindFrame(rx_raw, xfer_len, &found_type, &found_payload, &found_len, &frame_offset)) {
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
@@ -1127,6 +1170,9 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
}
}
if (resp_len) *resp_len = found_len;
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
m_gd32Role = V2_ROLE_APP;
}
/* expect_stale(两步法 step1)读到的是延迟一拍陈旧帧,序号由 poll 成功时推进。
* 此处若自增会导致 job 间隙/超时后 resend 使用错误 TX_SEQ。 */
if (!expect_stale) {
@@ -1256,14 +1302,29 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
const uint8_t *found_payload = nullptr;
size_t found_len = 0;
size_t frame_offset = 0;
uint8_t found_flags = 0U;
if (otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, &found_len, &frame_offset)) {
/* 恢复 Git 稳定 poll 语义(GD32 BOOT OTA 以此为准)。
* 仅额外:EVENT 帧跳过(电机 OTA 喂数时勿把 0xC1/0xC5 当 ACK);
* APP 独有 TYPE 纠正 role 粘滞。不做 LINK/RESPONSE 额外过滤。 */
if (otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, &found_len, &frame_offset, &found_flags)) {
if (found_type == V2_TYPE_CREDIT_UPDATE) {
DEBUG_DBG("SPI", "poll got CREDIT_UPDATE credit=%u", (unsigned)m_remoteCredit);
vTaskDelay(pdMS_TO_TICKS(1));
continue;
}
if ((found_flags & V2_FLAG_EVENT) != 0U) {
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (poll saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
DEBUG_DBG("SPI", "poll skip EVENT type=0x%02X", found_type);
vTaskDelay(pdMS_TO_TICKS(1));
continue;
}
if (expected_type != 0xFFU && found_type != expected_type &&
found_type != SPI_OTA_RSP_WAIT) {
DEBUG_DBG("SPI", "poll type mismatch: got=0x%02X want=0x%02X",
@@ -1283,11 +1344,11 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
}
if (resp_len) *resp_len = found_len;
/* WAIT 立刻返回,由 OTA 紧循环再 poll(勿在此空转) */
if (found_type == SPI_OTA_RSP_WAIT) {
m_linkState.exitBusy();
return ESP_OK;
}
/* WAIT/ACK/ERROR:只要 ack_seq 匹配,GD32 已消费本 TX_SEQ,必须推进。
* ★ 电机 OTA:feedData 缓冲满回 WAIT 时若不清 seq,ESP32 同 seq 重发
* → GD32 命中“重复请求”只回缓存 WAIT,feedData 永不再调用
* → fed 卡在首 chunk(日志 fed=232)、CAN 抽空后 0x01 超时。
* BOOT OTA 见 WAIT 后只 poll 不重发 DATA,推进 seq 同样正确。 */
if (m_lastRxAckMatched) {
m_txSeq = (uint8_t)(m_lastSentSeq + 1U);
}
@@ -1426,8 +1487,12 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
return send_ret;
}
/* CS 释放后短延时,再 poll 应答 */
vTaskDelay(pdMS_TO_TICKS(5));
/* CS 释放后短延时,再 poll 应答。
* GD32 主控 OTA / HELLO 等:5ms(BOOT AES 路径需要)。
* 电机 OTA 数据 0xD1:1ms。每 chunk 固定 5ms 会把 SPI 喂速压到 ~47KB/s 边缘,
* CAN 5ms/包消费时环缓经常被掏空(日志「缓冲空,等ESP32喂」+ ACK 超时重发)。 */
const uint32_t post_cs_ms = (type == 0xD1U) ? 1U : 5U;
vTaskDelay(pdMS_TO_TICKS(post_cs_ms));
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout,
@@ -1961,6 +2026,10 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint)
hdr.payload_len >= V2_HELLO_ACK_APP_LEN) {
applyAppIdentity16(&pload[V2_HELLO_ACK_BASE_LEN], nullptr, nullptr);
}
/* HELLO 成功时打印 role,便于对照 /system/status 粘滞 boot 问题 */
DEBUG_INFO("SPI", "HELLO ok role=%u credit=%u sess=0x%08lX",
(unsigned)m_gd32Role, (unsigned)m_remoteCredit,
(unsigned long)m_sessionId);
m_v2Active = true;
m_stats.resyncs++;
return ESP_OK;
+2 -1
View File
@@ -28,6 +28,7 @@
// GD32 SPI 业务 TYPE(与 SpiDefs / v2 TYPE 字段一致)
#define SPI_OTA_CMD_REQ (0x01)
#define SPI_OTA_CMD_DATA (0x02)
#define SPI_OTA_CMD_CANCEL (0x03) /* 中止 OTA 会话 → GD32 resetToIdle */
#define SPI_APP_ENTER_BOOT_CMD (0xB0)
#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5)
#define SPI_OTA_RSP_ACK (0x80)
@@ -463,7 +464,7 @@ private:
uint8_t *out, size_t *out_len);
static bool otaFindFrame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset);
size_t *frame_offset, uint8_t *out_flags = nullptr);
};
#endif // SPI_MASTER_H
+10 -1
View File
@@ -24,8 +24,12 @@ CONFIG_DCU_SPI_CLOCK_HZ=4000000
CONFIG_LWIP_MAX_SOCKETS=12
# C3 堆紧:窗口过大 OTA 峰值易顶;8KB≈5*MSS,在 4KB OTA 块下吞吐更好,仍比 12KB+ 保守。
# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig。
# WND 保持 8192:OTA 上传走接收窗口,不动以保上传吞吐。
CONFIG_LWIP_TCP_WND_DEFAULT=8192
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=8192
# SND_BUF 8192→5760(=4*MSS):发送侧(/logs、status、feedback 响应)每 socket 的
# 瞬时 TX pbuf 上限从 ~8KB 降到 ~5.76KB;max_open_sockets=6 时聚合发送峰值明显下降。
# 我方响应体均 ≤5KB,4*MSS 不损局域网 TX 吞吐;OTA 上传是 RX 侧,不受影响。
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=5760
# 乱序队列 pbuf:4→2,少占堆;HTTP 顺序上传基本不需要大 OOSEQ
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=2
@@ -95,3 +99,8 @@ CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200
# Brownout detector threshold (LVL5)
CONFIG_ESP_BROWNOUT_DET=y
CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5=y
# ── 控制台/调试 UART 波特率 3M(debug_log.h UART_DEBUG_BAUD_RATE 与此对齐)──
# 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig 或删 sdkconfig 重生。
CONFIG_ESP_CONSOLE_UART_BAUDRATE=3000000
CONFIG_CONSOLE_UART_BAUDRATE=3000000
-167
View File
@@ -1,167 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
bump_asset_versions.py — 按本地静态资源内容 hash 自动更新 HTML 里的 ?v= 版本号。
动机
----
ESP32_COMM/tools 下多个 HTML(can.html / can_monitor.html / index.html)手工维护
<link>/<script> 引用的 `?v=...` query 版本号。components.css 修改后部分页面没 bump,
浏览器继续加载旧 CSS(审计 finding WEB-001)。
本脚本扫描所有 HTML 中形如
<link ... href="css/foo.css?v=OLD">
<script ... src="js/bar.js?v=OLD">
的本地资源引用,按文件内容计算短 hash(sha1 前 8 位十六进制),把 OLD 替换成 hash。
内容不变就不改,内容变了就 bump,所有页面引用同一资源时自动收敛到同一版本号。
用法
----
python bump_asset_versions.py # 默认 dry-run,只打印将要改动的行
python bump_asset_versions.py --write # 实际写回 HTML
python bump_asset_versions.py --check # CI 模式,发现任何不一致即返回非 0
python bump_asset_versions.py --root PATH # 指定 tools 目录(默认脚本所在目录)
退出码
------
0 全部一致(dry-run 无改动 或 --write 成功 或 --check 通过)
1 发现需要 bump 的引用(dry-run/--check 时)
2 参数错误或文件 IO 错误
"""
from __future__ import annotations
import argparse
import hashlib
import re
import sys
from pathlib import Path
# 匹配 <link href="...?v=..."> 与 <script src="...?v=...">,仅本地相对路径(无 scheme/host)。
# 路径里允许字母/数字/_-./ 子目录。query 部分仅捕获 v= 的值,保留其它 query key(若有)。
ASSET_REF_RE = re.compile(
r"""
(?P<tag>(?:href|src)\s*=\s*") # 属性名 + =" 开头
(?P<path>[A-Za-z0-9_./-]+\.(?:css|js)) # 本地资源路径(.css 或 .js)
\?v=(?P<old>[^"&\s]+) # ?v= 版本号
(?P<rest>"?) # 收尾引号(保留)
""",
re.VERBOSE,
)
def short_hash(content: bytes) -> str:
"""sha1 前 8 位十六进制;8 位足够区分日常 bump,且 URL 友好。"""
return hashlib.sha1(content).hexdigest()[:8]
def find_html_files(root: Path) -> list[Path]:
return sorted(p for p in root.glob("*.html") if p.is_file())
def process_html(html_path: Path) -> tuple[list[str], str]:
"""
返回 (new_lines, new_text)。
new_lines 是"将被改动"的描述列表(仅 dry-run/日志用),空列表表示无改动。
new_text 是替换后的完整文本(无论是否有改动都返回)。
"""
text = html_path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
changes: list[str] = []
def replace_in_line(line: str, line_no: int) -> str:
def _sub(m: re.Match[str]) -> str:
rel_path = m.group("path")
old_ver = m.group("old")
asset_path = html_path.parent / rel_path
if not asset_path.is_file():
# 资源缺失:不动,交给调用方在校验阶段报错
return m.group(0)
new_ver = short_hash(asset_path.read_bytes())
if new_ver == old_ver:
return m.group(0)
changes.append(
f" L{line_no}: {rel_path} {old_ver} -> {new_ver}"
)
return f'{m.group("tag")}{rel_path}?v={new_ver}{m.group("rest")}'
return ASSET_REF_RE.sub(_sub, line)
new_lines_text = [replace_in_line(line, i + 1) for i, line in enumerate(lines)]
return changes, "".join(new_lines_text)
def collect_missing_assets(html_files: list[Path]) -> list[str]:
"""收集所有 HTML 引用但磁盘上不存在的本地资源,供 --check 报错。"""
missing: set[str] = set()
for html_path in html_files:
text = html_path.read_text(encoding="utf-8")
for m in ASSET_REF_RE.finditer(text):
rel = m.group("path")
if not (html_path.parent / rel).is_file():
missing.add(f"{html_path.name}: {rel}")
return sorted(missing)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="按本地静态资源内容 hash 自动更新 HTML 里的 ?v= 版本号。"
)
parser.add_argument(
"--root", type=Path, default=None,
help="tools 目录;默认为脚本所在目录。",
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--write", action="store_true", help="实际写回 HTML(默认 dry-run)。")
mode.add_argument("--check", action="store_true", help="CI 模式:有改动或资源缺失即非 0 退出。")
args = parser.parse_args(argv)
root = (args.root if args.root is not None else Path(__file__).resolve().parent)
if not root.is_dir():
print(f"ERROR: root 不是目录: {root}", file=sys.stderr)
return 2
html_files = find_html_files(root)
if not html_files:
print(f"[bump] 没有找到 HTML 文件: {root}", file=sys.stderr)
return 2
mode_label = "check" if args.check else ("write" if args.write else "dry-run")
print(f"[bump] root={root} mode={mode_label} html={len(html_files)}")
missing = collect_missing_assets(html_files)
if missing:
print("[bump] 引用了不存在的本地资源:", file=sys.stderr)
for item in missing:
print(f" {item}", file=sys.stderr)
return 2
total_changes = 0
for html_path in html_files:
changes, new_text = process_html(html_path)
if not changes:
continue
total_changes += len(changes)
rel = html_path.relative_to(root)
print(f"[bump] {rel}({len(changes)} 处):")
for line in changes:
print(line)
if args.write:
html_path.write_text(new_text, encoding="utf-8")
if total_changes == 0:
print("[bump] 全部一致,无需 bump。")
return 0
if args.write:
print(f"[bump] 已写回 {total_changes} 处版本号。")
return 0
if args.check:
print(f"[bump] CHECK FAIL:{total_changes} 处版本号与内容不一致。", file=sys.stderr)
return 1
print(f"[bump] dry-run:{total_changes} 处需要 bump(加 --write 写回)。")
return 1
if __name__ == "__main__":
sys.exit(main())
+15 -6
View File
@@ -4,19 +4,28 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
<link rel="stylesheet" href="css/can.css?v=c06f5688">
<link rel="stylesheet" href="css/responsive.css?v=46449483">
</head>
<body class="can-page">
<div class="container can-container">
<header class="header-bar can-header">
<div class="header-brand">
<span class="dot-mark"></span>
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
</svg>
</span>
<div class="brand-text">
<strong>ESP32 CAN 通信控制台</strong>
<span class="brand-sub">DCU · Robot_v2 · CAN FD</span>
</div>
</div>
<div class="header-status">
<span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></span>
+6 -6
View File
@@ -4,11 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 接收监控</title>
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
<link rel="stylesheet" href="css/can.css?v=c06f5688">
<link rel="stylesheet" href="css/responsive.css?v=46449483">
</head>
<body class="can-page">
<div class="container can-container">
@@ -182,6 +182,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/can_monitor.js?v=ef00eb03"></script>
<script type="module" src="js/can_monitor.js?v=9547d2a1"></script>
</body>
</html>
+8 -6
View File
@@ -36,6 +36,9 @@
--can: #43d97b; /* 绿:CAN 总线 / 健康 */
--can-hi: #7deda6;
--can-dim: rgba(67,217,123,.14);
--motor: #22d3ee; /* 青:电机控制台 */
--motor-hi: #67e8f9;
--motor-dim: rgba(34,211,238,.14);
--mem: #a88bfa; /* 紫:内存 / 调试 / 日志 */
--mem-hi: #c7b3ff;
--mem-dim: rgba(168,139,250,.14);
@@ -57,13 +60,12 @@
--radius-lg: 16px;
/* ---- 字体 ----
display:Chakra Petch(棱角科技感,用于标题/大数字/按钮;
无中文字形,中文自动回退苹方/雅黑)
mono:JetBrains Mono(SHA / HEX / 日志 / 数值)
body:系统中文字体优先,保证正文可读性 */
--font-display: "Chakra Petch","PingFang SC","Microsoft YaHei","Segoe UI",sans-serif;
沿用原始系统字体方案:中文优先苹方/雅黑,西文 Segoe UI;
mono 用于 SHA / HEX / 日志 / 数值(SF Mono → Fira Code → Cascadia → Consolas)。
--font-display 保留变量名以兼容既有引用,指向同一系统栈。 */
--font-display: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--font-mono: "JetBrains Mono","SF Mono","Cascadia Code","Consolas",monospace;
--font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
/* ---- 间距 / 阴影 ---- */
--gap: 18px;
+70 -16
View File
@@ -5,9 +5,9 @@
}
.can-launch-card {
background:
radial-gradient(circle at top right, rgba(34,211,238,.18), transparent 28%),
radial-gradient(circle at top right, rgba(67,217,123,.16), transparent 28%),
linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%);
border: 1px solid rgba(34,211,238,.14);
border: 1px solid rgba(67,217,123,.16);
border-radius: 14px;
padding: 20px;
box-shadow:
@@ -21,7 +21,7 @@
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18));
background: linear-gradient(180deg, rgba(67,217,123,.9), rgba(67,217,123,.18));
}
.can-launch-copy {
position: relative;
@@ -46,7 +46,7 @@
max-width: 700px;
}
.can-launch-kicker {
color: #88ecff;
color: #8ef0b4;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
@@ -57,9 +57,9 @@
align-items: center;
padding: 7px 12px;
border-radius: 999px;
background: rgba(34,211,238,.08);
border: 1px solid rgba(34,211,238,.18);
color: #a9f4ff;
background: rgba(67,217,123,.09);
border: 1px solid rgba(67,217,123,.22);
color: #a9f5c8;
font-size: 11px;
font-weight: 700;
letter-spacing: .12em;
@@ -88,8 +88,8 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px rgba(34,211,238,.35);
background: var(--can);
box-shadow: 0 0 10px rgba(67,217,123,.35);
}
.can-launch-actions {
display: grid;
@@ -104,7 +104,9 @@
min-height: 42px;
}
.can-launch-primary {
box-shadow: 0 10px 24px rgba(34,211,238,.18);
background: linear-gradient(135deg, var(--can) 0%, #22b45f 100%);
color: #04240f;
box-shadow: 0 10px 24px rgba(67,217,123,.2);
}
.can-launch-secondary {
color: #d5e4f4;
@@ -113,7 +115,7 @@
}
.can-launch-secondary:hover:not(:disabled) {
background: rgba(255,255,255,.05);
border-color: rgba(34,211,238,.18);
border-color: rgba(67,217,123,.25);
}
.can-launch-foot {
display: flex;
@@ -128,6 +130,35 @@
position: relative;
z-index: 1;
}
/* 电机入口:复用 can-launch 布局,换青色强调 */
.motor-launch-card {
background:
radial-gradient(circle at top right, rgba(34,211,238,.16), transparent 28%),
linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%);
border-color: rgba(34,211,238,.16);
}
.motor-launch-card::before {
background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18));
}
.motor-launch-card .can-launch-kicker { color: #67e8f9; }
.motor-launch-card .can-launch-badge {
background: rgba(34,211,238,.09);
border-color: rgba(34,211,238,.22);
color: #a5f3fc;
}
.motor-launch-card .can-launch-point::before {
background: var(--motor, #22d3ee);
box-shadow: 0 0 10px rgba(34,211,238,.35);
}
.motor-launch-primary {
background: linear-gradient(135deg, var(--motor, #22d3ee) 0%, #0891b2 100%) !important;
color: #042f2e !important;
box-shadow: 0 10px 24px rgba(34,211,238,.2);
}
.motor-launch-card .can-launch-secondary:hover:not(:disabled) {
border-color: rgba(34,211,238,.25);
}
.can-launch-foot-label {
flex: none;
display: inline-flex;
@@ -160,7 +191,6 @@ body.can-page {
.can-container {
max-width: min(1700px, 100%);
width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
.can-header {
@@ -170,6 +200,9 @@ body.can-page {
backdrop-filter: blur(14px);
background: rgba(15,22,31,.86);
}
/* sticky+backdrop-filter 合成层冲突致 fadeInUp 卡首帧(translateY(10px)+opacity:0),
* 禁用入场动画。选择器需 .header-bar.can-header 以胜过 responsive.css 的 .header-bar 规则 */
.header-bar.can-header { animation: none; }
.can-header-actions {
margin-left: auto;
}
@@ -942,9 +975,9 @@ body.can-page {
grid-template-columns:
auto
56px
minmax(0, 0.9fr)
minmax(0, 1.4fr)
minmax(0, 0.8fr);
minmax(0, 0.7fr)
minmax(0, 0.8fr)
minmax(0, 1.6fr);
align-items: center;
gap: 8px 10px;
min-width: 0;
@@ -1008,6 +1041,24 @@ body.can-page {
min-width: 0;
text-align: right;
}
/* Click-to-Copy 样式 */
.history-item-row .copyable {
cursor: pointer;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background .15s, color .15s;
}
.history-item-row .copyable:hover {
background: rgba(255,255,255,.08);
}
.history-item-row .copyable:active {
background: rgba(255,255,255,.14);
}
.history-item-row .copyable.copied {
background: rgba(34,197,94,.18);
color: #86efac;
}
/* === CAN 1Hz FEEDBACK === */
.can-feedback-card { margin-top: 18px; }
@@ -1095,7 +1146,10 @@ body.can-page {
}
.feedback-row:last-child { border-bottom: none; }
.fb-label { color: var(--muted, #8892a4); }
.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; }
.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; cursor: pointer; border-radius: 4px; padding: 1px 4px; margin: -1px -4px; transition: background .15s; }
.fb-val:hover { background: rgba(255,255,255,.08); }
.fb-val:active { background: rgba(255,255,255,.14); }
.fb-val.copied { background: rgba(34,197,94,.18); color: #86efac; }
.fb-val.mono { font-family: monospace; }
.fb-val.ok { color: #34d399; }
.fb-val.warn { color: #f59e0b; }
+7 -6
View File
@@ -136,14 +136,15 @@
box-shadow: 0 8px 20px rgba(3,7,16,.4);
}
.metric .k {
font-family: var(--font-display);
font-size: 10px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .13em; margin-bottom: 5px;
font-family: var(--font);
font-size: 11px; font-weight: 500; color: var(--text-label);
letter-spacing: .04em; margin-bottom: 6px;
}
.metric .v {
font-family: var(--font-display);
font-size: 21px; font-weight: 700; word-break: break-word;
font-variant-numeric: tabular-nums; letter-spacing: .01em;
font-family: var(--font);
font-size: 20px; font-weight: 700; line-height: 1.2;
word-break: break-word;
font-variant-numeric: tabular-nums;
}
.metric .v.highlight, .metric .v.accent { color: var(--esp32-hi); }
.metric .v.ok { color: var(--green); }
+6 -11
View File
@@ -5,14 +5,16 @@
/* ================= HEADER — 命令栏 ================= */
.header-bar {
position: relative;
z-index: 20; /* 高于下方卡片,保证 pill tooltip 不被遮挡 */
display: flex; align-items: center; gap: 16px;
padding: 16px 22px; margin-bottom: 26px;
background: linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%);
background:
radial-gradient(620px 240px at 88% -40%, rgba(47,216,193,.12), transparent 60%),
linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
flex-wrap: wrap;
overflow: hidden;
}
/* 顶部三色霓虹描边:ESP32 青绿 → BLE 蓝 → GD32 琥珀,一条细线点出子系统谱系 */
.header-bar::before {
@@ -21,15 +23,7 @@
background: linear-gradient(90deg,
var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%);
opacity: .85;
}
/* 右上角极淡的芯片电路装饰 */
.header-bar::after {
content: "";
position: absolute; right: -40px; top: -40px;
width: 220px; height: 220px; pointer-events: none;
background:
radial-gradient(circle at 30% 30%, rgba(47,216,193,.10), transparent 55%);
border-radius: 50%;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.header-brand {
@@ -139,6 +133,7 @@
.card--gd32 { --card-accent: var(--gd32); --card-accent-dim: var(--gd32-dim); }
.card--ble { --card-accent: var(--ble); --card-accent-dim: var(--ble-dim); }
.card--can { --card-accent: var(--can); --card-accent-dim: var(--can-dim); }
.card--motor { --card-accent: var(--motor); --card-accent-dim: var(--motor-dim); }
.card--mem { --card-accent: var(--mem); --card-accent-dim: var(--mem-dim); }
.card--status { --card-accent: var(--green); --card-accent-dim: var(--can-dim); }
+139 -172
View File
@@ -1,24 +1,19 @@
/* === responsive.css — 响应式断点 + 增强动画 === */
/* === RESPONSIVE === */
/* ============================================================
responsive.css — 响应式断点 + 动效(对齐新设计令牌)
============================================================ */
/* ================= 断点 ================= */
@media (max-width: 1100px) {
.main-layout { grid-template-columns: 1fr; }
.sys-drawer {
--sys-drawer-panel-w: min(340px, calc(100vw - 40px));
--sys-drawer-peek: 32px;
top: 64px;
height: calc(100vh - 72px);
max-height: calc(100vh - 72px);
}
.can-monitor-drawer {
--sys-drawer-panel-w: min(400px, calc(100vw - 40px));
top: 64px; height: calc(100vh - 72px); max-height: calc(100vh - 72px);
}
.can-monitor-drawer { --sys-drawer-panel-w: min(400px, calc(100vw - 40px)); }
.badge-grid { grid-template-columns: repeat(2, 1fr); }
.can-toolbar-grid,
.can-toolbar-foot,
.can-editor-layout { grid-template-columns: 1fr; }
.message-list {
max-height: 420px;
}
.can-toolbar-grid, .can-toolbar-foot, .can-editor-layout { grid-template-columns: 1fr; }
.message-list { max-height: 420px; }
.can-meta-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
@@ -26,254 +21,226 @@
.sys-drawer {
--sys-drawer-panel-w: calc(100vw - 36px);
--sys-drawer-peek: 28px;
top: 56px;
height: calc(100vh - 60px);
max-height: calc(100vh - 60px);
top: 56px; height: calc(100vh - 60px); max-height: calc(100vh - 60px);
}
.sys-drawer-rail-text { font-size: 11px; }
.header-bar { flex-direction: column; align-items: flex-start; }
.header-sub { margin-left: 0; }
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
.can-grid { grid-template-columns: 1fr 1fr; }
.btn-row { flex-direction: column; }
.can-meta-grid { grid-template-columns: 1fr; }
.can-launch-top,
.can-launch-actions,
.can-launch-foot {
grid-template-columns: 1fr;
flex-direction: column;
align-items: flex-start;
}
.can-selection-bar,
.can-selection-actions {
flex-direction: column;
align-items: flex-start;
}
.can-msg-toolbar {
flex-wrap: wrap;
.can-launch-top, .can-launch-actions, .can-launch-foot {
grid-template-columns: 1fr; flex-direction: column; align-items: flex-start;
}
.can-selection-bar, .can-selection-actions { flex-direction: column; align-items: flex-start; }
.can-msg-toolbar { flex-wrap: wrap; }
.history-item-row {
grid-template-columns: auto 56px minmax(0, 1fr);
grid-template-rows: auto auto;
}
.history-item-hex,
.history-item-foot {
grid-column: 1 / -1;
text-align: left;
}
.can-title {
font-size: 22px;
}
.can-frame-hex {
font-size: 15px;
}
/* 禁止再强制 980px 最小宽,避免报文/信号表横向滑动 */
.message-table,
.signal-table {
min-width: 0;
max-width: 100%;
}
.history-item-hex, .history-item-foot { grid-column: 1 / -1; text-align: left; }
.can-title { font-size: 22px; }
.can-frame-hex { font-size: 15px; }
.message-table, .signal-table { min-width: 0; max-width: 100%; }
}
/* ===== ENHANCED ANIMATIONS (v20260625b) ===== */
/* Global keyframes */
/* ================= 动画关键帧 =================
* 性能约定:常驻(infinite)动画只动 transform/opacity(合成器直通,
* 不走主线程重绘);box-shadow/background-position 关键帧仅限短暂、
* 临时态(如 dragover / OTA 阶段活跃)。 */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes popIn {
0% { opacity: 0; transform: scale(.85); }
60% { opacity: 1; transform: scale(1.04); }
100% { opacity: 1; transform: scale(1); }
}
/* 光泽扫过:由 background-position 改为伪元素自身平移(合成器友好),
* 扫动节奏与原版一致:扫过 + 停预周期。 */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes ringPulse {
0% { box-shadow: 0 0 0 0 rgba(34,211,238,.55); }
70% { box-shadow: 0 0 0 10px rgba(34,211,238,0); }
100% { box-shadow: 0 0 0 0 rgba(34,211,238,0); }
0% { box-shadow: 0 0 0 0 rgba(47,216,193,.5); }
70% { box-shadow: 0 0 0 10px rgba(47,216,193,0); }
100% { box-shadow: 0 0 0 0 rgba(47,216,193,0); }
}
/* 常驻波纹/呼吸辉光的合成器友好版:叠加层只动 transform/opacity。 */
@keyframes ringRipple {
0% { transform: scale(1); opacity: .5; }
70%,100% { transform: scale(3); opacity: 0; }
}
@keyframes glowBreathFade {
0%,100% { opacity: 0; }
50% { opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
0%,100% { transform: translateX(0); }
20% { transform: translateX(-4px); } 40% { transform: translateX(4px); }
60% { transform: translateX(-3px); } 80% { transform: translateX(3px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes floatY {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes glowBreath {
0%, 100% { box-shadow: 0 0 8px var(--accent-dim); }
50% { box-shadow: 0 0 18px rgba(34,211,238,.45); }
}
@keyframes flowDash {
to { background-position: 200% 0; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes floatY { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes flowDash { to { background-position: 200% 0; } }
@keyframes logoPulse {
0%,100% { opacity: 0; }
50% { opacity: 1; }
}
/* Page entrance — header + cards staggered */
.header-bar { animation: fadeInUp .45s ease both; }
.main-layout > div > .card { animation: fadeInUp .5s ease both; }
.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .05s; }
.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .12s; }
.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .19s; }
.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .26s; }
.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .08s; }
.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .15s; }
.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .22s; }
.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .29s; }
/* ================= 入场:头部 + 卡片错落浮现 ================= */
.header-bar { animation: fadeInUp .5s ease both; }
.main-layout > div > .card { animation: fadeInUp .55s ease both; }
.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .06s; }
.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .13s; }
.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .20s; }
.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .27s; }
.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .09s; }
.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .16s; }
.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .23s; }
.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .30s; }
.main-layout > div:nth-child(2) > .card:nth-child(5) { animation-delay: .37s; }
/* Card hover — lift + glow */
.card {
transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease;
}
/* ================= 卡片悬停:抬升 + 子系统色描边 ================= */
.card { transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease; }
.card:hover {
transform: translateY(-2px);
border-color: rgba(34,211,238,.28);
box-shadow: 0 10px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(34,211,238,.08);
border-color: color-mix(in srgb, var(--card-accent, var(--esp32)) 32%, var(--border));
box-shadow: 0 14px 34px rgba(2,5,12,.5),
0 0 0 1px color-mix(in srgb, var(--card-accent, var(--esp32)) 12%, transparent);
}
/* Brand dot — gentle ring pulse */
.header-brand .dot-mark {
animation: ringPulse 2.2s ease-out infinite, glowBreath 3.6s ease-in-out infinite;
/* ================= 品牌 Logo:呼吸辉光 =================
* box-shadow 关键帧每帧走主线程重绘;改为叠加静态辉光的伪元素
* 只动 opacity(合成器直通),基础辉光由 layout.css 静态 box-shadow 提供,
* 呼吸节奏与原 logoPulse/ringPulse/glowBreath 一致。 */
.brand-logo { position: relative; }
.brand-logo::before {
content: "";
position: absolute; inset: -1px; border-radius: inherit;
box-shadow: 0 0 26px rgba(47,216,193,.4), inset 0 0 16px rgba(47,216,193,.14);
opacity: 0; pointer-events: none;
animation: logoPulse 3.2s ease-in-out infinite;
will-change: opacity;
}
.header-brand .dot-mark { position: relative; }
.header-brand .dot-mark::after {
content: "";
position: absolute; inset: 0; border-radius: 50%;
background: rgba(47,216,193,.5); pointer-events: none;
animation: ringRipple 2.4s ease-out infinite;
will-change: transform, opacity;
}
.header-brand .dot-mark::before {
content: "";
position: absolute; inset: 0; border-radius: 50%;
box-shadow: 0 0 20px rgba(47,216,193,.45);
opacity: 0; pointer-events: none;
animation: glowBreathFade 3.6s ease-in-out infinite;
will-change: opacity;
}
/* Pills — subtle hover */
.pill { transition: transform .2s ease, border-color .2s ease, background .2s ease; }
.pill:hover { transform: translateY(-1px); border-color: rgba(34,211,238,.3); }
/* ================= 胶囊 / 按钮微交互 ================= */
.pill:hover { transform: translateY(-1px); border-color: var(--border-strong); }
/* Button — press + sheen on hover for primary */
.btn { position: relative; overflow: hidden; }
.btn:active:not(:disabled) { transform: translateY(0) scale(.97); transition: transform .08s ease; }
.btn-primary::before,
.btn-success::before,
.btn-danger::before {
.btn-primary::before, .btn-success::before, .btn-danger::before {
content: "";
position: absolute; top: 0; left: -120%;
width: 60%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
transform: skewX(-20deg);
transition: left .55s ease;
pointer-events: none;
}
.btn-primary:hover:not(:disabled)::before,
.btn-success:hover:not(:disabled)::before,
.btn-danger:hover:not(:disabled)::before {
left: 130%;
}
.btn-danger:hover:not(:disabled)::before { left: 130%; }
/* Upload zone — drag pulse + icon float */
.upload-zone { transition: border-color .2s, background .2s, transform .2s; }
.upload-zone:hover { transform: translateY(-1px); }
.upload-zone .upload-ico {
display: inline-block;
transition: transform .25s ease;
}
/* ================= 上传区:拖拽脉冲 + 图标浮动 ================= */
.upload-zone:hover .upload-ico { animation: floatY 1.6s ease-in-out infinite; }
.upload-zone.dragover { animation: ringPulse 1.2s ease-out infinite; }
/* File info appearance */
/* ================= 文件信息浮现 ================= */
.file-info.show { animation: fadeInUp .3s ease both; }
/* Progress bar — striped flow on active */
/* ================= 进度条:流动斜纹 ================= */
.progress-fill {
background-image:
linear-gradient(90deg, #22d3ee, #06b6d4),
repeating-linear-gradient(45deg,
rgba(255,255,255,.18) 0 10px,
rgba(255,255,255,0) 10px 20px);
linear-gradient(90deg, var(--esp32), #14b8a6),
repeating-linear-gradient(45deg, rgba(255,255,255,.2) 0 10px, rgba(255,255,255,0) 10px 20px);
background-size: 100% 100%, 200% 100%;
background-blend-mode: normal, overlay;
animation: flowDash 1.4s linear infinite;
}
.progress-fill.done { animation: none; }
/* Phase step capsule — pop when becoming active/done */
.pp-step.active,
.pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
/* ================= 阶段胶囊:激活弹入 ================= */
.pp-step.active, .pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
/* Stage flow — connector grow + dot pop */
/* ================= 阶段流:连接线生长 + 圆点动效 ================= */
.stage-node:not(:last-child)::after {
background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 50%, var(--border) 50%, var(--border) 100%);
background-size: 200% 100%;
background-position: 100% 0;
background: linear-gradient(90deg, var(--esp32) 0%, var(--esp32) 50%, var(--border) 50%, var(--border) 100%);
background-size: 200% 100%; background-position: 100% 0;
transition: background-position .5s ease;
}
.stage-node.done:not(:last-child)::after,
.stage-node.live:not(:last-child)::after {
background-position: 0% 0;
}
.stage-node.live:not(:last-child)::after { background-position: 0% 0; }
.stage-dot { transition: transform .3s ease, background .3s, border-color .3s, color .3s, box-shadow .3s; }
.stage-node.live .stage-dot {
animation: ringPulse 1.6s ease-out infinite;
}
.stage-node.live .stage-dot { animation: ringPulse 1.6s ease-out infinite; }
.stage-node.done .stage-dot { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
.stage-node.err .stage-dot { animation: shake .4s ease both; }
/* Stage status bar — slide in when state changes */
.stage-status-bar.active,
.stage-status-bar.ok,
.stage-status-bar.err { animation: fadeInUp .3s ease both; }
/* Stage result panel */
/* ================= 状态条切换浮现 ================= */
.stage-status-bar.active, .stage-status-bar.ok, .stage-status-bar.err { animation: fadeInUp .3s ease both; }
.stage-result { animation: popIn .4s cubic-bezier(.4,1.4,.5,1) both; }
/* Metric / badge values — number tick highlight */
.metric .v,
.badge-stat .v {
transition: color .3s ease, transform .3s ease;
}
.metric:hover,
.badge-stat:hover {
border-color: rgba(34,211,238,.25);
transition: border-color .25s ease;
}
/* ================= 指标数值过渡 ================= */
.metric .v, .badge-stat .v { transition: color .3s ease, transform .3s ease; }
/* Conn indicator — light ring pulse when warn */
/* ================= 连接指示 ================= */
.conn-indicator { transition: background .25s ease, border-color .25s ease; }
.conn-indicator.warn .light { animation: ringPulse 1.4s ease-out infinite; }
.conn-indicator.ok .light { animation: glowBreath 3s ease-in-out infinite; }
/* 在线呼吸辉光:常驻动画改伪元素 opacity(合成器直通),节奏同原 glowBreath */
.conn-indicator.ok .light { position: relative; }
.conn-indicator.ok .light::after {
content: "";
position: absolute; inset: 0; border-radius: 50%;
box-shadow: 0 0 20px rgba(47,216,193,.45);
opacity: 0; pointer-events: none;
animation: glowBreathFade 3s ease-in-out infinite;
will-change: opacity;
}
/* Heap / task bars — sheen */
.heap-bar-track,
.status-task-bar { position: relative; overflow: hidden; }
.heap-bar-track::after,
.status-task-bar::after {
/* ================= 堆 / 任务条:光泽扫过 =================
* shimmer 改动 transform(见上方关键帧注释):十几根条常驻扫光
* 不再每帧触发主线程重绘,视觉效果不变。 */
.heap-bar-track, .status-task-bar { position: relative; overflow: hidden; }
.heap-bar-track::after, .status-task-bar::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.12) 50%, transparent 100%);
transform: translateX(-100%);
animation: shimmer 2.6s linear infinite;
background-size: 200% 100%;
animation: shimmer 2.8s linear infinite;
will-change: transform;
pointer-events: none;
}
.heap-bar-fill,
.status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); }
.heap-bar-fill, .status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); }
/* Log lines — fresh entry fade-in */
/* ================= 日志新行淡入 ================= */
.log-line { animation: fadeIn .25s ease both; }
/* Toast — pop and float */
/* ================= Toast 弹出 ================= */
.toast.show { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
/* Reduced motion — respect user preference */
/* ================= 降低动态偏好 ================= */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
+64 -19
View File
@@ -1,7 +1,8 @@
<!--
index.html - ESP32 OTA 中继控制台主页面
index.html - ESP32 OTA 中继控制台主页面(DCU Mission Control · 美化版)
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA), style.css (UI 样式)
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA)
子系统配色:ESP32=青绿 · GD32=琥珀 · BLE=蓝 · CAN=绿 · 内存/调试=紫
-->
<!DOCTYPE html>
<html lang="zh-CN">
@@ -9,20 +10,29 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继控制台</title>
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
<link rel="stylesheet" href="css/can.css?v=c06f5688">
<link rel="stylesheet" href="css/responsive.css?v=46449483">
</head>
<body>
<div class="container">
<!-- ====== HEADER: compact status bar ====== -->
<!-- ====== HEADER: 命令栏 ====== -->
<header class="header-bar">
<div class="header-brand">
<span class="dot-mark"></span>
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
</svg>
</span>
<div class="brand-text">
<strong>ESP32 OTA 中继控制台</strong>
<span class="brand-sub">DCU · Robot_v2 · Relay</span>
</div>
</div>
<div class="header-status">
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">未连接</span></span>
@@ -44,7 +54,7 @@
<div>
<!-- 1. Connection -->
<div class="card">
<div class="card card--ble">
<div class="card-head"><span class="ico">🔗</span> 设备连接
<span id="bleHeadActions" style="display:none;margin-left:auto;">
<button id="connectBtn" class="btn btn-sm btn-primary">连接设备</button>
@@ -70,7 +80,7 @@
</div>
<!-- ====== Shared Target + Firmware + Actions ====== -->
<div class="card">
<div class="card card--gd32">
<div class="card-head"><span class="ico">🎯</span> 升级目标、固件文件与执行操作</div>
<div class="card-body">
<div style="display:flex;gap:6px;margin-bottom:14px;">
@@ -172,7 +182,7 @@
</div>
<!-- 4. Flash Progress + Stage Flow -->
<div class="card">
<div class="card card--esp32">
<div class="card-head"><span class="ico">⚡</span> 刷写进度与流程阶段</div>
<div class="card-body">
<!-- Phase step indicators -->
@@ -237,7 +247,7 @@
<div>
<!-- Primary status -->
<div class="card">
<div class="card card--status">
<div class="card-head"><span class="ico">📡</span> 设备状态</div>
<div class="card-body">
<div class="badge-grid">
@@ -273,7 +283,7 @@
</div>
<!-- Secondary metrics -->
<div class="card">
<div class="card card--mem">
<div class="card-head"><span class="ico">📊</span> 运行指标</div>
<div class="card-body">
<div class="metric-grid">
@@ -287,7 +297,7 @@
</div>
</div>
<div class="card" style="display:none">
<div class="card card--mem" style="display:none">
<div class="card-head">
<span class="ico">🧾</span> 系统状态 (/system/status)
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
@@ -302,7 +312,7 @@
</div>
<!-- CAN workspace entry -->
<div class="card collapsed">
<div class="card card--can collapsed">
<div class="card-head" onclick="toggleCollapse(this)">
<span class="ico">📨</span> CAN 通信页
<span class="collapse-arrow">▼</span>
@@ -338,11 +348,46 @@
</div>
</div>
<!-- Motor workspace entry -->
<div class="card card--motor collapsed">
<div class="card-head" onclick="toggleCollapse(this)">
<span class="ico">⚙️</span> 电机控制页
<span class="collapse-arrow">▼</span>
</div>
<div class="card-body">
<div class="can-launch-card motor-launch-card">
<div class="can-launch-copy">
<div class="can-launch-top">
<div>
<div class="can-launch-kicker">Motor Workspace</div>
<h3>电机 CAN FD 控制台</h3>
</div>
<span class="can-launch-badge">CMD / STATUS</span>
</div>
<p class="can-launch-desc">节点总览、CMD 控制、CONFIG/VERSION、电机 OTA。与主页共用设备 IP(<code>dcu_esp_ip</code>),本页不提供连接配置,进入后自动探测并轮询 <code>/motor/state</code>。</p>
<div class="can-launch-points">
<span class="can-launch-point">多节点在线状态</span>
<span class="can-launch-point">周期/单次 CMD</span>
<span class="can-launch-point">共用主页连接</span>
</div>
</div>
<div class="can-launch-actions">
<a class="btn btn-primary can-launch-primary motor-launch-primary" href="motor.html">进入电机控制</a>
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('motor.html','_blank','noopener')">新窗口打开</button>
</div>
<div class="can-launch-foot">
<span class="can-launch-foot-label">说明</span>
<span>请先在上方「设备连接」配置并测试 ESP32 IP;电机页只消费同一连接,不单独建连。</span>
</div>
</div>
</div>
</div>
<!-- Log panel -->
<div class="card log-panel">
<div class="card card--mem log-panel">
<div class="card-head" style="justify-content:space-between;">
<span><span class="ico">🖥️</span> 调试日志</span>
<span style="font-size:11px;font-weight:400;color:var(--text-label);text-transform:none;letter-spacing:0;">UDP :6666</span>
<span style="font-size:11px;font-weight:400;color:var(--text-label);text-transform:none;letter-spacing:0;font-family:var(--font-mono);">UDP :6666</span>
</div>
<div class="card-body">
<div class="log-toolbar">
@@ -374,7 +419,7 @@
<span class="sys-drawer-rail-ico">🧠</span>
<span class="sys-drawer-rail-text">堆与任务栈</span>
</div>
<div class="sys-drawer-panel card">
<div class="sys-drawer-panel card card--mem">
<div class="card-head sys-drawer-head">
<span class="ico">🧠</span> 堆与任务栈
<button class="btn btn-ghost btn-sm card-head-btn" type="button" onclick="refreshSystemStatusNow(event)">刷新</button>
+61 -7
View File
@@ -31,6 +31,43 @@ function formatTime(date) {
/** 简单延时 helper,用于批次间间隔 */
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// ==================== Click-to-Copy ====================
/** 将文本写入剪贴板并在元素上闪烁 copied 提示 */
function copyCell(text, target) {
if (!text) return;
function onDone() {
target.classList.add('copied');
setTimeout(function () { target.classList.remove('copied'); }, 1200);
showToast('已复制', 'success');
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
}
/** 在 historyList 上绑定一次事件代理 */
var _historyCopyBound = false;
function ensureHistoryCopyHandler() {
if (_historyCopyBound || !el.historyList) return;
_historyCopyBound = true;
el.historyList.addEventListener('click', function (e) {
var target = e.target.closest('[data-copy]');
if (!target) return;
copyCell(target.getAttribute('data-copy'), target);
});
}
// ==================== 发送错误码描述 ====================
var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)',
@@ -67,6 +104,17 @@ function buildSendResponseText(result) {
return '发送失败: ' + desc + ' (code=' + result.code + ')';
}
/**
* @description 历史列表用的短文案:红色标签已表明失败,去掉「发送失败:」前缀、
* gd32_err 细分与 (code=) 尾巴,只保留核心原因,避免挤占空间(完整原因见 hover/复制)。
*/
function buildSendResponseShort(result) {
if (result.status === 'ok') {
return result.recv ? ('0x' + result.recv) : 'OK';
}
return CAN_SEND_ERR_DESC[String(result.code)] || ('code=' + result.code);
}
// ==================== HTTP 请求(唯一入口 /can/send_batch)====================
/**
* @description 同通道发送 1~N 帧。单帧也走 batch(frames 长度 1)。
@@ -141,7 +189,9 @@ function makeHistoryEntry(opts) {
idHex: opts.idHex,
dlc: opts.dlc,
dataHex: opts.dataHex || '',
resultText: opts.resultText
resultText: opts.resultText,
/* 列表显示用短文案;缺省回退到完整文案,兼容旧持久化数据 */
resultShort: opts.resultShort || opts.resultText
};
}
@@ -161,6 +211,7 @@ function addHistoryEntry(entry, opts) {
/** @description 渲染发送历史列表(单行紧凑:状态|时间|CH·ID·DLC|HEX|结果)。 */
export function renderHistory() {
if (!el.historyList) return;
ensureHistoryCopyHandler();
if (!state.history.length) {
el.historyList.innerHTML = '<div class="status-empty">尚未发送任何 CAN 帧。</div>';
return;
@@ -171,9 +222,9 @@ export function renderHistory() {
'<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' +
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + escapeHtml(item.mode) + '</span>' +
'<span class="history-item-time">' + escapeHtml(item.time) + '</span>' +
'<span class="history-item-main" title="' + escapeHtml(meta) + '">' + escapeHtml(meta) + '</span>' +
'<span class="history-item-hex" title="' + escapeHtml(item.dataHex || '') + '">' + escapeHtml(item.dataHex || '--') + '</span>' +
'<span class="history-item-foot" title="' + escapeHtml(item.resultText || '') + '">' + escapeHtml(item.resultText) + '</span>' +
'<span class="history-item-main copyable" data-copy="' + escapeHtml(meta) + '" title="点击复制">' + escapeHtml(meta) + '</span>' +
'<span class="history-item-hex copyable" data-copy="' + escapeHtml(item.dataHex || '') + '" title="点击复制">' + escapeHtml(item.dataHex || '--') + '</span>' +
'<span class="history-item-foot copyable" data-copy="' + escapeHtml(item.resultText || '') + '" title="' + escapeHtml(item.resultText || '点击复制') + '">' + escapeHtml(item.resultShort || item.resultText) + '</span>' +
'</div>';
}).join('');
}
@@ -254,7 +305,8 @@ export async function sendCheckedMessages() {
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)
resultText: item.msg.name + ' · ' + buildSendResponseText(result),
resultShort: item.msg.name + ' · ' + buildSendResponseShort(result)
}), silent);
}
} catch (err) {
@@ -311,7 +363,8 @@ export async function sendSelectedMessage() {
mode: 'DBC', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dataHex: dataHex,
resultText: buildSendResponseText(result)
resultText: buildSendResponseText(result),
resultShort: buildSendResponseShort(result)
}));
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
@@ -391,7 +444,8 @@ export async function sendManualFrame() {
mode: 'RAW', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc, dataHex: dataHex,
resultText: buildSendResponseText(result)
resultText: buildSendResponseText(result),
resultShort: buildSendResponseShort(result)
}));
showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
+33
View File
@@ -8,6 +8,30 @@ var state = { fbTimer: null, fbInFlight: false };
var el = {};
// ── Click-to-Copy (feedback 值单元格) ──────────────────────
function copyFbVal(text, target) {
if (!text || text === '--') return;
function onDone() {
target.classList.add('copied');
setTimeout(function () { target.classList.remove('copied'); }, 1200);
showToast('已复制', 'success');
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
}
var ids = [
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
@@ -293,6 +317,15 @@ export function initCanMonitor() {
if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); });
if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback);
/* feedback 值 click-to-copy:在 grid 容器上事件代理 */
document.querySelectorAll('.feedback-grid').forEach(function (grid) {
grid.addEventListener('click', function (e) {
var target = e.target.closest('.fb-val');
if (!target) return;
copyFbVal(target.textContent.trim(), target);
});
});
/* 通道卡:默认折叠详情,点标题展开/收起 */
document.querySelectorAll('.feedback-channel .feedback-ch-head').forEach(function (head) {
var toggle = function (e) {
+216 -73
View File
@@ -19,21 +19,55 @@ import {
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
} from './heap-low-history.js';
// ==================== 渲染缓存(性能) ====================
// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建
// CPU/任务栈列表 + 无条件写几十个节点,引发周期性样式重算/重排/重绘掉帧。
// 这里统一三条策略:静态元素只查一次、文本/宽度/类名写前比较、列表增量更新。
var elCache = {}; // id -> element(页面静态节点,仅首次 getElementById)
function getEl(id) {
var el = elCache[id];
if (el === undefined || el === null) {
el = document.getElementById(id);
if (el) elCache[id] = el; // 未找到不缓存,等节点出现后再查
}
return el || null;
}
/** 写前比较的 textContent 更新:值未变则不触碰 DOM,避免无谓样式失效。 */
function setTextById(id, value) {
var el = getEl(id);
if (!el) return;
var v = String(value);
if (el.textContent !== v) el.textContent = v;
}
function setClassIfChanged(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function setTextIfChanged(el, txt) {
if (el && el.textContent !== txt) el.textContent = txt;
}
/** 写前比较的宽度更新:用元素上的 _lastW 缓存避免读回 style 字符串。 */
function setWidthIfChanged(el, widthStr) {
if (el && el._lastW !== widthStr) {
el._lastW = widthStr;
el.style.width = widthStr;
}
}
// ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接');
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
var pc = getEl('pillConn');
var pct = getEl('pillConnTxt');
if (pc) {
pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = label;
setClassIfChanged(pc, connected ? 'pill online' : 'pill offline');
if (pct) setTextIfChanged(pct, label);
}
// 系统状态卡徽章
var bc = document.getElementById('bConn');
var bc = getEl('bConn');
if (bc) {
bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
setTextIfChanged(bc, connected ? '在线' : '离线');
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
}
}
export function updateHealthUi(healthCode, freeHeap) {
@@ -41,13 +75,12 @@ export function updateHealthUi(healthCode, freeHeap) {
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
/* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */
if (freeHeap !== undefined) {
var mHeapEl = document.getElementById('mHeap');
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
setTextById('mHeap', formatBytes(freeHeap));
}
var mh = document.getElementById('mHealth');
var mh = getEl('mHealth');
if (mh) {
mh.textContent = health;
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
setTextIfChanged(mh, health);
setClassIfChanged(mh, 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err'));
}
ensureHealthBootToggleBound();
}
@@ -86,11 +119,22 @@ function ensureHealthBootToggleBound() {
}
/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */
/** 启动自检渲染签名:boot 数据启动后不变,签名相同就跳过整段 DOM 重建。 */
var bootUiSig = null;
function updateBootInitUi(data) {
var boot = data && data.boot ? data.boot : null;
var resultEl = document.getElementById('mBootResult');
var failedEl = document.getElementById('mBootFailed');
var listEl = document.getElementById('bootModuleList');
/* 签名 = 影响渲染的全部字段;每秒轮询里 boot 几乎永远不变,直接短路 */
var sig = boot
? [boot.mask, boot.critical_ok, boot.all_ok, boot.summary, boot.failed,
Array.isArray(boot.modules) ? boot.modules.length : 0].join('|')
: 'none';
if (sig === bootUiSig) return;
bootUiSig = sig;
var resultEl = getEl('mBootResult');
var failedEl = getEl('mBootFailed');
var listEl = getEl('bootModuleList');
if (!boot) {
if (resultEl) {
resultEl.textContent = '--';
@@ -146,23 +190,128 @@ function updateBootInitUi(data) {
ensureHealthBootToggleBound();
}
/* ==================== 状态任务列表:增量渲染 ====================
* innerHTML 全量重建会重解析 HTML、重建每条 .status-task-bar(其 ::after 微光
* 动画随之重启、合成层反复创建销毁),是主页每秒掉帧的主要来源之一。
* 这里按 key(任务名)缓存行节点:集合不变时只改数字/宽度,排序变了仅移动节点。
* 行结构统一:head(名称/占比) + bar + 3 项 meta。
* items: [{ key, rowCls, pctText, width, fillCls, metaLabels[3], metas[3] }]
*/
function buildStatusTaskRowHtml(it) {
return '' +
'<div class="' + it.rowCls + '">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(it.key) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(it.pctText) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="' + it.fillCls + '" style="width:' + it.width + '"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>' + it.metaLabels[0] + '</span><b>' + escapeHtml(it.metas[0]) + '</b></div>' +
'<div><span>' + it.metaLabels[1] + '</span><b>' + escapeHtml(it.metas[1]) + '</b></div>' +
'<div><span>' + it.metaLabels[2] + '</span><b>' + escapeHtml(it.metas[2]) + '</b></div>' +
'</div>' +
'</div>';
}
function updateStatusTaskRow(row, it) {
var refs = row._refs;
if (!refs) {
refs = row._refs = {
ratio: row.querySelector('.status-task-ratio'),
fill: row.querySelector('.status-task-bar > div'),
metas: row.querySelectorAll('.status-task-meta b')
};
}
setTextIfChanged(refs.ratio, it.pctText);
setClassIfChanged(refs.fill, it.fillCls);
setWidthIfChanged(refs.fill, it.width);
setTextIfChanged(refs.metas[0], it.metas[0]);
setTextIfChanged(refs.metas[1], it.metas[1]);
setTextIfChanged(refs.metas[2], it.metas[2]);
}
/** 空态提示:内容未变不重写,并清掉行缓存让下次有数据时重建。 */
function setListEmpty(listEl, html) {
var cache = listEl._rowCache;
if (cache) { cache.rows = {}; cache.keys = null; }
if (listEl._emptyHtml !== html) {
listEl._emptyHtml = html;
listEl.innerHTML = html;
}
}
function renderStatusTaskList(listEl, items) {
listEl._emptyHtml = null;
var cache = listEl._rowCache || (listEl._rowCache = { keys: null, rows: {} });
var keys = [];
var seen = {};
var hasDup = false;
for (var i = 0; i < items.length; i++) {
var k = items[i].key;
if (seen[k]) hasDup = true;
seen[k] = true;
keys.push(k);
}
var keyStr = keys.join('\u0001');
var sameSet = !hasDup && keys.length === Object.keys(cache.rows).length &&
keys.every(function (k2) { return !!cache.rows[k2]; });
if (!sameSet) {
/* 集合变化(或同名任务无法索引):一次性重建并缓存行引用 */
listEl.innerHTML = items.map(buildStatusTaskRowHtml).join('');
cache.rows = {};
cache.keys = null;
if (!hasDup) {
for (var c = 0; c < listEl.children.length; c++) cache.rows[keys[c]] = listEl.children[c];
cache.keys = keyStr;
}
return;
}
if (keyStr !== cache.keys) {
/* 集合相同仅排序变化:按新顺序移动既有节点,不重建、不解析 HTML */
for (var m = 0; m < keys.length; m++) listEl.appendChild(cache.rows[keys[m]]);
cache.keys = keyStr;
}
for (var u = 0; u < items.length; u++) updateStatusTaskRow(cache.rows[keys[u]], items[u]);
}
/* 原始 JSON 折叠面板:收起时跳过 JSON.stringify + 大段文本重排;
* 展开瞬间用 window.latestSystemStatus 补渲染,行为与之前一致。 */
var statusJsonBound = false;
function renderStatusJsonIfVisible(data) {
var statusJson = getEl('statusJson');
if (!statusJson) return;
var fold = getEl('statusJsonFold');
if (!statusJsonBound && fold) {
statusJsonBound = true;
var head = fold.querySelector('.status-subfold-head');
var renderLatest = function () {
/* 内联 onclick/onkeydown 已先切换 collapsed,这里只补展开后的内容 */
if (!fold.classList.contains('collapsed') && window.latestSystemStatus) {
statusJson.textContent = JSON.stringify(window.latestSystemStatus, null, 2);
}
};
if (head) {
head.addEventListener('click', renderLatest);
head.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') renderLatest();
});
}
}
if (fold && fold.classList.contains('collapsed')) return;
statusJson.textContent = JSON.stringify(data, null, 2);
}
export function updateHeapAndTaskStatusUi(data) {
if (!data) return;
var formatPercent = window.formatPercent;
var ota = data.ota || {};
var setText = function (id, value) {
var el = document.getElementById(id);
if (el) el.textContent = value;
};
var setText = function (id, value) { setTextById(id, value); };
updateBootInitUi(data);
// ---- 堆概况:已用 / 总计 + 占用条 ----
function setFill(id, pct) {
var el = document.getElementById(id);
var el = getEl(id);
if (!el) return;
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
el.className = 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : '');
setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%');
setClassIfChanged(el, 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : ''));
}
function setBar(usageId, fillId, pctId, free, total) {
if (free === undefined || total === undefined) {
@@ -176,15 +325,15 @@ export function updateHeapAndTaskStatusUi(data) {
setText(pctId, pct.toFixed(1) + '%');
}
function setCpuFill(pct) {
var el = document.getElementById('cpuUsageFill');
var el = getEl('cpuUsageFill');
if (!el) return;
var safePct = Math.max(0, Math.min(100, Number(pct) || 0));
el.style.width = safePct + '%';
el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : '');
setWidthIfChanged(el, safePct + '%');
setClassIfChanged(el, 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : ''));
}
function updateCpuUsageUi(cpuData) {
cpuData = cpuData || {};
var taskList = document.getElementById('cpuTaskList');
var taskList = getEl('cpuTaskList');
if (!cpuData.available) {
setText('cpuUsageText', '未启用 FreeRTOS 统计');
setText('cpuUsagePct', '--');
@@ -192,7 +341,7 @@ export function updateHeapAndTaskStatusUi(data) {
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) taskList.innerHTML = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
if (taskList) setListEmpty(taskList, '<div class="status-empty">固件未启用 CPU runtime stats</div>');
return;
}
if (!cpuData.valid) {
@@ -202,15 +351,19 @@ export function updateHeapAndTaskStatusUi(data) {
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) taskList.innerHTML = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>';
if (taskList) setListEmpty(taskList, '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>');
return;
}
var usagePct = Number(cpuData.usage_pct) || 0;
var idlePct = Number(cpuData.idle_pct) || 0;
var sampleMs = Number(cpuData.sample_ms) || 0;
var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : [];
/* 按占用率降序;占比相同再按任务号,保证排序稳定。
* renderStatusTaskList 在集合不变时只 appendChild 调序,不重建 DOM。 */
tasks.sort(function (a, b) {
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
var d = (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
if (d !== 0) return d;
return (Number(a.task_number) || 0) - (Number(b.task_number) || 0);
});
setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
@@ -221,28 +374,24 @@ export function updateHeapAndTaskStatusUi(data) {
setCpuFill(usagePct);
if (taskList) {
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动 */
/* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动。
* 增量更新:集合不变时只改数字/宽度,避免每秒 innerHTML 全量重建 */
if (!tasks.length) {
taskList.innerHTML = '<div class="status-empty">当前采样没有任务增量</div>';
setListEmpty(taskList, '<div class="status-empty">当前采样没有任务增量</div>');
} else {
taskList.innerHTML = tasks.map(function (task) {
renderStatusTaskList(taskList, tasks.map(function (task) {
var pct = Number(task.usage_pct) || 0;
var delta = Number(task.runtime_delta) || 0;
var fillClass = pct >= 85 ? 'err' : pct >= 70 ? 'warn' : '';
return '' +
'<div class="status-task cpu-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(formatPercent(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><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
'<div><span>占比</span><b>' + escapeHtml(formatPercent(pct)) + '</b></div>' +
'</div>' +
'</div>';
}).join('');
return {
key: String(task.name || 'task'),
rowCls: 'status-task cpu-task',
pctText: formatPercent(pct),
width: Math.max(0, Math.min(100, pct)) + '%',
fillCls: 'status-task-bar-fill' + (pct >= 85 ? ' err' : pct >= 70 ? ' warn' : ''),
metaLabels: ['任务号', '增量', '占比'],
metas: [String(task.task_number), String(delta), formatPercent(pct)]
};
}));
}
}
}
@@ -321,8 +470,8 @@ export function updateHeapAndTaskStatusUi(data) {
setText('mRtcFree', '--');
setText('mAllocBlocks', '--');
['memSegIram', 'memSegData', 'memSegBss', 'memSegUsed', 'memSegFree'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.width = '0%';
var el = getEl(id);
if (el) setWidthIfChanged(el, '0%');
});
return;
}
@@ -365,10 +514,10 @@ export function updateHeapAndTaskStatusUi(data) {
? formatBytes(Number(mem.task_stack_total)) : '--');
function setSeg(id, bytes) {
var el = document.getElementById(id);
var el = getEl(id);
if (!el) return;
var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0;
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%');
}
setSeg('memSegIram', iram);
setSeg('memSegData', dataSz);
@@ -410,12 +559,12 @@ export function updateHeapAndTaskStatusUi(data) {
if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state;
setText('sStatusUpdated', formatTime(new Date()));
var taskList = document.getElementById('taskStackList');
var taskList = getEl('taskStackList');
if (taskList) {
var taskStack = data.task_stack || {};
var names = Object.keys(taskStack);
if (!names.length) {
taskList.innerHTML = '<div class="status-empty">当前状态未包含任务栈信息</div>';
setListEmpty(taskList, '<div class="status-empty">当前状态未包含任务栈信息</div>');
} else {
/* 峰值占用比例降序;容器默认露出最高 3 条,其余滚动 */
names.sort(function (a, b) {
@@ -427,34 +576,28 @@ export function updateHeapAndTaskStatusUi(data) {
var pb = sb > 0 ? ((Number(ib.peak_used_bytes) || 0) * 100 / sb) : 0;
return pb - pa;
});
taskList.innerHTML = names.map(function (name) {
/* 增量更新:任务集合稳定时仅改变化的数值,不重建 DOM */
renderStatusTaskList(taskList, names.map(function (name) {
var item = taskStack[name] || {};
var stackSize = Number(item.stack_size_bytes) || 0;
var watermark = Number(item.high_watermark_bytes) || 0;
var peakUsed = Number(item.peak_used_bytes) || 0;
var usagePct = stackSize > 0 ? (peakUsed * 100 / stackSize) : 0;
var fillClass = usagePct >= 85 ? 'err' : usagePct >= 70 ? 'warn' : '';
return '' +
'<div class="status-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(formatPercent(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><span>栈大小</span><b>' + escapeHtml(formatBytes(stackSize)) + '</b></div>' +
'<div><span>剩余水位</span><b>' + escapeHtml(formatBytes(watermark)) + '</b></div>' +
'<div><span>峰值占用</span><b>' + escapeHtml(formatBytes(peakUsed)) + '</b></div>' +
'</div>' +
'</div>';
}).join('');
return {
key: name,
rowCls: 'status-task',
pctText: formatPercent(usagePct),
width: Math.max(0, Math.min(100, usagePct)) + '%',
fillCls: 'status-task-bar-fill' + (usagePct >= 85 ? ' err' : usagePct >= 70 ? ' warn' : ''),
metaLabels: ['栈大小', '剩余水位', '峰值占用'],
metas: [formatBytes(stackSize), formatBytes(watermark), formatBytes(peakUsed)]
};
}));
}
}
var statusJson = document.getElementById('statusJson');
if (statusJson) {
statusJson.textContent = JSON.stringify(data, null, 2);
}
/* 原始 JSON:折叠时跳过序列化与大段文本写入(展开瞬间自动补渲染) */
renderStatusJsonIfVisible(data);
}
/** 从设备 IP 推导网关 IP(取前三段 + .1) */
function getGatewayIp() {
+14 -2
View File
@@ -16,6 +16,18 @@ var autoScroll = true;
// 缓存 DOM 引用,避免高频日志下重复 getElementById
var scrollEl = document.getElementById('logScroll');
var countEl = document.getElementById('logCount');
var scrollPending = false;
/** rAF 批量自动滚动:/logs 一轮可能追加几十行,逐行读 scrollHeight 会每行
* 触发一次强制布局;合并到下一帧只滚一次,行为不变。 */
function scheduleAutoScroll() {
if (!autoScroll || !scrollEl || scrollPending) return;
scrollPending = true;
requestAnimationFrame(function () {
scrollPending = false;
if (autoScroll && scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
});
}
// ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) {
@@ -65,7 +77,7 @@ export function addLog(level, tag, message) {
entry.div = div;
if (scrollEl) {
scrollEl.appendChild(div);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
scheduleAutoScroll();
}
syncCount();
}
@@ -92,7 +104,7 @@ export function setLogFilter(level, btn) {
});
scrollEl.innerHTML = '';
scrollEl.appendChild(frag);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
scheduleAutoScroll();
}
export function toggleAutoScroll() {
autoScroll = !autoScroll;
+8 -1
View File
@@ -397,7 +397,14 @@ export function setupDropZone(zoneId, chip) {
zone.addEventListener('drop', function (e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover');
var file = e.dataTransfer.files[0];
if (file) processFile(file, chip);
if (!file) return;
/* 拖拽拿不到 FSAPI 句柄:必须清掉旧句柄,否则热重载会把上一个文件读回来
覆盖本次拖入的文件(表现为"刷错固件"),并同步删除 IDB 持久化句柄 */
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
processFile(file, chip);
});
}
+6 -3
View File
@@ -171,12 +171,15 @@ export function cleanupAfterOtaFailure(reason) {
setActivePhase(null);
window.currentOtaTarget = null;
// 4. 恢复上传按钮(两枚都解禁并恢复原文案,避免 chip 判错或一侧残留"上传中")
['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) {
// 4. 恢复上传按钮(两枚都解禁并恢复各自原文案,避免 chip 判错或一侧残留"上传中")
// ★ 按钮文案是固定的,绝不能用共享的 uploadBtnOrigText 回写两枚,否则最后
// 刷 GD32 后 ESP32 按钮会被错写成 "GD32 OTA"(反之亦然)。
var uploadBtnLabels = { uploadEsp32Btn: '上传并升级 ESP32', uploadGd32Btn: 'GD32 OTA' };
Object.keys(uploadBtnLabels).forEach(function (btnId) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = false;
if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText;
btn.textContent = uploadBtnLabels[btnId];
});
window.activeUploadChip = null;
window.otaSessionTarget = null;
+37 -24
View File
@@ -106,6 +106,18 @@ function setDisplayedRelayState(rs) {
updateStagesFromRelayState(rs);
}
/* 写前比较小工具:每秒轮询里大部分仪表盘字段不变,
* 无条件写 textContent/className/title 会白白触发样式失效。 */
function setNodeText(el, txt) {
if (el && el.textContent !== txt) el.textContent = txt;
}
function setNodeClass(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function setNodeTitle(el, title) {
if (el && el.title !== title) el.title = title;
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。
* @param {object} data /system/status JSON
@@ -122,28 +134,29 @@ function applyDashboardUi(data, ota, wifi) {
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
if (pillVerEl) {
pillVerEl.textContent = 'v' + data.version;
pillVerEl.dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
setNodeText(pillVerEl, 'v' + data.version);
var verTip = data.build_time ? ('编译: ' + data.build_time) : '';
if (pillVerEl.dataset.tooltip !== verTip) pillVerEl.dataset.tooltip = verTip;
}
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) {
if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') {
mGd32App.textContent = ota.gd32_app;
mGd32App.className = 'v ok';
setNodeText(mGd32App, ota.gd32_app);
setNodeClass(mGd32App, 'v ok');
} else {
mGd32App.textContent = ota.gd32_app_alive ? '未知' : '离线';
mGd32App.className = 'v';
setNodeText(mGd32App, ota.gd32_app_alive ? '未知' : '离线');
setNodeClass(mGd32App, 'v');
}
}
if (mGd32Boot) {
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
mGd32Boot.textContent = ota.gd32_boot;
mGd32Boot.className = 'v ok';
setNodeText(mGd32Boot, ota.gd32_boot);
setNodeClass(mGd32Boot, 'v ok');
} else {
/* BOOT 版本与 APP alive 无直接关系,无版本时统一「未知」 */
mGd32Boot.textContent = '未知';
mGd32Boot.className = 'v';
setNodeText(mGd32Boot, '未知');
setNodeClass(mGd32Boot, 'v');
}
}
}
@@ -153,19 +166,19 @@ function applyDashboardUi(data, ota, wifi) {
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
if (Number.isFinite(data.temperature)) {
var tC = data.temperature;
mTempEl.textContent = tC.toFixed(1) + '\u00B0C';
setNodeText(mTempEl, tC.toFixed(1) + '\u00B0C');
if (tC >= 100) {
mTempEl.className = 'v err';
setNodeClass(mTempEl, 'v err');
} else if (tC >= 80) {
mTempEl.className = 'v warn';
setNodeClass(mTempEl, 'v warn');
} else {
mTempEl.className = 'v ok';
setNodeClass(mTempEl, 'v ok');
}
mTempEl.title = 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)';
setNodeTitle(mTempEl, 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)');
} else {
mTempEl.textContent = '--';
mTempEl.className = 'v';
mTempEl.title = '温度无效或未采样';
setNodeText(mTempEl, '--');
setNodeClass(mTempEl, 'v');
setNodeTitle(mTempEl, '温度无效或未采样');
}
}
/* GD32 ADC:enabled 来自 HELLO_ACK 功能位(GD32 编译开关),ESP32 无本地开关 */
@@ -177,9 +190,9 @@ function applyDashboardUi(data, ota, wifi) {
function setMetricUi(el, text, cls, title) {
if (!el) return;
el.textContent = text;
el.className = cls || 'v';
if (title) el.title = title;
setNodeText(el, text);
setNodeClass(el, cls || 'v');
if (title) setNodeTitle(el, title);
}
if (mGd32TempEl || mVccEl || mVddaEl) {
@@ -233,12 +246,12 @@ function applyDashboardUi(data, ota, wifi) {
}
}
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
if (uptimeEl) setNodeText(uptimeEl, '\u23F1 ' + formatUptime(data.uptime_sec));
var pillIPEl = document.getElementById('pillIP');
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
if (pillIPEl) setNodeText(pillIPEl, wifi.ip || '--');
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
if (pillRSSIEl) setNodeText(pillRSSIEl, rssi);
}
/**
+12
View File
@@ -164,11 +164,23 @@ export async function startUpload(chip) {
if (chip === 'esp32') window.esp32File = freshFile;
else window.gd32File = freshFile;
file = freshFile;
/* .bin 直读分支此前无任何日志,热重载是否生效不可观察,补一条 */
addLog('I', '文件', '热重载: 已重新读取 ' + freshFile.name +
' (' + formatBytes(freshFile.size) + ')');
}
} catch (e) {
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
}
} else {
/* 无 FSAPI 句柄(拖拽/传统方式选择):无法读盘,只能用缓存。
GD32 缓存是内存中转换出的 Blob,arrayBuffer 校验永远通过,
若不显式告知,本地重新编译后点上传会静默刷入旧固件 */
var cachedAt = file.lastModified
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
: '未知时间';
addLog('W', '文件', '热重载不可用(无 FSAPI 句柄):本次上传缓存文件 ' + file.name +
'(磁盘修改于 ' + cachedAt + ')。如需自动读取最新编译产物,请点击上传区重新选择');
showToast('热重载不可用,上传的是缓存文件', 'warning');
try {
await file.arrayBuffer();
} catch (e) {
+3691
View File
File diff suppressed because it is too large Load Diff