feat(transport): 添加SPI Ready握手引脚支持并优化GPIO驱动依赖
添加ESP32-C3 GPIO驱动组件以支持SPI Ready握手机制,新增 SPI_READY_GPIO(10)引脚配置用于监测GD32 PB6就绪状态,避免SPI 通信时从机未准备好的丢帧问题。
This commit is contained in:
@@ -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 # 高精度定时器
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <string.h>
|
||||
#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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 时钟同步传输。
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
|
||||
+52
-9
@@ -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)
|
||||
|
||||
+15
-6
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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';
|
||||
|
||||
+9
-2
@@ -1,4 +1,4 @@
|
||||
<!--
|
||||
<!--
|
||||
index.html - ESP32 OTA 中继控制台主页面
|
||||
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
|
||||
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA), style.css (UI 样式)
|
||||
@@ -427,7 +427,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260626a"></script>
|
||||
<script src="app.js?v=20260705b"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
@@ -484,6 +484,13 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
};
|
||||
|
||||
window.selectFlashTarget = function (target) {
|
||||
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
|
||||
if (window.isUploading) {
|
||||
var active = (window.activeUploadChip || 'OTA').toUpperCase();
|
||||
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
|
||||
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
|
||||
return;
|
||||
}
|
||||
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
|
||||
applySharedModeUi();
|
||||
syncBleFileFromSelectedTarget();
|
||||
|
||||
Reference in New Issue
Block a user