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

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

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

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

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

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

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

- 版本查询checkVersionWait移到tickInFlight释放后异步触发
- 避免/motor/version请求拖长tick导致tickStuck误报通道闪黄
This commit is contained in:
小龙虾
2026-08-10 00:54:12 +08:00
parent f9cbcc396b
commit ef6d3a4be3
23 changed files with 2524 additions and 157 deletions
+2 -2
View File
@@ -14,8 +14,8 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 76
#define FW_VERSION_STRING "0.3.76" // 重启恢复提速2:httpd就绪后再打 MAIN「启动」banner(防环缩容裁掉);前端用 uptime<120s 判重启完成,不再干等10s
#define FW_VERSION_PATCH 78
#define FW_VERSION_STRING "0.3.78" // 版本查询checkVersionWait移到tickInFlight释放后异步触发,避免/motor/version请求拖长tick导致tickStuck误报通道闪黄
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
+109 -57
View File
@@ -1,13 +1,16 @@
/*
* can_feedback_task.cpp - 被动监听 GD32 主动推送的事件
* can_feedback_task.cpp - GD32 周期查询 + 事件消费任务
*
* 架构(2026-08 改造后):
* 0xC1 CAN 反馈已改为请求驱动(前端请求时 ESP32 主动向 GD32 查询),
* 不再被动缓存。本任务仅保留 0xC4/0xC5/0xC6/0xD2 事件分发:
* 0xC4 ADC 调试反馈、0xC5 多电机状态、0xC6 多电机版本、0xD2 电机 OTA 进度
* 这些仍由 GD32 主动推送,ESP32 被动接收。
* 0xC1 CAN 反馈:请求驱动(HTTP 侧 canFdQueryFeedback),本任务不处理。
* 0xC4 ADC / 0xC5 电机状态:由 GD32 主动按铃推送改为本任务周期查询——
* 0xC5 每 250ms 查 6 页、0xC4 每 1s 查 1 帧,查询响应 payload 格式与推送
* 一致,复用 onMotorStateEvent()/onGd32AdcFeedbackEvent() 解析入库。
* 消除 OTA 期间 GD32 悬空按铃(ESP32 抑制 ring 不读)导致“按铃超时取消”刷屏。
* 0xC6 电机版本 / 0xD2 电机 OTA 进度:仍由 GD32 事件驱动推送,本任务 takeEvent 消费。
*
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务(不查询不消费);
* 电机 OTA 期间(motorOtaIsActive())也暂停,避免与 0xD1 喂数抢占 SPI。
*/
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -27,78 +30,127 @@
namespace {
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态推送
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送
constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
constexpr uint32_t kQueryMotorStateMs = 250U; ///< 0xC5 电机状态查询周期
constexpr uint32_t kQueryAdcMs = 1000U; ///< 0xC4 ADC 查询周期
constexpr uint32_t kEventWaitMs = 20U; ///< 单次等 0xC6/0xD2 事件超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态查询
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay 进度推送
constexpr size_t kPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
static volatile bool s_paused = false;
/**
* @brief can_fb 任务主循环:takeEvent 分发 0xC4/0xC5/0xC6/0xD2 推送事件。
* 0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),不再在此处理。
* @brief 按页查询 GD32 电机状态(0xC5 请求驱动,替代 GD32 250ms 推送)。
* 任一页失败立即中断本轮:GD32 未就绪/瞬时错误时不空耗 6×200ms,下轮再试;
* page0 会刷新全局字段(active_count/boot_active),部分页成功即部分更新。
*/
static void queryMotorStatePages(void)
{
uint8_t req[1] = {0U};
for (uint8_t page = 0U; page < MOTOR_TOTAL_PAGES; ++page) {
req[0] = page;
uint8_t resp_type = 0U;
uint8_t resp_buf[kPayloadMax] = {};
size_t resp_len = 0U;
esp_err_t err = SpiMaster::instance().transact(
kSpiMotorStateType, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (err != ESP_OK || resp_type != SPI_OTA_RSP_ACK ||
resp_len < MOTOR_PAGE_HEADER) {
return;
}
(void)onMotorStateEvent(resp_buf, resp_len);
}
}
/**
* @brief 查询 GD32 ADC 调试快照(0xC4 请求驱动,替代 GD32 1Hz 推送)。
* GD32 0xC4 handler 回自身类型 0xC4(异于 0xC1/0xC5 的 0x80),两者皆接受。
*/
static void queryAdcFeedback(void)
{
uint8_t req[1] = {0U};
uint8_t resp_type = 0U;
uint8_t resp_buf[kPayloadMax] = {};
size_t resp_len = 0U;
esp_err_t err = SpiMaster::instance().transact(
kSpiAdcFeedbackType, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (err != ESP_OK || resp_len < 1U) {
return;
}
if (resp_type != kSpiAdcFeedbackType && resp_type != SPI_OTA_RSP_ACK) {
return;
}
(void)onGd32AdcFeedbackEvent(resp_buf, resp_len);
}
/**
* @brief 消费剩余事件驱动推送(0xC6 电机版本 / 0xD2 电机 OTA 进度)。
* 0xC4/0xC5 已改请求驱动,不再有对应推送;未知类型静默跳过。
*/
static void drainPushEvents(uint32_t wait_ms)
{
uint8_t ring_type = 0U;
uint8_t ring_payload[kPayloadMax] = {};
size_t ring_len = 0U;
if (!SpiMaster::instance().takeEvent(&ring_type, ring_payload,
sizeof(ring_payload), &ring_len,
wait_ms)) {
return;
}
if (ring_type == kSpiMotorVersionType) {
(void)onMotorVersionEvent(ring_payload, ring_len);
} else if (ring_type == kSpiMotorOtaStatusType) {
onMotorOtaStatusPush(ring_payload, ring_len);
}
}
/**
* @brief can_fb 任务主循环:周期查询 0xC5/0xC4 + 消费 0xC6/0xD2 推送。
* OTA / 电机 OTA 期间暂停,SPI 交由 OTA 独占。
*/
static void taskLoop(void *)
{
DEBUG_INFO(kTag, "事件监听任务启动 (0xC4 ADC / 0xC5 motor / 0xC6 ver / 0xD2 ota)");
DEBUG_INFO(kTag, "请求驱动查询任务启动 (0xC5 250ms / 0xC4 1s / 0xC6,0xD2 推送)");
/* 冷启动延后开始监听,错开上电初始化窗口 */
/* 冷启动延后开始查询,错开上电初始化窗口 */
{
esp_reset_reason_t rr = esp_reset_reason();
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
constexpr uint32_t kCanFbDeferMs = 1500U;
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始事件监听",
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始查询",
(unsigned long)kCanFbDeferMs);
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
}
}
uint8_t ring_type = 0U;
uint8_t ring_payload[kRingPayloadMax] = {};
size_t ring_len = 0U;
TickType_t last_motor_q = xTaskGetTickCount();
TickType_t last_adc_q = xTaskGetTickCount();
for (;;) {
if (s_paused) {
if (s_paused || motorOtaIsActive()) {
vTaskDelay(pdMS_TO_TICKS(kPauseDelayMs));
continue;
}
/* 阻塞等待 GD32 按铃事件(超时则重试) */
if (!SpiMaster::instance().takeEvent(&ring_type, ring_payload,
sizeof(ring_payload), &ring_len,
kWaitRingMs)) {
continue;
/* 周期查询:到期才执行(时间差对比,暂停恢复后立即补一次) */
const TickType_t now = xTaskGetTickCount();
if ((now - last_motor_q) >= pdMS_TO_TICKS(kQueryMotorStateMs)) {
queryMotorStatePages();
last_motor_q = xTaskGetTickCount();
}
if ((now - last_adc_q) >= pdMS_TO_TICKS(kQueryAdcMs)) {
queryAdcFeedback();
last_adc_q = xTaskGetTickCount();
}
/* 0xC4 ADC 调试反馈:GD32 关开关则不推;ESP32 始终可解析(无本地开关) */
if (ring_type == kSpiAdcFeedbackType) {
(void)onGd32AdcFeedbackEvent(ring_payload, ring_len);
continue;
}
/* 0xC5 多电机状态推送:GD32 250ms 分页推送,ESP32 解析入缓存 */
if (ring_type == kSpiMotorStateType) {
(void)onMotorStateEvent(ring_payload, ring_len);
continue;
}
/* 0xC6 多电机版本推送:GD32 收到电机版本应答(0x00FF)后事件驱动推送 */
if (ring_type == kSpiMotorVersionType) {
(void)onMotorVersionEvent(ring_payload, ring_len);
continue;
}
/* 0xD2 电机 OTA relay 进度推送 */
if (ring_type == kSpiMotorOtaStatusType) {
onMotorOtaStatusPush(ring_payload, ring_len);
continue;
}
/* 0xC1 已改为请求驱动,不再在此处理;未知类型静默跳过 */
/* 消费 0xC6/0xD2:短阻塞等 worker 入队(事件驱动唤醒) */
drainPushEvents(kEventWaitMs);
}
}
@@ -130,7 +182,7 @@ bool startCanFeedbackTask()
}
/**
* @brief 暂停/恢复事件监听。
* @brief 暂停/恢复周期查询与事件消费。
* @param pause true=OTA 期间暂停;false=恢复
*/
void pauseCanFeedbackTask(bool pause)
@@ -138,9 +190,9 @@ void pauseCanFeedbackTask(bool pause)
if (s_paused == pause) return; // 幂等:状态未变时不重复日志
s_paused = pause;
if (pause) {
DEBUG_DBG(kTag, "事件监听已暂停(OTA 进行中)");
DEBUG_DBG(kTag, "周期查询已暂停(OTA 进行中)");
} else {
DEBUG_DBG(kTag, "事件监听已恢复");
DEBUG_DBG(kTag, "周期查询已恢复");
}
}
@@ -151,4 +203,4 @@ void pauseCanFeedbackTask(bool pause)
bool canFeedbackPaused()
{
return s_paused;
}
}
+6 -5
View File
@@ -1,16 +1,17 @@
/*
* can_feedback_task.h - 事件监听任务(0xC4/0xC5/0xC6/0xD2 推送)
* can_feedback_task.h - GD32 周期查询 + 事件消费任务
*
* 2026-08 架构改造:0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),
* 不再被动缓存。本任务仅分发 GD32 主动推送的 ADC/电机状态/版本/OTA 进度事件。
* 2026-08 架构改造:0xC1 CAN 反馈请求驱动(canFdQueryFeedback);
* 0xC4 ADC / 0xC5 电机状态改为本任务周期查询(250ms/1s);
* 0xC6 电机版本 / 0xD2 电机 OTA 进度保留事件推送,由本任务 takeEvent 消费。
*/
#ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H
/** @brief 暂停/恢复事件监听(OTA 期间调用 pause=true)。 */
/** @brief 暂停/恢复周期查询与事件消费(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause);
/** @brief 查询监听是否处于暂停(OTA / send_batch 期间为 true)。
/** @brief 查询是否处于暂停(OTA / send_batch / 电机 OTA 期间为 true)。
* 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */
bool canFeedbackPaused();
+1 -1
View File
@@ -1172,7 +1172,7 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
* 实现“重发快且不漏”(旧逻辑见 WAIT 后只能干 poll 到 stall,ACK 丢了就永丢)。
* - 等 ACK 期间可预组下一包(与从机处理重叠)。
*/
constexpr uint32_t kMaxDataSends = 3U;
constexpr uint32_t kMaxDataSends = 5U;
/* 单次发送后等 ACK 上限:GD32 正常 ms 级回 ACK(ISR 回 WAIT、主循环回 ACK),
* 50ms 未到即重发(DUP-SEQ 回放捞回),不再干等 6s(用户指定 50ms)。
* credit==0 的排空积压由 transact 内部 credit 背压消化,此处无需长等。 */
+228
View File
@@ -1330,6 +1330,234 @@ esp_err_t can_send_batch_handler(httpd_req_t *req)
return ESP_OK;
}
/**
* @brief POST /can/send_burst — CAN 跨通道突发发送(一次 SPI 事务多帧近同时)。
*
* JSON: { "suffix": 512, "dlc": 32, "frames": [ { "ch":0, "node":1, "data":"HEX" }, ... ] }
* suffix = CAN ID 低 16 位(CMD=0x0200),id=(node<<16)|suffix;dlc 为本批统一数据长度。
* frames 可超过单次突发容量:内部按 canBurstCapacity(dlc) 分块,块与块之间背靠背
* 连续发起 SPI 事务(间隔 ~<1ms),实现「组内近同时、组间尽量紧」的多电机同步驱动。
*
* 返回: { "status":"ok","sent":N,"results":[ {"status":"ok"} | {"status":"error","code":c,"gd32_err":g}, ... ] }
* 单帧总线故障(0xE2/0xE3)/邮箱满(0xE4)等经 ACK 的每帧状态字节透传,前端据此处理冷却。
*/
esp_err_t can_send_burst_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
constexpr int kMaxTotalFrames = 24; /* 单次请求帧数上限(一步同步电机数上限) */
constexpr uint32_t kBurstTimeoutMs = 200U; /* 单次突发 SPI 事务超时 */
constexpr int kMaxBody = 49152;
int content_len = req->content_len;
if (content_len <= 0 || content_len > kMaxBody) {
return http_send_json_error(req, "400 Bad Request", "bad_body",
"body 长度无效或过大");
}
BatchBodyReader reader(req, content_len);
auto findKey = [&](const char *key) -> bool {
const size_t kl = strlen(key);
size_t match = 0;
int c;
while ((c = reader.nextChar()) != -1) {
if (c == key[match]) {
if (++match == kl) return true;
} else {
match = (c == key[0]) ? 1 : 0;
}
}
return false;
};
auto skipWs = [&]() {
int c;
while ((c = reader.peekChar()) != -1) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
reader.nextChar();
} else {
break;
}
}
};
auto findChar = [&](char target) -> bool {
int c;
while ((c = reader.nextChar()) != -1) {
if (c == target) return true;
}
return false;
};
auto readInt = [&]() -> long {
skipWs();
long sign = 1;
int c = reader.peekChar();
if (c == '-') { reader.nextChar(); sign = -1; }
long v = 0;
while ((c = reader.peekChar()) != -1 && c >= '0' && c <= '9') {
v = v * 10 + (c - '0');
reader.nextChar();
}
return v * sign;
};
char frameJson[320];
auto readFrameObj = [&]() -> int {
skipWs();
int c = reader.peekChar();
if (c != '{') return -1;
reader.nextChar();
frameJson[0] = '{';
size_t n = 1;
int depth = 1;
while ((c = reader.nextChar()) != -1) {
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
if (depth == 0) {
if (n + 1 >= sizeof(frameJson)) return -1;
frameJson[n++] = '}';
frameJson[n] = '\0';
return (int)n;
}
}
if (n + 1 >= sizeof(frameJson)) return -1;
frameJson[n++] = (char)c;
}
return -1;
};
/* ── prefix:suffix / dlc / frames ── */
if (!findKey("\"suffix\"") || !findChar(':')) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 suffix 字段");
}
long suffix_val = readInt();
if (!findKey("\"dlc\"") || !findChar(':')) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 dlc 字段");
}
long dlc_val = readInt();
if (dlc_val < 0 || dlc_val > CAN_MAX_DLC) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 超出范围");
}
if (!findKey("\"frames\"") || !findChar('[')) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "缺少 frames 字段");
}
const uint8_t dlc = static_cast<uint8_t>(dlc_val);
const uint16_t suffix = static_cast<uint16_t>(suffix_val & 0xFFFF);
const uint8_t cap = canBurstCapacity(dlc);
if (cap == 0U) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "dlc 过大无法突发");
}
/* 静态缓冲:httpd 单线程,避免大栈占用 */
static uint8_t s_chnode[kMaxTotalFrames];
static uint8_t s_data[kMaxTotalFrames][CAN_MAX_DLC];
static uint8_t s_status[kMaxTotalFrames];
int total = 0;
while (total < kMaxTotalFrames) {
skipWs();
int c = reader.peekChar();
if (c == -1 || c == ']') break;
if (c == ',') { reader.nextChar(); continue; }
if (c != '{') { reader.nextChar(); continue; }
int flen = readFrameObj();
if (flen <= 0) break;
JsonView fr = jsonParse(frameJson);
if (!fr.valid) {
return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧 JSON 非法");
}
JsonView ch_it = jsonGet(&fr, "ch");
JsonView node_it = jsonGet(&fr, "node");
JsonView data_it = jsonGet(&fr, "data");
if (!jsonIsNumber(&ch_it) || !jsonIsNumber(&node_it)) {
return http_send_json_error(req, "400 Bad Request", "bad_frame", "帧缺少 ch/node");
}
const int chv = static_cast<int>(jsonGetNumberValue(&ch_it));
const int nodev = static_cast<int>(jsonGetNumberValue(&node_it));
if (chv < 0 || chv >= CAN_CHANNEL_MAX) {
return http_send_json_error(req, "400 Bad Request", "bad_frame", "ch 非法(0~2)");
}
uint8_t data_buf[CAN_MAX_DLC];
uint8_t nbytes = 0U;
if (jsonIsString(&data_it)) {
size_t hex_len = 0U;
const char *hex = jsonGetStringValue(&data_it, &hex_len);
if (hex) {
nbytes = static_cast<uint8_t>(
http_hex_to_bytes_n(hex, hex_len, data_buf, CAN_MAX_DLC));
}
}
if (nbytes != dlc) {
return http_send_json_error(req, "400 Bad Request", "bad_frame",
"帧 data 长度与 dlc 不一致");
}
s_chnode[total] = static_cast<uint8_t>(((chv & 0x0F) << 4) | (nodev & 0x0F));
if (dlc > 0U) {
memcpy(s_data[total], data_buf, dlc);
}
total++;
}
if (total == 0) {
return http_send_json_error(req, "400 Bad Request", "bad_args", "frames 为空");
}
/* ── 分块背靠背突发发送 ── */
pauseCanFeedbackTask(true);
bool aborted = false;
for (int off = 0; off < total && !aborted; off += cap) {
const int n = ((total - off) > cap) ? cap : (total - off);
can_burst_entry_t entries[CAN_BURST_HARD_MAX];
for (int i = 0; i < n; ++i) {
entries[i].chnode = s_chnode[off + i];
entries[i].data = s_data[off + i];
}
uint8_t chunk_status[CAN_BURST_HARD_MAX];
uint8_t gerr = 0U;
(void)esp_task_wdt_reset();
const int rc = canFdSendBurst(suffix, dlc, entries, static_cast<uint8_t>(n),
chunk_status, sizeof(chunk_status),
kBurstTimeoutMs, &gerr);
if (rc < 0) {
/* SPI 失败/协议错误:本块及之后标记失败并中止 */
const uint8_t code = (rc == -3) ? gerr : 0xEEU;
for (int i = 0; i < n; ++i) s_status[off + i] = code;
for (int j = off + n; j < total; ++j) s_status[j] = 0xEEU;
aborted = true;
break;
}
for (int i = 0; i < n; ++i) s_status[off + i] = chunk_status[i];
}
pauseCanFeedbackTask(false);
/* ── 组装 JSON 响应 ── */
static char resp_buf[1600];
int off = snprintf(resp_buf, sizeof(resp_buf),
"{\"status\":\"ok\",\"sent\":%d,\"results\":[", total);
for (int i = 0; i < total; ++i) {
const uint8_t st = s_status[i];
int w;
if (st == 0x00U) {
w = snprintf(resp_buf + off, sizeof(resp_buf) - off,
"%s{\"status\":\"ok\"}", (i == 0) ? "" : ",");
} else {
w = snprintf(resp_buf + off, sizeof(resp_buf) - off,
"%s{\"status\":\"error\",\"code\":-3,\"gd32_err\":%u}",
(i == 0) ? "" : ",", (unsigned)st);
}
if (w < 0 || off + w >= (int)sizeof(resp_buf)) break;
off += w;
}
if (off < (int)sizeof(resp_buf) - 2) {
resp_buf[off++] = ']';
resp_buf[off++] = '}';
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, resp_buf, off);
}
esp_err_t spi_send_handler(httpd_req_t *req)
{
http_set_cors_headers(req);
+2
View File
@@ -20,6 +20,8 @@ esp_err_t restart_handler(httpd_req_t *req);
esp_err_t logs_handler(httpd_req_t *req);
/** @brief CAN 发送(唯一入口):同通道 1~N 帧,单帧即 frames 长度为 1。 */
esp_err_t can_send_batch_handler(httpd_req_t *req);
/** @brief CAN 跨通道突发发送:多帧经 0xC7 一次/多次 SPI 事务背靠背发出,近同时。 */
esp_err_t can_send_burst_handler(httpd_req_t *req);
esp_err_t spi_send_handler(httpd_req_t *req);
esp_err_t system_status_handler(httpd_req_t *req);
esp_err_t system_probe_handler(httpd_req_t *req);
+1
View File
@@ -477,6 +477,7 @@ esp_err_t WifiOta::httpServerStart()
{"/system/probe", HTTP_GET, system_probe_handler},
{"/system/heap_low_log", HTTP_GET, system_heap_low_log_handler},
{"/can/send_batch", HTTP_POST, can_send_batch_handler},
{"/can/send_burst", HTTP_POST, can_send_burst_handler},
{"/can/feedback", HTTP_GET, can_feedback_handler},
{"/can/mode", HTTP_GET, can_mode_handler},
{"/can/mode", HTTP_POST, can_mode_handler},
+107
View File
@@ -20,10 +20,15 @@
namespace {
constexpr uint8_t kSpiCanFrameType = 0xC0U;
constexpr uint8_t kSpiCanBurstType = 0xC7U;
constexpr uint8_t kSpiCanSetModeType = 0xC2U;
constexpr uint8_t kFeedbackFixedHeader = 28U;
constexpr uint8_t kFeedbackMaxPage = 1U; /* GD32 仅 page 0/1 */
/* 突发 payload 上限 = V2_MAX_PAYLOAD(236);头 4B = count+dlc+suffix(2)。 */
constexpr uint8_t kBurstHeaderLen = 4U;
constexpr uint16_t kBurstPayloadCap = 236U;
/**
* @brief 经 SPI 发送一帧 CAN,并解析 ACK/ERROR。
*
@@ -169,6 +174,108 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
recv_buf, recv_max, timeout_ms, gd32_err_out);
}
/**
* @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。
* payload = 4B 头 + count*(1+dlc),须 ≤ 236B;并受 GD32 硬上限约束。
*/
uint8_t canBurstCapacity(uint8_t dlc)
{
if (dlc > CAN_MAX_DLC) {
return 0U;
}
const uint16_t entry_len = static_cast<uint16_t>(1U + dlc);
const uint16_t avail = (kBurstPayloadCap > kBurstHeaderLen)
? static_cast<uint16_t>(kBurstPayloadCap - kBurstHeaderLen)
: 0U;
const uint16_t cap = static_cast<uint16_t>(avail / entry_len);
return static_cast<uint8_t>((cap < CAN_BURST_HARD_MAX) ? cap : CAN_BURST_HARD_MAX);
}
/**
* @brief 发送一次 CAN 突发(SPI 0xC7)。
*
* GD32 在 CS 释放 ISR 内对每帧调用非阻塞 transmitFrame(仅装载邮箱),
* 全部装载完成后才回 ACK,因此各通道帧近同时上线;ESP32 这里等待的只是
* 这一个 ACK,并非逐帧等待,不会破坏组内同时性。
*
* 返回值: count 成功, -1 参数错误, -2 SPI 通信失败, -3 GD32 拒绝。
*/
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
const can_burst_entry_t *entries, uint8_t count,
uint8_t *status_out, uint8_t status_max,
uint32_t timeout_ms, uint8_t *gd32_err_out)
{
if (gd32_err_out) {
*gd32_err_out = 0U;
}
if (dlc > CAN_MAX_DLC || entries == nullptr || count == 0U) {
return -1;
}
if (count > canBurstCapacity(dlc)) {
return -1;
}
uint8_t tx_buf[240];
size_t tx_len = 0U;
tx_buf[tx_len++] = count;
tx_buf[tx_len++] = dlc;
tx_buf[tx_len++] = static_cast<uint8_t>((id_suffix >> 8) & 0xFFU);
tx_buf[tx_len++] = static_cast<uint8_t>(id_suffix & 0xFFU);
for (uint8_t i = 0U; i < count; ++i) {
tx_buf[tx_len++] = entries[i].chnode;
if (dlc > 0U) {
if (entries[i].data == nullptr) {
return -1;
}
memcpy(&tx_buf[tx_len], entries[i].data, dlc);
tx_len += dlc;
}
}
uint8_t resp[32];
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = ESP_OK;
/* 与 0xC0 相同:排空可能残留的陈旧 WAIT,直到读到本次真正的 ACK/ERROR */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
ret = SpiMaster::instance().transact(
kSpiCanBurstType, tx_buf, tx_len,
&resp_type, resp, sizeof(resp), &resp_len, timeout_ms);
if (resp_type != SPI_OTA_RSP_WAIT) {
break;
}
vTaskDelay(pdMS_TO_TICKS(3));
}
if (ret != ESP_OK) {
DEBUG_WARN("CAN", "BURST SPI fail n=%u %s",
(unsigned)count, esp_err_to_name(ret));
return -2;
}
if (resp_type == SPI_OTA_RSP_ACK) {
/* resp[0]=count, resp[1..count]=每帧状态(CanTxResult 码) */
const uint8_t n = (resp_len > 0U) ? resp[0] : 0U;
if (status_out != nullptr && status_max > 0U) {
const uint8_t copy_n = (n < status_max) ? n : status_max;
for (uint8_t i = 0U; i < copy_n; ++i) {
status_out[i] = (static_cast<size_t>(1U + i) < resp_len)
? resp[1U + i] : 0xFFU;
}
}
return static_cast<int>(n);
}
const uint8_t gd32_err = (resp_len > 0U) ? resp[0] : 0xFFU;
if (gd32_err_out) {
*gd32_err_out = gd32_err;
}
DEBUG_WARN("CAN", "BURST拒 n=%u r=%02X e=%02X",
(unsigned)count, resp_type, gd32_err);
return -3;
}
/**
* @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。
*
+38
View File
@@ -53,6 +53,44 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
uint32_t timeout_ms,
uint8_t *gd32_err_out = nullptr);
// ============================================================
// CAN 跨通道突发发送(SPI 0xC7)
// 一次 SPI 事务携带多帧,GD32 背靠背装载各通道 CAN 邮箱,
// 组内近同时上线(偏差 <1ms),用于多电机同步驱动。
// ============================================================
#define CAN_BURST_HARD_MAX (16) ///< GD32 单次突发硬上限(帧数)
/** @brief 突发条目:chnode=(ch<<4)|node,data 指向 dlc 字节数据。 */
typedef struct {
uint8_t chnode; ///< (channel<<4) | node
const uint8_t *data; ///< dlc 字节数据
} can_burst_entry_t;
/**
* @brief 给定统一 DLC,返回单次突发最多可容纳的帧数。
* 受 236B SPI payload(4B 头 + count*(1+dlc))与 GD32 硬上限约束。
*/
uint8_t canBurstCapacity(uint8_t dlc);
/**
* @brief 发送一次 CAN 突发(SPI 0xC7)。count 必须 <= canBurstCapacity(dlc)。
* GD32 在 ISR 内对每帧调用非阻塞 transmitFrame,各通道邮箱背靠背装载。
* @param id_suffix CAN ID 低 16 位(CMD=0x0200);id=(node<<16)|suffix。
* @param dlc 所有条目统一数据长度(0~64)。
* @param entries count 个条目(chnode + data)。
* @param count 条目数(1~canBurstCapacity(dlc))。
* @param status_out 输出每帧状态(GD32 CanTxResult 码,0x00=成功),可为 nullptr。
* @param status_max status_out 容量。
* @param timeout_ms SPI 事务超时。
* @param gd32_err_out 返回 -3 时的 GD32 细分错误码;可为 nullptr。
* @return 成功返回 count;-1 参数错误;-2 SPI 通信失败;-3 GD32 ERROR。
*/
int canFdSendBurst(uint16_t id_suffix, uint8_t dlc,
const can_burst_entry_t *entries, uint8_t count,
uint8_t *status_out, uint8_t status_max,
uint32_t timeout_ms,
uint8_t *gd32_err_out = nullptr);
// ============================================================
// 不重复 ID 快照表(SPI 0xC3 协议配套)
// ============================================================
+43 -7
View File
@@ -70,15 +70,17 @@ static size_t spiRespReadBudget(void)
/**
* @brief 已知"短响应"的主动请求类型:应答帧 ≤ ~70B(实测 0xA1 PING ~60-70B、
* DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短)。
* DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短、0xC4 ADC 20B)。
* 对这些类型的主动 poll 可把读预算从 256 收紧到 96B,省 ~160B 0xFF 填充时钟
* (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)与被动 0xC1 事件读。
* (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)、0xC1/0xC5
* (分页响应 ~236B)与被动 0xC1 事件读。
*/
static bool isShortRespType(uint8_t type)
{
switch (type) {
case 0xA1U: case 0x02U: case 0x01U: case 0xF7U:
case 0xB0U: case 0xC0U: case 0xC2U: case 0xD1U:
case 0xC4U: case 0xC7U:
return true;
default:
return false;
@@ -87,6 +89,24 @@ static bool isShortRespType(uint8_t type)
/** 短响应读预算(覆盖最长 ~70B 应答 + 余量)。 */
static constexpr size_t kShortRespReadBudget = 96U;
/**
* @brief 已知 GD32 APP 在 onCsRelease ISR 内 µs 级装载响应的主动请求类型。
* 这些类型 CS 释放后无需 5ms 静默:waitSpiDataReady 由下降沿 ISR 精确唤醒
* (与 0xD1/0xA1 同机制)。仅 BOOT 主循环处理的 OTA 命令(0x01/0x02/0x03)
* 保留 5ms——过早盲读会打断 BOOT 的 EXTI/DMA 时序(实测 CHALLENGE 超时)。
*/
static bool isAppIsrFastRespType(uint8_t type)
{
switch (type) {
case 0xA1U: case 0xC0U: case 0xC1U: case 0xC2U:
case 0xC3U: case 0xC4U: case 0xC5U: case 0xC7U: case 0xD1U:
case 0xF7U:
return true;
default:
return false;
}
}
/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
bool SpiMaster::isAppOnlySpiType(uint8_t type)
{
@@ -646,6 +666,19 @@ void SpiMaster::workerLoop()
continue;
}
/* 无 job:先处理 GD32 重启重同步(session=0 响应置位;5s 冷却防空转)。
* OTA 中继/暂停期间不 HELLO,避免干扰在途 OTA 事务。 */
if (m_resyncPending && !m_suppressRingService && !m_spiPaused) {
const TickType_t now_tick = xTaskGetTickCount();
if ((uint32_t)(now_tick - m_lastResyncTick) >= pdMS_TO_TICKS(5000)) {
m_lastResyncTick = now_tick;
m_resyncPending = false;
DEBUG_INFO("SPI", "GD32 session=0,HELLO 重同步...");
(void)hello(1500U, 0U);
continue;
}
}
/* 无 job:等 GPIO / 提交唤醒。
* 超时只是无事件时的兜底回扫周期;ISR 的 xTaskNotifyFromISR 会立即唤醒
* 本任务,响应延迟不由这里决定。过短会令 spi_worker(优先级6) 忙转压住
@@ -981,6 +1014,9 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len,
DEBUG_WARN("SPI", "GD32 session=0 (对端已重启?) 放行 RESPONSE t=0x%02X #%lu",
hdr.type, (unsigned long)s_sess0_logs);
}
/* GD32 重启后 v2 未激活:置重同步标志,worker 空闲时 HELLO 重建 session。
* 功能由 session=0 放行兜底,重同步只是把链路拉回校验态。 */
self.m_resyncPending = true;
}
static uint32_t s_evt_sess0_logs = 0U;
if (is_event && (++s_evt_sess0_logs % 64U) == 1U) {
@@ -1641,13 +1677,13 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
}
/* CS 释放后的静默期分型处理:
* - 0xD1(电机 OTA 数据)/ 0xA1(APP PING):GD32 APP 在 onCsRelease ISR
* 内 µs 级装好响应并拉低 PB6 → 零盲等,waitSpiDataReady 由下降沿 ISR
* 精确唤醒,旧 1ms 固定延时压制喂速~47KB/s,现由事件驱动提速。
* - 其他类型(GD32 relay/BOOT AES/HELLO 等):保留 5ms——BOOT 在主循环
* - APP ISR 响应类型(0xC1/0xC4/0xC5 查询、0xD1 电机 OTA、0xA1 PING 等):
* GD32 APP 在 onCsRelease ISR 内 µs 级装好响应并拉低 PB6 → 零盲等,
* waitSpiDataReady 由下降沿 ISR 精确唤醒(见 isAppIsrFastRespType)。
* - 其他类型(BOOT OTA 命令 0x01/0x02/0x03 等):保留 5ms——BOOT 在主循环
* 处理 CHALLENGE/flash,poll 前 2 轮盲读 CS 过早打进去会打断其
* EXTI/DMA 时序(实测去掉后 CHALLENGE 超时)。 */
if (type != 0xD1U && type != 0xA1U) {
if (!isAppIsrFastRespType(type)) {
vTaskDelay(pdMS_TO_TICKS(5));
}
+4
View File
@@ -322,6 +322,10 @@ private:
volatile bool m_suppressRingService = false;
/** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */
volatile bool m_spiPaused = false;
/** GD32 重启检测(响应帧 session=0)置位:worker 空闲时 HELLO 重同步。 */
volatile bool m_resyncPending = false;
/** 上次 HELLO 重同步时刻(冷却 5s,防 GD32 不响应时反复 HELLO)。 */
volatile uint32_t m_lastResyncTick = 0;
/** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */
volatile uint8_t m_lastReqType = 0xFFU;
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */
+9 -3
View File
@@ -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 重生。
+737
View File
@@ -0,0 +1,737 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LAN9252 通断测试</title>
<style>
/* === WebGL 液态玻璃 (参考 yzrt/html-liquid-glass-effect-webgl) ===
参数:IOR=1.5 / Thickness=50 / Normal=10 / Blur=0 / Highlight=4 / Radius=26
实现:SVG feDisplacementMap 做真实背景像素折射位移 + saturate/contrast 色散
+ 边缘 4px 白色高光带 (Highlight Width=4)
+ 边缘 1px 暗描边 (Normal Strength=10)
所有组件统一用此风格,旧 yzrt .box 多层 inset 阴影已舍弃
*/
:root{
--grid:#E1E1E1;
--text:#f0f0f5;
--text-2:#c8c8d0;
--text-3:#8a8a95;
--separator:rgba(255,255,255,.08);
--blue:#5b9eff;
--green:#30d158;
--red:#ff453a;
--orange:#ff9f0a;
--purple:#bf5af2;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transition:.25s;}
html,body{height:100%;}
body{
font-family:"Microsoft YaHei","SF Pro Display","Segoe UI",sans-serif;
color:var(--text);font-size:15px;line-height:1.5;
min-height:100vh;padding:32px 20px 80px;
position:relative;overflow-x:hidden;
/* 彩色 blur 球 + 细点阵 — 让液态玻璃折射位移清晰可见
色球提供颜色参照(折射色散),点阵提供位置参照(位移扭曲) */
background:
/* 细点阵:让折射位移有参照物 */
radial-gradient(circle at 50% 50%, rgba(255,255,255,.12) 1px, transparent 1.5px) 0 0/24px 24px,
/* 彩色 blur 球:中饱和度,不要太鲜艳以免干扰文字 */
radial-gradient(circle at 15% 20%, #4a90e2 0%, transparent 35%),
radial-gradient(circle at 85% 15%, #b06ab3 0%, transparent 38%),
radial-gradient(circle at 75% 80%, #e27d60 0%, transparent 40%),
radial-gradient(circle at 20% 75%, #41d3a3 0%, transparent 38%),
radial-gradient(circle at 50% 50%, #f0a500 0%, transparent 45%),
/* 底色渐变 */
linear-gradient(135deg, #2d3561 0%, #2d4a6b 50%, #1a2a45 100%);
background-attachment:fixed;
}
/* 大色球:增强折射参照(放固定层,不会被玻璃遮挡) */
body::before{
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;
background:
radial-gradient(circle at 30% 40%, rgba(255,200,100,.18) 0%, transparent 20%),
radial-gradient(circle at 70% 60%, rgba(100,200,255,.15) 0%, transparent 25%);
}
.wrap{max-width:1100px;margin:0 auto;position:relative;z-index:1;}
h1{
font-size:36px;font-weight:700;letter-spacing:-.03em;margin-bottom:6px;color:#fff;
text-shadow:0 2px 8px rgba(0,0,0,.4);
}
.sub{color:rgba(255,255,255,.85);font-size:15px;margin-bottom:24px;font-weight:500;text-shadow:0 1px 4px rgba(0,0,0,.3);}
/* === 玻璃基础类 — 纯 WebGL 液态玻璃(无辅助效果) ===
IOR1.5/Thick50 → SVG feDisplacementMap 背景折射位移 + saturate/contrast 色散
Blur0 → 不模糊
Highlight4 → 边缘 8px 白色高光带(略扩散)
Normal10 → 边缘 2px 暗影
Radius26 → 圆角 */
.glass,.glass-strong{
position:relative;z-index:0;isolation:isolate;
border-radius:26px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
box-shadow:
inset 0 0 8px rgba(255,255,255,.6), /* Highlight Width=4 → 略向中间扩散 */
inset 0 0 2px rgba(0,0,0,.6), /* Normal Strength=10 */
0 4px 12px rgba(0,0,0,.18);
}
.glass-strong{
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 6px 16px rgba(0,0,0,.2);
}
.glass-subtle{
position:relative;z-index:0;isolation:isolate;
border-radius:18px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.card{padding:24px;margin-bottom:16px;}
.card > *{position:relative;z-index:1;}
.card h2{
font-size:22px;font-weight:700;letter-spacing:-.02em;margin-bottom:6px;color:#fff;
text-shadow:0 1px 6px rgba(0,0,0,.3);
}
.card-desc{font-size:13px;color:var(--text-2);margin-bottom:16px;}
/* 警告条 — 纯 WebGL 玻璃 + 橙红染色 */
.warn{
color:#fff;font-size:13px;margin-bottom:24px;padding:14px 18px;
position:relative;z-index:0;isolation:isolate;
background:linear-gradient(135deg,rgba(255,159,10,.35),rgba(255,69,58,.3));
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border:none;
border-radius:26px;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 4px 12px rgba(0,0,0,.16);
display:flex;align-items:flex-start;gap:10px;font-weight:500;
text-shadow:0 1px 3px rgba(0,0,0,.4);
}
.warn > *{position:relative;z-index:1;}
.warn-icon{font-size:16px;flex-shrink:0;}
/* === 按钮 — 纯 WebGL 液态玻璃(无辅助效果) === */
button{
--btn-glow:rgba(0,0,0,.3);
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
color:var(--text);
border:none;
border-radius:26px;
padding:11px 22px;
font-size:14px;font-weight:600;font-family:inherit;
cursor:pointer;letter-spacing:-.01em;
box-shadow:
inset 0 0 8px rgba(255,255,255,.6),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(0,0,0,.16);
}
button:hover{
transform:translateY(-1px);
backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 4px 10px rgba(0,0,0,.2);
}
button:active{transform:translateY(0);}
/* 液体弹动:点击时像液态玻璃被按压后弹性回弹
注:用 transform-origin:center 保证对称变形;
background 用 background-size:200% 避免渐变被拉伸变形;
box-shadow 跟随收缩,保持形状一致 */
button.pressed{
animation:liquidPress .6s cubic-bezier(.25,1.5,.5,1) 1;
background-size:200% 200% !important;
background-position:center !important;
}
@keyframes liquidPress{
0%{transform:scale(1,1);box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
25%{transform:scale(.90,.86);border-radius:32px;box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.6),0 1px 4px var(--btn-glow,rgba(0,0,0,.3));}
45%{transform:scale(1.06,.94);border-radius:22px;box-shadow:inset 0 0 10px rgba(255,255,255,.6),inset 0 0 2px rgba(0,0,0,.6),0 4px 12px var(--btn-glow,rgba(0,0,0,.3));}
65%{transform:scale(.97,1.03);border-radius:28px;box-shadow:inset 0 0 9px rgba(255,255,255,.55),inset 0 0 2px rgba(0,0,0,.6),0 3px 9px var(--btn-glow,rgba(0,0,0,.3));}
82%{transform:scale(1.01,.99);border-radius:25px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
100%{transform:scale(1,1);border-radius:26px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
}
button.selected{
--btn-glow:rgba(37,99,235,.45);
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(37,99,235,.45);
}
button.selected:hover{background:linear-gradient(135deg,rgba(29,78,216,.88),rgba(37,99,235,.88));}
button.confirm-btn{
--btn-glow:rgba(22,163,74,.4);
background:linear-gradient(135deg,rgba(22,163,74,.8),rgba(21,128,61,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(22,163,74,.4);
}
button.confirm-btn:hover{background:linear-gradient(135deg,rgba(21,128,61,.88),rgba(22,163,74,.88));}
button.confirm-btn:disabled{
--btn-glow:rgba(0,0,0,.2);
background:rgba(140,140,145,.3);color:rgba(255,255,255,.8);
cursor:not-allowed;filter:brightness(.85);
box-shadow:inset 0 0 2px rgba(0,0,0,.4);transform:none;
}
button.loop-btn{
--btn-glow:rgba(124,58,237,.4);
background:linear-gradient(135deg,rgba(124,58,237,.8),rgba(109,40,217,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(124,58,237,.4);
}
button.loop-btn:hover{background:linear-gradient(135deg,rgba(109,40,217,.92),rgba(124,58,237,.92));}
button.loop-btn.active{
--btn-glow:rgba(234,88,12,.5);
background:linear-gradient(135deg,rgba(234,88,12,.92),rgba(194,65,12,.92));
background-size:200% 200%;
animation:pulse 2s infinite;
}
button.stop-btn{
--btn-glow:rgba(220,38,38,.4);
background:linear-gradient(135deg,rgba(220,38,38,.8),rgba(185,28,28,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(220,38,38,.4);
}
button.stop-btn:hover{background:linear-gradient(135deg,rgba(185,28,28,.88),rgba(220,38,38,.88));}
@keyframes pulse{
0%,100%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 0 rgba(234,88,12,.5);}
50%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 12px rgba(234,88,12,0);}
}
.btn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;}
.ctrl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;}
.section-label{
font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.08em;
margin:18px 0 10px;font-weight:700;
}
.section-label:first-child{margin-top:0;}
/* SVG 引脚图容器 — 纯 WebGL 玻璃 */
.pin-svg-wrap{
display:flex;justify-content:center;margin:12px 0;padding:20px;
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border-radius:18px;
border:none;
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.pin-svg-wrap svg{max-width:100%;height:auto;}
.pin-rect{stroke:rgba(255,255,255,.3);stroke-width:.5;transition:fill .3s ease;}
.pin-num{fill:rgba(255,255,255,.7);font-size:12px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name{fill:rgba(255,255,255,.9);font-size:11px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name-data{fill:#7fd3ff;font-weight:600;}
.pin-name-ctrl{fill:#ffb74d;font-weight:600;}
.chip-body{fill:url(#chipGrad);stroke:rgba(255,255,255,.25);stroke-width:1;}
.chip-label{fill:#fff;font-size:22px;font-weight:700;text-anchor:middle;}
.chip-sub{fill:rgba(255,255,255,.7);font-size:10px;text-anchor:middle;}
.pin1-dot{fill:var(--blue);}
.pin-legend{
display:flex;gap:18px;justify-content:center;flex-wrap:wrap;margin-top:16px;
font-size:12px;color:var(--text-2);padding-top:16px;
border-top:1px solid var(--separator);font-weight:500;
position:relative;z-index:1;
}
.pin-legend span{display:flex;align-items:center;gap:6px;}
.pin-legend .swatch{
width:14px;height:14px;border-radius:4px;display:inline-block;
box-shadow:0 1px 2px rgba(0,0,0,.2),inset 0 1px 0 rgba(255,255,255,.5);
}
/* 连接 / 操作 */
.connect-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:14px;position:relative;z-index:1;}
.connect-label{color:var(--text-2);font-weight:500;}
.connect-ip{
color:var(--blue);font-weight:700;
font-family:ui-monospace,"SF Mono",monospace;font-size:15px;
background:rgba(37,99,235,.15);padding:5px 14px;border-radius:26px;
border:none;
box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.3);
}
.connect-hint{color:var(--text-3);font-size:12px;}
.action-row{display:flex;gap:10px;flex-wrap:wrap;position:relative;z-index:1;}
.action-desc{
font-size:13px;color:var(--text-2);margin-top:16px;padding-top:14px;
border-top:1px solid var(--separator);line-height:1.7;position:relative;z-index:1;
}
.action-desc strong{color:var(--text);font-weight:600;}
/* 日志面板 — 纯 WebGL 玻璃 */
.log-panel{padding:0;overflow:hidden;}
.log-panel-header{
padding:18px 24px 14px;border-bottom:1px solid var(--separator);
display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;
background:transparent;position:relative;z-index:1;
}
.log-panel-title{font-size:17px;font-weight:700;letter-spacing:-.01em;}
.log-filters{display:flex;gap:6px;}
.log-filter{
position:relative;z-index:0;isolation:isolate;
padding:5px 14px;border-radius:26px;font-size:12px;font-weight:600;
border:none;background:transparent;color:var(--text-2);
cursor:pointer;font-family:inherit;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 5px rgba(255,255,255,.4),
inset 0 0 2px rgba(0,0,0,.4),
0 1px 3px rgba(0,0,0,.1);
}
.log-filter.active{
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(37,99,235,.35);
}
.log-filter:hover:not(.active){color:var(--text);}
.log-scroll{
padding:14px 24px;font-family:ui-monospace,"SF Mono","Cascadia Code",monospace;
font-size:12px;line-height:1.8;max-height:380px;overflow-y:auto;
background:transparent;position:relative;z-index:1;
}
.log-scroll::-webkit-scrollbar{width:6px;}
.log-scroll::-webkit-scrollbar-track{background:transparent;}
.log-scroll::-webkit-scrollbar-thumb{background:rgba(0,0,0,.2);border-radius:3px;}
.log-scroll::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.35);}
.log-line{display:flex;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,.05);}
.log-line .lt{color:var(--text-3);flex-shrink:0;}
.log-line .lv{font-weight:700;min-width:18px;flex-shrink:0;}
.log-line .lv.E{color:var(--red);}
.log-line .lv.I{color:var(--blue);}
.log-line .lg{color:var(--purple);flex-shrink:0;}
.log-line .lm{color:#fff;word-break:break-all;}
.log-foot{
display:flex;justify-content:space-between;align-items:center;
padding:12px 24px;border-top:1px solid var(--separator);
font-size:12px;color:var(--text-3);font-weight:500;
background:transparent;position:relative;z-index:1;
}
</style>
</head>
<body>
<!-- SVG 折射滤镜:模拟 WebGL IOR=1.5 Thickness=50 的背景位移折射 -->
<svg style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<defs>
<filter id="glassRefract" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.012" numOctaves="2" seed="7" result="noise"/>
<feGaussianBlur in="noise" stdDeviation="1.5" result="softNoise"/>
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale="14" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="wrap">
<h1>LAN9252 通断测试</h1>
<div class="sub">通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压</div>
<div class="warn">
<span class="warn-icon">⚠️</span>
<span>不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。</span>
</div>
<!-- 连接 -->
<div class="glass card">
<div class="connect-row">
<span class="connect-label">设备 IP</span>
<span id="ipDisplay" class="connect-ip">读取中...</span>
<span class="connect-hint">共用主页"设备连接"配置的 IP</span>
</div>
</div>
<!-- 引脚图 -->
<div class="glass-strong card">
<h2>LAN9252 64-QFN 引脚图</h2>
<p class="card-desc">俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。</p>
<div class="pin-svg-wrap">
<svg id="pinSvg" width="720" height="720" viewBox="0 0 720 720">
<defs>
<linearGradient id="chipGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(60,60,80,.6)"/>
<stop offset="100%" stop-color="rgba(40,40,60,.5)"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
</svg>
</div>
<div class="pin-legend">
<span><span class="swatch" style="background:#16a34a;"></span> HIGH 3.3V</span>
<span><span class="swatch" style="background:#dc2626;"></span> LOW 0V</span>
<span><span class="swatch" style="background:#ea580c;"></span> 控制线测试</span>
<span><span class="swatch" style="background:rgba(232,232,237,.8);"></span> 数据线空闲</span>
<span><span class="swatch" style="background:rgba(255,245,230,.8);"></span> 控制线空闲</span>
<span><span class="swatch" style="background:rgba(240,240,243,.8);"></span> 电源/其他</span>
<span><span class="swatch" style="background:#2563eb;border-radius:50%;"></span> Pin1</span>
</div>
</div>
<!-- 模式选择 -->
<div class="glass card">
<h2>模式选择</h2>
<p class="card-desc">点击预览引脚图,再点"确认应用"发送命令</p>
<div class="section-label">数据线 pattern · D0-D15</div>
<div class="btn-grid">
<button class="mode-btn" data-action="01">0xFFFF 全高</button>
<button class="mode-btn" data-action="02">0x0000 全低</button>
<button class="mode-btn" data-action="03">0xAAAA 奇数高</button>
<button class="mode-btn" data-action="04">0x5555 偶数高</button>
</div>
<div class="section-label">控制线测试</div>
<div class="ctrl-grid">
<button class="mode-btn" data-action="05">CS 拉低 · PD7→Pin28</button>
<button class="mode-btn" data-action="06">RD 拉低 · PD4→Pin31</button>
<button class="mode-btn" data-action="07">WR 拉低 · PD5→Pin30</button>
<button class="mode-btn" data-action="08">A1 拉高 · PD11→Pin25</button>
<button class="mode-btn" data-action="09">A2 拉高 · PD12→Pin29</button>
</div>
</div>
<!-- 操作 -->
<div class="glass card">
<h2>操作</h2>
<div class="action-row">
<button class="confirm-btn" id="confirmBtn" disabled>✓ 确认应用</button>
<button class="loop-btn" id="loopBtn">🔄 循环切换</button>
<button class="stop-btn" id="stopBtn">⏹ 停止 / 恢复</button>
</div>
<p class="action-desc">
<strong>确认应用</strong> · 发送当前选中的模式到 GD32<br>
<strong>循环切换</strong> · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)<br>
<strong>停止 / 恢复</strong> · 恢复 EXMC 正常模式
</p>
</div>
<!-- 日志 -->
<div class="glass log-panel">
<div class="log-panel-header">
<span class="log-panel-title">操作日志</span>
<div class="log-filters">
<button class="log-filter active" onclick="setLogFilter('all',this)">全部</button>
<button class="log-filter" onclick="setLogFilter('E',this)">错误</button>
<button class="log-filter" onclick="setLogFilter('I',this)">信息</button>
<button class="log-filter" onclick="clearLogs()">清除</button>
</div>
</div>
<div class="log-scroll" id="logScroll"></div>
<div class="log-foot">
<span>共 <span id="logCount">0</span> 条</span>
<span style="font-family:ui-monospace,monospace;">SPI 0xA2 · UDP 6666</span>
</div>
</div>
</div><!-- /.wrap -->
<script>
/* ===== LAN9252 64-QFN 引脚表 ===== */
var pinData=[
{n:1,name:"OSCI",type:"other"},{n:2,name:"OSCO",type:"other"},
{n:3,name:"VDD12",type:"power"},{n:4,name:"VSS",type:"gnd"},
{n:5,name:"VDD33",type:"power"},{n:6,name:"VDDCR",type:"power"},
{n:7,name:"REG_EN",type:"other"},{n:8,name:"FXLOSEN",type:"other"},
{n:9,name:"FXSDA",type:"other"},{n:10,name:"FXSDB",type:"other"},
{n:11,name:"RST#",type:"other"},{n:12,name:"D2",type:"data",d:2},
{n:13,name:"D1",type:"data",d:1},{n:14,name:"VDDIO",type:"power"},
{n:15,name:"D14",type:"data",d:14},{n:16,name:"D13",type:"data",d:13},
{n:17,name:"D0",type:"data",d:0},{n:18,name:"SYNC1",type:"other"},
{n:19,name:"D9",type:"data",d:9},{n:20,name:"VDDIO",type:"power"},
{n:21,name:"D12",type:"data",d:12},{n:22,name:"D11",type:"data",d:11},
{n:23,name:"D10",type:"data",d:10},{n:24,name:"VDDCR",type:"power"},
{n:25,name:"A1",type:"ctrl",ctrl:"A1"},{n:26,name:"A3",type:"other"},
{n:27,name:"A4",type:"other"},{n:28,name:"CS",type:"ctrl",ctrl:"CS"},
{n:29,name:"A2",type:"ctrl",ctrl:"A2"},{n:30,name:"WR",type:"ctrl",ctrl:"WR"},
{n:31,name:"RD",type:"ctrl",ctrl:"RD"},{n:32,name:"VDDIO",type:"power"},
{n:33,name:"D15",type:"data",d:15},{n:34,name:"SYNC0",type:"other"},
{n:35,name:"D3",type:"data",d:3},{n:36,name:"D6",type:"data",d:6},
{n:37,name:"VDDIO",type:"power"},{n:38,name:"VDDCR",type:"power"},
{n:39,name:"D7",type:"data",d:7},{n:40,name:"D8",type:"data",d:8},
{n:41,name:"TESTMODE",type:"other"},{n:42,name:"EESDA",type:"other"},
{n:43,name:"EESCL",type:"other"},{n:44,name:"IRQ",type:"other"},
{n:45,name:"RUNLED",type:"other"},{n:46,name:"MODE1",type:"other"},
{n:47,name:"VDDIO",type:"power"},{n:48,name:"MODE0",type:"other"},
{n:49,name:"D4",type:"data",d:4},{n:50,name:"D5",type:"data",d:5},
{n:51,name:"TXRX1",type:"power"},{n:52,name:"TXNA",type:"other"},
{n:53,name:"TXPA",type:"other"},{n:54,name:"RXNA",type:"other"},
{n:55,name:"RXPA",type:"other"},{n:56,name:"VDD12",type:"power"},
{n:57,name:"RBIAS",type:"other"},{n:58,name:"VDD33B",type:"power"},
{n:59,name:"VDD12",type:"power"},{n:60,name:"RXPB",type:"other"},
{n:61,name:"RXNB",type:"other"},{n:62,name:"TXPB",type:"other"},
{n:63,name:"TXNB",type:"other"},{n:64,name:"TXRX2",type:"power"}
];
var actionMap={
"01":{t:"data",val:0xFFFF,label:"0xFFFF 全高"},"02":{t:"data",val:0x0000,label:"0x0000 全低"},
"03":{t:"data",val:0xAAAA,label:"0xAAAA 奇数高"},"04":{t:"data",val:0x5555,label:"0x5555 偶数高"},
"05":{t:"ctrl",val:"CS",label:"CS 拉低"},"06":{t:"ctrl",val:"RD",label:"RD 拉低"},
"07":{t:"ctrl",val:"WR",label:"WR 拉低"},"08":{t:"ctrl",val:"A1",label:"A1 拉高"},
"09":{t:"ctrl",val:"A2",label:"A2 拉高"},
"00":{t:"stop",label:"停止/恢复"}
};
/* ===== SVG ===== */
var SVG_NS="http://www.w3.org/2000/svg";
var CHIP_X1=120,CHIP_Y1=120,CHIP_X2=600,CHIP_Y2=600;
var CHIP_W=CHIP_X2-CHIP_X1,PIN_GAP=CHIP_W/16,PIN_OFF=PIN_GAP/2;
var DEFAULT_COLORS={data:"rgba(200,200,210,.5)",ctrl:"rgba(255,200,150,.5)",power:"rgba(220,220,230,.5)",gnd:"rgba(180,180,190,.5)",other:"rgba(210,210,220,.5)"};
function el(tag,attrs,text){
var e=document.createElementNS(SVG_NS,tag);
for(var k in attrs)e.setAttribute(k,attrs[k]);
if(text!==undefined)e.textContent=text;
return e;
}
function getPinPos(n){
if(n>=1&&n<=16){
/* 顶部:引脚上方,名称在上、编号在下 */
var x=CHIP_X2-PIN_OFF-(n-1)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y1-14,rw:14,rh:14,tx:x,ty:CHIP_Y1-16,anchor:"middle",ny:CHIP_Y1-32};
}else if(n>=17&&n<=32){
/* 左侧:引脚左方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y1+PIN_OFF+(n-17)*PIN_GAP;
return{rx:CHIP_X1-14,ry:y-7,rw:14,rh:14,tx:CHIP_X1-18,ty:y-1,anchor:"end",ny:y+13};
}else if(n>=33&&n<=48){
/* 底部:引脚下方,编号在上、名称在下 */
var x=CHIP_X1+PIN_OFF+(n-33)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y2,rw:14,rh:14,tx:x,ty:CHIP_Y2+22,anchor:"middle",ny:CHIP_Y2+38};
}else{
/* 右侧:引脚右方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y2-PIN_OFF-(n-49)*PIN_GAP;
return{rx:CHIP_X2,ry:y-7,rw:14,rh:14,tx:CHIP_X2+18,ty:y-1,anchor:"start",ny:y+13};
}
}
var pinRects={};
function buildSvg(){
var svg=document.getElementById("pinSvg");
svg.appendChild(el("rect",{class:"chip-body",x:CHIP_X1,y:CHIP_Y1,width:CHIP_W,height:CHIP_W,rx:14}));
svg.appendChild(el("text",{class:"chip-label",x:360,y:345},"LAN9252"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:368},"64-QFN"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:386},"Pin1 → 右上角"));
svg.appendChild(el("circle",{class:"pin1-dot",cx:CHIP_X2-14,cy:CHIP_Y1+14,r:5}));
for(var i=0;i<pinData.length;i++){
var p=pinData[i],pos=getPinPos(p.n);
var rect=el("rect",{class:"pin-rect",x:pos.rx,y:pos.ry,width:pos.rw,height:pos.rh,rx:3,id:"pin-"+p.n});
rect.setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
svg.appendChild(rect);
pinRects[p.n]=rect;
svg.appendChild(el("text",{class:"pin-num",x:pos.tx,y:pos.ty,"text-anchor":pos.anchor},String(p.n)));
var nc="pin-name";
if(p.type==="data")nc="pin-name pin-name-data";
else if(p.type==="ctrl")nc="pin-name pin-name-ctrl";
svg.appendChild(el("text",{class:nc,x:pos.tx,y:pos.ny||pos.ty,"text-anchor":pos.anchor},p.name));
}
}
function updatePinColors(actionHex){
var act=actionMap[actionHex];if(!act)return;
for(var i=0;i<pinData.length;i++){
var p=pinData[i],color=DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other;
var glow=false;
if(act.t==="data"&&p.type==="data"){
color=(act.val&(1<<p.d))?"#30d158":"#ff453a";glow=true;
}else if(act.t==="ctrl"&&p.type==="ctrl"&&p.ctrl===act.val){
color="#ff9f0a";glow=true;
}
pinRects[p.n].setAttribute("fill",color);
if(glow)pinRects[p.n].setAttribute("filter","url(#glow)");
else pinRects[p.n].removeAttribute("filter");
}
}
function resetPinColors(){
for(var i=0;i<pinData.length;i++){
var p=pinData[i];
pinRects[p.n].setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
pinRects[p.n].removeAttribute("filter");
}
}
/* ===== IP ===== */
var LS_ESP_IP="dcu_esp_ip",DEFAULT_IP="192.168.31.127";
function getIp(){try{var s=localStorage.getItem(LS_ESP_IP);if(s&&s.trim())return s.trim();}catch(e){}return DEFAULT_IP;}
document.getElementById("ipDisplay").textContent=getIp();
window.addEventListener("storage",function(e){if(e.key===LS_ESP_IP)document.getElementById("ipDisplay").textContent=getIp();});
/* ===== 日志 ===== */
var logScroll=document.getElementById("logScroll"),logCountEl=document.getElementById("logCount"),logCount=0,logFilter="all";
function addLog(level,tag,msg){
var line=document.createElement("div");
line.className="log-line";
if(logFilter!=="all"&&level!==logFilter){line.style.display="none";}
line.innerHTML='<span class="lt">['+new Date().toLocaleTimeString()+']</span>'+
'<span class="lv '+level+'">'+level+'</span>'+
'<span class="lg">['+tag+']</span>'+
'<span class="lm">'+msg+'</span>';
logScroll.insertBefore(line,logScroll.firstChild);
logCount++;logCountEl.textContent=logCount;
while(logScroll.children.length>200)logScroll.removeChild(logScroll.lastChild);
}
function setLogFilter(f,btn){
logFilter=f;
document.querySelectorAll(".log-filter").forEach(function(b){b.classList.remove("active");});
btn.classList.add("active");
logScroll.querySelectorAll(".log-line").forEach(function(l){
var lv=l.querySelector(".lv");
if(f==="all"||(lv&&lv.classList.contains(f)))l.style.display="";
else l.style.display="none";
});
}
function clearLogs(){logScroll.innerHTML="";logCount=0;logCountEl.textContent=0;}
/* ===== 按钮:液体弹动效果 ===== */
document.addEventListener("click",function(e){
var btn=e.target.closest("button");
if(!btn)return;
btn.classList.remove("pressed");
void btn.offsetWidth; /* 强制 reflow,允许重复触发 */
btn.classList.add("pressed");
var done=function(){
btn.classList.remove("pressed");
btn.removeEventListener("animationend",done);
};
btn.addEventListener("animationend",done);
},true);
/* ===== 模式选择 ===== */
var selectedAction=null,lastModeBtn=null;
document.querySelectorAll(".mode-btn").forEach(function(btn){
btn.addEventListener("click",function(){
var action=btn.getAttribute("data-action");
if(lastModeBtn)lastModeBtn.classList.remove("selected");
btn.classList.add("selected");
lastModeBtn=btn;
selectedAction=action;
updatePinColors(action);
document.getElementById("confirmBtn").disabled=false;
addLog("I","预览","选中: "+actionMap[action].label);
});
});
/* ===== 发送 ===== */
var busy=false;
function sendAction(actionHex){
if(busy)return;
var ip=getIp();if(!ip){addLog("E","错误","请先在主页配置 ESP32 IP");return;}
busy=true;
var label=actionMap[actionHex]?actionMap[actionHex].label:actionHex;
addLog("I","发送","action=0x"+actionHex+" ("+label+")");
fetch("http://"+ip+"/spi/send",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({type:"A2",data:actionHex,timeout_ms:2000}),
signal:AbortSignal.timeout(3000)
})
.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})
.then(function(j){
if(j.status==="ok"&&j.resp_type==="80")
addLog("I","成功",label+" → ACK"+(j.recv?" 0x"+j.recv:""));
else if(j.status==="ok"&&j.resp_type==="82")
addLog("E","失败",label+" → GD32 ERROR");
else
addLog("E","异常",label+" → "+JSON.stringify(j));
})
.catch(function(err){addLog("E","网络",label+" → "+err.message);})
.then(function(){busy=false;});
}
document.getElementById("confirmBtn").addEventListener("click",function(){
if(selectedAction)sendAction(selectedAction);
});
document.getElementById("stopBtn").addEventListener("click",function(){
stopLoop();
sendAction("00");
resetPinColors();
if(lastModeBtn)lastModeBtn.classList.remove("selected");
lastModeBtn=null;selectedAction=null;
document.getElementById("confirmBtn").disabled=true;
addLog("I","恢复","EXMC 恢复正常模式");
});
/* ===== 循环: 0x0000 ↔ 当前模式 ===== */
var loopTimer=null,loopToggle=false;
var loopBtn=document.getElementById("loopBtn");
loopBtn.addEventListener("click",function(){
if(loopTimer)stopLoop();else startLoop();
});
function startLoop(){
if(!selectedAction||actionMap[selectedAction].t!=="data"){
addLog("E","循环","请先选择一个数据线 pattern 模式");
return;
}
stopLoop();
var baseAction=selectedAction;
loopBtn.textContent="⏸ 停止循环";
loopBtn.classList.add("active");
addLog("I","循环","开始:每5s在 0x0000 ↔ "+actionMap[baseAction].label+" 切换");
loopToggle=false;
loopBtn.setAttribute("data-base",baseAction);
doLoopStep();
loopTimer=setInterval(doLoopStep,5000);
}
function stopLoop(){
if(loopTimer){clearInterval(loopTimer);loopTimer=null;}
loopBtn.textContent="🔄 循环切换";
loopBtn.classList.remove("active");
loopBtn.removeAttribute("data-base");
}
function doLoopStep(){
var baseAction=loopBtn.getAttribute("data-base");if(!baseAction)return;
loopToggle=!loopToggle;
var action=loopToggle?baseAction:"02";
if(lastModeBtn)lastModeBtn.classList.remove("selected");
var btn=document.querySelector('[data-action="'+action+'"]');
if(btn){btn.classList.add("selected");lastModeBtn=btn;}
selectedAction=action;
updatePinColors(action);
sendAction(action);
}
buildSvg();
addLog("I","系统","通断测试工具就绪");
</script>
</body>
</html>
+217 -3
View File
@@ -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); }
+102 -5
View File
@@ -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) {
+145 -15
View File
@@ -14,7 +14,7 @@
<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">
<link rel="stylesheet" href="css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="css/can.css?v=c06f5688">
<link rel="stylesheet" href="css/responsive.css?v=46449483">
</head>
@@ -57,12 +57,7 @@
<!-- 1. Connection -->
<div class="card card--ble">
<div class="card-head"><span class="ico">🔗</span> 设备连接
<span id="bleHeadActions" style="display:none;margin-left:auto;">
<button id="connectBtn" class="btn btn-sm btn-primary">连接设备</button>
<button id="disconnectBtn" class="btn btn-sm btn-outline" disabled>断开连接</button>
</span>
</div>
<div class="card-head"><span class="ico">🔗</span> 设备连接</div>
<div class="card-body">
<div style="display:flex;gap:6px;margin-bottom:14px;">
<button id="modeWifiBtn" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" data-action="set-console-mode" data-arg="wifi">WiFi OTA</button>
@@ -77,6 +72,69 @@
</div>
</div>
</div>
<!-- BLE 模式:launch 风格连接按钮(checkbox 驱动:勾选=已连接) -->
<div id="bleModeCards" style="display:none;">
<div class="ble-launch-wrap">
<label class="ble-launch-btn" for="bleConnectToggle" data-ble-state="idle">
<input id="bleConnectToggle" type="checkbox">
<div class="back">
<div class="spot-shadow"></div>
<div class="mid">
<div class="spot-main"></div>
<div class="ble-dial">
<div class="spot-light"></div>
<div class="center">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#bleIconGlow)">
<path d="M7 7 L17 17 L12 22 V2 L17 7 L7 17"
stroke="#BBE7E9" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round" fill="none"
shape-rendering="geometricPrecision"/>
</g>
<defs>
<filter id="bleIconGlow" x="-50%" y="-50%" width="200%" height="200%"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="bg"/>
<feColorMatrix in="SourceAlpha" type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="a"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="a" operator="out"/>
<feColorMatrix type="matrix"
values="0 0 0 0 0.37 0 0 0 0 0.66 0 0 0 0 1 0 0 0 0.88 0"/>
<feBlend mode="normal" in2="bg" result="g"/>
<feBlend mode="normal" in="SourceGraphic" in2="g" result="shape"/>
</filter>
</defs>
</svg>
</div>
<div class="launch">
<div style="--i:1">蓝</div>
<div style="--i:2">牙</div>
<div style="--i:3">连</div>
<div style="--i:4">接</div>
</div>
<div class="start">
<div class="text1">等待连接</div>
<div class="text2">
<span style="--n:0">已</span>
<span style="--n:1">连</span>
<span style="--n:2">接</span>
<span style="--n:3">.</span>
<span style="--n:4">.</span>
<span style="--n:5">.</span>
</div>
<div class="text3">连接中...</div>
</div>
</div>
</div>
</div>
</div>
</label>
<div class="ble-launch-hint">点击按钮连接 / 断开蓝牙设备</div>
</div>
</div>
<!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 -->
</div>
</div>
@@ -335,13 +393,8 @@
<div class="can-launch-actions">
<a class="btn btn-primary can-launch-primary" href="can.html">进入 CAN 页面</a>
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('can.html','_blank','noopener')">新窗口打开</button>
<a class="btn btn-outline can-launch-secondary" href="can.html" title="发送页右侧抽屉内嵌监控">CAN 发送 + 监控</a>
<a class="btn btn-outline can-launch-secondary" href="can_monitor.html">监控全屏</a>
</div>
<div class="can-launch-foot">
<span class="can-launch-foot-label">说明</span>
<span>手工 CAN 表单已迁到独立页面,避免和 OTA 控制流混在一起。</span>
</div>
</div>
</div>
</div>
@@ -370,7 +423,6 @@
</div>
</div>
<div class="can-launch-actions">
<a class="btn btn-primary can-launch-primary motor-launch-primary" href="motor.html">进入电机控制</a>
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('motor.html','_blank','noopener')">新窗口打开</button>
</div>
<div class="can-launch-foot">
@@ -381,6 +433,69 @@
</div>
</div>
<!-- 硬件诊断:LAN9252 通断测试 + SPI 测速 -->
<div class="card collapsed">
<div class="card-head" onclick="toggleCollapse(this)">
<span class="ico">🧰</span> 硬件诊断
<span class="collapse-arrow">▼</span>
</div>
<div class="card-body">
<div style="display:grid;gap:14px;">
<!-- LAN9252 continuity test -->
<div class="can-launch-card">
<div class="can-launch-copy">
<div class="can-launch-top">
<div>
<div class="can-launch-kicker">Hardware Diag</div>
<h3>LAN9252 HBI 硬件通断测试</h3>
</div>
<span class="can-launch-badge">DIAG</span>
</div>
<p class="can-launch-desc">GD32 驱动 EXMC 数据线/控制线输出已知电平,用万用表测 LAN9252 端引脚电压,定位断线/虚焊。通过 SPI 0xA2 命令触发,不点按钮时引脚高阻安全。与主页共用设备 IP(<code>dcu_esp_ip</code>)。</p>
<div class="can-launch-points">
<span class="can-launch-point">16 根数据线 pattern</span>
<span class="can-launch-point">5 根控制线逐个测</span>
<span class="can-launch-point">共用主页连接</span>
</div>
</div>
<div class="can-launch-actions">
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('continuity_test.html','_blank','noopener')">新窗口打开</button>
</div>
<div class="can-launch-foot">
<span class="can-launch-foot-label">说明</span>
<span>请先在上方「设备连接」配置并测试 ESP32 IP;通断测试页只消费同一连接,不单独建连。</span>
</div>
</div>
<!-- SPI speed test -->
<div class="can-launch-card">
<div class="can-launch-copy">
<div class="can-launch-top">
<div>
<div class="can-launch-kicker">SPI Link Probe</div>
<h3>SPI 链路测速</h3>
</div>
<span class="can-launch-badge">SPI</span>
</div>
<p class="can-launch-desc">0xA1 探针压测 ESP32↔GD32 SPI 链路真实 RTT 与等效吞吐,定位速度瓶颈与调度抖动。可调帧数/载荷,支持空载 RTT 与 2000 帧满载压力测试。</p>
<div class="can-launch-points">
<span class="can-launch-point">RTT 平均/最快/最慢</span>
<span class="can-launch-point">单帧 5 段剖析</span>
<span class="can-launch-point">等效吞吐 KB/s</span>
</div>
</div>
<div class="can-launch-actions">
<a class="btn btn-outline can-launch-secondary" href="spi_test.html">打开测速页</a>
</div>
<div class="can-launch-foot">
<span class="can-launch-foot-label">说明</span>
<span>临时诊断工具 SPI-SPEEDTEST;满帧 236B = 等效电机 OTA 0xD1 数据节奏,载荷 0 = 纯握手往返。</span>
</div>
</div>
</div>
</div>
</div>
<!-- Log panel -->
<div class="card card--mem log-panel">
<div class="card-head" style="justify-content:space-between;">
@@ -513,7 +628,22 @@
</div>
<script type="module" src="js/app.js?v=20260727a"></script>
<script type="module" src="js/ota/console-mode.js?v=06b7189e"></script>
<script type="module" src="js/ble_ota.js?v=01d3cb4e"></script>
<script type="module" src="js/ota/console-mode.js?v=06b7189f"></script>
<script type="module" src="js/ble_ota.js?v=eb2f1a"></script>
<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked
不依赖 ES 模块(子模块可能被浏览器缓存为旧版,不含 data-ble-state 逻辑)。
connecting 态由 session.js click handler 设置,此处跳过不覆盖。 -->
<script type="module">
setInterval(function() {
var btn = document.querySelector('.ble-launch-btn');
if (!btn || !btn.offsetParent) return;
var st = btn.getAttribute('data-ble-state');
if (st === 'connecting') return;
var toggle = document.getElementById('bleConnectToggle');
if (!toggle) return;
var expected = toggle.checked ? 'connected' : 'idle';
if (st !== expected) btn.setAttribute('data-ble-state', expected);
}, 500);
</script>
</body>
</html>
+39 -2
View File
@@ -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 统一处理
+10 -4
View File
@@ -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;
+9 -5
View File
@@ -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;
+658 -44
View File
@@ -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<node, {soft,hard,boot}>,序列化为普通对象 */
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]) =>
`<option value="${v}"${String(v) === String(selected) ? " selected" : ""}>${v} — ${m.zh}</option>`).join("");
}
function seqChOptions(selected) {
return [0, 1, 2].map((c) =>
`<option value="${c}"${parseInt(selected, 10) === c ? " selected" : ""}>CH${c}</option>`).join("");
}
function seqNodeOptions(selected) {
return FIXED_MOTOR_NODES.map((n) =>
`<option value="${n}"${parseInt(selected, 10) === n ? " selected" : ""}>N${n}</option>`).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 = '<div class="seq-empty">尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发</div>';
} else {
seqListEl.innerHTML = seq.steps.map((s, i) => `
<div class="seq-step" data-idx="${i}">
<div class="seq-step-head">
<span class="seq-idx">步骤 ${i + 1}</span>
<span class="seq-count">${s.actions.length} 电机</span>
<span class="seq-hold-label">保持</span>
<input class="seq-num seq-hold" data-f="holdMs" type="number" value="${s.holdMs}" min="0" max="600000" step="100" aria-label="步骤 ${i + 1} 保持时间 ms" />
<span class="seq-hold-unit">ms</span>
<div class="seq-ops">
<button data-op="addAction" title="本步添加电机动作(复制最后一个动作)">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button data-op="up" title="上移" ${i === 0 ? "disabled" : ""}>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M3.5 8.5 7 5l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button data-op="down" title="下移" ${i === seq.steps.length - 1 ? "disabled" : ""}>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M3.5 5.5 7 9l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button data-op="dup" title="复制本步(含全部动作)">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><rect x="4.5" y="4.5" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/><rect x="2.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2" opacity=".4"/></svg>
</button>
<button data-op="del" title="删除本步" class="seq-del">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M2.5 4h9M5.5 4V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3.5 4l.55 6.9A1 1 0 0 0 5.04 12h3.92a1 1 0 0 0 .99-.9L10.5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<div class="seq-actions">
${s.actions.map((a, j) => `
<div class="seq-action" data-aidx="${j}">
<div class="seq-action-head">
<span class="seq-action-tag">电机 ${j + 1}</span>
<select class="seq-ch" aria-label="步骤 ${i + 1} 电机 ${j + 1} 通道" title="发送通道">${seqChOptions(a.ch)}</select>
<select class="seq-node" aria-label="步骤 ${i + 1} 电机 ${j + 1} 节点" title="目标节点">${seqNodeOptions(a.node)}</select>
<select class="seq-mode" aria-label="步骤 ${i + 1} 电机 ${j + 1} 控制模式">${seqModeOptions(a.mode)}</select>
<div class="seq-ops">
<button data-op="delAction" title="删除此电机动作" class="seq-del">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M2.5 4h9M5.5 4V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3.5 4l.55 6.9A1 1 0 0 0 5.04 12h3.92a1 1 0 0 0 .99-.9L10.5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<div class="seq-step-fields">
<label class="field"><span class="field-label">电压 <span class="field-unit">V</span></span><input class="seq-num" data-f="voltage" type="number" value="${a.voltage}" step="0.5" /></label>
<label class="field"><span class="field-label">电流 <span class="field-unit">mA</span></span><input class="seq-num" data-f="current" type="number" value="${a.current}" step="100" /></label>
<label class="field"><span class="field-label">速度 <span class="field-unit">deg/s</span></span><input class="seq-num" data-f="velocity" type="number" value="${a.velocity}" step="10" /></label>
<label class="field"><span class="field-label">角度 <span class="field-unit">deg</span></span><input class="seq-num" data-f="angle" type="number" value="${a.angle}" step="1" /></label>
<label class="field"><span class="field-label">电流环P</span><input class="seq-num" data-f="pidCurP" type="number" value="${a.pidCurP}" step="1" /></label>
<label class="field"><span class="field-label">速度环P</span><input class="seq-num" data-f="pidVelP" type="number" value="${a.pidVelP}" step="1" /></label>
<label class="field"><span class="field-label">角度环P</span><input class="seq-num" data-f="pidAngP" type="number" value="${a.pidAngP}" step="1" /></label>
</div>
</div>`).join("")}
</div>
</div>`).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(); /* 异步恢复上次固件(无阻塞) */
+33
View File
@@ -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;
}
}
};
-2
View File
@@ -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();
+24 -2
View File
@@ -6,7 +6,7 @@
<title>电机控制台 — Motor CAN FD Console</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/motor.css?v=20260730a">
<link rel="stylesheet" href="css/motor.css?v=20260808c">
</head>
<body>
@@ -144,6 +144,7 @@
<button class="tab-btn" data-tab="version">版本信息</button>
<button class="tab-btn" data-tab="nodeid">节点 ID</button>
<button class="tab-btn" data-tab="ota">电机 OTA</button>
<button class="tab-btn" data-tab="seq">命令序列</button>
</nav>
<div class="tab-content">
<!-- CONFIG TAB -->
@@ -232,6 +233,27 @@
<div>[信息] 选固件 → 上传并比对 flash SHA-256(失败重传≤2次)→ 发进BOOT命令 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限</div>
</div>
</div>
<!-- 命令序列 TAB -->
<div class="tab-page" id="tab-seq">
<div class="seq-toolbar">
<label class="repeat-period" title="整序列循环次数,0 = 无限循环">
<span>循环</span>
<input type="number" id="seqLoops" value="0" min="0" max="9999" step="1" />
<span>次(0=无限)</span>
</label>
<button class="btn btn-sm" id="seqAddBtn">+ 添加步骤</button>
<button class="btn btn-sm" id="seqClearBtn">清空</button>
<button class="btn" id="seqStartBtn">▶ 开始序列</button>
<span class="seq-status" id="seqStatus">未运行</span>
</div>
<div class="seq-list" id="seqList"></div>
<p class="seq-hint">
命令序列:每步为一个「同步动作组」,组内每个动作 = 一台电机目标(通道/节点/模式/电压/电流/速度/角度/PID);
执行本步时组内全部动作经一次突发请求近同时下发(同组偏差微秒级;超过单次容量自动分组,组间约 1-2ms),
发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。
循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。
</p>
</div>
</div>
</section>
@@ -345,6 +367,6 @@
</div>
</div>
<script type="module" src="js/motor/main.js?v=20260801b"></script>
<script type="module" src="js/motor/main.js?v=20260808d"></script>
</body>
</html>