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

- 新增多电机状态、版本和OTA中继监控模块(monitor/motor_state.cpp,
  monitor/motor_version.cpp, monitor/motor_ota_relay.cpp)
- 在CAN反馈任务中添加0xC5/0xC6/0xD2类型消息处理,支持多电机状态推送
- 重构调试日志系统,将一次性JSON分配改为512B栈缓冲流式发送,降低内存峰值
- 调试UART波特率从115200提升至30000
- 修改版本管理方式,从自动生version.h改为手动维护
- 更新VSCode设置以支持ESP-IDF开发环境配置
- 在OTA协调器中添加电机OTA互斥机制,防止并发冲突
- 完善中继状态机异常处理,添加远程取消通知机制
This commit is contained in:
小龙虾
2026-07-26 20:40:16 +08:00
parent 770ae14156
commit 2138a572ee
47 changed files with 6469 additions and 799 deletions
+93 -24
View File
@@ -54,9 +54,9 @@ static size_t spiXferBytes(size_t frame_len)
/**
* @brief 读响应时单 CS 时钟预算(按最近 HELLO 的 GD32 role 分支)。
* - BOOT:TX DMA 满缓冲 128 → 读 128,避免过长时钟 underrun
* - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断 CRC
* unknown 偏 APP:宁长勿短(BOOT 短帧在 256 时钟下尾部 0xFF,仍可 parse)。
* - BOOT:TX DMA 满缓冲 128 → 读 128(与 Git 稳定路径一致,避免 BOOT underrun)
* - APP / unknown:TX DMA 满缓冲 256 → 读 256,避免 0xC1 等长 EVENT 被截断
* role 粘滞 boot 时靠 isAppOnlySpiType 纠正为 APP 后再走 256。
*/
static size_t spiRespReadBudget(void)
{
@@ -67,6 +67,24 @@ static size_t spiRespReadBudget(void)
return SPI_APP_TX_BUF_SIZE;
}
/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type)
{
switch (type) {
case 0xC1U: /* CAN feedback */
case 0xC3U: /* ID snapshot */
case 0xC4U: /* ADC */
case 0xC5U: /* motor state */
case 0xC6U: /* motor version */
case 0xD0U: /* motor OTA cmd(APP relay) */
case 0xD1U:
case 0xD2U:
return true;
default:
return false;
}
}
/** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。
* 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。
*/
@@ -252,6 +270,12 @@ esp_err_t SpiMaster::serviceRingOnce()
}
{
LockGuard lock(m_mutex);
/* APP 独有事件 → 纠正粘滞的 boot role(OTA 后 HELLO 未刷新时常见) */
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
(void)pushEvent(found_type, found_payload, found_len);
}
return ESP_OK;
@@ -787,7 +811,7 @@ bool SpiMaster::otaBuildFrame(uint8_t type, const uint8_t *payload, size_t paylo
*/
bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
uint8_t *type, const uint8_t **payload, size_t *payload_len,
size_t *frame_offset)
size_t *frame_offset, uint8_t *out_flags)
{
if (!buf) return false;
@@ -807,10 +831,13 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
if (hdr.session_id != self.m_sessionId) {
self.m_stats.seq_errors++;
const bool is_event = (hdr.flags & V2_FLAG_EVENT) != 0U;
/* EVENT 按铃帧在 GD32 session 未激活时 session_id=0(见 requestEsp32Read)。
* 若此处直接拒绝:按铃被读走却解析失败 → 狂刷 WARN,CAN 反馈全丢。
* 策略:EVENT 一律放行;REQUEST/RESPONSE 仍拒绝错 session。 */
if (!is_event) {
/* session_id==0:GD32 重启后 g_v2Active=false,响应帧 session 填 0
* (见 GD32 spi_build_frame)。典型场景:刷完 GD32 固件、ESP32 未重启。
* 若拒收:GD32 明明在回 ACK,ESP32 全丢 → OTA/命令全超时死循环
* (日志特征:v2 SES mismatch got=00000000 刷屏 + fed 卡首 chunk)。
* 放行并按 stale 语义交给上层(ack_seq 归属照常判定)。 */
const bool is_sess0 = (hdr.session_id == 0U);
if (!is_event && !is_sess0) {
static uint32_t s_sess_mm_logs = 0U;
if ((++s_sess_mm_logs % 32U) == 1U) {
DEBUG_WARN("SPI", "v2 SES mismatch got=%08lX exp=%08lX t=%02X #%lu",
@@ -819,8 +846,15 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
}
return false;
}
if (is_sess0 && !is_event) {
static uint32_t s_sess0_logs = 0U;
if ((++s_sess0_logs % 64U) == 1U) {
DEBUG_WARN("SPI", "GD32 session=0 (对端已重启?) 放行 RESPONSE t=0x%02X #%lu",
hdr.type, (unsigned long)s_sess0_logs);
}
}
static uint32_t s_evt_sess0_logs = 0U;
if ((++s_evt_sess0_logs % 64U) == 1U) {
if (is_event && (++s_evt_sess0_logs % 64U) == 1U) {
DEBUG_DBG("SPI", "EVENT session mismatch accepted: got=0x%08lX exp=0x%08lX type=0x%02X",
(unsigned long)hdr.session_id, (unsigned long)self.m_sessionId, hdr.type);
}
@@ -858,6 +892,7 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
if (payload) *payload = pload;
if (payload_len) *payload_len = hdr.payload_len;
if (frame_offset) *frame_offset = hdr.frame_offset;
if (out_flags) *out_flags = hdr.flags;
return true;
}
@@ -1080,11 +1115,17 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
(void)otaFindFrame(drain, drain_len, nullptr, nullptr, nullptr, nullptr);
}
} else {
vTaskDelay(pdMS_TO_TICKS(5));
/* PB6 高 = GD32 无待发数据 = 响应缓冲已释放、可接收新命令。
* 此时 remoteCredit 仍为 0 是陈旧值:credit 只随帧头传递,GD32 转空闲后
* 没有帧能把 credit=1 送回主机(典型为电机 OTA 取消后:一次瞬时 credit=0
* 被锁存,GD32 转空闲再不发帧)→ 死锁,所有发送被拒。
* PB6 高即证明对端可收,直接放行打破死锁;真忙时 PB6 会低、走上面 drain。 */
m_remoteCredit = 1U;
break;
}
}
if (m_remoteCredit == 0U) {
DEBUG_WARN("SPI", "remoteCredit=0, refuse TX type=0x%02X", type);
DEBUG_WARN("SPI", "remoteCredit=0 且 PB6 持续低, refuse TX type=0x%02X", type);
return ESP_ERR_INVALID_STATE;
}
}
@@ -1118,6 +1159,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
const uint8_t *found_payload = nullptr;
size_t found_len = 0;
size_t frame_offset = 0;
/* 与 Git 稳定路径一致:step1 陈旧帧原样解析即可(otaSendAndPoll 传 nullptr 丢弃)。
* 勿在此处过滤 LINK/EVENT,否则改变 delayed-by-one 语义。 */
if (otaFindFrame(rx_raw, xfer_len, &found_type, &found_payload, &found_len, &frame_offset)) {
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
@@ -1127,6 +1170,9 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
}
}
if (resp_len) *resp_len = found_len;
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
m_gd32Role = V2_ROLE_APP;
}
/* expect_stale(两步法 step1)读到的是延迟一拍陈旧帧,序号由 poll 成功时推进。
* 此处若自增会导致 job 间隙/超时后 resend 使用错误 TX_SEQ。 */
if (!expect_stale) {
@@ -1256,14 +1302,29 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
const uint8_t *found_payload = nullptr;
size_t found_len = 0;
size_t frame_offset = 0;
uint8_t found_flags = 0U;
if (otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, &found_len, &frame_offset)) {
/* 恢复 Git 稳定 poll 语义(GD32 BOOT OTA 以此为准)。
* 仅额外:EVENT 帧跳过(电机 OTA 喂数时勿把 0xC1/0xC5 当 ACK);
* APP 独有 TYPE 纠正 role 粘滞。不做 LINK/RESPONSE 额外过滤。 */
if (otaFindFrame(rx_raw, rx_len, &found_type, &found_payload, &found_len, &frame_offset, &found_flags)) {
if (found_type == V2_TYPE_CREDIT_UPDATE) {
DEBUG_DBG("SPI", "poll got CREDIT_UPDATE credit=%u", (unsigned)m_remoteCredit);
vTaskDelay(pdMS_TO_TICKS(1));
continue;
}
if ((found_flags & V2_FLAG_EVENT) != 0U) {
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (poll saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
DEBUG_DBG("SPI", "poll skip EVENT type=0x%02X", found_type);
vTaskDelay(pdMS_TO_TICKS(1));
continue;
}
if (expected_type != 0xFFU && found_type != expected_type &&
found_type != SPI_OTA_RSP_WAIT) {
DEBUG_DBG("SPI", "poll type mismatch: got=0x%02X want=0x%02X",
@@ -1283,16 +1344,16 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
}
if (resp_len) *resp_len = found_len;
/* WAIT 立刻返回,由 OTA 紧循环再 poll(勿在此空转) */
if (found_type == SPI_OTA_RSP_WAIT) {
m_linkState.exitBusy();
return ESP_OK;
}
if (m_lastRxAckMatched) {
m_txSeq = (uint8_t)(m_lastSentSeq + 1U);
}
m_linkState.exitBusy();
return ESP_OK;
/* WAIT/ACK/ERROR:只要 ack_seq 匹配,GD32 已消费本 TX_SEQ,必须推进。
* ★ 电机 OTA:feedData 缓冲满回 WAIT 时若不清 seq,ESP32 同 seq 重发
* → GD32 命中“重复请求”只回缓存 WAIT,feedData 永不再调用
* → fed 卡在首 chunk(日志 fed=232)、CAN 抽空后 0x01 超时。
* BOOT OTA 见 WAIT 后只 poll 不重发 DATA,推进 seq 同样正确。 */
if (m_lastRxAckMatched) {
m_txSeq = (uint8_t)(m_lastSentSeq + 1U);
}
m_linkState.exitBusy();
return ESP_OK;
}
vTaskDelay(pdMS_TO_TICKS(1));
@@ -1426,8 +1487,12 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
return send_ret;
}
/* CS 释放后短延时,再 poll 应答 */
vTaskDelay(pdMS_TO_TICKS(5));
/* CS 释放后短延时,再 poll 应答。
* GD32 主控 OTA / HELLO 等:5ms(BOOT AES 路径需要)。
* 电机 OTA 数据 0xD1:1ms。每 chunk 固定 5ms 会把 SPI 喂速压到 ~47KB/s 边缘,
* CAN 5ms/包消费时环缓经常被掏空(日志「缓冲空,等ESP32喂」+ ACK 超时重发)。 */
const uint32_t post_cs_ms = (type == 0xD1U) ? 1U : 5U;
vTaskDelay(pdMS_TO_TICKS(post_cs_ms));
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout,
@@ -1961,6 +2026,10 @@ esp_err_t SpiMaster::hello(uint32_t timeout_ms, uint8_t role_hint)
hdr.payload_len >= V2_HELLO_ACK_APP_LEN) {
applyAppIdentity16(&pload[V2_HELLO_ACK_BASE_LEN], nullptr, nullptr);
}
/* HELLO 成功时打印 role,便于对照 /system/status 粘滞 boot 问题 */
DEBUG_INFO("SPI", "HELLO ok role=%u credit=%u sess=0x%08lX",
(unsigned)m_gd32Role, (unsigned)m_remoteCredit,
(unsigned long)m_sessionId);
m_v2Active = true;
m_stats.resyncs++;
return ESP_OK;