feat(can): 优化CAN反馈任务错误日志记录机制

- 添加s_lastLoggedError数组跟踪各通道最后记录的错误计数
- 实现错误计数变化时才触发告警,避免重复打印
- 扩展错误日志信息,包含TEC、REC和总线状态详细信息
- 修复P0/P1页面携带相同统计数据时的重复打印问题

perf(ota): 移除冗余的OTA协调器状态检查方法

- 删除isGd32BusyState、isEsp32BusyState等未使用的状态判断方法
- 移除esp32FinalizeErrCategory错误分类函数
- 清理相关的头文件声明,减少代码冗余

refactor(relay): 移除未使用的中继状态获取方法

- 删除getRelaySeq、getRelayTotalPackets状态查询接口
- 移除getGd32Version版本获取方法
- 清理StateLockGuard中的isLocked方法
This commit is contained in:
小龙虾
2026-07-11 17:51:42 +08:00
parent fedb08e215
commit c0ca4e2a9f
18 changed files with 404 additions and 630 deletions
+4 -4
View File
@@ -4,11 +4,11 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 39
#define FW_VERSION_STRING "0.3.39"
#define FW_VERSION_PATCH 40
#define FW_VERSION_STRING "0.3.40"
// 编译时间
#define BUILD_TIME "2026-07-09 02:43:44"
#define BUILD_TIME "2026-07-11 17:47:55"
// Git 信息
#define GIT_HASH "661ae54"
#define GIT_HASH "fedb08e"
+11 -21
View File
@@ -39,6 +39,7 @@ static volatile bool s_paused = false;
/* 累积工作缓冲:跨 page 0/1 累积每通道的完整反馈 */
static can_feedback_result_t s_work [CAN_CHANNEL_MAX];
static uint8_t s_workIdx[CAN_CHANNEL_MAX];
static uint32_t s_lastLoggedError[CAN_CHANNEL_MAX];
static void publish(uint8_t ch, const can_feedback_result_t &fb)
{
@@ -86,19 +87,22 @@ static void taskLoop(void *)
}
/* 解析本页并累积到工作缓冲,page=0 会自动重置 */
const uint8_t page = ring_payload[1];
if (canFdParseFeedbackPage(ring_payload, ring_len,
&s_work[ch], &s_workIdx[ch]) == 0) {
publish(ch, s_work[ch]);
/* 仅在出现错误时打印告警,正常工作静默 */
if (s_work[ch].stats.error_count != 0U) {
/* 累计错误发生变化时才告警一次;P0/P1 携带相同统计,不能重复打印。 */
if (s_work[ch].stats.error_count != 0U
&& s_work[ch].stats.error_count != s_lastLoggedError[ch]) {
DEBUG_WARN(kTag,
"CH%u P%u seq=%lu err=%lu",
"CH%u seq=%lu err=%lu TEC=%u REC=%u state=0x%02X",
static_cast<unsigned>(ch),
static_cast<unsigned>(page),
static_cast<unsigned long>(s_work[ch].stats.snapshot_seq),
static_cast<unsigned long>(s_work[ch].stats.error_count));
static_cast<unsigned long>(s_work[ch].stats.error_count),
static_cast<unsigned>(s_work[ch].stats.tec),
static_cast<unsigned>(s_work[ch].stats.rec),
static_cast<unsigned>(s_work[ch].stats.bus_state));
s_lastLoggedError[ch] = s_work[ch].stats.error_count;
}
}
}
@@ -123,6 +127,7 @@ bool startCanFeedbackTask()
}
}
memset(s_latest, 0, sizeof(s_latest));
memset(s_lastLoggedError, 0, sizeof(s_lastLoggedError));
memset(s_valid, 0, sizeof(s_valid));
memset(s_work, 0, sizeof(s_work));
memset(s_workIdx, 0, sizeof(s_workIdx));
@@ -153,21 +158,6 @@ void pauseCanFeedbackTask(bool pause)
}
}
bool getLatestIdSnapshot(uint8_t channel, id_snapshot_cache_t *out)
{
if (channel >= CAN_CHANNEL_MAX || out == nullptr || s_mutex == nullptr) return false;
bool ok = false;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
if (s_latest[channel].valid) {
out->count = s_latest[channel].valid;
memcpy(out->entries, s_latest[channel].entries, sizeof(out->entries));
ok = true;
}
xSemaphoreGive(s_mutex);
}
return ok;
}
/**
* @brief 拷贝某通道的最新快照(线程安全)。
* @return true 表示拿到一份有效数据。
-15
View File
@@ -7,13 +7,6 @@
#include <stdint.h>
#include "transport/canfd_bridge.h"
/** @brief ID 快照缓存结构。 */
typedef struct {
id_snapshot_entry_t entries[CANFD_ID_SNAPSHOT_MAX];
uint8_t count;
bool valid;
} id_snapshot_cache_t;
/** @brief 暂停/恢复 CAN 反馈轮询(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause);
@@ -35,14 +28,6 @@ bool startCanFeedbackTask();
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out);
/**
* @brief 获取指定通道的最新 ID 快照(线程安全)。
* @param channel 0/1/2。
* @param out 输出缓冲。
* @return true 有有效数据。
*/
bool getLatestIdSnapshot(uint8_t channel, id_snapshot_cache_t *out);
#ifdef __cplusplus
}
#endif
+1 -57
View File
@@ -555,18 +555,6 @@ void OtaCoordinator::restartEsp32()
// 不会返回
}
/**
* @brief 将 ESP32 OTA finalize 错误码分类。
* @param err finalize 返回的 esp_err_t。
* @return 0=成功,1=SHA不匹配,2=其他失败。
*/
int OtaCoordinator::esp32FinalizeErrCategory(esp_err_t err)
{
if (err == ESP_OK) return 0; // 成功
if (err == ESP_ERR_INVALID_VERSION) return 1; // SHA 不匹配
return 2; // 其他失败
}
// ============================================================
// 中止
// ============================================================
@@ -655,39 +643,12 @@ bool OtaCoordinator::isIdleState(ota_system_state_t s) const
s == OTA_SYS_GD32_COMPLETE);
}
/** @brief 判断给定状态值是否属于 GD32 忙态(不加锁,纯判断)。 */
bool OtaCoordinator::isGd32BusyState(ota_system_state_t s) const
{
return (s == OTA_SYS_GD32_STORING ||
s == OTA_SYS_GD32_RELAYING);
}
/** @brief 判断给定状态值是否属于 ESP32 忙态(不加锁,纯判断)。 */
bool OtaCoordinator::isEsp32BusyState(ota_system_state_t s) const
{
return (s == OTA_SYS_ESP32_RECEIVING ||
s == OTA_SYS_ESP32_FINALIZING ||
s == OTA_SYS_ESP32_REBOOTING);
}
/** @brief 判断系统是否处于空闲状态(IDLE/STORED/COMPLETE)。 */
bool OtaCoordinator::isIdle() const
{
return isIdleState(getSystemState());
}
/** @brief 判断 GD32 通道是否正忙(STORING 或 RELAYING)。 */
bool OtaCoordinator::isGd32Busy() const
{
return isGd32BusyState(getSystemState());
}
/** @brief 判断 ESP32 通道是否正忙(RECEIVING/FINALIZING/REBOOTING)。 */
bool OtaCoordinator::isEsp32Busy() const
{
return isEsp32BusyState(getSystemState());
}
/** @brief 把状态枚举转成可读字符串(不加锁,纯函数)。
* 供持锁代码路径和已取一次状态值的调用方复用,避免二次加锁。 */
const char* OtaCoordinator::stateToString(ota_system_state_t s)
@@ -706,29 +667,12 @@ const char* OtaCoordinator::stateToString(ota_system_state_t s)
}
}
/** @brief 获取系统状态的字符串名称(用于日志和前端显示)。
* 委托 stateToString(getSystemState()),保持向后兼容。 */
const char* OtaCoordinator::getSystemStateString() const
{
return stateToString(getSystemState());
}
/** @brief 获取 GD32 APP 存活状态(委托 OtaRelay)。 */
bool OtaCoordinator::getGd32AppAlive() const
{
return OtaRelay::getGd32AppAlive();
}
/** @brief 获取 GD32 固件是否已存储完成(线程安全)。 */
bool OtaCoordinator::isGd32StorageDone() const
{
// ★ M1/L3: m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast
bool h = lock();
bool done = m_gd32StorageDone;
unlock(h);
return done;
}
// ============================================================
// OTA 状态快照(PR1: 收口状态读取)
// ============================================================
@@ -738,7 +682,7 @@ OtaSnapshot OtaCoordinator::getSnapshot() const
// ── coordinator 自身状态(一次 mutex,避免二次加锁导致快照不一致)──
// 持锁时一次性取 m_sysState,并调不加锁的 stateToString()
// 就地转字符串。旧实现释放锁后再调 getSystemStateString() 二次加锁,
// 就地转字符串。旧实现释放锁后再调 stateToString(getSystemState()) 二次加锁,
// 两次取值之间状态可能变化,导致 snap.sys_state 与 snap.sys_state_str
// 描述不同时刻状态。
bool h = lock();
-50
View File
@@ -395,20 +395,6 @@ public:
*/
bool isIdleState(ota_system_state_t s) const;
/**
* @brief 检查给定状态值是否属于 GD32 忙态(不加锁,纯判断)。
* @param s 状态枚举值。
* @return true 表示 STORING 或 RELAYING。
*/
bool isGd32BusyState(ota_system_state_t s) const;
/**
* @brief 检查给定状态值是否属于 ESP32 忙态(不加锁,纯判断)。
* @param s 状态枚举值。
* @return true 表示 RECEIVING/FINALIZING/REBOOTING。
*/
bool isEsp32BusyState(ota_system_state_t s) const;
/**
* @brief 检查系统是否空闲(可接受新的 OTA 请求)。
* 内部仍加锁取一次状态后调 isIdleState()。
@@ -416,27 +402,6 @@ public:
*/
bool isIdle() const;
/**
* @brief 检查是否有 GD32 相关操作正在进行。
* 内部仍加锁取一次状态后调 isGd32BusyState()。
* @return true 表示 STORING 或 RELAYING。
*/
bool isGd32Busy() const;
/**
* @brief 检查是否有 ESP32 OTA 正在进行。
* 内部仍加锁取一次状态后调 isEsp32BusyState()。
* @return true 表示 RECEIVING/FINALIZING/REBOOTING。
*/
bool isEsp32Busy() const;
/**
* @brief 获取系统状态的可读字符串描述。
* 内部委托 stateToString(getSystemState()),保持向后兼容。
* @return 状态名称字符串(如 "IDLE"、"GD32_STORING" 等)。
*/
const char* getSystemStateString() const;
// ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)──
/**
* @brief 获取 GD32 APP 存活状态。
@@ -444,27 +409,12 @@ public:
*/
bool getGd32AppAlive() const;
/**
* @brief GD32 固件存储完成标志(供状态查询)。
* @return true 表示固件已完整存储。
*/
bool isGd32StorageDone() const;
// ── 完全复位 ──
/**
* @brief 完全复位协调器和所有子系统状态。
*/
void reset();
// ── ESP32 OTA finalize 错误分类(BLE/WiFi 共用)──
/**
* @brief 把 finalize 返回的 esp_err_t 映射为前端可读的事件码。
* 统一 BLE/WiFi 两端语义,便于日志/状态一致。
* @param err finalize 返回的 esp_err_t。
* @return 0=成功;1=SHA 不匹配;2=其他失败。
*/
static int esp32FinalizeErrCategory(esp_err_t err);
// ── IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)──
/**
* @brief IRelayListener 回调:中继已开始。
-19
View File
@@ -123,31 +123,12 @@ relay_state_t OtaRelay::getState()
return relayState;
}
uint32_t OtaRelay::getRelaySeq()
{
StateLockGuard lock;
return relaySeq;
}
uint32_t OtaRelay::getRelayTotalPackets()
{
StateLockGuard lock;
return relayTotalPackets;
}
bool OtaRelay::getGd32AppAlive()
{
StateLockGuard lock;
return gd32AppAlive;
}
void OtaRelay::getGd32Version(gd32_version_t *out)
{
if (!out) return;
StateLockGuard lock;
*out = gd32Version;
}
RelaySnapshot OtaRelay::getSnapshot()
{
StateLockGuard lock;
-16
View File
@@ -134,26 +134,11 @@ public:
* @return 当前 relay_state_t 状态值。
*/
static relay_state_t getState();
/**
* @brief 获取中继当前序号.
* @return 当前中继序号。
*/
static uint32_t getRelaySeq();
/**
* @brief 获取中继总包数.
* @return 本次中继的总包数。
*/
static uint32_t getRelayTotalPackets();
/**
* @brief 获取GD32 APP存活标志位.
* @return true 表示 GD32 APP 存活。
*/
static bool getGd32AppAlive();
/**
* @brief 获取GD32固件版本.
* @param out 输出版本信息结构体指针。
*/
static void getGd32Version(gd32_version_t *out);
/**
* @brief 一次性读取所有中继状态字段(单次锁保护)。
* 消除逐 getter 调用之间 relay task 更新状态导致的不一致。
@@ -238,7 +223,6 @@ public:
~StateLockGuard() {
if (m_locked && s_stateMutex) { xSemaphoreGive(s_stateMutex); }
}
bool isLocked() const { return m_locked; }
StateLockGuard(const StateLockGuard&) = delete;
StateLockGuard& operator=(const StateLockGuard&) = delete;
private:
-103
View File
@@ -199,109 +199,6 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size)
return total;
}
esp_err_t ota_prepare_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
DEBUG_INFO("WIFI", "=== HTTP: POST /ota/prepare ===");
char buf[256] = {0};
int ret = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (ret <= 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "recv failed");
return ESP_FAIL;
}
buf[ret] = '\0';
cJSON *root = cJSON_Parse(buf);
if (!root) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON parse failed");
return ESP_FAIL;
}
cJSON *type_item = cJSON_GetObjectItem(root, "type");
cJSON *size_item = cJSON_GetObjectItem(root, "total_size");
if (!type_item || !cJSON_IsString(type_item) ||
!size_item || !cJSON_IsNumber(size_item)) {
cJSON_Delete(root);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing type or total_size");
return ESP_FAIL;
}
const char *type = cJSON_GetStringValue(type_item);
uint32_t total_size = (uint32_t)cJSON_GetNumberValue(size_item);
cJSON_Delete(root);
if (type && strcmp(type, "esp32") == 0) {
clear_esp32_prepare();
esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_WIFI);
if (err != ESP_OK) {
if (err == ESP_ERR_INVALID_STATE) {
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"ESP32 OTA occupied by another channel");
} else {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed");
}
return ESP_FAIL;
}
s_esp32_prepared = true;
s_esp32_prepare_size = total_size;
s_esp32_prepare_epoch = OtaCoordinator::instance().getOtaEpoch();
const char resp[] = "{\"status\": \"ok\", \"target\": \"esp32\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
DEBUG_INFO("WIFI", "ESP32 OTA 准备完成: %u 字节", total_size);
return ESP_OK;
}
if (type && strcmp(type, "gd32") == 0) {
clear_gd32_prepare();
if (OtaRelay::getState() != RELAY_IDLE) {
DEBUG_WARN("WIFI", "GD32 prepare 前复位中继状态: %d", OtaRelay::getState());
OtaRelay::instance().reset();
}
// ★ 统一调用 prepareGd32Storage 完成:validate + begin + erase + clearMeta + saveMeta
// 与 BLE 路径 / ota_upload_handler 共享同一序列,消除手写重复
esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage(total_size, OTA_CHANNEL_WIFI);
if (prep_err == ESP_ERR_INVALID_SIZE) {
DEBUG_ERR("WIFI", "prepare: firmware size invalid: %u", total_size);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "firmware size invalid");
return ESP_FAIL;
} else if (prep_err == ESP_ERR_INVALID_STATE) {
DEBUG_ERR("WIFI", "GD32 prepare 协调器拒绝(系统忙)");
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"system busy with another OTA operation");
return ESP_FAIL;
} else if (prep_err != ESP_OK) {
DEBUG_ERR("WIFI", "GD32 prepare 失败: %s", esp_err_to_name(prep_err));
char err_msg[96];
snprintf(err_msg, sizeof(err_msg), "prepare gd32_ota failed: %s",
esp_err_to_name(prep_err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg);
return ESP_FAIL;
}
WifiOta::instance().stateLock();
WifiOta::instance().setGd32StorageDone(false);
WifiOta::instance().stateUnlock();
s_gd32_prepared = true;
s_gd32_prepare_size = total_size;
s_gd32_prepare_epoch = OtaCoordinator::instance().getOtaEpoch();
const char resp[] = "{\"status\": \"ok\", \"target\": \"gd32\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
DEBUG_INFO("WIFI", "GD32 OTA 准备完成: %u 字节", total_size);
return ESP_OK;
}
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "unknown type");
return ESP_FAIL;
}
esp_err_t ota_upload_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
-2
View File
@@ -69,8 +69,6 @@ public:
bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); }
/** @brief 设置 STA 重试耗尽标志。 */
void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); }
/** @brief 获取 GD32 固件存储完成标志。 */
bool getGd32StorageDone() const { return m_gd32StorageDone.load(); }
/** @brief 设置 GD32 固件存储完成标志。 */
void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); }
// ── 状态互斥锁(保护复合读-改-写操作)──
-86
View File
@@ -58,7 +58,6 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr uint8_t kSpiCanSetModeType = 0xC2U;
constexpr uint8_t kSpiIdSnapshotType = 0xC3U;
constexpr size_t kFeedbackPayloadLen = 123U; // 与 GD32 协议表对齐 (kTxBufSize=128 - overhead=5)
static inline uint32_t readU32Le(const uint8_t *p) {
@@ -86,21 +85,6 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
return sendCanFrameOverSpi(channel, ext_id, data, dlc, recv_buf, recv_max, timeout_ms);
}
/**
* @brief 发送一帧无需上层消费响应的 CANFD 数据。
* GD32 APP 仍会返回 ACK/ERROR,这里主动读走响应,避免残留到下一笔 SPI 事务。
*/
int canFdSendNoReply(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc)
{
if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) {
return -1;
}
return (sendCanFrameOverSpi(channel, ext_id, data, dlc, nullptr, 0U, 1000U) >= 0) ? 0 : -1;
}
/**
* @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。
*
@@ -176,76 +160,6 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
return 0;
}
/**
* @brief 拉取指定通道的不重复 ID 快照表(GD32 SPI 帧类型 0xC3)。
* 分页拉取,每页 8 条,最多拉 2 页(16 条)。
*/
int canFdPullIdSnapshot(can_channel_t channel,
id_snapshot_entry_t *out,
uint8_t *out_count,
uint32_t timeout_ms)
{
if (channel >= CAN_CHANNEL_MAX || out == nullptr || out_count == nullptr) {
return -1;
}
*out_count = 0U;
uint8_t total = 0U;
for (uint8_t page = 0U; page < 2U; ++page) {
uint8_t req[2] = { static_cast<uint8_t>(channel), page };
uint8_t resp_buf[kFeedbackPayloadLen] = {};
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = SpiMaster::instance().otaSendAndPoll(
kSpiIdSnapshotType,
req, 2U,
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms);
if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 4U) {
return -1;
}
/* resp: [ch][page][total_count][entry_count][entries...] */
if (page == 0U) {
total = resp_buf[2];
}
const uint8_t entryCount = resp_buf[3];
if (entryCount == 0U) break;
uint16_t offset = 4U;
for (uint8_t i = 0U; i < entryCount && *out_count < CANFD_ID_SNAPSHOT_MAX; ++i) {
if (offset + 16U > resp_len) break;
id_snapshot_entry_t &e = out[*out_count];
e.id = readU32Le(&resp_buf[offset]);
e.seq = readU32Le(&resp_buf[offset + 4U]);
e.rx_count = readU32Le(&resp_buf[offset + 8U]);
e.dlc = resp_buf[offset + 12U];
const uint8_t flags = resp_buf[offset + 13U];
e.fd_flag = (flags & 0x01U) ? 1U : 0U;
e.brs = (flags & 0x02U) ? 1U : 0U;
e.esi = (flags & 0x04U) ? 1U : 0U;
const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX)
? e.dlc : static_cast<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
const uint16_t entrySize = 16U + dataLen;
if (offset + entrySize > resp_len) break;
memcpy(e.data, &resp_buf[offset + 16U], dataLen);
/* 清零剩余字节 */
for (uint8_t d = dataLen; d < CANFD_ID_SNAPSHOT_DATA_MAX; ++d) {
e.data[d] = 0U;
}
offset += entrySize;
(*out_count)++;
}
if (*out_count >= total) break; /* 已拉完 */
}
return 0;
}
int canFdSetRxMode(can_rx_mode_t mode,
can_rx_mode_t *actual,
uint32_t timeout_ms)
-24
View File
@@ -50,18 +50,6 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
uint8_t *recv_buf, uint8_t recv_max,
uint32_t timeout_ms);
/**
* @brief 发送一帧无需响应的 CANFD 数据。
* @param channel CAN 通道编号(0~2)。
* @param ext_id 扩展帧 ID(29 位)。
* @param data CAN 数据负载。
* @param dlc 数据长度。
* @return 成功返回 0,失败返回 -1。
*/
int canFdSendNoReply(can_channel_t channel, uint32_t ext_id,
const uint8_t *data, uint8_t dlc);
// ============================================================
// 不重复 ID 快照表(SPI 0xC3 协议配套)
// ============================================================
@@ -147,16 +135,4 @@ int canFdSetRxMode(can_rx_mode_t mode,
can_rx_mode_t *actual,
uint32_t timeout_ms);
/**
* @brief 拉取指定通道的不重复 ID 快照表(SPI 帧 0xC3,分页)。
* @param channel 目标通道 0/1/2。
* @param out 输出条目数组(至少 CANFD_ID_SNAPSHOT_MAX 个元素)。
* @param out_count 输出实际条目数。
* @param timeout_ms SPI 超时。
* @return 0 成功,<0 失败。
*/
int canFdPullIdSnapshot(can_channel_t channel,
id_snapshot_entry_t *out,
uint8_t *out_count,
uint32_t timeout_ms);
#endif // CANFD_BRIDGE_H