diff --git a/components/transport/CMakeLists.txt b/components/transport/CMakeLists.txt index 1a99cb9..dafd67f 100644 --- a/components/transport/CMakeLists.txt +++ b/components/transport/CMakeLists.txt @@ -17,6 +17,7 @@ idf_component_register( "../../main/include" # app_config.h REQUIRES esp_driver_spi # SPI 主设备驱动 + esp_driver_gpio # GPIO 驱动(SPI Ready 握手引脚) freertos # FreeRTOS 内核 mbedtls # AES 加密 esp_timer # 高精度定时器 diff --git a/config/sdkconfig.defaults b/config/sdkconfig.defaults index a9a8cda..d52d44e 100644 --- a/config/sdkconfig.defaults +++ b/config/sdkconfig.defaults @@ -17,8 +17,9 @@ CONFIG_ESP32C3_REV_MIN_0=y CONFIG_ESP32C3_REV_MIN=0 # FreeRTOS -CONFIG_FREERTOS_UNICORE=n -CONFIG_FREERTOS_NUMBER_OF_CORES=2 +# ESP32-C3 为单核芯片,多核配置会让 IDF 在启动时校验失败或回退 +CONFIG_FREERTOS_UNICORE=y +CONFIG_FREERTOS_NUMBER_OF_CORES=1 CONFIG_FREERTOS_HZ=1000 # WiFi diff --git a/main/include/app_config.h b/main/include/app_config.h index 85eedfd..06b386c 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -54,16 +54,23 @@ inline constexpr uint8_t WIFI_AP_MAX_CONNECT = 4; ///< AP 模 // ============================================================ inline constexpr uint16_t HTTP_SERVER_PORT = 80; ///< HTTP 服务器监听端口 inline constexpr uint32_t HTTP_SERVER_STACK_SIZE = 8192; ///< HTTP 服务器任务栈大小(字节) +// ★ 曾降至 6142 省 2KB,但 system_status_handler→sample_cpu_usage() 在栈上分配 +// TaskStatus_t tasks[24](~864B)+previous_samples[24](192B)+CpuUsageSnapshot(~608B)≈1.7KB, +// 加上 spi_send_handler 的 buf[512]+resp[1024]+SPI 调用链 ≈3.5KB, +// 再加 httpd 框架开销 ~2KB → 总需求逼近 6KB,6144 栈导致栈溢出 → TCB 被破坏 +// → "Stack protection fault"(栈边界被 "http" ASCII 覆盖)。恢复 8192 留足余量。 // ============================================================ // OTA 配置 // ============================================================ -inline constexpr size_t OTA_BLOCK_SIZE = 8192; +inline constexpr size_t OTA_BLOCK_SIZE = 4096; ///< OTA 上传静态接收缓冲区,从 8192 降到 4096 省 4KB inline constexpr size_t OTA_MAX_FIRMWARE_SIZE = 216 * 1024; // 216KB = 221184B = 0x35F90,固件大小上限 inline constexpr size_t GD32_OTA_PACKET_SIZE = 48; // GD32 OTA 实际转发包载荷 inline constexpr uint32_t OTA_POLL_RETRIES = 40; // 每包 ACK 轮询次数 inline constexpr uint32_t OTA_RESEND_RETRIES = 10; // 每包重发次数上限 inline constexpr uint32_t OTA_PACKET_DELAY_MS = 5; // 每包发送后延时(ms),给 GD32 处理时间 +inline constexpr uint32_t OTA_POST_SEND_DELAY_MS = 3; // 发送请求后首次轮询前的延迟(ms),无 READY 拉低时防止读到 stale 数据 +inline constexpr uint32_t OTA_POLL_NO_FRAME_DELAY_MS = 8; // 轮询未找到帧时的重试延迟(ms),无 READY 拉低时需更长 // ============================================================ // 健康监控阈值 @@ -127,4 +134,3 @@ inline constexpr uint8_t GD32_CHIP_UID[] = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B }; - diff --git a/main/include/version.h b/main/include/version.h index 2b02fd4..c215374 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 36 -#define FW_VERSION_STRING "0.3.36" +#define FW_VERSION_PATCH 37 +#define FW_VERSION_STRING "0.3.37" // 编译时间 -#define BUILD_TIME "2026-07-02 01:59:27" +#define BUILD_TIME "2026-07-06 19:07:54" // Git 信息 -#define GIT_HASH "6335d05" +#define GIT_HASH "2fe5393" diff --git a/main/monitor/debug_log.cpp b/main/monitor/debug_log.cpp index c8a28b3..14a6982 100644 --- a/main/monitor/debug_log.cpp +++ b/main/monitor/debug_log.cpp @@ -118,10 +118,12 @@ char* DebugLog::ringDrainJson(int *out_len) if (!out_len) return NULL; *out_len = 0; int count = ringCount(); + // ★ 限制单次导出条数,避免 count=64 时 malloc(32KB) 在堆紧张时失败 + // 前端每秒轮询,16 条/次足够保持实时性;超出的旧条目下一轮再拉 + static constexpr int kMaxDrain = 16; + if (count > kMaxDrain) count = kMaxDrain; int capacity = count * (LOG_RING_SIZE * 2) + 16; if (capacity < 16) capacity = 16; - // 当 count 较大(如 64)时 capacity 可能达 32KB+,单次 malloc 有风险。 - // 当前为简化实现保留单次 malloc;若后续日志量增大,可改为按 8KB 分块 realloc。 char *buf = (char*)malloc(capacity); if (!buf) return NULL; @@ -390,14 +392,27 @@ void DebugLog::log(debug_log_level_t level, const char *tag, const char *fmt, .. } snprintf(prefix, sizeof(prefix), "[%s][%s]", level_str, tag); + // 控制单条日志栈缓冲大小,避免调用任务(如 can_fb)因日志格式化爆栈。 va_list args; va_start(args, fmt); - char msg[512]; + char msg[256]; vsnprintf(msg, sizeof(msg), fmt, args); va_end(args); - char log_buf[600]; + char log_buf[320]; int total = snprintf(log_buf, sizeof(log_buf), "%s %s\n", prefix, msg); + // ★ snprintf 截断时返回"本应写入的长度"(可能 > sizeof(log_buf)), + // 直接传给 send() 会越界读取栈外内存 → 破坏相邻任务 TCB → "Stack protection fault" + // (崩溃栈中出现 HTTP User-Agent / "/ota/relay" 等字符串即此症状)。 + // 截断到实际缓冲区大小,避免越界读。 + if (total < 0) { + return; + } + if ((size_t)total >= sizeof(log_buf)) { + total = (int)sizeof(log_buf) - 1; + log_buf[total] = '\0'; + } + send((const uint8_t *)log_buf, total); } diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 5b542a1..c3d13dc 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -224,19 +224,6 @@ void HealthMonitor::taskEntry(void *pvParameters) } } -// ============================================================ -// 健康事件日志 -// ============================================================ -/** - * @brief 记录一条健康事件日志。 - * @param event 事件名称。 - * @param value 事件值。 - */ -void HealthMonitor::logEvent(const char *event, int value) -{ - DEBUG_INFO("HEALTH", "[健康事件] %s: %d", event, value); -} - // ============================================================ // OTA 健康检查 // ============================================================ @@ -353,30 +340,30 @@ void HealthMonitor::setWifiConnected(bool connected) */ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi) { + health_state_t cur = getState(); + health_state_t next = SYS_NORMAL; if (rssi < WIFI_RSSI_CRITICAL) { - setState(SYS_CRITICAL); - DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL); - logEvent("WIFI危险", rssi); - return HEALTH_MON_CRITICAL; + next = SYS_CRITICAL; } else if (rssi < WIFI_RSSI_NORMAL) { - health_state_t cur = getState(); - if (cur < SYS_WARNING) { - setState(SYS_WARNING); - DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL); - logEvent("WIFI警告", rssi); - } - return HEALTH_MON_WARNING; + next = SYS_WARNING; } - // RSSI 已恢复正常(>= WIFI_RSSI_NORMAL):从 WARNING 或 CRITICAL 任一状态降级到 NORMAL。 - // 历史缺陷:原仅当 cur == SYS_WARNING 时降级,导致 CRITICAL 状态下 RSSI 恢复正常无法降级。 - // 注意:SAFE_SHUTDOWN 不应被 RSSI 恢复降级,仅处理 WARNING/CRITICAL。 - health_state_t cur = getState(); - if ((cur == SYS_WARNING || cur == SYS_CRITICAL) && rssi >= WIFI_RSSI_NORMAL) { - setState(SYS_NORMAL); - DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm (从 %d 降级)", rssi, WIFI_RSSI_NORMAL, (int)cur); - logEvent("WIFI恢复", rssi); + // 仅在状态发生转换时打印一次,避免同一阈值下反复刷屏。 + if (next != cur) { + if (next == SYS_CRITICAL) { + setState(SYS_CRITICAL); + DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL); + } else if (next == SYS_WARNING) { + setState(SYS_WARNING); + DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL); + } else { // next == SYS_NORMAL,从 WARNING/CRITICAL 恢复 + setState(SYS_NORMAL); + DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm (从 %d 降级)", rssi, WIFI_RSSI_NORMAL, (int)cur); + } } + if (next == SYS_CRITICAL) return HEALTH_MON_CRITICAL; + if (next == SYS_WARNING) return HEALTH_MON_WARNING; + return HEALTH_MON_OK; } diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index 134d131..414059d 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -65,8 +65,6 @@ public: /** @brief OTA 中继转发阶段执行健康检查(跳过 WiFi 检查)。 */ health_status_t checkBeforeOtaRelay(); - /** @brief 记录一条健康事件日志。 */ - void logEvent(const char *event, int value); /** @brief 获取当前系统健康状态(线程安全)。 */ health_state_t getCurrent(); diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 38ccfde..a7c143f 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -456,6 +456,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻 DEBUG_INFO(TAG, "ESP32 OTA 开始: %" PRIu32 " 字节, 系统状态 → ESP32_RECEIVING (epoch=%u, channel=%d)", total_size, m_otaEpoch, (int)channel); + pauseCanFeedbackTask(true); } else { DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err)); } @@ -582,6 +583,7 @@ void OtaCoordinator::doAbortLocked() Esp32OtaCore::instance().abort(); } + pauseCanFeedbackTask(false); m_sysState = OTA_SYS_IDLE; m_gd32StorageDone = false; m_busySinceTick = 0; diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 92b8b26..8194d78 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -418,7 +418,7 @@ esp_err_t WifiOta::httpServerStart() config.server_port = HTTP_SERVER_PORT; config.stack_size = HTTP_SERVER_STACK_SIZE; config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检 - config.max_open_sockets = 4; // LWIP_MAX_SOCKETS 默认 7,HTTP 内部占 3,最多 4 + config.max_open_sockets = 4; // 配合 LWIP_MAX_SOCKETS=12: 4(httpd) + 1(UDP) + ~3(LWIP内部) = 8, 余量 4; 空闲时前端 status/logs/上传可能并发,3 个易被 LRU 踢掉导致连接中断 config.lru_purge_enable = true; // socket 满时自动清理最久未用的连接 config.recv_wait_timeout = 30; // GD32 上传前会擦除 Flash,5 秒默认值过紧 config.send_wait_timeout = 30; @@ -734,6 +734,30 @@ void WifiOta::taskEntry(void *pvParameters) DEBUG_ERR("WIFI", "HTTP 服务器启动失败"); } + // ★ 开机清理:如果 NVS 中有不完整的 OTA 元数据(上次 OTA 中断), + // GD32 可能还卡在 Bootloader 中,导致 SPI 全面超时 → HTTP socket 饥荒。 + // 主动清除残留元数据并尝试恢复 GD32 APP。 + { + ota_meta_t boot_meta; + if (OtaCoordinator::instance().loadGd32Meta(&boot_meta) == ESP_OK && + boot_meta.total_size > 0 && boot_meta.stored_size < boot_meta.total_size) { + DEBUG_WARN("WIFI", "检测到不完整 OTA 缓存 (stored=%u total=%u),清除残留状态", + (unsigned)boot_meta.stored_size, (unsigned)boot_meta.total_size); + OtaCoordinator::instance().clearGd32Meta(); + OtaCoordinator::instance().abortCurrent(); + // 给 GD32 时间从 Bootloader 超时退出并重启到 APP + vTaskDelay(pdMS_TO_TICKS(3000)); + // 尝试探测 GD32 APP 是否恢复 + gd32_version_t ver = {}; + if (OtaCoordinator::instance().probeGd32AppOnce(&ver, nullptr) == ESP_OK) { + DEBUG_INFO("WIFI", "GD32 APP 已恢复: v%d.%d.%d.%d", + ver.app_major, ver.app_minor, ver.app_patch, ver.app_build); + } else { + DEBUG_WARN("WIFI", "GD32 APP 未恢复,SPI 链路可能不可用"); + } + } + } + // ★ WiFi 已连接,UDP 从这里开始能通 // 等待网络栈稳定(ARP 表建立、路由就绪),避免首批 UDP 包丢失 vTaskDelay(pdMS_TO_TICKS(500)); @@ -772,16 +796,9 @@ void WifiOta::taskEntry(void *pvParameters) int8_t rssi = WifiOta::getRssi(); if (rssi != -127) { HealthMonitor::instance().updateFromWifiRssi(rssi); - - if (rssi < WIFI_RSSI_CRITICAL) { - DEBUG_WARN("WIFI", "WiFi RSSI 危险: %d dBm (阈值: %d)", rssi, WIFI_RSSI_CRITICAL); - } else if (rssi < WIFI_RSSI_WARNING) { - DEBUG_WARN("WIFI", "WiFi RSSI 较弱: %d dBm", rssi); - } } } vTaskDelay(pdMS_TO_TICKS(1000)); } } - diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index fb8be27..07126da 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -4,6 +4,7 @@ #include #include "canfd_bridge.h" #include "spi_master.h" +#include "monitor/debug_log.h" namespace { @@ -39,7 +40,16 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, &resp_len, timeout_ms); if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK) { - return -1; + if (ret != ESP_OK) { + DEBUG_WARN("CAN", "CAN_SEND SPI失败: ch=%u id=0x%08lX err=%s", + (unsigned)channel, (unsigned long)ext_id, esp_err_to_name(ret)); + return -2; /* SPI 通信失败 */ + } + /* resp_type != ACK: 读取 GD32 返回的错误码 */ + uint8_t gd32_err = (resp_len > 0 && recv_buf) ? recv_buf[0] : 0xFF; + DEBUG_WARN("CAN", "CAN_SEND GD32拒绝: ch=%u id=0x%08lX resp=0x%02X err=0x%02X", + (unsigned)channel, (unsigned long)ext_id, resp_type, gd32_err); + return -3; /* GD32 返回 ERROR */ } if (recv_buf == nullptr || recv_max == 0U) { @@ -67,6 +77,7 @@ static inline uint32_t readU32Le(const uint8_t *p) { /** * @brief 发送一帧 CANFD 数据,可选等待并读取响应。 * 通过 SPI 协议帧发送给 GD32 APP,并读取 ACK/ERROR 响应。 + * 返回值: >=0 成功(接收数据长度), -1 参数错误, -2 SPI通信失败, -3 GD32拒绝。 */ int canFdSend(can_channel_t channel, uint32_t ext_id, const uint8_t *data, uint8_t dlc, diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 90de889..8de7fc2 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -12,6 +12,7 @@ #include "freertos/task.h" #include "freertos/semphr.h" #include "driver/spi_master.h" +#include "driver/gpio.h" #include "soc/spi_struct.h" #include "esp_err.h" #include "esp_log.h" @@ -35,6 +36,44 @@ SpiMaster& SpiMaster::instance() return inst; } +/** + * @brief 等待 GD32 PB6 SPI Ready 握手引脚拉高(就绪)。 + * GD32 从机在 CS 断言瞬间拉低 PB6(忙),在响应帧就绪后拉高(就绪)。 + * 主机每次发起 CS 前调用,确保从机已准备好接收下一笔事务。 + * @param timeout_ms 超时时间(毫秒),0 表示不等待直接返回。 + * @return ESP_OK 表示从机就绪;ESP_ERR_TIMEOUT 表示超时(退化为继续发起)。 + */ +esp_err_t SpiMaster::waitSpiReady(uint32_t timeout_ms) +{ + if (timeout_ms == 0U) { + return ESP_OK; + } + + const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); + for (;;) { + int level = gpio_get_level((gpio_num_t)SPI_READY_GPIO); + if (level == 1) { + /* 从机就绪:复位超时计数器,下次掉线时首次超时又能立即告警 */ + m_spiReadyTimeoutCount = 0U; + return ESP_OK; + } + if (xTaskGetTickCount() >= deadline) { + /* 限频:首次超时立即告警,之后每 kSpiReadyWarnEvery 次打印一条, + * 避免 GD32 掉线/卡死时高频日志刷屏。计数器无锁:仅在持锁的 + * transferClocked 路径调用,单任务访问,无需保护。 */ + ++m_spiReadyTimeoutCount; + if (m_spiReadyTimeoutCount == 1U || + (m_spiReadyTimeoutCount % kSpiReadyWarnEvery) == 0U) { + DEBUG_WARN("SPI", "waitSpiReady 超时: GPIO%d 仍为 LOW(从机忙), timeout=%ums, 累计=%lu", + SPI_READY_GPIO, (unsigned)timeout_ms, + (unsigned long)m_spiReadyTimeoutCount); + } + return ESP_ERR_TIMEOUT; + } + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + // ============================================================ // 内部 SPI 传输 // ============================================================ @@ -69,6 +108,11 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, return ESP_ERR_INVALID_SIZE; } + /* CS 断言前等待 GD32 PB6 拉高(就绪):从机在上一笔 onCsRelease/ + * processCommand 末尾才会拉高 PB6,提前发起 CS 会因从机未装好 + * DMA/响应帧而丢失字节。超时退化为继续发起,保留原有容错路径。 */ + (void)waitSpiReady(SPI_READY_TIMEOUT_MS); + uint8_t tx_staging[kSpiTransferMaxBytes]; uint8_t rx_staging[kSpiTransferMaxBytes]; const uint8_t *tx_ptr = tx_buf; @@ -336,6 +380,23 @@ esp_err_t SpiMaster::init() DEBUG_INFO("SPI", "SPI2 总线初始化完成: MOSI=GPIO5, MISO=GPIO7, SCLK=GPIO6, CS=GPIO%d, CLK=%d Hz", SPI_MASTER_CS_GPIO, SPI_CLOCK_HZ); + /* 配置 SPI Ready 握手输入引脚(GPIO10),监测 GD32 PB6 电平。 + * GD32 在 CS 下降沿拉低 PB6(忙),在 onCsRelease/processCommand 末尾拉高(就绪)。 + * 主机每次发起 CS 前须等待 PB6=HIGH,避免从机未就绪时抢跑丢帧。 */ + gpio_config_t ready_cfg = {}; + ready_cfg.pin_bit_mask = (1ULL << SPI_READY_GPIO); + ready_cfg.mode = GPIO_MODE_INPUT; + ready_cfg.pull_up_en = GPIO_PULLUP_ENABLE; + ready_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; + ready_cfg.intr_type = GPIO_INTR_DISABLE; + esp_err_t gpio_ret = gpio_config(&ready_cfg); + if (gpio_ret != ESP_OK) { + DEBUG_WARN("SPI", "SPI Ready 握手引脚 GPIO%d 配置失败: %s", SPI_READY_GPIO, esp_err_to_name(gpio_ret)); + /* 非致命:握手失败时退化为不等待,保持原有行为 */ + } else { + DEBUG_INFO("SPI", "SPI Ready 握手输入已配置: GPIO%d (监测 GD32 PB6)", SPI_READY_GPIO); + } + return ESP_OK; } diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 843278d..dc4e7e8 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -1,4 +1,4 @@ -/* +/* * spi_master.h - SPI Master 驱动头文件 * CANFD 3通道发送接口 + 底层 SPI 总线 * @@ -17,6 +17,8 @@ #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz #define SPI_MODE (0) // CPOL=0, CPHA=0 #define SPI_QUEUE_SIZE (1) ///< SPI 事务队列深度 +#define SPI_READY_GPIO (10) // GD32 PB6 SPI Ready 握手输入引脚(HIGH=就绪,LOW=忙) +#define SPI_READY_TIMEOUT_MS (100) // waitSpiReady 单次超时(毫秒) #define SPI_OTA_FRAME_MAX (133) ///< SPI OTA 单帧最大字节数 // GD32 SPI OTA 帧类型 @@ -153,6 +155,11 @@ private: SemaphoreHandle_t m_lock; }; + /** @brief 等待 GD32 PB6 SPI Ready 握手引脚拉高(就绪),CS 断言前调用。 */ + esp_err_t waitSpiReady(uint32_t timeout_ms = SPI_READY_TIMEOUT_MS); + // waitSpiReady 超时日志限频:每 kSpiReadyWarnEvery 次超时打印一条,避免 GD32 掉线时刷屏 + static constexpr uint32_t kSpiReadyWarnEvery = 32U; ///< 超时日志打印间隔 + uint32_t m_spiReadyTimeoutCount = 0U; ///< 累计超时次数(用于限频) // 内部 SPI 传输 /** * @brief 执行底层 SPI 时钟同步传输。 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 8e852b3..be2aec2 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -2,8 +2,10 @@ CONFIG_ESP_WIFI_RX_BA_WIN=6 CONFIG_ESP_WIFI_TX_BA_WIN=6 -# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 = 7 够用 -CONFIG_LWIP_MAX_SOCKETS=7 +# LWIP: HTTP server max_open_sockets=4 + WiFi/LWIP 内部 ~3 + UDP debug log + 余量 +# 原值 7 与 httpd max_open_sockets=4 仅余 3 个 socket,一旦 handler 阻塞即 accept(EMFILE) +# 提到 12 留足缓冲,配合 can_feedback_task 退避从根本上避免 socket 饥荒 +CONFIG_LWIP_MAX_SOCKETS=12 CONFIG_LWIP_TCP_WND_DEFAULT=11480 CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 diff --git a/tools/app.js b/tools/app.js index d914cf4..707556f 100644 --- a/tools/app.js +++ b/tools/app.js @@ -27,6 +27,7 @@ let latestSystemStatus = null; let isEsp32Rebooting = false; let rebootStartTime = 0; let pollingPausedForUpload = false; +let uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 let activeXhr = null; // 当前上传 XHR,用于 abort let uploadBtnOrigText = ''; // 上传按钮原始文字 let activeUploadChip = null; // 当前上传目标芯片 ('esp32' | 'gd32' | null) @@ -588,6 +589,9 @@ function updateHeapAndTaskStatusUi(data) { setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : '')); setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--'); + // 缓存最近一次成功的 sys_state_code / relay_state,供 pollStatus 失败时判断设备是否在忙 + if (Number.isFinite(ota.sys_state_code)) lastSysStateCode = ota.sys_state_code; + if (Number.isFinite(ota.relay_state)) lastRelayState = ota.relay_state; var storedText = '--'; if (ota.total_size || ota.stored_size) { storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0); @@ -795,6 +799,7 @@ function updateSystemStatusUi(data) { if (stErr) stErr.textContent = 'GD32 错误(待机)'; } else if (isGd32Complete || rs === 5 || (gd32AppAlive && sysState === 'GD32_COMPLETE')) { markPhaseDone('gd32Ota'); + currentOtaTarget = null; // GD32 OTA 完成,允许切换目标 setOtaProgressBadge(100); setProgress(100, 'GD32 刷写完成'); var sbDone = document.getElementById('stageStatusBar'); @@ -830,6 +835,7 @@ function updateSystemStatusUi(data) { setOtaProgressBadge(95); if (gd32AppAlive) { markPhaseDone('gd32Ota'); + currentOtaTarget = null; // GD32 OTA 完成,允许切换目标 setProgress(100, 'GD32 刷写完成'); var sb = document.getElementById('stageStatusBar'); var st = document.getElementById('stageStatusText'); @@ -856,7 +862,7 @@ async function testConnection() { // 立即发一次请求检测 try { var resp = await fetch(getBaseUrl() + '/system/status', { - signal: AbortSignal.timeout(3000) + signal: statusAbortCtrl.signal }); if (resp.ok) { var data = await resp.json(); @@ -885,10 +891,10 @@ async function refreshSystemStatusNow(event) { event.preventDefault(); event.stopPropagation(); } - if (pollingPausedForUpload) return; + // ★ ESP32 上传期间也允许日志轮询,让用户看到完整进展 try { var resp = await fetch(getBaseUrl() + '/system/status', { - signal: AbortSignal.timeout(3000) + signal: statusAbortCtrl.signal }); if (!resp.ok) { addLog('E', '状态', '刷新失败: HTTP ' + resp.status); @@ -1749,6 +1755,9 @@ var logsTimerId = null; var pollSlowMode = false; // 降速模式(10s 一次) var lastDiagTime = 0; // 上次诊断时间 var diagResult = ''; // 最近一次诊断结果 +var lastSysStateCode = -1; // 上次成功的 sys_state_code,用于失败时判断设备是否在忙 +var lastRelayState = 0; // 上次成功的 relay_state +var statusAbortCtrl = null; // 取消上一次 in-flight pollStatus,防止堆积刷新 // 从设备 IP 推导网关 IP(取前三段 + .1) function getGatewayIp() { var parts = getEspIp().split('.'); @@ -1818,15 +1827,19 @@ function restorePolling() { function pausePollingForUpload() { if (pollingPausedForUpload) return; pollingPausedForUpload = true; + uploadPollChip = activeUploadChip || window.flashTarget || null; consecutivePollFailures = 0; if (statusTimerId) clearInterval(statusTimerId); if (logsTimerId) clearInterval(logsTimerId); - statusTimerId = null; - logsTimerId = null; + // ★ ESP32/GD32 上传期间均保持轮询,让用户在网页上看到完整 OTA 进展 + // ESP32 用 3s 间隔(上传为主),GD32 用 2.5s(中继进度依赖 status) + statusTimerId = setInterval(pollStatus, uploadPollChip === "gd32" ? 2500 : 3000); + logsTimerId = setInterval(pollLogs, uploadPollChip === "gd32" ? 2500 : 3000); } function resumePollingAfterUpload() { if (!pollingPausedForUpload) return; pollingPausedForUpload = false; + uploadPollChip = null; consecutivePollFailures = 0; if (pollSlowMode) { statusTimerId = setInterval(pollStatus, 10000); @@ -1840,10 +1853,14 @@ function resumePollingAfterUpload() { } /** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */ async function pollStatus() { - if (pollingPausedForUpload) return; + // ★ 上传期间状态轮询间隔已在 pausePollingForUpload 中缩短,此处不再拦截 + // 取消上一次 in-flight 请求,防止慢响应堆积导致 UI 瞬间刷新多次 + if (statusAbortCtrl) { try { statusAbortCtrl.abort(); } catch(_e) {} } + statusAbortCtrl = new AbortController(); + var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 3000); try { var resp = await fetch(getBaseUrl() + '/system/status', { - signal: AbortSignal.timeout(3000) + signal: statusAbortCtrl.signal }); if (resp.ok) { var data = await resp.json(); @@ -1865,12 +1882,26 @@ async function pollStatus() { } try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); } } + clearTimeout(pollTimer); } catch (e) { + clearTimeout(pollTimer); + // 自身 AbortController 触发的取消不算失败,静默返回 + if (e && e.name === "AbortError") return; // 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次, // 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。 if (pageVisible) consecutivePollFailures++; + // 判断设备是否处于"已知忙/重启"状态(依据最近一次成功轮询的 sys_state_code): + // 1=GD32_STORING, 3=GD32_RELAYING, 5=ESP32_RECEIVING, + // 6=ESP32_FINALIZING, 7=ESP32_REBOOTING + // 也包含 relay_state 非 IDLE(1-4 表示握手/协商/传输/收尾) + var lastDeviceBusy = + lastSysStateCode === 1 || lastSysStateCode === 3 || + lastSysStateCode === 5 || lastSysStateCode === 6 || + lastSysStateCode === 7 || + (lastRelayState >= 1 && lastRelayState <= 4); // OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的) - if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget) { + // 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈 + if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting && !currentOtaTarget && !lastDeviceBusy) { addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); var diag = await diagnoseNetwork(); addLog('W', '诊断', diag); @@ -1895,6 +1926,18 @@ async function pollStatus() { } else { applyRebootingRuntimeState(); } + } else if (lastDeviceBusy) { + // 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙" + // 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导) + var pc = document.getElementById('pillConn'); + var pct = document.getElementById('pillConnTxt'); + if (pc) pc.className = 'pill processing'; + if (pct) pct.textContent = '设备忙'; + var cs = document.getElementById('connStatus'); + var cst = document.getElementById('connStatusTxt'); + if (cs) cs.className = 'conn-indicator warn'; + if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询'; + updateConnectionUi(true, '设备忙'); } else if (!pollSlowMode) { updateConnectionUi(false, '未连接'); applyDisconnectedRuntimeState(); @@ -1919,7 +1962,7 @@ document.addEventListener('visibilitychange', function () { var lastLogTimestamp = 0; /** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */ async function pollLogs() { - if (pollingPausedForUpload) return; + // ★ ESP32 上传期间也允许日志轮询 try { var resp = await fetch(getBaseUrl() + '/logs', { signal: AbortSignal.timeout(2000) diff --git a/tools/can.js b/tools/can.js index e196a9d..76778f1 100644 --- a/tools/can.js +++ b/tools/can.js @@ -1039,12 +1039,6 @@ return json; } - function buildSendResponseText(result) { - if (result.status === 'ok') { - return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据'; - } - return '发送失败: code=' + result.code; - } function updateResponseBox(box, textEl, result, mode) { if (!box || !textEl) return; @@ -1478,5 +1472,20 @@ setTimeout(testConnection, 150); } + var CAN_SEND_ERR_DESC = { + '-1': '参数错误(通道/ID/DLC 无效)', + '-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)', + '-3': 'GD32 CAN 总线拒绝(BUS_OFF / TEC 过高 / 邮箱满)', + }; + + function buildSendResponseText(result) { + if (result.status === 'ok') { + return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据'; + } + var code = String(result.code); + var desc = CAN_SEND_ERR_DESC[code] || '未知错误'; + return '发送失败: ' + desc + ' (code=' + result.code + ')'; + } + init(); })(); diff --git a/tools/can_monitor.js b/tools/can_monitor.js index 4408425..9395fe4 100644 --- a/tools/can_monitor.js +++ b/tools/can_monitor.js @@ -53,8 +53,12 @@ // ── 1Hz 轮询 /can/feedback ───────────────────────────────── async function pollFeedback() { + // 取消上一次 in-flight 请求,防止 SPI 慢响应堆积导致 UI 瞬间刷新多次 + if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch(_e) {} } + state.fbAbortCtrl = new AbortController(); + var fbPollTimer = setTimeout(function() { try { state.fbAbortCtrl.abort(); } catch(_e) {} }, 3000); try { - var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) }); + var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal }); if (!resp.ok) return; var data = await resp.json(); var channels = data.channels || []; @@ -86,7 +90,11 @@ el.fbPollStatus.className = 'pill online'; } setConnState(true, '已连接'); + clearTimeout(fbPollTimer); } catch (e) { + clearTimeout(fbPollTimer); + // 自身 AbortController 触发的取消不算失败,静默返回 + if (e && e.name === "AbortError") return; if (el.fbPollStatus) { el.fbPollStatus.textContent = '轮询: 离线'; el.fbPollStatus.className = 'pill offline'; diff --git a/tools/index.html b/tools/index.html index 72d1ca5..10cb134 100644 --- a/tools/index.html +++ b/tools/index.html @@ -1,4 +1,4 @@ -