feat(can): 添加跨通道CAN突发发送功能支持多电机同步驱动

- 新增 /can/send_burst HTTP接口,支持一次SPI事务批量发送多帧CAN消息
- 实现canFdSendBurst函数,通过0xC7 SPI类型实现背靠背装载各通道邮箱
- 添加canBurstCapacity函数计算单次突发最大帧数限制
- 突发发送期间暂停can_feedback_task避免SPI抢占冲突

refactor(can_feedback_task): 改造为周期查询+事件消费模式

- 0xC4/0xC5从GD32主动推送改为ESP32周期查询(250ms/1s)
- 0xC6/0xD2仍保留事件驱动推送模式,由任务消费处理
- 消除OTA期间GD32悬空按铃导致的超时刷屏问题
- 电机OTA期间也暂停任务避免与0xD1抢夺SPI资源

perf(ota_relay): 增加重发次数上限提升传输可靠性

- 将kMaxDataSends从3次增加到5次,提高数据包传输成功率

chore(version): 更新固件版本号至0.3.78

- 版本查询checkVersionWait移到tickInFlight释放后异步触发
- 避免/motor/version请求拖长tick导致tickStuck误报通道闪黄
This commit is contained in:
小龙虾
2026-08-10 00:54:12 +08:00
parent f9cbcc396b
commit ef6d3a4be3
23 changed files with 2524 additions and 157 deletions
+2 -2
View File
@@ -14,8 +14,8 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 76
#define FW_VERSION_STRING "0.3.76" // 重启恢复提速2:httpd就绪后再打 MAIN「启动」banner(防环缩容裁掉);前端用 uptime<120s 判重启完成,不再干等10s
#define FW_VERSION_PATCH 78
#define FW_VERSION_STRING "0.3.78" // 版本查询checkVersionWait移到tickInFlight释放后异步触发,避免/motor/version请求拖长tick导致tickStuck误报通道闪黄
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
+109 -57
View File
@@ -1,13 +1,16 @@
/*
* can_feedback_task.cpp - 被动监听 GD32 主动推送的事件
* can_feedback_task.cpp - GD32 周期查询 + 事件消费任务
*
* 架构(2026-08 改造后):
* 0xC1 CAN 反馈已改为请求驱动(前端请求时 ESP32 主动向 GD32 查询),
* 不再被动缓存。本任务仅保留 0xC4/0xC5/0xC6/0xD2 事件分发:
* 0xC4 ADC 调试反馈、0xC5 多电机状态、0xC6 多电机版本、0xD2 电机 OTA 进度
* 这些仍由 GD32 主动推送,ESP32 被动接收。
* 0xC1 CAN 反馈:请求驱动(HTTP 侧 canFdQueryFeedback),本任务不处理。
* 0xC4 ADC / 0xC5 电机状态:由 GD32 主动按铃推送改为本任务周期查询——
* 0xC5 每 250ms 查 6 页、0xC4 每 1s 查 1 帧,查询响应 payload 格式与推送
* 一致,复用 onMotorStateEvent()/onGd32AdcFeedbackEvent() 解析入库。
* 消除 OTA 期间 GD32 悬空按铃(ESP32 抑制 ring 不读)导致“按铃超时取消”刷屏。
* 0xC6 电机版本 / 0xD2 电机 OTA 进度:仍由 GD32 事件驱动推送,本任务 takeEvent 消费。
*
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务(不查询不消费);
* 电机 OTA 期间(motorOtaIsActive())也暂停,避免与 0xD1 喂数抢占 SPI。
*/
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -27,78 +30,127 @@
namespace {
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态推送
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送
constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
constexpr uint32_t kQueryMotorStateMs = 250U; ///< 0xC5 电机状态查询周期
constexpr uint32_t kQueryAdcMs = 1000U; ///< 0xC4 ADC 查询周期
constexpr uint32_t kEventWaitMs = 20U; ///< 单次等 0xC6/0xD2 事件超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态查询
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送
constexpr size_t kPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
static volatile bool s_paused = false;
/**
* @brief can_fb 任务主循环:takeEvent 分发 0xC4/0xC5/0xC6/0xD2 推送事件。
* 0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),不再在此处理。
* @brief 按页查询 GD32 电机状态(0xC5 请求驱动,替代 GD32 250ms 推送)。
* 任一页失败立即中断本轮:GD32 未就绪/瞬时错误时不空耗 6×200ms,下轮再试;
* page0 会刷新全局字段(active_count/boot_active),部分页成功即部分更新。
*/
static void queryMotorStatePages(void)
{
uint8_t req[1] = {0U};
for (uint8_t page = 0U; page < MOTOR_TOTAL_PAGES; ++page) {
req[0] = page;
uint8_t resp_type = 0U;
uint8_t resp_buf[kPayloadMax] = {};
size_t resp_len = 0U;
esp_err_t err = SpiMaster::instance().transact(
kSpiMotorStateType, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK ||
resp_len < MOTOR_PAGE_HEADER) {
return;
}
(void)onMotorStateEvent(resp_buf, resp_len);
}
}
/**
* @brief 查询 GD32 ADC 调试快照(0xC4 请求驱动,替代 GD32 1Hz 推送)。
* GD32 0xC4 handler 回自身类型 0xC4(异于 0xC1/0xC5 的 0x80),两者皆接受。
*/
static void queryAdcFeedback(void)
{
uint8_t req[1] = {0U};
uint8_t resp_type = 0U;
uint8_t resp_buf[kPayloadMax] = {};
size_t resp_len = 0U;
esp_err_t err = SpiMaster::instance().transact(
kSpiAdcFeedbackType, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (err != ESP_OK || resp_len < 1U) {
return;
}
if (resp_type != kSpiAdcFeedbackType && resp_type != SPI_OTA_RSP_ACK) {
return;
}
(void)onGd32AdcFeedbackEvent(resp_buf, resp_len);
}
/**
* @brief 消费剩余事件驱动推送(0xC6 电机版本 / 0xD2 电机 OTA 进度)。
* 0xC4/0xC5 已改请求驱动,不再有对应推送;未知类型静默跳过。
*/
static void drainPushEvents(uint32_t wait_ms)
{
uint8_t ring_type = 0U;
uint8_t ring_payload[kPayloadMax] = {};
size_t ring_len = 0U;
if (!SpiMaster::instance().takeEvent(&ring_type, ring_payload,
sizeof(ring_payload), &ring_len,
wait_ms)) {
return;
}
if (ring_type == kSpiMotorVersionType) {
(void)onMotorVersionEvent(ring_payload, ring_len);
} else if (ring_type == kSpiMotorOtaStatusType) {
onMotorOtaStatusPush(ring_payload, ring_len);
}
}
/**
* @brief can_fb 任务主循环:周期查询 0xC5/0xC4 + 消费 0xC6/0xD2 推送。
* OTA / 电机 OTA 期间暂停,SPI 交由 OTA 独占。
*/
static void taskLoop(void *)
{
DEBUG_INFO(kTag, "事件监听任务启动 (0xC4 ADC / 0xC5 motor / 0xC6 ver / 0xD2 ota)");
DEBUG_INFO(kTag, "请求驱动查询任务启动 (0xC5 250ms / 0xC4 1s / 0xC6,0xD2 推送)");
/* 冷启动延后开始监听,错开上电初始化窗口 */
/* 冷启动延后开始查询,错开上电初始化窗口 */
{
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 后开始事件监听",
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始查询",
(unsigned long)kCanFbDeferMs);
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
}
}
uint8_t ring_type = 0U;
uint8_t ring_payload[kRingPayloadMax] = {};
size_t ring_len = 0U;
TickType_t last_motor_q = xTaskGetTickCount();
TickType_t last_adc_q = xTaskGetTickCount();
for (;;) {
if (s_paused) {
if (s_paused || motorOtaIsActive()) {
vTaskDelay(pdMS_TO_TICKS(kPauseDelayMs));
continue;
}
/* 阻塞等待 GD32 按铃事件(超时则重试) */
if (!SpiMaster::instance().takeEvent(&ring_type, ring_payload,
sizeof(ring_payload), &ring_len,
kWaitRingMs)) {
continue;
/* 周期查询:到期才执行(时间差对比,暂停恢复后立即补一次) */
const TickType_t now = xTaskGetTickCount();
if ((now - last_motor_q) >= pdMS_TO_TICKS(kQueryMotorStateMs)) {
queryMotorStatePages();
last_motor_q = xTaskGetTickCount();
}
if ((now - last_adc_q) >= pdMS_TO_TICKS(kQueryAdcMs)) {
queryAdcFeedback();
last_adc_q = xTaskGetTickCount();
}
/* 0xC4 ADC 调试反馈:GD32 关开关则不推;ESP32 始终可解析(无本地开关) */
if (ring_type == kSpiAdcFeedbackType) {
(void)onGd32AdcFeedbackEvent(ring_payload, ring_len);
continue;
}
/* 0xC5 多电机状态推送:GD32 250ms 分页推送,ESP32 解析入缓存 */
if (ring_type == kSpiMotorStateType) {
(void)onMotorStateEvent(ring_payload, ring_len);
continue;
}
/* 0xC6 多电机版本推送:GD32 收到电机版本应答(0x00FF)后事件驱动推送 */
if (ring_type == kSpiMotorVersionType) {
(void)onMotorVersionEvent(ring_payload, ring_len);
continue;
}
/* 0xD2 电机 OTA relay 进度推送 */
if (ring_type == kSpiMotorOtaStatusType) {
onMotorOtaStatusPush(ring_payload, ring_len);
continue;
}
/* 0xC1 已改为请求驱动,不再在此处理;未知类型静默跳过 */
/* 消费 0xC6/0xD2:短阻塞等 worker 入队(事件驱动唤醒) */
drainPushEvents(kEventWaitMs);
}
}
@@ -130,7 +182,7 @@ bool startCanFeedbackTask()
}
/**
* @brief 暂停/恢复事件监听。
* @brief 暂停/恢复周期查询与事件消费。
* @param pause true=OTA 期间暂停;false=恢复
*/
void pauseCanFeedbackTask(bool pause)
@@ -138,9 +190,9 @@ void pauseCanFeedbackTask(bool pause)
if (s_paused == pause) return; // 幂等:状态未变时不重复日志
s_paused = pause;
if (pause) {
DEBUG_DBG(kTag, "事件监听已暂停(OTA 进行中)");
DEBUG_DBG(kTag, "周期查询已暂停(OTA 进行中)");
} else {
DEBUG_DBG(kTag, "事件监听已恢复");
DEBUG_DBG(kTag, "周期查询已恢复");
}
}
@@ -151,4 +203,4 @@ void pauseCanFeedbackTask(bool pause)
bool canFeedbackPaused()
{
return s_paused;
}
}
+6 -5
View File
@@ -1,16 +1,17 @@
/*
* can_feedback_task.h - 事件监听任务(0xC4/0xC5/0xC6/0xD2 推送)
* can_feedback_task.h - GD32 周期查询 + 事件消费任务
*
* 2026-08 架构改造:0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),
* 不再被动缓存。本任务仅分发 GD32 主动推送的 ADC/电机状态/版本/OTA 进度事件。
* 2026-08 架构改造:0xC1 CAN 反馈请求驱动(canFdQueryFeedback);
* 0xC4 ADC / 0xC5 电机状态改为本任务周期查询(250ms/1s);
* 0xC6 电机版本 / 0xD2 电机 OTA 进度保留事件推送,由本任务 takeEvent 消费。
*/
#ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H
/** @brief 暂停/恢复事件监听(OTA 期间调用 pause=true)。 */
/** @brief 暂停/恢复周期查询与事件消费(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause);
/** @brief 查询监听是否处于暂停(OTA / send_batch 期间为 true)。
/** @brief 查询是否处于暂停(OTA / send_batch / 电机 OTA 期间为 true)。
* 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */
bool canFeedbackPaused();
+1 -1
View File
@@ -1172,7 +1172,7 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
* 实现“重发快且不漏”(旧逻辑见 WAIT 后只能干 poll 到 stall,ACK 丢了就永丢)。
* - 等 ACK 期间可预组下一包(与从机处理重叠)。
*/
constexpr uint32_t kMaxDataSends = 3U;
constexpr uint32_t kMaxDataSends = 5U;
/* 单次发送后等 ACK 上限:GD32 正常 ms 级回 ACK(ISR 回 WAIT、主循环回 ACK),
* 50ms 未到即重发(DUP-SEQ 回放捞回),不再干等 6s(用户指定 50ms)。
* credit==0 的排空积压由 transact 内部 credit 背压消化,此处无需长等。 */
+228
View File
@@ -1330,6 +1330,234 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
return ESP_OK;
}
/**
* @brief POST /can/send_burst — CAN 跨通道突发发送(一次 SPI 事务多帧近同时)。
*
* JSON: { "suffix": 512, "dlc": 32, "frames": [ { "ch":0, "node":1, "data":"HEX" }, ... ] }
* suffix = CAN ID 低 16 位(CMD=0x0200),id=(node<<16)|suffix;dlc 为本批统一数据长度。
* frames 可超过单次突发容量:内部按 canBurstCapacity(dlc) 分块,块与块之间背靠背
* 连续发起 SPI 事务(间隔 ~<1ms),实现「组内近同时、组间尽量紧」的多电机同步驱动。
*
* 返回: { "status":"ok","sent":N,"results":[ {"status":"ok"} | {"status":"error","code":c,"gd32_err":g}, ... ] }
* 单帧总线故障(0xE2/0xE3)/邮箱满(0xE4)等经 ACK 的每帧状态字节透传,前端据此处理冷却。
*/
esp_err_t can_send_burst_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
constexpr int kMaxTotalFrames = 24; /* 单次请求帧数上限(一步同步电机数上限) */
constexpr uint32_t kBurstTimeoutMs = 200U; /* 单次突发 SPI 事务超时 */
constexpr int kMaxBody = 49152;
int content_len = req->content_len;
if (content_len <= 0 || content_len > kMaxBody) {
return http_send_json_error(req, "400 Bad Request", "bad_body",
"body 长度无效或过大");
}
BatchBodyReader reader(req, content_len);
auto findKey = [&](const char *key) -> bool {
const size_t kl = strlen(key);
size_t match = 0;
int c;
while ((c = reader.nextChar()) != -1) {
if (c == key[match]) {
if (++match == kl) return true;
} else {
match = (c == key[0]) ? 1 : 0;
}
}
return false;
};
auto skipWs = [&]() {
int c;
while ((c = reader.peekChar()) != -1) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
reader.nextChar();
} else {
break;
}
}
};
auto findChar = [&](char target) -> bool {
int c;
while ((c = reader.nextChar()) != -1) {
if (c == target) return true;
}
return false;
};
auto readInt = [&]() -> long {
skipWs();
long sign = 1;
int c = reader.peekChar();
if (c == '-') { reader.nextChar(); sign = -1; }
long v = 0;
while ((c = reader.peekChar()) != -1 && c >= '0' && c <= '9') {
v = v * 10 + (c - '0');
reader.nextChar();
}
return v * sign;
};
char frameJson[320];
auto readFrameObj = [&]() -> int {
skipWs();
int c = reader.peekChar();
if (c != '{') return -1;
reader.nextChar();
frameJson[0] = '{';
size_t n = 1;
int depth = 1;
while ((c = reader.nextChar()) != -1) {
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
if (depth == 0) {
if (n + 1 >= sizeof(frameJson)) return -1;
frameJson[n++] = '}';
frameJson[n] = '\0';
return (int)n;
}
}
if (n + 1 >= sizeof(frameJson)) return -1;
frameJson[n++] = (char)c;
}
return -1;
};
/* ── prefix:suffix / dlc / frames ── */
if (!findKey("\"suffix\"") || !findChar(':')) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 suffix 字段");
}
long suffix_val = readInt();
if (!findKey("\"dlc\"") || !findChar(':')) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 dlc 字段");
}
long dlc_val = readInt();
if (dlc_val < 0 || dlc_val > CAN_MAX_DLC) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 超出范围");
}
if (!findKey("\"frames\"") || !findChar('[')) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 frames 字段");
}
const uint8_t dlc = static_cast<uint8_t>(dlc_val);
const uint16_t suffix = static_cast<uint16_t>(suffix_val & 0xFFFF);
const uint8_t cap = canBurstCapacity(dlc);
if (cap == 0U) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 过大无法突发");
}
/* 静态缓冲:httpd 单线程,避免大栈占用 */
static uint8_t s_chnode[kMaxTotalFrames];
static uint8_t s_data[kMaxTotalFrames][CAN_MAX_DLC];
static uint8_t s_status[kMaxTotalFrames];
int total = 0;
while (total < kMaxTotalFrames) {
skipWs();
int c = reader.peekChar();
if (c == -1 || c == ']') break;
if (c == ',') { reader.nextChar(); continue; }
if (c != '{') { reader.nextChar(); continue; }
int flen = readFrameObj();
if (flen <= 0) break;
JsonView fr = jsonParse(frameJson);
if (!fr.valid) {
return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧 JSON 非法");
}
JsonView ch_it = jsonGet(&fr, "ch");
JsonView node_it = jsonGet(&fr, "node");
JsonView data_it = jsonGet(&fr, "data");
if (!jsonIsNumber(&ch_it) || !jsonIsNumber(&node_it)) {
return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧缺少 ch/node");
}
const int chv = static_cast<int>(jsonGetNumberValue(&ch_it));
const int nodev = static_cast<int>(jsonGetNumberValue(&node_it));
if (chv < 0 || chv >= CAN_CHANNEL_MAX) {
return http_send_json_error(req, "400 Bad Request", "bad_frame", "ch 非法(0~2)");
}
uint8_t data_buf[CAN_MAX_DLC];
uint8_t nbytes = 0U;
if (jsonIsString(&data_it)) {
size_t hex_len = 0U;
const char *hex = jsonGetStringValue(&data_it, &hex_len);
if (hex) {
nbytes = static_cast<uint8_t>(
http_hex_to_bytes_n(hex, hex_len, data_buf, CAN_MAX_DLC));
}
}
if (nbytes != dlc) {
return http_send_json_error(req, "400 Bad Request", "bad_frame",
"帧 data 长度与 dlc 不一致");
}
s_chnode[total] = static_cast<uint8_t>(((chv & 0x0F) << 4) | (nodev & 0x0F));
if (dlc > 0U) {
memcpy(s_data[total], data_buf, dlc);
}
total++;
}
if (total == 0) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "frames 为空");
}
/* ── 分块背靠背突发发送 ── */
pauseCanFeedbackTask(true);
bool aborted = false;
for (int off = 0; off < total && !aborted; off += cap) {
const int n = ((total - off) > cap) ? cap : (total - off);
can_burst_entry_t entries[CAN_BURST_HARD_MAX];
for (int i = 0; i < n; ++i) {
entries[i].chnode = s_chnode[off + i];
entries[i].data = s_data[off + i];
}
uint8_t chunk_status[CAN_BURST_HARD_MAX];
uint8_t gerr = 0U;
(void)esp_task_wdt_reset();
const int rc = canFdSendBurst(suffix, dlc, entries, static_cast<uint8_t>(n),
chunk_status, sizeof(chunk_status),
kBurstTimeoutMs, &gerr);
if (rc < 0) {
/* SPI 失败/协议错误:本块及之后标记失败并中止 */
const uint8_t code = (rc == -3) ? gerr : 0xEEU;
for (int i = 0; i < n; ++i) s_status[off + i] = code;
for (int j = off + n; j < total; ++j) s_status[j] = 0xEEU;
aborted = true;
break;
}
for (int i = 0; i < n; ++i) s_status[off + i] = chunk_status[i];
}
pauseCanFeedbackTask(false);
/* ── 组装 JSON 响应 ── */
static char resp_buf[1600];
int off = snprintf(resp_buf, sizeof(resp_buf),
"{\"status\":\"ok\",\"sent\":%d,\"results\":[", total);
for (int i = 0; i < total; ++i) {
const uint8_t st = s_status[i];
int w;
if (st == 0x00U) {
w = snprintf(resp_buf + off, sizeof(resp_buf) - off,
"%s{\"status\":\"ok\"}", (i == 0) ? "" : ",");
} else {
w = snprintf(resp_buf + off, sizeof(resp_buf) - off,
"%s{\"status\":\"error\",\"code\":-3,\"gd32_err\":%u}",
(i == 0) ? "" : ",", (unsigned)st);
}
if (w < 0 || off + w >= (int)sizeof(resp_buf)) break;
off += w;
}
if (off < (int)sizeof(resp_buf) - 2) {
resp_buf[off++] = ']';
resp_buf[off++] = '}';
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, resp_buf, off);
}
esp_err_t spi_send_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
+2
View File
@@ -20,6 +20,8 @@ esp_err_t restart_handler(httpd_req_t *req);
esp_err_t logs_handler(httpd_req_t *req);
/** @brief CAN 发送(唯一入口):同通道 1~N 帧,单帧即 frames 长度为 1。 */
esp_err_t can_send_batch_handler(httpd_req_t *req);
/** @brief CAN 跨通道突发发送:多帧经 0xC7 一次/多次 SPI 事务背靠背发出,近同时。 */
esp_err_t can_send_burst_handler(httpd_req_t *req);
esp_err_t spi_send_handler(httpd_req_t *req);
esp_err_t system_status_handler(httpd_req_t *req);
esp_err_t system_probe_handler(httpd_req_t *req);
+1
View File
@@ -477,6 +477,7 @@ esp_err_t WifiOta::httpServerStart()
{"/system/probe", HTTP_GET, system_probe_handler},
{"/system/heap_low_log", HTTP_GET, system_heap_low_log_handler},
{"/can/send_batch", HTTP_POST, can_send_batch_handler},
{"/can/send_burst", HTTP_POST, can_send_burst_handler},
{"/can/feedback", HTTP_GET, can_feedback_handler},
{"/can/mode", HTTP_GET, can_mode_handler},
{"/can/mode", HTTP_POST, can_mode_handler},
+107
View File
@@ -20,10 +20,15 @@
namespace {
constexpr uint8_t kSpiCanFrameType = 0xC0U;
constexpr uint8_t kSpiCanBurstType = 0xC7U;
constexpr uint8_t kSpiCanSetModeType = 0xC2U;
constexpr uint8_t kFeedbackFixedHeader = 28U;
constexpr uint8_t kFeedbackMaxPage = 1U; /* GD32 仅 page 0/1 */
/* 突发 payload 上限 = V2_MAX_PAYLOAD(236);头 4B = count+dlc+suffix(2)。 */
constexpr uint8_t kBurstHeaderLen = 4U;
constexpr uint16_t kBurstPayloadCap = 236U;
/**
* @brief 经 SPI 发送一帧 CAN,并解析 ACK/ERROR。
*
@@ -169,6 +174,108 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
recv_buf, recv_max, timeout_ms, gd32_err_out);
}
/**
* @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。
* payload = 4B 头 + count*(1+dlc),须 ≤ 236B;并受 GD32 硬上限约束。
*/
uint8_t canBurstCapacity(uint8_t dlc)
{
if (dlc > CAN_MAX_DLC) {
return 0U;
}
const uint16_t entry_len = static_cast<uint16_t>(1U + dlc);
const uint16_t avail = (kBurstPayloadCap > kBurstHeaderLen)
? static_cast<uint16_t>(kBurstPayloadCap - kBurstHeaderLen)
: 0U;
const uint16_t cap = static_cast<uint16_t>(avail / entry_len);
return static_cast<uint8_t>((cap < CAN_BURST_HARD_MAX) ? cap : CAN_BURST_HARD_MAX);
}
/**
* @brief 发送一次 CAN 突发(SPI 0xC7)。
*
* GD32 在 CS 释放 ISR 内对每帧调用非阻塞 transmitFrame(仅装载邮箱),
* 全部装载完成后才回 ACK,因此各通道帧近同时上线;ESP32 这里等待的只是
* 这一个 ACK,并非逐帧等待,不会破坏组内同时性。
*
* 返回值: count 成功, -1 参数错误, -2 SPI 通信失败, -3 GD32 拒绝。
*/
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
const can_burst_entry_t *entries, uint8_t count,
uint8_t *status_out, uint8_t status_max,
uint32_t timeout_ms, uint8_t *gd32_err_out)
{
if (gd32_err_out) {
*gd32_err_out = 0U;
}
if (dlc > CAN_MAX_DLC || entries == nullptr || count == 0U) {
return -1;
}
if (count > canBurstCapacity(dlc)) {
return -1;
}
uint8_t tx_buf[240];
size_t tx_len = 0U;
tx_buf[tx_len++] = count;
tx_buf[tx_len++] = dlc;
tx_buf[tx_len++] = static_cast<uint8_t>((id_suffix >> 8) & 0xFFU);
tx_buf[tx_len++] = static_cast<uint8_t>(id_suffix & 0xFFU);
for (uint8_t i = 0U; i < count; ++i) {
tx_buf[tx_len++] = entries[i].chnode;
if (dlc > 0U) {
if (entries[i].data == nullptr) {
return -1;
}
memcpy(&tx_buf[tx_len], entries[i].data, dlc);
tx_len += dlc;
}
}
uint8_t resp[32];
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = ESP_OK;
/* 与 0xC0 相同:排空可能残留的陈旧 WAIT,直到读到本次真正的 ACK/ERROR */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
ret = SpiMaster::instance().transact(
kSpiCanBurstType, tx_buf, tx_len,
&resp_type, resp, sizeof(resp), &resp_len, timeout_ms);
if (resp_type != SPI_OTA_RSP_WAIT) {
break;
}
vTaskDelay(pdMS_TO_TICKS(3));
}
if (ret != ESP_OK) {
DEBUG_WARN("CAN", "BURST SPI fail n=%u %s",
(unsigned)count, esp_err_to_name(ret));
return -2;
}
if (resp_type == SPI_OTA_RSP_ACK) {
/* resp[0]=count, resp[1..count]=每帧状态(CanTxResult 码) */
const uint8_t n = (resp_len > 0U) ? resp[0] : 0U;
if (status_out != nullptr && status_max > 0U) {
const uint8_t copy_n = (n < status_max) ? n : status_max;
for (uint8_t i = 0U; i < copy_n; ++i) {
status_out[i] = (static_cast<size_t>(1U + i) < resp_len)
? resp[1U + i] : 0xFFU;
}
}
return static_cast<int>(n);
}
const uint8_t gd32_err = (resp_len > 0U) ? resp[0] : 0xFFU;
if (gd32_err_out) {
*gd32_err_out = gd32_err;
}
DEBUG_WARN("CAN", "BURST拒 n=%u r=%02X e=%02X",
(unsigned)count, resp_type, gd32_err);
return -3;
}
/**
* @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。
*
+38
View File
@@ -53,6 +53,44 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
uint32_t timeout_ms,
uint8_t *gd32_err_out = nullptr);
// ============================================================
// CAN 跨通道突发发送(SPI 0xC7)
// 一次 SPI 事务携带多帧,GD32 背靠背装载各通道 CAN 邮箱,
// 组内近同时上线(偏差 <1ms),用于多电机同步驱动。
// ============================================================
#define CAN_BURST_HARD_MAX (16) ///< GD32 单次突发硬上限(帧数)
/** @brief 突发条目:chnode=(ch<<4)|node,data 指向 dlc 字节数据。 */
typedef struct {
uint8_t chnode; ///< (channel<<4) | node
const uint8_t *data; ///< dlc 字节数据
} can_burst_entry_t;
/**
* @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。
* 受 236B SPI payload(4B 头 + count*(1+dlc))与 GD32 硬上限约束。
*/
uint8_t canBurstCapacity(uint8_t dlc);
/**
* @brief 发送一次 CAN 突发(SPI 0xC7)。count 必须 <= canBurstCapacity(dlc)。
* GD32 在 ISR 内对每帧调用非阻塞 transmitFrame,各通道邮箱背靠背装载。
* @param id_suffix CAN ID 低 16 位(CMD=0x0200);id=(node<<16)|suffix。
* @param dlc 所有条目统一数据长度(0~64)。
* @param entries count 个条目(chnode + data)。
* @param count 条目数(1~canBurstCapacity(dlc))。
* @param status_out 输出每帧状态(GD32 CanTxResult 码,0x00=成功),可为 nullptr。
* @param status_max status_out 容量。
* @param timeout_ms SPI 事务超时。
* @param gd32_err_out 返回 -3 时的 GD32 细分错误码;可为 nullptr。
* @return 成功返回 count;-1 参数错误;-2 SPI 通信失败;-3 GD32 ERROR。
*/
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
const can_burst_entry_t *entries, uint8_t count,
uint8_t *status_out, uint8_t status_max,
uint32_t timeout_ms,
uint8_t *gd32_err_out = nullptr);
// ============================================================
// 不重复 ID 快照表(SPI 0xC3 协议配套)
// ============================================================
+43 -7
View File
@@ -70,15 +70,17 @@ static size_t spiRespReadBudget(void)
/**
* @brief 已知"短响应"的主动请求类型:应答帧 ≤ ~70B(实测 0xA1 PING ~60-70B、
* DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短)。
* DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短、0xC4 ADC 20B)。
* 对这些类型的主动 poll 可把读预算从 256 收紧到 96B,省 ~160B 0xFF 填充时钟
* (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)与被动 0xC1 事件读。
* (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)、0xC1/0xC5
* (分页响应 ~236B)与被动 0xC1 事件读。
*/
static bool isShortRespType(uint8_t type)
{
switch (type) {
case 0xA1U: case 0x02U: case 0x01U: case 0xF7U:
case 0xB0U: case 0xC0U: case 0xC2U: case 0xD1U:
case 0xC4U: case 0xC7U:
return true;
default:
return false;
@@ -87,6 +89,24 @@ static bool isShortRespType(uint8_t type)
/** 短响应读预算(覆盖最长 ~70B 应答 + 余量)。 */
static constexpr size_t kShortRespReadBudget = 96U;
/**
* @brief 已知 GD32 APP 在 onCsRelease ISR 内 µs 级装载响应的主动请求类型。
* 这些类型 CS 释放后无需 5ms 静默:waitSpiDataReady 由下降沿 ISR 精确唤醒
* (与 0xD1/0xA1 同机制)。仅 BOOT 主循环处理的 OTA 命令(0x01/0x02/0x03)
* 保留 5ms——过早盲读会打断 BOOT 的 EXTI/DMA 时序(实测 CHALLENGE 超时)。
*/
static bool isAppIsrFastRespType(uint8_t type)
{
switch (type) {
case 0xA1U: case 0xC0U: case 0xC1U: case 0xC2U:
case 0xC3U: case 0xC4U: case 0xC5U: case 0xC7U: case 0xD1U:
case 0xF7U:
return true;
default:
return false;
}
}
/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
bool SpiMaster::isAppOnlySpiType(uint8_t type)
{
@@ -646,6 +666,19 @@ void SpiMaster::workerLoop()
continue;
}
/* 无 job:先处理 GD32 重启重同步(session=0 响应置位;5s 冷却防空转)。
* OTA 中继/暂停期间不 HELLO,避免干扰在途 OTA 事务。 */
if (m_resyncPending && !m_suppressRingService && !m_spiPaused) {
const TickType_t now_tick = xTaskGetTickCount();
if ((uint32_t)(now_tick - m_lastResyncTick) >= pdMS_TO_TICKS(5000)) {
m_lastResyncTick = now_tick;
m_resyncPending = false;
DEBUG_INFO("SPI", "GD32 session=0,HELLO 重同步...");
(void)hello(1500U, 0U);
continue;
}
}
/* 无 job:等 GPIO / 提交唤醒。
* 超时只是无事件时的兜底回扫周期;ISR 的 xTaskNotifyFromISR 会立即唤醒
* 本任务,响应延迟不由这里决定。过短会令 spi_worker(优先级6) 忙转压住
@@ -981,6 +1014,9 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
DEBUG_WARN("SPI", "GD32 session=0 (对端已重启?) 放行 RESPONSE t=0x%02X #%lu",
hdr.type, (unsigned long)s_sess0_logs);
}
/* GD32 重启后 v2 未激活:置重同步标志,worker 空闲时 HELLO 重建 session。
* 功能由 session=0 放行兜底,重同步只是把链路拉回校验态。 */
self.m_resyncPending = true;
}
static uint32_t s_evt_sess0_logs = 0U;
if (is_event && (++s_evt_sess0_logs % 64U) == 1U) {
@@ -1641,13 +1677,13 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
}
/* CS 释放后的静默期分型处理:
* - 0xD1(电机 OTA 数据)/ 0xA1(APP PING):GD32 APP 在 onCsRelease ISR
* 内 µs 级装好响应并拉低 PB6 → 零盲等,waitSpiDataReady 由下降沿 ISR
* 精确唤醒,旧 1ms 固定延时压制喂速~47KB/s,现由事件驱动提速。
* - 其他类型(GD32 relay/BOOT AES/HELLO 等):保留 5ms——BOOT 在主循环
* - APP ISR 响应类型(0xC1/0xC4/0xC5 查询、0xD1 电机 OTA、0xA1 PING 等):
* GD32 APP 在 onCsRelease ISR 内 µs 级装好响应并拉低 PB6 → 零盲等,
* waitSpiDataReady 由下降沿 ISR 精确唤醒(见 isAppIsrFastRespType)。
* - 其他类型(BOOT OTA 命令 0x01/0x02/0x03 等):保留 5ms——BOOT 在主循环
* 处理 CHALLENGE/flash,poll 前 2 轮盲读 CS 过早打进去会打断其
* EXTI/DMA 时序(实测去掉后 CHALLENGE 超时)。 */
if (type != 0xD1U && type != 0xA1U) {
if (!isAppIsrFastRespType(type)) {
vTaskDelay(pdMS_TO_TICKS(5));
}
+4
View File
@@ -322,6 +322,10 @@ private:
volatile bool m_suppressRingService = false;
/** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */
volatile bool m_spiPaused = false;
/** GD32 重启检测(响应帧 session=0)置位:worker 空闲时 HELLO 重同步。 */
volatile bool m_resyncPending = false;
/** 上次 HELLO 重同步时刻(冷却 5s,防 GD32 不响应时反复 HELLO)。 */
volatile uint32_t m_lastResyncTick = 0;
/** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */
volatile uint8_t m_lastReqType = 0xFFU;
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */