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:
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 背压消化,此处无需长等。 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,累积到结果结构中。
|
||||
*
|
||||
|
||||
@@ -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 协议配套)
|
||||
// ============================================================
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 重生。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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(); /* 异步恢复上次固件(无阻塞) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user