feat(debug_log): 新增UART0 RX泵任务支持串口日志转发

新增UART0 RX泵任务功能,将GPIO20接收到的串口数据按切行、剥离后以tag=UART推入ring buffer,通过HTTP /logs接口显示在网页日志面板。
接线方式:任意串口源TX连接至H1/H2的ESP_RX引脚,波特率3M 8N1。

feat(motor_ota): GD32 OTA增加包级防呆机制防止错误固件刷入

中继触发前校验gd32_ota镜像向量表身份(Gd32FlashStore::validateStoredImageIdentity),
对Bootloader包或非APP包在WiFi /ota/relay与BLE 0xF7均拒绝中继,
避免GD32 APP区被错误包擦除。新增0x87完成码用于固件身份验证失败场景。

feat(motor_state): 扩展CAN状态数据结构支持CONFIG负载

扩展motor_slot_t结构体,新增config_rx_count、config_seq和config_data字段
用于存储CONFIG帧的相关信息。CAN状态数据包大小从44B扩展到76B,
页面总数从6调整为9,支持目标值回报的配置数据传输。

fix(motor_ota): 修复OTA取消流程中的信用度管理和任务清理问题

★ 先复位credit再发送CANCEL命令,防止传输卡住时背压导致CANCEL被拒绝。
延长取消等待时间至10s覆盖最坏情况,确保feedTask正常退出而非强制删除。
取消完成后复位credit防止锁存死锁,确保后续通信正常进行。

fix(http_server): 修复HTTP请求处理中的边界检查和安全验证

拒绝chunked请求(content_len<=0)防止无限循环,添加通道参数范围验证(ch 0~2),
在OTA中继触发前验证固件身份防止错误包刷入,使用http_json_append避免缓冲区越界。

refactor(gd32_flash): 实现GD32固件身份验证算法确保刷写安全

基于Cortex-M向量表内容实现包级身份校验,通过MSP和复位向量位置
区分APP包、Bootloader包和无效固件,为OTA安全提供基础保障。

BREAKING CHANGE: 电机状态API数据格式变更,CAN通道参数验证更严格
This commit is contained in:
小龙虾
2026-09-01 02:49:34 +08:00
parent ef6d3a4be3
commit ebb3dcdf38
62 changed files with 462 additions and 19644 deletions
+38
View File
@@ -503,6 +503,35 @@ esp_err_t DebugLog::uartInit()
return ESP_OK;
}
// ============================================================
// UART0 RX 泵:把 RX(GPIO20) 收到的串口行转发进 ring buffer,
// 经 HTTP /logs 出现在网页日志面板(tag=UART)。
// 接线:H1/H2 排针上任意串口源 TX → ESP_RX(GPIO20),3M 8N1。
// ============================================================
static void uartRxPumpTask(void *arg)
{
(void)arg;
uint8_t chunk[64];
char line[128];
int pos = 0;
for (;;) {
const int n = uart_read_bytes(UART_NUM_0, chunk, sizeof(chunk), portMAX_DELAY);
for (int i = 0; i < n; i++) {
const char c = (char)chunk[i];
if (c == '\n' || pos >= (int)sizeof(line) - 1) {
if (pos > 0) {
line[pos] = '\0';
DebugLog::instance().log(DEBUG_LOG_INFO, "UART", "%s", line);
pos = 0;
}
} else if (c != '\r') {
line[pos++] = c;
}
}
}
}
esp_err_t DebugLog::uartSend(const uint8_t *data, size_t len)
{
if (!m_uartInitialized || !m_uartAvailable) {
@@ -558,6 +587,15 @@ esp_err_t DebugLog::init()
m_debugAddr.sin_port = htons(DEBUG_LOG_PORT);
m_debugAddr.sin_addr.s_addr = inet_addr(DEBUG_LOG_IP);
/* UART0 RX 泵:串口行 → ring → /logs 网页面板 */
if (m_uartAvailable) {
if (xTaskCreate(uartRxPumpTask, "uart_rx", 3072, nullptr, 1, nullptr) == pdPASS) {
ESP_LOGI(TAG, "UART0 RX 泵已启动 (GPIO%d → /logs)", UART_DEBUG_RX_GPIO);
} else {
ESP_LOGW(TAG, "UART0 RX 泵任务创建失败(堆不足?)");
}
}
m_initialized = true;
ESP_LOGI(TAG, "调试日志已初始化,上位机 %s:%d", DEBUG_LOG_IP, DEBUG_LOG_PORT);
return ESP_OK;
+59 -8
View File
@@ -288,13 +288,16 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
/* feedTask 字节喂完 ≠ GD32/电机已完成。suppress 期间 0xD2 被丢;
* 末包 CAN ACK / 跳转 APP 还要数百 ms。只 QUERY 一次会把前端卡在 kTransfer。
* 退出后轮询 QUERY,直到 GD32 进 FINISHED/ERROR/IDLE,或最多 ~8s。 */
* 退出后轮询 QUERY,直到 GD32 进 FINISHED/ERROR/IDLE,或最多 ~8s。
* 用户取消(s_feed_running 已被 motorOtaCancel 置 false)时跳过:
* 设备端已进 IDLE、GD32 已 resetToIdle,无终态可等——立即收尾退出,
* 把 cancel task 的等待窗口压到最短(也消除强删窗口)。 */
{
constexpr uint32_t kExitPollMs = 8000U;
constexpr uint32_t kExitGapMs = 150U;
const uint32_t exit_deadline = nowMs() + kExitPollMs;
bool got_terminal = false;
while (nowMs() < exit_deadline) {
while (s_feed_running && nowMs() < exit_deadline) {
/* 优先吃 ring 推送(已恢复 suppress) */
{
const motor_ota_status_t st = motorOtaGetStatus();
@@ -395,6 +398,25 @@ int motorOtaStart(uint32_t fw_size, uint8_t channel)
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
300U); /* 300ms: fire-and-forget,让 worker 有足够时间完成 START。
* 原 500ms 偏保守;GD32 收到 START 立即进 kPreCancel,<50ms 响应。 */
if (ret == ESP_OK && resp_type != kRspAck && resp_len >= 1U &&
resp_payload[0] == 0xE5U) {
/* 0xE5 = GD32 端 relay 会话残留(上次 CANCEL 丢失/未达):不清会直接
* 让本次 OTA 空转失败。补发 CANCEL(GD32 resetToIdle 归 kIdle)后
* 重试一次 START,一次自愈即恢复,无需用户重试或刷新。 */
DEBUG_WARN(TAG, "START 0xE5(GD32 残留会话)→ 补发 CANCEL 后重试 START");
uint8_t ccmd[1] = {1U};
uint8_t crt = 0;
uint8_t crp[4] = {0};
size_t crl = 0;
SpiMaster::instance().resetCredit(); /* 解除背压,保证 CANCEL 发出 */
(void)SpiMaster::instance().transact(
kSpiMotorOtaCmd, ccmd, 1, &crt, crp, sizeof(crp), &crl, 500U);
SpiMaster::instance().resetCredit(); /* GD32 已 resetToIdle,窗口对齐满 */
ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 6,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
300U);
}
if (ret != ESP_OK || resp_type != kRspAck) {
DEBUG_WARN(TAG, "START SPI 未确认 ACK (ret=%s resp=0x%02X)——fire-and-forget 继续",
esp_err_to_name(ret), resp_type);
@@ -441,7 +463,7 @@ void motorOtaCancel(void)
}
/* 通知 GD32 取消 relay 状态机 + 等 feedTask 退出是耗时操作
*(SPI transact 最多 1s + feedTask 退出最多 2s),不能在 httpd 线程
*(SPI transact 最多 1s + feedTask 退出最长 ~8.2s),不能在 httpd 线程
* 同步阻塞,否则 cancel HTTP 请求本身会超时("Failed to fetch"),
* 且 httpd 单线程会饿死其他端点。
* 用独立 task 异步执行,HTTP handler 立即返回。 */
@@ -453,7 +475,12 @@ void motorOtaCancel(void)
}
auto do_cancel = [](void *) {
/* 通知 GD32 取消 relay 状态机 */
/* ★ 先复位 credit 再发 CANCEL:取消的典型时机恰是传输卡住(GD32 缓冲满,
* 最后一帧 RESPONSE credit=0 已锁存进 m_remoteCredit)——不先解除背压,
* CANCEL 自己会被 CREDIT 背压 refuse,GD32 端 relay 会话收不到取消而残留,
* 下次 START 被 GD32 0xE5 拒 → "取消后重新开始没法开始"。
* GD32 收到 CANCEL 后 resetToIdle 恢复满窗口,与复位值对齐。 */
SpiMaster::instance().resetCredit();
uint8_t cmd[1] = {1U}; /* cmd=CANCEL */
uint8_t resp_type = 0;
uint8_t resp_payload[4];
@@ -462,22 +489,43 @@ void motorOtaCancel(void)
kSpiMotorOtaCmd, cmd, 1,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1000U);
if (ret != ESP_OK) {
/* 重试一次:再复位 credit(防首次期间又被失配帧锁 0)后重发 */
vTaskDelay(pdMS_TO_TICKS(50));
SpiMaster::instance().resetCredit();
ret = SpiMaster::instance().transact(
kSpiMotorOtaCmd, cmd, 1,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
1000U);
}
DEBUG_WARN(TAG, "cancel: GD32 resp ret=%s type=0x%02X", esp_err_to_name(ret), resp_type);
/* 等待 feedTask 真正退出(最多 2s),避免 s_feed_running 已清但 task
* 仍卡在 SpiMaster::transact 阻塞中,导致 motorOtaIsActive() 瞬时仍 true。 */
/* 等待 feedTask 真正退出。feedTask 最坏存活:主循环收尾 ≤0.2s +
* 退出轮询 8s ≈ 8.2s(120s 整体超时路径同样有界),等待上限取 10s
* 覆盖之——正常路径 feedTask 因 s_status 已被置 IDLE 在 ~0.3s 内退出,
* 这里只是兜底;尽量不强删(强删会泄漏 job 槽 + worker 悬垂栈写)。 */
TaskHandle_t t = s_feed_task;
if (t) {
for (int i = 0; i < 200 && s_feed_task != nullptr; i++) {
for (int i = 0; i < 1000 && s_feed_task != nullptr; i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (s_feed_task != nullptr) {
DEBUG_ERR(TAG, "cancel: feedTask 未在 2s 内退出,强制删除");
DEBUG_ERR(TAG, "cancel: feedTask 未在 10s 内退出,强制删除");
vTaskDelete(s_feed_task);
s_feed_task = nullptr;
/* 强删绕过了 feedTask 收尾,suppress 必须在此恢复,
* 否则 ring service 永久关闭 → GD32 推送全部收不到。 */
SpiMaster::instance().setSuppressRingService(false);
}
}
s_feed_running = false; /* 双保险 */
/* credit 复位:取消的典型时机是传输卡住(GD32 缓冲满)——最后一帧
* RESPONSE 的 credit=0(session 合法)被锁存进 m_remoteCredit。
* GD32 resetToIdle 后转空闲不再发帧,relay 又跑在 APP(不重启、
* 无 session=0 帧)→ 没有任何 resync 入口能把 credit 送回来,
* 后续所有 transact 被 CREDIT 背压拒绝 → 网页所有消息收不到。
* GD32 端 clearOtaSessionQueues 已把接收窗口恢复满,此处对齐复位。 */
SpiMaster::instance().resetCredit();
cancel_task = nullptr;
vTaskDelete(NULL);
};
@@ -487,6 +535,7 @@ void motorOtaCancel(void)
DEBUG_ERR(TAG, "cancel: failed to create cancel task, doing sync cancel");
cancel_task = nullptr;
/* 降级:同步执行(极端 OOM 情况) */
SpiMaster::instance().resetCredit(); /* 先解除背压,保证 CANCEL 能发出 */
uint8_t cmd[1] = {1U};
uint8_t resp_type = 0;
uint8_t resp_payload[4];
@@ -503,9 +552,11 @@ void motorOtaCancel(void)
if (s_feed_task != nullptr) {
vTaskDelete(s_feed_task);
s_feed_task = nullptr;
SpiMaster::instance().setSuppressRingService(false);
}
}
s_feed_running = false;
SpiMaster::instance().resetCredit(); /* 同 do_cancel:防 credit=0 锁存死锁 */
}
DEBUG_WARN(TAG, "cancel: dispatched feed_running=%d", (int)s_feed_running);
}
+3
View File
@@ -65,6 +65,9 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
slot.rx_count = rdU32Le(&e[4]);
slot.seq = rdU32Le(&e[8]);
memcpy(slot.data, &e[12], MOTOR_STATUS_DLC);
slot.config_rx_count = rdU32Le(&e[44]);
slot.config_seq = rdU32Le(&e[48]);
memcpy(slot.config_data, &e[52], MOTOR_CONFIG_DLC);
offset += MOTOR_ENTRY_SIZE;
}
+9 -3
View File
@@ -6,10 +6,12 @@
*
* payload 格式(每页):
* [0] page, [1] total_pages, [2] entry_count, [3] active_count
* [4..] 每个 entry 44B:
* [4..] 每个 entry 76B:
* [0] node_id, [1] channel, [2] online, [3] dlc,
* [4..7] rx_count (LE), [8..11] seq (LE),
* [12..43] data[32] (原始 STATUS payload)
* [44..47] config_rx_count (LE), [48..51] config_seq (LE),
* [52..75] config_data[24] (原始 CONFIG payload, 目标值回报)
*
* 同一节点号可能同时挂在多个 CAN 通道(两台电机同号异线),
* 缓存表与 GD32 端一致按 node×channel 双键存储(27 槽)。
@@ -29,9 +31,10 @@ extern "C" {
#define MOTOR_CHANNELS 3
#define MOTOR_MAX_SLOTS (MOTOR_MAX_NODES * MOTOR_CHANNELS)
#define MOTOR_STATUS_DLC 32
#define MOTOR_ENTRY_SIZE 44
#define MOTOR_CONFIG_DLC 24
#define MOTOR_ENTRY_SIZE 76
#define MOTOR_PAGE_HEADER 4
#define MOTOR_TOTAL_PAGES 6
#define MOTOR_TOTAL_PAGES 9
/** @brief 单电机状态快照。 */
typedef struct {
@@ -42,6 +45,9 @@ typedef struct {
uint32_t rx_count; ///< 累计收到帧数
uint32_t seq; ///< GD32 侧更新序号
uint8_t data[MOTOR_STATUS_DLC]; ///< 原始 STATUS payload
uint32_t config_rx_count; ///< 累计收到 CONFIG 帧数
uint32_t config_seq; ///< CONFIG 更新序号
uint8_t config_data[MOTOR_CONFIG_DLC]; ///< 原始 CONFIG payload (目标值回报)
} motor_slot_t;
/** @brief 全局电机状态表。 */