feat(main): 添加启动模块自检与汇总功能
- 新增 SystemBoot 类用于跟踪各模块初始化状态,提供位图标记与报告功能 - 在 main.cpp 中重构初始化流程,按顺序初始化各模块并记录状态 - 添加 system_boot.cpp 模块文件到构建配置 - CAN反馈任务增加冷启动延迟,避免与GD32初始化窗口冲突 refactor(ota): 优化OTA协调器线程安全机制 - 修改 lock()/unlock() 方法返回take结果,确保mutex正确释放 - 修复多任务并发时mutex死锁问题,避免vTaskPriorityDisinherit断言 - 统一finalize失败后的状态复位逻辑,确保协调器回到IDLE状态 - 优化忙状态超时检测与自愈机制 perf(boot): 优化冷启动时序与资源管理 - 简化README中冷启动优化说明,聚焦核心逻辑 - 优化BOD阈值设置为LVL5,改进电源稳定性 - 增加BLE OTA工作线程优先级,减少丢包情况 - 优化堆告警节流机制,在OTA期间降低告警频率 fix(relay): 改进GD32应用存活检测逻辑 - 调整GD32 APP存活验证时序,增加会话建立验证 - 修复HELLO建立session后未验证role的问题 - 优化FINISH后等待APP启动的静默时间与重试机制 - 统一GD32探测逻辑到OTA中继任务 refactor(server): 优化HTTP服务端内存与性能 - 使用静态缓冲区替代动态分配,减少堆碎片化风险 - 优化CAN批量发送期间暂停反馈任务的机制 - 调整BLE连接参数请求逻辑,提升传输性能 - 改进错误处理流程,避免堆紧张时的额外分配 bump(version): 更新固件版本至0.3.50 - 更新版本号从0.3.49到0.3.50 - 更新编译时间戳与Git哈希值
This commit is contained in:
@@ -291,7 +291,7 @@ ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器)
|
||||
| IP 分配 | DHCP(可切换静态 IP 192.168.31.32)|
|
||||
| 国家码 | CN(允许信道 1-13)|
|
||||
|
||||
**冷启动优化**: 复位原因为 POWERON/BROWNOUT 时:电源轨稳定等待 + 仅清 WiFi 连接缓存;**仅 BROWNOUT 后**才清 PHY 强制重校准(避免每次上电全量 RF 校准与 GD32 抢电流导致起不来、需再插拔)。`wifi_start` 前另有短延迟。BOD 阈值略放宽(LVL5)。
|
||||
**冷启动**: POWERON/BROWNOUT 时做短延时后初始化;清 WiFi 连接缓存;欠压复位后清 PHY 重校准;BOD 档位 LVL5。
|
||||
|
||||
### mDNS 发现
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"core/main.cpp" # 程序入口 (app_main)
|
||||
"core/system_boot.cpp" # 启动模块位图与汇总
|
||||
"ota/esp32_ota_core.cpp" # ESP32 本机 OTA 核心(分区写入/SHA验证)
|
||||
"ota/ota_coordinator.cpp" # OTA 多通道协调器(状态机/互斥)
|
||||
"relay/ota_relay.cpp" # GD32 SPI OTA 中继状态机
|
||||
|
||||
+81
-74
@@ -1,17 +1,27 @@
|
||||
/*
|
||||
* ESP32-C3FH4 OTA 中继 - 程序入口
|
||||
* FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT / CAN 反馈
|
||||
*
|
||||
* 使用各模块单例初始化并创建任务;入口只做装配,不实现协议。
|
||||
* 初始化顺序(每位成功后 SystemBoot::mark=1,末尾统一 report):
|
||||
* 1. 冷启动延时
|
||||
* 2. netif / event_loop / debug_log
|
||||
* 3. wifi earlyInit
|
||||
* 4. health_mon + task
|
||||
* 5. cpu_stats(可选)
|
||||
* 6. protocol / ota_coord
|
||||
* 7. spi init + L0 本机自检 → mark spi;HELLO 对端轻检 → mark can_link(非关键)
|
||||
* 8. ota_relay / wifi_ota / ble_ota 任务
|
||||
* 9. can_fb 任务(依赖 spi L0)
|
||||
* 10. ota_mark_valid(可选)
|
||||
* 11. SystemBoot::report()
|
||||
*/
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_chip_info.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_ota_ops.h"
|
||||
|
||||
#include "system_boot.h"
|
||||
#include "monitor/health_monitor.h"
|
||||
#include "monitor/cpu_stats.h"
|
||||
#include "relay/ota_relay.h"
|
||||
@@ -25,31 +35,21 @@
|
||||
#include "ota/esp32_ota_core.h"
|
||||
#include "monitor/can_feedback_task.h"
|
||||
|
||||
/**
|
||||
* @brief 创建关键任务;失败则打 ERR 并 ESP_ERROR_CHECK 触发重启。
|
||||
*/
|
||||
static void createCriticalTask(TaskFunction_t entry, const char *name,
|
||||
/** @brief 创建关键任务;成功返回 true。 */
|
||||
static bool createCriticalTask(TaskFunction_t entry, const char *name,
|
||||
uint32_t stack, UBaseType_t prio)
|
||||
{
|
||||
BaseType_t xRet = xTaskCreatePinnedToCore(
|
||||
entry, name, stack, NULL, prio, NULL, 0);
|
||||
if (xRet != pdPASS) {
|
||||
DEBUG_ERR("MAIN", "创建关键任务 %s 失败", name);
|
||||
ESP_ERROR_CHECK(ESP_FAIL);
|
||||
}
|
||||
DEBUG_INFO("MAIN", "任务已创建: %s (prio=%u stack=%lu)",
|
||||
name, (unsigned)prio, (unsigned long)stack);
|
||||
return xTaskCreatePinnedToCore(entry, name, stack, NULL, prio, NULL, 0) == pdPASS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 程序主入口:初始化组件并创建 FreeRTOS 任务。
|
||||
* @brief 程序主入口:按模块初始化并汇总结果。
|
||||
*/
|
||||
extern "C" void app_main(void)
|
||||
{
|
||||
esp_chip_info_t chip_info;
|
||||
esp_chip_info(&chip_info);
|
||||
SystemBoot::reset();
|
||||
|
||||
/* 上电/欠压复位:先等电源与 GD32 侧负载稳定,再进 WiFi/SPI 重活 */
|
||||
/* 上电/欠压复位后短延时再初始化子系统 */
|
||||
{
|
||||
esp_reset_reason_t rr = esp_reset_reason();
|
||||
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
|
||||
@@ -59,79 +59,86 @@ extern "C" void app_main(void)
|
||||
|
||||
Esp32OtaCore::initMutex();
|
||||
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
if (esp_netif_init() == ESP_OK) {
|
||||
SystemBoot::mark(BOOT_NETIF);
|
||||
}
|
||||
|
||||
if (esp_event_loop_create_default() == ESP_OK) {
|
||||
SystemBoot::mark(BOOT_EVENT_LOOP);
|
||||
}
|
||||
|
||||
DebugLog::instance().init();
|
||||
SystemBoot::mark(BOOT_DEBUG_LOG);
|
||||
|
||||
/* earlyInit:true=已启动 STA 连接流程;false=将走 AP(或 CONFIG 直选 AP) */
|
||||
/* earlyInit:true=已启动 STA 连接流程;false=将走 AP */
|
||||
const bool wifi_early_started_sta = WifiOta::instance().earlyInit();
|
||||
SystemBoot::mark(BOOT_WIFI_EARLY);
|
||||
|
||||
HealthMonitor::instance().init();
|
||||
DEBUG_INFO("MAIN", "健康监控状态已初始化");
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动", FW_VERSION);
|
||||
DEBUG_INFO("MAIN", "Chip: %s, Rev %d", CONFIG_IDF_TARGET, chip_info.revision);
|
||||
DEBUG_INFO("MAIN", "Free heap: %lu bytes", (unsigned long)esp_get_free_heap_size());
|
||||
DEBUG_INFO("MAIN", "CPU Freq: %d MHz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ);
|
||||
DEBUG_INFO("MAIN", "WiFi early: %s(最终 STA/AP 由 wifi_ota 任务确认)",
|
||||
wifi_early_started_sta ? "STA 连接中" : "非 STA 优先/将切 AP");
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
SystemBoot::mark(BOOT_HEALTH_MON);
|
||||
|
||||
/* 健康监控:温度/RSSI;缺失则运维盲区 → 关键 */
|
||||
createCriticalTask(HealthMonitor::taskEntry, "health_mon",
|
||||
TASK_STACK_HEALTH_MON, configMAX_PRIORITIES - 4);
|
||||
if (createCriticalTask(HealthMonitor::taskEntry, "health_mon",
|
||||
TASK_STACK_HEALTH_MON, configMAX_PRIORITIES - 4)) {
|
||||
SystemBoot::mark(BOOT_HEALTH_TASK);
|
||||
}
|
||||
|
||||
/* CPU 1Hz 采样:非致命 */
|
||||
CpuStats::instance().start();
|
||||
SystemBoot::mark(BOOT_CPU_STATS);
|
||||
|
||||
Protocol::instance().init();
|
||||
DEBUG_INFO("MAIN", "协议层初始化完成");
|
||||
SystemBoot::mark(BOOT_PROTOCOL);
|
||||
|
||||
OtaCoordinator::instance().init();
|
||||
OtaCoordinator::instance().setBleCoexListener(&WifiOta::instance());
|
||||
DEBUG_INFO("MAIN", "OTA 全局协调器已初始化");
|
||||
SystemBoot::mark(BOOT_OTA_COORD);
|
||||
|
||||
/* SPI 是中继/CAN 反馈根基:失败则无法完成核心职责,按关键失败处理 */
|
||||
bool spi_l0_ok = false;
|
||||
{
|
||||
esp_err_t ret = SpiMaster::instance().init();
|
||||
if (ret != ESP_OK) {
|
||||
DEBUG_ERR("MAIN", "SPI 总线初始化失败: %s", esp_err_to_name(ret));
|
||||
ESP_ERROR_CHECK(ret);
|
||||
}
|
||||
DEBUG_INFO("MAIN", "SPI 总线初始化完成");
|
||||
}
|
||||
|
||||
createCriticalTask(OtaRelay::taskEntry, "ota_relay",
|
||||
TASK_STACK_OTA_RELAY, configMAX_PRIORITIES - 2);
|
||||
|
||||
createCriticalTask(WifiOta::taskEntry, "wifi_ota",
|
||||
TASK_STACK_WIFI_OTA, configMAX_PRIORITIES - 3);
|
||||
|
||||
createCriticalTask(BleOta::taskEntry, "ble_ota",
|
||||
TASK_STACK_BLE_OTA, configMAX_PRIORITIES - 3);
|
||||
|
||||
if (startCanFeedbackTask()) {
|
||||
DEBUG_INFO("MAIN", "CAN 反馈轮询任务已创建");
|
||||
} else {
|
||||
DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)");
|
||||
}
|
||||
|
||||
/* 任务就绪后尽快标记 app 有效,避免后续探测拖太久导致回滚窗口拉长 */
|
||||
{
|
||||
esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback();
|
||||
if (ota_valid_err == ESP_OK) {
|
||||
DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效");
|
||||
} else {
|
||||
DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动",
|
||||
esp_err_to_name(ota_valid_err));
|
||||
if (ret == ESP_OK && SpiMaster::instance().selfTestL0()) {
|
||||
SystemBoot::mark(BOOT_SPI);
|
||||
spi_l0_ok = true;
|
||||
/* 对端轻检:GD32 可能仍在启动延时,失败不拉垮关键启动 */
|
||||
if (SpiMaster::instance().selfTestPeerL0()) {
|
||||
SystemBoot::mark(BOOT_CAN_LINK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中");
|
||||
DEBUG_INFO("MAIN", "GD32 版本探测由 ota_relay 任务负责(hello+probe)");
|
||||
DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)",
|
||||
DEBUG_LOG_IP, DEBUG_LOG_PORT);
|
||||
DEBUG_INFO("MAIN", "========================================");
|
||||
if (createCriticalTask(OtaRelay::taskEntry, "ota_relay",
|
||||
TASK_STACK_OTA_RELAY, configMAX_PRIORITIES - 2)) {
|
||||
SystemBoot::mark(BOOT_OTA_RELAY_TASK);
|
||||
}
|
||||
|
||||
if (createCriticalTask(WifiOta::taskEntry, "wifi_ota",
|
||||
TASK_STACK_WIFI_OTA, configMAX_PRIORITIES - 3)) {
|
||||
SystemBoot::mark(BOOT_WIFI_TASK);
|
||||
}
|
||||
|
||||
if (createCriticalTask(BleOta::taskEntry, "ble_ota",
|
||||
TASK_STACK_BLE_OTA, configMAX_PRIORITIES - 3)) {
|
||||
SystemBoot::mark(BOOT_BLE_TASK);
|
||||
}
|
||||
|
||||
/* CAN 反馈依赖 SPI 本机 L0 */
|
||||
if (spi_l0_ok && startCanFeedbackTask()) {
|
||||
SystemBoot::mark(BOOT_CAN_FB_TASK);
|
||||
}
|
||||
|
||||
if (esp_ota_mark_app_valid_cancel_rollback() == ESP_OK) {
|
||||
SystemBoot::mark(BOOT_OTA_VALID);
|
||||
}
|
||||
|
||||
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s, heap=%lu, wifi_early=%s",
|
||||
FW_VERSION,
|
||||
(unsigned long)esp_get_free_heap_size(),
|
||||
wifi_early_started_sta ? "STA" : "AP/other");
|
||||
|
||||
const bool critical_ok = SystemBoot::report();
|
||||
|
||||
if (!critical_ok) {
|
||||
DEBUG_ERR("MAIN", "critical init FAIL, restart in 3s");
|
||||
vTaskDelay(pdMS_TO_TICKS(3000));
|
||||
esp_restart();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @file system_boot.cpp
|
||||
* @brief 启动模块位图实现。
|
||||
*/
|
||||
#include "system_boot.h"
|
||||
#include "monitor/debug_log.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
struct BootEntry {
|
||||
uint32_t bit;
|
||||
const char *name;
|
||||
bool critical;
|
||||
};
|
||||
|
||||
static const BootEntry kEntries[] = {
|
||||
{ BOOT_NETIF, "netif", true },
|
||||
{ BOOT_EVENT_LOOP, "event_loop", true },
|
||||
{ BOOT_DEBUG_LOG, "debug_log", true },
|
||||
{ BOOT_WIFI_EARLY, "wifi_early", true },
|
||||
{ BOOT_HEALTH_MON, "health_mon", true },
|
||||
{ BOOT_HEALTH_TASK, "health_task", true },
|
||||
{ BOOT_CPU_STATS, "cpu_stats", false },
|
||||
{ BOOT_PROTOCOL, "protocol", true },
|
||||
{ BOOT_OTA_COORD, "ota_coord", true },
|
||||
{ BOOT_SPI, "spi", true },
|
||||
{ BOOT_OTA_RELAY_TASK, "ota_relay_task", true },
|
||||
{ BOOT_WIFI_TASK, "wifi_task", true },
|
||||
{ BOOT_BLE_TASK, "ble_task", true },
|
||||
{ BOOT_CAN_LINK, "can_link", false },
|
||||
{ BOOT_CAN_FB_TASK, "can_fb_task", false },
|
||||
{ BOOT_OTA_VALID, "ota_mark_valid", false },
|
||||
};
|
||||
|
||||
static uint32_t s_mask = 0U;
|
||||
|
||||
static void evaluate(bool *critical_ok, bool *all_ok, char *fail_list, size_t fail_cap)
|
||||
{
|
||||
*critical_ok = true;
|
||||
*all_ok = true;
|
||||
if (fail_list && fail_cap > 0U) {
|
||||
fail_list[0] = '\0';
|
||||
}
|
||||
size_t fail_len = 0U;
|
||||
|
||||
for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) {
|
||||
const BootEntry &e = kEntries[i];
|
||||
const bool ok = (s_mask & e.bit) != 0U;
|
||||
if (ok) {
|
||||
continue;
|
||||
}
|
||||
*all_ok = false;
|
||||
if (e.critical) {
|
||||
*critical_ok = false;
|
||||
}
|
||||
if (!fail_list || fail_cap == 0U) {
|
||||
continue;
|
||||
}
|
||||
if (fail_len + 2U < fail_cap) {
|
||||
if (fail_len > 0U) {
|
||||
fail_list[fail_len++] = ',';
|
||||
}
|
||||
const char *p = e.name;
|
||||
while (*p != '\0' && fail_len + 1U < fail_cap) {
|
||||
fail_list[fail_len++] = *p++;
|
||||
}
|
||||
fail_list[fail_len] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SystemBoot::reset()
|
||||
{
|
||||
s_mask = 0U;
|
||||
}
|
||||
|
||||
void SystemBoot::mark(SystemBootBit bit)
|
||||
{
|
||||
s_mask |= static_cast<uint32_t>(bit);
|
||||
}
|
||||
|
||||
bool SystemBoot::report()
|
||||
{
|
||||
bool critical_ok = true;
|
||||
bool all_ok = true;
|
||||
char fail_list[192] = {0};
|
||||
evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list));
|
||||
|
||||
if (critical_ok && all_ok) {
|
||||
DEBUG_INFO("BOOT", "init: OK (all modules)");
|
||||
} else if (critical_ok) {
|
||||
DEBUG_WARN("BOOT", "init: OK (critical), skipped/failed: %s",
|
||||
fail_list[0] != '\0' ? fail_list : "?");
|
||||
} else {
|
||||
DEBUG_ERR("BOOT", "init: FAIL, modules: %s",
|
||||
fail_list[0] != '\0' ? fail_list : "?");
|
||||
}
|
||||
return critical_ok;
|
||||
}
|
||||
|
||||
int SystemBoot::appendStatusJson(char *buf, size_t cap, int offset)
|
||||
{
|
||||
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) {
|
||||
return offset;
|
||||
}
|
||||
|
||||
bool critical_ok = true;
|
||||
bool all_ok = true;
|
||||
char fail_list[192] = {0};
|
||||
evaluate(&critical_ok, &all_ok, fail_list, sizeof(fail_list));
|
||||
|
||||
const char *summary;
|
||||
if (critical_ok && all_ok) {
|
||||
summary = "OK (all modules)";
|
||||
} else if (critical_ok) {
|
||||
summary = "OK (critical)";
|
||||
} else {
|
||||
summary = "FAIL";
|
||||
}
|
||||
|
||||
int n = snprintf(buf + offset, cap - (size_t)offset,
|
||||
",\"boot\":{\"critical_ok\":%s,\"all_ok\":%s,\"mask\":%lu,"
|
||||
"\"summary\":\"%s\",\"failed\":\"%s\",\"modules\":[",
|
||||
critical_ok ? "true" : "false",
|
||||
all_ok ? "true" : "false",
|
||||
(unsigned long)s_mask,
|
||||
summary,
|
||||
fail_list);
|
||||
if (n < 0) {
|
||||
return offset;
|
||||
}
|
||||
if (offset + n >= (int)cap) {
|
||||
buf[cap - 1] = '\0';
|
||||
return (int)(cap - 1);
|
||||
}
|
||||
offset += n;
|
||||
|
||||
for (size_t i = 0; i < sizeof(kEntries) / sizeof(kEntries[0]); ++i) {
|
||||
const BootEntry &e = kEntries[i];
|
||||
const bool ok = (s_mask & e.bit) != 0U;
|
||||
n = snprintf(buf + offset, cap - (size_t)offset,
|
||||
"%s{\"name\":\"%s\",\"ok\":%s,\"critical\":%s}",
|
||||
i == 0 ? "" : ",",
|
||||
e.name,
|
||||
ok ? "true" : "false",
|
||||
e.critical ? "true" : "false");
|
||||
if (n < 0) {
|
||||
return offset;
|
||||
}
|
||||
if (offset + n >= (int)cap) {
|
||||
buf[cap - 1] = '\0';
|
||||
return (int)(cap - 1);
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
|
||||
n = snprintf(buf + offset, cap - (size_t)offset, "]}");
|
||||
if (n < 0) {
|
||||
return offset;
|
||||
}
|
||||
if (offset + n >= (int)cap) {
|
||||
buf[cap - 1] = '\0';
|
||||
return (int)(cap - 1);
|
||||
}
|
||||
return offset + n;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file system_boot.h
|
||||
* @brief ESP32 启动阶段模块初始化位图与汇总报告。
|
||||
*
|
||||
* 每个模块 init/建任务成功后 mark(bit)=1,启动末尾 report() 打印整体结果。
|
||||
* /system/status 通过 appendStatusJson() 把同一份结果暴露给网页。
|
||||
*/
|
||||
#ifndef SYSTEM_BOOT_H
|
||||
#define SYSTEM_BOOT_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/** 启动模块位(成功置 1) */
|
||||
enum SystemBootBit : uint32_t {
|
||||
BOOT_NETIF = 1u << 0,
|
||||
BOOT_EVENT_LOOP = 1u << 1,
|
||||
BOOT_DEBUG_LOG = 1u << 2,
|
||||
BOOT_WIFI_EARLY = 1u << 3,
|
||||
BOOT_HEALTH_MON = 1u << 4,
|
||||
BOOT_HEALTH_TASK = 1u << 5,
|
||||
BOOT_CPU_STATS = 1u << 6,
|
||||
BOOT_PROTOCOL = 1u << 7,
|
||||
BOOT_OTA_COORD = 1u << 8,
|
||||
BOOT_SPI = 1u << 9, ///< SPI 主机 L0(本机就绪)
|
||||
BOOT_OTA_RELAY_TASK = 1u << 10,
|
||||
BOOT_WIFI_TASK = 1u << 11,
|
||||
BOOT_BLE_TASK = 1u << 12,
|
||||
BOOT_CAN_LINK = 1u << 13, ///< SPI 对端 L0(HELLO 可达 GD32,非关键)
|
||||
BOOT_CAN_FB_TASK = 1u << 14, ///< CAN 反馈任务(依赖 SPI L0)
|
||||
BOOT_OTA_VALID = 1u << 15,
|
||||
};
|
||||
|
||||
class SystemBoot {
|
||||
public:
|
||||
static void reset();
|
||||
static void mark(SystemBootBit bit);
|
||||
|
||||
/** 串口汇总;返回关键模块是否全部成功 */
|
||||
static bool report();
|
||||
|
||||
/**
|
||||
* @brief 追加 boot JSON 字段(含前导逗号)到 status 缓冲。
|
||||
* @return 新的写入偏移
|
||||
*/
|
||||
static int appendStatusJson(char *buf, size_t cap, int offset);
|
||||
};
|
||||
|
||||
#endif /* SYSTEM_BOOT_H */
|
||||
@@ -56,7 +56,7 @@ inline constexpr uint32_t TASK_STACK_WIFI_OTA = 4096; ///< wifi_ota 主循
|
||||
inline constexpr uint32_t TASK_STACK_OTA_RELAY = 2560; ///< ota_relay:2.5KB(轻载 peak~1KB;转发时看 /system/status.task_stack)
|
||||
inline constexpr uint32_t TASK_STACK_SPI_WORKER = 5120; ///< spi_worker:5KB(帧缓冲热路径;已纳入 task_stack 巡检)
|
||||
inline constexpr uint32_t TASK_STACK_BLE_OTA = 3072; ///< ble_ota 主任务:peak~1KB
|
||||
inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker:peak~1.1KB(负载峰值曾~1.9KB)
|
||||
inline constexpr uint32_t TASK_STACK_BLE_WORKER = 3072; ///< ble_ota_worker
|
||||
inline constexpr uint32_t TASK_STACK_HEALTH_MON = 3072; ///< health_mon:peak~1.2KB
|
||||
inline constexpr uint32_t TASK_STACK_CPU_STATS = 3072; ///< cpu_stats:peak~2KB
|
||||
inline constexpr uint32_t TASK_STACK_WIFI_RECONNECT = 2560; ///< wifi_reconnect:peak~1KB
|
||||
@@ -134,7 +134,7 @@ inline constexpr size_t BLE_OTA_ACK_WINDOW = 32;
|
||||
// Reorder buffer 窗口大小(吸收 BLE 窗口内乱序到达的包,须 >= BLE_OTA_ACK_WINDOW)
|
||||
inline constexpr size_t BLE_OTA_REORDER_WINDOW = 32;
|
||||
// 数据队列深度:略大于窗口,seq=0 预擦期间多扛几批
|
||||
inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 8);
|
||||
inline constexpr size_t BLE_OTA_DATA_QUEUE_LEN = (BLE_OTA_ACK_WINDOW * 2 + 16);
|
||||
|
||||
// ============================================================
|
||||
// 安全常量(需与 GD32 固件团队确认)
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 3
|
||||
#define FW_VERSION_PATCH 49
|
||||
#define FW_VERSION_STRING "0.3.49"
|
||||
#define FW_VERSION_PATCH 50
|
||||
#define FW_VERSION_STRING "0.3.50"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-07-17 00:10:21"
|
||||
#define BUILD_TIME "2026-07-17 02:02:16"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "5db3f66"
|
||||
#define GIT_HASH "6364eac"
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "transport/spi_master.h"
|
||||
#include "monitor/debug_log.h"
|
||||
#include "monitor/can_feedback_task.h"
|
||||
#include "esp_system.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
@@ -56,6 +57,17 @@ static void taskLoop(void *)
|
||||
{
|
||||
DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)");
|
||||
|
||||
/* 冷启动延后开始监听,错开上电初始化窗口 */
|
||||
{
|
||||
esp_reset_reason_t rr = esp_reset_reason();
|
||||
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
|
||||
constexpr uint32_t kCanFbDeferMs = 1500U;
|
||||
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始 CAN 反馈监听",
|
||||
(unsigned long)kCanFbDeferMs);
|
||||
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t ring_type = 0U;
|
||||
uint8_t ring_payload[kRingPayloadMax] = {};
|
||||
size_t ring_len = 0U;
|
||||
|
||||
@@ -220,14 +220,12 @@ void HealthMonitor::taskEntry(void *pvParameters)
|
||||
|
||||
uint32_t free_heap = esp_get_free_heap_size();
|
||||
if (free_heap < HEAP_WARNING_MIN) {
|
||||
/* WiFi OTA 收流时 TCP 窗口 + httpd 会把 free 压到 5~10KB,属预期,勿当故障刷屏。 */
|
||||
/* 堆告警节流:OTA 期间阈值更低、间隔更长 */
|
||||
static TickType_t s_last_heap_warn = 0;
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
const bool ota_busy = !OtaCoordinator::instance().isIdle();
|
||||
/* 堆越紧越要少打日志(日志本身还要占堆/UDP) */
|
||||
const uint32_t interval_ms = ota_busy ? 15000U
|
||||
: (free_heap < 8192U ? 10000U : 3000U);
|
||||
/* OTA 中只在 free 极低(<4KB)时告警;空闲时维持 10KB 阈值 */
|
||||
const uint32_t warn_floor = ota_busy ? 4096U : (uint32_t)HEAP_WARNING_MIN;
|
||||
if (free_heap < warn_floor &&
|
||||
(now - s_last_heap_warn) >= pdMS_TO_TICKS(interval_ms)) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* esp32_ota_core.cpp - ESP32 本机 OTA 核心流程实现(WiFi / BLE 通道共用)
|
||||
* esp32_ota_core.cpp - ESP32 本机 OTA 核心流程(WiFi / BLE 通道共用)
|
||||
*
|
||||
* 设计依据 WiFi 原流程(wifi_ota.cpp 旧实现),修复已知 bug:
|
||||
* - 旧 wifi_ota.cpp:625 在 esp_ota_end 成功后又调 esp_ota_abort → 用 m_ended 标志避免
|
||||
* - SHA 统一从分区读回计算,不在传输过程中做增量 SHA
|
||||
* SHA-256 在写入完成后从 OTA 分区读回计算;finalize 用 m_ended 区分 end 与 abort。
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
|
||||
@@ -49,10 +49,7 @@ OtaCoordinator& OtaCoordinator::instance()
|
||||
// ============================================================
|
||||
bool OtaCoordinator::lock() const
|
||||
{
|
||||
// ★ 修复: 返回 take 结果,由调用方传回 unlock()。
|
||||
// 旧实现用成员 bool m_locked 记录 take 结果,多任务并发 lock() 时
|
||||
// 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give,
|
||||
// mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。
|
||||
/* 返回 take 是否成功;调用方须把该值传给 unlock() */
|
||||
if (!m_mutex) return false;
|
||||
bool ok = (xSemaphoreTake(m_mutex, kLockTimeout) == pdTRUE);
|
||||
if (!ok) {
|
||||
@@ -64,7 +61,7 @@ bool OtaCoordinator::lock() const
|
||||
|
||||
void OtaCoordinator::unlock(bool handle) const
|
||||
{
|
||||
// ★ 修复: 仅在对应的 lock() 成功 take 时才 give。
|
||||
/* 仅当对应 lock() 成功时 give */
|
||||
if (handle && m_mutex) {
|
||||
xSemaphoreGive(m_mutex);
|
||||
}
|
||||
@@ -97,11 +94,7 @@ void OtaCoordinator::watchdogCheckLocked()
|
||||
return; // 未超时
|
||||
}
|
||||
|
||||
// ★ 超时自愈:完整中止到 IDLE(释放 Esp32OtaCore / 中继 / GD32 meta 等)。
|
||||
// 旧逻辑只标 ERROR 不清理子系统:若卡在 FINALIZING 且 core 已半释放,
|
||||
// 前端仍长时间判“占用”;若 core 仍持互斥,ERROR 后 begin 还要靠 forceCleanup。
|
||||
// REBOOTING 本应已 esp_restart;超时则同样中止,避免永久卡死。
|
||||
DEBUG_ERR(TAG, "忙状态 %d 卡死超过 %" PRIu32 " ms,强制中止 → IDLE",
|
||||
DEBUG_ERR(TAG, "忙状态 %d 超过 %" PRIu32 " ms,强制中止 → IDLE",
|
||||
(int)m_sysState, (uint32_t)pdTICKS_TO_MS(kBusyWatchdogTicks));
|
||||
doAbortLocked();
|
||||
}
|
||||
@@ -580,9 +573,7 @@ Esp32FinalizeResult OtaCoordinator::finalizeForReboot(const char *frontend_sha_h
|
||||
if (r.err == ESP_OK) {
|
||||
notifyEsp32FinalizeComplete();
|
||||
} else {
|
||||
// ★ SHA 不匹配 / end 失败:Esp32OtaCore 已 abort+释放互斥,但协调器若仍停在
|
||||
// ESP32_FINALIZING,WiFi 前端会一直被“设备 OTA 占用”拒绝(BLE 路径曾单独
|
||||
// 补 abortCurrent;统一放到这里,WiFi/BLE 共用)。
|
||||
/* finalize 失败:复位协调器到 IDLE */
|
||||
DEBUG_ERR(TAG, "ESP32 finalize 失败: %s,复位协调器 → IDLE",
|
||||
esp_err_to_name(r.err));
|
||||
abortCurrent();
|
||||
@@ -758,17 +749,12 @@ OtaSnapshot OtaCoordinator::getSnapshot() const
|
||||
{
|
||||
OtaSnapshot snap{};
|
||||
|
||||
// ── coordinator 自身状态(一次 mutex,避免二次加锁导致快照不一致)──
|
||||
// 持锁时一次性取 m_sysState,并调不加锁的 stateToString()
|
||||
// 就地转字符串。旧实现释放锁后再调 stateToString(getSystemState()) 二次加锁,
|
||||
// 两次取值之间状态可能变化,导致 snap.sys_state 与 snap.sys_state_str
|
||||
// 描述不同时刻状态。
|
||||
/* 持锁一次读取状态并生成字符串,保证快照字段一致 */
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
snap.sys_state = m_sysState;
|
||||
snap.channel = m_otaChannel;
|
||||
snap.storage_done = m_gd32StorageDone;
|
||||
// 就地把状态枚举转字符串,与 sys_state 来自同一时刻
|
||||
snap.sys_state_str = stateToString(snap.sys_state);
|
||||
unlock(h);
|
||||
} else {
|
||||
|
||||
@@ -469,20 +469,14 @@ private:
|
||||
OtaCoordinator(const OtaCoordinator&) = delete;
|
||||
OtaCoordinator& operator=(const OtaCoordinator&) = delete;
|
||||
|
||||
// 线程安全辅助
|
||||
// ★ lock() 返回是否成功 take;unlock(handle) 据此决定是否 give。
|
||||
// 旧实现用共享 bool m_locked 记录 take 结果,多任务并发 lock() 时
|
||||
// 超时方会把 m_locked 置 false,覆盖持锁方的 true,导致 unlock() 跳过 give,
|
||||
// mutex 永久死锁 → 后续 take 超时触发 vTaskPriorityDisinherit assert → PANIC。
|
||||
// 线程安全:lock() 返回 take 是否成功;unlock(handle) 仅在成功时 give
|
||||
bool lock() const;
|
||||
void unlock(bool handle) const;
|
||||
|
||||
// ★ 卡死自愈检查:忙状态超时则 doAbortLocked → IDLE(不含 REBOOTING)。
|
||||
// 必须在已持锁状态下调用。
|
||||
// 忙状态超时检查(须已持锁);超时则 doAbortLocked → IDLE
|
||||
void watchdogCheckLocked();
|
||||
|
||||
// ★ 已持锁版本的中止序列:根据当前 m_sysState 调用对应子模块的清理,
|
||||
// 并把状态机复位为 IDLE。abortCurrent / abortCurrentIfEpoch 共享此实现。
|
||||
// 已持锁中止:清理子系统并将状态复位为 IDLE
|
||||
void doAbortLocked();
|
||||
|
||||
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
|
||||
|
||||
+12
-14
@@ -1445,26 +1445,22 @@ ota_result_t OtaRelay::doSendFinishOta()
|
||||
return OTA_RESULT_OK;
|
||||
}
|
||||
|
||||
/** @brief 协议子步骤:轮询等待 GD32 APP 存活(10 次重试)。 */
|
||||
/** @brief 协议子步骤:轮询等待 GD32 APP 存活。 */
|
||||
ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
|
||||
{
|
||||
// ★ APP 存活验证
|
||||
setGd32AppAlive(false);
|
||||
|
||||
// GD32 刚跳转到新 APP,SPI v2 session_id 已重置为 0。
|
||||
// 必须先 hello() 建立新 session,否则 appPing 的帧被 otaFindFrame
|
||||
// 以 SESSION mismatch 拒绝(旧 session_id vs 新 APP session_id=0)。
|
||||
bool session_established = false;
|
||||
/* FINISH 后短暂安静,避免 jump/APP SPI arm 窗口被 HELLO 打乱(对齐既有 300ms 节拍)。 */
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
|
||||
/* APP 完整初始化(含 EthercatSlave::init)需 5-6 秒,偶发 >10 秒。
|
||||
* 增加重试次数到 20、缩短延迟到 300ms,总窗口 ~26 秒,
|
||||
* APP 启动后最快 300ms 内检测到(原 500ms)。 */
|
||||
/* APP 起机约数秒;HELLO 须 role=APP 再建 session,再 appPing。 */
|
||||
bool session_established = false;
|
||||
for (uint32_t attempt = 0; attempt < 20; ++attempt) {
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
|
||||
if (!session_established) {
|
||||
esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP);
|
||||
if (hr == ESP_OK) {
|
||||
if (hr == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
|
||||
session_established = true;
|
||||
} else {
|
||||
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
|
||||
@@ -1475,6 +1471,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
|
||||
if (probeAndCacheAppAlive(timeout_ms)) {
|
||||
break;
|
||||
}
|
||||
/* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */
|
||||
session_established = false;
|
||||
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
|
||||
}
|
||||
|
||||
@@ -1484,7 +1482,6 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
|
||||
return OTA_RESULT_ERR_GD32;
|
||||
}
|
||||
|
||||
// 全流程唯一成功总结(版本来自 probe 写入的快照)
|
||||
gd32_version_t ver = getSnapshot().version;
|
||||
DEBUG_INFO("OTA", "GD32 OTA 完成: APP=v%d.%d.%d.%d, BOOT=v%d.%d.%d.%d",
|
||||
ver.app_major, ver.app_minor, ver.app_patch, ver.app_build,
|
||||
@@ -1531,8 +1528,7 @@ void OtaRelay::taskEntry(void *pvParameters)
|
||||
return;
|
||||
}
|
||||
|
||||
/* 启动 GD32 探测(原 main.cpp 与此处双份;现统一在本任务):
|
||||
* APP 完整 init 约 5–6s;先 hello 建 v2 session,再 appPing。 */
|
||||
/* 启动 GD32 探测:先 hello 建 v2 session,再 appPing。 */
|
||||
{
|
||||
DEBUG_INFO("OTA", "启动初始 GD32 探测 (hello+probe)...");
|
||||
bool session_ok = false;
|
||||
@@ -1540,7 +1536,8 @@ void OtaRelay::taskEntry(void *pvParameters)
|
||||
for (uint32_t attempt = 0; attempt < 20U; ++attempt) {
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
if (!session_ok) {
|
||||
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK) {
|
||||
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
|
||||
SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
|
||||
session_ok = true;
|
||||
} else {
|
||||
DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U);
|
||||
@@ -1551,6 +1548,7 @@ void OtaRelay::taskEntry(void *pvParameters)
|
||||
alive = true;
|
||||
break;
|
||||
}
|
||||
session_ok = false;
|
||||
DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/20)", attempt + 1U);
|
||||
}
|
||||
if (!alive) {
|
||||
|
||||
+20
-16
@@ -238,13 +238,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WiFi 共存策略:BLE OTA 传输期间的处理
|
||||
//
|
||||
// 不切换 WiFi 省电模式(保持 WIFI_PS_NONE),让 STA 全程可用,
|
||||
// 协调器照常以 403 拒绝并发 OTA。ESP32-C3 coex 默认调度已能保证 BLE OTA 可用。
|
||||
// (曾尝试切到 WIFI_PS_MAX_MODEM 让出空中时间,但 STA 监听间隔被拉到极大,
|
||||
// HTTP server 的 TCP 握手/数据包大量丢失或超时,前端 XHR 长时间无响应。)
|
||||
// 仍保留 setBleOtaActive() 暂停 RSSI 轮询(避免 get_ap_info 的射频活动干扰 BLE)。
|
||||
// BLE OTA 期间 WiFi 共存:保持 WIFI_PS_NONE;暂停 RSSI 轮询
|
||||
// ============================================================
|
||||
static bool s_wifi_ps_boosted = false;
|
||||
|
||||
@@ -365,24 +359,28 @@ static int ble_ota_request_conn_params(uint16_t conn_handle, bool windows_compat
|
||||
{
|
||||
struct ble_gap_upd_params params = {};
|
||||
if (windows_compatible) {
|
||||
/* 15–20 ms:多数 Windows 主机可接受 */
|
||||
params.itvl_min = 12;
|
||||
params.itvl_max = 16;
|
||||
params.min_ce_len = 0;
|
||||
params.max_ce_len = 0;
|
||||
} else {
|
||||
/* 7.5–15 ms:勿锁死 7.5,否则 Windows 常拒然后掉到更慢默认值 */
|
||||
params.itvl_min = 6;
|
||||
params.itvl_max = 6;
|
||||
params.itvl_max = 12;
|
||||
params.min_ce_len = 12;
|
||||
params.max_ce_len = 12;
|
||||
params.max_ce_len = 24;
|
||||
}
|
||||
params.latency = 0;
|
||||
params.supervision_timeout = 200;
|
||||
|
||||
int rc = ble_gap_update_params(conn_handle, ¶ms);
|
||||
DEBUG_INFO(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms rc=%d",
|
||||
DEBUG_INFO(TAG, "请求 BLE 连接参数(%s): interval=%.1f..%.1fms ce=%u..%u rc=%d",
|
||||
windows_compatible ? "Windows兼容" : "高速",
|
||||
params.itvl_min * 1.25f,
|
||||
params.itvl_max * 1.25f,
|
||||
(unsigned)params.min_ce_len,
|
||||
(unsigned)params.max_ce_len,
|
||||
rc);
|
||||
return rc;
|
||||
}
|
||||
@@ -646,6 +644,11 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size)
|
||||
esp32_reorder_alloc();
|
||||
// ★ BLE OTA 开始:降低 WiFi 射频占用
|
||||
ble_ota_boost_wifi_ps();
|
||||
/* 刷写前再要一次高速连接参数(连接时若未谈妥,此处补刀) */
|
||||
if (BleOta::instance().m_bleConnected &&
|
||||
BleOta::instance().m_connHandle != 0xFFFFU) {
|
||||
(void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -1317,6 +1320,10 @@ static bool process_gd32_packet(uint8_t cmd, uint16_t seq, uint32_t total_size,
|
||||
s_ble_gd32_meta.total_size = total_size;
|
||||
s_ble_gd32_meta_active = true;
|
||||
ble_ota_boost_wifi_ps();
|
||||
if (BleOta::instance().m_bleConnected &&
|
||||
BleOta::instance().m_connHandle != 0xFFFFU) {
|
||||
(void)ble_ota_request_conn_params(BleOta::instance().m_connHandle, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2017,11 +2024,8 @@ esp_err_t BleOta::serverStart()
|
||||
esp_err_t BleOta::suspendStack()
|
||||
{
|
||||
/*
|
||||
* ★ 软挂起 only(ESP32-C3 WiFi/BLE 共用射频)。
|
||||
* 禁止在此调用 nimble_port_stop / deinit / controller_disable:
|
||||
* - 会从 httpd 任务阻塞甚至打挂 WiFi,表现为「一点击上传就失联」;
|
||||
* - C3 上 deinit BT 控制器与 WiFi 共存状态机冲突。
|
||||
* 软挂起:停广播、断连、拒收新数据,NimBLE host 仍跑、WiFi 不受影响。
|
||||
* ESP32-C3 WiFi/BLE 共用射频:此处仅软挂起(停广播、断连、拒收新数据),
|
||||
* 不调用 nimble_port_stop / deinit / controller_disable。
|
||||
*/
|
||||
if (m_suspendedForWifiOta) {
|
||||
DEBUG_DBG(TAG, "suspendStack: 已挂起,跳过");
|
||||
@@ -2146,7 +2150,7 @@ void BleOta::taskEntry(void *pvParameters)
|
||||
"ble_ota_worker",
|
||||
TASK_STACK_BLE_WORKER, // 实测 peak~1.1KB,负载峰值曾~1.9KB
|
||||
NULL,
|
||||
10, // 中等优先级,避免被 NimBLE host task 过度抢占
|
||||
12, // 略提高:尽快排空队列,减少 withoutResponse 连发丢包
|
||||
NULL);
|
||||
if (wk_ok != pdPASS) {
|
||||
DEBUG_ERR(TAG, "OTA 数据工作线程创建失败,BLE OTA 数据传输不可用");
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "monitor/can_feedback_task.h"
|
||||
#include "monitor/task_stack_check.h"
|
||||
#include "ble_ota.h"
|
||||
#include "system_boot.h"
|
||||
|
||||
static size_t parseHexToBytes(const char *hex, uint8_t *out, size_t max_out)
|
||||
{
|
||||
@@ -641,7 +642,7 @@ esp_err_t can_feedback_handler(httpd_req_t *req)
|
||||
valid[i] = getLatestCanFeedback(i, &results[i]);
|
||||
}
|
||||
|
||||
/* ★ 修复:改用静态缓冲区,避免每次请求 malloc 8KB 加剧堆碎片化 */
|
||||
/* 静态响应缓冲,减少频繁大块堆分配 */
|
||||
static char s_feedback_json[8192];
|
||||
char *resp = s_feedback_json;
|
||||
memset(resp, 0, sizeof(s_feedback_json));
|
||||
@@ -837,17 +838,15 @@ esp_err_t can_send_handler(httpd_req_t *req)
|
||||
* @brief POST /can/send_batch — 同通道连续发送多帧,单次 HTTP 完成。
|
||||
*
|
||||
* JSON: { "ch": 0, "frames": [ { "id": n, "dlc": n, "data": "HEX" }, ... ] }
|
||||
* 返回: { "status":"ok", "results":[ { "status":"ok"|"error", ... }, ... ] }
|
||||
* 帧间 5ms;暂停 CAN 反馈,避免与批量 SPI 竞态导致偶发崩溃。
|
||||
* 返回: { "status":"ok", "results":[ ... ], 可选 skipped/abort_* }
|
||||
* 帧间间隔 kInterFrameDelayMs;批量期间暂停 CAN 反馈任务。
|
||||
*/
|
||||
esp_err_t can_send_batch_handler(httpd_req_t *req)
|
||||
{
|
||||
http_set_cors_headers(req);
|
||||
|
||||
/* 单次尽量装下整批,避免前端分块导致“一阵一阵” */
|
||||
constexpr int kMaxBody = 49152;
|
||||
constexpr int kMaxFrames = 128;
|
||||
/* 成功路径通常 <20ms;失败路径不再拖到数百 ms */
|
||||
constexpr uint32_t kPerFrameTimeoutMs = 80U;
|
||||
constexpr uint32_t kInterFrameDelayMs = 5U;
|
||||
|
||||
@@ -906,7 +905,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
|
||||
cJSON_AddStringToObject(out_root, "status", "ok");
|
||||
cJSON_AddItemToObject(out_root, "results", results);
|
||||
|
||||
/* 暂停 0xC1 反馈抢 SPI,降低 worker 竞态 / 长时间占线导致的 WDT 风险 */
|
||||
/* 批量发送期间暂停 CAN 反馈,独占 SPI 业务路径 */
|
||||
pauseCanFeedbackTask(true);
|
||||
|
||||
auto fill_err = [](cJSON *item, int code, uint8_t gd32_err) {
|
||||
@@ -918,11 +917,6 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
|
||||
cJSON_AddNumberToObject(item, "gd32_err", gd32_err);
|
||||
};
|
||||
|
||||
/* 中途致命错误后不再逐帧 SPI 重试(否则每帧再等一整段超时) */
|
||||
bool abort_rest = false;
|
||||
int abort_code = -2;
|
||||
uint8_t abort_gd32 = 0U;
|
||||
|
||||
for (int i = 0; i < frame_count; i++) {
|
||||
(void)esp_task_wdt_reset();
|
||||
|
||||
@@ -932,11 +926,6 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
|
||||
}
|
||||
cJSON_AddItemToArray(results, item);
|
||||
|
||||
if (abort_rest) {
|
||||
fill_err(item, abort_code, abort_gd32);
|
||||
continue; /* 无 SPI、无帧间延时 */
|
||||
}
|
||||
|
||||
cJSON *fr = cJSON_GetArrayItem(frames, i);
|
||||
if (!cJSON_IsObject(fr)) {
|
||||
fill_err(item, -1, 0);
|
||||
@@ -987,25 +976,22 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
|
||||
cJSON_AddStringToObject(item, "recv", resp_hex);
|
||||
} else {
|
||||
fill_err(item, resp_len, gd32_err);
|
||||
/* SPI 失败 / BUS_OFF / TEC 过高:后面不再逐帧 SPI,也不再为每帧分配 cJSON */
|
||||
/* SPI 失败 / BUS_OFF / TEC 过高:中止剩余帧,由 skipped 字段告知前端 */
|
||||
const bool fatal_spi = (resp_len == -2);
|
||||
const bool fatal_bus = (resp_len == -3) &&
|
||||
(gd32_err == 0xE2U || gd32_err == 0xE3U);
|
||||
if (fatal_spi || fatal_bus) {
|
||||
abort_rest = true;
|
||||
abort_code = resp_len;
|
||||
abort_gd32 = gd32_err;
|
||||
const int skipped = frame_count - i - 1;
|
||||
if (skipped > 0) {
|
||||
cJSON_AddNumberToObject(out_root, "skipped", skipped);
|
||||
cJSON_AddNumberToObject(out_root, "abort_code", abort_code);
|
||||
cJSON_AddNumberToObject(out_root, "abort_gd32_err", abort_gd32);
|
||||
cJSON_AddNumberToObject(out_root, "abort_code", resp_len);
|
||||
cJSON_AddNumberToObject(out_root, "abort_gd32_err", gd32_err);
|
||||
}
|
||||
break; /* 不再为剩余帧 CreateObject,避免堆被打穿 */
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i + 1 < frame_count && !abort_rest) {
|
||||
if (i + 1 < frame_count) {
|
||||
vTaskDelay(pdMS_TO_TICKS(kInterFrameDelayMs));
|
||||
}
|
||||
}
|
||||
@@ -1018,9 +1004,8 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
|
||||
cJSON_Delete(out_root);
|
||||
out_root = nullptr;
|
||||
if (!printed) {
|
||||
/* 堆已紧时勿再走大段错误路径分配;给前端最小 JSON */
|
||||
static const char kOom[] =
|
||||
"{\"status\":\"error\",\"code\":\"json_print\",\"message\":\"JSON输出失败(堆不足)\"}";
|
||||
"{\"status\":\"error\",\"code\":\"json_print\",\"message\":\"JSON输出失败\"}";
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, kOom, HTTPD_RESP_USE_STRLEN);
|
||||
@@ -1104,7 +1089,8 @@ esp_err_t spi_send_handler(httpd_req_t *req)
|
||||
|
||||
static constexpr uint32_t INTERNAL_HEAP_CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
|
||||
/* 状态 JSON 用静态缓冲:OTA 后 largest free 常 <2KB,calloc(8KB) 会 500 "no memory"。 */
|
||||
static constexpr size_t SYSTEM_STATUS_JSON_CAP = 6144;
|
||||
/* 含 boot 模块列表与 CPU 任务;OTA 后 largest free 常偏紧,仍用静态缓冲避免 calloc */
|
||||
static constexpr size_t SYSTEM_STATUS_JSON_CAP = 7168;
|
||||
static char s_status_json[SYSTEM_STATUS_JSON_CAP];
|
||||
static SemaphoreHandle_t s_status_json_mux = nullptr;
|
||||
static portMUX_TYPE s_status_json_init_mux = portMUX_INITIALIZER_UNLOCKED;
|
||||
@@ -1344,6 +1330,9 @@ esp_err_t system_status_handler(httpd_req_t *req)
|
||||
(unsigned long)ls.stats.max_resp_latency_us);
|
||||
}
|
||||
|
||||
/* 启动自检位图(与串口 BOOT report 同源) */
|
||||
len = SystemBoot::appendStatusJson(resp, SYSTEM_STATUS_JSON_CAP, len);
|
||||
|
||||
// 前导逗号与前一块分隔;之前因 append_cpu_status_json 尾随逗号叠加
|
||||
// 形成 "],," 导致前端 JSON.parse 报 "Expected double-quoted property name"
|
||||
len = append_jsonf(resp, SYSTEM_STATUS_JSON_CAP, len, ",\"task_stack\": {");
|
||||
|
||||
@@ -313,12 +313,9 @@ esp_err_t WifiOta::initSta()
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||
|
||||
// ★ 冷启动时在 wifi_start 前增加延迟
|
||||
// 长时间断电后首次上电,WiFi PHY 校准需要额外时间
|
||||
// esp_wifi_start() 内部会触发 PHY 初始化和校准
|
||||
// 如果校准未完成就启动射频,可能导致连接失败
|
||||
/* 冷启动时在 wifi_start 前短延时 */
|
||||
if (WifiOta::instance().m_coldBoot) {
|
||||
DEBUG_INFO("WIFI", "冷启动: wifi_start 前再等 300ms...");
|
||||
DEBUG_INFO("WIFI", "冷启动: wifi_start 前等待 300ms");
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
}
|
||||
|
||||
@@ -400,16 +397,11 @@ esp_err_t WifiOta::httpServerStart()
|
||||
config.server_port = HTTP_SERVER_PORT;
|
||||
config.stack_size = HTTP_SERVER_STACK_SIZE;
|
||||
config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检
|
||||
/* LWIP_MAX_SOCKETS=12;HTTP 内部约 3 + UDP 日志 1 → 业务 socket 不宜过大。
|
||||
* 上传期间前端必须停 status/logs(见 app.js pausePollingForUpload)。
|
||||
* ★ 修复:增加到 7,防止浏览器超时 abort 后旧连接占满所有槽位。 */
|
||||
/* LWIP_MAX_SOCKETS=12;HTTP 内部约占 3 + UDP 日志 1;业务连接上限 7 */
|
||||
config.max_open_sockets = 7;
|
||||
/* ★ 修复:启用 LRU 淘汰,当所有 socket 被占满时主动关闭最旧空闲连接。
|
||||
* OTA 上传期间前端已暂停轮询(pausePollingForUpload),不会踢掉 upload 连接。
|
||||
* 若仍担心极端情况,可在 ota_upload_handler 入口设置该 session 的更长超时。 */
|
||||
/* 满连接时淘汰最久未用的空闲 socket */
|
||||
config.lru_purge_enable = true;
|
||||
/* ★ 修复:缩短超时,加速释放废弃连接。
|
||||
* OTA 写 Flash 期间 handler 本身持有连接不会被超时踢(httpd 只在 idle 时检测)。 */
|
||||
/* idle 收发超时(秒);进行中的 handler 连接不受此 idle 检测踢掉 */
|
||||
config.recv_wait_timeout = 10;
|
||||
config.send_wait_timeout = 10;
|
||||
|
||||
@@ -551,10 +543,9 @@ bool WifiOta::earlyInit()
|
||||
"OTHER",
|
||||
WifiOta::instance().m_coldBoot ? "是" : "否");
|
||||
|
||||
/* 与 GD32/CAN 收发器同板上电时,电源上升沿慢 + 负载大。
|
||||
* 若立刻清 PHY/开 WiFi 全量校准,易触发 brownout,表现为「上电起不来,拔插才好」。 */
|
||||
/* 冷启动:电源与外设上电完成后再初始化 NVS/WiFi */
|
||||
if (WifiOta::instance().m_coldBoot) {
|
||||
DEBUG_INFO("WIFI", "冷启动: 电源轨稳定等待 400ms(再做 NVS/WiFi)...");
|
||||
DEBUG_INFO("WIFI", "冷启动: 等待 400ms 后初始化 NVS/WiFi");
|
||||
vTaskDelay(pdMS_TO_TICKS(400));
|
||||
}
|
||||
|
||||
@@ -576,9 +567,7 @@ bool WifiOta::earlyInit()
|
||||
DEBUG_INFO("WIFI", "NVS 初始化成功");
|
||||
}
|
||||
|
||||
// ★ 冷启动:只清 WiFi 连接缓存;勿在每次 POWERON 都清 PHY。
|
||||
// 全量 PHY 校准电流大,与 GD32 同时上电易 brownout → 必须再插拔才能起来。
|
||||
// 仅在欠压复位后清 PHY,避免校准脏数据死循环。
|
||||
/* 冷启动清理 WiFi 连接缓存;欠压复位后再清 PHY 强制重校准 */
|
||||
if (WifiOta::instance().m_coldBoot) {
|
||||
err = clearNvsNamespace("nvs.net80211");
|
||||
if (err == ESP_OK) {
|
||||
@@ -589,7 +578,7 @@ bool WifiOta::earlyInit()
|
||||
if (reset_reason == ESP_RST_BROWNOUT) {
|
||||
err = clearNvsNamespace("phy");
|
||||
if (err == ESP_OK) {
|
||||
DEBUG_WARN("WIFI", "BROWNOUT 后: 已清除 PHY 校准,将重新校准");
|
||||
DEBUG_WARN("WIFI", "欠压复位后: 已清除 PHY 校准数据");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,15 +158,13 @@ private:
|
||||
uint32_t m_erasedUpTo = 0;
|
||||
esp_err_t ensureErasedForWrite(uint32_t offset, size_t len);
|
||||
|
||||
// ── NVS 互斥锁(保护元数据读-改-写原子性)──
|
||||
// lockNvs 返回是否成功 take;unlockNvs(handle) 据此决定是否 give,
|
||||
// 参考 OtaCoordinator::lock/unlock 模式(避免共享 bool 被并发覆盖导致死锁)。
|
||||
// NVS 互斥:lockNvs 返回 take 是否成功;unlockNvs(handle) 仅成功时 give
|
||||
SemaphoreHandle_t m_nvsMutex = nullptr;
|
||||
bool lockNvs(uint32_t timeout_ms);
|
||||
void unlockNvs(bool handle);
|
||||
static constexpr uint32_t kNvsLockTimeoutMs = 100; ///< NVS 加锁超时(ms)
|
||||
|
||||
// 已持锁版本:由持锁的公开方法或 metaUpdate 调用,不再自行加锁,避免递归 take 死锁
|
||||
// 已持锁版本(调用方已持 m_nvsMutex)
|
||||
esp_err_t metaLoadLocked(ota_meta_t *meta);
|
||||
esp_err_t metaSaveLocked(const ota_meta_t *meta);
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
|
||||
&resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms);
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
/* 批量失败时勿每帧打日志:UDP/格式化会把堆打穿 */
|
||||
/* 同类错误节流日志,降低高频失败时的日志开销 */
|
||||
static uint32_t s_spi_fail_n = 0U;
|
||||
static int s_last_spi_err = 0;
|
||||
const int err_i = static_cast<int>(ret);
|
||||
|
||||
@@ -295,7 +295,7 @@ esp_err_t SpiMaster::submitJob(Job &job)
|
||||
return job.result;
|
||||
}
|
||||
|
||||
/* 堆 Job + 动态 binary sem:避免调用方超时返回后栈上 Job/done 被 worker UAF。 */
|
||||
/* 跨任务作业:堆上 Job + binary 完成信号量 */
|
||||
Job *heap_job = static_cast<Job *>(calloc(1, sizeof(Job)));
|
||||
if (heap_job == nullptr) {
|
||||
DEBUG_ERR("SPI", "submitJob: calloc failed kind=%u", (unsigned)job.kind);
|
||||
@@ -519,8 +519,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
|
||||
t.tx_buffer = tx_ptr;
|
||||
t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
|
||||
|
||||
/* 禁止 portMAX_DELAY:SPI 硬件/从机异常时 worker 会永久卡死,
|
||||
* 表现即 OTA「转发进度 1/1117 后完全沉默」。所有等待必须有上界。 */
|
||||
/* SPI 事务等待使用有界超时 */
|
||||
const uint32_t effective_ms = (timeout_ms == 0U) ? 500U
|
||||
: (timeout_ms < 50U ? 50U : timeout_ms);
|
||||
const TickType_t queue_wait = pdMS_TO_TICKS(effective_ms);
|
||||
@@ -544,7 +543,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
|
||||
ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, result_wait);
|
||||
if (ret != ESP_OK) {
|
||||
/* 再给一次有界抢救(最多 1s),清掉可能残留的 in-flight 事务。
|
||||
* 旧代码用 portMAX_DELAY —— GD32 不响应时 SPI worker 永久死锁。 */
|
||||
* 使用有界超时,避免对端无响应时 worker 永久阻塞。 */
|
||||
DEBUG_WARN("SPI", "spi_device_get_trans_result timeout: %s len=%u, bounded retry 1s",
|
||||
esp_err_to_name(ret), (unsigned)(total_len * 8U));
|
||||
ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, pdMS_TO_TICKS(1000));
|
||||
@@ -740,10 +739,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
|
||||
*/
|
||||
esp_err_t SpiMaster::init()
|
||||
{
|
||||
// ★ 修复:先创建 mutex,再使用 LockGuard。
|
||||
// 旧代码在 LockGuard 构造时 m_mutex 仍为 NULL,
|
||||
// 虽然 LockGuard 会跳过 NULL take/give,但在极端情况下
|
||||
// 其他任务可能在 mutex 创建后立即尝试 take,产生竞态。
|
||||
/* 先创建互斥锁,再进入临界区 */
|
||||
if (!m_mutex) {
|
||||
m_mutex = xSemaphoreCreateMutex();
|
||||
if (m_mutex == NULL) {
|
||||
@@ -840,9 +836,7 @@ esp_err_t SpiMaster::init()
|
||||
m_stats = SpiLinkStats{};
|
||||
m_eventHead = m_eventTail = m_eventCount = 0;
|
||||
|
||||
/* job 队列 + 事件信号量 + SPI worker。
|
||||
* worker 启动后可能立刻抢 m_mutex;init 持锁时 worker 会阻塞在 LockGuard,
|
||||
* init 返回释放锁后 worker 继续 —— 不会死锁。 */
|
||||
/* 创建 job 队列、事件信号量与 SPI worker(init 仍持 m_mutex) */
|
||||
if (m_jobQueue == nullptr) {
|
||||
m_jobQueue = xQueueCreate(kJobQueueLen, sizeof(Job *));
|
||||
if (m_jobQueue == nullptr) {
|
||||
@@ -871,6 +865,24 @@ esp_err_t SpiMaster::init()
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool SpiMaster::selfTestL0() const
|
||||
{
|
||||
return (m_spiDevice != nullptr) && (m_mutex != nullptr) &&
|
||||
(m_workerTask != nullptr) && (m_jobQueue != nullptr);
|
||||
}
|
||||
|
||||
bool SpiMaster::selfTestPeerL0()
|
||||
{
|
||||
/* 短超时、最多 2 次;不拉长整体启动 */
|
||||
for (uint32_t attempt = 0U; attempt < 2U; ++attempt) {
|
||||
if (hello(600U, 0U) == ESP_OK) {
|
||||
return true;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SPI OTA 协议
|
||||
// ============================================================
|
||||
@@ -1228,12 +1240,11 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
|
||||
* ESP_OK - 读到过期帧(正常,忽略)
|
||||
* ESP_ERR_INVALID_RESPONSE - 读到 0xFF(正常,延迟一拍)
|
||||
* 其他 - SPI 真实错误 */
|
||||
/* 将总超时切成 send + poll,禁止 poll 单独再给 500ms(短超时批量时会「失败卡很久」) */
|
||||
/* 总超时在 send 与 poll 之间分配,二者之和不超过 timeout_ms */
|
||||
uint32_t send_timeout = (timeout_ms > 500U) ? 500U : timeout_ms;
|
||||
if (send_timeout == 0U) {
|
||||
send_timeout = 1U;
|
||||
}
|
||||
/* 至少给 poll 留一点时间;总超时很短时 send/poll 各半 */
|
||||
uint32_t poll_timeout;
|
||||
if (timeout_ms > send_timeout) {
|
||||
poll_timeout = timeout_ms - send_timeout;
|
||||
@@ -1244,7 +1255,7 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
|
||||
}
|
||||
poll_timeout = timeout_ms - send_timeout;
|
||||
} else {
|
||||
poll_timeout = timeout_ms; /* 极短超时:发完立刻 poll,总预算仍受 deadline 约束 */
|
||||
poll_timeout = timeout_ms;
|
||||
}
|
||||
if (poll_timeout == 0U) {
|
||||
poll_timeout = 1U;
|
||||
@@ -1257,7 +1268,7 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
|
||||
return send_ret;
|
||||
}
|
||||
|
||||
/* 等 GD32 完成 onCsRelease 再 poll;5ms 兼顾「能发出」与批量节奏 */
|
||||
/* CS 释放后短延时,再 poll 应答 */
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
|
||||
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
|
||||
@@ -1707,18 +1718,11 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint)
|
||||
}
|
||||
|
||||
m_gd32Role = pload[4];
|
||||
m_localCredit = pload[5];
|
||||
m_remoteCredit = hdr.credit;
|
||||
/* payload[5]/hdr.credit 均为对端 credit_max → 写入 m_remoteCredit。
|
||||
* 勿写入 m_localCredit(本端 RX 窗口,init 为 8)。 */
|
||||
m_remoteCredit = (pload[5] != 0U) ? pload[5] : hdr.credit;
|
||||
m_v2Active = true;
|
||||
m_stats.resyncs++;
|
||||
if (role_hint == 0U || m_gd32Role == role_hint || m_gd32Role == V2_ROLE_BOOT) {
|
||||
DEBUG_INFO("SPI", "HELLO success: session=0x%08lX role=%u credit=%u attempt=%lu",
|
||||
(unsigned long)m_sessionId, m_gd32Role, m_localCredit,
|
||||
(unsigned long)attempt);
|
||||
} else {
|
||||
DEBUG_DBG("SPI", "HELLO ok role=%u (want hint=%u) attempt=%lu",
|
||||
m_gd32Role, role_hint, (unsigned long)attempt);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,17 +100,13 @@ public:
|
||||
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
|
||||
|
||||
/**
|
||||
* @brief OTA 中继期间禁止 worker 按铃读。
|
||||
*
|
||||
* PB6 在 OTA 下也会因 WAIT/ACK 拉低。若 worker 在 job 间隙把 RESPONSE
|
||||
* 当 EVENT 偷读,业务 poll 只能空等 → 约 10s 后 DATA 重发 → GD32 DUP-SEQ,
|
||||
* 表现为刷写断断续续。中继开始设 true,结束/复位设 false。
|
||||
* @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。
|
||||
* 中继开始设 true,结束/复位设 false。
|
||||
*/
|
||||
void setSuppressRingService(bool suppress);
|
||||
|
||||
/**
|
||||
* @brief OTA 业务层确认当前请求已成功(结构化 ACK 校验通过)。
|
||||
* 在 SPI 头 ack_seq 偶发失配但仍接受业务 ACK 时补齐 m_txSeq,避免后续包卡死。
|
||||
* @brief 业务层确认当前请求已成功时推进/对齐 m_txSeq。
|
||||
*/
|
||||
void confirmTxSeqAccepted();
|
||||
|
||||
@@ -190,6 +186,17 @@ public:
|
||||
/** @brief 向 GD32 APP 发送存活探测命令,等待 APP 回复 ALIVE ACK。 */
|
||||
esp_err_t appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version = nullptr);
|
||||
|
||||
/**
|
||||
* @brief L0 本机自检(不依赖 GD32):SPI 设备/互斥/worker/队列已就绪。
|
||||
*/
|
||||
bool selfTestL0() const;
|
||||
|
||||
/**
|
||||
* @brief L0 对端轻检:短超时 HELLO(role=any),确认 SPI 链路可达 GD32。
|
||||
* @note GD32 仍在启动延时中时可能失败,属非关键项。
|
||||
*/
|
||||
bool selfTestPeerL0();
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief 私有默认构造函数。
|
||||
|
||||
+1
-1
@@ -84,6 +84,6 @@ CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=16
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0
|
||||
CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200
|
||||
|
||||
# 与 GD32 同板上电:略放宽欠压阈值,减少上电瞬间复位(仍保留 BOD)
|
||||
# Brownout detector threshold (LVL5)
|
||||
CONFIG_ESP_BROWNOUT_DET=y
|
||||
CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5=y
|
||||
|
||||
+6
-6
@@ -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=20260717w">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260717w">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260717w">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260717w">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260717w">
|
||||
<link rel="stylesheet" href="css/base.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260717x">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -318,6 +318,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/can.js?v=20260717w"></script>
|
||||
<script type="module" src="js/can.js?v=20260717x"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+167
-1
@@ -122,6 +122,9 @@
|
||||
font-size: 20px; font-weight: 700; word-break: break-word;
|
||||
}
|
||||
.metric .v.highlight { color: var(--accent); }
|
||||
.metric .v.ok { color: var(--green); }
|
||||
.metric .v.warn { color: var(--amber); }
|
||||
.metric .v.err { color: var(--red); }
|
||||
|
||||
.status-kv-grid {
|
||||
display: grid;
|
||||
@@ -150,7 +153,134 @@
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
/* === 健康状态 → 启动自检(点击展开,非常驻) === */
|
||||
.badge-stat-click {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: border-color .15s ease, background .15s ease;
|
||||
}
|
||||
.badge-stat-click:hover,
|
||||
.badge-stat-click:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated));
|
||||
}
|
||||
.badge-stat-click.is-open {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.health-boot-hint {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.badge-stat-click.is-open .health-boot-hint {
|
||||
color: var(--accent);
|
||||
}
|
||||
.health-boot-panel {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.health-boot-panel.collapsed,
|
||||
.health-boot-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.health-boot-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.health-boot-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.health-boot-summary {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
}
|
||||
.health-boot-summary.ok { color: var(--green); }
|
||||
.health-boot-summary.warn { color: var(--amber); }
|
||||
.health-boot-summary.err { color: var(--red); }
|
||||
.health-boot-failed {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.health-boot-failed b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.health-boot-failed b.ok { color: var(--green); }
|
||||
.health-boot-failed b.warn { color: var(--amber); }
|
||||
.health-boot-failed b.err { color: var(--red); }
|
||||
|
||||
/* === BOOT MODULE CHIPS (/system/status boot) === */
|
||||
.boot-module-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
min-height: 8px;
|
||||
}
|
||||
.boot-mod {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-root);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.boot-mod .boot-mod-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.boot-mod.ok {
|
||||
color: var(--green);
|
||||
border-color: color-mix(in srgb, var(--green) 35%, var(--border));
|
||||
}
|
||||
.boot-mod.ok .boot-mod-dot { background: var(--green); }
|
||||
.boot-mod.fail {
|
||||
color: var(--red);
|
||||
border-color: color-mix(in srgb, var(--red) 35%, var(--border));
|
||||
}
|
||||
.boot-mod.fail .boot-mod-dot { background: var(--red); }
|
||||
.boot-mod.fail.optional {
|
||||
color: var(--amber);
|
||||
border-color: color-mix(in srgb, var(--amber) 35%, var(--border));
|
||||
}
|
||||
.boot-mod.fail.optional .boot-mod-dot { background: var(--amber); }
|
||||
|
||||
.status-section-title {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-label);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 堆概况 ===== */
|
||||
.heap-group {
|
||||
@@ -179,6 +309,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.heap-bar-row {
|
||||
@@ -196,6 +327,7 @@
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
/* 百分比数值与文案同一行;进度条单独占下一行 */
|
||||
@@ -208,6 +340,7 @@
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.heap-bar-track {
|
||||
@@ -257,6 +390,39 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 抽屉内数值/任务名/JSON 强制等宽,避免被其它样式冲掉 */
|
||||
.sys-drawer-body .heap-bar-pct,
|
||||
.sys-drawer-body .heap-detail-row b,
|
||||
.sys-drawer-body .status-task-name,
|
||||
.sys-drawer-body .status-task-ratio,
|
||||
.sys-drawer-body .status-task-meta b,
|
||||
.sys-drawer-body .status-json {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.sys-drawer-body .status-section-title,
|
||||
.sys-drawer-body .heap-group-title,
|
||||
.sys-drawer-body .heap-bar-text {
|
||||
font-family: var(--font);
|
||||
color: inherit;
|
||||
}
|
||||
.sys-drawer-body .status-section-title {
|
||||
color: var(--text-label);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sys-drawer-body .heap-bar-text {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sys-drawer-body .status-json {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-task-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
+21
-10
@@ -9,11 +9,11 @@
|
||||
<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=20260716q">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260716q">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260716q">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260716q">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260716q">
|
||||
<link rel="stylesheet" href="css/base.css?v=20260718b">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260718b">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260718b">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260718b">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260718b">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -244,9 +244,11 @@
|
||||
<div class="v err" id="bConn">离线</div>
|
||||
<div class="k">连接状态</div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="badge-stat badge-stat-click" id="healthBadge"
|
||||
role="button" tabindex="0" title="点击查看启动自检"
|
||||
aria-expanded="false" aria-controls="healthBootPanel">
|
||||
<div class="v" id="mHealth">--</div>
|
||||
<div class="k">健康状态</div>
|
||||
<div class="k">健康状态 <span class="health-boot-hint">▾ 自检</span></div>
|
||||
</div>
|
||||
<div class="badge-stat">
|
||||
<div class="v err" id="connBadge">offline</div>
|
||||
@@ -257,6 +259,15 @@
|
||||
<div class="k">GATT 状态</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 启动自检:点健康状态展开,默认折叠 -->
|
||||
<div class="health-boot-panel collapsed" id="healthBootPanel" hidden>
|
||||
<div class="health-boot-head">
|
||||
<span class="health-boot-title">启动自检</span>
|
||||
<span class="health-boot-summary" id="mBootResult">--</span>
|
||||
</div>
|
||||
<div class="health-boot-failed">失败/跳过:<b id="mBootFailed">--</b></div>
|
||||
<div class="boot-module-list" id="bootModuleList" aria-label="启动模块列表"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -425,8 +436,8 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/app.js?v=20260716q"></script>
|
||||
<script type="module" src="js/ota/console-mode.js?v=20260716q"></script>
|
||||
<script type="module" src="js/ble_ota.js?v=20260716q"></script>
|
||||
<script type="module" src="js/app.js?v=20260718b"></script>
|
||||
<script type="module" src="js/ota/console-mode.js?v=20260718b"></script>
|
||||
<script type="module" src="js/ble_ota.js?v=20260718d"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const UUIDS = {
|
||||
version: 0x0004,
|
||||
};
|
||||
|
||||
export const UI_BUILD = '2026-07-16-ble-begin-ready';
|
||||
export const UI_BUILD = '2026-07-18-ble-fix';
|
||||
export const BLE_OTA_NOTIFY_ACK = 0xA1;
|
||||
export const BLE_OTA_NOTIFY_FINISH = 0xB1;
|
||||
export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
|
||||
|
||||
@@ -199,8 +199,10 @@ export function handleNotify(event) {
|
||||
updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes);
|
||||
}
|
||||
|
||||
const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`;
|
||||
bleLog('ACK', ackText);
|
||||
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程 */
|
||||
if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) {
|
||||
bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`);
|
||||
}
|
||||
|
||||
// ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK
|
||||
// 不再要求 ackSeq >= expectedSeq,因为 ESP32 可能用较低的 seq 发送 ACK
|
||||
|
||||
@@ -238,6 +238,7 @@ export async function uploadFirmware() {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Windows WebBT 同时只允许一个 GATT 操作,必须逐包 await,禁止 Promise.all 并发写 */
|
||||
await state.dataInChar.writeValueWithoutResponse(packet);
|
||||
}
|
||||
|
||||
|
||||
+12
-16
@@ -20,7 +20,7 @@
|
||||
import {
|
||||
state, el, getBaseUrl, getDeviceIp, setDeviceIp, parseDbc, readDbcText, buildSummary,
|
||||
rebuildMergedDbc, makeDbcSourceId
|
||||
} from './can/dbc.js?v=20260717w';
|
||||
} from './can/dbc.js?v=20260717x';
|
||||
import {
|
||||
updateDbcFileInfo,
|
||||
filterMessages,
|
||||
@@ -30,12 +30,11 @@ import {
|
||||
recomputeTransmitterFilterEmpty,
|
||||
updateNodeFilterLabel,
|
||||
updateBatchUi
|
||||
} from './can/messages.js?v=20260717w';
|
||||
} from './can/messages.js?v=20260717x';
|
||||
import {
|
||||
ensureMessageDefaults,
|
||||
renderSelectedMessage,
|
||||
updateEncodedPreview
|
||||
} from './can/signals.js?v=20260717w';
|
||||
} from './can/signals.js?v=20260717x';
|
||||
import {
|
||||
renderHistory,
|
||||
sendSelectedMessage,
|
||||
@@ -44,15 +43,15 @@ import {
|
||||
sendManualFrame,
|
||||
resetManualFields,
|
||||
loadPreviewToManual
|
||||
} from './can/send.js?v=20260717w';
|
||||
} from './can/send.js?v=20260717x';
|
||||
import {
|
||||
scheduleCanPersist,
|
||||
loadCanSession,
|
||||
bindCanPersistLifecycle
|
||||
} from './can/persist.js?v=20260717w';
|
||||
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717w';
|
||||
import { showToast } from './common/toast.js?v=20260717w';
|
||||
import { initCanMonitor } from './can_monitor.js?v=20260717w';
|
||||
} from './can/persist.js?v=20260717x';
|
||||
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717x';
|
||||
import { showToast } from './common/toast.js?v=20260717x';
|
||||
import { initCanMonitor } from './can_monitor.js?v=20260717x';
|
||||
|
||||
// ==================== 本地工具 ====================
|
||||
// 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h),
|
||||
@@ -63,10 +62,10 @@ import { initCanMonitor } from './can_monitor.js?v=20260717w';
|
||||
function cacheElements() {
|
||||
[
|
||||
'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData',
|
||||
'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount',
|
||||
'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcMessageCount',
|
||||
'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcFileCount', 'dbcFileChips',
|
||||
'dbcSearch', 'dbcHint', 'clearDbcBtn',
|
||||
'importDbcBtn', 'replaceDbcBtn',
|
||||
'importDbcBtn',
|
||||
'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint',
|
||||
'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn',
|
||||
'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco',
|
||||
@@ -149,11 +148,10 @@ function afterDbcSourcesChanged(opts) {
|
||||
if (opts.resetSelection || !state.selectedMessageKey) {
|
||||
selectMessage(state.dbc.messages[0].key);
|
||||
} else {
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
}
|
||||
} else {
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
}
|
||||
if (el.dbcHint) {
|
||||
el.dbcHint.hidden = true;
|
||||
@@ -268,7 +266,6 @@ function bindEvents() {
|
||||
if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value;
|
||||
if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
|
||||
renderMessageList();
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
scheduleCanPersist();
|
||||
});
|
||||
@@ -277,7 +274,6 @@ function bindEvents() {
|
||||
if (el.dbcFileInput) el.dbcFileInput.click();
|
||||
}
|
||||
if (el.importDbcBtn) el.importDbcBtn.addEventListener('click', openDbcPicker);
|
||||
if (el.replaceDbcBtn) el.replaceDbcBtn.addEventListener('click', openDbcPicker);
|
||||
if (el.dbcDropZone) {
|
||||
el.dbcDropZone.addEventListener('dragover', function (event) {
|
||||
event.preventDefault();
|
||||
@@ -389,7 +385,7 @@ function init() {
|
||||
selectMessage(state.dbc.messages[0].key);
|
||||
}
|
||||
} else {
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
}
|
||||
updateBatchUi();
|
||||
refreshManualHint();
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* 包含:共享 state/el、基础工具、DBC 解析、cssEscape、readDbcText。
|
||||
*
|
||||
* 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出,
|
||||
* 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。
|
||||
* 供 messages/signals/send 共享(ES Module 的 live binding 保证引用一致)。
|
||||
*/
|
||||
|
||||
// ==================== 共享状态 ====================
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* feedback.js - 1Hz 反馈轮询(预留模块)
|
||||
*
|
||||
* 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。
|
||||
* 1Hz 轮询 /can/feedback 或 ID 快照功能可在本模块按需实现。
|
||||
*/
|
||||
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js';
|
||||
|
||||
// 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。
|
||||
export { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow };
|
||||
@@ -5,12 +5,12 @@
|
||||
*
|
||||
* 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、
|
||||
* common/utils.js(escapeHtml/formatBytes)、
|
||||
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/renderSelectedMessage/updateEncodedPreview)。
|
||||
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。
|
||||
*/
|
||||
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717v';
|
||||
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717v';
|
||||
import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js?v=20260717v';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717v';
|
||||
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717x';
|
||||
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717x';
|
||||
import { renderInlineSignalGrid, ensureMessageDefaults, updateEncodedPreview } from './signals.js?v=20260717x';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717x';
|
||||
|
||||
// ==================== DBC 文件信息 ====================
|
||||
/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */
|
||||
@@ -395,7 +395,6 @@ export function selectMessage(key) {
|
||||
state.selectedMessageKey = key;
|
||||
ensureMessageDefaults(msg);
|
||||
renderMessageList();
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
scheduleCanPersist();
|
||||
}
|
||||
|
||||
+1
-12
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* 依赖:dbc.js(state/el/parseDbc/rebuildMergedDbc)
|
||||
*/
|
||||
import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717v';
|
||||
import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717x';
|
||||
|
||||
var LS_KEY = 'dcu_can_session_v1';
|
||||
var MAX_HISTORY = 200;
|
||||
@@ -135,17 +135,6 @@ export function scheduleCanPersist() {
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** @description 清空持久化(全部清除 DBC 时)。 */
|
||||
export function clearCanSessionPersist() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 从 localStorage 恢复会话到 state + 表单。
|
||||
* @returns {{ restored: boolean, dbcCount: number, historyCount: number }}
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
* 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js
|
||||
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。
|
||||
*/
|
||||
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717w';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717w';
|
||||
import { showToast } from '../common/toast.js?v=20260717w';
|
||||
import { updateBatchUi } from './messages.js?v=20260717w';
|
||||
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717w';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717w';
|
||||
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717x';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x';
|
||||
import { showToast } from '../common/toast.js?v=20260717x';
|
||||
import { updateBatchUi } from './messages.js?v=20260717x';
|
||||
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717x';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717x';
|
||||
|
||||
// ==================== 本地工具(保持与原 can.js 一致) ====================
|
||||
function formatTime(date) {
|
||||
@@ -109,7 +109,7 @@ async function sendCanBatchRequest(channel, frames) {
|
||||
if (!json.results || !Array.isArray(json.results)) {
|
||||
throw new Error('批量响应缺少 results');
|
||||
}
|
||||
/* 设备中途 fatal 会 skipped 剩余帧(少分配堆);前端补齐等长 results */
|
||||
/* 设备可能返回 skipped;补齐与 frames 等长的 results */
|
||||
var results = json.results.slice();
|
||||
var need = frames.length - results.length;
|
||||
if (need > 0) {
|
||||
@@ -172,12 +172,12 @@ export function renderHistory() {
|
||||
}
|
||||
|
||||
// ==================== 批量发送 ====================
|
||||
/** 单批最多帧数(与设备 /can/send_batch 上限 128 对齐;一次 HTTP 连发,避免分块成“一阵一阵”) */
|
||||
/** 单批最多帧数(与设备 /can/send_batch 上限一致) */
|
||||
var BATCH_CHUNK = 128;
|
||||
|
||||
/**
|
||||
* @description 批量发送所有勾选的报文。
|
||||
* 优先一次 HTTP /can/send_batch(设备侧帧间 5ms);失败则回退单帧(无额外间隔)。
|
||||
* 优先 POST /can/send_batch;不支持时回退逐帧 /can/send。
|
||||
* 批量过程中不重绘历史,结束后统一刷新。
|
||||
*/
|
||||
export async function sendCheckedMessages() {
|
||||
|
||||
+4
-15
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical)
|
||||
*
|
||||
* 包含:信号行渲染、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作、
|
||||
* renderSelectedMessage shim。
|
||||
* 包含:行内信号表格、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作。
|
||||
*
|
||||
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
|
||||
*/
|
||||
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717v';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717v';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717v';
|
||||
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717x';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717x';
|
||||
|
||||
// ==================== 信号值存取 ====================
|
||||
/** @description 确保报文有 signalValues bag,并为每个信号填充默认值。返回 bag。 */
|
||||
@@ -81,15 +80,6 @@ export function getActiveSignals(message) {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 旧版 shim ====================
|
||||
/**
|
||||
* @description 旧版"顶部独立信号编辑面板"的兼容 shim。
|
||||
* 该面板已移除,信号编辑现在通过报文行展开完成;
|
||||
* 仍被 selectMessage、handleDbcFile、clearDbc、handleSignalInputEvent 调用,
|
||||
* 保留空函数以避免修改所有调用方。
|
||||
*/
|
||||
export function renderSelectedMessage() {}
|
||||
|
||||
// ==================== 信号行渲染 ====================
|
||||
/** @description 在报文展开行内渲染信号表格(inline grid)。 */
|
||||
export function renderInlineSignalGrid(message, host) {
|
||||
@@ -178,7 +168,6 @@ function handleSignalInputEvent(evt) {
|
||||
setSignalValue(message, name, input.value);
|
||||
var signal = findSignal(message, name);
|
||||
if (signal && signal.multiplexRole === 'mux') {
|
||||
if (key === state.selectedMessageKey) renderSelectedMessage();
|
||||
var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host');
|
||||
if (inlineGrid) renderInlineSignalGrid(message, inlineGrid);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
|
||||
*/
|
||||
import { showToast } from './common/toast.js?v=20260717v';
|
||||
import { showToast } from './common/toast.js?v=20260717x';
|
||||
|
||||
var state = { fbTimer: null, fbAbortCtrl: null };
|
||||
|
||||
|
||||
@@ -49,11 +49,105 @@ export function updateHealthUi(healthCode, freeHeap) {
|
||||
mh.textContent = health;
|
||||
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
|
||||
}
|
||||
ensureHealthBootToggleBound();
|
||||
}
|
||||
/**
|
||||
* 每次 pollStatus 成功后刷新堆/栈/CPU(固定周期约 1000ms,中继期 400ms;
|
||||
* sample_ms 由设备两次采样墙钟差给出;WiFi 上传 ESP32 期间 pause 不刷)。
|
||||
*/
|
||||
|
||||
/** 健康状态 badge 点击展开/收起启动自检面板(只绑一次)。 */
|
||||
function ensureHealthBootToggleBound() {
|
||||
var badge = document.getElementById('healthBadge');
|
||||
var panel = document.getElementById('healthBootPanel');
|
||||
if (!badge || !panel || badge.dataset.bootToggleBound === '1') return;
|
||||
badge.dataset.bootToggleBound = '1';
|
||||
|
||||
function toggle() {
|
||||
var open = panel.classList.contains('collapsed') || panel.hasAttribute('hidden');
|
||||
if (open) {
|
||||
panel.hidden = false;
|
||||
panel.classList.remove('collapsed');
|
||||
badge.classList.add('is-open');
|
||||
badge.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
panel.hidden = true;
|
||||
panel.classList.add('collapsed');
|
||||
badge.classList.remove('is-open');
|
||||
badge.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
badge.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
});
|
||||
badge.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */
|
||||
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');
|
||||
if (!boot) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = '--';
|
||||
resultEl.className = 'health-boot-summary';
|
||||
}
|
||||
if (failedEl) {
|
||||
failedEl.textContent = '--';
|
||||
failedEl.className = '';
|
||||
}
|
||||
if (listEl) listEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var criticalOk = !!boot.critical_ok;
|
||||
var allOk = !!boot.all_ok;
|
||||
var summary = boot.summary || (allOk ? 'OK (all modules)' : (criticalOk ? 'OK (critical)' : 'FAIL'));
|
||||
var failed = (boot.failed && String(boot.failed).trim()) ? String(boot.failed) : '';
|
||||
var tone = allOk ? 'ok' : (criticalOk ? 'warn' : 'err');
|
||||
|
||||
if (resultEl) {
|
||||
resultEl.textContent = summary;
|
||||
resultEl.className = 'health-boot-summary ' + tone;
|
||||
resultEl.title = 'mask=0x' + (Number(boot.mask) || 0).toString(16);
|
||||
}
|
||||
if (failedEl) {
|
||||
failedEl.textContent = failed || (allOk ? '无' : '--');
|
||||
failedEl.className = failed ? tone : 'ok';
|
||||
}
|
||||
|
||||
if (listEl) {
|
||||
var modules = Array.isArray(boot.modules) ? boot.modules : [];
|
||||
if (!modules.length) {
|
||||
listEl.innerHTML = '';
|
||||
} else {
|
||||
listEl.innerHTML = modules.map(function (m) {
|
||||
var ok = !!m.ok;
|
||||
var critical = !!m.critical;
|
||||
var cls = 'boot-mod ' + (ok ? 'ok' : (critical ? 'fail' : 'fail optional'));
|
||||
var tip = (m.name || '') + (ok ? ' 成功' : ' 失败') + (critical ? ' · 关键' : ' · 非关键');
|
||||
return '<span class="' + cls + '" title="' + escapeHtml(tip) + '">' +
|
||||
'<span class="boot-mod-dot"></span>' +
|
||||
escapeHtml(m.name || '?') +
|
||||
'</span>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
if (!window._bootInitLogged) {
|
||||
window._bootInitLogged = true;
|
||||
var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : '');
|
||||
try {
|
||||
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
|
||||
} catch (_) {}
|
||||
}
|
||||
ensureHealthBootToggleBound();
|
||||
}
|
||||
|
||||
export function updateHeapAndTaskStatusUi(data) {
|
||||
if (!data) return;
|
||||
var ota = data.ota || {};
|
||||
@@ -62,6 +156,8 @@ export function updateHeapAndTaskStatusUi(data) {
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
|
||||
updateBootInitUi(data);
|
||||
|
||||
// ---- 堆概况:已用 / 总计 + 占用条 ----
|
||||
function setFill(id, pct) {
|
||||
var el = document.getElementById(id);
|
||||
|
||||
+25
-1
@@ -208,7 +208,13 @@ export function updateSystemStatusUi(data) {
|
||||
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
|
||||
if (window.currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); }
|
||||
|
||||
if (isGd32Error && (window.currentOtaTarget === 'gd32' || window.otaSessionTarget === 'gd32')) {
|
||||
/* 本会话正在 GD32 路径(含仅 RELAYING 而 target 被清的情况) */
|
||||
var gd32Session =
|
||||
window.otaSessionState === OtaSessionState.RELAYING ||
|
||||
window.currentOtaTarget === 'gd32' ||
|
||||
window.otaSessionTarget === 'gd32';
|
||||
|
||||
if (isGd32Error && gd32Session) {
|
||||
var failDetail = 'GD32 OTA 失败';
|
||||
if (relayTotal > 0) {
|
||||
failDetail += '(已转发 ' + relaySeq + '/' + relayTotal +
|
||||
@@ -218,6 +224,7 @@ export function updateSystemStatusUi(data) {
|
||||
}
|
||||
window.cleanupAfterOtaFailure(failDetail);
|
||||
window.pollLogs();
|
||||
return;
|
||||
} else if (isGd32Error) {
|
||||
// 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理
|
||||
updateStagesFromRelayState(255);
|
||||
@@ -233,6 +240,23 @@ export function updateSystemStatusUi(data) {
|
||||
if (isGd32Relaying || (rs >= 1 && rs <= 4)) {
|
||||
window.gd32RelaySessionLive = true;
|
||||
}
|
||||
/*
|
||||
* 设备侧 ERROR 常被 status 软清为 IDLE,前端收不到 code=8。
|
||||
* 已见真实中继、设备已回 IDLE、且未宣布完成 → 按失败收尾,释放会话。
|
||||
*/
|
||||
var deviceIdleAfterRelay =
|
||||
(sysStateCode === 0 || sysStateCode === 2) &&
|
||||
(rs === 0 || rs === 5) &&
|
||||
window.gd32RelaySessionLive &&
|
||||
!window.gd32CompleteAnnounced;
|
||||
if (deviceIdleAfterRelay) {
|
||||
window.cleanupAfterOtaFailure(
|
||||
'GD32 中继已结束但未成功(设备已回 IDLE' +
|
||||
(relayTotal > 0 ? ',已转发 ' + relaySeq + '/' + relayTotal : '') + ')'
|
||||
);
|
||||
window.pollLogs();
|
||||
return;
|
||||
}
|
||||
/* 包进度:进入 FINISHING/COMPLETE 时先补 100%,再走完成/收尾 UI */
|
||||
if (window.gd32RelaySessionLive && relayTotal > 0 &&
|
||||
(relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) {
|
||||
|
||||
@@ -58,6 +58,31 @@ export async function startUpload(chip) {
|
||||
if (!file) return;
|
||||
// ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。
|
||||
// FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。
|
||||
// RELAYING 可能因 ERROR 被设备软清为 IDLE 而前端未收尾,先探测再决定。
|
||||
if (otaSessionActive()) {
|
||||
var busyState = window.otaSessionState;
|
||||
if (busyState === OtaSessionState.RELAYING || busyState === OtaSessionState.VERIFYING) {
|
||||
try {
|
||||
var staleProbe = await fetch(window.getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
if (staleProbe.ok) {
|
||||
var staleSt = await staleProbe.json();
|
||||
var so = staleSt.ota || {};
|
||||
var ssc0 = Number.isFinite(so.sys_state_code) ? so.sys_state_code : -1;
|
||||
var rs0 = Number.isFinite(so.relay_state) ? so.relay_state : -1;
|
||||
/* 设备已空闲/完成/错误残留:前端会话是陈旧活跃态,强制清掉 */
|
||||
var deviceFree = (ssc0 === 0 || ssc0 === 2 || ssc0 === 4 || ssc0 === 8) &&
|
||||
(rs0 === 0 || rs0 === 5 || rs0 === 255 || rs0 < 0);
|
||||
if (deviceFree) {
|
||||
addLog('W', '上传', '检测到陈旧会话 ' + busyState +
|
||||
'(设备已 ' + (so.sys_state || ssc0) + '),自动清理后继续');
|
||||
otaSessionReset();
|
||||
}
|
||||
}
|
||||
} catch (_) { /* 探测失败则仍按占用拒绝 */ }
|
||||
}
|
||||
}
|
||||
if (otaSessionActive()) {
|
||||
var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase();
|
||||
addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传');
|
||||
@@ -70,11 +95,7 @@ export async function startUpload(chip) {
|
||||
window.otaSessionTarget = chip;
|
||||
|
||||
window.pausePollingForUpload();
|
||||
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
|
||||
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
|
||||
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
|
||||
// 设备端互斥被绕过、403 路径走漏。
|
||||
// ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true)
|
||||
/* 先进入 UPLOADING(同步 isUploading),再 await 读文件,防止重复提交 */
|
||||
otaSessionTransition(OtaSessionState.UPLOADING);
|
||||
window.activeUploadChip = chip;
|
||||
/* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */
|
||||
|
||||
@@ -159,9 +159,39 @@ export function startRelayProgressWatch() {
|
||||
if (rstate >= 1 && rstate <= 4) {
|
||||
window.gd32RelaySessionLive = true;
|
||||
}
|
||||
/* 中继失败:必须把前端会话从 RELAYING 拉到 FAILED,否则无法新开上传 */
|
||||
if (code === 8 || rstate === 255) {
|
||||
stopRelayProgressWatch();
|
||||
window.pollLogs();
|
||||
if (window.otaSessionState === OtaSessionState.RELAYING ||
|
||||
window.otaSessionState === OtaSessionState.UPLOADING) {
|
||||
var failMsg = 'GD32 中继失败(relay_state=' + rstate +
|
||||
', sys=' + (o.sys_state || code) + ')';
|
||||
if (typeof window.cleanupAfterOtaFailure === 'function') {
|
||||
window.cleanupAfterOtaFailure(failMsg);
|
||||
} else {
|
||||
otaSessionTransition(OtaSessionState.FAILED, failMsg);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* ERROR 可能在 status 路径被软清为 IDLE,前端收不到 code=8。
|
||||
* 本会话已见过真实中继,设备却已回 IDLE 且未宣布完成 → 按失败清理。
|
||||
*/
|
||||
var idleAfterRelay = (code === 0 || code === 2) &&
|
||||
(rstate === 0 || rstate === 5) &&
|
||||
window.gd32RelaySessionLive &&
|
||||
!window.gd32CompleteAnnounced &&
|
||||
window.otaSessionState === OtaSessionState.RELAYING;
|
||||
if (idleAfterRelay) {
|
||||
stopRelayProgressWatch();
|
||||
window.pollLogs();
|
||||
if (typeof window.cleanupAfterOtaFailure === 'function') {
|
||||
window.cleanupAfterOtaFailure('GD32 中继已结束但未成功(设备已回 IDLE)');
|
||||
} else {
|
||||
otaSessionTransition(OtaSessionState.FAILED, 'GD32 中继已结束但未成功');
|
||||
}
|
||||
return;
|
||||
}
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user