Compare commits

...
3 Commits
Author SHA1 Message Date
小龙虾 f9cbcc396b feat(spi): 增强 SPI master:性能剖析与执行耗时跟踪
- 新增 `lastWorkerExecUs()`:获取最近一次 worker 任务执行耗时(µs)。
- 引入 `SpiProfile` 结构体:事务级详细剖析,记录 SPI 事务各阶段的时间戳。
- 实现 `lastProfile()`:诊断用,获取最近一次事务剖析快照。
- SPI 时钟配置提升至 13.3 MHz,改善性能。
- 修改前端 JavaScript 轮询逻辑:新增 uptime 检查,更好地处理 ESP32 重启状态。
- SPI 测速 HTML 的计时单位由毫秒改为微秒显示。
- 新建 `spi_ring_cache.h`:共享 SPI ring push 缓存工具。
- 新增 `http_util.h`:共享 HTTP 处理器工具,含 JSON 拼接与查询参数解析。
- 引入 `json_parse.h`:轻量级 JSON 解析器,针对扁平 JSON 对象设计。
- 新增 Windows 环境下的项目构建批处理脚本。
- 重构 CAN 反馈处理:从推送式改为请求驱动架构,降低内存占用、提升效率。
2026-08-02 17:37:19 +08:00
小龙虾 04a118219f 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
- 更新前端资源版本号以确保缓存刷新
2026-08-01 04:14:51 +08:00
小龙虾 e9cb7342f6 feat(motor): 支持多通道同节点号电机状态管理
支持同一节点号在多个CAN通道上的电机状态管理,采用node×channel双键存储机制。

- 修改motor_state.cpp中的槽位索引逻辑,从单节点索引改为(node-1)*MOTOR_CHANNELS+channel索引
- 增加MOTOR_CHANNELS和MOTOR_MAX_SLOTS宏定义,支持9节点×3通道共27个槽位
- 更新http_handlers.cpp中的二进制响应缓冲区为static类型,避免大缓冲区占用栈空间
- 调整循环遍历范围从MOTOR_MAX_NODES改为MOTOR_MAX_SLOTS
- 添加HTML页面的空favicon配置,避免浏览器404请求
- 新增CSS样式用于显示节点在其他通道在线的状态提示
2026-07-30 11:57:21 +08:00
53 changed files with 2636 additions and 4789 deletions
@@ -0,0 +1,81 @@
## 方案:/can/feedback 改为请求驱动架构
### 目标
- ESP32 不再被动缓存 GD32 的 0xC1 推送,改为前端请求时主动向 GD32 查询
- GD32 停止主动推送 0xC1(降低 SPI 资源占用)
- 删除 `s_latest[3]`/`s_work[3]` 常驻 BSS(省 ~7.8KB)+ handler 栈上 `results[3]`(省 ~3.9KB 栈)
### 依据
- GD32 `handleFeedback` 已支持 0xC1 请求查询(`[channel, page]` → ACK + 页数据),无需新增协议
- ESP32 `transact(0xC1, ...)` 是现成请求/响应机制,`canFdSend`/`canFdSetRxMode` 已在用
- `canFdParseFeedbackPage` 可直接复用(只解析 payload 字节,不关心来源)
- 0xC4/0xC5/0xC6/0xD2 推送独立,不受影响
### 改动清单
#### 1. ESP32 侧 — `main/transport/canfd_bridge.cpp` / `.h`
新增 `canFdQueryFeedback(channel, page, can_feedback_result_t *out, uint8_t *entryIdx)`:
```cpp
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx);
```
- 发 `transact(0xC1, {channel, page}, ...)`,超时 200ms
- 成功(resp_type==ACK)→ 调 `canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx)`
- 返回 0=成功,-1=SPI/参数失败,-2=GD32 拒绝(ERROR)
#### 2. ESP32 侧 — `main/server/http_handlers.cpp` `can_feedback_handler`
- 删除 `results[CAN_CHANNEL_MAX]` 栈数组 + `getLatestCanFeedback` 循环
- 改为:对每个使能通道,调 `canFdQueryFeedback(ch, 0, &result, &entryIdx)` + `canFdQueryFeedback(ch, 1, &result, &entryIdx)`,在栈上用单个 `can_feedback_result_t result`(1.3KB,用完即释放)
- lite 路径:只需 page 0(统计字段在 page0 header),1 次 SPI 查询即可
- full 路径:page 0 + page 1,2 次查询,然后序列化
- 查询失败 → 该通道 `valid=false`(与原来无快照时行为一致)
- `pauseCanFeedbackTask` 调用保留(send_batch 仍需独占 SPI)
#### 3. ESP32 侧 — `can_mode_handler` GET
- 原来 `getLatestCanFeedback(0, &fb)` 读缓存 mode → 改为 `canFdQueryFeedback(0, 0, &fb, &entryIdx)` 取 page0 的 mode 字段
- 原来的 fallback `canFdSetRxMode` 保留(查询也失败时兜底)
#### 4. ESP32 侧 — `main/monitor/can_feedback_task.cpp`
- **删除** `s_latest[3]`、`s_valid[3]`、`s_work[3]`、`s_workIdx[3]`、`s_lastLoggedError[3]`、`publish()`、`getLatestCanFeedback()`
- **删除** taskLoop 里的 0xC1 分支(`canFdParseFeedbackPage` + `publish`)
- **保留** 0xC4/0xC5/0xC6/0xD2 事件分发(这些仍走推送)
- **保留** `pauseCanFeedbackTask`(send_batch / OTA 仍需暂停事件消费)
- `s_mutex` 删除(仅保护 s_latest/s_valid,不再需要)
#### 5. ESP32 侧 — `main/monitor/can_feedback_task.h`
- 删除 `getLatestCanFeedback` 声明
- 保留 `startCanFeedbackTask`、`pauseCanFeedbackTask`
#### 6. GD32 侧 — `app_spi_cmd_can_feedback.cpp`
- `canFeedbackStartRound()`:改为空函数(或加 `s_pushEnabled=false` 永久禁用推送)
- `canFeedbackPushStep()`:开头加 `if (!s_pushEnabled) return;` 或直接 return
- **保留** `handleFeedback`(请求查询处理程序不动)
- **保留** `buildFeedbackPayload`(查询和推送共用)
#### 7. GD32 侧 — `task_runner.cpp`
- `canFeedbackStartRound()` 调用可保留(变成空函数无副作用)或删除
### 内存收益
| 项 | 改前 | 改后 | 省 |
|---|---|---|---|
| `s_latest[3]` BSS | 3.9KB | 0 | 3.9KB |
| `s_work[3]` BSS | 3.9KB | 0 | 3.9KB |
| handler 栈 `results[3]` | 3.9KB | 1.3KB(单通道) | 2.6KB 栈 |
| **合计常驻 BSS** | | | **~7.8KB** |
### 性能影响
- 原来读缓存:<1ms
- 改后查询:lite 路径 1 次 SPI 往返 ~2-4ms;full 路径 6 次往返(3ch×2page)~12-24ms
- 前端 1Hz 轮询,24ms 完全可接受(httpd 单线程最坏阻塞 <50ms)
- GD32 不再 1Hz 推送 6 帧,省下 SPI 带宽和 PB6 中断
### 风险与缓解
- **SPI 查询失败**:GD32 不响应时 `transact` 超时返回,handler 返回 valid=false,前端显示"--"(与原无快照行为一致)
- **OTA 期间查询**:send_batch/OTA 已 `pauseCanFeedbackTask`,且 SPI worker 串行化,查询不会与 OTA 数据流冲突
- **can_monitor.html full 路径延迟**:6 次 SPI 查询 ~24ms + JSON 序列化,总 <50ms,远小于 1s 轮询间隔
### 不改动的部分
- 0xC4 ADC、0xC5 motor state、0xC6 motor version、0xD2 motor OTA 推送机制不变
- `canFdParseFeedbackPage` 解析逻辑不变
- GD32 `handleFeedback` 查询处理程序不变
- 前端 JS 不变(API 响应格式不变)
-8
View File
@@ -1,8 +0,0 @@
idf_component_register(
SRCS "cJSON.c"
INCLUDE_DIRS "."
REQUIRES
)
# ESP-IDF uses no DLL, export all symbols
target_compile_definitions(${COMPONENT_LIB} PUBLIC CJSON_HIDE_SYMBOLS=0)
File diff suppressed because it is too large Load Diff
-306
View File
@@ -1,306 +0,0 @@
/*
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
#ifndef cJSON__h
#define cJSON__h
#ifdef __cplusplus
extern "C"
{
#endif
#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
#define __WINDOWS__
#endif
#ifdef __WINDOWS__
/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
For *nix builds that support visibility attribute, you can define similar behavior by
setting default visibility to hidden by adding
-fvisibility=hidden (for gcc)
or
-xldscope=hidden (for sun cc)
to CFLAGS
then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
*/
#define CJSON_CDECL __cdecl
#define CJSON_STDCALL __stdcall
/* export symbols by default, this is necessary for copy pasting the C and header file */
#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_EXPORT_SYMBOLS
#endif
#if defined(CJSON_HIDE_SYMBOLS)
#define CJSON_PUBLIC(type) type CJSON_STDCALL
#elif defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
#elif defined(CJSON_IMPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
#endif
#else /* !__WINDOWS__ */
#define CJSON_CDECL
#define CJSON_STDCALL
#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
#else
#define CJSON_PUBLIC(type) type
#endif
#endif
/* project version */
#define CJSON_VERSION_MAJOR 1
#define CJSON_VERSION_MINOR 7
#define CJSON_VERSION_PATCH 19
#include <stddef.h>
/* cJSON Types: */
#define cJSON_Invalid (0)
#define cJSON_False (1 << 0)
#define cJSON_True (1 << 1)
#define cJSON_NULL (1 << 2)
#define cJSON_Number (1 << 3)
#define cJSON_String (1 << 4)
#define cJSON_Array (1 << 5)
#define cJSON_Object (1 << 6)
#define cJSON_Raw (1 << 7) /* raw json */
#define cJSON_IsReference 256
#define cJSON_StringIsConst 512
/* The cJSON structure: */
typedef struct cJSON
{
/* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
struct cJSON *next;
struct cJSON *prev;
/* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
struct cJSON *child;
/* The type of the item, as above. */
int type;
/* The item's string, if type==cJSON_String and type == cJSON_Raw */
char *valuestring;
/* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
int valueint;
/* The item's number, if type==cJSON_Number */
double valuedouble;
/* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
char *string;
} cJSON;
typedef struct cJSON_Hooks
{
/* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
void *(CJSON_CDECL *malloc_fn)(size_t sz);
void (CJSON_CDECL *free_fn)(void *ptr);
} cJSON_Hooks;
typedef int cJSON_bool;
/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_NESTING_LIMIT
#define CJSON_NESTING_LIMIT 1000
#endif
/* Limits the length of circular references can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_CIRCULAR_LIMIT
#define CJSON_CIRCULAR_LIMIT 10000
#endif
/* returns the version of cJSON as a string */
CJSON_PUBLIC(const char*) cJSON_Version(void);
/* Supply malloc, realloc and free functions to cJSON */
CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
/* Render a cJSON entity to text for transfer/storage. */
CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
/* Render a cJSON entity to text for transfer/storage without any formatting. */
CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
/* Delete a cJSON entity and all subentities. */
CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
/* Returns the number of items in an array (or object). */
CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
/* Get item "string" from object. Case insensitive. */
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
/* Check item type and return its value */
CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
/* These functions check the type of an item */
CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
/* These calls create a cJSON item of the appropriate type. */
CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
/* raw json */
CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
/* Create a string where valuestring references a string so
* it will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
/* Create an object/array that only references it's elements so
* they will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
/* These utilities create an Array of count items.
* The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
/* Append item to the specified array/object. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
* WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
* writing to `item->string` */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
/* Remove/Detach items from Arrays/Objects. */
CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
/* Update array items. */
CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
/* Duplicate a cJSON item */
CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
* need to be released. With recurse!=0, it will duplicate any children connected to the item.
* The item->next and ->prev pointers are always zero on return from Duplicate. */
/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
* case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
* The input pointer json cannot point to a read-only address area, such as a string constant,
* but should point to a readable and writable address area. */
CJSON_PUBLIC(void) cJSON_Minify(char *json);
/* Helper functions for creating and adding items to an object at the same time.
* They return the added item or NULL on failure. */
CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
/* When assigning an integer value, it needs to be propagated to valuedouble too. */
#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
/* helper for the cJSON_SetNumberValue macro */
CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
#define cJSON_SetBoolValue(object, boolValue) ( \
(object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
(object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
cJSON_Invalid\
)
/* Macro for iterating over an array or object */
#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
CJSON_PUBLIC(void) cJSON_free(void *object);
#ifdef __cplusplus
}
#endif
#endif
+1 -1
View File
@@ -22,6 +22,7 @@ idf_component_register(
"monitor/motor_ota_relay.cpp" # 电机 OTA relay(0xD0~0xD2)
"monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲)
"monitor/task_stack.cpp" # 任务栈水位收集 + 溢出钩子
"monitor/spi_speed_test.cpp" # [SPI-SPEEDTEST] 临时:SPI 测速端点(可整行删除)
INCLUDE_DIRS
"."
"core"
@@ -34,7 +35,6 @@ idf_component_register(
REQUIRES
transport # M5: 传输层组件(SPI/CANFD/协议)
app_update # ESP32 OTA API
cjson # JSON 解析/生成
esp_driver_tsens # 温度传感器驱动
esp_driver_uart # UART 驱动(调试日志)
esp_wifi # WiFi STA/AP 模式
+3 -1
View File
@@ -132,7 +132,9 @@ extern "C" void app_main(void)
SystemBoot::mark(BOOT_OTA_VALID);
}
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s, heap=%lu, wifi_early=%s",
/* ★ banner 文案必须包含「启动」,前端 polling.js 靠
* /OTA Relay .*启动/ 匹配本行判定 ESP32 重启完成(阶段 6)。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动, heap=%lu, wifi_early=%s",
FW_VERSION,
(unsigned long)esp_get_free_heap_size(),
wifi_early_started_sta ? "STA" : "AP/other");
+2 -2
View File
@@ -14,8 +14,8 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 72
#define FW_VERSION_STRING "0.3.72"
#define FW_VERSION_PATCH 76
#define FW_VERSION_STRING "0.3.76" // 重启恢复提速2:httpd就绪后再打 MAIN「启动」banner(防环缩容裁掉);前端用 uptime<120s 判重启完成,不再干等10s
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
+19 -96
View File
@@ -1,14 +1,11 @@
/*
* can_feedback_task.cpp - 被动监听 GD32 主动推送的 CAN 反馈快照
* can_feedback_task.cpp - 被动监听 GD32 主动推送的事件
*
* 架构(GD32 主动推送,ESP32 被动接收):
* GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页,
* 通过 requestEsp32Read(0xC1) 下拉 DATA_READY 主动按铃,将每页反馈 payload
* 推送给 ESP32。ESP32 本任务 SpiMaster::takeEvent() 阻塞等待按铃/事件,
* 读到一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再
* publish() 到 s_latest[channel] 供 Web 读取。
*
* 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。
* 架构(2026-08 改造后):
* 0xC1 CAN 反馈已改为请求驱动(前端请求时 ESP32 主动向 GD32 查询),
* 不再被动缓存。本任务仅保留 0xC4/0xC5/0xC6/0xD2 事件分发:
* 0xC4 ADC 调试反馈、0xC5 多电机状态、0xC6 多电机版本、0xD2 电机 OTA 进度
* 这些仍由 GD32 主动推送,ESP32 被动接收。
*
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。
*/
@@ -17,7 +14,6 @@
#include "freertos/semphr.h"
#include "app_config.h"
#include "transport/canfd_bridge.h"
#include "transport/spi_master.h"
#include "monitor/debug_log.h"
#include "monitor/can_feedback_task.h"
@@ -25,7 +21,6 @@
#include "monitor/motor_state.h"
#include "monitor/motor_version.h"
#include "monitor/motor_ota_relay.h"
#include "app_config.h"
#include "esp_system.h"
#include <string.h>
@@ -34,7 +29,6 @@ namespace {
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态推送
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
@@ -42,44 +36,22 @@ constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay
constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
static can_feedback_result_t s_latest[CAN_CHANNEL_MAX];
static bool s_valid [CAN_CHANNEL_MAX];
static SemaphoreHandle_t s_mutex = nullptr;
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];
/**
* @brief 将某通道完整反馈发布到 s_latest(线程安全)。
* @param ch 通道 0..2
* @param fb 解析后的快照
*/
static void publish(uint8_t ch, const can_feedback_result_t &fb)
{
if (s_mutex == nullptr) return;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_latest[ch] = fb;
s_valid[ch] = true;
xSemaphoreGive(s_mutex);
}
}
/**
* @brief can_fb 任务主循环:takeEvent 分发 0xC1 CAN / 0xC4 ADC。
* @brief can_fb 任务主循环:takeEvent 分发 0xC4/0xC5/0xC6/0xD2 推送事件。
* 0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),不再在此处理。
*/
static void taskLoop(void *)
{
DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)");
DEBUG_INFO(kTag, "事件监听任务启动 (0xC4 ADC / 0xC5 motor / 0xC6 ver / 0xD2 ota)");
/* 冷启动延后开始监听,错开上电初始化窗口 */
{
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 后开始 CAN 反馈监听",
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始事件监听",
(unsigned long)kCanFbDeferMs);
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
}
@@ -126,39 +98,14 @@ static void taskLoop(void *)
continue;
}
if (ring_type != kSpiCanFeedbackType || ring_len < 28U) {
continue;
}
const uint8_t ch = ring_payload[0];
if (ch >= CAN_CHANNEL_MAX) {
continue;
}
/* 解析本页并累积到工作缓冲,page=0 会自动重置 */
if (canFdParseFeedbackPage(ring_payload, ring_len,
&s_work[ch], &s_workIdx[ch]) == 0) {
publish(ch, s_work[ch]);
/* 累计错误发生变化时才告警一次;P0/P1 携带相同统计,不能重复打印。 */
if (s_work[ch].stats.error_count != 0U
&& s_work[ch].stats.error_count != s_lastLoggedError[ch]) {
DEBUG_WARN(kTag,
"CH%u err=%lu TEC=%u REC=%u",
static_cast<unsigned>(ch),
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));
s_lastLoggedError[ch] = s_work[ch].stats.error_count;
}
}
/* 0xC1 已改为请求驱动,不再在此处理;未知类型静默跳过 */
}
}
} // namespace
/**
* @brief 启动 CAN 反馈被动监听任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask()
@@ -166,19 +113,6 @@ bool startCanFeedbackTask()
static bool s_started = false;
if (s_started) return true;
if (s_mutex == nullptr) {
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == nullptr) {
DEBUG_ERR(kTag, "互斥量创建失败");
return false;
}
}
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));
BaseType_t xRet = xTaskCreatePinnedToCore(
taskLoop,
"can_fb",
@@ -196,7 +130,7 @@ bool startCanFeedbackTask()
}
/**
* @brief 暂停/恢复 CAN(及同任务分发的 ADC)事件监听。
* @brief 暂停/恢复事件监听。
* @param pause true=OTA 期间暂停;false=恢复
*/
void pauseCanFeedbackTask(bool pause)
@@ -204,28 +138,17 @@ void pauseCanFeedbackTask(bool pause)
if (s_paused == pause) return; // 幂等:状态未变时不重复日志
s_paused = pause;
if (pause) {
DEBUG_DBG(kTag, "CAN 监听已暂停(OTA 进行中)");
DEBUG_DBG(kTag, "事件监听已暂停(OTA 进行中)");
} else {
DEBUG_DBG(kTag, "CAN 监听已恢复");
DEBUG_DBG(kTag, "事件监听已恢复");
}
}
/**
* @brief 拷贝某通道的最新快照(线程安全)。
* @return true 表示拿到一份有效数据。
* @brief 查询是否处于暂停(OTA / send_batch 期间为 true)。
* 暂停期间 HTTP handler 应跳过 0xC1 查询,避免与 OTA 抢占 SPI。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out)
bool canFeedbackPaused()
{
if (channel >= CAN_CHANNEL_MAX || out == nullptr || s_mutex == nullptr) {
return false;
}
bool ok = false;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
if (s_valid[channel]) {
*out = s_latest[channel];
ok = true;
}
xSemaphoreGive(s_mutex);
}
return ok;
return s_paused;
}
+9 -15
View File
@@ -1,35 +1,29 @@
/*
* can_feedback_task.h - 被动接收 GD32 主动推送的 CAN 反馈快照
* can_feedback_task.h - 事件监听任务(0xC4/0xC5/0xC6/0xD2 推送)
*
* ESP32 侧不主动 1Hz 轮询 0xC1;由 GD32 按铃推送,本任务 takeEvent 接收。
* 2026-08 架构改造:0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),
* 不再被动缓存。本任务仅分发 GD32 主动推送的 ADC/电机状态/版本/OTA 进度事件。
*/
#ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H
#include <stdint.h>
#include "transport/canfd_bridge.h"
/** @brief 暂停/恢复 CAN 反馈监听(OTA 期间调用 pause=true)。 */
/** @brief 暂停/恢复事件监听(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause);
/** @brief 查询监听是否处于暂停(OTA / send_batch 期间为 true)。
* 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */
bool canFeedbackPaused();
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief 启动 CAN 反馈被动监听任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask();
/**
* @brief 拷贝某通道的最新反馈快照(线程安全)。
* @param channel 0/1/2。
* @param out 输出缓冲。
* @return true 表示拿到一份有效数据,false 表示通道无效或尚未拿到第一份。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out);
#ifdef __cplusplus
}
#endif
+6 -2
View File
@@ -11,8 +11,12 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/** 单次快照最多跟踪的任务数 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 32;
/** 单次快照最多跟踪的任务数。
* 系统常驻任务 ~14(app)+ ~5(IDF: tiT/wifi/nimble/hci/ble)= ~19,
* 按需短暂任务(ble_finish/restart/cancel/gd32_wait/ota_feed)不常驻。
* 20 覆盖常驻集 + 余量 1;偶发按需任务多出时 uxTaskGetSystemState 截断,
* 仅影响该拍 CPU 统计显示,不影响功能。原 32 浪费 ~1KB BSS。 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 20;
/** 采样周期(ms)— 窗口 sample_ms 应对齐此值 */
static constexpr uint32_t CPU_STATS_SAMPLE_MS = 1000;
+10 -2
View File
@@ -509,7 +509,12 @@ esp_err_t DebugLog::uartSend(const uint8_t *data, size_t len)
return ESP_ERR_INVALID_STATE;
}
const int ret = uart_write_bytes(UART_NUM_0, data, len);
/* 非阻塞直写 FIFO:满了丢弃(日志必进 ring,web /logs 仍完整)。
* 旧实现 uart_write_bytes 在 TX buffer=0 时同步阻塞到整行发完
* (115200 下 ~5-8ms/条),OTA 期间日志密集会把 ota_relay(prio 23)
* / spi_worker(prio 6) 阻塞数 ms~数十 ms——50ms ACK 窗口被拖爆,
* 表现为改短重试后超时重发频繁(线上 12/777)。 */
const int ret = uart_tx_chars(UART_NUM_0, (const char *)data, (int)len);
if (ret < 0) {
return ESP_FAIL;
}
@@ -646,7 +651,10 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len)
if (sock >= 0) {
const size_t send_len = len > 1024 ? 1024 : len;
const int ret = ::sendto(sock, data, send_len, 0,
/* MSG_DONTWAIT:非阻塞发送。lwIP 在 ARP 未解析/路由未就绪时
* sendto 会排队等待(可达 SO_SNDTIMEO 200ms),会阻塞打日志的
* 业务任务(OTA relay/worker)——日志转发必须尽力而为。 */
const int ret = ::sendto(sock, data, send_len, MSG_DONTWAIT,
(struct sockaddr *)&m_debugAddr, sizeof(m_debugAddr));
if (ret >= 0) {
udp_ret = ESP_OK;
+5 -41
View File
@@ -2,9 +2,7 @@
* gd32_adc_feedback.cpp - GD32 ADC 调试反馈解析与缓存
*/
#include "monitor/gd32_adc_feedback.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -17,29 +15,6 @@ static SemaphoreHandle_t s_mux = nullptr;
static gd32_adc_feedback_t s_latest = {};
static bool s_have = false;
/** @brief 读小端 u16。 */
static uint16_t rdU16Le(const uint8_t *p)
{
return static_cast<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8));
}
/** @brief 读小端 u32。 */
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
/** @brief 懒创建快照互斥量。 */
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
/**
@@ -80,7 +55,7 @@ bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len)
fb.uptime_sec = uptime;
fb.recv_ms = esp_timer_get_time() / 1000;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
@@ -103,7 +78,7 @@ bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
if (out == nullptr) {
return false;
}
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
@@ -124,7 +99,7 @@ bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
*/
uint32_t gd32AdcFeedbackAgeMs(void)
{
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return UINT32_MAX;
}
@@ -135,16 +110,5 @@ uint32_t gd32AdcFeedbackAgeMs(void)
recv = s_latest.recv_ms;
xSemaphoreGive(s_mux);
}
if (!have || recv <= 0) {
return UINT32_MAX;
}
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv) {
return 0U;
}
const int64_t age = now - recv;
if (age > static_cast<int64_t>(UINT32_MAX)) {
return UINT32_MAX;
}
return static_cast<uint32_t>(age);
return ringCacheAgeMs(have, recv);
}
+27 -31
View File
@@ -29,6 +29,7 @@
#include "spi_master.h"
#include "esp_event.h"
#include "esp_netif_types.h"
#include "server/http_util.h"
// ============================================================
// heap hook:捕获"破新低时的最后一次 alloc"(最后一击,未必是主因)
@@ -399,8 +400,13 @@ void HealthMonitor::taskEntry(void *pvParameters)
* 必须在 maybeRecord 之前,否则 est 长期偏低会误触发 hook 快照。 */
hookResyncEstFree(free_heap);
// 任务栈指标:本周期只采集一次,堆低水位快照和栈水位巡检共用。
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
// 堆低水位快照:仅在"最低空闲"创新低时记录一条上下文(5s 检测足够)
self.maybeRecordHeapLowWater();
// 传入已采集的 metrics,避免 maybeRecordHeapLowWater 内部二次遍历任务表。
self.maybeRecordHeapLowWater(metrics, count);
if (free_heap < HEAP_WARNING_MIN) {
/* 堆告警节流:OTA 期间阈值更低、间隔更长 */
@@ -426,10 +432,8 @@ void HealthMonitor::taskEntry(void *pvParameters)
DEBUG_ERR("MON", "健康状态危险: %d", cur);
}
// 栈水位主动巡检:复用 /system/status 的任务表,低水位时 DEBUG_ERR 主动告警。
// 栈水位主动巡检:复用已采集的任务表,低水位时 DEBUG_ERR 主动告警。
// health_mon 任务不被 OTA 暂停,告警经 UDP/UART 直发,穿透 OTA 门控。
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
for (size_t i = 0; i < count && i < STACK_TASK_MAX; ++i) {
if (metrics[i].handle == NULL || metrics[i].stack_size_bytes == 0) {
continue;
@@ -604,23 +608,6 @@ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
// ============================================================
// 堆低水位快照("最低空闲"创新低时记录上下文)
// ============================================================
namespace {
/** @brief snprintf 追加到 buf 的安全封装:截断但不越界,返回新 offset。 */
int appendJsonfSafe(char *buf, size_t cap, int offset, const char *fmt, ...)
{
if (offset < 0 || (size_t)offset >= cap) return offset;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
va_end(ap);
if (n < 0) return offset;
if ((size_t)n >= cap - (size_t)offset) {
// 被截断:vsnprintf 返回的是"本应写入"的长度,按截断后修正 offset
return (int)cap - 1;
}
return offset + n;
}
} // namespace
/**
* @brief 检测"内部堆最低空闲"是否创新低,是则覆盖写入唯一快照槽。
@@ -630,7 +617,7 @@ int appendJsonfSafe(char *buf, size_t cap, int offset, const char *fmt, ...)
* 所有外部读取(OtaCoordinator::getSnapshot / collectTaskStackMetrics / rssi / temp)
* 都先在锁外取,避免持锁期间嵌套调用其它模块的锁;持锁后只做字段拷贝。
*/
void HealthMonitor::maybeRecordHeapLowWater()
void HealthMonitor::maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics, size_t pre_count)
{
// 与 http_handlers.cpp 同口径:内部 DRAM 数据堆
static constexpr uint32_t CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
@@ -691,8 +678,17 @@ void HealthMonitor::maybeRecordHeapLowWater()
HeapLowTaskStack stacks[8];
uint8_t stack_count = 0;
{
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
// 优先复用巡检循环已采集的指标,避免二次遍历
TaskStackMetric local_metrics[STACK_TASK_MAX];
const TaskStackMetric *metrics;
size_t count;
if (pre_metrics && pre_count > 0) {
metrics = pre_metrics;
count = pre_count;
} else {
count = collectTaskStackMetrics(local_metrics, STACK_TASK_MAX);
metrics = local_metrics;
}
// 先全部采集
struct Item { char name[16]; uint32_t rem; };
Item items[STACK_TASK_MAX];
@@ -816,7 +812,7 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
LockGuard lock(m_mutex);
if (!m_heapLowWaterValid) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
const HeapLowWaterRecord &r = m_heapLowWater;
@@ -824,7 +820,7 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
* hook_confidence 告知前端置信度:hook 命中=est_low_watermark,未命中=none。
* 前端应据此区分 estimated 与 measured(free_heap/min_free_heap 为实测)。 */
const char *hook_confidence = r.hook_fired ? "est_low_watermark" : "none";
int pos = appendJsonfSafe(buf, cap, offset,
int pos = http_json_append(buf, cap, offset,
"{\"count\":1,\"records\":[{"
"\"ts_ms\":%llu,\"free_heap\":%lu,\"min_free_heap\":%lu,"
"\"largest_free_block\":%lu,\"sys_state\":%u,\"ota_channel\":%u,"
@@ -863,24 +859,24 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
(unsigned long)r.hook_fired,
(unsigned long)r.ip_event_count);
if (pos == offset) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
for (uint8_t k = 0; k < r.task_stack_count && k < 8; ++k) {
int after = appendJsonfSafe(buf, cap, pos,
int after = http_json_append(buf, cap, pos,
"%s{\"name\":\"%s\",\"remaining\":%lu}",
k == 0 ? "" : ",",
r.task_stacks[k].name,
(unsigned long)r.task_stacks[k].remaining);
if (after == pos) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
pos = after;
}
int closed = appendJsonfSafe(buf, cap, pos, "]}]}");
int closed = http_json_append(buf, cap, pos, "]}]}");
if (closed == pos) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
return closed;
}
+6 -1
View File
@@ -12,6 +12,7 @@
#include "driver/temperature_sensor.h"
#include "esp_timer.h"
#include "app_config.h"
#include "task_stack.h" // TaskStackMetric(maybeRecordHeapLowWater 可选参数)
// T9.3: 健康状态等级
typedef enum {
@@ -133,7 +134,11 @@ public:
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。 */
void maybeRecordHeapLowWater();
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。
* @param pre_metrics 巡检循环已采集的任务栈指标(避免二次遍历);null 时内部自采 */
void maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics = nullptr, size_t pre_count = 0);
/** @brief 将唯一快照序列化为 JSON:{"count":0|1,"records":[...]}。线程安全。 */
int dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const;
+154 -85
View File
@@ -35,6 +35,59 @@ static int64_t nowMs() {
return esp_timer_get_time() / 1000;
}
/* ── 共享辅助:终态判断 + QUERY SPI 事务 ── */
/** @brief GD32 侧已进终态(IDLE/FINISHED/ERROR),喂数据应停止。 */
static inline bool isTerminalState(motor_ota_state_t s)
{
return s == MOTOR_OTA_IDLE || s == MOTOR_OTA_FINISHED || s == MOTOR_OTA_ERROR;
}
/**
* @brief 发 SPI 0xD0 QUERY 查询 GD32 真实状态。
* @param out_state 非 null 时写入 GD32 返回的 state
* @param out_err 非 null 时写入 error_code
* @param update_status true 时把结果同步写回 s_status(持锁)
* @return true=QUERY 成功且 ACK 有有效数据;false=SPI 失败或无 ACK
*
* 消除 feedTask 中 4 处重复的 qcmd[1]={2} + transact + parse qp[0..5] 块。
*/
static bool queryMotorOtaStatus(motor_ota_state_t *out_state = nullptr,
uint8_t *out_err = nullptr,
bool update_status = false)
{
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 < 1U) {
return false;
}
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (out_state) *out_state = qst;
if (out_err) *out_err = (ql >= 2U) ? qp[1] : 0U;
if (update_status && s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = (ql >= 2U) ? qp[1] : 0U;
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
if (ql >= 10U) {
s_status.fw_size = static_cast<uint32_t>(qp[6])
| (static_cast<uint32_t>(qp[7]) << 8)
| (static_cast<uint32_t>(qp[8]) << 16)
| (static_cast<uint32_t>(qp[9]) << 24);
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
return true;
}
/* ═══════════════════ 喂数据 task ═══════════════════ */
static void feedTask(void *arg)
@@ -67,7 +120,13 @@ static void feedTask(void *arg)
* 连续 SPI 失败计数:超过阈值时主动 QUERY GD32 真实状态,避免盲重试空转到 120s。 */
uint32_t consec_fail = 0U;
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 */
bool handshake_confirmed = false; /* GD32 已进 kTransfer/kErase,可标 TRANSFER */
while (s_feed_running && s_fw_offset < s_fw_size) {
if (nowMs() >= deadline_ms) {
@@ -81,17 +140,16 @@ static void feedTask(void *arg)
* (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 */
const motor_ota_status_t st = motorOtaGetStatus();
if (nowMs() >= grace_end_ms) {
if (st.state == MOTOR_OTA_IDLE || st.state == MOTOR_OTA_FINISHED ||
st.state == MOTOR_OTA_ERROR) {
if (isTerminalState(st.state)) {
ESP_LOGW(TAG, "GD32 终态 0x%02X,停止喂数据 offset=%u/%u",
(int)st.state, (unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
}
/* 注:旧版有 "GD32 回到握手阶段→重置 offset" 的逻辑,
* 但 v1.4.38+ 已移除握手重试(0x05 直接中止不重握手),
* 且 v1.4.58 feedData 在握手期间就接受数据(预填充缓冲)。
* 若在此重置 offset,会导致缓冲中数据重复。已移除。 */
/* GD32 auto-retry 检测(v1.5.5+):GD32 SPI 断流/电机超时后自动重新握手,
* 重置 s_fw_fed=0。ESP32 侧通过连续 WAIT→QUERY 检测到 state!=kTransfer 后
* 重置 s_fw_offset=0 从头喂(见下方 kRspWait 分支)。
* 注:v1.4.38~v1.5.4 曾移除此逻辑(当时无 auto-retry,握手失败即中止)。 */
const uint32_t remaining = s_fw_size - s_fw_offset;
const uint32_t data_len = (remaining < MOTOR_OTA_CHUNK_DATA)
@@ -123,27 +181,92 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
* 避免 GD32 已进 ERROR 终态但 feedTask 不知道、盲重试空转到 120s 超时。 */
if (++consec_fail >= kQueryAfterFails) {
consec_fail = 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 >= 1U) {
motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED ||
qst == MOTOR_OTA_ERROR) {
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true) && isTerminalState(qst)) {
ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u",
(int)qst, (unsigned)qp[1],
(int)qst, (unsigned)qerr,
(unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
}
}
continue; /* 不推进 offset,同 offset 重发 */
}
consec_fail = 0U;
consec_wait = 0U;
if (resp_type == kRspAck) {
s_fw_offset += data_len;
/* 进度:每 8 chunk 写 s_status。
* ★ 不再喂数据一成功就强标 TRANSFER:GD32 feedData() 在握手阶段
* (kPreCancel/kHandshakeReq 等) 也接收预填充数据,若此刻强标
* TRANSFER,无电机的空通道会让网页误显示"数据传输 N/912"而非
* "握手中…→超时"。改为:只在 GD32 真正进 kErase/kTransfer 后
* 才标 TRANSFER;握手未完成时保持握手态显示。
* 握手未确认时每 8 个 ACK 重新 QUERY 一次(GD32 握手+擦除
* 通常 1~3s,8 个 ACK ~16 个 chunk ≈ 3700B <1s,需多轮确认)。 */
++ack_n;
const bool touch_status = ((ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size);
/* 握手未确认时定期 QUERY:确认 GD32 已过握手阶段才标 TRANSFER */
if (!handshake_confirmed && (ack_n & 7U) == 0U) {
motor_ota_state_t qst;
if (queryMotorOtaStatus(&qst, nullptr, false)) {
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;
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, false)) {
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 (isTerminalState(qst)) {
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 = qp[1];
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
s_status.error_code = qerr;
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
@@ -151,36 +274,6 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
}
}
}
continue; /* 不推进 offset,同 offset 重发 */
}
consec_fail = 0U;
if (resp_type == kRspAck) {
s_fw_offset += data_len;
/* 进度:每 8 chunk 写 s_status;state 在 suppress 下不会收 0xD2,
* 喂数一开始就标 TRANSFER,避免网页卡在「开始握手」。 */
const bool touch_status = ((++ack_n & 7U) == 0U) || (s_fw_offset >= s_fw_size);
if (touch_status || ack_n == 1U) {
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) {
if (s_status.state < MOTOR_OTA_TRANSFER ||
s_status.state == MOTOR_OTA_PRE_CANCEL ||
s_status.state == MOTOR_OTA_HANDSHAKE_REQ ||
s_status.state == MOTOR_OTA_HANDSHAKE_NONCE ||
s_status.state == MOTOR_OTA_HANDSHAKE_CONFIRM ||
s_status.state == MOTOR_OTA_ERASE) {
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) {
/* 缓冲满:不推进 offset,稍候用新 TX_SEQ 再发同一 offset。
* WAIT 路径 SPI 层已推进 seq(见 otaPollResponseUnlocked),
* 避免同 seq 重发被 GD32 当重复请求死锁。 */
vTaskDelay(pdMS_TO_TICKS(2));
} else {
/* 非 ACK/WAIT(含 ERROR/陈旧 type):不 break 整次 OTA。
* 曾因 poll 读到 EVENT/错 type 直接 break → fed 卡首 chunk。 */
@@ -205,8 +298,7 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
/* 优先吃 ring 推送(已恢复 suppress) */
{
const motor_ota_status_t st = motorOtaGetStatus();
if (st.state == MOTOR_OTA_FINISHED || st.state == MOTOR_OTA_ERROR ||
st.state == MOTOR_OTA_IDLE) {
if (isTerminalState(st.state)) {
ESP_LOGI(TAG, "feedTask 退出,ring 终态: state=%u err=%u pkt=%u/%u",
(unsigned)st.state, (unsigned)st.error_code,
(unsigned)st.packets_acked, (unsigned)st.total_packets);
@@ -214,37 +306,14 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
break;
}
}
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qresp_type = 0;
uint8_t qresp[16] = {0};
size_t qresp_len = 0;
esp_err_t qr = SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1,
&qresp_type, qresp, sizeof(qresp), &qresp_len, 300U);
if (qr == ESP_OK && qresp_type == kRspAck && qresp_len >= 10U) {
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qresp[0]);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qresp[1];
s_status.packets_acked = static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8);
s_status.fw_size = static_cast<uint32_t>(qresp[6])
| (static_cast<uint32_t>(qresp[7]) << 8)
| (static_cast<uint32_t>(qresp[8]) << 16)
| (static_cast<uint32_t>(qresp[9]) << 24);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
if (qst == MOTOR_OTA_FINISHED || qst == MOTOR_OTA_ERROR ||
qst == MOTOR_OTA_IDLE) {
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true)) {
if (isTerminalState(qst)) {
const motor_ota_status_t snap = motorOtaGetStatus();
ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u",
(unsigned)qst, (unsigned)qresp[1],
(unsigned)(static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8)),
(unsigned)(static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8)));
(unsigned)qst, (unsigned)qerr,
(unsigned)snap.packets_acked, (unsigned)snap.total_packets);
got_terminal = true;
break;
}
+11 -33
View File
@@ -2,9 +2,7 @@
* motor_state.cpp - GD32 多电机状态推送解析与缓存 (SPI TYPE 0xC5)
*/
#include "monitor/motor_state.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -13,21 +11,6 @@ namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_state_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorStateEvent(const uint8_t *payload, size_t len)
@@ -51,7 +34,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
return false;
}
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
@@ -63,19 +46,20 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
s_table.boot_active = (active_cnt & 0x80U) ? 1U : 0U;
}
/* 逐条解析 entry */
/* 逐条解析 entry:node×channel 双键槽位,同节点号多通道互不覆盖 */
size_t offset = MOTOR_PAGE_HEADER;
for (uint8_t i = 0; i < entry_count; ++i) {
const uint8_t *e = &payload[offset];
const uint8_t node = e[0];
if (node < 1 || node > MOTOR_MAX_NODES) {
const uint8_t ch = e[1];
if (node < 1 || node > MOTOR_MAX_NODES || ch >= MOTOR_CHANNELS) {
offset += MOTOR_ENTRY_SIZE;
continue;
}
motor_slot_t &slot = s_table.slots[node - 1];
motor_slot_t &slot = s_table.slots[(node - 1) * MOTOR_CHANNELS + ch];
slot.node_id = node;
slot.channel = e[1];
slot.channel = ch;
slot.online = e[2];
slot.dlc = e[3];
slot.rx_count = rdU32Le(&e[4]);
@@ -87,7 +71,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
/* 统计有效槽位 */
uint8_t valid = 0;
for (uint8_t i = 0; i < MOTOR_MAX_NODES; ++i) {
for (uint8_t i = 0; i < MOTOR_MAX_SLOTS; ++i) {
if (s_table.slots[i].node_id != 0) valid++;
}
s_table.valid_slots = valid;
@@ -101,7 +85,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
bool getLatestMotorState(motor_state_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
@@ -117,7 +101,7 @@ bool getLatestMotorState(motor_state_table_t *out)
uint32_t motorStateAgeMs(void)
{
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return UINT32_MAX;
int64_t recv = 0;
@@ -127,11 +111,5 @@ uint32_t motorStateAgeMs(void)
recv = s_table.recv_ms;
xSemaphoreGive(s_mux);
}
if (!have || recv <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv) return 0U;
const int64_t age = now - recv;
return (age > static_cast<int64_t>(UINT32_MAX)) ? UINT32_MAX
: static_cast<uint32_t>(age);
return ringCacheAgeMs(have, recv);
}
+7 -2
View File
@@ -10,6 +10,9 @@
* [0] node_id, [1] channel, [2] online, [3] dlc,
* [4..7] rx_count (LE), [8..11] seq (LE),
* [12..43] data[32] (原始 STATUS payload)
*
* 同一节点号可能同时挂在多个 CAN 通道(两台电机同号异线),
* 缓存表与 GD32 端一致按 node×channel 双键存储(27 槽)。
*/
#ifndef MOTOR_STATE_H
#define MOTOR_STATE_H
@@ -23,10 +26,12 @@ extern "C" {
#endif
#define MOTOR_MAX_NODES 9
#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_PAGE_HEADER 4
#define MOTOR_TOTAL_PAGES 2
#define MOTOR_TOTAL_PAGES 6
/** @brief 单电机状态快照。 */
typedef struct {
@@ -41,7 +46,7 @@ typedef struct {
/** @brief 全局电机状态表。 */
typedef struct {
motor_slot_t slots[MOTOR_MAX_NODES]; ///< 按 node_id-1 索引
motor_slot_t slots[MOTOR_MAX_SLOTS]; ///< 按 (node_id-1)*MOTOR_CHANNELS+channel 索引
uint8_t active_count; ///< 在线电机数
uint8_t boot_active; ///< 1=总线上有电机在 BOOT(0x103 心跳,来自 0xC5 头 bit7)
uint8_t valid_slots; ///< 有效槽位数(node_id!=0)
+3 -20
View File
@@ -4,9 +4,7 @@
* 镜像 motor_state.cpp 的互斥保护缓存模型。
*/
#include "monitor/motor_version.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -15,21 +13,6 @@ namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_version_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorVersionEvent(const uint8_t *payload, size_t len)
@@ -40,7 +23,7 @@ bool onMotorVersionEvent(const uint8_t *payload, size_t len)
const uint8_t count = payload[0];
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
@@ -69,7 +52,7 @@ bool onMotorVersionEvent(const uint8_t *payload, size_t len)
bool getLatestMotorVersion(motor_version_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
+62
View File
@@ -0,0 +1,62 @@
/*
* spi_ring_cache.h - SPI ring push 缓存共享辅助
*
* gd32_adc_feedback / motor_state / motor_version 三个模块共用相同的
* 互斥懒创建 + 小端读 + ageMs 计算样板,提取到此处消除三份拷贝。
*/
#ifndef SPI_RING_CACHE_H
#define SPI_RING_CACHE_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── 小端读 ── */
static inline uint16_t rdU16Le(const uint8_t *p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
static inline uint32_t rdU32Le(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
/* ── 互斥懒创建 ── */
static inline void ringCacheEnsureMux(SemaphoreHandle_t *mux)
{
if (*mux == nullptr) {
*mux = xSemaphoreCreateMutex();
}
}
/* ── 通用 ageMs ──
* 传入缓存的 have 标志 + recv_ms 时间戳,返回距 now 的毫秒数。
* 无数据或 recv_ms<=0 返回 UINT32_MAX。
*/
static inline uint32_t ringCacheAgeMs(bool have, int64_t recv_ms)
{
if (!have || recv_ms <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv_ms) return 0U;
const int64_t age = now - recv_ms;
return (age > (int64_t)UINT32_MAX) ? UINT32_MAX : (uint32_t)age;
}
#ifdef __cplusplus
}
#endif
#endif // SPI_RING_CACHE_H
+181
View File
@@ -0,0 +1,181 @@
/**
* @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 "server/http_util.h"
#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;
} // namespace
esp_err_t spi_speedtest_handler(httpd_req_t *req)
{
uint32_t frames = http_query_u32(req, "frames", kFramesDefault);
if (frames < 1U) { frames = 1U; }
if (frames > kFramesMax) { frames = kFramesMax; }
size_t payload_len = (size_t)http_query_u32(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 分布直方图(µs 计时,<2ms 区间细化为 0.5ms 粒度):
* <0.5 / 0.5-1 / 1-1.5 / 1.5-2 / 2-3 / 3-5 / >=5 ms,
* 用于判断抖动形态(<2ms 内的尖峰/双峰 vs 离散 +1ms 碰撞) */
uint32_t histo[7] = {0U, 0U, 0U, 0U, 0U, 0U, 0U};
/* 事务耗时(worker 内执行段,SpiMaster::lastWorkerExecUs)与
* 调度/唤醒开销(RTT − 事务)拆解:事务慢=GD32 应答/SPI 时序;
* 调度慢=ESP32 任务抢占(WiFi/esp_timer 等)。 */
uint64_t w_sum_us = 0U, w_min_us = UINT64_MAX, w_max_us = 0U;
uint64_t s_sum_us = 0U, s_min_us = UINT64_MAX, s_max_us = 0U;
/* 5 段细分(SpiMaster::SpiProfile):提交/调度、发送帧、等应答、读响应、唤醒 */
uint64_t seg_sum[5] = {0U, 0U, 0U, 0U, 0U};
uint64_t seg_max[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 uint64_t wus = SpiMaster::instance().lastWorkerExecUs();
const uint64_t sus = (rtt > wus) ? (rtt - wus) : 0U;
w_sum_us += wus;
if (wus < w_min_us) { w_min_us = wus; }
if (wus > w_max_us) { w_max_us = wus; }
s_sum_us += sus;
if (sus < s_min_us) { s_min_us = sus; }
if (sus > s_max_us) { s_max_us = sus; }
/* 5 段细分(防回绕钳 0) */
const SpiMaster::SpiProfile &pf = SpiMaster::instance().lastProfile();
const uint64_t seg[5] = {
(pf.t_w0 > pf.t_submit0) ? (pf.t_w0 - pf.t_submit0) : 0U,
(pf.t_send1 > pf.t_send0) ? (pf.t_send1 - pf.t_send0) : 0U,
(pf.t_wait1 > pf.t_wait0) ? (pf.t_wait1 - pf.t_wait0) : 0U,
(pf.t_read1 > pf.t_wait1) ? (pf.t_read1 - pf.t_wait1) : 0U,
(pf.t_submit1 > pf.t_w1) ? (pf.t_submit1 - pf.t_w1) : 0U,
};
for (int k = 0; k < 5; ++k) {
seg_sum[k] += seg[k];
if (seg[k] > seg_max[k]) { seg_max[k] = seg[k]; }
}
if (rtt < 500U) { ++histo[0]; }
else if (rtt < 1000U) { ++histo[1]; }
else if (rtt < 1500U) { ++histo[2]; }
else if (rtt < 2000U) { ++histo[3]; }
else if (rtt < 3000U) { ++histo[4]; }
else if (rtt < 5000U) { ++histo[5]; }
else { ++histo[6]; }
} 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; }
if (w_min_us == UINT64_MAX) { w_min_us = 0U; }
if (s_min_us == UINT64_MAX) { s_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[640];
const int len = snprintf(text, sizeof(text),
"SPI 测速结果\n"
"帧数: %u 成功: %u 失败: %u%s\n"
"载荷: %u B/帧 总耗时: %u ms\n"
"往返延迟: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"SPI 事务: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"调度开销: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"单帧分段 平均: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"单帧分段 最慢: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"延迟分布: <0.5ms:%u 0.5-1ms:%u 1-1.5ms:%u 1.5-2ms:%u 2-3ms:%u 3-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,
(unsigned long long)rtt_avg_us, (unsigned long long)rtt_min_us,
(unsigned long long)rtt_max_us,
(unsigned long long)(w_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)w_min_us, (unsigned long long)w_max_us,
(unsigned long long)(s_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)s_min_us, (unsigned long long)s_max_us,
(unsigned long long)(seg_sum[0] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[1] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[2] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[3] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[4] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)seg_max[0], (unsigned long long)seg_max[1],
(unsigned long long)seg_max[2], (unsigned long long)seg_max[3],
(unsigned long long)seg_max[4],
(unsigned)histo[0], (unsigned)histo[1], (unsigned)histo[2],
(unsigned)histo[3], (unsigned)histo[4],
(unsigned)histo[5], (unsigned)histo[6], 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);
}
+14
View File
@@ -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
+11
View File
@@ -19,6 +19,7 @@
#include "esp32_ota_core.h"
#include "app_config.h"
#include "monitor/debug_log.h"
#include "spi_master.h" /* setSpiPaused:OTA 期间暂停 SPI,避免 flash 写与 SPI 读并发 */
#define TAG "OTA_CORE"
@@ -110,6 +111,7 @@ esp_err_t Esp32OtaCore::finalizeCleanupAndReturn(esp_err_t code)
}
m_ended = false;
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 结束(失败也恢复 SPI) */
resetState();
return code;
}
@@ -201,6 +203,12 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
m_ended = false;
memset(m_sha256, 0, sizeof(m_sha256));
/* ESP32 自我 OTA 期间暂停 SPI(GD32 按铃读 + 业务 transact 全部停摆):
* esp_ota_write 写 flash 时 cache 反复失效,SPI worker 的 flash 驻留代码
* 访问会被拖慢甚至触发 Cache error,且此时 GD32 数据无意义。
* finalize()/abort() 恢复。 */
SpiMaster::instance().setSpiPaused(true);
DEBUG_DBG(TAG, "ESP32 OTA begin part=%s size=%u (erase-on-write)",
m_partition->label, (unsigned)total_size);
return ESP_OK;
@@ -382,11 +390,13 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 失败也恢复 SPI */
resetState();
return err;
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 收尾完成,恢复 SPI 业务 */
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received);
// 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理
@@ -413,6 +423,7 @@ void Esp32OtaCore::abort()
esp_ota_abort(m_handle);
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 中止,恢复 SPI 业务 */
resetState();
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
}
+38 -47
View File
@@ -100,6 +100,24 @@ void OtaCoordinator::watchdogCheckLocked()
doAbortLocked();
}
esp_err_t OtaCoordinator::beginLockedWithChecks(const char *tag, bool &out_lock)
{
out_lock = lock();
if (!out_lock) return ESP_ERR_INVALID_STATE;
// ★ 卡死自愈检查
watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数期间禁止新的 GD32/ESP32 OTA
if (motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 %s", tag);
unlock(true);
out_lock = false;
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
// ============================================================
// 初始化
// ============================================================
@@ -138,24 +156,15 @@ void OtaCoordinator::init()
// ============================================================
esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 先做卡死自愈检查:若之前忙状态卡死,自动转 ERROR 继续后续流程
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数任务与 GD32 固件共用 gd32_ota 分区,
// 电机喂数期间禁止新的 GD32 存储,避免分区被并发覆写。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 GD32 存储");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
bool h;
esp_err_t pre = beginLockedWithChecks("GD32 存储", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 GD32 存储仍在进行中(STORING)且来自同一通道,
// 自动中止旧存储并重新开始。覆盖"上传过程中刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
// RELAYING 是中继阶段,不可中断,也拒绝。
if (h && m_sysState == OTA_SYS_GD32_STORING) {
if (m_sysState == OTA_SYS_GD32_STORING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "GD32存中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
@@ -169,23 +178,20 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
// 系统忙检查:仅拒绝真正"在传输"的活跃状态
// STORED / COMPLETE 是"待中继/已中继"的非活跃状态 → 允许新存储覆盖
if (h && (m_sysState == OTA_SYS_ESP32_RECEIVING ||
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING ||
m_sysState == OTA_SYS_GD32_RELAYING)) {
m_sysState == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "系统忙 (state=%d),拒绝 GD32 存储请求", (int)m_sysState);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 如果之前已有存储但未中继(STORED)或刚中继完(COMPLETE),允许新存储覆盖。
// 注意:清理 NVS meta 不在此处做,避免与紧跟的 prepareGd32Storage 内
// clearGd32Meta + saveGd32Meta 重复一次写入,仅做状态注释说明。
if (h && (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE)) {
if (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE) {
DEBUG_INFO(TAG, "覆盖旧 GD32 存储状态,开始新存储 (size=%" PRIu32 ")", total_size);
}
if (h) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_GD32_STORING;
@@ -193,9 +199,10 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
DEBUG_DBG(TAG, "GD32存开始 %" PRIu32 "B →STORING ep=%u ch=%d",
total_size, m_otaEpoch, (int)channel);
}
// ★ 预分配 flashWriteChunk 对齐缓冲(OTA 期消除逐块 malloc;非 OTA 期零占用)
Gd32FlashStore::acquireAlignBufForOta();
unlock(h);
return h ? ESP_OK : ESP_ERR_INVALID_STATE;
return ESP_OK;
}
esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t channel)
@@ -416,23 +423,14 @@ void OtaCoordinator::notifyBleCoexEnd()
// ============================================================
esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 卡死自愈检查
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:ESP32 自身 OTA 会重启设备,中断正在进行的电机喂数,
// 电机 OTA 期间拒绝 ESP32 OTA。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 ESP32 OTA");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
bool h;
esp_err_t pre = beginLockedWithChecks("ESP32 OTA", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "ESP32收中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
@@ -447,7 +445,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器
// 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。
// core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "FINALIZING残留(core空闲),强制复位");
doAbortLocked();
@@ -459,19 +457,17 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 重启中,均拒绝。
if (h && (m_sysState == OTA_SYS_GD32_STORING ||
if (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_REBOOTING)) {
m_sysState == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "忙 state=%d(%s) 拒ESP32 OTA",
(int)m_sysState, stateToString(m_sysState));
unlock(h);
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = ESP_ERR_INVALID_STATE;
if (h) {
// 委托 Esp32OtaCore 处理分区/互斥逻辑
err = Esp32OtaCore::instance().begin(total_size);
esp_err_t err = Esp32OtaCore::instance().begin(total_size);
if (err == ESP_OK) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch(使旧 handler 的 abortCurrentIfEpoch 失效)
m_otaChannel = channel; // ★ 记录发起通道
@@ -481,7 +477,6 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
} else {
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
}
}
unlock(h);
return err;
@@ -622,9 +617,11 @@ void OtaCoordinator::doAbortLocked()
if (m_sysState == OTA_SYS_GD32_STORING) {
Gd32FlashStore::instance().metaClear();
Gd32FlashStore::releaseAlignBufForOta(); // 释放 OTA 期对齐缓冲
} else if (m_sysState == OTA_SYS_GD32_RELAYING) {
/* 中继中止必须 CANCEL 远端,否则 GD32 停在 WAITING_* 半会话 */
OtaRelay::instance().abortRemoteAndReset(true);
Gd32FlashStore::releaseAlignBufForOta(); // 中继阶段也可能写过 flash
} else if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING) {
Esp32OtaCore::instance().abort();
@@ -761,12 +758,6 @@ const char* OtaCoordinator::stateToString(ota_system_state_t s)
}
}
/** @brief 获取 GD32 APP 存活状态(委托 OtaRelay)。 */
bool OtaCoordinator::getGd32AppAlive() const
{
return OtaRelay::getGd32AppAlive();
}
// ============================================================
// OTA 状态快照
// ============================================================
+7 -6
View File
@@ -420,12 +420,6 @@ public:
bool isIdle() const;
// ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)──
/**
* @brief 获取 GD32 APP 存活状态。
* @return true 表示 GD32 APP 存活。
*/
bool getGd32AppAlive() const;
// ── 完全复位 ──
/**
* @brief 完全复位协调器和所有子系统状态。
@@ -479,6 +473,13 @@ private:
// 已持锁中止:清理子系统并将状态复位为 IDLE
void doAbortLocked();
/** @brief 统一 "lock + watchdog 自愈 + 电机 OTA 互斥拒绝" 前缀检查。
* beginGd32Storage / beginEsp32Ota 共用此逻辑。
* @param tag 日志标签(如 "GD32 存储" / "ESP32 OTA")
* @param out_lock 成功时输出锁句柄(调用方负责 unlock)
* @return ESP_OK=前缀通过可继续;ESP_ERR_INVALID_STATE=被拒绝(已 unlock) */
esp_err_t beginLockedWithChecks(const char *tag, bool &out_lock);
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
// 供 notifyEsp32*Complete / notifyEsp32Rebooting 等锁内路径复用,避免重复样板代码。
void setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg);
+91 -88
View File
@@ -349,10 +349,14 @@ ota_result_t OtaRelay::handleTransferring(relay_event_t event, const uint8_t *pa
DEBUG_INFO("OTA", "=== 阶段3: 数据包传输 (%" PRIu32 " 包) ===", m_transferTotalPackets);
/* 转发预算:自本阶段起算。正常 ~15ms/包,预算 40ms/包 + 20s 底(上限 120s)。 */
constexpr uint32_t kXferMinMs = 20U * 1000U;
/* 转发预算:自本阶段起算。正常 ~2ms/包(12MHz+ 读预算收紧后实测),
* 单包失败重发最坏 ~95ms/轮 × 3 轮 ≈ 285ms(底层 3 次尝试 + relay 50ms 窗口)。
* 预算按"正常 + 重发余量":25ms/包 ≈ 正常 2ms + 约 10% 包重发 1 次的平均,
* 底 10s 覆盖慢启动/握手余量(777 包正常仅 ~1.6s);上限 120s 兜极端慢速。
* 跨包卡死(10s 无成功帧)由 kStallTimeoutMs 单独检测,总预算不重复兜底。 */
constexpr uint32_t kXferMinMs = 10U * 1000U;
constexpr uint32_t kXferMaxMs = 120U * 1000U;
constexpr uint32_t kXferPerPktMs = 40U;
constexpr uint32_t kXferPerPktMs = 25U;
uint32_t xfer_ms = kXferMinMs + m_transferTotalPackets * kXferPerPktMs;
if (xfer_ms > kXferMaxMs) {
xfer_ms = kXferMaxMs;
@@ -448,9 +452,6 @@ ota_result_t OtaRelay::forwardFirmware()
DEBUG_INFO("OTA", "开始固件转发: %" PRIu32 " 字节", meta.total_size);
/* 中继全程禁止 worker 把 OTA ACK/WAIT 当 EVENT 偷读 */
SpiMaster::instance().setSuppressRingService(true);
/* 完整性门控(避免与 /ota/relay·BLE 触发前的 verifyGd32Firmware 二次全盘 SHA):
* - meta 必须完整 stored≥total
* - checksum 非全零:触发路径已 finalize+比对,此处信任 meta,跳过再扫 Flash
@@ -500,10 +501,8 @@ ota_result_t OtaRelay::forwardFirmware()
DEBUG_ERR("OTA", "中继失败: result=%d, state=%d", (int)res, (int)getState());
/* 必须通知 GD32 CANCEL,否则远端停在 WAITING_* 半会话 */
abortRemoteAndReset(true);
} else {
/* 成功路径:解除按铃抑制(reset 也会做,成功时状态机可能停在 DONE) */
SpiMaster::instance().setSuppressRingService(false);
}
/* 成功/失败统一由 reset() 解除 ring 抑制(relay task 侧持有 ref) */
return res;
}
@@ -1163,14 +1162,21 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
size_t resp_len = 0;
const uint32_t expected_ack = (seq == m_transferTotalPackets - 1) ? kLastPacketAckSeq : (seq + 1U);
/*
* 协议要点:
* - GD32 ISR 收 DATA 后立刻回 WAIT,主循环 AES/Flash 后再 ACK。
* - 一旦见过 WAIT,说明包已进从机;再发 DATA = DUP-SEQ,只会反复 WAIT。
* - 因此:见 WAIT 后只 poll;仅「完全无响应」才允许重发 DATA。
* 协议要点(v2,GD32 侧缓存修复后):
* - GD32 ISR 收 DATA 后立刻回 WAIT,主循环 AES/Flash 后再回 ACK。
* - 一旦见过 WAIT,包已进从机;但那帧 ACK 可能因时序(尤其 8MHz)丢失。
* - GD32 主循环发 ACK 时已同步更新重发缓存(见 SpiOta::sender),
* 且主循环对已写入包号去重(receive_packet_num < next_expected_packet 只回 ACK,
* 不二次写 flash)——故重发 DATA 安全。
* - 因此:见 WAIT 后若 poll 超时,允许有限次重发 DATA 触发 DUP-SEQ 回放最终 ACK,
* 实现“重发快且不漏”(旧逻辑见 WAIT 后只能干 poll 到 stall,ACK 丢了就永丢)。
* - 等 ACK 期间可预组下一包(与从机处理重叠)。
*/
constexpr uint32_t kMaxDataSends = 3U;
constexpr uint32_t kAckWaitMs = 6000U; /* 单次发送后等 ACK 上限(覆盖 GD32 增量 drain) */
/* 单次发送后等 ACK 上限:GD32 正常 ms 级回 ACK(ISR 回 WAIT、主循环回 ACK),
* 50ms 未到即重发(DUP-SEQ 回放捞回),不再干等 6s(用户指定 50ms)。
* credit==0 的排空积压由 transact 内部 credit 背压消化,此处无需长等。 */
constexpr uint32_t kAckWaitMs = 50U;
constexpr uint32_t kPollSliceMs = 50U; /* 略紧:DATA_READY 驱动,缩短空切片 */
TickType_t pkt_start = xTaskGetTickCount();
@@ -1195,15 +1201,16 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
};
for (uint32_t send_i = 0; send_i < kMaxDataSends; ++send_i) {
/* 已见 WAIT:禁止再发 DATA,只延长 poll */
if (saw_wait && send_i > 0U) {
DEBUG_DBG("OTA", "包 %" PRIu32 " 已见 WAIT,继续 poll 不重发", seq);
} else {
/* saw_wait 后仍允许重发 DATA:触发 GD32 DUP-SEQ 回放最终 ACK(缓存已修复)。
* 重发的 DATA 进 GD32 主循环会因包号去重只回 ACK,不二次写 flash。 */
{
resp_len = 0;
resp_type = 0;
/* 等首响应上限 10ms:一次 SPI 事务最多 5ms(用户指定),
* 超时立即进入下方 50ms poll 窗口,不再干等 200ms。 */
esp_err_t err = SpiMaster::instance().transact(
SPI_OTA_CMD_DATA, tx_buf, 64,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1200);
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 10);
logRespPayload("DATA_PACKET", resp_type, resp_payload, resp_len);
if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) {
saw_wait = true;
@@ -1264,16 +1271,12 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
}
}
/* 超时:仅当从未见 WAIT 时才重发 DATA;见过 WAIT 则再 poll 一轮 */
if (saw_wait) {
if (send_i + 1U >= kMaxDataSends) {
break;
}
continue;
}
/* 超时:无论是否见过 WAIT 都允许重发 DATA。
* 见过 WAIT 时重发靠 GD32 DUP-SEQ 回放缓存的最终 ACK 来捞回丢失的 ACK;
* 未见 WAIT 时重发则是重新送注。两者均安全(GD32 对已写包号去重)。 */
if (send_i + 1U < kMaxDataSends) {
DEBUG_WARN("OTA", "包 %" PRIu32 " 无 WAIT/ACK,重发 DATA (%" PRIu32 "/%" PRIu32 ")",
seq, send_i + 1U, kMaxDataSends);
DEBUG_WARN("OTA", "包 %" PRIu32 " 超时重发 DATA (%" PRIu32 "/%" PRIu32 ") saw_wait=%d",
seq, send_i + 1U, kMaxDataSends, (int)saw_wait);
vTaskDelay(pdMS_TO_TICKS(2));
}
}
@@ -1413,6 +1416,58 @@ bool OtaRelay::probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime)
return false;
}
/**
* @brief 统一 "HELLO(role=APP) → 可选 PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。
*
* 两处原循环差异仅:taskEntry 在 HELLO 已带身份(gd32IdKnown)时跳过 PING 直接成功。
* 用 allow_hello_shortcut 参数统一:true 时启用 HELLO 身份快路径。
*
* @param timeout_ms PING 超时
* @param ping_interval_ms 每轮间隔
* @param max_attempts 最大轮数
* @param allow_hello_shortcut true=HELLO 带身份即成功(taskEntry 场景)
* @return true=APP 存活已确认
*/
bool OtaRelay::helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
uint32_t max_attempts, bool allow_hello_shortcut)
{
bool session_ok = false;
for (uint32_t attempt = 0; attempt < max_attempts; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(ping_interval_ms));
if (!session_ok) {
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_ok = true;
/* HELLO 已带身份时直接缓存版本/开关,无需再等 PING 才亮版本 */
if (allow_hello_shortcut && SpiMaster::instance().gd32IdKnown()) {
StateLockGuard lock;
gd32Version = SpiMaster::instance().gd32VersionCached();
gd32AppAlive = true;
DEBUG_INFO("OTA", "HELLO就绪 APP=v%d.%d.%d.%d",
gd32Version.app_major, gd32Version.app_minor,
gd32Version.app_patch, gd32Version.app_build);
DEBUG_INFO("OTA", "HELLO就绪 BOOT=v%d.%d.%d.%d",
gd32Version.boot_major, gd32Version.boot_minor,
gd32Version.boot_patch, gd32Version.boot_build);
return true;
}
} else {
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/%u)", attempt + 1U, (unsigned)max_attempts);
continue;
}
}
/* HELLO 成功但无身份(旧固件),或 doWaitForAppAlive 路径:回退 APP PING */
if (probeAndCacheAppAlive(timeout_ms)) {
return true;
}
/* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */
session_ok = false;
DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/%u)", attempt + 1U, (unsigned)max_attempts);
}
return false;
}
/** @brief 协议子步骤:发送 FINISH_OTA 指令并等待 GD32 ACK(10 次重试)。 */
ota_result_t OtaRelay::doSendFinishOta()
{
@@ -1486,30 +1541,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
/* FINISH 后短暂安静,避免 jump/APP SPI arm 窗口被 HELLO 打乱(对齐既有 300ms 节拍)。 */
vTaskDelay(pdMS_TO_TICKS(500));
/* APP 起机约数秒;HELLO 须 role=APP 再建 session,再 appPing。 */
bool session_established = false;
for (uint32_t attempt = 0; attempt < 20; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(300));
if (!session_established) {
esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP);
if (hr == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_established = true;
} else {
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
continue;
}
}
if (probeAndCacheAppAlive(timeout_ms)) {
break;
}
/* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */
session_established = false;
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
}
if (!getGd32AppAlive()) {
/* doWaitForAppAlive 不启用 HELLO 身份快路径:OTA 完成后必须 PING 确认 APP 真正运行 */
if (!helloAndProbeApp(timeout_ms, 300U, 20U, /*allow_hello_shortcut=*/false)) {
DEBUG_ERR("OTA", "GD32 APP 启动确认超时,OTA 失败");
/* APP 未起来时 CANCEL 可能无效,仍尽力清理 BOOT 半会话 */
abortRemoteAndReset(true);
@@ -1563,42 +1596,7 @@ void OtaRelay::taskEntry(void *pvParameters)
/* 启动 GD32 探测:先 hello 建 v2 session,再 appPing。 */
{
DEBUG_INFO("OTA", "启动初始 GD32 探测 (hello+probe)...");
bool session_ok = false;
bool alive = false;
for (uint32_t attempt = 0; attempt < 20U; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(300));
if (!session_ok) {
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_ok = true;
/* HELLO 已带身份时直接缓存版本/开关,无需再等 PING 才亮版本 */
if (SpiMaster::instance().gd32IdKnown()) {
StateLockGuard lock;
gd32Version = SpiMaster::instance().gd32VersionCached();
gd32AppAlive = true;
alive = true;
DEBUG_INFO("OTA", "HELLO就绪 APP=v%d.%d.%d.%d",
gd32Version.app_major, gd32Version.app_minor,
gd32Version.app_patch, gd32Version.app_build);
DEBUG_INFO("OTA", "HELLO就绪 BOOT=v%d.%d.%d.%d",
gd32Version.boot_major, gd32Version.boot_minor,
gd32Version.boot_patch, gd32Version.boot_build);
break;
}
} else {
DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U);
continue;
}
}
/* 旧固件无 HELLO 身份:回退 APP PING */
if (probeAndCacheAppAlive(2000)) {
alive = true;
break;
}
session_ok = false;
DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/20)", attempt + 1U);
}
if (!alive) {
if (!helloAndProbeApp(2000U, 300U, 20U, /*allow_hello_shortcut=*/true)) {
DEBUG_WARN("OTA", "启动 GD32 探测未成功(中继前仍可再 hello)");
}
}
@@ -1611,6 +1609,11 @@ void OtaRelay::taskEntry(void *pvParameters)
}
DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg);
/* 尽早抑制 ring service:onRelayStarted 可能因协调器锁(/system/status
* 等争锁)阻塞,此窗口内 worker 会把上一阶段残留 RESPONSE 当 EVENT 偷读
* (线上 log#1:TRANSFERRING 起始瞬间 serviceRing 读到 type=0x80)。
* forwardFirmware 不再重复设置;释放统一走 reset()。 */
SpiMaster::instance().setSuppressRingService(true);
// 通过 IRelayListener 接口回调通知协调器
IRelayListener *listener = getRelayListener();
if (listener) listener->onRelayStarted();
+13 -40
View File
@@ -118,16 +118,6 @@ public:
*/
ota_result_t forwardFirmware();
// ── 事件驱动接口 ──
/**
* @brief 向状态机投递事件,驱动状态转换.
* @param event 事件类型.
* @param payload 关联数据(响应载荷等),可为nullptr.
* @param len 数据长度.
* @return 当前操作结果码(OTA_RESULT_OK 表示事件已处理且可继续).
*/
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 状态查询(线程安全)──
/**
* @brief 获取当前中继状态.
@@ -145,31 +135,6 @@ public:
* @return 中继进度快照。
*/
static RelaySnapshot getSnapshot();
/**
* @brief 设置中继状态.
* @param s 目标状态。
*/
static void setState(relay_state_t s);
/**
* @brief 设置中继当前序号.
* @param seq 序号值。
*/
static void setRelaySeq(uint32_t seq);
/**
* @brief 设置中继总包数.
* @param total 总包数。
*/
static void setRelayTotalPackets(uint32_t total);
/**
* @brief 设置GD32 APP存活标志位.
* @param alive true 表示存活。
*/
static void setGd32AppAlive(bool alive);
/**
* @brief 设置GD32固件版本.
* @param ver 版本信息结构体引用。
*/
static void setGd32Version(const gd32_version_t &ver);
/**
* @brief 获取 OTA 中继任务句柄。
* @return FreeRTOS 任务句柄。
@@ -195,11 +160,6 @@ public:
* @param listener 监听器指针,传 nullptr 取消注册。
*/
static void setRelayListener(IRelayListener *listener);
/**
* @brief 获取当前注册的监听器。
* @return 监听器指针,未注册时为 nullptr。
*/
static IRelayListener* getRelayListener();
// ── 队列访问器 ──
/**
@@ -223,6 +183,19 @@ public:
*/
static bool probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime = nullptr);
/** @brief 统一 "HELLO(role=APP) → PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。 */
static bool helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
uint32_t max_attempts, bool allow_hello_shortcut);
// ── 内部状态写入(仅 ota_relay.cpp 使用,无外部调用)──
static void setState(relay_state_t s);
static void setRelaySeq(uint32_t seq);
static void setRelayTotalPackets(uint32_t total);
static void setGd32AppAlive(bool alive);
static void setGd32Version(const gd32_version_t &ver);
static IRelayListener* getRelayListener();
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 线程安全锁 ──
static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用)
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间
+5 -1
View File
@@ -11,7 +11,11 @@ void drainOtaDataQueue()
{
BleOtaDataBatch stale;
while (s_otaDataQueue && xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
if (stale.data) free(stale.data);
if (stale.pool_idx >= 0) {
ble_ota_pool_release(stale.pool_idx); // 静态池槽:归还
} else if (stale.data) {
free(stale.data); // heap 回退路径:释放
}
}
}
+157 -8
View File
@@ -54,6 +54,132 @@ ota_meta_t s_ble_gd32_meta = {};
bool s_ble_gd32_meta_active = false;
bool s_conn_fallback_requested = false;
// ============================================================
// DATA_IN 缓冲池(2026-08 内存优化)
//
// 背景:原 DATA_IN 路径每包 malloc(recv_len) + worker free(item.data),
// BLE 高速下发时高频堆抖动制造碎片,挤掉 lwip pbuf 连续块导致网络断连。
//
// 策略:OTA begin 时一次性 calloc 池(连续大块,比高频 per-pack malloc
// 碎片小得多),DATA_IN 借槽拷贝 → 入队 → worker 处理后归还;
// OTA end/abort/断连时 free 整个池。
// - 非 OTA 期间:池指针为 NULL,零占用,堆基线不受影响。
// - OTA 期间:借/还 O(1) 标志位操作,无堆抖动。
// - 池满/包过大回退 malloc + 负 ACK(保留原语义,避免功能回退)。
//
// 池容量 8 槽 × 512B = 4KB(2026-08-01 修正):
// 原 8×4096B=32KB 使 OTA 全程多占 28KB 堆,刷写 1MB 固件收尾时
// (0xF8 后需创建 finish task + image verify)直接 ESP_ERR_NO_MEM。
// 单次 ATT 写入上限 = MTU 512B(BLE_MTU_SIZE),常态包 ≤512B 全走池;
// NimBLE 合并的大 batch(≤4096B,经 BLE_OTA_BUF_SIZE 截断)回退
// malloc,与无池时代行为一致。4KB 固定占用对收尾任务足够安全。
// ============================================================
struct BleDataPool {
static constexpr size_t kSlots = 8;
static constexpr size_t kSlotSize = 512; // 单次 ATT 写入上限(MTU);大 batch 回退 malloc
uint8_t *buf[kSlots]; // kSlots 个独立槽(calloc 一次分配 kSlots*kSlotSize 后切分)
volatile bool in_use[kSlots];
};
static BleDataPool *s_data_pool = nullptr;
// 前向声明(poolAlloc 与 poolFree 互相引用)
static void poolFree();
/** @brief OTA begin 时分配池。失败返回 false(调用方回退纯 malloc 路径)。
* 安全性:上次会话遗留的池仅当全部槽空闲才复用。若 worker 仍持有在途
* batch(in_use=true),强制复位复用会让新会话 DATA_IN 覆写旧 worker
* 正在解析的内存 → flash 写坏 → 收尾 SHA/verify 失败。此时本会话回退
* malloc,旧池留待 worker 归还后由下次 poolFree 释放。 */
static bool poolAlloc()
{
if (s_data_pool) {
// 上次会话遗留池:检查是否全部空闲(in_use 仅由 worker/队列消费侧复位)
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (s_data_pool->in_use[i]) {
DEBUG_WARN(TAG, "DATA_IN 池槽仍被占用,本会话回退 malloc");
return false;
}
}
return true; // 全部空闲:安全复用
}
s_data_pool = (BleDataPool *)calloc(1, sizeof(BleDataPool));
if (!s_data_pool) return false;
// 一次性分配所有槽的负载数据(连续大块,碎片友好)
uint8_t *blob = (uint8_t *)calloc(BleDataPool::kSlots, BleDataPool::kSlotSize);
if (!blob) {
free(s_data_pool);
s_data_pool = nullptr;
return false;
}
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
s_data_pool->buf[i] = blob + i * BleDataPool::kSlotSize;
s_data_pool->in_use[i] = false;
}
return true;
}
/** @brief OTA end/abort/断连时释放整个池。
* 安全性:仅当所有槽均空闲时才释放;若 worker 仍有 in-flight batch
* 占用槽(in_use=true),跳过本次释放,留待下次 poolFree 全部空闲时
* 再释放。这避免了 worker 访问已释放内存的竞态(单核抢占:
* session_reset 运行在 NimBLE host task,worker 是独立任务,二者仅
* 通过队列交接,无显式同步)。 */
static void poolFree()
{
if (!s_data_pool) return;
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (s_data_pool->in_use[i]) {
// 有槽仍被 worker 占用,跳过释放(下次 poolFree 再试)
DEBUG_DBG(TAG, "DATA_IN 池延迟释放: 槽%u in_use", (unsigned)i);
return;
}
}
// buf[0] 是连续 blob 的起始(一次 calloc 分配)
if (s_data_pool->buf[0]) {
free(s_data_pool->buf[0]);
}
free(s_data_pool);
s_data_pool = nullptr;
}
/** @brief 从池借一个槽,返回索引;无空闲或池未分配返回 -1。仅 DATA_IN(host task)调用。 */
static int poolAcquire()
{
if (!s_data_pool) return -1;
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (!s_data_pool->in_use[i]) {
s_data_pool->in_use[i] = true;
return (int)i;
}
}
return -1;
}
/** @brief 归还槽到池。仅 worker / drain(队列消费侧)调用。 */
static void poolRelease(int idx)
{
if (!s_data_pool) return;
if (idx >= 0 && idx < (int)BleDataPool::kSlots) {
s_data_pool->in_use[idx] = false;
}
}
/** @brief 借槽拷贝数据;返回槽索引或 -1(池满/未分配/包过大)。 */
static int poolCopyIn(const uint8_t *src, size_t len)
{
if (!s_data_pool || len > BleDataPool::kSlotSize) return -1;
int idx = poolAcquire();
if (idx < 0) return -1;
memcpy(s_data_pool->buf[idx], src, len);
return idx;
}
/** @brief 归还池槽(跨 TU 调用入口:drainOtaDataQueue / 会话清理)。 */
void ble_ota_pool_release(int idx)
{
poolRelease(idx);
}
// ============================================================
// DATA_IN 丢包负反馈:把 malloc 失败 / 队列满从"静默丢包"变成
// "主动发缺口 ACK 让前端按 completed_bytes 续传退避"。
@@ -394,6 +520,11 @@ esp_err_t ble_esp32_ota_begin(size_t total_size)
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
// ★ 分配 reorder buffer(失败则自动回退丢弃策略)
esp32_reorder_alloc();
// ★ 分配 DATA_IN 缓冲池(失败则 DATA_IN 回退 per-pack malloc 路径)
if (!poolAlloc()) {
DEBUG_WARN(TAG, "DATA_IN 池分配失败,回退 malloc free=%u",
(unsigned)esp_get_free_heap_size());
}
// ★ BLE OTA 开始:降低 WiFi 射频占用
ble_ota_boost_wifi_ps();
/* 刷写前再要一次高速连接参数(连接时若未谈妥,此处补刀) */
@@ -439,6 +570,8 @@ void ble_ota_session_reset()
BleOta::instance().m_esp32RebootPending = false;
// 释放 ESP32 reorder buffer(无 buffer 时为空操作)
esp32_reorder_free();
// 释放 DATA_IN 缓冲池(非 OTA 期间零占用,恢复堆基线)
poolFree();
// 复位 GD32 路径乱序包计数器(节流 ACK 计数),保证下次 GD32 上传从干净状态开始
s_gd32_ooo_count = 0;
s_ble_gd32_meta = {};
@@ -697,21 +830,33 @@ int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
// 将数据拷贝到堆上并送入工作队列,避免在 NimBLE host task 中执行
// 将数据拷贝到队列缓冲并送入工作线程,避免在 NimBLE host task 中执行
// 耗时的 flash 操作(esp_ota_begin / esp_ota_write)导致 BLE 连接断开。
// ★ 优先走静态池(消除高频 malloc/free 堆抖动);池满回退 malloc + 负 ACK。
if (s_otaDataQueue) {
// 解析 cmd 字节供负 ACK 区分 ESP32/GD32 续传分支(与 worker 解析一致)
uint8_t cmd = parse_cmd_from_batch(buf, (size_t)recv_len);
int pool_idx = poolCopyIn(buf, (size_t)recv_len);
if (pool_idx >= 0) {
BleOtaDataBatch item = { s_data_pool->buf[pool_idx], (size_t)recv_len, pool_idx };
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
// 队列满:归还池槽 + 发缺口 ACK 让前端按 completed_bytes 续传退避,
// 而非等满 8s 窗口超时后整窗口重发 → 重传风暴叠加低堆二次失败
s_ota_drop_queue_full += 1;
poolRelease(pool_idx);
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
sendNegativeAckIfDue(cmd);
}
} else {
// 池满或包过大:回退 malloc(保留原负 ACK 语义,避免静默丢包)
uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len);
if (heap_buf) {
memcpy(heap_buf, buf, (size_t)recv_len);
BleOtaDataBatch item = { heap_buf, (size_t)recv_len };
BleOtaDataBatch item = { heap_buf, (size_t)recv_len, -1 };
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
// 队列满:发缺口 ACK 让前端按 completed_bytes 续传退避,
// 而非等满 8s 窗口超时后整窗口重发 → 重传风暴叠加低堆二次失败
s_ota_drop_queue_full += 1;
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
free(heap_buf);
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
sendNegativeAckIfDue(cmd);
}
} else {
@@ -721,6 +866,7 @@ int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
sendNegativeAckIfDue(cmd);
}
}
}
return 0;
}
@@ -1096,7 +1242,8 @@ void ble_ota_data_worker_task(void *pvParameters)
BleOtaDataBatch item;
if (xQueueReceive(s_otaDataQueue, &item, portMAX_DELAY) == pdTRUE) {
if (!item.data || item.len == 0) {
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
continue;
}
@@ -1104,12 +1251,14 @@ void ble_ota_data_worker_task(void *pvParameters)
if (!BleOta::instance().isConnected() ||
BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_DBG(TAG, "BLE不可用 丢%zuB队列", item.len);
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
continue;
}
ble_ota_process_data_batch(item.data, item.len);
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
}
}
}
+2 -1
View File
@@ -227,7 +227,8 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
BleOtaDataBatch stale;
size_t dropped = 0;
while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
free(stale.data);
if (stale.pool_idx >= 0) ble_ota_pool_release(stale.pool_idx);
else free(stale.data);
dropped++;
}
if (dropped > 0) {
+3
View File
@@ -61,6 +61,7 @@
struct BleOtaDataBatch {
uint8_t *data;
size_t len;
int pool_idx; ///< 静态池槽索引(>=0 为池内,<0 为 heap malloc,需 free)
};
struct BleGd32RelayWaitCtx {
@@ -87,6 +88,8 @@ extern bool s_wifi_ps_boosted;
extern bool s_conn_fallback_requested;
void drainOtaDataQueue();
/** @brief 归还 DATA_IN 静态池槽(drainOtaDataQueue / 会话清理跨 TU 调用)。 */
void ble_ota_pool_release(int idx);
void ble_ota_start_advertising(void);
void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
void sendFinishEvent(uint8_t stage, uint32_t value);
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
/*
* http_util.h - HTTP handler 共享工具(JSON 追加、query 解析、hex 编解码)
*
* 消除 http_handlers.cpp / health_monitor.cpp / spi_speed_test.cpp 中的重复样板。
*/
#ifndef HTTP_UTIL_H
#define HTTP_UTIL_H
#include <stddef.h>
#include <stdint.h>
#include <stdarg.h>
#include <stdbool.h>
#include "esp_http_server.h"
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── JSON buffer 追加 ──────────────────────────────────────────
* 统一替代 feedback_append / append_jsonf / appendJsonfSafe 三份拷贝。
* 截断时不越界,返回新 offset;出错返回 -1 或原 offset(视调用方约定)。
* 返回 (int)cap-1 表示已截断填满。 */
static inline int http_json_append(char *buf, size_t cap, int offset, const char *fmt, ...)
{
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
va_end(ap);
if (n < 0) return offset;
if ((size_t)n >= cap - (size_t)offset) return (int)cap - 1; /* 截断 */
return offset + n;
}
/* ── Query 参数解析 ────────────────────────────────────────────
* 统一替代 6 处内联 ?lite=1 / ?bin=1 / ?ch=N 解析块。 */
/** 查询布尔型参数:值为 "1"/"y"/"t"/"T" 时返回 true,缺失或其它返回 false。 */
static inline bool http_query_bool(httpd_req_t *req, const char *key)
{
char query[48];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return false;
char val[8] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return false;
return val[0] == '1' || val[0] == 'y' || val[0] == 'Y' ||
val[0] == 't' || val[0] == 'T';
}
/** 查询无符号整型参数:缺失/非法时返回 defval。 */
static inline uint32_t http_query_u32(httpd_req_t *req, const char *key, uint32_t defval)
{
char query[64];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return defval;
char val[16] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return defval;
return (uint32_t)strtoul(val, NULL, 10);
}
/* ── Hex 编解码 ────────────────────────────────────────────────
* 统一替代内联 strtoul 循环和 snprintf("%02X") 循环。 */
/** hex 字符串 → 字节数组,返回写入字节数。 */
static inline size_t http_hex_to_bytes(const char *hex, uint8_t *out, size_t max_out)
{
size_t hex_len = strlen(hex);
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** hex 字符串(带长度)→ 字节数组,返回写入字节数。
* 供 json_parse.h 的非 NUL 终止字符串值使用。 */
static inline size_t http_hex_to_bytes_n(const char *hex, size_t hex_len, uint8_t *out, size_t max_out)
{
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** 字节数组 → 大写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_upper(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02X", in[i]);
}
out[n * 2] = '\0';
}
/** 字节数组 → 小写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_lower(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02x", in[i]);
}
out[n * 2] = '\0';
}
#ifdef __cplusplus
}
#endif
#endif // HTTP_UTIL_H
+243
View File
@@ -0,0 +1,243 @@
/*
* json_parse.h - 极简 JSON 扁平对象解析器(cJSON 零堆分配替代)
*
* 项目中对 JSON 的使用全部是"解析前端发来的扁平 JSON 对象":
* { "sha256": "...", "verify_only": true, "total_size": 12345, "type": "esp32" }
* 无嵌套对象、无数组(can_send_batch 已自研流式解析)。
* 本解析器直接在调用方提供的可写字符串缓冲上做扫描,
* 不分配堆内存,字符串值通过就地 NUL 终止返回指针。
*
* 用法:
* JsonView root = jsonParse(buf); // buf 须为可写 char[]
* if (!root.valid) return error;
* JsonView sha = jsonGet(&root, "sha256");
* if (sha.type == JSON_STRING) { use(sha.str); }
* JsonView sz = jsonGet(&root, "total_size");
* if (sz.type == JSON_NUMBER) { uint32_t n = (uint32_t)sz.num; }
* // 无需 free —— 指针指向 buf,buf 由调用方管理
*/
#ifndef JSON_PARSE_H
#define JSON_PARSE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
JSON_INVALID = 0,
JSON_NULL,
JSON_BOOL,
JSON_NUMBER,
JSON_STRING,
JSON_OBJECT,
} JsonType;
typedef struct {
JsonType type;
bool valid;
char *str; /* JSON_STRING:指向 buf 内字符串(不一定 NUL 终止!) */
size_t str_len; /* JSON_STRING:字符串长度(配合 str 使用,避免就地写 NUL) */
double num; /* JSON_NUMBER / JSON_BOOL */
char *obj_start; /* JSON_OBJECT:'{' 后的内容区 */
size_t obj_len;
} JsonView;
/**
* @brief 解析 JSON 字符串为根对象视图(就地扫描,可能写入 NUL 终止符)。
* @param json 可写 NUL 终止 JSON 字符串(须为顶层 { ... } 扁平对象)
* @return type=JSON_OBJECT 且 valid=true,或 valid=false 解析失败
*/
static inline JsonView jsonParse(char *json)
{
JsonView v = {};
if (!json) return v;
char *p = json;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '{') return v;
p++;
char *content = p;
int depth = 1;
while (*p) {
if (*p == '"') {
p++;
while (*p && *p != '"') {
if (*p == '\\' && p[1]) p++;
p++;
}
if (*p) p++;
} else if (*p == '{') {
depth++; p++;
} else if (*p == '}') {
depth--;
if (depth == 0) break;
p++;
} else {
p++;
}
}
if (depth != 0) return v;
v.type = JSON_OBJECT;
v.valid = true;
v.obj_start = content;
v.obj_len = (size_t)(p - content);
return v;
}
/**
* @brief 在扁平 JSON 对象中查找指定 key 的值。
* @return valid=true 表示找到;字符串值经就地 NUL 终止后通过 .str 返回
*/
static inline JsonView jsonGet(const JsonView *obj, const char *key)
{
JsonView result = {};
if (!obj || !obj->valid || obj->type != JSON_OBJECT || !key) return result;
char *p = obj->obj_start;
char *end = obj->obj_start + obj->obj_len;
while (p < end) {
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')) p++;
if (p >= end) break;
/* "key" */
if (*p != '"') break;
p++;
char *key_start = p;
while (p < end && *p != '"') p++;
if (p >= end) break;
size_t klen = (size_t)(p - key_start);
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end || *p != ':') break;
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end) break;
bool match = (strlen(key) == klen && strncmp(key_start, key, klen) == 0);
if (*p == '"') {
p++;
char *val_start = p;
while (p < end && *p != '"') {
if (*p == '\\' && p + 1 < end) p++;
p++;
}
if (p >= end) break;
if (match) {
/* 不就地写 NUL(会破坏后续 jsonGet 扫描);用 str_len 返回长度 */
result.type = JSON_STRING;
result.valid = true;
result.str = val_start;
result.str_len = (size_t)(p - val_start);
return result;
}
p++;
} else if (*p == 't' || *p == 'f') {
if (match) {
result.type = JSON_BOOL;
result.valid = true;
result.num = (*p == 't') ? 1.0 : 0.0;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else if (*p == 'n') {
if (match) {
result.type = JSON_NULL;
result.valid = true;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else {
char *val_start = p;
if (*p == '-') p++;
while (p < end && ((*p >= '0' && *p <= '9') || *p == '.' || *p == 'e' || *p == 'E' || *p == '+' || *p == '-')) p++;
if (match) {
/* 不就地写 NUL;strtod 可在非 NUL 终止串上工作(指定长度)。
* 但 strtod 需要终止符——用临时方式:记录起止,用 sscanf 或手动解析。
* 更简方案:buf 本身是 NUL 终止的(jsonParse 要求 NUL 终止输入),
* 而 *p 此处是数字后的分隔符(',' 或 '}' 或空白),不是 NUL。
* 我们临时保存 *p、写 NUL、解析、恢复 *p。 */
char saved = *p;
*p = '\0';
result.type = JSON_NUMBER;
result.valid = true;
result.num = strtod(val_start, nullptr);
*p = saved; /* 恢复,不影响后续 jsonGet 扫描 */
return result;
}
}
}
return result;
}
/* ── 便捷谓词(兼容 cJSON 调用风格)── */
static inline bool jsonIsString(const JsonView *item) {
return item && item->valid && item->type == JSON_STRING;
}
static inline bool jsonIsNumber(const JsonView *item) {
return item && item->valid && item->type == JSON_NUMBER;
}
static inline bool jsonIsBool(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL;
}
static inline bool jsonIsTrue(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL && item->num != 0.0;
}
/**
* @brief 获取字符串值指针和长度(字符串不一定 NUL 终止,须配合 str_len 使用)。
* @param item JSON 值视图
* @param out_len 可选,输出字符串长度
* @return 字符串指针(指向 buf 内部),或 nullptr
*/
static inline const char *jsonGetStringValue(const JsonView *item, size_t *out_len = nullptr) {
if (item && item->valid && item->type == JSON_STRING) {
if (out_len) *out_len = item->str_len;
return item->str;
}
if (out_len) *out_len = 0;
return nullptr;
}
/**
* @brief 比较字符串值是否等于指定 C 字符串。
* 因 jsonGet 不写 NUL 终止符,不能用 strcmp,须用此函数。
*/
static inline bool jsonStringEquals(const JsonView *item, const char *cstr) {
if (!jsonIsString(item) || !cstr) return false;
size_t clen = strlen(cstr);
return clen == item->str_len && strncmp(item->str, cstr, clen) == 0;
}
/**
* @brief 将字符串值拷贝到 NUL 终止的输出缓冲(安全截断)。
* @return 实际拷贝长度(不含 NUL)
*/
static inline size_t jsonGetStringCopy(const JsonView *item, char *out, size_t out_max) {
if (!jsonIsString(item) || !out || out_max == 0) return 0;
size_t n = (item->str_len < out_max - 1) ? item->str_len : out_max - 1;
memcpy(out, item->str, n);
out[n] = '\0';
return n;
}
static inline double jsonGetNumberValue(const JsonView *item) {
return (item && item->valid && item->type == JSON_NUMBER) ? item->num : 0.0;
}
#ifdef __cplusplus
}
#endif
#endif // JSON_PARSE_H
+12 -1
View File
@@ -31,6 +31,7 @@
#include "spi_master.h"
#include "ble_ota.h"
#include "http_handlers.h"
#include "monitor/spi_speed_test.h" /* [SPI-SPEEDTEST] 临时:可整行删除 */
// ----------------------------------------
@@ -298,7 +299,10 @@ esp_err_t WifiOta::initSta()
country.policy = WIFI_COUNTRY_POLICY_MANUAL;
wifi_config_t wifi_config = {};
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
/* FAST_SCAN:扫到目标 SSID 即停并连接(多 AP 同名时取先扫到的,可能非最强)。
* 原 ALL_CHANNEL_SCAN 要把 13 信道全扫完才连,OTA 重启后页面恢复要多等 3~8s。
* esp_restart 不清 nvs.net80211 缓存,驱动可复用上次 AP 信息,恢复更快。 */
wifi_config.sta.scan_method = WIFI_FAST_SCAN;
wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
wifi_config.sta.pmf_cfg.capable = true;
@@ -483,6 +487,7 @@ esp_err_t WifiOta::httpServerStart()
{"/motor/ota/relay", HTTP_POST, motor_ota_relay_handler},
{"/motor/ota/status", HTTP_GET, motor_ota_status_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 只注册一次,
@@ -820,6 +825,12 @@ void WifiOta::taskEntry(void *pvParameters)
/* 启动日志 burst 已过:环 64→16 槽,释放大约 7.5KB 给业务堆 */
DebugLog::instance().shrinkRingAfterBoot();
/* ★ 重启完成判定信号(前端 polling.js 匹配 /OTA Relay .*启动/ + tag=MAIN):
* 必须在本函数(httpd 就绪、shrinkRingAfterBoot 之后)打印——main.cpp 的
* 早期 banner 在环缩容时会被裁掉,前端首轮 /logs 拉不到,只能干等 10s
* 安全阈值才恢复页面。此处是环内最新日志,前端一定能拉到。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v%s 启动", FW_VERSION);
// WiFi 状态监控
// 每秒一次循环:每 10 秒检查 RSSI
uint32_t tick = 0;
+57 -5
View File
@@ -14,6 +14,7 @@
#include "mbedtls/private/sha256.h"
#include "gd32_flash_store.h"
#include "app_config.h" // OTA_BLOCK_SIZE(flashWriteChunk 静态对齐缓冲容量)
#include "monitor/debug_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -147,6 +148,54 @@ esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len)
return ESP_OK;
}
// ============================================================
// OTA 期对齐缓冲池(2026-08 内存优化)
//
// flashWriteChunk 在非 4 字节对齐写入时需要临时缓冲读回 padding。
// 原实现每次 malloc/free,OTA 收流时与非对齐块叠加产生堆抖动。
// 现改为 OTA begin 时一次性 calloc,end/abort 时 free,消除抖动
// 又不增加常驻 BSS(非 OTA 期零占用)。
// ============================================================
static uint8_t *s_align_buf = nullptr;
static constexpr size_t kAlignBufCap = OTA_BLOCK_SIZE + 8;
void Gd32FlashStore::acquireAlignBufForOta()
{
if (!s_align_buf) {
s_align_buf = (uint8_t *)calloc(1, kAlignBufCap);
if (!s_align_buf) {
DEBUG_WARN("OTA", "对齐缓冲分配失败,回退 malloc free=%u",
(unsigned)esp_get_free_heap_size());
}
}
}
void Gd32FlashStore::releaseAlignBufForOta()
{
if (s_align_buf) {
free(s_align_buf);
s_align_buf = nullptr;
}
}
/** @brief 取对齐缓冲。优先用 OTA 期预分配;未分配或太小则临时 malloc。 */
static uint8_t *acquireAlignBuf(size_t needed)
{
if (s_align_buf && needed <= kAlignBufCap) {
return s_align_buf;
}
// OTA 未预分配(begin 失败)或包过大:回退临时 malloc
return (uint8_t *)malloc(needed);
}
/** @brief 归还对齐缓冲。OTA 池的不释放;临时 malloc 的 free。 */
static void releaseAlignBuf(uint8_t *buf)
{
if (buf && buf != s_align_buf) {
free(buf);
}
}
/** @brief 向 gd32_ota 分区写入数据块,自动处理非 4 字节对齐的读写。 */
esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len)
{
@@ -188,9 +237,12 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
offset, len, head_pad);
}
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
/* OTA 期按需对齐缓冲(OTA begin 时 calloc,end/abort 时 free):
* 消除每非对齐块的 malloc/free 抖动,又不增加常驻 BSS。
* 容量 = OTA_BLOCK_SIZE(4KB) + 最多 3 字节 tail pad。 */
uint8_t *aligned_buf = acquireAlignBuf(aligned_len);
if (!aligned_buf) {
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
DEBUG_ERR("OTA", "flashWriteChunk 对齐缓冲不足/未分配: %zu", aligned_len);
return ESP_ERR_NO_MEM;
}
@@ -200,7 +252,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s",
aligned_offset, head_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
@@ -215,13 +267,13 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s",
aligned_offset + total, tail_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len);
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
+6
View File
@@ -145,6 +145,12 @@ public:
*/
const esp_partition_t* getPartition();
// ── OTA 期对齐缓冲生命周期 ──
/** @brief GD32 OTA begin 时调用:预分配对齐缓冲(消除 flashWriteChunk 逐块 malloc)。 */
static void acquireAlignBufForOta();
/** @brief GD32 OTA end/abort 时调用:释放对齐缓冲(非 OTA 期零占用)。 */
static void releaseAlignBufForOta();
private:
Gd32FlashStore();
~Gd32FlashStore() = default;
+61 -8
View File
@@ -61,11 +61,13 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = ESP_OK;
/* CAN 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
/* 0xC0 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
* 喂数(0xD1→WAIT)残留在 SPI 响应管道里的陈旧帧(0xC0 命令本身永不产生 WAIT)。
* 陈旧 WAIT 会被下方误判为 ERROR → resp_len=0 → gd32_err=0xFF → 无声拒绝发送(
* 表现为“失败一次就再也发不出去”)。此处最多重试几次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。 */
* 表现为“失败一次就再也发不出去”)。此处最多重试 4 次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。
* SPI 传输失败/无响应的重发已由 SpiMaster::otaSendAndPoll 统一处理
* (同 TX_SEQ,最多 3 次尝试),此处不再各自重试;GD32 拒绝(ERROR)同样不重试。 */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
@@ -73,8 +75,8 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
kSpiCanFrameType,
tx_buf, tx_len,
&resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms);
if (ret != ESP_OK || resp_type != SPI_OTA_RSP_WAIT) {
break; /* SPI 失败或已拿到真正响应(ACK/ERROR) */
if (resp_type != SPI_OTA_RSP_WAIT) {
break; /* 已拿到真正响应(ACK/ERROR);SPI 失败(ret!=OK)也退出交上层处理 */
}
vTaskDelay(pdMS_TO_TICKS(3)); /* 让 GD32 更新响应,再读一次排空陈旧 WAIT */
}
@@ -241,8 +243,10 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
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 = static_cast<uint16_t>(8U + dataLen);
if (offset + entrySize > len) {
// 线协议条目实际大小按原始 dlc 计算(CANFD 帧可能 64B),offset 必须按
// 实际条目推进,否则后续条目错位。data 拷贝按 dataLen 截断。
const uint16_t wireEntrySize = static_cast<uint16_t>(8U + e.dlc);
if (offset + wireEntrySize > len) {
break;
}
@@ -251,7 +255,7 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
e.data[d] = 0U;
}
offset += entrySize;
offset += wireEntrySize;
(*entryIdx)++;
}
@@ -264,6 +268,55 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
return 0;
}
/* ── 0xC1 请求/响应查询(2026-08 架构改造:替代被动推送缓存)── */
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx)
{
if (out == nullptr || entryIdx == nullptr) {
return -1;
}
if (channel >= CAN_CHANNEL_MAX || page > 1U) {
return -1;
}
uint8_t req[2] = { channel, page };
uint8_t resp_type = 0;
/* SPI v2 payload 上限 236B(256 帧 - 20 开销),与 GD32 kFeedbackPayloadLen 一致 */
uint8_t resp_buf[SPI_OTA_FRAME_MAX];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
0xC1, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (ret != ESP_OK) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u SPI失败: %s",
(unsigned)channel, (unsigned)page, esp_err_to_name(ret));
return -1;
}
if (resp_type == SPI_OTA_RSP_ERROR) {
uint8_t gd32_err = (resp_len > 0) ? resp_buf[0] : 0;
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u GD32拒绝 err=0x%02X",
(unsigned)channel, (unsigned)page, (unsigned)gd32_err);
return -2;
}
if (resp_type != SPI_OTA_RSP_ACK || resp_len < 1U) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 非ACK type=0x%02X len=%zu",
(unsigned)channel, (unsigned)page, (unsigned)resp_type, resp_len);
return -1;
}
/* 解析复用现有 canFdParseFeedbackPage(payload 格式与推送一致) */
int parse_rc = canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx);
if (parse_rc != 0) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 解析失败 rc=%d len=%zu",
(unsigned)channel, (unsigned)page, parse_rc, resp_len);
return -1;
}
return 0;
}
/**
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
* @return 0 成功;-1 参数非法或 SPI/应答失败。
+17 -1
View File
@@ -57,7 +57,7 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
// 不重复 ID 快照表(SPI 0xC3 协议配套)
// ============================================================
#define CANFD_ID_SNAPSHOT_MAX 16 ///< 每通道最大不重复 ID 条目数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数(CANFD 最大 64B,保持完整不截断)
/**
* @brief 单个不重复 ID 的最新帧快照条目。
@@ -131,6 +131,22 @@ typedef struct {
int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
can_feedback_result_t *out, uint8_t *entryIdx);
/**
* @brief 主动向 GD32 查询一页 CAN 反馈(SPI 0xC1 请求/响应)。
*
* 2026-08 架构改造:ESP32 不再被动缓存 GD32 推送,改为前端请求时按需查询。
* GD32 handleFeedback 接收 [channel, page] 返回 ACK + 页数据(与推送 payload 格式一致)。
* 查询成功后调 canFdParseFeedbackPage 解析,复用现有累积逻辑。
*
* @param channel 目标通道 0/1/2。
* @param page 页号 0 或 1(page 0 重置累积 + 填统计,page 1 追加 entries)。
* @param out 累积结果(跨 page 0/1 累积,调用方按通道维护)。
* @param entryIdx 当前已填充条目数(page 0 时被重置为 0)。
* @return 0 成功;-1 参数/SPI 失败;-2 GD32 返回 ERROR。
*/
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx);
/**
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
* @param mode 目标模式(0=全收,1=按 ID 过滤,2=CAN0 桥接到 1/2)。
+226 -56
View File
@@ -21,6 +21,7 @@
#include "esp_log.h"
#include "esp_random.h" // esp_random() for HELLO session_id
#include "esp_intr_alloc.h" // ESP_INTR_FLAG_IRAM
#include "esp_timer.h" // esp_timer_get_time():worker job 执行耗时采样
#include "spi_master.h"
#include "spi_link_v2.h"
#include "app_config.h"
@@ -67,8 +68,27 @@ static size_t spiRespReadBudget(void)
return SPI_APP_TX_BUF_SIZE;
}
/**
* @brief 已知"短响应"的主动请求类型:应答帧 ≤ ~70B(实测 0xA1 PING ~60-70B、
* DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短)。
* 对这些类型的主动 poll 可把读预算从 256 收紧到 96B,省 ~160B 0xFF 填充时钟
* (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)与被动 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:
return true;
default:
return false;
}
}
/** 短响应读预算(覆盖最长 ~70B 应答 + 余量)。 */
static constexpr size_t kShortRespReadBudget = 96U;
/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type)
bool SpiMaster::isAppOnlySpiType(uint8_t type)
{
switch (type) {
case 0xC1U: /* CAN feedback */
@@ -85,6 +105,34 @@ static bool isAppOnlySpiType(uint8_t type)
}
}
/* ── 去重辅助实现 ── */
void SpiMaster::copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
uint8_t *resp_payload, size_t resp_max,
const uint8_t *found_payload, size_t found_len,
size_t *resp_len)
{
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
} else if (copy_len > 0) {
memset(resp_payload, 0, copy_len);
}
}
if (resp_len) *resp_len = found_len;
}
void SpiMaster::correctRoleFromType(uint8_t found_type, const char *src)
{
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X, %s)",
(unsigned)m_gd32Role, found_type, src);
m_gd32Role = V2_ROLE_APP;
}
}
/** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。
* 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。
*/
@@ -97,6 +145,11 @@ static int IRAM_ATTR readReadyLevelRaw(void)
* 但 serviceRingOnce / otaPollResponseUnlocked 等更早出现的方法需要它。 */
static esp_err_t waitSpiDataReady(uint32_t timeout_ms);
/** @brief DATA_READY 下降沿事件信号量(ISR give / waitSpiDataReady take)。
* 二值信号量:多余边沿饱和为 1,消费方醒来后必须重查电平,
* 陈旧 give 只多一次电平检查,无副作用。 */
static SemaphoreHandle_t s_readyEdgeSem = nullptr;
// ============================================================
// 单例
// ============================================================
@@ -184,18 +237,52 @@ bool SpiMaster::popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t
}
/**
* @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理。
* @param suppress true=OTA 等关键路径禁止偷读 PB6;false=恢复
* @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理(引用计数)。
* @param suppress true=+1(OTA 等关键路径禁止偷读 PB6);false=-1(钳位到 0)。
*
* GD32 中继(forwardFirmware)与电机 OTA feedTask 都可能持有抑制;任一
* 持有者退出时若直接清位,会把另一方的保护误清(电机 OTA 收尾曾把进行中
* 的 GD32 中继抑制清掉 → worker 偷读 OTA ACK/WAIT → poll 空等 6s)。
*/
void SpiMaster::setSuppressRingService(bool suppress)
{
m_suppressRingService = suppress;
taskENTER_CRITICAL(&s_spiJobMux);
uint32_t refs = m_ringSuppressRefs;
if (suppress) {
++refs;
} else if (refs > 0U) {
--refs;
}
m_ringSuppressRefs = refs;
const bool now_suppressed = (refs > 0U);
taskEXIT_CRITICAL(&s_spiJobMux);
m_suppressRingService = now_suppressed;
if (now_suppressed) {
/* 丢弃已锁存的按铃标志,避免解除后立刻误读残留边沿 */
m_gd32RequestPending = false;
}
}
/**
* @brief ESP32 自我 OTA 期间暂停/恢复全部 SPI 活动。
*
* 暂停:worker 空转不读 SPI(GD32 按铃丢弃),submitJob 快速拒绝
* (业务调用立即返回 ESP_ERR_INVALID_STATE,不排队不阻塞)。
* 恢复:唤醒 worker 恢复正常。幂等,可多次调用。
*/
void SpiMaster::setSpiPaused(bool paused)
{
m_spiPaused = paused;
if (paused) {
/* 丢弃已锁存的按铃标志:暂停期间不读 GD32,解除后勿误读残留边沿 */
m_gd32RequestPending = false;
}
if (m_workerTask != nullptr) {
xTaskNotify(m_workerTask, 0x10U, eSetBits); /* 唤醒 worker 处理状态变化 */
}
}
/**
* @brief 业务侧确认本端 TX 序号已被对端接受,推进 m_txSeq。
* @note 在收到明确业务 ACK 后调用,避免重发与序号失配。
@@ -220,13 +307,15 @@ esp_err_t SpiMaster::serviceRingOnce()
return ESP_ERR_NOT_FOUND;
}
/* 仅 worker 调用。有 pending 或 DATA_READY 低时读一帧 EVENT。 */
if (!m_gd32RequestPending) {
/* 无 ISR 标志时,若线已低仍尝试读(丢边沿兜底) */
/* 仅 worker 调用。有 pending 或 DATA_READY 低时读一帧 EVENT。
* ★ pending 也必须验电平:业务响应(PING/OTA ACK)拉低 PB6 时链路忙,
* ISR 误记 pending;响应被 poll 读走后 PB6 已回高,若不验电平会白等
* waitSpiDataReady(3ms) 超时,把每笔业务周期拖慢 ~3ms(测速直方图
* 79% 帧堆在 3-4ms 的根因)。真按铃时 PB6 在被读走前保持低,不误伤。 */
if (readReadyLevelRaw() != 0) {
m_gd32RequestPending = false;
return ESP_ERR_NOT_FOUND;
}
}
m_gd32RequestPending = false;
m_linkState.enterBusy(HandshakeState::SERVING_GD32_RING);
@@ -271,11 +360,7 @@ 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;
}
correctRoleFromType(found_type, "ring");
(void)pushEvent(found_type, found_payload, found_len);
}
return ESP_OK;
@@ -421,6 +506,10 @@ void SpiMaster::finishPoolJob(Job *job)
*/
esp_err_t SpiMaster::submitJob(Job &job)
{
/* ESP32 自我 OTA 暂停:快速拒绝,不排队不等待(worker 空转中)。 */
if (m_spiPaused) {
return ESP_ERR_INVALID_STATE;
}
if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) {
executeJob(job);
return job.result;
@@ -522,6 +611,15 @@ void SpiMaster::workerLoop()
DEBUG_INFO("SPI", "SPI worker started, handle=%p", (void *)m_workerTask);
for (;;) {
/* ESP32 自我 OTA 暂停:空转不读 SPI(flash 写期间 cache 失效会拖慢
* flash 驻留代码,且此时 GD32 数据无意义)。恢复由 setSpiPaused(false)
* 通知唤醒。 */
if (m_spiPaused) {
m_gd32RequestPending = false;
uint32_t bits = 0U;
(void)xTaskNotifyWait(0U, 0xFFFFFFFFU, &bits, pdMS_TO_TICKS(50));
continue;
}
/* ★ 只在 ISR 置位 m_gd32RequestPending 时读 EVENT。
* 禁止见 DATA_READY 低就 serviceRing:命令 HELLO_ACK/OTA 响应也会拉低
* PB6,会被偷走当事件 → 业务 poll 全 0xFF,OTA/ENTER_BOOT 全挂。
@@ -534,7 +632,16 @@ void SpiMaster::workerLoop()
Job *job = nullptr;
if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(1)) == pdTRUE && job != nullptr) {
/* 计时 worker 实际执行段(取帧→发→读响应→完成):
* 测速页 RTT − 本值 = 提交/调度/唤醒开销,用于区分
* "ESP32 侧任务抢占" vs "GD32 应答慢" 的抖动归属。 */
const int64_t w0 = esp_timer_get_time();
m_lastProfile.t_w0 = (uint64_t)w0;
executeJob(*job);
const int64_t w1 = esp_timer_get_time();
m_lastProfile.t_w1 = (uint64_t)w1;
const int64_t wus = w1 - w0;
m_lastWorkerExecUs = (wus > 0) ? (uint32_t)wus : 0U;
finishPoolJob(job);
continue;
}
@@ -557,8 +664,11 @@ void SpiMaster::workerLoop()
* @brief GPIO 边沿中断回调(DATA_READY / PB6)。
*
* 仅处理下降沿:GD32 有待读数据(按铃)。上升沿不再使用(旧的"等上升沿
* ready"逻辑已删除,现由 waitSpiDataReady 做电平轮询)。
* ISR 内仅做标志翻转 + 任务通知,不做 SPI 传输。
* ready"逻辑已删除)。
* ISR 内仅做标志翻转 + 信号量/任务通知,不做 SPI 传输。
*
* 所有下降沿先 give s_readyEdgeSem:唤醒阻塞在 waitSpiDataReady 的任务
* (OTA 响应等待),把原来 1ms tick 轮询变成 µs 级事件驱动。
*
* 本端忙时(acceptRing()==false):记下 pending 但不 notify,避免抢断
* 当前 SPI 事务;事务结束 worker 下一轮会立即消费此标志,省去最多 25ms
@@ -572,6 +682,13 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg)
if (readReadyLevelRaw() != 0) {
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) {
/* OTA 中继:PB6 低是 WAIT/ACK 就绪,留给业务 poll,不按 EVENT 处理 */
return;
@@ -594,6 +711,8 @@ void IRAM_ATTR SpiMaster::onReadyEdgeISR(void *arg)
/**
* @brief 等 GD32 拉低 PB6(有待读响应)。
* v2 DATA_READY:低 = 有数据;高 = 队列空。TX 前不等 HIGH(CS 即互斥)。
* 事件驱动:阻塞在 s_readyEdgeSem 上,由下降沿 ISR 精确唤醒(µs 级),
* 每次醒来重查电平以过滤陈旧 give;信号量不可用时退回 1ms tick 轮询。
* @return ESP_OK 已低;ESP_ERR_TIMEOUT 超时仍为高(调用方可再决定是否盲 poll)。
*/
static esp_err_t waitSpiDataReady(uint32_t timeout_ms)
@@ -602,11 +721,16 @@ static esp_err_t waitSpiDataReady(uint32_t timeout_ms)
if (timeout_ms == 0U) { return ESP_ERR_TIMEOUT; }
const TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms);
while (xTaskGetTickCount() < deadline) {
for (;;) {
if (readReadyLevelRaw() == 0) { return ESP_OK; }
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;
}
}
// ============================================================
@@ -674,7 +798,9 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
t.tx_buffer = tx_ptr;
t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
/* SPI 事务等待使用有界超时 */
/* 中断模式(queue+result 信号量):2026-08-02 实测 polling 模式在 C3 单核上
* 整体更慢——传输段虽略快(-14~-17µs),但 worker 忙等占死 CPU,提交/调度
* 13→140µs、唤醒 18→76µs、2-5ms 尾巴 23→129 帧,RTT 658→822µs。回退。 */
const uint32_t effective_ms = (timeout_ms == 0U) ? 500U
: (timeout_ms < 50U ? 50U : timeout_ms);
const TickType_t queue_wait = pdMS_TO_TICKS(effective_ms);
@@ -731,7 +857,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
* 时钟预算见 spiRespReadBudget():BOOT=128,APP=256。
*/
esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms)
uint32_t timeout_ms, size_t budget_hint)
{
if (out_frame == nullptr || out_len == nullptr || out_max < V2_HEADER_LEN) {
return ESP_ERR_INVALID_ARG;
@@ -743,6 +869,9 @@ esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_
*out_len = 0U;
size_t budget = spiRespReadBudget();
if (budget_hint > 0U && budget_hint < budget) {
budget = budget_hint; /* 主动 poll 按请求类型收紧,省 0xFF 填充时钟 */
}
if (budget > out_max) {
budget = out_max;
}
@@ -968,6 +1097,14 @@ esp_err_t SpiMaster::init()
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 服务(全局仅安装一次)并注册边沿回调 */
static bool s_isrInstalled = false;
if (!s_isrInstalled) {
@@ -1037,7 +1174,11 @@ esp_err_t SpiMaster::init()
}
}
if (m_workerTask == nullptr) {
BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", TASK_STACK_SPI_WORKER, this, 6, &m_workerTask);
/* 优先级 8(2026-08-02 测速实验):原 6 会被 ota_feed(7) 抢占,
* SPI 完成 ISR 唤醒 worker 后还要等 feed 让出,拖长 RTT 唤醒链。
* 8 仍低于 health_mon(21)/ota_relay(23);worker 阻塞循环为主,
* 仅 job 执行时运行,不会饿死 ota_feed/httpd。 */
BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", TASK_STACK_SPI_WORKER, this, 8, &m_workerTask);
if (ok != pdPASS || m_workerTask == nullptr) {
DEBUG_ERR("SPI", "SPI worker task create failed");
m_workerTask = nullptr;
@@ -1092,6 +1233,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
if (!otaBuildFrame(type, payload, payload_len, tx_frame, &tx_len)) {
return ESP_ERR_INVALID_ARG;
}
/* 记录最近一次主动请求类型:poll 读预算按它收紧(isShortRespType)。 */
m_lastReqType = type;
DEBUG_DBG("SPI", "OTA TX: type=0x%02X len=%u", type, (unsigned)tx_len);
@@ -1162,17 +1305,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
/* 与 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) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
}
}
if (resp_len) *resp_len = found_len;
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
m_gd32Role = V2_ROLE_APP;
}
copyFoundFrame(resp_type, found_type, resp_payload, resp_max, found_payload, found_len, resp_len);
correctRoleFromType(found_type, "send");
/* expect_stale(两步法 step1)读到的是延迟一拍陈旧帧,序号由 poll 成功时推进。
* 此处若自增会导致 job 间隙/超时后 resend 使用错误 TX_SEQ。 */
if (!expect_stale) {
@@ -1254,7 +1388,12 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
? ((timeout_ms >= 80U) ? 40U : timeout_ms)
: 15U;
const uint32_t wait_ms = (ready_slice < remain_ms) ? ready_slice : remain_ms;
/* 分段剖析:等 READY 段 = GD32 收到帧→拉低 PB6 的处理时间 */
m_lastProfile.t_wait0 = (uint64_t)esp_timer_get_time();
const bool ready = (waitSpiDataReady(wait_ms) == ESP_OK);
if (ready) {
m_lastProfile.t_wait1 = (uint64_t)esp_timer_get_time();
}
now = xTaskGetTickCount();
remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U;
@@ -1271,7 +1410,12 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
uint8_t rx_raw[SPI_OTA_FRAME_MAX];
size_t rx_len = 0U;
esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, spi_timeout);
/* 读预算按最近请求类型收紧(短响应类型 96B,其余按角色默认) */
const size_t budget_hint = isShortRespType(m_lastReqType) ? kShortRespReadBudget : 0U;
esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, spi_timeout, budget_hint);
if (ret == ESP_OK) {
m_lastProfile.t_read1 = (uint64_t)esp_timer_get_time();
}
did_spi_read = true;
if (ret != ESP_OK) {
if (ret == ESP_ERR_INVALID_RESPONSE) {
@@ -1315,11 +1459,7 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
}
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;
}
correctRoleFromType(found_type, "poll");
DEBUG_DBG("SPI", "poll skip EVENT type=0x%02X", found_type);
vTaskDelay(pdMS_TO_TICKS(1));
continue;
@@ -1333,16 +1473,7 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
continue;
}
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
} else if (copy_len > 0) {
memset(resp_payload, 0, copy_len);
}
}
if (resp_len) *resp_len = found_len;
copyFoundFrame(resp_type, found_type, resp_payload, resp_max, found_payload, found_len, resp_len);
/* WAIT/ACK/ERROR:只要 ack_seq 匹配,GD32 已消费本 TX_SEQ,必须推进。
* ★ 电机 OTA:feedData 缓冲满回 WAIT 时若不清 seq,ESP32 同 seq 重发
@@ -1397,7 +1528,11 @@ esp_err_t SpiMaster::transact(uint8_t type, const uint8_t *payload, size_t paylo
job.out_payload = resp_payload;
job.out_max = resp_max;
job.out_len = resp_len;
return submitJob(job);
/* 分段剖析:记录调用方侧两个时间戳(worker 侧在 workerLoop/otaSendAndPoll 内写) */
m_lastProfile.t_submit0 = (uint64_t)esp_timer_get_time();
const esp_err_t p_ret = submitJob(job);
m_lastProfile.t_submit1 = (uint64_t)esp_timer_get_time();
return p_ret;
}
return otaSendAndPoll(type, payload, payload_len,
resp_type, resp_payload, resp_max, resp_len, timeout_ms);
@@ -1480,23 +1615,58 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
poll_timeout = 1U;
}
/* 统一重发机制(嵌入 worker,所有业务共用,调用方无需各自重试):
* 仅当"没读到任何有效响应"时同 TX_SEQ 重发(失败路径 seq 不推进):
* - 发送级失败(transferClocked 错误/credit 拒绝):帧大概率未送达 GD32;
* - poll 超时(无响应):帧可能未送达或响应丢失,重发靠 GD32 DUP-SEQ 去重兜底。
* 读到任何帧(WAIT/ACK/ERROR/陈旧)即成功返回,不重发:
* - 保证 GD32 明确拒绝(ERROR 0x82)天然不重试(如 CAN 负载高/BUS_OFF);
* - OTA relay 的 WAIT 语义、旧帧排空语义均不受影响。 */
constexpr uint32_t kTxRetryMax = 3U; /* 总尝试次数(1 次首发 + 2 次重发) */
for (uint32_t attempt = 0U; attempt < kTxRetryMax; ++attempt) {
m_lastProfile.t_send0 = (uint64_t)esp_timer_get_time();
esp_err_t send_ret = otaTransferUnlocked(type, payload, payload_len,
nullptr, nullptr, 0U, nullptr, send_timeout,
/*expect_stale=*/true);
m_lastProfile.t_send1 = (uint64_t)esp_timer_get_time();
if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) {
if (attempt + 1U < kTxRetryMax) {
DEBUG_DBG("SPI", "TX retry(%u/%u) send fail type=0x%02X %s",
(unsigned)(attempt + 1U), (unsigned)kTxRetryMax,
type, esp_err_to_name(send_ret));
vTaskDelay(pdMS_TO_TICKS(3));
continue;
}
return send_ret;
}
/* 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));
/* CS 释放后的静默期分型处理:
* - 0xD1(电机 OTA 数据)/ 0xA1(APP PING):GD32 APP 在 onCsRelease ISR
* 内 µs 级装好响应并拉低 PB6 → 零盲等,waitSpiDataReady 由下降沿 ISR
* 精确唤醒,旧 1ms 固定延时压制喂速~47KB/s,现由事件驱动提速。
* - 其他类型(GD32 relay/BOOT AES/HELLO 等):保留 5ms——BOOT 在主循环
* 处理 CHALLENGE/flash,poll 前 2 轮盲读 CS 过早打进去会打断其
* EXTI/DMA 时序(实测去掉后 CHALLENGE 超时)。 */
if (type != 0xD1U && type != 0xA1U) {
vTaskDelay(pdMS_TO_TICKS(5));
}
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout,
/*expected_type=*/0xFFU);
esp_err_t poll_ret = otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len,
poll_timeout, /*expected_type=*/0xFFU);
if (poll_ret == ESP_OK) {
return ESP_OK; /* 已读到有效响应帧(ACK/ERROR/陈旧) */
}
if (attempt + 1U < kTxRetryMax) {
DEBUG_DBG("SPI", "TX retry(%u/%u) no resp type=0x%02X %s",
(unsigned)(attempt + 1U), (unsigned)kTxRetryMax,
type, esp_err_to_name(poll_ret));
vTaskDelay(pdMS_TO_TICKS(3));
continue;
}
return poll_ret;
}
return ESP_ERR_TIMEOUT; /* 不可达:循环必有返回 */
}
/**
+60 -1
View File
@@ -100,6 +100,31 @@ public:
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
/** @brief 最近一次 worker job 实际执行耗时(µs,worker 内计时)。
* 测速页用 RTT − 本值拆出"提交/调度/唤醒"开销,定位抖动归属。 */
uint32_t lastWorkerExecUs() const { return m_lastWorkerExecUs; }
/**
* @brief 单次 transact 的分段耗时剖析(测速页诊断用)。
* 5 段:提交/调度(t_submit0→t_w0)、发送帧(t_send0→t_send1)、
* 等应答(t_wait0→t_wait1)、读响应(t_wait1→t_read1)、唤醒(t_w1→t_submit1)。
* 写入顺序由 done 信号量保证 happens-before(worker 先写全再 give,调用方 take 后读)。
*/
struct SpiProfile {
uint64_t t_submit0 = 0; ///< 调用方 transact 入口
uint64_t t_w0 = 0; ///< worker executeJob 开始
uint64_t t_send0 = 0; ///< 发送帧开始
uint64_t t_send1 = 0; ///< 发送帧结束
uint64_t t_wait0 = 0; ///< 进入 poll(等 READY)
uint64_t t_wait1 = 0; ///< READY 就绪(wait 段结束)
uint64_t t_read1 = 0; ///< 响应帧读取完成
uint64_t t_w1 = 0; ///< worker executeJob 结束
uint64_t t_submit1 = 0; ///< 调用方 submitJob 返回
};
/** @brief 最近一次 transact 的剖析快照(仅测速/诊断用)。 */
SpiProfile lastProfile() const { return m_lastProfile; }
/** @brief SPI 任务队列当前待处理 job 数(固定 Job pool 槽,无堆分配)。
* 供 /system/heap_low_log 堆低水位快照采样,定位"低谷是不是 SPI 队列堆积引起"。 */
UBaseType_t getPendingJobCount() const { return m_jobQueue ? uxQueueMessagesWaiting(m_jobQueue) : 0; }
@@ -107,9 +132,18 @@ public:
/**
* @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。
* 中继开始设 true,结束/复位设 false。
* 实现为引用计数:GD32 中继与电机 OTA feed 可并发持有,任一持有者
* 退出都不会提前解除抑制(避免电机 OTA 收尾把 GD32 中继的抑制误清)。
*/
void setSuppressRingService(bool suppress);
/**
* @brief ESP32 自我 OTA 期间暂停全部 SPI 活动(GD32 按铃读 + 业务 transact)。
* 暂停后 worker 空转不读 SPI,submitJob 快速返回 ESP_ERR_INVALID_STATE
* (业务立即失败,不阻塞排队);恢复时唤醒 worker 恢复正常。幂等。
*/
void setSpiPaused(bool paused);
/**
* @brief 业务层确认当前请求已成功时推进/对齐 m_txSeq。
*/
@@ -282,12 +316,22 @@ private:
SpiLinkState m_linkState;
/** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,仅 SPI worker 消费。 */
volatile bool m_gd32RequestPending = false;
/** ring service 抑制引用计数(setSuppressRingService 内部使用,临界区保护)。 */
uint32_t m_ringSuppressRefs = 0;
/** OTA 中继时为 true:worker 不 serviceRing,避免偷读 OTA ACK/WAIT。 */
volatile bool m_suppressRingService = false;
/** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */
volatile bool m_spiPaused = false;
/** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */
volatile uint8_t m_lastReqType = 0xFFU;
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */
TaskHandle_t m_spiTaskHandle = NULL;
TaskHandle_t m_workerTask = NULL;
QueueHandle_t m_jobQueue = NULL;
/** 最近一次 job 执行耗时(µs,worker 写入、业务只读;单核 32 位读原子)。 */
uint32_t m_lastWorkerExecUs = 0;
/** 最近一次 transact 分段剖析(worker 与调用方交叉写入,done 信号量同步)。 */
SpiProfile m_lastProfile;
/** 事件计数信号量(worker push / takeEvent pop)。 */
SemaphoreHandle_t m_eventSem = NULL;
@@ -405,6 +449,19 @@ private:
/** @brief GPIO 边沿中断回调(注册到 ESP-IDF GPIO ISR 服务)。 */
static void IRAM_ATTR onReadyEdgeISR(void *arg);
/** @brief APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type);
// ── 去重辅助 ──
/** @brief 把 otaFindFrame 找到的帧拷贝到调用方输出缓冲(安全截断)。 */
static void copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
uint8_t *resp_payload, size_t resp_max,
const uint8_t *found_payload, size_t found_len,
size_t *resp_len);
/** @brief 见到 APP 独有 TYPE 时纠正粘滞的 boot role(3 处调用合并)。 */
void correctRoleFromType(uint8_t found_type, const char *src);
// 内部 SPI 传输
/**
* @brief 执行底层 SPI 时钟同步传输。
@@ -434,9 +491,11 @@ private:
* @param out_max 输出缓冲容量
* @param out_len 实际帧长
* @param timeout_ms 单次 SPI 超时
* @param budget_hint 读预算提示(0=按角色默认;>0 时取 min(hint, 角色预算),
* 主动 poll 按最近请求类型收紧,省 0xFF 填充时钟)
*/
esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms);
uint32_t timeout_ms, size_t budget_hint = 0U);
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
/**
+4 -2
View File
@@ -12,8 +12,10 @@ CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000
# 不要再开 QIO,除非确认 flash 型号与硬件连接均兼容。
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
# DCU SPI clock -- 4 MHz(GD32 SPI0 RX DMA 武装后可安全提速;8/12MHz 须零错误验证后再开)
CONFIG_DCU_SPI_CLOCK_HZ=4000000
# DCU SPI clock -- 13.3 MHz(2026-08-02:80MHz/6 整除,比 12M 再省 ~8%;
# 半周期 37.5ns,MISO 输出延迟 + GPIO 矩阵延迟 ~45ns 预算已近上限,需实测;
# ≥16MHz 全双工不可行。写 13333333:若写 13000000 驱动取整 80/7=11.4M 反而更慢)
CONFIG_DCU_SPI_CLOCK_HZ=13333333
# GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压
# (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发)
+23
View File
@@ -0,0 +1,23 @@
@echo off
REM Clear MSYS/MINGW env vars to bypass IDF MSys detection when called from Git Bash
set MSYSTEM=
set MSYSTEM_CHOST=
set MSYSTEM_PREFIX=
set MINGW_CHOST=
set MINGW_PREFIX=
set MINGW_PACKAGE_PREFIX=
set CHERE_INVOKING=
set MSYS2_PATH_TYPE=
REM Toolchain installed at C:\Espressif (see .vscode/settings.json idf.toolsPath)
set IDF_TOOLS_PATH=C:\Espressif
call "F:\software\.espressif\v6.0\esp-idf\export.bat"
if errorlevel 1 (
echo EXPORT_FAILED
exit /b 1
)
cd /d "G:\Robot_v2\DCU\ESP32_COMM"
idf.py build
exit /b %errorlevel%
+3 -1
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
@@ -327,6 +329,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/can.js?v=20260727a"></script>
<script type="module" src="js/can.js?v=20260801f"></script>
</body>
</html>
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 接收监控</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
+2
View File
@@ -247,6 +247,8 @@ label { display: block; }
}
.nc-mode.mode-off { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg-muted); }
.nc-mode.mode-fault { background: var(--danger-dim); color: var(--danger); }
/* 节点在其他通道在线:橙色通道标签,提示切换顶部通道查看 */
.nc-mode.mode-other-ch { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.nc-status-text {
margin-left: auto; font-size: 0.68rem; color: var(--fg-muted);
font-family: var(--font-mono); max-width: 7.5em;
+2
View File
@@ -10,6 +10,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继控制台</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
+1 -1
View File
@@ -44,7 +44,7 @@ import {
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js';
} from './can/send.js?v=20260801f';
import {
scheduleCanPersist,
loadCanSession,
+22 -8
View File
@@ -14,7 +14,7 @@
*/
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.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 { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js';
import { scheduleCanPersist } from './persist.js';
@@ -124,12 +124,23 @@ async function sendCanBatchRequest(channel, frames) {
var n = frames && frames.length ? frames.length : 0;
/* 单帧设备侧 timeout 2s;多帧按帧数留余量 */
var timeoutMs = (n <= 1) ? 8000 : Math.max(15000, n * 500);
var resp = await fetch(getBaseUrl() + '/can/send_batch', {
/* AbortSignal.timeout 在连接被 reset 的边缘情况下偶发不触发,
* 叠加一条手动竞速超时,避免 await 永久挂起导致整批不渲染。 */
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, 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: AbortSignal.timeout(timeoutMs)
signal: ac.signal
});
} catch (err) {
throw new Error('请求失败: ' + (err && err.message ? err.message : err));
} finally {
clearTimeout(timer);
}
var text = await resp.text();
var json = {};
try {
@@ -231,12 +242,12 @@ export function renderHistory() {
// ==================== 批量发送 ====================
/**
* 单批最多帧数。实测一次发送 >9 帧失败率明显上升,故按 9 帧分批;
* 批次之间留 BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
* 单批最多帧数。按 64 帧分批(一次请求发 64 帧);批次之间留
* BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
*/
var BATCH_CHUNK = 32;
var BATCH_CHUNK = 64;
/** 批次间延时(ms),仅在还有后续批次时等待 */
var BATCH_INTER_CHUNK_DELAY_MS = 200;
var BATCH_INTER_CHUNK_DELAY_MS = 100;
/**
* @description 批量发送所有勾选的报文(仅 /can/send_batch)。
@@ -330,10 +341,13 @@ export async function sendCheckedMessages() {
renderHistory();
scheduleCanPersist();
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone, { force: true });
} finally {
state.batchInFlight = false;
updateBatchUi();
/* 兜底渲染:无论 try 内是否抛异常,都保证已写入的 silent 历史条目可见,
* 避免「批量发了但结果不显示」的偶发现象(renderHistory 幂等,多调一次无害)。 */
renderHistory();
}
}
+13 -5
View File
@@ -216,11 +216,19 @@ export async function pollStatus() {
if (window.isEsp32Rebooting ||
window.otaSessionState === window.OtaSessionState.REBOOTING) {
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询,
// 保持"重启中"状态,不清除标志,不推进阶段。
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner 触发;
// 这里只在已观察到下线(rebootObservedDown)或超 10s 安全阈值时接受恢复。
if (window.rebootObservedDown || rbElapsed >= 10) {
/* 重启完成的证据(任一即可):
* 1) rebootObservedDown:已观察到设备下线后恢复。
* 2) freshBoot:设备回报 uptime 很小(<120s,与 resolveEsp32VerifyAmbiguous
* 的 freshBoot 阈值一致)。★ 关键:设备掉线仅 3~5s 时(FAST_SCAN 生效后
* 常见),在途轮询的 TCP SYN 重传会在设备起来后完成握手、全部成功,
* rebootObservedDown 永远不置位,旧逻辑只能干等 10s 兜底——
* 实测设备 3s 就绪、页面却 10s 才恢复。uptime 是新固件启动的权威信号。
* 3) rbElapsed >= 10:安全兜底(兼容 uptime 缺失 / 设备未真重启)。
* pre-restart 窗口(finalize 响应后约 1s 设备仍在跑旧固件)uptime 很大,
* 不会误判为 freshBoot。 */
var upS = Number(data && data.uptime_sec);
var freshBoot = Number.isFinite(upS) && upS >= 0 && upS < 120;
if (window.rebootObservedDown || freshBoot || rbElapsed >= 10) {
markEsp32RebootComplete();
}
}
+8 -2
View File
@@ -12,14 +12,20 @@ const TOAST_AUTOHIDE_MS = 3000;
var _toastHideTimer = null;
var _toastLastKey = '';
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';
var toast = document.getElementById('toast');
if (!toast) return;
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
var key = type + '|' + message;
var now = Date.now();
if (key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
if (!(opts && opts.force) && key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
return;
}
_toastLastKey = key;
+176 -232
View File
@@ -58,12 +58,12 @@ const state = {
ip: DEFAULT_ESP_IP,
ch: 0,
chEnabled: [true, true, true], // 各通道启用状态(由 onFeedback 更新)
chOnline: [0, 0, 0], // 各通道在线电机数(由 onMotorState 按 m.ch 统计,驱动下拉框信号标记)
selectedNode: null, // 当前选中节点号
motors: [], // 最近一次 /motor/state 的 motors 数组
decoded: new Map(), // node -> 解码后的 STATUS 对象
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
* 锁相计算 computePhaseLockDelayMs() 失败时回退到此值,避免硬编码 250ms。
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。 */
refreshIntervalMs: 250,
lastSentMode: new Map(), // node -> 最近发送的 Control_Mode
@@ -82,25 +82,15 @@ const state = {
offlineSince: 0, // 进入离线时刻,短离线恢复不 toast
tickN: 0, // 轮询序号(每 5 轮附带 status)
statusTickN: 0, // status lite/full 交替计数(4 lite + 1 full 循环)
/* ── 锁相参数:把轮询沿对齐到 GD32 0xC5 推送后沿 ──
* GD32 由 CONFIG_TASK_PERIOD_MOTOR_STATE_TICKS 节拍驱动 250ms 推送(独立于 1Hz heartbeat),
* 与浏览器 setTimeout 不同源、不锁相,age_ms 会在 0~250 间拍频扫描。
* 这里用最近一次 age_ms + 本地 performance.now() 反推下一帧推送时刻,
* 把下一轮 poll 目标时刻钉在「GD32 刚推完 + 安全余量」处。 */
phase: {
motorPeriodMs: 250, // GD32 推送周期(motor state = 2500 × 100µs)
servoMs: 60, // 推送后沿安全余量(吸收 SPI/HTTP/mutex 抖动;周期短,余量相应减小)
haveSync: false, // 是否已建立相位基准
lastAgeMs: null, // 最近一次 /motor/state 返回的 age_ms
lastRecvPerfMs: 0, // 对应响应到达时的 performance.now()
},
/* ── age 显示平滑:原始 age 对丢帧极敏感(丢 1 帧 → age 跳到 ~500ms),
* 用滚动窗口中位数显示,反映「稳态相位」而非瞬时抖动。原始值仍可在
* state.phaseDebug.history 查看 / 控制台诊断。 */
ageWindow: [], // 最近 N 次原始 age_ms(按到达顺序)
ageLossCount: 0, // 窗口内 age > motorPeriodMs*1.5 的次数(近似丢帧计数)
phaseDebug: { lastSlowReq: null, slowStats: {} }, // 慢请求诊断(>200ms)
};
/* 控制台诊断入口:main.js 以 ES module 加载,state 等顶层变量不会挂到 window,
* 控制台直接敲 state 会报 "state is not defined"。这里显式暴露只读诊断快照。
* 用法:控制台敲 motorDbg.state 。 */
window.motorDbg = {
state, // 完整 state 引用,方便深挖
};
/**
* 连续「硬失败」多少次才判离线。
* HTTP 500/503 busy、超时 一律视为瞬时,不计入、不改 LED。
@@ -109,8 +99,7 @@ const FAIL_THRESHOLD = 5;
const CONN_TOAST_GAP_MS = 6000;
/** 离线不足此时长就恢复:不弹「连接已恢复」(过滤毛刺) */
const RECOVER_TOAST_MIN_OFFLINE_MS = 8000;
/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。
* 锁相成功时由 computePhaseLockDelayMs() 覆盖;此值仅作未同步/异常回退。 */
/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。 */
const TICK_INTERVAL_MS = 250;
const PROBE_INTERVAL_MS = 2000;
@@ -178,8 +167,8 @@ function loadUiState() {
* 此时 polling 尚未启动,直接写 state 即可,无需重排定时器) */
const rr = parseInt(o.refreshRate, 10);
if (Number.isFinite(rr)) {
/* 与 #refreshRate min/max 一致:最快对齐 GD32 250ms,最慢 5s */
const clamped = clampNum(rr, 250, 5000);
/* 与 #refreshRate min/max 一致:最快 50ms(20Hz),最慢 5s */
const clamped = clampNum(rr, 50, 5000);
state.refreshIntervalMs = clamped;
const el = document.getElementById("refreshRate");
if (el) el.value = clamped;
@@ -591,21 +580,15 @@ function recordSlowReq(path, durMs, errMsg) {
};
}
function noteHardSuccess() {
state.failCount = 0;
}
/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */
function noteHardSuccess() { state.failCount = 0; }
function noteHardFailure(e, source) {
if (state.probing || state.connecting) return;
/* 瞬时错误:完全静默,连 failCount 都不动,避免 LED/toast 毛刺 */
if (isTransientError(e)) return;
state.failCount += 1;
const msg = (e && e.message) ? e.message : String(e || "请求失败");
if (state.connected && state.failCount < FAIL_THRESHOLD) {
/* 在线且未达阈值:UI 完全不动(不再把 LED 打成黄灯) */
return;
if (e && e.transient) return; /* 500/503/超时等瞬时错误不计入 */
state.failCount++;
if (state.failCount >= FAIL_THRESHOLD && state.connected) {
enterDisconnected(e && e.message, source);
}
enterDisconnected(msg, source);
}
function enterDisconnected(msg, source) {
@@ -677,7 +660,7 @@ async function probeOnce() {
* 使用主页共享 IP 自动连接(本页无连接按钮)。
* 失败不阻断页面,进入探测直到 /system/status 可用。
*/
async function connectShared() {
function connectShared() {
state.ip = getSharedDeviceIp();
state.connecting = true;
state.probing = false;
@@ -687,29 +670,10 @@ async function connectShared() {
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent =
"自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP,请在主页配置并测试连接";
try {
const data = await fetchDeviceJson("/system/status", 4000);
state.connecting = false;
state.connected = true;
state.failCount = 0;
state.offlineSince = 0;
$("#netBanner").classList.remove("show");
applySystemStatus(data);
updateConnUi();
startPolling();
connToast("已连接 " + state.ip + " · /system/status 正常", "ok");
} catch (e) {
state.connecting = false;
state.connected = false;
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent =
"无法连接 " + state.ip + " — 自动重试中。请先到 OTA 主页配置 IP 并点「测试」";
connToast("自动连接失败:" + (e.message || "无法访问 " + state.ip) + " — 将持续探测", "warn");
"自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP";
/* HTTP 探测驱动连接:/system/status 可用 → startPolling */
startProbe();
}
}
function applySystemStatus(data) {
const wifi = (data && data.wifi) || {};
@@ -762,36 +726,37 @@ function stopTimer(key) {
* 双链路轮询:motor 和 feedback 各自独立定时器,互不阻塞。
*
* 历史问题:原来 motor → feedback 串行,任一挂起(ESP32 端 mutex 阻塞 /
* WiFi 抖动)会拖累另一个,表现为"age 不动 + 数据卡顿"。
* 现在:motor 走锁相定时器,feedback 走固定 250ms 定时器,
* WiFi 抖动)会拖累另一个,表现为数据卡顿。
* 现在:motor 和 feedback 各走独立固定节拍定时器,
* - motor 挂起 3s 超时期间,feedback 仍正常刷
* - feedback 挂起不影响 motor 锁相
* - feedback 挂起不影响 motor
* 两者共用 ESP32 端 httpJsonWorkMutex,ESP32 串行处理即可。
*/
function stopDataPolling() {
state.pollGen += 1; /* 使在途 pollLoop 退出后不再续约 */
state.pollGen += 1;
stopTimer("tick");
stopTimer("feedback");
stopTimer("status");
state.tickInFlight = false;
state.feedbackInFlight = false;
}
/**
* 启动数据链路:HTTP 三链(motor tick / feedback 1Hz / status 1Hz)立即开车。
*/
function startPolling() {
stopDataPolling();
stopDataPolling(); /* 递增 pollGen,清残留定时器/在途链 */
state.failCount = 0;
state.tickN = 0;
state.statusTickN = 0;
const gen = state.pollGen;
schedulePollLoop(gen, 0);
scheduleFeedbackPoll(gen, 100);
/* status 独立定时器:与 motor/feedback 解耦,避免 ESP32 端 httpJsonWorkMutex
* 被 system_status_handler 长时间占用时拖慢 motor/feedback 轮询。
* - lite(?lite=1):轻量路径,~150B,只返回 wifi + gd32_features,
* 不调 heap_caps_get_info / 任务栈遍历。1s 一次(高频)。
* - full:完整路径,~3KB,含 OTA/heap/SPI 等。5s 一次(低频)。
* 两条路径交替:lite, lite, lite, lite, full, lite, lite, lite, lite, full, …
* 这样完整 status 阻塞 httpd 的频率从 2s/次 降到 5s/次,pending 显著减少。 */
scheduleFeedbackPoll(gen, 0);
scheduleStatusPoll(gen, 1000);
/* 连上后把持久化的刷新速度同步给 GD32,使 0xC5 推送周期与本地轮询一致(无需用户重新拨动) */
sendMotorPeriod(state.refreshIntervalMs || 250);
}
function scheduleStatusPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("status");
@@ -835,54 +800,13 @@ function scheduleFeedbackPoll(gen, delayMs) {
function schedulePollLoop(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("tick");
/* 锁相模式:delayMs 省略时,根据最近一次 age_ms 反推下一帧推送时刻,
* 把 poll 沿钉到「GD32 推送后沿 + servoMs」处。任何异常都退回固定节拍。 */
let delay = delayMs;
if (delay == null) {
delay = computePhaseLockDelayMs();
}
const delay = delayMs != null ? delayMs : (state.refreshIntervalMs || TICK_INTERVAL_MS);
state.poll.tick = setTimeout(function () {
state.poll.tick = null;
pollLoop(gen);
}, delay);
}
/**
* 根据最近一次 /motor/state 的 age_ms + 本地时钟,计算下一轮 motor 轮询延迟。
*
* 数据源 GD32 0xC5 固定 ~250ms 推送,浏览器无法拿到比源更新的数据:
* - 用户间隔 ≤ 推送周期:按 1×period 锁相(最快 250ms)
* - 用户间隔 > 推送周期:按 N×period 抽稀(N = round(user/period))
* 这样改「刷新速度」到 500/1000/2000 会真实变慢,而不是被锁相钉死在 250。
* 上一帧推送本地时刻 = lastRecvPerfMs - lastAgeMs。
* 无同步样本时退回 max(userMs, period)。
*/
function computePhaseLockDelayMs() {
const ph = state.phase;
const period = ph.motorPeriodMs || TICK_INTERVAL_MS;
const userMs = state.refreshIntervalMs || TICK_INTERVAL_MS;
/* 不能比 GD32 推送更快;更慢则取整到 N 个推送周期 */
const n = Math.max(1, Math.round(userMs / period));
const targetPeriod = n * period;
if (!ph.haveSync || ph.lastAgeMs == null || ph.lastRecvPerfMs <= 0) {
return targetPeriod;
}
const now = performance.now();
const lastPushPerfMs = ph.lastRecvPerfMs - ph.lastAgeMs;
let nextPushPerfMs = lastPushPerfMs + targetPeriod;
while (nextPushPerfMs <= now) {
nextPushPerfMs += targetPeriod;
}
const target = nextPushPerfMs + ph.servoMs;
let delay = target - now;
/* clamp:太小会贴着推送沿被抖到沿前;太大说明相位估计已漂远,
* 退回固定节拍等下一轮重新对齐。 */
if (!Number.isFinite(delay) || delay < 30 || delay > targetPeriod + 200) {
return targetPeriod;
}
return delay;
}
/**
* motor 轮询主链:只跑 fetchMotorState,feedback/status 已剥离独立定时器。
* motor 挂起 3s 超时期间,feedback 仍正常刷 CAN 统计(不再被 motor 拖)。
@@ -901,8 +825,6 @@ async function pollLoop(gen) {
} finally {
state.tickInFlight = false;
if (gen === state.pollGen && state.connected && !state.probing) {
/* 不传 delayMs → schedulePollLoop 用锁相计算;
* 失败轮(noteHardFailure 已处理)或尚未同步时退回固定节拍。 */
schedulePollLoop(gen);
}
}
@@ -1042,74 +964,45 @@ async function fetchFeedback(gen) {
}
/* ═══════════════════════ /motor/state 处理 ═══════════════════════ */
/* 后端表已改 node×channel 双键(同节点号可同时在多通道在线),
* motors[] 可能含同 node 不同 ch 的多条记录,decoded 缓存键也必须带通道。 */
function motorKey(m) { return m.ch + ":" + m.node; }
function onMotorState(data) {
const motors = (data && data.motors) ? data.motors : [];
state.motors = motors;
/* 锁相采样:记录 age_ms 与本地高精度时钟,供 schedulePollLoop 反推
* GD32 下一次推送时刻。age 异常(null / UINT32_MAX 上界 / 负数)时
* 不更新基准,schedulePollLoop 会自动回退固定节拍。
* 上界 600 ≈ 2.4 × motorPeriodMs:覆盖 1 拍丢失后 age 跳到 ~500ms 的情形,
* 仍能维持锁相基准;超过 600 视为长时通信故障,退回固定节拍重学。 */
if (data && data.age_ms != null) {
const a = Number(data.age_ms);
if (Number.isFinite(a) && a >= 0 && a < 600) {
state.phase.lastAgeMs = a;
state.phase.lastRecvPerfMs = performance.now();
state.phase.haveSync = true;
}
}
/* age 显示平滑:滚动窗口采样 + 丢帧近似计数。
* age > motorPeriodMs*1.5 视为丢帧(GD32 ring 超时 / SPI 抢占 / ESP32 忙)。 */
if (data && data.age_ms != null) {
const a = Number(data.age_ms);
if (Number.isFinite(a) && a >= 0) {
const w = state.ageWindow;
w.push(a);
if (w.length > 12) w.shift();
if (a > state.phase.motorPeriodMs * 1.5) {
state.ageLossCount = (state.ageLossCount || 0) + 1;
}
}
}
/* 解码每个节点的 STATUS 帧 */
/* 解码每个 节点×通道 的 STATUS 帧 */
const decoded = new Map();
for (const m of motors) {
const bytes = hexToBytes(m.data);
const d = decodeStatus(bytes);
if (d) decoded.set(m.node, d);
if (d) decoded.set(motorKey(m), d);
}
state.decoded = decoded;
/* 按实际通道统计在线电机数:驱动通道下拉框信号标记 + 本页通道过滤视图 */
const chOnline = [0, 0, 0];
for (const m of motors) {
if (m.online && m.node >= 1 && m.node <= 9 && m.ch >= 0 && m.ch <= 2) chOnline[m.ch]++;
}
state.chOnline = chOnline;
updateChSelectOptions();
/* 固定节点 1–9:始终可选(含离线),便于电机已进 BOOT / 无 STATUS 时仍能刷写。
* 自动选择:当前选中已是 1–9 则保持;否则优先第一个在线,否则默认 1。 */
* 自动选择:当前选中已是 1–9 则保持;否则优先当前通道的在线节点,其次任意在线,否则默认 1。 */
let autoSelected = false;
if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) {
const online = motors.find((m) => m.online && m.node >= 1 && m.node <= 9);
const online = motors.find((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9)
|| motors.find((m) => m.online && m.node >= 1 && m.node <= 9);
state.selectedNode = online ? online.node : 1;
autoSelected = true;
}
renderNodeList();
renderStatusPanel();
refreshChannelViews();
updateNodeLabels();
if (autoSelected) restoreVersionUi(state.selectedNode); /* 自动切节点时回填缓存版本 */
/* age 显示:用滚动窗口中位数过滤丢帧瞬时跳变。
* 原始 age 在 GD32 丢一帧时会跳到 ~500ms,但稳态相位没变;
* 中位数反映「最近典型 age」,对单点跳变免疫。
* 丢帧计数单独显示,让用户能区分「相位漂移」和「丢帧」。 */
const onlineCount = motors.filter((m) => m.online && m.node >= 1 && m.node <= 9).length;
const w = state.ageWindow;
const medAge = w.length ? median(w) : null;
const lossN = state.ageLossCount || 0;
const ageText = medAge != null
? `age≈${Math.round(medAge)}ms` + (lossN ? ` · 丢帧${lossN}` : "")
: (data && data.age_ms != null ? `age ${data.age_ms}ms` : "age --");
$("#nodeSummary").textContent = `${onlineCount}/9 在线 · ${ageText}`;
/* BOOT 徽章:总线上有电机在 Bootloader(0x103 心跳)时显示,独立于 N1-N9。
* 0x103 固定 ID 不带节点号,故不归属任何 Nx,只作总线级 BOOT 提示。
* 同时写 class + style,避免任何残留内联 display 把 .show 盖掉。 */
@@ -1124,33 +1017,41 @@ function onMotorState(data) {
}
}
/* 滚动窗口中位数:复制后排序取中,避免污染原数组顺序。 */
function median(arr) {
if (!arr || !arr.length) return null;
const s = arr.slice().sort((a, b) => a - b);
const n = s.length;
return (n % 2) ? s[(n - 1) >> 1] : (s[n / 2 - 1] + s[n / 2]) / 2;
}
/* ─── 节点列表渲染 ───
* ★ 固定显示 N1–N9:即使离线/无 0xC5 推送也可见可选,方便电机在 BOOT 时刷写。
* 性能:首次全量重建 DOM;之后只增量更新文本/class。 */
const FIXED_MOTOR_NODES = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const _nodeCards = new Map(); // node -> { card, led, modeEl, stTxt, angleVal, velVal, curVal, faultsEl }
/** 把后端 motors[] 叠到固定 1–9 槽位;无数据的槽位记为 offline。 */
/** 把后端 motors[] 叠到固定 1–9 槽位;无数据的槽位记为 offline。
* ★ node×channel 双键:同节点号可能在多个通道各有一条记录。
* 优先级:当前通道在线 > 其他通道在线(otherCh 标注,如 "0/2")>
* 当前通道离线记录 > 空槽位。 */
function getDisplayMotors() {
const byNode = new Map();
for (const m of state.motors || []) {
if (m && m.node >= 1 && m.node <= 9) byNode.set(m.node, m);
}
const motors = state.motors || [];
return FIXED_MOTOR_NODES.map((node) => {
const m = byNode.get(node);
if (m) return m;
const entries = motors.filter((m) => m && m.node === node);
const cur = entries.find((m) => m.ch === state.ch);
if (cur && cur.online) return cur;
const others = entries.filter((m) => m.online && m.ch !== state.ch);
if (others.length) {
const chs = others.map((o) => o.ch).sort().join("/");
return { node, ch: others[0].ch, online: false, otherCh: chs, dlc: 0, rx_count: 0, seq: 0, data: "" };
}
if (cur) return cur;
return { node, ch: state.ch || 0, online: false, dlc: 0, rx_count: 0, seq: 0, data: "" };
});
}
/** 通道切换或电机数据更新后,按当前通道刷新节点列表 / STATUS 面板 / 在线汇总 */
function refreshChannelViews() {
renderNodeList();
renderStatusPanel();
const onlineCount = (state.motors || [])
.filter((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9).length;
$("#nodeSummary").textContent = `${onlineCount}/9 在线 · CH${state.ch}`;
}
function renderNodeList() {
const motors = getDisplayMotors();
@@ -1185,7 +1086,8 @@ function renderNodeList() {
for (const m of motors) {
const ref = _nodeCards.get(m.node);
if (!ref) continue;
const d = state.decoded.get(m.node);
/* otherCh 卡片不取解码数据:其 STATUS 属于其他通道,本通道视图不展示/不标故障 */
const d = m.otherCh != null ? null : state.decoded.get(motorKey(m));
const faultCount = faultCountOf(d);
const faultLabel = faultShortLabel(d);
const isSel = m.node === state.selectedNode;
@@ -1199,19 +1101,22 @@ function renderNodeList() {
if (ref.card.className !== cls) ref.card.className = cls;
ref.card.setAttribute("aria-selected", String(isSel));
if (faultLabel) ref.card.title = "故障: " + faultLabel;
else if (m.otherCh != null) ref.card.title = `节点在 CH${m.otherCh} 在线,切换顶部通道查看`;
else if (!m.online) ref.card.title = "离线也可选中刷写";
else ref.card.title = "在线";
const ledCls = m.online ? (faultCount > 0 ? "led-err" : "led-on") : "";
const ledCls = m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : "");
const ledFull = "led " + ledCls;
if (ref.led.className !== ledFull) ref.led.className = ledFull;
const sentMode = state.lastSentMode.get(m.node);
const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
/* motStatus 是报错位(1=故障),不是运行标志;无最近 CMD 时用 待机 */
const modeLabel = !m.online ? "离线" : (mode ? mode.zh : "待机");
/* motStatus 是报错位(1=故障),不是运行标志;无最近 CMD 时用 待机。
* otherCh:节点在其他通道在线,标注实际通道供用户切换。 */
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
if (ref.modeEl.textContent !== modeLabel) ref.modeEl.textContent = modeLabel;
const modeCls = !m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode");
const modeCls = m.otherCh != null ? "nc-mode mode-other-ch"
: (!m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode"));
if (ref.modeEl.className !== modeCls) ref.modeEl.className = modeCls;
const stTxt = !m.online ? "--" : (faultLabel || "OK");
@@ -1236,7 +1141,8 @@ function renderNodeList() {
/* 构建单节点卡片的 HTML(仅集合变化时调用) */
function buildNodeCardHtml(m) {
const d = state.decoded.get(m.node);
/* otherCh 卡片不取解码数据(同 renderNodeList 增量路径) */
const d = m.otherCh != null ? null : state.decoded.get(motorKey(m));
const faultCount = faultCountOf(d);
const faultLabel = faultShortLabel(d);
const isSel = m.node === state.selectedNode;
@@ -1248,17 +1154,19 @@ function buildNodeCardHtml(m) {
].join(" ").trim();
const sentMode = state.lastSentMode.get(m.node);
const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
const modeLabel = !m.online ? "离线" : (mode ? mode.zh : "待机");
const modeCls = !m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : "");
const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
const modeCls = m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : ""));
const stTxt = !m.online ? "--" : (faultLabel || "OK");
const cardTitle = faultLabel ? ("故障: " + faultLabel) : (m.online ? "在线" : "离线也可选中刷写");
const cardTitle = faultLabel ? ("故障: " + faultLabel)
: m.otherCh != null ? `节点在 CH${m.otherCh} 在线,切换顶部通道查看`
: (m.online ? "在线" : "离线也可选中刷写");
const angle = d && m.online ? d.rtAngle.toFixed(1) + "°" : "--";
const vel = d && m.online ? d.rtVelocity.toFixed(0) : "--";
const cur = d && m.online ? d.rtCurrent.toFixed(0) : "--";
return `<div class="${cls}" role="option" tabindex="0" data-node="${m.node}" aria-selected="${isSel}" title="${cardTitle}">
${faultCount > 0 ? `<div class="nc-faults">${"<span class='fault-dot'></span>".repeat(faultCount)}</div>` : ""}
<div class="nc-top">
<span class="led ${m.online ? (faultCount > 0 ? "led-err" : "led-on") : ""}"></span>
<span class="led ${m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : "")}"></span>
<span class="nc-id">N${m.node}</span>
<span class="nc-mode ${modeCls}">${modeLabel}</span>
<span class="nc-status-text" title="${faultLabel ? ("故障: " + faultLabel) : ""}">${stTxt}</span>
@@ -1310,8 +1218,13 @@ function flashEl(el) {
function renderStatusPanel() {
const n = state.selectedNode;
const motor = n != null ? state.motors.find((m) => m.node === n) : null;
const d = n != null ? state.decoded.get(n) : null;
/* ★ node×channel 双键:只展示当前通道的记录;节点在其他通道在线时给出切换提示 */
const entries = n != null ? state.motors.filter((m) => m.node === n) : [];
const cur = entries.find((m) => m.ch === state.ch);
const others = entries.filter((m) => m.online && m.ch !== state.ch);
const onOtherCh = !(cur && cur.online) && others.length > 0;
const motor = onOtherCh ? null : cur;
const d = (motor && motor.online) ? state.decoded.get(motorKey(motor)) : null;
const offline = !motor || !motor.online || !d;
$("#offlineOverlay").classList.toggle("show", offline);
@@ -1320,6 +1233,10 @@ function renderStatusPanel() {
if (!state.connected) {
$("#offlineTitle").textContent = "设备未连接";
$("#offlineSub").textContent = "本页共用 OTA 主页 IP,请到主页配置并测试连接";
} else if (onOtherCh) {
const chs = others.map((o) => "CH" + o.ch).sort().join(" / ");
$("#offlineTitle").textContent = `节点 ${n} 在 ${chs} 有信号`;
$("#offlineSub").textContent = `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`;
} else if (!motor || !motor.online) {
$("#offlineTitle").textContent = n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)";
$("#offlineSub").textContent = "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写";
@@ -1382,17 +1299,25 @@ function busStateText(bs) {
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
/** 根据通道启用状态更新下拉框选项样式,禁用通道贴 "(禁用)" 标签并置灰 */
/** 根据通道启用/信号状态更新下拉框选项:
* - 禁用通道贴 "(禁用)" 标签并置灰
* - 有信号通道(存在在线电机)贴 "●N" 标记并着色 */
function updateChSelectOptions() {
const sel = $("#chSelect");
if (!sel) return;
for (let i = 0; i < sel.options.length; i++) {
const opt = sel.options[i];
const en = state.chEnabled[i];
const cnt = state.chOnline[i] || 0;
const base = "CH" + i;
opt.textContent = en ? base : base + " (禁用)";
opt.style.color = en ? "" : "var(--fg-dim,#666)";
opt.textContent = !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base);
opt.style.color = !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : "");
}
/* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效),
* 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白 */
const curEn = state.chEnabled[state.ch];
const curCnt = state.chOnline[state.ch] || 0;
sel.style.color = !curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "");
}
function onFeedback(data) {
@@ -2247,8 +2172,14 @@ function startOtaPoll() {
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
if (st.state && st.state !== lastState) {
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}` +
(st.total_packets ? ` · ${st.packets_acked || 0}/${st.total_packets}` : ""));
(showPkt && st.total_packets ? ` · ${st.packets_acked || 0}/${st.total_packets}` : ""));
lastState = st.state;
} else if (st.state === "transfer" && st.total_packets > 0) {
const acked = st.packets_acked || 0;
@@ -2328,13 +2259,23 @@ function startOtaPoll() {
setOtaStage(stage);
const acked = st.packets_acked || 0;
const total = st.total_packets || 1;
/* HTML 模板已是「包 <已确认> / <总数>」,这里各填各的;
* 之前往 otaPktNum 塞整串 "acked/total" 会渲染成「包 696/912 / 912」。 */
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
* 不更新进度条和包数——
* 此时 packets_acked 是 ESP32 预填充字节数不是电机真实 ACK,
* 显示 0/912 会让人误以为在传输。transfer 起才显示进度。 */
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;
if (elapsed > 0.5 && acked > 0) {
if (elapsed > 0.5 && acked > 0 && inTransfer) {
$("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
}
$("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
@@ -2398,40 +2339,56 @@ function otaDone(sendCancel = true) {
$("#chSelect").addEventListener("change", () => {
state.ch = parseInt($("#chSelect").value, 10) || 0;
updateConnUi();
updateChSelectOptions(); /* 同步选框字色:切到有信号通道则绿色,否则默认 */
/* 按新通道重刷节点列表 / STATUS 面板 / 在线汇总(通道视图过滤立即生效) */
refreshChannelViews();
/* 通道切换不额外拉 feedback:等下一轮 pollLoop,避免与单飞链并发 */
});
/* ═══════════════════════ 刷新速度(motor 轮询间隔) ═══════════════════════ */
/* 用户改 #refreshRate 后:clamp、回填、写 state,并按「N×GD32周期」立即重排。
* 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。
* 注意:旧实现只把 userMs 当锁相失败 fallback,成功锁相后永远 ~250ms,
* 导致改刷新速度“看起来没反应”。现在 delay 真正跟随 N×period。 */
* 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。 */
const refreshRateInput = $("#refreshRate");
/* 下发 GD32 0xC5 主动推送周期:复用通用 SPI 透传口 /spi/send(不新增 HTTP 路由)。
* 命令码 0xC5 + 子操作码 0xFE,data 十六进制 = FE <ms_lo> <ms_hi>(毫秒 LE)。
* GD32 端钳制 [50,5000]ms 并回 ACK。失败静默(不影响本地轮询)。 */
async function sendMotorPeriod(ms) {
const lo = ms & 0xFF, hi = (ms >> 8) & 0xFF;
const toHex = (b) => b.toString(16).padStart(2, "0").toUpperCase();
try {
await fetch(getBaseUrl() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "C5", data: "FE" + toHex(lo) + toHex(hi), timeout_ms: 2000 }),
signal: AbortSignal.timeout(3000),
});
} catch (e) { /* 周期下发失败静默:GD32 保持旧周期,本地轮询不受影响 */ }
}
function applyRefreshRate() {
const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
const v = clampNum(parseInt(refreshRateInput.value, 10) || period, period, 5000);
const v = clampNum(parseInt(refreshRateInput.value, 10) || 250, 50, 5000);
refreshRateInput.value = v;
state.refreshIntervalMs = v;
const n = Math.max(1, Math.round(v / period));
const effective = n * period;
/* 取消在途 tick,按新间隔重排;本轮 in-flight 完成后 finally 也会走新 computePhaseLock。 */
if (state.poll.tick) {
clearTimeout(state.poll.tick);
state.poll.tick = null;
}
if (state.connected && !state.probing && !state.tickInFlight) {
schedulePollLoop(state.pollGen, effective);
/* HTTP 轮询驱动:清掉旧节拍后必须立即按新间隔重排 motor 链,
* 否则轮询链断死、STATUS 面板停更。
* tickInFlight 时在途 pollLoop 的 finally 也会重排,schedulePollLoop 内部
* stopTimer 保证不会双链并发。 */
if (state.connected && !state.probing) {
schedulePollLoop(state.pollGen, 0);
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
}
const hz = 1000 / effective;
const note = (effective !== v) ? ` → 实际 ${effective}ms` : "";
toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz,${n}×${period}ms)${note}`, "ok");
const hz = 1000 / v;
toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
}
refreshRateInput.addEventListener("change", applyRefreshRate);
refreshRateInput.addEventListener("input", () => {
/* 输入过程中只 clamp 显示,松手/change 再应用节拍,避免每个键都 toast */
const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
/* 输入过程中只 clamp 下限显示,松手/change 再应用节拍,避免每个键都 toast */
const raw = parseInt(refreshRateInput.value, 10);
if (Number.isFinite(raw) && raw < period) refreshRateInput.value = period;
if (Number.isFinite(raw) && raw < 50) refreshRateInput.value = 50;
});
/* ═══════════════════════ 示波器系统 ═══════════════════════ */
@@ -2648,11 +2605,12 @@ const scopeSys = (() => {
card.addEventListener("click", () => card.classList.toggle("open"));
});
/* 采样:用真实解码的 STATUS 数据填充历史缓冲(1Hz 数据 → 平滑插值) */
/* 采样:用真实解码的 STATUS 数据填充历史缓冲(1Hz 数据 → 平滑插值)。
* node×channel 双键:只采当前通道的记录,避免波形混入其他通道同号电机数据。 */
function sample() {
const n = state.selectedNode;
const d = n != null ? state.decoded.get(n) : null;
const motor = n != null ? state.motors.find((m) => m.node === n) : null;
const motor = n != null ? state.motors.find((m) => m.node === n && m.ch === state.ch) : null;
const d = motor ? state.decoded.get(motorKey(motor)) : null;
const online = d && motor && motor.online;
if (!online) {
hist.u[head] = hist.v[head] = hist.w[head] = 0;
@@ -2714,33 +2672,19 @@ bindAutoSave();
initCmdCommit(); /* CMD 输入提交制:必须在 loadUiState 回填之后初始化,否则初始提交值是 HTML 默认值 */
otaRestorePersistedFirmware(); /* 异步恢复上次固件(无阻塞) */
/* 暴露诊断对象到 window,供控制台排查 age 波动 / 锁相状态:
* window.phaseDebug.history // 最近 12 次 raw age_ms
* window.phaseDebug.lossCount // 累计丢帧次数
* window.phaseDebug.lastSyncAge // 锁相基准用的 age
* window.phaseDebug.haveSync // 是否已建立锁相
* window.phaseDebug.lastSlowReq // 上次慢请求(>200ms)path+耗时,定位"age 不动"卡顿
* window.phaseDebug.reset() // 强制重新学习锁相 */
/* 暴露诊断对象到 window,供控制台排查慢请求:
* window.phaseDebug.lastSlowReq // 上次慢请求(>200ms)path+耗时
* window.phaseDebug.slowStats // 累计慢请求统计
* window.phaseDebug.reset() // 清空慢请求统计 */
window.phaseDebug = {
get history() { return state.ageWindow.slice(); },
get lossCount() { return state.ageLossCount || 0; },
get lastSyncAge() { return state.phase.lastAgeMs; },
get haveSync() { return state.phase.haveSync; },
get motorPeriodMs() { return state.phase.motorPeriodMs; },
get servoMs() { return state.phase.servoMs; },
get lastSlowReq() { return state.phaseDebug && state.phaseDebug.lastSlowReq; },
get slowStats() { return state.phaseDebug && state.phaseDebug.slowStats; },
reset() {
state.phase.haveSync = false;
state.phase.lastAgeMs = null;
state.phase.lastRecvPerfMs = 0;
state.ageWindow.length = 0;
state.ageLossCount = 0;
if (state.phaseDebug) {
state.phaseDebug.lastSlowReq = null;
state.phaseDebug.slowStats = {};
}
console.log("[phaseDebug] 锁相基准 + 慢请求统计已清空");
console.log("[phaseDebug] 慢请求统计已清空");
},
};
+6 -4
View File
@@ -4,7 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>电机控制台 — Motor CAN FD Console</title>
<link rel="stylesheet" href="css/motor.css?v=20260727b">
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/motor.css?v=20260730a">
</head>
<body>
@@ -36,9 +38,9 @@
</select>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="电机状态轮询间隔(ms)。GD32 0xC5 推送周期 250ms:设 ≤250 按 250ms 锁相;设更大则按 N×250ms 抽稀(例如 1000≈每 4 次推送拉一次)">
<div class="conn-group" title="电机状态刷新间隔(ms,50~5000)。同时驱动前端 HTTP 轮询与 GD32 0xC5 SPI 主动推送周期,端到端节拍一致">
<label for="refreshRate" class="refresh-hint">刷新速度</label>
<input type="number" id="refreshRate" class="refresh-input" value="250" min="250" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒)" />
<input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒)" />
<span class="refresh-hint">ms</span>
</div>
<div class="toolbar-right">
@@ -343,6 +345,6 @@
</div>
</div>
<script type="module" src="js/motor/main.js?v=20260727l"></script>
<script type="module" src="js/motor/main.js?v=20260801b"></script>
</body>
</html>
+175
View File
@@ -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*µs/) || [])[1] || "?";
var min = (text.match(/最快\s*([\d.]+)\s*µs/) || [])[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 + "µs", min + "µs", 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>