feat(motor-ota): 改进电机OTA中继的握手确认和重试机制
- 添加握手确认逻辑,避免在握手阶段就标记TRANSFER状态 - 实现GD32 auto-retry检测机制,当连续WAIT超限时查询真实状态 - 修复预填充缓冲区数据重复问题,移除旧的offset重置逻辑 - 优化SPI响应等待机制,允许在WAIT后重发DATA以恢复丢失的ACK - 添加连续SPI失败和WAIT计数监控,超过阈值时主动查询GD32状态 perf(can): 提升CAN批量发送性能和可靠性 - 将CAN帧间延迟从5ms减少到1ms以提升传输速度 - 将单批最大帧数从32增加到64,并将批次间隔从200ms减少到100ms - 添加手动超时控制器防止fetch请求永久挂起 - 实现批量发送完成后的兜底渲染机制确保结果正确显示 refactor(spi): 优化SPI通信的事件驱动机制 - 实现基于信号量的事件驱动等待机制,替换原有的轮询方式 - 将SPI时钟频率从4MHz提升至8MHz以提高传输速度 - 优化OTA发送后的静默期处理,不同类型的命令采用不同的延迟策略 - 添加临时SPI测速端点用于性能测试 chore(version): 更新固件版本至0.3.74 - 版本号从0.3.72提升到0.3.74 - 更新前端资源版本号以确保缓存刷新
This commit is contained in:
@@ -22,6 +22,7 @@ idf_component_register(
|
|||||||
"monitor/motor_ota_relay.cpp" # 电机 OTA relay(0xD0~0xD2)
|
"monitor/motor_ota_relay.cpp" # 电机 OTA relay(0xD0~0xD2)
|
||||||
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
|
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
|
||||||
"monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子
|
"monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子
|
||||||
|
"monitor/spi_speed_test.cpp" # [SPI-SPEEDTEST] 临时:SPI 测速端点(可整行删除)
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"."
|
"."
|
||||||
"core"
|
"core"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
// 固件版本
|
// 固件版本
|
||||||
#define FW_VERSION_MAJOR 0
|
#define FW_VERSION_MAJOR 0
|
||||||
#define FW_VERSION_MINOR 3
|
#define FW_VERSION_MINOR 3
|
||||||
#define FW_VERSION_PATCH 72
|
#define FW_VERSION_PATCH 74
|
||||||
#define FW_VERSION_STRING "0.3.72"
|
#define FW_VERSION_STRING "0.3.74" // can batch: 帧间隔 5→1ms;前端 BATCH_CHUNK=64 + fetch 超时加固 + finally 兜底 renderHistory
|
||||||
|
|
||||||
// Git 信息(手动填写)
|
// Git 信息(手动填写)
|
||||||
#define GIT_HASH "770ae14"
|
#define GIT_HASH "770ae14"
|
||||||
@@ -67,7 +67,13 @@ static void feedTask(void *arg)
|
|||||||
* 连续 SPI 失败计数:超过阈值时主动 QUERY GD32 真实状态,避免盲重试空转到 120s。 */
|
* 连续 SPI 失败计数:超过阈值时主动 QUERY GD32 真实状态,避免盲重试空转到 120s。 */
|
||||||
uint32_t consec_fail = 0U;
|
uint32_t consec_fail = 0U;
|
||||||
constexpr uint32_t kQueryAfterFails = 8U; /* 连续 8 次失败(~2s)后 QUERY 一次 */
|
constexpr uint32_t kQueryAfterFails = 8U; /* 连续 8 次失败(~2s)后 QUERY 一次 */
|
||||||
|
uint32_t consec_wait = 0U;
|
||||||
|
constexpr uint32_t kQueryAfterWaits = 150U; /* 连续 150 次 WAIT(~300ms)后 QUERY:
|
||||||
|
* 正常 buffer-full WAIT <100 次即恢复;
|
||||||
|
* 超限说明 GD32 可能因 auto-retry 重置了
|
||||||
|
* s_fw_fed=0,当前 offset 被 gap 拒收。 */
|
||||||
uint32_t ack_n = 0U; /* 成功 ACK 计数,用于降频写 s_status */
|
uint32_t ack_n = 0U; /* 成功 ACK 计数,用于降频写 s_status */
|
||||||
|
bool handshake_confirmed = false; /* GD32 已进 kTransfer/kErase,可标 TRANSFER */
|
||||||
|
|
||||||
while (s_feed_running && s_fw_offset < s_fw_size) {
|
while (s_feed_running && s_fw_offset < s_fw_size) {
|
||||||
if (nowMs() >= deadline_ms) {
|
if (nowMs() >= deadline_ms) {
|
||||||
@@ -88,10 +94,10 @@ static void feedTask(void *arg)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* 注:旧版有 "GD32 回到握手阶段→重置 offset" 的逻辑,
|
/* GD32 auto-retry 检测(v1.5.5+):GD32 SPI 断流/电机超时后自动重新握手,
|
||||||
* 但 v1.4.38+ 已移除握手重试(0x05 直接中止不重握手),
|
* 重置 s_fw_fed=0。ESP32 侧通过连续 WAIT→QUERY 检测到 state!=kTransfer 后
|
||||||
* 且 v1.4.58 feedData 在握手期间就接受数据(预填充缓冲)。
|
* 重置 s_fw_offset=0 从头喂(见下方 kRspWait 分支)。
|
||||||
* 若在此重置 offset,会导致缓冲中数据重复。已移除。 */
|
* 注:v1.4.38~v1.5.4 曾移除此逻辑(当时无 auto-retry,握手失败即中止)。 */
|
||||||
|
|
||||||
const uint32_t remaining = s_fw_size - s_fw_offset;
|
const uint32_t remaining = s_fw_size - s_fw_offset;
|
||||||
const uint32_t data_len = (remaining < MOTOR_OTA_CHUNK_DATA)
|
const uint32_t data_len = (remaining < MOTOR_OTA_CHUNK_DATA)
|
||||||
@@ -154,33 +160,98 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
|
|||||||
continue; /* 不推进 offset,同 offset 重发 */
|
continue; /* 不推进 offset,同 offset 重发 */
|
||||||
}
|
}
|
||||||
consec_fail = 0U;
|
consec_fail = 0U;
|
||||||
|
consec_wait = 0U;
|
||||||
|
|
||||||
if (resp_type == kRspAck) {
|
if (resp_type == kRspAck) {
|
||||||
s_fw_offset += data_len;
|
s_fw_offset += data_len;
|
||||||
/* 进度:每 8 chunk 写 s_status;state 在 suppress 下不会收 0xD2,
|
/* 进度:每 8 chunk 写 s_status。
|
||||||
* 喂数一开始就标 TRANSFER,避免网页卡在「开始握手」。 */
|
* ★ 不再喂数据一成功就强标 TRANSFER:GD32 feedData() 在握手阶段
|
||||||
const bool touch_status = ((++ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size);
|
* (kPreCancel/kHandshakeReq 等) 也接收预填充数据,若此刻强标
|
||||||
if (touch_status || ack_n == 1U) {
|
* TRANSFER,无电机的空通道会让网页误显示"数据传输 N/912"而非
|
||||||
const uint16_t fed_pkts = static_cast<uint16_t>((s_fw_offset + 47U) / 48U);
|
* "握手中…→超时"。改为:只在 GD32 真正进 kErase/kTransfer 后
|
||||||
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
|
* 才标 TRANSFER;握手未完成时保持握手态显示。
|
||||||
if (s_status.state < MOTOR_OTA_TRANSFER ||
|
* 握手未确认时每 8 个 ACK 重新 QUERY 一次(GD32 握手+擦除
|
||||||
s_status.state == MOTOR_OTA_PRE_CANCEL ||
|
* 通常 1~3s,8 个 ACK ~16 个 chunk ≈ 3700B <1s,需多轮确认)。 */
|
||||||
s_status.state == MOTOR_OTA_HANDSHAKE_REQ ||
|
++ack_n;
|
||||||
s_status.state == MOTOR_OTA_HANDSHAKE_NONCE ||
|
const bool touch_status = ((ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size);
|
||||||
s_status.state == MOTOR_OTA_HANDSHAKE_CONFIRM ||
|
|
||||||
s_status.state == MOTOR_OTA_ERASE) {
|
/* 握手未确认时定期 QUERY:确认 GD32 已过握手阶段才标 TRANSFER */
|
||||||
s_status.state = MOTOR_OTA_TRANSFER;
|
if (!handshake_confirmed && (ack_n & 7U) == 0U) {
|
||||||
|
uint8_t qcmd[1] = {2U};
|
||||||
|
uint8_t qt = 0, qp[16] = {0};
|
||||||
|
size_t ql = 0;
|
||||||
|
if (SpiMaster::instance().transact(
|
||||||
|
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
|
||||||
|
&& qt == kRspAck && ql >= 1U) {
|
||||||
|
motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
|
||||||
|
if (qst == MOTOR_OTA_ERASE || qst == MOTOR_OTA_TRANSFER ||
|
||||||
|
qst == MOTOR_OTA_FINISH_OTA || qst == MOTOR_OTA_WAIT_APP_RUN) {
|
||||||
|
handshake_confirmed = true;
|
||||||
|
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
|
||||||
|
s_status.state = MOTOR_OTA_TRANSFER;
|
||||||
|
s_status.last_update_ms = nowMs();
|
||||||
|
xSemaphoreGive(s_mutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 终态(ERROR/IDLE/FINISHED)交给下方终态检测处理 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (touch_status) {
|
||||||
|
const uint16_t fed_pkts = static_cast<uint16_t>((s_fw_offset + 47U) / 48U);
|
||||||
|
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(2)) == pdTRUE) {
|
||||||
|
/* 握手未确认时不标 TRANSFER、不更新 packets_acked:
|
||||||
|
* 此刻 fed_pkts 是喂给 GD32 环缓冲的字节数(预填充),
|
||||||
|
* 不是电机真实 ACK,握手未成时显示会让用户误以为在传输。 */
|
||||||
|
if (handshake_confirmed) {
|
||||||
|
s_status.state = MOTOR_OTA_TRANSFER;
|
||||||
|
s_status.packets_acked = fed_pkts;
|
||||||
|
}
|
||||||
|
s_status.last_update_ms = nowMs();
|
||||||
|
xSemaphoreGive(s_mutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (resp_type == kRspWait) {
|
||||||
|
/* 缓冲满 / gap 拒收:不推进 offset,稍候重试。
|
||||||
|
* WAIT 路径 SPI 层已推进 seq,避免同 seq 重发被 GD32 当重复请求死锁。 */
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(2));
|
||||||
|
/* 连续 WAIT 超限:可能 GD32 auto-retry 重置了 s_fw_fed=0,
|
||||||
|
* 当前 offset 被 gap 拒收(永远不会变 ACK)。QUERY 确认真实状态。 */
|
||||||
|
if (++consec_wait >= kQueryAfterWaits) {
|
||||||
|
consec_wait = 0U;
|
||||||
|
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
|
||||||
|
uint8_t qt = 0, qp[16] = {0};
|
||||||
|
size_t ql = 0;
|
||||||
|
if (SpiMaster::instance().transact(
|
||||||
|
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
|
||||||
|
&& qt == kRspAck && ql >= 2U) {
|
||||||
|
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
|
||||||
|
const uint8_t qerr = qp[1];
|
||||||
|
if (qst != MOTOR_OTA_TRANSFER) {
|
||||||
|
/* GD32 不在传输态(auto-retry 回了 kPreCancel/握手):
|
||||||
|
* 重置 offset 从头喂,配合 GD32 重新擦除+握手。 */
|
||||||
|
ESP_LOGW(TAG, "GD32 retry detected: state=%u err=0x%02X, reset offset %u->0",
|
||||||
|
(unsigned)qst, (unsigned)qerr, (unsigned)s_fw_offset);
|
||||||
|
s_fw_offset = 0U;
|
||||||
|
ack_n = 0U;
|
||||||
|
handshake_confirmed = false; /* 重新握手,需重新确认 */
|
||||||
|
} else {
|
||||||
|
handshake_confirmed = true; /* GD32 在 transfer,确认握手完成 */
|
||||||
|
}
|
||||||
|
/* 终态仍 break */
|
||||||
|
if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED ||
|
||||||
|
qst == MOTOR_OTA_ERROR) {
|
||||||
|
ESP_LOGW(TAG, "QUERY(WAIT) 终态 0x%02X err=0x%02X,停止",
|
||||||
|
(unsigned)qst, (unsigned)qerr);
|
||||||
|
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
|
||||||
|
s_status.state = qst;
|
||||||
|
s_status.error_code = qerr;
|
||||||
|
s_status.last_update_ms = nowMs();
|
||||||
|
xSemaphoreGive(s_mutex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
s_status.packets_acked = fed_pkts;
|
|
||||||
s_status.last_update_ms = nowMs();
|
|
||||||
xSemaphoreGive(s_mutex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (resp_type == kRspWait) {
|
|
||||||
/* 缓冲满:不推进 offset,稍候用新 TX_SEQ 再发同一 offset。
|
|
||||||
* WAIT 路径 SPI 层已推进 seq(见 otaPollResponseUnlocked),
|
|
||||||
* 避免同 seq 重发被 GD32 当重复请求死锁。 */
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(2));
|
|
||||||
} else {
|
} else {
|
||||||
/* 非 ACK/WAIT(含 ERROR/陈旧 type):不 break 整次 OTA。
|
/* 非 ACK/WAIT(含 ERROR/陈旧 type):不 break 整次 OTA。
|
||||||
* 曾因 poll 读到 EVENT/错 type 直接 break → fed 卡首 chunk。 */
|
* 曾因 poll 读到 EVENT/错 type 直接 break → fed 卡首 chunk。 */
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @file spi_speed_test.cpp
|
||||||
|
* @brief 【临时功能 SPI-SPEEDTEST】SPI 链路测速端点 GET /spi/speedtest
|
||||||
|
*
|
||||||
|
* 用 0xA1 APP PING(无副作用、GD32 在 onCsRelease ISR 内 µs 级应答)作探针,
|
||||||
|
* 经 SpiMaster::transact() 走与业务完全相同的 worker 串行路径,
|
||||||
|
* 测量事件驱动握手(ready 下降沿唤醒)下的真实 RTT 与等效吞吐。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* GET /spi/speedtest # 默认 200 帧 × 236B payload
|
||||||
|
* GET /spi/speedtest?frames=500&payload=0 # 500 帧空载(纯 RTT)
|
||||||
|
* 返回中文纯文本: 帧数/成功/失败、总耗时、往返延迟 平均/最快/最慢、等效吞吐
|
||||||
|
*
|
||||||
|
* ── 删除本临时功能的步骤(共 3 处挂接点,全部以 [SPI-SPEEDTEST] 标记)──
|
||||||
|
* 1. 删除本文件与 monitor/spi_speed_test.h
|
||||||
|
* 2. main/CMakeLists.txt 删除 "monitor/spi_speed_test.cpp" 一行
|
||||||
|
* 3. server/wifi_ota.cpp 删除 include 与 kHttpRoutes 中各一行
|
||||||
|
* (spi_master.cpp 中 0xA1 零盲等白名单为正式优化,保留不删)
|
||||||
|
*/
|
||||||
|
#include "spi_speed_test.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "spi_master.h"
|
||||||
|
#include "http_handlers.h" /* http_set_cors_headers */
|
||||||
|
#include "monitor/debug_log.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr uint8_t kProbeType = 0xA1U; /* APP PING:无副作用探针 */
|
||||||
|
constexpr uint32_t kFramesDefault = 200U;
|
||||||
|
constexpr uint32_t kFramesMax = 2000U; /* ~3s 上限,避免占死 HTTP/worker */
|
||||||
|
constexpr size_t kPayloadDefault = 236U; /* 满帧:等效 0xD1 电机 OTA 数据节奏 */
|
||||||
|
constexpr size_t kPayloadMax = 236U; /* APP kTxBufSize=256 - V2_OVERHEAD */
|
||||||
|
constexpr uint32_t kFrameTimeoutMs = 500U;
|
||||||
|
|
||||||
|
/** @brief 从 query 里取无符号整型参数,缺失/非法时返回默认值。 */
|
||||||
|
uint32_t queryU32(httpd_req_t *req, const char *key, uint32_t defval)
|
||||||
|
{
|
||||||
|
char qbuf[64];
|
||||||
|
char vbuf[16];
|
||||||
|
if (httpd_req_get_url_query_str(req, qbuf, sizeof(qbuf)) != ESP_OK) {
|
||||||
|
return defval;
|
||||||
|
}
|
||||||
|
if (httpd_query_key_value(qbuf, key, vbuf, sizeof(vbuf)) != ESP_OK) {
|
||||||
|
return defval;
|
||||||
|
}
|
||||||
|
char *end = nullptr;
|
||||||
|
const unsigned long v = strtoul(vbuf, &end, 10);
|
||||||
|
return (end && *end == '\0') ? (uint32_t)v : defval;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
esp_err_t spi_speedtest_handler(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
uint32_t frames = queryU32(req, "frames", kFramesDefault);
|
||||||
|
if (frames < 1U) { frames = 1U; }
|
||||||
|
if (frames > kFramesMax) { frames = kFramesMax; }
|
||||||
|
size_t payload_len = (size_t)queryU32(req, "payload", (uint32_t)kPayloadDefault);
|
||||||
|
if (payload_len > kPayloadMax) { payload_len = kPayloadMax; }
|
||||||
|
|
||||||
|
/* 探针 payload:递增填充(CRC32 覆盖,顺带校验数据路径完整性) */
|
||||||
|
uint8_t probe[kPayloadMax];
|
||||||
|
for (size_t i = 0; i < payload_len; ++i) {
|
||||||
|
probe[i] = (uint8_t)i;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t n_ok = 0U;
|
||||||
|
uint32_t n_fail = 0U;
|
||||||
|
uint64_t rtt_sum_us = 0U;
|
||||||
|
uint64_t rtt_min_us = UINT64_MAX;
|
||||||
|
uint64_t rtt_max_us = 0U;
|
||||||
|
/* RTT 分布直方图:<2 / 2-3 / 3-4 / 4-5 / >=5 ms,用于判断抖动形态
|
||||||
|
* (双峰=离散 +1ms 碰撞;均匀=tick 相位量化) */
|
||||||
|
uint32_t histo[5] = {0U, 0U, 0U, 0U, 0U};
|
||||||
|
|
||||||
|
DEBUG_INFO("SPI", "[SPEEDTEST] start frames=%u payload=%u",
|
||||||
|
(unsigned)frames, (unsigned)payload_len);
|
||||||
|
const int64_t t_start = esp_timer_get_time();
|
||||||
|
|
||||||
|
for (uint32_t i = 0U; i < frames; ++i) {
|
||||||
|
uint8_t resp_type = 0U;
|
||||||
|
uint8_t resp_payload[64];
|
||||||
|
size_t resp_len = 0U;
|
||||||
|
|
||||||
|
const int64_t t0 = esp_timer_get_time();
|
||||||
|
const esp_err_t ret = SpiMaster::instance().transact(
|
||||||
|
kProbeType, probe, payload_len,
|
||||||
|
&resp_type, resp_payload, sizeof(resp_payload), &resp_len,
|
||||||
|
kFrameTimeoutMs);
|
||||||
|
const int64_t t1 = esp_timer_get_time();
|
||||||
|
|
||||||
|
if (ret == ESP_OK && resp_type == 0x80U) {
|
||||||
|
const uint64_t rtt = (uint64_t)(t1 - t0);
|
||||||
|
++n_ok;
|
||||||
|
rtt_sum_us += rtt;
|
||||||
|
if (rtt < rtt_min_us) { rtt_min_us = rtt; }
|
||||||
|
if (rtt > rtt_max_us) { rtt_max_us = rtt; }
|
||||||
|
const uint32_t ms_bucket = (uint32_t)(rtt / 1000U);
|
||||||
|
if (ms_bucket < 2U) { ++histo[0]; }
|
||||||
|
else if (ms_bucket < 3U) { ++histo[1]; }
|
||||||
|
else if (ms_bucket < 4U) { ++histo[2]; }
|
||||||
|
else if (ms_bucket < 5U) { ++histo[3]; }
|
||||||
|
else { ++histo[4]; }
|
||||||
|
} else {
|
||||||
|
++n_fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int64_t elapsed_us = esp_timer_get_time() - t_start;
|
||||||
|
const uint32_t elapsed_ms = (uint32_t)(elapsed_us / 1000);
|
||||||
|
const uint64_t rtt_avg_us = (n_ok > 0U) ? (rtt_sum_us / n_ok) : 0U;
|
||||||
|
/* 吞吐语义:payload 字节 / 完整"发送+应答"周期,等效 0xD1 单缓冲喂速上限 */
|
||||||
|
double tput_kbps = 0.0;
|
||||||
|
if (elapsed_us > 0 && n_ok > 0U) {
|
||||||
|
tput_kbps = ((double)n_ok * (double)payload_len) / ((double)elapsed_us / 1000000.0) / 1024.0;
|
||||||
|
}
|
||||||
|
if (rtt_min_us == UINT64_MAX) { rtt_min_us = 0U; }
|
||||||
|
|
||||||
|
DEBUG_INFO("SPI", "[SPEEDTEST] done ok=%u fail=%u avg=%uus tput=%.1fKB/s",
|
||||||
|
(unsigned)n_ok, (unsigned)n_fail, (unsigned)rtt_avg_us, tput_kbps);
|
||||||
|
|
||||||
|
char text[480];
|
||||||
|
const int len = snprintf(text, sizeof(text),
|
||||||
|
"SPI 测速结果\n"
|
||||||
|
"帧数: %u 成功: %u 失败: %u%s\n"
|
||||||
|
"载荷: %u B/帧 总耗时: %u ms\n"
|
||||||
|
"往返延迟: 平均 %.1f ms 最快 %.1f ms 最慢 %.1f ms\n"
|
||||||
|
"延迟分布: <2ms:%u 2-3ms:%u 3-4ms:%u 4-5ms:%u >=5ms:%u\n"
|
||||||
|
"等效吞吐: %.1f KB/s\n",
|
||||||
|
(unsigned)frames, (unsigned)n_ok, (unsigned)n_fail,
|
||||||
|
(n_fail > 0U) ? " <-- 存在失败帧, 链路异常!" : "",
|
||||||
|
(unsigned)payload_len, (unsigned)elapsed_ms,
|
||||||
|
(double)rtt_avg_us / 1000.0, (double)rtt_min_us / 1000.0,
|
||||||
|
(double)rtt_max_us / 1000.0,
|
||||||
|
(unsigned)histo[0], (unsigned)histo[1], (unsigned)histo[2],
|
||||||
|
(unsigned)histo[3], (unsigned)histo[4], tput_kbps);
|
||||||
|
|
||||||
|
http_set_cors_headers(req);
|
||||||
|
httpd_resp_set_type(req, "text/plain; charset=utf-8");
|
||||||
|
return httpd_resp_send(req, text, (len > 0) ? len : 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @file spi_speed_test.h
|
||||||
|
* @brief 【临时功能 SPI-SPEEDTEST】SPI 链路测速端点声明。
|
||||||
|
* 删除方法见 spi_speed_test.cpp 文件头。
|
||||||
|
*/
|
||||||
|
#ifndef SPI_SPEED_TEST_H
|
||||||
|
#define SPI_SPEED_TEST_H
|
||||||
|
|
||||||
|
#include "esp_http_server.h"
|
||||||
|
|
||||||
|
/** @brief GET /spi/speedtest — 0xA1 探针测 SPI RTT/吞吐(临时功能)。 */
|
||||||
|
esp_err_t spi_speedtest_handler(httpd_req_t *req);
|
||||||
|
|
||||||
|
#endif // SPI_SPEED_TEST_H
|
||||||
+16
-17
@@ -1163,10 +1163,14 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
|
|||||||
size_t resp_len = 0;
|
size_t resp_len = 0;
|
||||||
const uint32_t expected_ack = (seq == m_transferTotalPackets - 1) ? kLastPacketAckSeq : (seq + 1U);
|
const uint32_t expected_ack = (seq == m_transferTotalPackets - 1) ? kLastPacketAckSeq : (seq + 1U);
|
||||||
/*
|
/*
|
||||||
* 协议要点:
|
* 协议要点(v2,GD32 侧缓存修复后):
|
||||||
* - GD32 ISR 收 DATA 后立刻回 WAIT,主循环 AES/Flash 后再 ACK。
|
* - GD32 ISR 收 DATA 后立刻回 WAIT,主循环 AES/Flash 后再回 ACK。
|
||||||
* - 一旦见过 WAIT,说明包已进从机;再发 DATA = DUP-SEQ,只会反复 WAIT。
|
* - 一旦见过 WAIT,包已进从机;但那帧 ACK 可能因时序(尤其 8MHz)丢失。
|
||||||
* - 因此:见 WAIT 后只 poll;仅「完全无响应」才允许重发 DATA。
|
* - GD32 主循环发 ACK 时已同步更新重发缓存(见 SpiOta::sender),
|
||||||
|
* 且主循环对已写入包号去重(receive_packet_num < next_expected_packet 只回 ACK,
|
||||||
|
* 不二次写 flash)——故重发 DATA 安全。
|
||||||
|
* - 因此:见 WAIT 后若 poll 超时,允许有限次重发 DATA 触发 DUP-SEQ 回放最终 ACK,
|
||||||
|
* 实现“重发快且不漏”(旧逻辑见 WAIT 后只能干 poll 到 stall,ACK 丢了就永丢)。
|
||||||
* - 等 ACK 期间可预组下一包(与从机处理重叠)。
|
* - 等 ACK 期间可预组下一包(与从机处理重叠)。
|
||||||
*/
|
*/
|
||||||
constexpr uint32_t kMaxDataSends = 3U;
|
constexpr uint32_t kMaxDataSends = 3U;
|
||||||
@@ -1195,10 +1199,9 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (uint32_t send_i = 0; send_i < kMaxDataSends; ++send_i) {
|
for (uint32_t send_i = 0; send_i < kMaxDataSends; ++send_i) {
|
||||||
/* 已见 WAIT:禁止再发 DATA,只延长 poll */
|
/* saw_wait 后仍允许重发 DATA:触发 GD32 DUP-SEQ 回放最终 ACK(缓存已修复)。
|
||||||
if (saw_wait && send_i > 0U) {
|
* 重发的 DATA 进 GD32 主循环会因包号去重只回 ACK,不二次写 flash。 */
|
||||||
DEBUG_DBG("OTA", "包 %" PRIu32 " 已见 WAIT,继续 poll 不重发", seq);
|
{
|
||||||
} else {
|
|
||||||
resp_len = 0;
|
resp_len = 0;
|
||||||
resp_type = 0;
|
resp_type = 0;
|
||||||
esp_err_t err = SpiMaster::instance().transact(
|
esp_err_t err = SpiMaster::instance().transact(
|
||||||
@@ -1264,16 +1267,12 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 超时:仅当从未见 WAIT 时才重发 DATA;见过 WAIT 则再 poll 一轮 */
|
/* 超时:无论是否见过 WAIT 都允许重发 DATA。
|
||||||
if (saw_wait) {
|
* 见过 WAIT 时重发靠 GD32 DUP-SEQ 回放缓存的最终 ACK 来捞回丢失的 ACK;
|
||||||
if (send_i + 1U >= kMaxDataSends) {
|
* 未见 WAIT 时重发则是重新送注。两者均安全(GD32 对已写包号去重)。 */
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (send_i + 1U < kMaxDataSends) {
|
if (send_i + 1U < kMaxDataSends) {
|
||||||
DEBUG_WARN("OTA", "包 %" PRIu32 " 无 WAIT/ACK,重发 DATA (%" PRIu32 "/%" PRIu32 ")",
|
DEBUG_WARN("OTA", "包 %" PRIu32 " 超时重发 DATA (%" PRIu32 "/%" PRIu32 ") saw_wait=%d",
|
||||||
seq, send_i + 1U, kMaxDataSends);
|
seq, send_i + 1U, kMaxDataSends, (int)saw_wait);
|
||||||
vTaskDelay(pdMS_TO_TICKS(2));
|
vTaskDelay(pdMS_TO_TICKS(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
|
|||||||
constexpr int kMaxFrames = 128;
|
constexpr int kMaxFrames = 128;
|
||||||
constexpr uint32_t kPerFrameTimeoutMs = 80U;
|
constexpr uint32_t kPerFrameTimeoutMs = 80U;
|
||||||
constexpr uint32_t kSingleFrameTimeoutMs = 1200U; /* 正常 <20ms;httpd 单线程,此值决定其它端点最坏阻塞 */
|
constexpr uint32_t kSingleFrameTimeoutMs = 1200U; /* 正常 <20ms;httpd 单线程,此值决定其它端点最坏阻塞 */
|
||||||
constexpr uint32_t kInterFrameDelayMs = 5U;
|
constexpr uint32_t kInterFrameDelayMs = 1U;
|
||||||
|
|
||||||
int content_len = req->content_len;
|
int content_len = req->content_len;
|
||||||
if (content_len <= 0 || content_len > kMaxBody) {
|
if (content_len <= 0 || content_len > kMaxBody) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
#include "spi_master.h"
|
#include "spi_master.h"
|
||||||
#include "ble_ota.h"
|
#include "ble_ota.h"
|
||||||
#include "http_handlers.h"
|
#include "http_handlers.h"
|
||||||
|
#include "monitor/spi_speed_test.h" /* [SPI-SPEEDTEST] 临时:可整行删除 */
|
||||||
|
|
||||||
|
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
@@ -483,6 +484,7 @@ esp_err_t WifiOta::httpServerStart()
|
|||||||
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
|
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
|
||||||
{"/motor/ota/status", HTTP_GET, motor_ota_status_handler},
|
{"/motor/ota/status", HTTP_GET, motor_ota_status_handler},
|
||||||
{"/motor/ota/cancel", HTTP_POST, motor_ota_cancel_handler},
|
{"/motor/ota/cancel", HTTP_POST, motor_ota_cancel_handler},
|
||||||
|
{"/spi/speedtest", HTTP_GET, spi_speedtest_handler}, /* [SPI-SPEEDTEST] 临时:可整行删除 */
|
||||||
};
|
};
|
||||||
|
|
||||||
/* 同一 path 可有多 method(如 GET+POST);OPTIONS 每个 path 只注册一次,
|
/* 同一 path 可有多 method(如 GET+POST);OPTIONS 每个 path 只注册一次,
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ static int IRAM_ATTR readReadyLevelRaw(void)
|
|||||||
* 但 serviceRingOnce / otaPollResponseUnlocked 等更早出现的方法需要它。 */
|
* 但 serviceRingOnce / otaPollResponseUnlocked 等更早出现的方法需要它。 */
|
||||||
static esp_err_t waitSpiDataReady(uint32_t timeout_ms);
|
static esp_err_t waitSpiDataReady(uint32_t timeout_ms);
|
||||||
|
|
||||||
|
/** @brief DATA_READY 下降沿事件信号量(ISR give / waitSpiDataReady take)。
|
||||||
|
* 二值信号量:多余边沿饱和为 1,消费方醒来后必须重查电平,
|
||||||
|
* 陈旧 give 只多一次电平检查,无副作用。 */
|
||||||
|
static SemaphoreHandle_t s_readyEdgeSem = nullptr;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 单例
|
// 单例
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -220,12 +225,14 @@ esp_err_t SpiMaster::serviceRingOnce()
|
|||||||
return ESP_ERR_NOT_FOUND;
|
return ESP_ERR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 仅 worker 调用。有 pending 或 DATA_READY 低时读一帧 EVENT。 */
|
/* 仅 worker 调用。有 pending 或 DATA_READY 低时读一帧 EVENT。
|
||||||
if (!m_gd32RequestPending) {
|
* ★ pending 也必须验电平:业务响应(PING/OTA ACK)拉低 PB6 时链路忙,
|
||||||
/* 无 ISR 标志时,若线已低仍尝试读(丢边沿兜底) */
|
* ISR 误记 pending;响应被 poll 读走后 PB6 已回高,若不验电平会白等
|
||||||
if (readReadyLevelRaw() != 0) {
|
* waitSpiDataReady(3ms) 超时,把每笔业务周期拖慢 ~3ms(测速直方图
|
||||||
return ESP_ERR_NOT_FOUND;
|
* 79% 帧堆在 3-4ms 的根因)。真按铃时 PB6 在被读走前保持低,不误伤。 */
|
||||||
}
|
if (readReadyLevelRaw() != 0) {
|
||||||
|
m_gd32RequestPending = false;
|
||||||
|
return ESP_ERR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
m_gd32RequestPending = false;
|
m_gd32RequestPending = false;
|
||||||
m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING);
|
m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING);
|
||||||
@@ -557,8 +564,11 @@ void SpiMaster::workerLoop()
|
|||||||
* @brief GPIO 边沿中断回调(DATA_READY / PB6)。
|
* @brief GPIO 边沿中断回调(DATA_READY / PB6)。
|
||||||
*
|
*
|
||||||
* 仅处理下降沿:GD32 有待读数据(按铃)。上升沿不再使用(旧的"等上升沿
|
* 仅处理下降沿:GD32 有待读数据(按铃)。上升沿不再使用(旧的"等上升沿
|
||||||
* ready"逻辑已删除,现由 waitSpiDataReady 做电平轮询)。
|
* ready"逻辑已删除)。
|
||||||
* ISR 内仅做标志翻转 + 任务通知,不做 SPI 传输。
|
* ISR 内仅做标志翻转 + 信号量/任务通知,不做 SPI 传输。
|
||||||
|
*
|
||||||
|
* 所有下降沿先 give s_readyEdgeSem:唤醒阻塞在 waitSpiDataReady 的任务
|
||||||
|
* (OTA 响应等待),把原来 1ms tick 轮询变成 µs 级事件驱动。
|
||||||
*
|
*
|
||||||
* 本端忙时(acceptRing()==false):记下 pending 但不 notify,避免抢断
|
* 本端忙时(acceptRing()==false):记下 pending 但不 notify,避免抢断
|
||||||
* 当前 SPI 事务;事务结束 worker 下一轮会立即消费此标志,省去最多 25ms
|
* 当前 SPI 事务;事务结束 worker 下一轮会立即消费此标志,省去最多 25ms
|
||||||
@@ -572,6 +582,13 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg)
|
|||||||
if (readReadyLevelRaw() != 0) {
|
if (readReadyLevelRaw() != 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* 无条件先 give 边沿事件:精确唤醒 waitSpiDataReady(响应已装载)。
|
||||||
|
* 无人等待时二值信号量饱和为 1,由消费方电平重查兜底。 */
|
||||||
|
if (s_readyEdgeSem != nullptr) {
|
||||||
|
BaseType_t sem_hp = pdFALSE;
|
||||||
|
xSemaphoreGiveFromISR(s_readyEdgeSem, &sem_hp);
|
||||||
|
portYIELD_FROM_ISR(sem_hp);
|
||||||
|
}
|
||||||
if (self->m_suppressRingService) {
|
if (self->m_suppressRingService) {
|
||||||
/* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */
|
/* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */
|
||||||
return;
|
return;
|
||||||
@@ -594,6 +611,8 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg)
|
|||||||
/**
|
/**
|
||||||
* @brief 等 GD32 拉低 PB6(有待读响应)。
|
* @brief 等 GD32 拉低 PB6(有待读响应)。
|
||||||
* v2 DATA_READY:低 = 有数据;高 = 队列空。TX 前不等 HIGH(CS 即互斥)。
|
* v2 DATA_READY:低 = 有数据;高 = 队列空。TX 前不等 HIGH(CS 即互斥)。
|
||||||
|
* 事件驱动:阻塞在 s_readyEdgeSem 上,由下降沿 ISR 精确唤醒(µs 级),
|
||||||
|
* 每次醒来重查电平以过滤陈旧 give;信号量不可用时退回 1ms tick 轮询。
|
||||||
* @return ESP_OK 已低;ESP_ERR_TIMEOUT 超时仍为高(调用方可再决定是否盲 poll)。
|
* @return ESP_OK 已低;ESP_ERR_TIMEOUT 超时仍为高(调用方可再决定是否盲 poll)。
|
||||||
*/
|
*/
|
||||||
static esp_err_t waitSpiDataReady(uint32_t timeout_ms)
|
static esp_err_t waitSpiDataReady(uint32_t timeout_ms)
|
||||||
@@ -602,11 +621,16 @@ static esp_err_t waitSpiDataReady(uint32_t timeout_ms)
|
|||||||
if (timeout_ms == 0U) { return ESP_ERR_TIMEOUT; }
|
if (timeout_ms == 0U) { return ESP_ERR_TIMEOUT; }
|
||||||
|
|
||||||
const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
|
const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
|
||||||
while (xTaskGetTickCount() < deadline) {
|
for (;;) {
|
||||||
if (readReadyLevelRaw() == 0) { return ESP_OK; }
|
if (readReadyLevelRaw() == 0) { return ESP_OK; }
|
||||||
vTaskDelay(pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1);
|
const TickType_t now = xTaskGetTickCount();
|
||||||
|
if (now >= deadline) { return ESP_ERR_TIMEOUT; }
|
||||||
|
if (s_readyEdgeSem != nullptr) {
|
||||||
|
(void)xSemaphoreTake(s_readyEdgeSem, deadline - now);
|
||||||
|
} else {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ESP_ERR_TIMEOUT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -968,6 +992,14 @@ esp_err_t SpiMaster::init()
|
|||||||
DEBUG_INFO("SPI", "DATA_READY 输入已配置: GPIO%d (← GD32 PB6,低=有数据)", SPI_READY_GPIO);
|
DEBUG_INFO("SPI", "DATA_READY 输入已配置: GPIO%d (← GD32 PB6,低=有数据)", SPI_READY_GPIO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 边沿事件信号量必须先于 ISR 注册创建,避免 ISR 触发时句柄为空 */
|
||||||
|
if (s_readyEdgeSem == nullptr) {
|
||||||
|
s_readyEdgeSem = xSemaphoreCreateBinary();
|
||||||
|
if (s_readyEdgeSem == nullptr) {
|
||||||
|
DEBUG_WARN("SPI", "ready edge sem create failed, 退回轮询等待");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 安装 GPIO ISR 服务(全局仅安装一次)并注册边沿回调 */
|
/* 安装 GPIO ISR 服务(全局仅安装一次)并注册边沿回调 */
|
||||||
static bool s_isrInstalled = false;
|
static bool s_isrInstalled = false;
|
||||||
if (!s_isrInstalled) {
|
if (!s_isrInstalled) {
|
||||||
@@ -1487,12 +1519,16 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
|
|||||||
return send_ret;
|
return send_ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CS 释放后短延时,再 poll 应答。
|
/* CS 释放后的静默期分型处理:
|
||||||
* GD32 主控 OTA / HELLO 等:5ms(BOOT AES 路径需要)。
|
* - 0xD1(电机 OTA 数据)/ 0xA1(APP PING):GD32 APP 在 onCsRelease ISR
|
||||||
* 电机 OTA 数据 0xD1:1ms。每 chunk 固定 5ms 会把 SPI 喂速压到 ~47KB/s 边缘,
|
* 内 µs 级装好响应并拉低 PB6 → 零盲等,waitSpiDataReady 由下降沿 ISR
|
||||||
* CAN 5ms/包消费时环缓经常被掏空(日志「缓冲空,等ESP32喂」+ ACK 超时重发)。 */
|
* 精确唤醒,旧 1ms 固定延时压制喂速~47KB/s,现由事件驱动提速。
|
||||||
const uint32_t post_cs_ms = (type == 0xD1U) ? 1U : 5U;
|
* - 其他类型(GD32 relay/BOOT AES/HELLO 等):保留 5ms——BOOT 在主循环
|
||||||
vTaskDelay(pdMS_TO_TICKS(post_cs_ms));
|
* 处理 CHALLENGE/flash,poll 前 2 轮盲读 CS 过早打进去会打断其
|
||||||
|
* EXTI/DMA 时序(实测去掉后 CHALLENGE 超时)。 */
|
||||||
|
if (type != 0xD1U && type != 0xA1U) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(5));
|
||||||
|
}
|
||||||
|
|
||||||
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
|
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
|
||||||
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout,
|
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout,
|
||||||
|
|||||||
+4
-2
@@ -12,8 +12,10 @@ CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000
|
|||||||
# 不要再开 QIO,除非确认 flash 型号与硬件连接均兼容。
|
# 不要再开 QIO,除非确认 flash 型号与硬件连接均兼容。
|
||||||
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
|
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
|
||||||
|
|
||||||
# DCU SPI clock -- 4 MHz(GD32 SPI0 RX DMA 武装后可安全提速;8/12MHz 须零错误验证后再开)
|
# DCU SPI clock -- 8 MHz(2026-07-31 提速:事件驱动握手后时钟占周期 ~78%,
|
||||||
CONFIG_DCU_SPI_CLOCK_HZ=4000000
|
# 8MHz 实测测速页/relay/PING 零错误后启用;半周期 62.5ns,GD32 从机 MISO
|
||||||
|
# 输出延迟 + ESP32 GPIO 矩阵输入延迟 ~45ns 仍有余量;≥16MHz 全双工不可行)
|
||||||
|
CONFIG_DCU_SPI_CLOCK_HZ=8000000
|
||||||
|
|
||||||
# GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压
|
# GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压
|
||||||
# (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发)
|
# (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发)
|
||||||
|
|||||||
+1
-1
@@ -329,6 +329,6 @@
|
|||||||
<span id="toastMsg">操作成功</span>
|
<span id="toastMsg">操作成功</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="js/can.js?v=20260727a"></script>
|
<script type="module" src="js/can.js?v=20260801f"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ import {
|
|||||||
sendManualFrame,
|
sendManualFrame,
|
||||||
resetManualFields,
|
resetManualFields,
|
||||||
loadPreviewToManual
|
loadPreviewToManual
|
||||||
} from './can/send.js';
|
} from './can/send.js?v=20260801f';
|
||||||
import {
|
import {
|
||||||
scheduleCanPersist,
|
scheduleCanPersist,
|
||||||
loadCanSession,
|
loadCanSession,
|
||||||
|
|||||||
+26
-12
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js';
|
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js';
|
||||||
import { escapeHtml } from '../common/utils.js';
|
import { escapeHtml } from '../common/utils.js';
|
||||||
import { showToast } from '../common/toast.js';
|
import { showToast } from '../common/toast.js?v=20260801f';
|
||||||
import { updateBatchUi } from './messages.js';
|
import { updateBatchUi } from './messages.js';
|
||||||
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js';
|
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js';
|
||||||
import { scheduleCanPersist } from './persist.js';
|
import { scheduleCanPersist } from './persist.js';
|
||||||
@@ -124,12 +124,23 @@ async function sendCanBatchRequest(channel, frames) {
|
|||||||
var n = frames && frames.length ? frames.length : 0;
|
var n = frames && frames.length ? frames.length : 0;
|
||||||
/* 单帧设备侧 timeout 2s;多帧按帧数留余量 */
|
/* 单帧设备侧 timeout 2s;多帧按帧数留余量 */
|
||||||
var timeoutMs = (n <= 1) ? 8000 : Math.max(15000, n * 500);
|
var timeoutMs = (n <= 1) ? 8000 : Math.max(15000, n * 500);
|
||||||
var resp = await fetch(getBaseUrl() + '/can/send_batch', {
|
/* AbortSignal.timeout 在连接被 reset 的边缘情况下偶发不触发,
|
||||||
method: 'POST',
|
* 叠加一条手动竞速超时,避免 await 永久挂起导致整批不渲染。 */
|
||||||
headers: { 'Content-Type': 'application/json' },
|
var ac = new AbortController();
|
||||||
body: JSON.stringify({ ch: channel, frames: frames }),
|
var timer = setTimeout(function () { ac.abort(); }, timeoutMs);
|
||||||
signal: AbortSignal.timeout(timeoutMs)
|
var resp;
|
||||||
});
|
try {
|
||||||
|
resp = await fetch(getBaseUrl() + '/can/send_batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ch: channel, frames: frames }),
|
||||||
|
signal: ac.signal
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('请求失败: ' + (err && err.message ? err.message : err));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
var text = await resp.text();
|
var text = await resp.text();
|
||||||
var json = {};
|
var json = {};
|
||||||
try {
|
try {
|
||||||
@@ -231,12 +242,12 @@ export function renderHistory() {
|
|||||||
|
|
||||||
// ==================== 批量发送 ====================
|
// ==================== 批量发送 ====================
|
||||||
/**
|
/**
|
||||||
* 单批最多帧数。实测一次发送 >9 帧失败率明显上升,故按 9 帧分批;
|
* 单批最多帧数。按 64 帧分批(一次请求发 64 帧);批次之间留
|
||||||
* 批次之间留 BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
|
* BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
|
||||||
*/
|
*/
|
||||||
var BATCH_CHUNK = 32;
|
var BATCH_CHUNK = 64;
|
||||||
/** 批次间延时(ms),仅在还有后续批次时等待 */
|
/** 批次间延时(ms),仅在还有后续批次时等待 */
|
||||||
var BATCH_INTER_CHUNK_DELAY_MS = 200;
|
var BATCH_INTER_CHUNK_DELAY_MS = 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 批量发送所有勾选的报文(仅 /can/send_batch)。
|
* @description 批量发送所有勾选的报文(仅 /can/send_batch)。
|
||||||
@@ -330,10 +341,13 @@ export async function sendCheckedMessages() {
|
|||||||
renderHistory();
|
renderHistory();
|
||||||
scheduleCanPersist();
|
scheduleCanPersist();
|
||||||
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
|
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
|
||||||
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
|
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone, { force: true });
|
||||||
} finally {
|
} finally {
|
||||||
state.batchInFlight = false;
|
state.batchInFlight = false;
|
||||||
updateBatchUi();
|
updateBatchUi();
|
||||||
|
/* 兜底渲染:无论 try 内是否抛异常,都保证已写入的 silent 历史条目可见,
|
||||||
|
* 避免「批量发了但结果不显示」的偶发现象(renderHistory 幂等,多调一次无害)。 */
|
||||||
|
renderHistory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,20 @@ const TOAST_AUTOHIDE_MS = 3000;
|
|||||||
var _toastHideTimer = null;
|
var _toastHideTimer = null;
|
||||||
var _toastLastKey = '';
|
var _toastLastKey = '';
|
||||||
var _toastLastAt = 0;
|
var _toastLastAt = 0;
|
||||||
export function showToast(message, type) {
|
/**
|
||||||
|
* @param {string} message
|
||||||
|
* @param {string} [type] success/error/warning
|
||||||
|
* @param {{ force?: boolean }} [opts] force=true 时跳过 5s 去重,
|
||||||
|
* 用于用户主动操作(如批量发送完成)的反馈,避免 alert 阻塞后时间漂移导致被去重吞掉。
|
||||||
|
*/
|
||||||
|
export function showToast(message, type, opts) {
|
||||||
type = type || 'success';
|
type = type || 'success';
|
||||||
var toast = document.getElementById('toast');
|
var toast = document.getElementById('toast');
|
||||||
if (!toast) return;
|
if (!toast) return;
|
||||||
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
|
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
|
||||||
var key = type + '|' + message;
|
var key = type + '|' + message;
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
if (key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
|
if (!(opts && opts.force) && key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_toastLastKey = key;
|
_toastLastKey = key;
|
||||||
|
|||||||
+23
-7
@@ -2172,8 +2172,14 @@ function startOtaPoll() {
|
|||||||
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
|
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
|
||||||
if (st.state && st.state !== lastState) {
|
if (st.state && st.state !== lastState) {
|
||||||
const zh = OTA_STATE_ZH[st.state] || st.state;
|
const zh = OTA_STATE_ZH[st.state] || st.state;
|
||||||
|
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
|
||||||
|
* 不显示 x/y 进度——
|
||||||
|
* 握手未成时 packets_acked 是 ESP32 预填充字节数,不是电机真实 ACK,
|
||||||
|
* 显示 "0/912" 无意义且让用户误以为在传输。transfer 起才显示进度。 */
|
||||||
|
const showPkt = (st.state === "transfer" || st.state === "wait_app_run" ||
|
||||||
|
st.state === "finish_ota" || st.state === "finished");
|
||||||
otaLog(`<span class="log-info">[状态]</span> ${zh}` +
|
otaLog(`<span class="log-info">[状态]</span> ${zh}` +
|
||||||
(st.total_packets ? ` · ${st.packets_acked || 0}/${st.total_packets}` : ""));
|
(showPkt && st.total_packets ? ` · ${st.packets_acked || 0}/${st.total_packets}` : ""));
|
||||||
lastState = st.state;
|
lastState = st.state;
|
||||||
} else if (st.state === "transfer" && st.total_packets > 0) {
|
} else if (st.state === "transfer" && st.total_packets > 0) {
|
||||||
const acked = st.packets_acked || 0;
|
const acked = st.packets_acked || 0;
|
||||||
@@ -2253,13 +2259,23 @@ function startOtaPoll() {
|
|||||||
setOtaStage(stage);
|
setOtaStage(stage);
|
||||||
const acked = st.packets_acked || 0;
|
const acked = st.packets_acked || 0;
|
||||||
const total = st.total_packets || 1;
|
const total = st.total_packets || 1;
|
||||||
/* HTML 模板已是「包 <已确认> / <总数>」,这里各填各的;
|
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
|
||||||
* 之前往 otaPktNum 塞整串 "acked/total" 会渲染成「包 696/912 / 912」。 */
|
* 不更新进度条和包数——
|
||||||
$("#otaPktNum").textContent = String(acked);
|
* 此时 packets_acked 是 ESP32 预填充字节数不是电机真实 ACK,
|
||||||
$("#otaPktTotal").textContent = String(total);
|
* 显示 0/912 会让人误以为在传输。transfer 起才显示进度。 */
|
||||||
$("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
|
const inTransfer = (st.state === "transfer" || st.state === "wait_app_run" ||
|
||||||
|
st.state === "finish_ota" || st.state === "finished");
|
||||||
|
if (inTransfer) {
|
||||||
|
$("#otaPktNum").textContent = String(acked);
|
||||||
|
$("#otaPktTotal").textContent = String(total);
|
||||||
|
$("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
|
||||||
|
} else {
|
||||||
|
/* 握手阶段进度条归零,避免残留上次进度 */
|
||||||
|
$("#otaPktNum").textContent = "0";
|
||||||
|
$("#otaProgressFill").style.width = "0%";
|
||||||
|
}
|
||||||
const elapsed = (performance.now() - startTime) / 1000;
|
const elapsed = (performance.now() - startTime) / 1000;
|
||||||
if (elapsed > 0.5 && acked > 0) {
|
if (elapsed > 0.5 && acked > 0 && inTransfer) {
|
||||||
$("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
|
$("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
|
||||||
}
|
}
|
||||||
$("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
|
$("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
|
||||||
|
|||||||
+1
-1
@@ -345,6 +345,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="js/motor/main.js?v=20260730g"></script>
|
<script type="module" src="js/motor/main.js?v=20260801b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
【临时功能 SPI-SPEEDTEST】SPI 链路测速独立页面
|
||||||
|
─────────────────────────────────────────────
|
||||||
|
完全自包含(无外部 css/js 依赖),不被任何其他页面引用。
|
||||||
|
删除方法:直接删掉本文件即可(固件端点删除方法见
|
||||||
|
main/monitor/spi_speed_test.cpp 文件头)。
|
||||||
|
|
||||||
|
用法:浏览器直接打开本文件,或经 serve.bat 访问
|
||||||
|
http://localhost:8080/spi_test.html
|
||||||
|
-->
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SPI 测速(临时工具)</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #14171c; color: #d8dee6;
|
||||||
|
max-width: 760px; margin: 0 auto; padding: 24px 16px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 20px; margin-bottom: 4px; }
|
||||||
|
.sub { color: #7a8494; font-size: 12px; margin-bottom: 20px; }
|
||||||
|
.card {
|
||||||
|
background: #1c2129; border: 1px solid #2a313c; border-radius: 8px;
|
||||||
|
padding: 16px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
label { font-size: 13px; color: #9aa5b3; margin-right: 6px; }
|
||||||
|
input {
|
||||||
|
background: #12151a; color: #d8dee6; border: 1px solid #2a313c;
|
||||||
|
border-radius: 4px; padding: 6px 8px; font-size: 14px; width: 130px;
|
||||||
|
}
|
||||||
|
input.narrow { width: 70px; }
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||||||
|
button {
|
||||||
|
background: #2563eb; color: #fff; border: 0; border-radius: 6px;
|
||||||
|
padding: 8px 18px; font-size: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
button:disabled { background: #3a4250; cursor: wait; }
|
||||||
|
button.alt { background: #374151; }
|
||||||
|
button.alt:hover { background: #4b5563; }
|
||||||
|
pre {
|
||||||
|
background: #12151a; border: 1px solid #2a313c; border-radius: 6px;
|
||||||
|
padding: 12px; font-size: 14px; line-height: 1.7; white-space: pre-wrap;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
pre.fail { border-color: #b91c1c; color: #fca5a5; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #2a313c; }
|
||||||
|
th { color: #7a8494; font-weight: normal; }
|
||||||
|
td.bad { color: #f87171; }
|
||||||
|
.hint { color: #7a8494; font-size: 12px; line-height: 1.7; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SPI 链路测速</h1>
|
||||||
|
<div class="sub">临时诊断工具 [SPI-SPEEDTEST] — 探针 0xA1 APP PING,删除时直接删掉本文件</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<label>设备 IP</label><input id="ip" value="192.168.31.127">
|
||||||
|
<label>帧数</label><input id="frames" class="narrow" value="200">
|
||||||
|
<label>载荷 B</label><input id="payload" class="narrow" value="236">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="btnRun">开始测速</button>
|
||||||
|
<button id="btnRtt" class="alt">空载 RTT(500 帧)</button>
|
||||||
|
<button id="btnStress" class="alt">压力测试(2000 帧满载)</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
满帧 236B = 等效电机 OTA 0xD1 数据节奏;载荷 0 = 纯握手往返。<br>
|
||||||
|
判读:失败必须为 0;「最快」是链路物理极限周期;「平均-最快」差值是软件调度开销。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<pre id="out">等待测试…</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>时间</th><th>帧数</th><th>载荷</th><th>失败</th><th>平均</th><th>最快</th><th>吞吐</th></tr></thead>
|
||||||
|
<tbody id="hist"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="hint">本页历史仅存内存,刷新即清空——方便改参数前后对比。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
(function () {
|
||||||
|
var out = document.getElementById("out");
|
||||||
|
var hist = document.getElementById("hist");
|
||||||
|
var btns = [
|
||||||
|
document.getElementById("btnRun"),
|
||||||
|
document.getElementById("btnRtt"),
|
||||||
|
document.getElementById("btnStress")
|
||||||
|
];
|
||||||
|
var ipInput = document.getElementById("ip");
|
||||||
|
|
||||||
|
var savedIp = null;
|
||||||
|
try { savedIp = window.localStorage.getItem("spi_test_ip"); } catch (e) {}
|
||||||
|
if (savedIp) { ipInput.value = savedIp; }
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
for (var i = 0; i < btns.length; i++) { btns[i].disabled = busy; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
||||||
|
|
||||||
|
function addHistory(frames, payload, text) {
|
||||||
|
/* 从设备中文文本里提取关键数字做历史行 */
|
||||||
|
var fail = (text.match(/失败:\s*(\d+)/) || [])[1] || "?";
|
||||||
|
var avg = (text.match(/平均\s*([\d.]+)\s*ms/) || [])[1] || "?";
|
||||||
|
var min = (text.match(/最快\s*([\d.]+)\s*ms/) || [])[1] || "?";
|
||||||
|
var tput = (text.match(/等效吞吐:\s*([\d.]+)\s*KB\/s/) || [])[1] || "-";
|
||||||
|
var d = new Date();
|
||||||
|
var tr = document.createElement("tr");
|
||||||
|
var cells = [
|
||||||
|
pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds()),
|
||||||
|
String(frames), String(payload) + "B", fail,
|
||||||
|
avg + "ms", min + "ms", tput === "-" ? "-" : tput + "KB/s"
|
||||||
|
];
|
||||||
|
for (var i = 0; i < cells.length; i++) {
|
||||||
|
var td = document.createElement("td");
|
||||||
|
td.textContent = cells[i];
|
||||||
|
if (i === 3 && fail !== "0") { td.className = "bad"; }
|
||||||
|
tr.appendChild(td);
|
||||||
|
}
|
||||||
|
hist.insertBefore(tr, hist.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(frames, payload) {
|
||||||
|
var ip = ipInput.value.trim();
|
||||||
|
try { window.localStorage.setItem("spi_test_ip", ip); } catch (e) {}
|
||||||
|
var url = "http://" + ip + "/spi/speedtest?frames=" + frames + "&payload=" + payload;
|
||||||
|
setBusy(true);
|
||||||
|
out.className = "";
|
||||||
|
out.textContent = "测试中… (" + frames + " 帧 × " + payload + "B)";
|
||||||
|
fetch(url)
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) { throw new Error("HTTP " + r.status); }
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then(function (text) {
|
||||||
|
var failed = /失败:\s*[1-9]/.test(text);
|
||||||
|
out.className = failed ? "fail" : "";
|
||||||
|
out.textContent = text;
|
||||||
|
addHistory(frames, payload, text);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
out.className = "fail";
|
||||||
|
out.textContent = "请求失败: " + err.message + "\n请检查设备 IP 与网络连通性。";
|
||||||
|
})
|
||||||
|
.then(function () { setBusy(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(v, lo, hi, dflt) {
|
||||||
|
var n = parseInt(v, 10);
|
||||||
|
if (isNaN(n)) { return dflt; }
|
||||||
|
return Math.min(hi, Math.max(lo, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
btns[0].onclick = function () {
|
||||||
|
run(clampInt(document.getElementById("frames").value, 1, 2000, 200),
|
||||||
|
clampInt(document.getElementById("payload").value, 0, 236, 236));
|
||||||
|
};
|
||||||
|
btns[1].onclick = function () { run(500, 0); };
|
||||||
|
btns[2].onclick = function () { run(2000, 236); };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user