From ef6d3a4be313474547d674699ff8ec18f8db4242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Mon, 10 Aug 2026 00:54:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(can):=20=E6=B7=BB=E5=8A=A0=E8=B7=A8?= =?UTF-8?q?=E9=80=9A=E9=81=93CAN=E7=AA=81=E5=8F=91=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81=E5=A4=9A=E7=94=B5=E6=9C=BA?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /can/send_burst HTTP接口,支持一次SPI事务批量发送多帧CAN消息 - 实现canFdSendBurst函数,通过0xC7 SPI类型实现背靠背装载各通道邮箱 - 添加canBurstCapacity函数计算单次突发最大帧数限制 - 突发发送期间暂停can_feedback_task避免SPI抢占冲突 refactor(can_feedback_task): 改造为周期查询+事件消费模式 - 0xC4/0xC5从GD32主动推送改为ESP32周期查询(250ms/1s) - 0xC6/0xD2仍保留事件驱动推送模式,由任务消费处理 - 消除OTA期间GD32悬空按铃导致的超时刷屏问题 - 电机OTA期间也暂停任务避免与0xD1抢夺SPI资源 perf(ota_relay): 增加重发次数上限提升传输可靠性 - 将kMaxDataSends从3次增加到5次,提高数据包传输成功率 chore(version): 更新固件版本号至0.3.78 - 版本查询checkVersionWait移到tickInFlight释放后异步触发 - 避免/motor/version请求拖长tick导致tickStuck误报通道闪黄 --- main/include/version.h | 4 +- main/monitor/can_feedback_task.cpp | 166 ++++--- main/monitor/can_feedback_task.h | 11 +- main/relay/ota_relay.cpp | 2 +- main/server/http_handlers.cpp | 228 +++++++++ main/server/http_handlers.h | 2 + main/server/wifi_ota.cpp | 1 + main/transport/canfd_bridge.cpp | 107 +++++ main/transport/canfd_bridge.h | 38 ++ main/transport/spi_master.cpp | 50 +- main/transport/spi_master.h | 4 + sdkconfig.defaults | 12 +- tools/continuity_test.html | 737 +++++++++++++++++++++++++++++ tools/css/components.css | 220 ++++++++- tools/css/motor.css | 107 ++++- tools/index.html | 160 ++++++- tools/js/ble/session.js | 41 +- tools/js/ble/state.js | 14 +- tools/js/common/connection.js | 14 +- tools/js/motor/main.js | 702 +++++++++++++++++++++++++-- tools/js/motor/tick.worker.js | 33 ++ tools/js/ota/console-mode.js | 2 - tools/motor.html | 26 +- 23 files changed, 2524 insertions(+), 157 deletions(-) create mode 100644 tools/continuity_test.html create mode 100644 tools/js/motor/tick.worker.js diff --git a/main/include/version.h b/main/include/version.h index 9484ffd..d35d2e1 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -14,8 +14,8 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 76 -#define FW_VERSION_STRING "0.3.76" // 重启恢复提速2:httpd就绪后再打 MAIN「启动」banner(防环缩容裁掉);前端用 uptime<120s 判重启完成,不再干等10s +#define FW_VERSION_PATCH 78 +#define FW_VERSION_STRING "0.3.78" // 版本查询checkVersionWait移到tickInFlight释放后异步触发,避免/motor/version请求拖长tick导致tickStuck误报通道闪黄 // Git 信息(手动填写) #define GIT_HASH "770ae14" \ No newline at end of file diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index be91239..620e58a 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -1,13 +1,16 @@ /* - * can_feedback_task.cpp - 被动监听 GD32 主动推送的事件 + * can_feedback_task.cpp - GD32 周期查询 + 事件消费任务 * * 架构(2026-08 改造后): - * 0xC1 CAN 反馈已改为请求驱动(前端请求时 ESP32 主动向 GD32 查询), - * 不再被动缓存。本任务仅保留 0xC4/0xC5/0xC6/0xD2 事件分发: - * 0xC4 ADC 调试反馈、0xC5 多电机状态、0xC6 多电机版本、0xD2 电机 OTA 进度 - * 这些仍由 GD32 主动推送,ESP32 被动接收。 + * 0xC1 CAN 反馈:请求驱动(HTTP 侧 canFdQueryFeedback),本任务不处理。 + * 0xC4 ADC / 0xC5 电机状态:由 GD32 主动按铃推送改为本任务周期查询—— + * 0xC5 每 250ms 查 6 页、0xC4 每 1s 查 1 帧,查询响应 payload 格式与推送 + * 一致,复用 onMotorStateEvent()/onGd32AdcFeedbackEvent() 解析入库。 + * 消除 OTA 期间 GD32 悬空按铃(ESP32 抑制 ring 不读)导致“按铃超时取消”刷屏。 + * 0xC6 电机版本 / 0xD2 电机 OTA 进度:仍由 GD32 事件驱动推送,本任务 takeEvent 消费。 * - * OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。 + * OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务(不查询不消费); + * 电机 OTA 期间(motorOtaIsActive())也暂停,避免与 0xD1 喂数抢占 SPI。 */ #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -27,78 +30,127 @@ namespace { -constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时 -constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔 -constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈 -constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态推送 -constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动) -constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送 -constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断 -constexpr const char *kTag = "CAN_FB"; +constexpr uint32_t kQueryMotorStateMs = 250U; ///< 0xC5 电机状态查询周期 +constexpr uint32_t kQueryAdcMs = 1000U; ///< 0xC4 ADC 查询周期 +constexpr uint32_t kEventWaitMs = 20U; ///< 单次等 0xC6/0xD2 事件超时 +constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔 +constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈 +constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态查询 +constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动) +constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送 +constexpr size_t kPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断 +constexpr const char *kTag = "CAN_FB"; static volatile bool s_paused = false; /** - * @brief can_fb 任务主循环:takeEvent 分发 0xC4/0xC5/0xC6/0xD2 推送事件。 - * 0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),不再在此处理。 + * @brief 按页查询 GD32 电机状态(0xC5 请求驱动,替代 GD32 250ms 推送)。 + * 任一页失败立即中断本轮:GD32 未就绪/瞬时错误时不空耗 6×200ms,下轮再试; + * page0 会刷新全局字段(active_count/boot_active),部分页成功即部分更新。 + */ +static void queryMotorStatePages(void) +{ + uint8_t req[1] = {0U}; + for (uint8_t page = 0U; page < MOTOR_TOTAL_PAGES; ++page) { + req[0] = page; + uint8_t resp_type = 0U; + uint8_t resp_buf[kPayloadMax] = {}; + size_t resp_len = 0U; + esp_err_t err = SpiMaster::instance().transact( + kSpiMotorStateType, req, sizeof(req), + &resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U); + if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK || + resp_len < MOTOR_PAGE_HEADER) { + return; + } + (void)onMotorStateEvent(resp_buf, resp_len); + } +} + +/** + * @brief 查询 GD32 ADC 调试快照(0xC4 请求驱动,替代 GD32 1Hz 推送)。 + * GD32 0xC4 handler 回自身类型 0xC4(异于 0xC1/0xC5 的 0x80),两者皆接受。 + */ +static void queryAdcFeedback(void) +{ + uint8_t req[1] = {0U}; + uint8_t resp_type = 0U; + uint8_t resp_buf[kPayloadMax] = {}; + size_t resp_len = 0U; + esp_err_t err = SpiMaster::instance().transact( + kSpiAdcFeedbackType, req, sizeof(req), + &resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U); + if (err != ESP_OK || resp_len < 1U) { + return; + } + if (resp_type != kSpiAdcFeedbackType && resp_type != SPI_OTA_RSP_ACK) { + return; + } + (void)onGd32AdcFeedbackEvent(resp_buf, resp_len); +} + +/** + * @brief 消费剩余事件驱动推送(0xC6 电机版本 / 0xD2 电机 OTA 进度)。 + * 0xC4/0xC5 已改请求驱动,不再有对应推送;未知类型静默跳过。 + */ +static void drainPushEvents(uint32_t wait_ms) +{ + uint8_t ring_type = 0U; + uint8_t ring_payload[kPayloadMax] = {}; + size_t ring_len = 0U; + if (!SpiMaster::instance().takeEvent(&ring_type, ring_payload, + sizeof(ring_payload), &ring_len, + wait_ms)) { + return; + } + if (ring_type == kSpiMotorVersionType) { + (void)onMotorVersionEvent(ring_payload, ring_len); + } else if (ring_type == kSpiMotorOtaStatusType) { + onMotorOtaStatusPush(ring_payload, ring_len); + } +} + +/** + * @brief can_fb 任务主循环:周期查询 0xC5/0xC4 + 消费 0xC6/0xD2 推送。 + * OTA / 电机 OTA 期间暂停,SPI 交由 OTA 独占。 */ static void taskLoop(void *) { - DEBUG_INFO(kTag, "事件监听任务启动 (0xC4 ADC / 0xC5 motor / 0xC6 ver / 0xD2 ota)"); + DEBUG_INFO(kTag, "请求驱动查询任务启动 (0xC5 250ms / 0xC4 1s / 0xC6,0xD2 推送)"); - /* 冷启动延后开始监听,错开上电初始化窗口 */ + /* 冷启动延后开始查询,错开上电初始化窗口 */ { esp_reset_reason_t rr = esp_reset_reason(); if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) { constexpr uint32_t kCanFbDeferMs = 1500U; - DEBUG_INFO(kTag, "冷启动: %lu ms 后开始事件监听", + DEBUG_INFO(kTag, "冷启动: %lu ms 后开始查询", (unsigned long)kCanFbDeferMs); vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs)); } } - uint8_t ring_type = 0U; - uint8_t ring_payload[kRingPayloadMax] = {}; - size_t ring_len = 0U; + TickType_t last_motor_q = xTaskGetTickCount(); + TickType_t last_adc_q = xTaskGetTickCount(); for (;;) { - if (s_paused) { + if (s_paused || motorOtaIsActive()) { vTaskDelay(pdMS_TO_TICKS(kPauseDelayMs)); continue; } - /* 阻塞等待 GD32 按铃事件(超时则重试) */ - if (!SpiMaster::instance().takeEvent(&ring_type, ring_payload, - sizeof(ring_payload), &ring_len, - kWaitRingMs)) { - continue; + /* 周期查询:到期才执行(时间差对比,暂停恢复后立即补一次) */ + const TickType_t now = xTaskGetTickCount(); + if ((now - last_motor_q) >= pdMS_TO_TICKS(kQueryMotorStateMs)) { + queryMotorStatePages(); + last_motor_q = xTaskGetTickCount(); + } + if ((now - last_adc_q) >= pdMS_TO_TICKS(kQueryAdcMs)) { + queryAdcFeedback(); + last_adc_q = xTaskGetTickCount(); } - /* 0xC4 ADC 调试反馈:GD32 关开关则不推;ESP32 始终可解析(无本地开关) */ - if (ring_type == kSpiAdcFeedbackType) { - (void)onGd32AdcFeedbackEvent(ring_payload, ring_len); - continue; - } - - /* 0xC5 多电机状态推送:GD32 250ms 分页推送,ESP32 解析入缓存 */ - if (ring_type == kSpiMotorStateType) { - (void)onMotorStateEvent(ring_payload, ring_len); - continue; - } - - /* 0xC6 多电机版本推送:GD32 收到电机版本应答(0x00FF)后事件驱动推送 */ - if (ring_type == kSpiMotorVersionType) { - (void)onMotorVersionEvent(ring_payload, ring_len); - continue; - } - - /* 0xD2 电机 OTA relay 进度推送 */ - if (ring_type == kSpiMotorOtaStatusType) { - onMotorOtaStatusPush(ring_payload, ring_len); - continue; - } - - /* 0xC1 已改为请求驱动,不再在此处理;未知类型静默跳过 */ + /* 消费 0xC6/0xD2:短阻塞等 worker 入队(事件驱动唤醒) */ + drainPushEvents(kEventWaitMs); } } @@ -130,7 +182,7 @@ bool startCanFeedbackTask() } /** - * @brief 暂停/恢复事件监听。 + * @brief 暂停/恢复周期查询与事件消费。 * @param pause true=OTA 期间暂停;false=恢复 */ void pauseCanFeedbackTask(bool pause) @@ -138,9 +190,9 @@ void pauseCanFeedbackTask(bool pause) if (s_paused == pause) return; // 幂等:状态未变时不重复日志 s_paused = pause; if (pause) { - DEBUG_DBG(kTag, "事件监听已暂停(OTA 进行中)"); + DEBUG_DBG(kTag, "周期查询已暂停(OTA 进行中)"); } else { - DEBUG_DBG(kTag, "事件监听已恢复"); + DEBUG_DBG(kTag, "周期查询已恢复"); } } @@ -151,4 +203,4 @@ void pauseCanFeedbackTask(bool pause) bool canFeedbackPaused() { return s_paused; -} +} \ No newline at end of file diff --git a/main/monitor/can_feedback_task.h b/main/monitor/can_feedback_task.h index 0546f6a..3bc3035 100644 --- a/main/monitor/can_feedback_task.h +++ b/main/monitor/can_feedback_task.h @@ -1,16 +1,17 @@ /* - * can_feedback_task.h - 事件监听任务(0xC4/0xC5/0xC6/0xD2 推送) + * can_feedback_task.h - GD32 周期查询 + 事件消费任务 * - * 2026-08 架构改造:0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback), - * 不再被动缓存。本任务仅分发 GD32 主动推送的 ADC/电机状态/版本/OTA 进度事件。 + * 2026-08 架构改造:0xC1 CAN 反馈请求驱动(canFdQueryFeedback); + * 0xC4 ADC / 0xC5 电机状态改为本任务周期查询(250ms/1s); + * 0xC6 电机版本 / 0xD2 电机 OTA 进度保留事件推送,由本任务 takeEvent 消费。 */ #ifndef CAN_FEEDBACK_TASK_H #define CAN_FEEDBACK_TASK_H -/** @brief 暂停/恢复事件监听(OTA 期间调用 pause=true)。 */ +/** @brief 暂停/恢复周期查询与事件消费(OTA 期间调用 pause=true)。 */ void pauseCanFeedbackTask(bool pause); -/** @brief 查询监听是否处于暂停(OTA / send_batch 期间为 true)。 +/** @brief 查询是否处于暂停(OTA / send_batch / 电机 OTA 期间为 true)。 * 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */ bool canFeedbackPaused(); diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 05e012e..132b6a7 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -1172,7 +1172,7 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64], * 实现“重发快且不漏”(旧逻辑见 WAIT 后只能干 poll 到 stall,ACK 丢了就永丢)。 * - 等 ACK 期间可预组下一包(与从机处理重叠)。 */ - constexpr uint32_t kMaxDataSends = 3U; + constexpr uint32_t kMaxDataSends = 5U; /* 单次发送后等 ACK 上限:GD32 正常 ms 级回 ACK(ISR 回 WAIT、主循环回 ACK), * 50ms 未到即重发(DUP-SEQ 回放捞回),不再干等 6s(用户指定 50ms)。 * credit==0 的排空积压由 transact 内部 credit 背压消化,此处无需长等。 */ diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index 5ed4b0b..03370e1 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -1330,6 +1330,234 @@ esp_err_t can_send_batch_handler(httpd_req_t *req) return ESP_OK; } +/** + * @brief POST /can/send_burst — CAN 跨通道突发发送(一次 SPI 事务多帧近同时)。 + * + * JSON: { "suffix": 512, "dlc": 32, "frames": [ { "ch":0, "node":1, "data":"HEX" }, ... ] } + * suffix = CAN ID 低 16 位(CMD=0x0200),id=(node<<16)|suffix;dlc 为本批统一数据长度。 + * frames 可超过单次突发容量:内部按 canBurstCapacity(dlc) 分块,块与块之间背靠背 + * 连续发起 SPI 事务(间隔 ~<1ms),实现「组内近同时、组间尽量紧」的多电机同步驱动。 + * + * 返回: { "status":"ok","sent":N,"results":[ {"status":"ok"} | {"status":"error","code":c,"gd32_err":g}, ... ] } + * 单帧总线故障(0xE2/0xE3)/邮箱满(0xE4)等经 ACK 的每帧状态字节透传,前端据此处理冷却。 + */ +esp_err_t can_send_burst_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + constexpr int kMaxTotalFrames = 24; /* 单次请求帧数上限(一步同步电机数上限) */ + constexpr uint32_t kBurstTimeoutMs = 200U; /* 单次突发 SPI 事务超时 */ + constexpr int kMaxBody = 49152; + + int content_len = req->content_len; + if (content_len <= 0 || content_len > kMaxBody) { + return http_send_json_error(req, "400 Bad Request", "bad_body", + "body 长度无效或过大"); + } + + BatchBodyReader reader(req, content_len); + + auto findKey = [&](const char *key) -> bool { + const size_t kl = strlen(key); + size_t match = 0; + int c; + while ((c = reader.nextChar()) != -1) { + if (c == key[match]) { + if (++match == kl) return true; + } else { + match = (c == key[0]) ? 1 : 0; + } + } + return false; + }; + auto skipWs = [&]() { + int c; + while ((c = reader.peekChar()) != -1) { + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + reader.nextChar(); + } else { + break; + } + } + }; + auto findChar = [&](char target) -> bool { + int c; + while ((c = reader.nextChar()) != -1) { + if (c == target) return true; + } + return false; + }; + auto readInt = [&]() -> long { + skipWs(); + long sign = 1; + int c = reader.peekChar(); + if (c == '-') { reader.nextChar(); sign = -1; } + long v = 0; + while ((c = reader.peekChar()) != -1 && c >= '0' && c <= '9') { + v = v * 10 + (c - '0'); + reader.nextChar(); + } + return v * sign; + }; + char frameJson[320]; + auto readFrameObj = [&]() -> int { + skipWs(); + int c = reader.peekChar(); + if (c != '{') return -1; + reader.nextChar(); + frameJson[0] = '{'; + size_t n = 1; + int depth = 1; + while ((c = reader.nextChar()) != -1) { + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + if (n + 1 >= sizeof(frameJson)) return -1; + frameJson[n++] = '}'; + frameJson[n] = '\0'; + return (int)n; + } + } + if (n + 1 >= sizeof(frameJson)) return -1; + frameJson[n++] = (char)c; + } + return -1; + }; + + /* ── prefix:suffix / dlc / frames ── */ + if (!findKey("\"suffix\"") || !findChar(':')) { + return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 suffix 字段"); + } + long suffix_val = readInt(); + if (!findKey("\"dlc\"") || !findChar(':')) { + return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 dlc 字段"); + } + long dlc_val = readInt(); + if (dlc_val < 0 || dlc_val > CAN_MAX_DLC) { + return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 超出范围"); + } + if (!findKey("\"frames\"") || !findChar('[')) { + return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 frames 字段"); + } + + const uint8_t dlc = static_cast(dlc_val); + const uint16_t suffix = static_cast(suffix_val & 0xFFFF); + const uint8_t cap = canBurstCapacity(dlc); + if (cap == 0U) { + return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 过大无法突发"); + } + + /* 静态缓冲:httpd 单线程,避免大栈占用 */ + static uint8_t s_chnode[kMaxTotalFrames]; + static uint8_t s_data[kMaxTotalFrames][CAN_MAX_DLC]; + static uint8_t s_status[kMaxTotalFrames]; + + int total = 0; + while (total < kMaxTotalFrames) { + skipWs(); + int c = reader.peekChar(); + if (c == -1 || c == ']') break; + if (c == ',') { reader.nextChar(); continue; } + if (c != '{') { reader.nextChar(); continue; } + int flen = readFrameObj(); + if (flen <= 0) break; + + JsonView fr = jsonParse(frameJson); + if (!fr.valid) { + return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧 JSON 非法"); + } + JsonView ch_it = jsonGet(&fr, "ch"); + JsonView node_it = jsonGet(&fr, "node"); + JsonView data_it = jsonGet(&fr, "data"); + if (!jsonIsNumber(&ch_it) || !jsonIsNumber(&node_it)) { + return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧缺少 ch/node"); + } + const int chv = static_cast(jsonGetNumberValue(&ch_it)); + const int nodev = static_cast(jsonGetNumberValue(&node_it)); + if (chv < 0 || chv >= CAN_CHANNEL_MAX) { + return http_send_json_error(req, "400 Bad Request", "bad_frame", "ch 非法(0~2)"); + } + + uint8_t data_buf[CAN_MAX_DLC]; + uint8_t nbytes = 0U; + if (jsonIsString(&data_it)) { + size_t hex_len = 0U; + const char *hex = jsonGetStringValue(&data_it, &hex_len); + if (hex) { + nbytes = static_cast( + http_hex_to_bytes_n(hex, hex_len, data_buf, CAN_MAX_DLC)); + } + } + if (nbytes != dlc) { + return http_send_json_error(req, "400 Bad Request", "bad_frame", + "帧 data 长度与 dlc 不一致"); + } + s_chnode[total] = static_cast(((chv & 0x0F) << 4) | (nodev & 0x0F)); + if (dlc > 0U) { + memcpy(s_data[total], data_buf, dlc); + } + total++; + } + if (total == 0) { + return http_send_json_error(req, "400 Bad Request", "bad_args", "frames 为空"); + } + + /* ── 分块背靠背突发发送 ── */ + pauseCanFeedbackTask(true); + bool aborted = false; + for (int off = 0; off < total && !aborted; off += cap) { + const int n = ((total - off) > cap) ? cap : (total - off); + can_burst_entry_t entries[CAN_BURST_HARD_MAX]; + for (int i = 0; i < n; ++i) { + entries[i].chnode = s_chnode[off + i]; + entries[i].data = s_data[off + i]; + } + uint8_t chunk_status[CAN_BURST_HARD_MAX]; + uint8_t gerr = 0U; + (void)esp_task_wdt_reset(); + const int rc = canFdSendBurst(suffix, dlc, entries, static_cast(n), + chunk_status, sizeof(chunk_status), + kBurstTimeoutMs, &gerr); + if (rc < 0) { + /* SPI 失败/协议错误:本块及之后标记失败并中止 */ + const uint8_t code = (rc == -3) ? gerr : 0xEEU; + for (int i = 0; i < n; ++i) s_status[off + i] = code; + for (int j = off + n; j < total; ++j) s_status[j] = 0xEEU; + aborted = true; + break; + } + for (int i = 0; i < n; ++i) s_status[off + i] = chunk_status[i]; + } + pauseCanFeedbackTask(false); + + /* ── 组装 JSON 响应 ── */ + static char resp_buf[1600]; + int off = snprintf(resp_buf, sizeof(resp_buf), + "{\"status\":\"ok\",\"sent\":%d,\"results\":[", total); + for (int i = 0; i < total; ++i) { + const uint8_t st = s_status[i]; + int w; + if (st == 0x00U) { + w = snprintf(resp_buf + off, sizeof(resp_buf) - off, + "%s{\"status\":\"ok\"}", (i == 0) ? "" : ","); + } else { + w = snprintf(resp_buf + off, sizeof(resp_buf) - off, + "%s{\"status\":\"error\",\"code\":-3,\"gd32_err\":%u}", + (i == 0) ? "" : ",", (unsigned)st); + } + if (w < 0 || off + w >= (int)sizeof(resp_buf)) break; + off += w; + } + if (off < (int)sizeof(resp_buf) - 2) { + resp_buf[off++] = ']'; + resp_buf[off++] = '}'; + } + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, resp_buf, off); +} + esp_err_t spi_send_handler(httpd_req_t *req) { http_set_cors_headers(req); diff --git a/main/server/http_handlers.h b/main/server/http_handlers.h index f1548b1..362a476 100644 --- a/main/server/http_handlers.h +++ b/main/server/http_handlers.h @@ -20,6 +20,8 @@ esp_err_t restart_handler(httpd_req_t *req); esp_err_t logs_handler(httpd_req_t *req); /** @brief CAN 发送(唯一入口):同通道 1~N 帧,单帧即 frames 长度为 1。 */ esp_err_t can_send_batch_handler(httpd_req_t *req); +/** @brief CAN 跨通道突发发送:多帧经 0xC7 一次/多次 SPI 事务背靠背发出,近同时。 */ +esp_err_t can_send_burst_handler(httpd_req_t *req); esp_err_t spi_send_handler(httpd_req_t *req); esp_err_t system_status_handler(httpd_req_t *req); esp_err_t system_probe_handler(httpd_req_t *req); diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 6003945..5f7efe3 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -477,6 +477,7 @@ esp_err_t WifiOta::httpServerStart() {"/system/probe", HTTP_GET, system_probe_handler}, {"/system/heap_low_log", HTTP_GET, system_heap_low_log_handler}, {"/can/send_batch", HTTP_POST, can_send_batch_handler}, + {"/can/send_burst", HTTP_POST, can_send_burst_handler}, {"/can/feedback", HTTP_GET, can_feedback_handler}, {"/can/mode", HTTP_GET, can_mode_handler}, {"/can/mode", HTTP_POST, can_mode_handler}, diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 149ea2f..6691d6d 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -20,10 +20,15 @@ namespace { constexpr uint8_t kSpiCanFrameType = 0xC0U; +constexpr uint8_t kSpiCanBurstType = 0xC7U; constexpr uint8_t kSpiCanSetModeType = 0xC2U; constexpr uint8_t kFeedbackFixedHeader = 28U; constexpr uint8_t kFeedbackMaxPage = 1U; /* GD32 仅 page 0/1 */ +/* 突发 payload 上限 = V2_MAX_PAYLOAD(236);头 4B = count+dlc+suffix(2)。 */ +constexpr uint8_t kBurstHeaderLen = 4U; +constexpr uint16_t kBurstPayloadCap = 236U; + /** * @brief 经 SPI 发送一帧 CAN,并解析 ACK/ERROR。 * @@ -169,6 +174,108 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, recv_buf, recv_max, timeout_ms, gd32_err_out); } +/** + * @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。 + * payload = 4B 头 + count*(1+dlc),须 ≤ 236B;并受 GD32 硬上限约束。 + */ +uint8_t canBurstCapacity(uint8_t dlc) +{ + if (dlc > CAN_MAX_DLC) { + return 0U; + } + const uint16_t entry_len = static_cast(1U + dlc); + const uint16_t avail = (kBurstPayloadCap > kBurstHeaderLen) + ? static_cast(kBurstPayloadCap - kBurstHeaderLen) + : 0U; + const uint16_t cap = static_cast(avail / entry_len); + return static_cast((cap < CAN_BURST_HARD_MAX) ? cap : CAN_BURST_HARD_MAX); +} + +/** + * @brief 发送一次 CAN 突发(SPI 0xC7)。 + * + * GD32 在 CS 释放 ISR 内对每帧调用非阻塞 transmitFrame(仅装载邮箱), + * 全部装载完成后才回 ACK,因此各通道帧近同时上线;ESP32 这里等待的只是 + * 这一个 ACK,并非逐帧等待,不会破坏组内同时性。 + * + * 返回值: count 成功, -1 参数错误, -2 SPI 通信失败, -3 GD32 拒绝。 + */ +int canFdSendBurst(uint16_t id_suffix, uint8_t dlc, + const can_burst_entry_t *entries, uint8_t count, + uint8_t *status_out, uint8_t status_max, + uint32_t timeout_ms, uint8_t *gd32_err_out) +{ + if (gd32_err_out) { + *gd32_err_out = 0U; + } + if (dlc > CAN_MAX_DLC || entries == nullptr || count == 0U) { + return -1; + } + if (count > canBurstCapacity(dlc)) { + return -1; + } + + uint8_t tx_buf[240]; + size_t tx_len = 0U; + tx_buf[tx_len++] = count; + tx_buf[tx_len++] = dlc; + tx_buf[tx_len++] = static_cast((id_suffix >> 8) & 0xFFU); + tx_buf[tx_len++] = static_cast(id_suffix & 0xFFU); + for (uint8_t i = 0U; i < count; ++i) { + tx_buf[tx_len++] = entries[i].chnode; + if (dlc > 0U) { + if (entries[i].data == nullptr) { + return -1; + } + memcpy(&tx_buf[tx_len], entries[i].data, dlc); + tx_len += dlc; + } + } + + uint8_t resp[32]; + uint8_t resp_type = 0U; + size_t resp_len = 0U; + esp_err_t ret = ESP_OK; + /* 与 0xC0 相同:排空可能残留的陈旧 WAIT,直到读到本次真正的 ACK/ERROR */ + for (uint8_t attempt = 0U; attempt < 4U; ++attempt) { + resp_type = 0U; + resp_len = 0U; + ret = SpiMaster::instance().transact( + kSpiCanBurstType, tx_buf, tx_len, + &resp_type, resp, sizeof(resp), &resp_len, timeout_ms); + if (resp_type != SPI_OTA_RSP_WAIT) { + break; + } + vTaskDelay(pdMS_TO_TICKS(3)); + } + + if (ret != ESP_OK) { + DEBUG_WARN("CAN", "BURST SPI fail n=%u %s", + (unsigned)count, esp_err_to_name(ret)); + return -2; + } + if (resp_type == SPI_OTA_RSP_ACK) { + /* resp[0]=count, resp[1..count]=每帧状态(CanTxResult 码) */ + const uint8_t n = (resp_len > 0U) ? resp[0] : 0U; + if (status_out != nullptr && status_max > 0U) { + const uint8_t copy_n = (n < status_max) ? n : status_max; + for (uint8_t i = 0U; i < copy_n; ++i) { + status_out[i] = (static_cast(1U + i) < resp_len) + ? resp[1U + i] : 0xFFU; + } + } + return static_cast(n); + } + + const uint8_t gd32_err = (resp_len > 0U) ? resp[0] : 0xFFU; + if (gd32_err_out) { + *gd32_err_out = gd32_err; + } + DEBUG_WARN("CAN", "BURST拒 n=%u r=%02X e=%02X", + (unsigned)count, resp_type, gd32_err); + return -3; +} + /** * @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。 * diff --git a/main/transport/canfd_bridge.h b/main/transport/canfd_bridge.h index 2b435d4..d4a46bf 100644 --- a/main/transport/canfd_bridge.h +++ b/main/transport/canfd_bridge.h @@ -53,6 +53,44 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, uint32_t timeout_ms, uint8_t *gd32_err_out = nullptr); +// ============================================================ +// CAN 跨通道突发发送(SPI 0xC7) +// 一次 SPI 事务携带多帧,GD32 背靠背装载各通道 CAN 邮箱, +// 组内近同时上线(偏差 <1ms),用于多电机同步驱动。 +// ============================================================ +#define CAN_BURST_HARD_MAX (16) ///< GD32 单次突发硬上限(帧数) + +/** @brief 突发条目:chnode=(ch<<4)|node,data 指向 dlc 字节数据。 */ +typedef struct { + uint8_t chnode; ///< (channel<<4) | node + const uint8_t *data; ///< dlc 字节数据 +} can_burst_entry_t; + +/** + * @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。 + * 受 236B SPI payload(4B 头 + count*(1+dlc))与 GD32 硬上限约束。 + */ +uint8_t canBurstCapacity(uint8_t dlc); + +/** + * @brief 发送一次 CAN 突发(SPI 0xC7)。count 必须 <= canBurstCapacity(dlc)。 + * GD32 在 ISR 内对每帧调用非阻塞 transmitFrame,各通道邮箱背靠背装载。 + * @param id_suffix CAN ID 低 16 位(CMD=0x0200);id=(node<<16)|suffix。 + * @param dlc 所有条目统一数据长度(0~64)。 + * @param entries count 个条目(chnode + data)。 + * @param count 条目数(1~canBurstCapacity(dlc))。 + * @param status_out 输出每帧状态(GD32 CanTxResult 码,0x00=成功),可为 nullptr。 + * @param status_max status_out 容量。 + * @param timeout_ms SPI 事务超时。 + * @param gd32_err_out 返回 -3 时的 GD32 细分错误码;可为 nullptr。 + * @return 成功返回 count;-1 参数错误;-2 SPI 通信失败;-3 GD32 ERROR。 + */ +int canFdSendBurst(uint16_t id_suffix, uint8_t dlc, + const can_burst_entry_t *entries, uint8_t count, + uint8_t *status_out, uint8_t status_max, + uint32_t timeout_ms, + uint8_t *gd32_err_out = nullptr); + // ============================================================ // 不重复 ID 快照表(SPI 0xC3 协议配套) // ============================================================ diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 0ea8757..9636b7a 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -70,15 +70,17 @@ static size_t spiRespReadBudget(void) /** * @brief 已知"短响应"的主动请求类型:应答帧 ≤ ~70B(实测 0xA1 PING ~60-70B、 - * DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短)。 + * DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短、0xC4 ADC 20B)。 * 对这些类型的主动 poll 可把读预算从 256 收紧到 96B,省 ~160B 0xFF 填充时钟 - * (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)与被动 0xC1 事件读。 + * (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)、0xC1/0xC5 + * (分页响应 ~236B)与被动 0xC1 事件读。 */ static bool isShortRespType(uint8_t type) { switch (type) { case 0xA1U: case 0x02U: case 0x01U: case 0xF7U: case 0xB0U: case 0xC0U: case 0xC2U: case 0xD1U: + case 0xC4U: case 0xC7U: return true; default: return false; @@ -87,6 +89,24 @@ static bool isShortRespType(uint8_t type) /** 短响应读预算(覆盖最长 ~70B 应答 + 余量)。 */ static constexpr size_t kShortRespReadBudget = 96U; +/** + * @brief 已知 GD32 APP 在 onCsRelease ISR 内 µs 级装载响应的主动请求类型。 + * 这些类型 CS 释放后无需 5ms 静默:waitSpiDataReady 由下降沿 ISR 精确唤醒 + * (与 0xD1/0xA1 同机制)。仅 BOOT 主循环处理的 OTA 命令(0x01/0x02/0x03) + * 保留 5ms——过早盲读会打断 BOOT 的 EXTI/DMA 时序(实测 CHALLENGE 超时)。 + */ +static bool isAppIsrFastRespType(uint8_t type) +{ + switch (type) { + case 0xA1U: case 0xC0U: case 0xC1U: case 0xC2U: + case 0xC3U: case 0xC4U: case 0xC5U: case 0xC7U: case 0xD1U: + case 0xF7U: + return true; + default: + return false; + } +} + /** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */ bool SpiMaster::isAppOnlySpiType(uint8_t type) { @@ -646,6 +666,19 @@ void SpiMaster::workerLoop() continue; } + /* 无 job:先处理 GD32 重启重同步(session=0 响应置位;5s 冷却防空转)。 + * OTA 中继/暂停期间不 HELLO,避免干扰在途 OTA 事务。 */ + if (m_resyncPending && !m_suppressRingService && !m_spiPaused) { + const TickType_t now_tick = xTaskGetTickCount(); + if ((uint32_t)(now_tick - m_lastResyncTick) >= pdMS_TO_TICKS(5000)) { + m_lastResyncTick = now_tick; + m_resyncPending = false; + DEBUG_INFO("SPI", "GD32 session=0,HELLO 重同步..."); + (void)hello(1500U, 0U); + continue; + } + } + /* 无 job:等 GPIO / 提交唤醒。 * 超时只是无事件时的兜底回扫周期;ISR 的 xTaskNotifyFromISR 会立即唤醒 * 本任务,响应延迟不由这里决定。过短会令 spi_worker(优先级6) 忙转压住 @@ -981,6 +1014,9 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, DEBUG_WARN("SPI", "GD32 session=0 (对端已重启?) 放行 RESPONSE t=0x%02X #%lu", hdr.type, (unsigned long)s_sess0_logs); } + /* GD32 重启后 v2 未激活:置重同步标志,worker 空闲时 HELLO 重建 session。 + * 功能由 session=0 放行兜底,重同步只是把链路拉回校验态。 */ + self.m_resyncPending = true; } static uint32_t s_evt_sess0_logs = 0U; if (is_event && (++s_evt_sess0_logs % 64U) == 1U) { @@ -1641,13 +1677,13 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t } /* CS 释放后的静默期分型处理: - * - 0xD1(电机 OTA 数据)/ 0xA1(APP PING):GD32 APP 在 onCsRelease ISR - * 内 µs 级装好响应并拉低 PB6 → 零盲等,waitSpiDataReady 由下降沿 ISR - * 精确唤醒,旧 1ms 固定延时压制喂速~47KB/s,现由事件驱动提速。 - * - 其他类型(GD32 relay/BOOT AES/HELLO 等):保留 5ms——BOOT 在主循环 + * - APP ISR 响应类型(0xC1/0xC4/0xC5 查询、0xD1 电机 OTA、0xA1 PING 等): + * GD32 APP 在 onCsRelease ISR 内 µs 级装好响应并拉低 PB6 → 零盲等, + * waitSpiDataReady 由下降沿 ISR 精确唤醒(见 isAppIsrFastRespType)。 + * - 其他类型(BOOT OTA 命令 0x01/0x02/0x03 等):保留 5ms——BOOT 在主循环 * 处理 CHALLENGE/flash,poll 前 2 轮盲读 CS 过早打进去会打断其 * EXTI/DMA 时序(实测去掉后 CHALLENGE 超时)。 */ - if (type != 0xD1U && type != 0xA1U) { + if (!isAppIsrFastRespType(type)) { vTaskDelay(pdMS_TO_TICKS(5)); } diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index 07f1459..4e48a53 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -322,6 +322,10 @@ private: volatile bool m_suppressRingService = false; /** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */ volatile bool m_spiPaused = false; + /** GD32 重启检测(响应帧 session=0)置位:worker 空闲时 HELLO 重同步。 */ + volatile bool m_resyncPending = false; + /** 上次 HELLO 重同步时刻(冷却 5s,防 GD32 不响应时反复 HELLO)。 */ + volatile uint32_t m_lastResyncTick = 0; /** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */ volatile uint8_t m_lastReqType = 0xFFU; /** SPI worker 任务句柄(ISR 唯一通知目标)。 */ diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 8674fd4..e5f8b3f 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -14,7 +14,9 @@ CONFIG_ESPTOOLPY_FLASHMODE_DIO=y # 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 反而更慢) +# ≥16MHz 全双工不可行。写 13333333:若写 13000000 驱动取整 80/7=11.4M 反而更慢。 +# 2026-08-09 注:曾怀疑 13.33M 是 relay 重发率高(~4.4%)的根因,改 12M 实测 +# 重发率几乎不变 → 时钟不是主因,回到 13.33M,继续找逻辑层瓶颈。) CONFIG_DCU_SPI_CLOCK_HZ=13333333 # GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压 @@ -98,9 +100,13 @@ CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL=16 CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY=0 CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO=200 -# Brownout detector threshold (LVL5) +# Brownout detector threshold (LVL7, ~2.8V) +# 长时间断电后 CHIP_EN RC 充电慢,LVL5 可能停在半复位状态;提高到 LVL7 确保电源稳定后才启动。 CONFIG_ESP_BROWNOUT_DET=y -CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5=y +# CONFIG_ESP_BROWNOUT_DET_LVL_SEL_5 is not set +CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7=y +# CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_5 is not set +CONFIG_ESP32C3_BROWNOUT_DET_LVL_SEL_7=y # ── 控制台/调试 UART 波特率 3M(debug_log.h UART_DEBUG_BAUD_RATE 与此对齐)── # 注意:仅改 defaults 不会覆盖已有 sdkconfig,构建前须同步写入 sdkconfig 或删 sdkconfig 重生。 diff --git a/tools/continuity_test.html b/tools/continuity_test.html new file mode 100644 index 0000000..38cf657 --- /dev/null +++ b/tools/continuity_test.html @@ -0,0 +1,737 @@ + + + + + +LAN9252 通断测试 + + + + + +
+ +

LAN9252 通断测试

+
通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压
+ +
+ ⚠️ + 不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。 +
+ + +
+
+ 设备 IP + 读取中... + 共用主页"设备连接"配置的 IP +
+
+ + +
+

LAN9252 64-QFN 引脚图

+

俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。

+
+ + + + + + + + + + + + +
+
+ HIGH 3.3V + LOW 0V + 控制线测试 + 数据线空闲 + 控制线空闲 + 电源/其他 + Pin1 +
+
+ + +
+

模式选择

+

点击预览引脚图,再点"确认应用"发送命令

+ + +
+ + + + +
+ + +
+ + + + + +
+
+ + +
+

操作

+
+ + + +
+

+ 确认应用 · 发送当前选中的模式到 GD32
+ 循环切换 · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)
+ 停止 / 恢复 · 恢复 EXMC 正常模式 +

+
+ + +
+
+ 操作日志 +
+ + + + +
+
+
+
+ 共 0 条 + SPI 0xA2 · UDP 6666 +
+
+ +
+ + + + diff --git a/tools/css/components.css b/tools/css/components.css index 80331aa..c913767 100644 --- a/tools/css/components.css +++ b/tools/css/components.css @@ -699,9 +699,223 @@ code { color: var(--esp32-hi); font-family: var(--font-mono); } border-bottom: 1px solid rgba(255,255,255,.04); } .heap-low-table th { color: var(--text-dim); font-weight: 500; position: sticky; top: 0; background: var(--bg-elevated); text-align: right; } -.heap-low-table td:nth-child(5), .heap-low-table td:nth-child(6), -.heap-low-table td:nth-child(7), .heap-low-table td:nth-child(9) { text-align: left; } -.heap-low-table td:first-child { color: var(--text-dim); } +.ble-low-table td:nth-child(5), .ble-low-table td:nth-child(6), +.ble-low-table td:nth-child(7), .ble-low-table td:nth-child(9) { text-align: left; } +.ble-low-table td:first-child { color: var(--text-dim); } + +/* ================= BLE 连接按钮(launch 风格) ================= + 圆形物理按键:未连接=暗淡待机;勾选=已连接,蓝光环绕 + 文字波浪动画。 + checkbox 隐藏,由 JS setConnectionState 驱动 checked,避免异步连接失败导致状态错位。 */ +.ble-launch-wrap { + display: flex; flex-direction: column; align-items: center; + gap: 12px; padding: 6px 0 2px; +} +.ble-launch-btn { + cursor: pointer; position: relative; + display: flex; flex-direction: column; align-items: center; + margin: 0; +} +.ble-launch-btn input { + opacity: 0; width: 0; height: 0; display: none; +} +/* —— 三态:idle 待机 / connecting 连接中转圈 / connected 已连接变亮 —— */ +/* 默认 text3 隐藏 */ +.ble-launch-btn .back .mid .ble-dial .center .start .text3 { + opacity: 0; +} + +/* connecting:仅光晕转圈(loading),按钮整体保持暗淡 */ +.ble-launch-btn[data-ble-state="connecting"] .back .spot-shadow, +.ble-launch-btn[data-ble-state="connecting"] .back .mid .spot-main, +.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .spot-light { + opacity: 0.7; + animation: ble-spot-rot 1.4s linear infinite; +} +.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .center .start .text1 { + opacity: 0; +} +.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .center .start .text3 { + opacity: 1; + color: #9bb8d8; + animation: ble-text-pulse 1.2s ease-in-out infinite; +} + +/* connected:整个按钮变亮 + 静态整圈蓝光晕(不转圈)+ 文字波浪 */ +/* spot 元素默认是横向条状(为转圈动画设计),connected 时改成整圈圆环 */ +.ble-launch-btn[data-ble-state="connected"] .back .spot-shadow { + transition: opacity 1.2s ease; + opacity: 0.9; + width: 106px; height: 106px; + border-radius: 50%; + filter: blur(12px) brightness(1.2); +} +.ble-launch-btn[data-ble-state="connected"] .back .mid .spot-main { + transition: opacity 1.2s ease; + opacity: 0.95; + width: 100px; height: 100px; + border-radius: 50%; + filter: blur(9px) brightness(1.6); +} +.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .spot-light { + transition: opacity 1.2s ease; + opacity: 0.9; + width: 92px; height: 92px; + border-radius: 50%; + filter: blur(8px) brightness(1.2); +} +.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .icon svg { + filter: brightness(1.15); +} +.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .launch { + color: #e9f0fa; +} +.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .launch > div { + animation: ble-wave-text 1.8s calc(var(--i) * -0.13s) infinite; +} +.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .start .text1 { + transition: transform 0.5s ease, opacity 0.5s ease; + transform: translateX(10px); + opacity: 0; +} +.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .start .text2 { + opacity: 1; + animation: ble-text2 0.5s forwards; +} +.ble-launch-btn:active .back .mid .ble-dial { + transform: scale(0.94); + box-shadow: + 0 -1px 2px rgba(255,255,255,0.3), + inset 0 0 10px rgba(0,0,0,0.24), + inset 0 -13px 26px rgba(0,0,0,0.8); +} + +/* —— 外圈金属环 —— */ +.ble-launch-btn .back { + position: relative; z-index: 200; + width: 128px; height: 128px; + display: flex; justify-content: center; align-items: center; + border-radius: 50%; + background-image: linear-gradient(0, #5e5e61 20%, #212123); + box-shadow: + inset 0 1px 1px rgba(255,255,255,0.3), + inset 0 3px 5px rgba(255,255,255,0.6), + inset 0 -4px 2px rgba(255,255,255,0.05), + inset 0 -2px 0 rgba(0,0,0,0.15), + inset -2px 4px 0 rgba(0,0,0,0.3), + inset 2px 4px 0 rgba(0,0,0,0.3), + 0 -7px 16px 2px rgba(255,255,255,0.18), + 0 4px 16px 2px rgba(0,0,0,0.5), + 0 2px 5px #7c7c7c; + cursor: pointer; +} +.ble-launch-btn .back .spot-shadow { + position: absolute; + width: 96px; height: 27px; + background: rgba(95,168,255,0.9); + opacity: 0; + filter: blur(14px) brightness(1.1); +} +.ble-launch-btn .back .mid { + overflow: hidden; position: relative; + width: 106px; height: 106px; + display: flex; justify-content: center; align-items: center; + border-radius: 50%; + background: #000; + box-shadow: 0 0 4px #000, 0 1px 2px rgba(255,255,255,0.8); +} +.ble-launch-btn .back .mid::before { + content: ''; position: absolute; z-index: 500; + width: 100%; height: 100%; border-radius: 50%; + background: transparent; + box-shadow: inset 0 -12px 18px rgba(0,0,0,0.8); + pointer-events: none; +} +.ble-launch-btn .back .mid .spot-main { + position: absolute; + width: 110px; height: 20px; + background: #5fa8ff; + opacity: 0; + filter: blur(11px) brightness(1.5); +} +.ble-launch-btn .back .mid .ble-dial { + position: relative; z-index: 90; + display: flex; justify-content: center; align-items: center; + width: 100px; height: 100px; border-radius: 50%; + background: #424242; + box-shadow: + 0 -1px 2px rgba(255,255,255,0.5), + inset 0 -9px 18px rgba(0,0,0,0.8); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.ble-launch-btn .back .mid .ble-dial .spot-light { + position: absolute; + width: 110px; height: 20px; + opacity: 0; + background: rgba(95,168,255,0.6); + filter: blur(14px) brightness(1.1); +} +.ble-launch-btn .back .mid .ble-dial .center { + position: relative; z-index: 100; + width: 86px; height: 86px; + display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 2px; + border-radius: 50%; + background: #232323; + cursor: pointer; +} +.ble-launch-btn .back .mid .ble-dial .center .icon svg { + margin-top: -3px; + width: 18px; + filter: brightness(1) grayscale(1); + transition: filter 1.5s ease; +} +.ble-launch-btn .back .mid .ble-dial .center .launch { + display: flex; margin-top: -3px; + font-size: 13px; color: #595959; + font-family: var(--font-display); + letter-spacing: 0.04em; +} +.ble-launch-btn .back .mid .ble-dial .center .start { + position: relative; + width: 100%; height: 11px; + font-size: 9px; color: #737373; + text-align: center; + font-family: var(--font-display); +} +.ble-launch-btn .back .mid .ble-dial .center .start > div { + position: absolute; left: 50%; top: 0; + transform: translateX(-50%); + white-space: nowrap; +} +.ble-launch-btn .back .mid .ble-dial .center .start .text2 { + opacity: 0; + display: inline-flex; +} +.ble-launch-btn .back .mid .ble-dial .center .start .text2 > span { + animation: ble-wave-text 1.8s calc(var(--n) * -0.13s) infinite; +} +.ble-launch-hint { + font-size: 11px; color: var(--text-dim); + letter-spacing: 0.04em; +} + +@keyframes ble-wave-text { + 0% { opacity: 0.7; } + 50% { color: #c6e0ff; opacity: 1; } + 100% { opacity: 0.7; } +} +@keyframes ble-spot-rot { + 0% { transform: rotate(0); } + 100% { transform: rotate(360deg); } +} +@keyframes ble-text2 { + 0% { transform: translateX(-80%); } + 100% { transform: translateX(-50%); } +} +@keyframes ble-text-pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} .heap-low-table tbody tr:hover { background: rgba(255,255,255,.03); } .heap-low-table td.has-suspect { color: var(--red); font-weight: 500; } .heap-low-table td.idle-suspect { color: var(--gd32-hi); } diff --git a/tools/css/motor.css b/tools/css/motor.css index c356331..fe5fba0 100644 --- a/tools/css/motor.css +++ b/tools/css/motor.css @@ -74,11 +74,14 @@ label { display: block; } /* ═══════════════════════ TOOLBAR ═══════════════════════ */ .toolbar { + grid-row: 1; + grid-column: 1; position: relative; z-index: 10; display: flex; align-items: center; gap: var(--sp-4); padding: 0 var(--sp-5); - background: var(--bg-panel); + background: linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 7%, var(--bg-panel)), var(--bg-panel) 42%); border-bottom: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0, 0, 0, .15); } .toolbar-brand { display: flex; align-items: center; gap: var(--sp-3); @@ -146,6 +149,15 @@ label { display: block; } font-family: var(--font-mono); font-size: 0.82rem; outline: none; } .ch-select:focus { border-color: var(--primary); } +/* 轮询卡顿:通道下拉框变黄并呼吸发光(fixed-tick 下某拍未按时完成) */ +.ch-select.stuck { + border-color: color-mix(in srgb, var(--warn) 65%, transparent); + animation: stuckPulse 1.2s ease-in-out infinite; +} +@keyframes stuckPulse { + 0%, 100% { box-shadow: 0 0 2px color-mix(in srgb, var(--warn) 15%, transparent); } + 50% { box-shadow: 0 0 10px color-mix(in srgb, var(--warn) 40%, transparent); } +} /* 刷新速度输入框:与 .ch-select 同风格,宽度收窄 */ .refresh-input { @@ -169,6 +181,8 @@ label { display: block; } /* ═══════════════════════ MAIN LAYOUT ═══════════════════════ */ .main-grid { + grid-row: 2; + grid-column: 1; position: relative; z-index: 1; display: grid; grid-template-columns: minmax(240px, 290px) minmax(0, 0.9fr) minmax(420px, 1.1fr); @@ -178,15 +192,17 @@ label { display: block; } min-height: 0; } .panel { - background: var(--bg-panel); + background: linear-gradient(145deg, color-mix(in srgb, var(--bg-panel) 94%, #ffffff 3%), var(--bg-panel) 52%); border: 1px solid var(--border); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-height: 0; overflow: hidden; + box-shadow: 0 10px 28px rgba(0, 0, 0, .16), inset 0 1px 0 rgba(255, 255, 255, .025); } .panel-head { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); + background: linear-gradient(90deg, var(--primary-dim), transparent 48%); border-bottom: 1px solid var(--border); flex-shrink: 0; } @@ -588,10 +604,12 @@ label { display: block; } /* ═══════════════════════ STATUS BAR ═══════════════════════ */ .statusbar { + grid-row: 3; + grid-column: 1; position: relative; z-index: 10; display: flex; align-items: center; gap: var(--sp-4); padding: 0 var(--sp-5); - background: var(--bg-panel); + background: color-mix(in srgb, var(--bg-panel) 92%, #000000 8%); border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted); overflow: hidden; @@ -641,12 +659,91 @@ label { display: block; } /* disconnected banner */ .net-banner { - display: none; align-items: center; gap: var(--sp-2); + display: none; grid-row: 1; grid-column: 1; + align-self: end; transform: translateY(100%); z-index: 20; + align-items: center; gap: var(--sp-2); padding: 4px var(--sp-5); font-size: .72rem; font-family: var(--font-mono); - background: var(--danger-dim); color: var(--danger); + background: linear-gradient(90deg, color-mix(in srgb, var(--danger) 20%, var(--bg-panel)), var(--danger-dim)); color: var(--danger); border-bottom: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); + box-shadow: 0 8px 20px rgba(0, 0, 0, .24); } .net-banner.show { display: flex; } +body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); } + +/* ═══════════════════════ 命令序列 tab ═══════════════════════ */ +.seq-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-4); } +.seq-status { font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-dim); margin-left: auto; white-space: nowrap; } +.seq-status.on { color: var(--primary); } +.seq-list { display: flex; flex-direction: column; gap: var(--sp-3); } +.seq-empty { + padding: var(--sp-5); text-align: center; color: var(--fg-dim); font-size: 0.8rem; + border: 1px dashed var(--border-strong); border-radius: var(--radius); line-height: 1.7; +} +.seq-step { + padding: var(--sp-3); border-radius: var(--radius); + border: 1px solid var(--border); background: var(--bg-root); + transition: border-color .2s, background .2s; +} +.seq-step:hover { border-color: var(--border-strong); } +.seq-step.current { + border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent); + background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root)); +} +.seq-locked .seq-step { opacity: .55; pointer-events: none; } +.seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); } +.seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; } +.seq-num { + padding: 4px 8px; background: var(--bg-root); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); + font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg); + transition: border-color .2s; +} +.seq-num:focus { border-color: var(--primary); } +/* 隐藏原生上下箭头(样式丑且尺寸不可控):数值直接键入,聚焦后可用键盘 ↑↓ 微调 */ +.seq-num { -moz-appearance: textfield; appearance: textfield; } +.seq-num::-webkit-inner-spin-button, +.seq-num::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } +.seq-step-head .seq-num { width: 6.8em; text-align: right; } +.seq-step-head select { + padding: 4px 8px; background: var(--bg-root); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); + font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg); +} +.seq-step-head select:focus { border-color: var(--primary); } +.seq-hold-label, .seq-hold-unit { font-size: 0.68rem; color: var(--fg-dim); font-family: var(--font-mono); } +.seq-ops { margin-left: auto; display: flex; gap: 2px; } +.seq-ops button { + width: 26px; height: 26px; border-radius: var(--radius-sm); + display: inline-flex; align-items: center; justify-content: center; + color: var(--fg-dim); background: transparent; border: none; + transition: background .15s, color .15s; +} +.seq-ops button svg { flex-shrink: 0; } +.seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); } +.seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); } +.seq-ops button:disabled { opacity: .25; cursor: default; } +.seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); } +.seq-step-fields .field-label { font-size: 0.62rem; } +.seq-step-fields .seq-num { width: 100%; } +/* 动作组:一步内多个电机动作(近同时下发),动作行嵌套在步骤卡片内 */ +.seq-count { + font-family: var(--font-mono); font-size: 0.68rem; color: var(--fg-dim); + padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap; +} +.seq-actions { display: flex; flex-direction: column; gap: var(--sp-2); } +.seq-action { + padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm); + border: 1px solid var(--border); background: var(--bg-panel); +} +.seq-action-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); } +.seq-action-tag { font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; color: var(--fg-muted); min-width: 4.2em; } +.seq-action-head select { + padding: 4px 8px; background: var(--bg-root); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); + font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg); +} +.seq-action-head select:focus { border-color: var(--primary); } +.seq-hint { margin-top: var(--sp-4); font-size: 0.72rem; color: var(--fg-dim); line-height: 1.6; } /* ═══════════════════════ RESPONSIVE ═══════════════════════ */ @media (max-width: 1365px) { diff --git a/tools/index.html b/tools/index.html index f37217a..78db022 100644 --- a/tools/index.html +++ b/tools/index.html @@ -14,7 +14,7 @@ - + @@ -57,12 +57,7 @@
-
🔗 设备连接 - -
+
🔗 设备连接
@@ -77,6 +72,69 @@
+ + @@ -335,13 +393,8 @@ -
- 说明 - 手工 CAN 表单已迁到独立页面,避免和 OTA 控制流混在一起。 -
@@ -370,7 +423,6 @@
- 进入电机控制
@@ -381,6 +433,69 @@
+ + +
@@ -513,7 +628,22 @@
- - + + + + diff --git a/tools/js/ble/session.js b/tools/js/ble/session.js index b389de3..a39be76 100644 --- a/tools/js/ble/session.js +++ b/tools/js/ble/session.js @@ -111,8 +111,45 @@ export async function abortTask() { } export function bindEvents() { - els.connectBtn.addEventListener('click', connectBle); - els.disconnectBtn.addEventListener('click', disconnectBle); + if (els.bleConnectToggle) { + // launch 风格按钮:用 click 而非 change 触发——navigator.bluetooth.requestDevice + // 必须在 user gesture 内调用,change 事件不被识别为 user gesture,会报 + // "Must be handling a user gesture to show a permission request." + // preventDefault 阻止 label 默认勾选,checked 由 setConnectionState 按实际结果驱动。 + els.bleConnectToggle.addEventListener('click', (e) => { + e.preventDefault(); + if (state._bleConnecting) return; // 连接进行中,忽略重复点击 + var btn = document.querySelector('.ble-launch-btn'); + if (state.server) { + els.bleConnectToggle.checked = false; + if (btn) btn.setAttribute('data-ble-state', 'idle'); + disconnectBle(); // → resetUiAfterDisconnect 置 idle + } else { + state._bleConnecting = true; + els.bleConnectToggle.checked = true; // 即时视觉反馈 + if (btn) btn.setAttribute('data-ble-state', 'connecting'); + // 连接结束后按实际结果收口:成功→connected / 失败→idle + // 不依赖 state.js 的 setConnectionState(ES 模块子导入可能被浏览器缓存为旧版) + connectBle().finally(() => { + state._bleConnecting = false; + var b = document.querySelector('.ble-launch-btn'); + if (b) b.setAttribute('data-ble-state', state.server ? 'connected' : 'idle'); + }); + } + }); + + // 兜底:自动断开(onDisconnected)、重连等路径绕过 click handler, + // 用轮询同步 data-ble-state,确保按钮状态始终与 state.server 一致。 + setInterval(function() { + if (state._bleConnecting) return; // 连接中不干扰 + var btn = document.querySelector('.ble-launch-btn'); + if (!btn || !btn.offsetParent) return; // 按钮不可见(WiFi 模式)时跳过 + var expected = state.server ? 'connected' : 'idle'; + if (btn.getAttribute('data-ble-state') !== expected) { + btn.setAttribute('data-ble-state', expected); + } + }, 800); + } els.autoFlashBtn.addEventListener('click', autoFlash); els.abortBtn.addEventListener('click', abortTask); // 上传区事件由 app.js 的 setupSharedUploadZone 统一处理 diff --git a/tools/js/ble/state.js b/tools/js/ble/state.js index ded1be7..2e4bfb9 100644 --- a/tools/js/ble/state.js +++ b/tools/js/ble/state.js @@ -38,8 +38,7 @@ export const state = { export const $ = (id) => document.getElementById(id); export const els = { - connectBtn: $('connectBtn'), - disconnectBtn: $('disconnectBtn'), + bleConnectToggle: $('bleConnectToggle'), autoFlashBtn: $('autoFlashBtn'), abortBtn: $('abortBtn'), uploadZone: $('uploadZone'), @@ -129,8 +128,15 @@ export function setConnectionState(connected) { } if (els.gattState) els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪'; } - els.connectBtn.disabled = connected; - els.disconnectBtn.disabled = !connected; + // launch 风格按钮:checked 与 data-ble-state 由连接结果驱动 + // connecting 态由点击时设置;此处仅在连接结束(成功→connected / 失败→idle)时收口 + if (els.bleConnectToggle) { + if (els.bleConnectToggle.checked !== connected) { + els.bleConnectToggle.checked = !!connected; + } + } + var btn = document.querySelector('.ble-launch-btn'); + if (btn) btn.setAttribute('data-ble-state', connected ? 'connected' : 'idle'); els.autoFlashBtn.disabled = !idle; els.abortBtn.disabled = !state.uploading; diff --git a/tools/js/common/connection.js b/tools/js/common/connection.js index 78fb96e..ee596be 100644 --- a/tools/js/common/connection.js +++ b/tools/js/common/connection.js @@ -193,7 +193,7 @@ function updateBootInitUi(data) { /* ==================== 状态任务列表:增量渲染 ==================== * innerHTML 全量重建会重解析 HTML、重建每条 .status-task-bar(其 ::after 微光 * 动画随之重启、合成层反复创建销毁),是主页每秒掉帧的主要来源之一。 - * 这里按 key(任务名)缓存行节点:集合不变时只改数字/宽度,排序变了仅移动节点。 + * 这里按 key(任务名)缓存行节点:集合不变时只改数字/宽度,排序变了仅更新 CSS order。 * 行结构统一:head(名称/占比) + bar + 3 项 meta。 * items: [{ key, rowCls, pctText, width, fillCls, metaLabels[3], metas[3] }] */ @@ -264,11 +264,15 @@ function renderStatusTaskList(listEl, items) { return; } if (keyStr !== cache.keys) { - /* 集合相同仅排序变化:按新顺序移动既有节点,不重建、不解析 HTML */ - for (var m = 0; m < keys.length; m++) listEl.appendChild(cache.rows[keys[m]]); + /* 集合相同仅排序变化:保留节点位置,避免带动画的光泽伪元素重启 */ cache.keys = keyStr; } - for (var u = 0; u < items.length; u++) updateStatusTaskRow(cache.rows[keys[u]], items[u]); + for (var u = 0; u < items.length; u++) { + var row = cache.rows[keys[u]]; + var order = String(u); + if (row.style.order !== order) row.style.order = order; + updateStatusTaskRow(row, items[u]); + } } /* 原始 JSON 折叠面板:收起时跳过 JSON.stringify + 大段文本重排; @@ -359,7 +363,7 @@ export function updateHeapAndTaskStatusUi(data) { var sampleMs = Number(cpuData.sample_ms) || 0; var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : []; /* 按占用率降序;占比相同再按任务号,保证排序稳定。 - * renderStatusTaskList 在集合不变时只 appendChild 调序,不重建 DOM。 */ + * renderStatusTaskList 在集合不变时只更新 CSS order,不移动或重建 DOM。 */ tasks.sort(function (a, b) { var d = (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0); if (d !== 0) return d; diff --git a/tools/js/motor/main.js b/tools/js/motor/main.js index a88f32c..309859e 100644 --- a/tools/js/motor/main.js +++ b/tools/js/motor/main.js @@ -6,7 +6,7 @@ /* ═══════════════════════════════════════════════════════════════ 电机控制台 — 共用 OTA 主页设备 IP(localStorage dcu_esp_ip),本页无连接配置 数据源: - GET /motor/state 1Hz → 节点列表 + STATUS 解码 + GET /motor/state refreshIntervalMs 驱动(默认 250ms,50~5000 可调)→ 节点列表 + STATUS 解码 POST /can/send_batch 唯一发送入口(CMD / CONFIG / VERSION / OTA) GET /can/feedback 1Hz → 状态栏 RX/TX/ERR/TEC/REC/BUS GET /system/status 进入页自动探测 + 5s 健康检查 / 断线 2s 探测 @@ -74,6 +74,8 @@ const state = { poll: { tick: null, probe: null, status: null, feedback: null }, pollGen: 0, // 递增使在途 tick 失效 tickInFlight: false, + tickStuck: 0, // 连续未按时完成的 motor 轮询拍数(>0 = 卡顿,通道下拉框变黄) + versionWait: null, // 挂载到主 tick 的版本等待 {node,prevSeq,deadline,resolve,reject};命中/超时由 pollLoop 检查 feedbackInFlight: false, // /can/feedback 独立链路占用标记 statusInFlight: false, // /system/status 独立链路占用标记 failCount: 0, // 连续硬失败计数,达阈值才断线 @@ -81,6 +83,7 @@ const state = { lastConnToastAt: 0, // toast 去抖 offlineSince: 0, // 进入离线时刻,短离线恢复不 toast tickN: 0, // 轮询序号(每 5 轮附带 status) + recvTs: [], // 最近 N 次数据到达时间戳滑动窗口(用于计算实际接收 Hz) statusTickN: 0, // status lite/full 交替计数(4 lite + 1 full 循环) phaseDebug: { lastSlowReq: null, slowStats: {} }, // 慢请求诊断(>200ms) }; @@ -90,6 +93,8 @@ const state = { * 用法:控制台敲 motorDbg.state 。 */ window.motorDbg = { state, // 完整 state 引用,方便深挖 + get tickWorker() { return tickWorker; }, // Worker 节拍源对象(null=未创建) + get tickUseFallback() { return tickUseFallback; }, // true=已回退到 setTimeout 兜底 }; /** * 连续「硬失败」多少次才判离线。 @@ -101,6 +106,9 @@ const CONN_TOAST_GAP_MS = 6000; const RECOVER_TOAST_MIN_OFFLINE_MS = 8000; /* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。 */ const TICK_INTERVAL_MS = 250; +/* 固定节拍调度基准:pollLoop 完成后按绝对时间对齐下一个 tick, + * 使实际刷新周期 ≈ 设定值(单次请求耗时不再累加进周期)。 */ +let tickNextAt = 0; const PROBE_INTERVAL_MS = 2000; const MIN_CMD_PERIOD_MS = 80; @@ -133,6 +141,10 @@ function saveUiState() { if (el) o[id] = el.value; } if (state.selectedNode != null) o.selectedNode = state.selectedNode; + /* 命令序列:步骤 + 循环次数(有内容才写入,保持旧数据兼容) */ + if (seq.steps.length > 0 || seq.loops !== 0) { + o.seq = { loops: seq.loops, steps: seq.steps }; + } /* 版本缓存:state.versions 是 Map,序列化为普通对象 */ if (state.versions && state.versions.size > 0) { const vo = {}; @@ -156,6 +168,14 @@ function loadUiState() { if (el && o[id] != null) el.value = o[id]; } if (o.selectedNode != null) state.selectedNode = o.selectedNode; + /* 恢复命令序列(旧数据无 seq 字段则保持空); + * normStep 归一:新结构 {holdMs, actions[]} 直通,旧扁平步骤转单动作组 */ + if (o.seq && Array.isArray(o.seq.steps)) { + seq.steps = o.seq.steps + .filter((s) => s && typeof s === "object") + .map(normStep); + seq.loops = clampNum(parseInt(o.seq.loops, 10) || 0, 0, 9999); + } /* 恢复版本缓存到 state.versions Map */ if (o.versions && typeof o.versions === "object") { for (const [n, v] of Object.entries(o.versions)) { @@ -372,20 +392,36 @@ function cmdVal(id) { return cmdCommitted[id] !== undefined ? cmdCommitted[id] : $("#" + id).value; } -function buildCmdBytes() { +/* 编码一条 CMD 帧(DLC 32)。values = {mode, voltage, current, velocity, angle, + * pidCurP, pidVelP, pidAngP},字段值接受字符串/数字(与输入框值同构)。 + * 表单版与命令序列步骤共用此编码,保证两种发送路径帧格式一致。 */ +function buildCmdBytes(values) { const buf = new ArrayBuffer(32); const dv = new DataView(buf); - const mode = parseInt($("#modeSelect").value, 10) || 0; + const mode = parseInt(values.mode, 10) || 0; dv.setUint8(0, mode); - dv.setUint16(1, Math.round((parseFloat(cmdVal("inVoltage")) || 0) * 1000) & 0xFFFF, true); - dv.setFloat32(3, parseFloat(cmdVal("inCurrent")) || 0, true); - dv.setFloat32(7, parseFloat(cmdVal("inVelocity")) || 0, true); - dv.setFloat32(11, parseFloat(cmdVal("inAngle")) || 0, true); - dv.setInt16(15, parseInt(cmdVal("pidCurP"), 10) || 0, true); - dv.setInt16(17, parseInt(cmdVal("pidVelP"), 10) || 0, true); - dv.setInt16(19, parseInt(cmdVal("pidAngP"), 10) || 0, true); + dv.setUint16(1, Math.round((parseFloat(values.voltage) || 0) * 1000) & 0xFFFF, true); + dv.setFloat32(3, parseFloat(values.current) || 0, true); + dv.setFloat32(7, parseFloat(values.velocity) || 0, true); + dv.setFloat32(11, parseFloat(values.angle) || 0, true); + dv.setInt16(15, parseInt(values.pidCurP, 10) || 0, true); + dv.setInt16(17, parseInt(values.pidVelP, 10) || 0, true); + dv.setInt16(19, parseInt(values.pidAngP, 10) || 0, true); return new Uint8Array(buf); } +/* 表单版:读当前 CMD 输入区已提交值(cmdCommitted) */ +function buildFormCmdBytes() { + return buildCmdBytes({ + mode: $("#modeSelect").value, + voltage: cmdVal("inVoltage"), + current: cmdVal("inCurrent"), + velocity: cmdVal("inVelocity"), + angle: cmdVal("inAngle"), + pidCurP: cmdVal("pidCurP"), + pidVelP: cmdVal("pidVelP"), + pidAngP: cmdVal("pidAngP"), + }); +} function bytesToHexStr(bytes) { let s = ""; for (let i = 0; i < bytes.length; i++) s += pad2(bytes[i]); @@ -428,6 +464,8 @@ async function sendBatch(frames, opts) { } state.sendInFlight = true; const n = frames.length; + /* 通道:默认用顶部选中通道;调用方可经 opts.ch 指定(命令序列每步独立通道) */ + const txCh = (opts && Number.isInteger(opts.ch)) ? opts.ch : state.ch; /* 单帧 3s:ESP32 handler 内部单帧超时 1200ms + HTTP 排队余量; * 旧值 8s 会在 SPI 卡顿时长时间霸占互斥锁,阻塞后续周期 CMD */ const timeoutMs = (n <= 1) ? 3000 : Math.max(15000, n * 500); @@ -435,7 +473,7 @@ async function sendBatch(frames, opts) { const resp = await fetch(getBaseUrl() + "/can/send_batch", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ch: state.ch, frames: frames }), + body: JSON.stringify({ ch: txCh, frames: frames }), signal: AbortSignal.timeout(timeoutMs), }); const text = await resp.text(); @@ -465,6 +503,7 @@ async function sendBatch(frames, opts) { /* BUS_OFF / TEC 高:停止发送,3s 冷却 */ state.busCooldownUntil = nowMs() + 3000; stopRepeat(true); + stopSeq("总线异常,命令序列已停止", true); toast("总线异常 (" + GD32_ERR_DESC[gerr] + ") — 停止发送,冷却 3s", "err"); return { ok: false, results: results, busFault: gerr, desc: GD32_ERR_DESC[gerr] }; } @@ -502,6 +541,79 @@ async function sendOneFrameWhenFree(id, dlc, dataHex, opts) { return res; } +/** + * 突发(近同时)发送多个电机动作:POST /can/send_burst。 + * ESP32 转 0xC7 SPI 突发,GD32 背靠背装载各通道 CAN 邮箱,组内偏差 µs 级; + * 超过单次突发容量时 ESP32 内部分块背靠背连发(组间 ~1-2ms)。 + * 与 sendBatch 共享 state.sendInFlight 互斥;流控策略与 sendBatch 一致。 + * @param {Array} actions [{ch,node,mode,voltage,current,velocity,angle,pid*}] + * @returns {Promise<{ok,results?,networkError?,busFault?,busy?,desc?}>} + */ +async function sendBurst(actions, opts) { + opts = opts || {}; + if (state.sendInFlight) { + return { ok: false, desc: "上一次发送仍在进行(已跳过)", busy: true }; + } + if (!state.connected && !opts.allowOffline) { + return { ok: false, desc: "设备未连接", networkError: true }; + } + state.sendInFlight = true; + const frames = actions.map((a) => ({ + ch: a.ch, node: a.node, data: bytesToHexStr(buildCmdBytes(a)), + })); + const timeoutMs = Math.max(3000, frames.length * 250); + try { + const resp = await fetch(getBaseUrl() + "/can/send_burst", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ suffix: 0x0200, dlc: 32, frames: frames }), + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await resp.text(); + let json = {}; + try { json = text ? JSON.parse(text) : {}; } catch (e) { throw new Error("响应非合法 JSON"); } + if (!resp.ok) throw new Error("HTTP " + resp.status + ": " + (json.message || text || "发送失败")); + if (!json.results || !Array.isArray(json.results)) throw new Error("响应缺少 results"); + + const results = json.results.slice(); + while (results.length < frames.length) { + results.push({ status: "error", code: -2, gd32_err: 0 }); + } + + /* 流控:取第一个错误帧的状态(同 sendBatch 逻辑) */ + const firstErr = results.find((r) => r.status !== "ok") || {}; + const gerr = firstErr.gd32_err; + if (gerr === 0xE4 && !opts.retried) { + /* 邮箱满:随机退避 50~150ms 重试一次 */ + state.sendInFlight = false; + const backoff = 50 + Math.floor(Math.random() * 101); + await sleep(backoff); + return sendBurst(actions, Object.assign({}, opts, { retried: true })); + } + if (gerr === 0xE2 || gerr === 0xE3) { + /* BUS_OFF / TEC 高:停止发送,3s 冷却 */ + state.busCooldownUntil = nowMs() + 3000; + stopRepeat(true); + stopSeq("总线异常,命令序列已停止", true); + toast("总线异常 (" + GD32_ERR_DESC[gerr] + ") — 停止发送,冷却 3s", "err"); + return { ok: false, results: results, busFault: gerr, desc: GD32_ERR_DESC[gerr] }; + } + const allOk = results.every((r) => r.status === "ok"); + return { ok: allOk, results: results, desc: allOk ? "ok" : describeResult(firstErr) }; + } catch (e) { + noteHardFailure(e, "send"); + return { ok: false, networkError: true, desc: e.message || "网络错误" }; + } finally { + state.sendInFlight = false; + /* latest-wins 排空(与 sendBatch 一致) */ + if (!opts.retried && state.pendingCmd && state.connected && nowMs() >= state.busCooldownUntil) { + const p = state.pendingCmd; + state.pendingCmd = null; + sendBatch([{ id: p.id, dlc: p.dlc, data: p.data }], { silent: true }); + } + } +} + /* ═══════════════════════ 连接稳健性 ═══════════════════════ * 设备端 /system/status、/motor/state、/can/feedback 共用 httpJsonWorkMutex。 * 任一并发 → 500 busy;旧逻辑还把瞬时失败变成 LED 黄灯/断线 toast → 「偶尔闪一下」。 @@ -600,6 +712,7 @@ function enterDisconnected(msg, source) { state.failCount = 0; state.offlineSince = nowMs(); stopRepeat(true); + stopSeq("连接中断,命令序列已停止", true); stopDataPolling(); updateConnUi(); $("#netBanner").classList.add("show"); @@ -739,6 +852,21 @@ function stopDataPolling() { stopTimer("status"); state.tickInFlight = false; state.feedbackInFlight = false; + state.tickStuck = 0; /* 停止轮询 → 清除卡顿标记 */ + /* 清理挂起的版本等待:主 tick 已停,resolve(null) 让调用方走超时分支,避免 Promise 悬挂 */ + if (state.versionWait) { + const w = state.versionWait; + state.versionWait = null; + w.resolve(null); + } + updateTickStuckUi(); + /* 停止 Worker 节拍(后台节流兜底) */ + if (tickWorker) { + tickWorker.postMessage({ type: "stop" }); + try { tickWorker.terminate(); } catch (e) { /* ignore */ } + tickWorker = null; + tickUseFallback = false; + } } /** @@ -749,6 +877,8 @@ function startPolling() { state.failCount = 0; state.tickN = 0; state.statusTickN = 0; + state.recvTs = []; /* 清除旧的到达时间窗口,Hz 从新数据重新累积 */ + tickNextAt = 0; /* 节拍基准重置:首个 tick 立即执行后按新间隔对齐 */ const gen = state.pollGen; schedulePollLoop(gen, 0); scheduleFeedbackPoll(gen, 0); @@ -797,37 +927,121 @@ function scheduleFeedbackPoll(gen, delayMs) { }); }, delayMs == null ? 1000 : delayMs); } -function schedulePollLoop(gen, delayMs) { +/* ═══════ Web Worker 节拍(后台标签页不节流) ═══════ + * 浏览器对后台(隐藏)标签页的 setTimeout/setInterval 强制 ≥1s 节流, + * 使刷新速度在切后台后降到 ~1Hz。Dedicated Worker 的定时器不受此限制, + * 故把 motor 轮询的节拍源放到 tick.worker.js:Worker 按设定间隔定期发 + * 'tick' 消息,主线程收到后才真正发起一次 fetchMotorState。 + * 前台行为与原来一致;后台也能守住设定的刷新速度。 */ +const TICK_WORKER_URL = "js/motor/tick.worker.js?v=20260802g"; +let tickWorker = null; /* Web Worker 节拍源(null = 未创建/失败) */ +let tickUseFallback = false; /* Worker 创建/加载失败 → 回退到 setTimeout 兜底 */ + +/** + * tick 统一入口:Worker 每 tick、或 setTimeout 兜底触达时调用。 + * 负责单飞互斥 + 卡顿标记(是否跳过由 tickInFlight 决定)。 + */ +function tickHandler() { + if (!state.connected || state.probing) return; + if (state.tickInFlight) { + /* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过并标记卡顿 */ + state.tickStuck = (state.tickStuck || 0) + 1; + updateTickStuckUi(); + return; + } + pollLoop(state.pollGen); +} + +/** + * 确保 Worker 就绪并按 interval 校时。Worker 已存在时只更新间隔。 + * Worker 加载失败(如 404 或不被服务端支持)→ 回退到 setTimeout 兜底。 + */ +function ensureTickWorker(interval) { + if (tickUseFallback) return; + if (tickWorker) { + tickWorker.postMessage({ type: "update", interval }); + return; + } + try { + tickWorker = new Worker(TICK_WORKER_URL); + } catch (err) { + tickWorker = null; + tickUseFallback = true; + startTickFallback(state.pollGen); + return; + } + tickWorker.onmessage = (e) => { if (e.data === "tick") tickHandler(); }; + tickWorker.onerror = () => { + try { tickWorker.terminate(); } catch (e) { /* ignore */ } + tickWorker = null; + tickUseFallback = true; + startTickFallback(state.pollGen); + }; + tickWorker.postMessage({ type: "start", interval }); +} + +/* Worker 不可用时的兜底:留用原 setTimeout 固定节拍(前台正常,后台受节流)。 */ +function startTickFallback(gen) { if (gen !== state.pollGen) return; stopTimer("tick"); - const delay = delayMs != null ? delayMs : (state.refreshIntervalMs || TICK_INTERVAL_MS); + tickNextAt = 0; + scheduleTickFallbackFrame(gen, 0); +} +function scheduleTickFallbackFrame(gen, delayMs) { + if (gen !== state.pollGen) return; + stopTimer("tick"); + const iv = state.refreshIntervalMs || TICK_INTERVAL_MS; + let delay = delayMs != null ? delayMs : iv; + if (delayMs == null && tickNextAt > 0) { + /* 固定节拍对齐:按绝对时间调度,请求耗时不再拖慢周期 */ + const remain = tickNextAt - nowMs(); + if (remain <= 0) delay = (tickNextAt + iv < nowMs()) ? iv : 10; + else delay = remain; + } state.poll.tick = setTimeout(function () { state.poll.tick = null; - pollLoop(gen); + if (gen !== state.pollGen) return; + tickHandler(); + tickNextAt = (tickNextAt > 0 ? tickNextAt : nowMs()) + (state.refreshIntervalMs || TICK_INTERVAL_MS); + scheduleTickFallbackFrame(gen, null); }, delay); } /** * motor 轮询主链:只跑 fetchMotorState,feedback/status 已剥离独立定时器。 - * motor 挂起 3s 超时期间,feedback 仍正常刷 CAN 统计(不再被 motor 拖)。 + * 节拍由 Web Worker(或兜底 setTimeout)驱动;pollLoop 自身不重排, + * 单飞互斥和卡顿标记在 tickHandler 侧完成。失败/断线由上层停止调度。 */ async function pollLoop(gen) { - if (gen !== state.pollGen || !state.connected || state.probing) return; - if (state.tickInFlight) { - schedulePollLoop(gen); - return; - } + if (gen !== state.pollGen) return; state.tickInFlight = true; try { await fetchMotorState(gen); - if (gen !== state.pollGen || !state.connected || state.probing) return; + if (gen !== state.pollGen) return; state.tickN += 1; } finally { state.tickInFlight = false; - if (gen === state.pollGen && state.connected && !state.probing) { - schedulePollLoop(gen); - } + if (state.tickStuck) { state.tickStuck = 0; updateTickStuckUi(); } /* 请求结束 → 卡顿解除 */ } + /* 版本查询搭便车主 tick 节拍:tickInFlight 已释放,checkVersionWait 异步触发, + * 不阻塞下一拍 tickHandler(/motor/version 即使慢也不会触发 tickStuck 误报)。 */ + if (state.versionWait) { + checkVersionWait(); + } +} + +/** + * 调度 motor 节拍。delayMs === 0 表示立即触发首拍并启动 Worker; + * 之后每拍由 Worker 自然驱动,不再从这里重排。 + */ +function schedulePollLoop(gen, delayMs) { + if (gen !== state.pollGen) return; + if (tickUseFallback) { + if (delayMs === 0) startTickFallback(gen); + return; + } + ensureTickWorker(state.refreshIntervalMs || TICK_INTERVAL_MS); + if (delayMs === 0) setTimeout(tickHandler, 0); /* 立即首拍,后续靠 Worker tick */ } async function fetchSystemStatus(gen, wantFull) { @@ -972,6 +1186,11 @@ function onMotorState(data) { const motors = (data && data.motors) ? data.motors : []; state.motors = motors; + /* 记录实际数据到达时间戳滑动窗口,用于 Hz 计算 */ + const ts = state.recvTs; + ts.push(Date.now()); + if (ts.length > RECV_HZ_WINDOW * 2) ts.splice(0, ts.length - RECV_HZ_WINDOW); + /* 解码每个 节点×通道 的 STATUS 帧 */ const decoded = new Map(); for (const m of motors) { @@ -1193,19 +1412,43 @@ nodeListEl.addEventListener("keydown", (e) => { }); /* ─── 节点相关标签更新 ─── */ +/* 数据到达记录:滑动窗口长度(足够在 50ms~5s 间隔下都平滑) */ +const RECV_HZ_WINDOW = 10; + +/* STATUS 面板右上角频率:基于实际数据到达时间计算,不用用户设定的数值做死算。 + * 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz。 + * 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */ +function statusHzText() { + const ts = state.recvTs; + /* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃) */ + const maxAge = (state.refreshIntervalMs || TICK_INTERVAL_MS) * 2 * RECV_HZ_WINDOW; + const cutoff = Date.now() - maxAge; + while (ts.length > 0 && ts[0] < cutoff) ts.shift(); + if (ts.length >= 2) { + const span = ts[ts.length - 1] - ts[0]; + const avg = span / (ts.length - 1); + if (avg > 0 && avg < 60000) { + const hz = 1000 / avg; + return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz"; + } + } + /* 窗口数据不足时回退到理论值,不让面板空着 */ + const hz = 1000 / (state.refreshIntervalMs || TICK_INTERVAL_MS); + return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz"; +} function updateNodeLabels() { const n = state.selectedNode; if (n == null) { $("#nodeBadge").textContent = "NODE -"; $("#cmdIdLabel").textContent = "ID 0x000-0200 · DLC 32"; - $("#statusIdLabel").textContent = "0x000-0100 · 1 Hz"; + $("#statusIdLabel").textContent = "0x000-0100 · " + statusHzText(); document.querySelectorAll(".cfgNodeNibble").forEach((el) => (el.textContent = "-")); return; } const hex = nodeHex(n); $("#nodeBadge").textContent = `NODE ${n}`; $("#cmdIdLabel").textContent = `ID ${hexId(ID_CMD(n))} · DLC 32`; - $("#statusIdLabel").textContent = `${hexId(ID_STATUS(n))} · 1 Hz`; + $("#statusIdLabel").textContent = `${hexId(ID_STATUS(n))} · ${statusHzText()}`; document.querySelectorAll(".cfgNodeNibble").forEach((el) => (el.textContent = hex)); } @@ -1299,6 +1542,19 @@ function busStateText(bs) { return parts.length ? parts.join("|") : ("0x" + bs.toString(16)); } +/* ─── 轮询卡顿指示(fixed-tick 下某拍未按时完成 = 上一次请求仍占用链路) ─── + * 通道下拉框变黄 + 悬浮提示显示连续卡顿拍数;请求结束自动清除。 */ +function updateTickStuckUi() { + const sel = $("#chSelect"); + if (!sel) return; + const stuck = (state.tickStuck || 0) > 0; + sel.classList.toggle("stuck", stuck); + sel.title = stuck + ? `电机状态轮询卡顿:连续 ${state.tickStuck} 拍未按时完成(上一次请求仍占用链路)` + : ""; + updateChSelectOptions(); /* 同步选框字色:卡顿黄色优先于在线绿色 */ +} + /** 根据通道启用/信号状态更新下拉框选项: * - 禁用通道贴 "(禁用)" 标签并置灰 * - 有信号通道(存在在线电机)贴 "●N" 标记并着色 */ @@ -1314,10 +1570,12 @@ function updateChSelectOptions() { opt.style.color = !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : ""); } /* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效), - * 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白 */ + * 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白。 + * 卡顿优先:轮询卡顿时显示黄色,覆盖在线绿色(updateTickStuckUi 触发本函数同步) */ 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)" : ""); + sel.style.color = (state.tickStuck || 0) > 0 ? "var(--warn,#f59e0b)" + : (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "")); } function onFeedback(data) { @@ -1376,7 +1634,7 @@ async function doSendCmd(silent) { if (!requireNode()) return; if (inBusCooldown()) return; const node = state.selectedNode; - const bytes = buildCmdBytes(); + const bytes = buildFormCmdBytes(); const id = ID_CMD(node); const dataHex = bytesToHexStr(bytes); const mode = parseInt($("#modeSelect").value, 10) || 0; @@ -1418,6 +1676,7 @@ function getPeriod() { function startRepeat() { if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; + stopSeq("周期发送已启动,命令序列已停止", true); /* 与命令序列互斥 */ const period = getPeriod(); periodInput.value = period; stopTimer("repeat"); @@ -1439,6 +1698,335 @@ periodInput.addEventListener("change", () => { if (repeatTimer) { stopRepeat(true); startRepeat(); } }); +/* ═══════════════════════ 命令序列发送(循环发送设定步骤) ═══════════════════════ + * 每步 = 一个「同步动作组」:组内每个 action = 一个电机目标(ch/node/模式/电压/电流/ + * 速度/角度/PID)。执行本步时把组内全部 action 经一次 POST /can/send_burst 下发 + * (ESP32 转 0xC7 突发,GD32 背靠背入各通道 CAN 邮箱),组内多电机近同时发送; + * 发完本步再等 holdMs 进下一步。循环次数 0 = 无限循环。与周期发送互斥。 + * 目标完全独立于左侧选中节点:每个 action 自带 ch/node,可多通道、多电机混合搭配。 + * 兼容旧数据:无 actions 的扁平旧步骤自动归一为单动作步骤(normStep)。 + * 步骤持久化在 saveUiState/loadUiState(序列化到 o.seq)。 */ +const seq = { + running: false, + stopRequested: false, + steps: [], /* [{holdMs, actions:[{ch,node,mode,voltage,current,velocity,angle,pidCurP,pidVelP,pidAngP}]}] */ + loops: 0, /* 循环次数,0 = 无限 */ + round: 1, /* 当前轮(1 起) */ + stepIdx: 0, /* 当前步索引 */ + statusText: "未运行", +}; +const seqListEl = $("#seqList"); +const seqStartBtn = $("#seqStartBtn"); +const seqLoopsInput = $("#seqLoops"); + +function seqModeOptions(selected) { + return Object.entries(MODES).map(([v, m]) => + ``).join(""); +} +function seqChOptions(selected) { + return [0, 1, 2].map((c) => + ``).join(""); +} +function seqNodeOptions(selected) { + return FIXED_MOTOR_NODES.map((n) => + ``).join(""); +} +/* 归一单个 action:补齐 ch/node/各参数默认值(ch/node 缺失时回退当前通道/选中节点) */ +function actionFromAny(a) { + a = a || {}; + return { + ch: Number.isInteger(a.ch) ? clampNum(a.ch, 0, 2) : clampNum(state.ch || 0, 0, 2), + node: (Number.isInteger(a.node) && a.node >= 1 && a.node <= 9) ? a.node : (state.selectedNode || 1), + mode: a.mode != null ? String(a.mode) : "3", + voltage: a.voltage != null ? a.voltage : "24.000", + current: a.current != null ? a.current : "0", + velocity: a.velocity != null ? a.velocity : "100", + angle: a.angle != null ? a.angle : "0", + pidCurP: a.pidCurP != null ? a.pidCurP : "100", + pidVelP: a.pidVelP != null ? a.pidVelP : "100", + pidAngP: a.pidAngP != null ? a.pidAngP : "100", + }; +} +/* 归一步骤:新结构 {holdMs, actions[]};旧扁平步骤(含 mode 等顶层字段)→ 单动作组 */ +function normStep(s) { + s = s || {}; + const holdMs = clampNum(parseInt(s.holdMs, 10) || 1000, 0, 600000); + if (Array.isArray(s.actions) && s.actions.length) { + return { holdMs: holdMs, actions: s.actions.map(actionFromAny) }; + } + return { holdMs: holdMs, actions: [actionFromAny(s)] }; +} +function cloneAction(a) { return Object.assign({}, actionFromAny(a)); } +/* 读取当前 CMD 表单已提交值 + 当前通道/选中节点,作为一个新 action(与 buildFormCmdBytes 同源) */ +function defaultActionFromForm() { + return { + ch: clampNum(state.ch || 0, 0, 2), + node: (state.selectedNode >= 1 && state.selectedNode <= 9) ? state.selectedNode : 1, + mode: $("#modeSelect").value, + voltage: cmdVal("inVoltage"), + current: cmdVal("inCurrent"), + velocity: cmdVal("inVelocity"), + angle: cmdVal("inAngle"), + pidCurP: cmdVal("pidCurP"), + pidVelP: cmdVal("pidVelP"), + pidAngP: cmdVal("pidAngP"), + }; +} + +function renderSeqList() { + if (!seq.steps.length) { + seqListEl.innerHTML = '
尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发
'; + } else { + seqListEl.innerHTML = seq.steps.map((s, i) => ` +
+
+ 步骤 ${i + 1} + ${s.actions.length} 电机 + 保持 + + ms +
+ + + + + +
+
+
+ ${s.actions.map((a, j) => ` +
+
+ 电机 ${j + 1} + + + +
+ +
+
+
+ + + + + + + +
+
`).join("")} +
+
`).join(""); + } + updateSeqUi(); +} + +function updateSeqUi() { + const running = seq.running; + seqStartBtn.textContent = running ? "■ 停止序列" : "▶ 开始序列"; + seqStartBtn.classList.toggle("btn-primary", running); + const st = $("#seqStatus"); + if (st) { + if (running) { + const cs = seq.steps[seq.stepIdx]; + const cnt = cs ? cs.actions.length : 0; + st.textContent = `运行中 · 步骤 ${seq.stepIdx + 1}/${seq.steps.length} (${cnt} 电机) · 第 ${seq.round} 轮`; + st.classList.add("on"); + } else { + st.textContent = seq.statusText || `未运行 · ${seq.steps.length} 步`; + st.classList.remove("on"); + } + } + seqListEl.classList.toggle("seq-locked", running); + seqListEl.querySelectorAll(".seq-step").forEach((row) => { + row.classList.toggle("current", running && parseInt(row.dataset.idx, 10) === seq.stepIdx); + }); + seqLoopsInput.disabled = running; + $("#seqAddBtn").disabled = running; + $("#seqClearBtn").disabled = running; +} + +/* 读取当前 CMD 表单已提交值作为新步骤:单动作组(用户可再「+ 电机」扩展) */ +function seqReadStepFromForm() { + return { holdMs: 1000, actions: [defaultActionFromForm()] }; +} + +/* 步骤行编辑(input 事件同时覆盖 number 输入与 select 选择): + * holdMs 归属步骤本身;ch/node/mode/各参数归属 .seq-action(data-aidx)对应的 action */ +seqListEl.addEventListener("input", (e) => { + const row = e.target.closest(".seq-step"); + if (!row) return; + const step = seq.steps[parseInt(row.dataset.idx, 10)]; + if (!step) return; + const t = e.target; + if (t.dataset.f === "holdMs") { + step.holdMs = clampNum(parseInt(t.value, 10) || 0, 0, 600000); + } else { + const actRow = t.closest(".seq-action"); + const action = actRow ? step.actions[parseInt(actRow.dataset.aidx, 10)] : null; + if (!action) return; + if (t.classList.contains("seq-ch")) action.ch = clampNum(parseInt(t.value, 10) || 0, 0, 2); + else if (t.classList.contains("seq-node")) action.node = clampNum(parseInt(t.value, 10) || 1, 1, 9); + else if (t.classList.contains("seq-mode")) action.mode = t.value; + else if (t.dataset.f) action[t.dataset.f] = t.value; + else return; + } + debounceSaveUi(); +}); + +/* 步骤操作:上移/下移/复制/删除 + 动作级增删(运行中锁定,指针事件被 .seq-locked 禁用) */ +seqListEl.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-op]"); + if (!btn || seq.running) return; + const row = btn.closest(".seq-step"); + if (!row) return; + const i = parseInt(row.dataset.idx, 10); + const step = seq.steps[i]; + if (!step) return; + const op = btn.dataset.op; + if (op === "up" && i > 0) { + const t = seq.steps[i]; seq.steps[i] = seq.steps[i - 1]; seq.steps[i - 1] = t; + } else if (op === "down" && i < seq.steps.length - 1) { + const t = seq.steps[i]; seq.steps[i] = seq.steps[i + 1]; seq.steps[i + 1] = t; + } else if (op === "dup") { + seq.steps.splice(i + 1, 0, { holdMs: step.holdMs, actions: step.actions.map(cloneAction) }); + } else if (op === "del") { + seq.steps.splice(i, 1); + } else if (op === "addAction") { + /* 默认复制本步最后一个动作(改个节点即成另一台电机);空步骤则取自 CMD 表单 */ + step.actions.push(step.actions.length + ? cloneAction(step.actions[step.actions.length - 1]) + : defaultActionFromForm()); + } else if (op === "delAction") { + const actRow = btn.closest(".seq-action"); + if (!actRow) return; + step.actions.splice(parseInt(actRow.dataset.aidx, 10), 1); + if (!step.actions.length) seq.steps.splice(i, 1); /* 无动作则整步删除 */ + } else { + return; + } + renderSeqList(); + saveUiState(); +}); + +$("#seqAddBtn").addEventListener("click", () => { + if (seq.running) return; + seq.steps.push(seqReadStepFromForm()); + renderSeqList(); + saveUiState(); +}); + +$("#seqClearBtn").addEventListener("click", async () => { + if (seq.running || seq.steps.length === 0) return; + const ok = await showModal("清空命令序列", `将删除全部 ${seq.steps.length} 个步骤。`); + if (!ok) return; + seq.steps = []; + renderSeqList(); + saveUiState(); +}); + +seqLoopsInput.addEventListener("change", () => { + seq.loops = clampNum(parseInt(seqLoopsInput.value, 10) || 0, 0, 9999); + seqLoopsInput.value = seq.loops; + saveUiState(); +}); + +seqStartBtn.addEventListener("click", () => { + if (seq.running) { stopSeq("已手动停止"); return; } + startSeq(); +}); + +function startSeq() { + if (!state.connected) { toast("设备未连接", "err"); return; } + if (seq.steps.length === 0) { toast("请先添加至少一个步骤", "warn"); return; } + if (inBusCooldown()) return; + stopRepeat(true); /* 与周期发送互斥 */ + seq.loops = clampNum(parseInt(seqLoopsInput.value, 10) || 0, 0, 9999); + seqLoopsInput.value = seq.loops; + seq.running = true; + seq.stopRequested = false; + seq.round = 1; + seq.stepIdx = 0; + seq.statusText = ""; + updateSeqUi(); + const totalActions = seq.steps.reduce((n, s) => n + s.actions.length, 0); + toast(`命令序列已启动 · ${seq.steps.length} 步 / ${totalActions} 电机动作 · 循环 ${seq.loops === 0 ? "无限" : seq.loops + " 次"}(同步动作组近同时下发)`, "ok"); + seqRunLoop(); +} + +function stopSeq(reason, silent) { + seq.running = false; + seq.stopRequested = true; + seq.statusText = reason || "已停止"; + updateSeqUi(); + if (!silent) toast(reason || "命令序列已停止", "warn"); +} + +/* 序列主循环:stop-and-wait 逐步发送。每步把组内全部动作经一次 sendBurst + * (/can/send_burst → 0xC7 突发)近同时下发,发完等 holdMs 推进; + * 循环次数用尽或异常停止。 */ +async function seqRunLoop() { + while (seq.running && !seq.stopRequested) { + if (!state.connected || state.probing) { stopSeq("连接断开,序列已停止", true); return; } + if (nowMs() < state.busCooldownUntil) { await sleep(200); continue; } /* 总线冷却等待 */ + const step = seq.steps[seq.stepIdx]; + if (!step || !Array.isArray(step.actions) || !step.actions.length) { stopSeq("序列数据无效,已停止"); return; } + let chBad = null; + for (const a of step.actions) { + if (!state.chEnabled[a.ch]) { chBad = a.ch; break; } + } + if (chBad != null) { stopSeq(`步骤 ${seq.stepIdx + 1} 的通道 CH${chBad} 已禁用,序列已停止`); return; } + updateSeqUi(); + const targets = step.actions.map((a) => `CH${a.ch}·N${a.node}`).join(" "); + const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + $("#sbLastSend").textContent = `最近发送: 序列步${seq.stepIdx + 1} → ${step.actions.length} 电机 [${targets}] ${time}`; + + /* 互斥发送:通道忙(手动 CMD 等在途)时小退避重试本步,不推进步骤 */ + let res; + let busyTries = 0; + do { + res = await sendBurst(step.actions); + if (!seq.running) return; + if (res.busy && busyTries < 20) { busyTries++; await sleep(50); } + } while (res.busy && busyTries < 20); + + if (res.busFault) { stopSeq("总线异常,序列已停止"); return; } + if (res.networkError) { stopSeq("网络错误,序列已停止"); return; } + if (!res.ok) { stopSeq("发送失败:" + (res.desc || "未知错误")); return; } + for (const a of step.actions) state.lastSentMode.set(a.node, parseInt(a.mode, 10) || 0); + state.txCount++; + renderNodeList(); + + if (!seq.running) return; + const holdMs = clampNum(parseInt(step.holdMs, 10) || 0, 0, 600000); + if (holdMs > 0) await sleep(holdMs); + if (!seq.running) return; + + if (seq.stepIdx + 1 >= seq.steps.length) { + if (seq.loops > 0 && seq.round >= seq.loops) { + stopSeq(`已完成 ${seq.loops} 轮`); + return; + } + seq.round++; + seq.stepIdx = 0; + } else { + seq.stepIdx++; + } + } +} + /* ═══════════════════════ 配置操作(CMD_CONFIG,DLC 24) ═══════════════════════ */ const CONFIG_ACTIONS = { motReset: { title: "电机复位", bit: 0, desc: "将向节点发送 motReset 脉冲,电机立即停止并清除故障标志。运行中的电机将失去力矩输出,请确认机械安全。" }, @@ -1570,15 +2158,39 @@ async function fetchMotorVersionNode(node) { } } -/** 轮询直到 node 的版本 seq 超过 prevSeq(新应答);超时返回 null。 */ -async function waitMotorVersion(node, prevSeq, timeoutMs) { - const deadline = nowMs() + (timeoutMs || 5000); - while (nowMs() < deadline) { - const v = await fetchMotorVersionNode(node); - if (v && (prevSeq == null || v.seq > prevSeq)) return v; - await sleep(200); +/** 版本等待:挂载到主 tick(pollLoop),每拍检查一次 seq,命中或超时才 resolve。 + * 节拍跟随 state.refreshIntervalMs(与 motor 状态轮询共用同一调度), + * 不再开独立 sleep 循环,避免与主 tick 节拍错位造成的窗口延迟。 + * 超时返回 null(与原轮询语义一致)。 */ +function waitMotorVersionOnTick(node, prevSeq, timeoutMs) { + return new Promise((resolve) => { + state.versionWait = { + node, + prevSeq, + deadline: nowMs() + (timeoutMs || 5000), + resolve, + }; + }); +} + +/** 主 tick 每拍调用:检查挂起的版本等待。 + * seq 增长 → resolve 并清空;超时 → resolve(null) 并清空。 + * 瞬时错误忽略,下一拍重试。 */ +async function checkVersionWait() { + const w = state.versionWait; + if (!w) return; + if (nowMs() >= w.deadline) { + state.versionWait = null; + w.resolve(null); + return; } - return null; + try { + const v = await fetchMotorVersionNode(w.node); + if (v && (w.prevSeq == null || v.seq > w.prevSeq)) { + state.versionWait = null; + w.resolve(v); + } + } catch (e) { /* 瞬时 busy 忽略,下拍再查 */ } } $("#queryVerBtn").addEventListener("click", async () => { @@ -1615,7 +2227,7 @@ $("#queryVerBtn").addEventListener("click", async () => { } state.txCount++; - const v = await waitMotorVersion(node, prevSeq, 5000); + const v = await waitMotorVersionOnTick(node, prevSeq, 5000); if (v && v.version) { const parsed = parseVersionPayload(v.version); if (parsed && (parsed.soft || parsed.hard || parsed.boot)) { @@ -2040,6 +2652,7 @@ async function startOta() { if (!ok) return; stopRepeat(true); + stopSeq("OTA 开始,命令序列已停止", true); ota.running = true; ota.node = node; /* 记录目标节点:finished 后 verifyNewApp 查版本用 */ ota.startTime = performance.now(); @@ -2229,7 +2842,7 @@ function startOtaPoll() { const prevSeq = before ? before.seq : null; const res = await sendOneFrameWhenFree(ID_GET_VERSION_REQ(node), 24, bytesToHexStr(new Uint8Array(24))); if (res.ok) { - const v = await waitMotorVersion(node, prevSeq, 4000); + const v = await waitMotorVersionOnTick(node, prevSeq, 4000); if (v && v.version) { const parsed = parseVersionPayload(v.version); if (parsed && parsed.soft) { @@ -2369,16 +2982,15 @@ function applyRefreshRate() { const v = clampNum(parseInt(refreshRateInput.value, 10) || 250, 50, 5000); refreshRateInput.value = v; state.refreshIntervalMs = v; + updateNodeLabels(); /* STATUS 面板右上角 Hz 跟随刷新速度 */ + /* 后台节流兜底模式下清掉旧 setTimeout 节拍,避免双链 */ if (state.poll.tick) { clearTimeout(state.poll.tick); state.poll.tick = null; } - /* HTTP 轮询驱动:清掉旧节拍后必须立即按新间隔重排 motor 链, - * 否则轮询链断死、STATUS 面板停更。 - * tickInFlight 时在途 pollLoop 的 finally 也会重排,schedulePollLoop 内部 - * stopTimer 保证不会双链并发。 */ if (state.connected && !state.probing) { - schedulePollLoop(state.pollGen, 0); + tickNextAt = 0; /* 节拍基准重置:立即按新间隔重新对齐 */ + schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */ sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */ } const hz = 1000 / v; @@ -2668,6 +3280,8 @@ updateConnUi(); renderNodeList(); renderStatusPanel(); restoreVersionUi(state.selectedNode); /* 回填上次缓存的版本号 */ +renderSeqList(); /* 命令序列步骤渲染(依赖 loadUiState 恢复的数据) */ +seqLoopsInput.value = seq.loops; bindAutoSave(); initCmdCommit(); /* CMD 输入提交制:必须在 loadUiState 回填之后初始化,否则初始提交值是 HTML 默认值 */ otaRestorePersistedFirmware(); /* 异步恢复上次固件(无阻塞) */ diff --git a/tools/js/motor/tick.worker.js b/tools/js/motor/tick.worker.js new file mode 100644 index 0000000..f3ae9a5 --- /dev/null +++ b/tools/js/motor/tick.worker.js @@ -0,0 +1,33 @@ +/** + * tick.worker.js - 电机轮询节拍心跳 Worker + * + * 浏览器对后台(隐藏)标签页的 setTimeout/setInterval 做 ≥1s 节流, + * 但 Dedicated Worker 的定时器不受此限制。 + * 此 Worker 只做一件事:按主线程设定的间隔定期发送 'tick' 消息。 + * + * 主线程收到 tick 后触发 pollLoop(如不在飞行中), + * 从而实现后台标签页仍保持设定刷新速度。 + * + * 消息协议: + * { type: 'start', interval: N } → 启动节拍,interval 单位 ms + * { type: 'stop' } → 停止节拍 + * { type: 'update', interval: N } → 更新间隔(不重置 tick 计数) + */ +let timer = null; + +self.onmessage = function(e) { + const msg = e.data; + if (!msg || !msg.type) return; + + if (msg.type === 'start' || msg.type === 'update') { + if (timer) clearInterval(timer); + timer = setInterval(() => { + self.postMessage('tick'); + }, msg.interval || 250); + } else if (msg.type === 'stop') { + if (timer) { + clearInterval(timer); + timer = null; + } + } +}; \ No newline at end of file diff --git a/tools/js/ota/console-mode.js b/tools/js/ota/console-mode.js index 97c8432..d1018eb 100644 --- a/tools/js/ota/console-mode.js +++ b/tools/js/ota/console-mode.js @@ -62,8 +62,6 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { var bleBtn = document.getElementById('modeBleBtn'); if (wifiCards) wifiCards.style.display = isBle ? 'none' : ''; if (bleCards) bleCards.style.display = isBle ? '' : 'none'; - var headBtns = document.getElementById('bleHeadActions'); - if (headBtns) headBtns.style.display = isBle ? '' : 'none'; if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary'; if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline'; applySharedModeUi(); diff --git a/tools/motor.html b/tools/motor.html index 4432a95..e84c231 100644 --- a/tools/motor.html +++ b/tools/motor.html @@ -6,7 +6,7 @@ 电机控制台 — Motor CAN FD Console - + @@ -144,6 +144,7 @@ +
@@ -232,6 +233,27 @@
[信息] 选固件 → 上传并比对 flash SHA-256(失败重传≤2次)→ 发进BOOT命令 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限
+ +
+
+ + + + + 未运行 +
+
+

+ 命令序列:每步为一个「同步动作组」,组内每个动作 = 一台电机目标(通道/节点/模式/电压/电流/速度/角度/PID); + 执行本步时组内全部动作经一次突发请求近同时下发(同组偏差微秒级;超过单次容量自动分组,组间约 1-2ms), + 发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。 + 循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。 +

+
@@ -345,6 +367,6 @@ - + \ No newline at end of file