diff --git a/main/monitor/motor_state.cpp b/main/monitor/motor_state.cpp
index 70c31d7..91e928d 100644
--- a/main/monitor/motor_state.cpp
+++ b/main/monitor/motor_state.cpp
@@ -63,19 +63,20 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
s_table.boot_active = (active_cnt & 0x80U) ? 1U : 0U;
}
- /* 逐条解析 entry */
+ /* 逐条解析 entry:node×channel 双键槽位,同节点号多通道互不覆盖 */
size_t offset = MOTOR_PAGE_HEADER;
for (uint8_t i = 0; i < entry_count; ++i) {
const uint8_t *e = &payload[offset];
const uint8_t node = e[0];
- if (node < 1 || node > MOTOR_MAX_NODES) {
+ const uint8_t ch = e[1];
+ if (node < 1 || node > MOTOR_MAX_NODES || ch >= MOTOR_CHANNELS) {
offset += MOTOR_ENTRY_SIZE;
continue;
}
- motor_slot_t &slot = s_table.slots[node - 1];
+ motor_slot_t &slot = s_table.slots[(node - 1) * MOTOR_CHANNELS + ch];
slot.node_id = node;
- slot.channel = e[1];
+ slot.channel = ch;
slot.online = e[2];
slot.dlc = e[3];
slot.rx_count = rdU32Le(&e[4]);
@@ -87,7 +88,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
/* 统计有效槽位 */
uint8_t valid = 0;
- for (uint8_t i = 0; i < MOTOR_MAX_NODES; ++i) {
+ for (uint8_t i = 0; i < MOTOR_MAX_SLOTS; ++i) {
if (s_table.slots[i].node_id != 0) valid++;
}
s_table.valid_slots = valid;
diff --git a/main/monitor/motor_state.h b/main/monitor/motor_state.h
index 0e3a272..ffb681b 100644
--- a/main/monitor/motor_state.h
+++ b/main/monitor/motor_state.h
@@ -10,6 +10,9 @@
* [0] node_id, [1] channel, [2] online, [3] dlc,
* [4..7] rx_count (LE), [8..11] seq (LE),
* [12..43] data[32] (原始 STATUS payload)
+ *
+ * 同一节点号可能同时挂在多个 CAN 通道(两台电机同号异线),
+ * 缓存表与 GD32 端一致按 node×channel 双键存储(27 槽)。
*/
#ifndef MOTOR_STATE_H
#define MOTOR_STATE_H
@@ -23,10 +26,12 @@ extern "C" {
#endif
#define MOTOR_MAX_NODES 9
+#define MOTOR_CHANNELS 3
+#define MOTOR_MAX_SLOTS (MOTOR_MAX_NODES * MOTOR_CHANNELS)
#define MOTOR_STATUS_DLC 32
#define MOTOR_ENTRY_SIZE 44
#define MOTOR_PAGE_HEADER 4
-#define MOTOR_TOTAL_PAGES 2
+#define MOTOR_TOTAL_PAGES 6
/** @brief 单电机状态快照。 */
typedef struct {
@@ -41,7 +46,7 @@ typedef struct {
/** @brief 全局电机状态表。 */
typedef struct {
- motor_slot_t slots[MOTOR_MAX_NODES]; ///< 按 node_id-1 索引
+ motor_slot_t slots[MOTOR_MAX_SLOTS]; ///< 按 (node_id-1)*MOTOR_CHANNELS+channel 索引
uint8_t active_count; ///< 在线电机数
uint8_t boot_active; ///< 1=总线上有电机在 BOOT(0x103 心跳,来自 0xC5 头 bit7)
uint8_t valid_slots; ///< 有效槽位数(node_id!=0)
diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp
index fb09f3b..dd692fb 100644
--- a/main/server/http_handlers.cpp
+++ b/main/server/http_handlers.cpp
@@ -1868,7 +1868,7 @@ esp_err_t motor_state_handler(httpd_req_t *req)
* rsvd u16 = 0
* Entry: node u8, ch u8, online u8, dlc u8,
* rx_count u32, seq u32, data[32]
- * 9 节点最大 12 + 9*44 = 408B。
+ * 27 槽(9 node × 3 ch,node×channel 双键)最大 12 + 27*44 = 1200B。
* 无 have_data 时仍发 header(count=0),让前端区分「无数据」与「空总线」。 */
bool want_bin = false;
{
@@ -1882,8 +1882,9 @@ esp_err_t motor_state_handler(httpd_req_t *req)
}
if (want_bin) {
httpd_resp_set_type(req, "application/octet-stream");
- /* 静态栈缓冲:12 + 9*44 = 408B,远小于常见任务栈 */
- uint8_t buf[12 + MOTOR_MAX_NODES * MOTOR_ENTRY_SIZE];
+ /* 静态缓冲:27 槽扩容后 1200B 不再上栈(httpd 栈 7KB 已紧);
+ * httpd 单任务串行处理请求,静态缓冲无竞态。 */
+ static uint8_t buf[12 + MOTOR_MAX_SLOTS * MOTOR_ENTRY_SIZE];
size_t off = 0;
/* magic 'MS' (LE: 0x53, 0x4D) */
buf[off++] = 0x53U; buf[off++] = 0x4DU;
@@ -1904,7 +1905,7 @@ esp_err_t motor_state_handler(httpd_req_t *req)
uint8_t count = 0;
if (have) {
- for (uint8_t i = 0; i < MOTOR_MAX_NODES; ++i) {
+ for (uint8_t i = 0; i < MOTOR_MAX_SLOTS; ++i) {
const motor_slot_t &s = tbl.slots[i];
if (s.node_id == 0) continue;
buf[off++] = s.node_id;
@@ -1953,7 +1954,7 @@ esp_err_t motor_state_handler(httpd_req_t *req)
(unsigned)tbl.active_count, tbl.boot_active ? "true" : "false", (unsigned long)age);
bool first = true;
- for (uint8_t i = 0; i < MOTOR_MAX_NODES && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) {
+ for (uint8_t i = 0; i < MOTOR_MAX_SLOTS && off > 0 && (size_t)off < HTTP_JSON_WORK_CAP - 128; ++i) {
const motor_slot_t &s = tbl.slots[i];
if (s.node_id == 0) continue;
diff --git a/tools/can.html b/tools/can.html
index 86dd153..2afefda 100644
--- a/tools/can.html
+++ b/tools/can.html
@@ -4,6 +4,8 @@
ESP32 CAN 通信控制台
+
+
diff --git a/tools/can_monitor.html b/tools/can_monitor.html
index 44b15d2..3333315 100644
--- a/tools/can_monitor.html
+++ b/tools/can_monitor.html
@@ -4,6 +4,8 @@
ESP32 CAN 接收监控
+
+
diff --git a/tools/css/motor.css b/tools/css/motor.css
index 44c9f52..c356331 100644
--- a/tools/css/motor.css
+++ b/tools/css/motor.css
@@ -247,6 +247,8 @@ label { display: block; }
}
.nc-mode.mode-off { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg-muted); }
.nc-mode.mode-fault { background: var(--danger-dim); color: var(--danger); }
+/* 节点在其他通道在线:橙色通道标签,提示切换顶部通道查看 */
+.nc-mode.mode-other-ch { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.nc-status-text {
margin-left: auto; font-size: 0.68rem; color: var(--fg-muted);
font-family: var(--font-mono); max-width: 7.5em;
diff --git a/tools/index.html b/tools/index.html
index d369270..f37217a 100644
--- a/tools/index.html
+++ b/tools/index.html
@@ -10,6 +10,8 @@
ESP32 OTA 中继控制台
+
+
diff --git a/tools/js/motor/main.js b/tools/js/motor/main.js
index d531ad1..5dcf96e 100644
--- a/tools/js/motor/main.js
+++ b/tools/js/motor/main.js
@@ -58,12 +58,12 @@ const state = {
ip: DEFAULT_ESP_IP,
ch: 0,
chEnabled: [true, true, true], // 各通道启用状态(由 onFeedback 更新)
+ chOnline: [0, 0, 0], // 各通道在线电机数(由 onMotorState 按 m.ch 统计,驱动下拉框信号标记)
selectedNode: null, // 当前选中节点号
motors: [], // 最近一次 /motor/state 的 motors 数组
decoded: new Map(), // node -> 解码后的 STATUS 对象
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
- * 锁相计算 computePhaseLockDelayMs() 失败时回退到此值,避免硬编码 250ms。
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。 */
refreshIntervalMs: 250,
lastSentMode: new Map(), // node -> 最近发送的 Control_Mode
@@ -82,25 +82,15 @@ const state = {
offlineSince: 0, // 进入离线时刻,短离线恢复不 toast
tickN: 0, // 轮询序号(每 5 轮附带 status)
statusTickN: 0, // status lite/full 交替计数(4 lite + 1 full 循环)
- /* ── 锁相参数:把轮询沿对齐到 GD32 0xC5 推送后沿 ──
- * GD32 由 CONFIG_TASK_PERIOD_MOTOR_STATE_TICKS 节拍驱动 250ms 推送(独立于 1Hz heartbeat),
- * 与浏览器 setTimeout 不同源、不锁相,age_ms 会在 0~250 间拍频扫描。
- * 这里用最近一次 age_ms + 本地 performance.now() 反推下一帧推送时刻,
- * 把下一轮 poll 目标时刻钉在「GD32 刚推完 + 安全余量」处。 */
- phase: {
- motorPeriodMs: 250, // GD32 推送周期(motor state = 2500 × 100µs)
- servoMs: 60, // 推送后沿安全余量(吸收 SPI/HTTP/mutex 抖动;周期短,余量相应减小)
- haveSync: false, // 是否已建立相位基准
- lastAgeMs: null, // 最近一次 /motor/state 返回的 age_ms
- lastRecvPerfMs: 0, // 对应响应到达时的 performance.now()
- },
- /* ── age 显示平滑:原始 age 对丢帧极敏感(丢 1 帧 → age 跳到 ~500ms),
- * 用滚动窗口中位数显示,反映「稳态相位」而非瞬时抖动。原始值仍可在
- * state.phaseDebug.history 查看 / 控制台诊断。 */
- ageWindow: [], // 最近 N 次原始 age_ms(按到达顺序)
- ageLossCount: 0, // 窗口内 age > motorPeriodMs*1.5 的次数(近似丢帧计数)
phaseDebug: { lastSlowReq: null, slowStats: {} }, // 慢请求诊断(>200ms)
};
+
+/* 控制台诊断入口:main.js 以 ES module 加载,state 等顶层变量不会挂到 window,
+ * 控制台直接敲 state 会报 "state is not defined"。这里显式暴露只读诊断快照。
+ * 用法:控制台敲 motorDbg.state 。 */
+window.motorDbg = {
+ state, // 完整 state 引用,方便深挖
+};
/**
* 连续「硬失败」多少次才判离线。
* HTTP 500/503 busy、超时 一律视为瞬时,不计入、不改 LED。
@@ -109,8 +99,7 @@ const FAIL_THRESHOLD = 5;
const CONN_TOAST_GAP_MS = 6000;
/** 离线不足此时长就恢复:不弹「连接已恢复」(过滤毛刺) */
const RECOVER_TOAST_MIN_OFFLINE_MS = 8000;
-/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。
- * 锁相成功时由 computePhaseLockDelayMs() 覆盖;此值仅作未同步/异常回退。 */
+/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。 */
const TICK_INTERVAL_MS = 250;
const PROBE_INTERVAL_MS = 2000;
@@ -178,8 +167,8 @@ function loadUiState() {
* 此时 polling 尚未启动,直接写 state 即可,无需重排定时器) */
const rr = parseInt(o.refreshRate, 10);
if (Number.isFinite(rr)) {
- /* 与 #refreshRate min/max 一致:最快对齐 GD32 250ms,最慢 5s */
- const clamped = clampNum(rr, 250, 5000);
+ /* 与 #refreshRate min/max 一致:最快 50ms(20Hz),最慢 5s */
+ const clamped = clampNum(rr, 50, 5000);
state.refreshIntervalMs = clamped;
const el = document.getElementById("refreshRate");
if (el) el.value = clamped;
@@ -591,21 +580,15 @@ function recordSlowReq(path, durMs, errMsg) {
};
}
-function noteHardSuccess() {
- state.failCount = 0;
-}
+/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */
+function noteHardSuccess() { state.failCount = 0; }
function noteHardFailure(e, source) {
- if (state.probing || state.connecting) return;
- /* 瞬时错误:完全静默,连 failCount 都不动,避免 LED/toast 毛刺 */
- if (isTransientError(e)) return;
- state.failCount += 1;
- const msg = (e && e.message) ? e.message : String(e || "请求失败");
- if (state.connected && state.failCount < FAIL_THRESHOLD) {
- /* 在线且未达阈值:UI 完全不动(不再把 LED 打成黄灯) */
- return;
+ if (e && e.transient) return; /* 500/503/超时等瞬时错误不计入 */
+ state.failCount++;
+ if (state.failCount >= FAIL_THRESHOLD && state.connected) {
+ enterDisconnected(e && e.message, source);
}
- enterDisconnected(msg, source);
}
function enterDisconnected(msg, source) {
@@ -677,7 +660,7 @@ async function probeOnce() {
* 使用主页共享 IP 自动连接(本页无连接按钮)。
* 失败不阻断页面,进入探测直到 /system/status 可用。
*/
-async function connectShared() {
+function connectShared() {
state.ip = getSharedDeviceIp();
state.connecting = true;
state.probing = false;
@@ -687,28 +670,9 @@ async function connectShared() {
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent =
- "自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP,请在主页配置并测试连接";
- try {
- const data = await fetchDeviceJson("/system/status", 4000);
- state.connecting = false;
- state.connected = true;
- state.failCount = 0;
- state.offlineSince = 0;
- $("#netBanner").classList.remove("show");
- applySystemStatus(data);
- updateConnUi();
- startPolling();
- connToast("已连接 " + state.ip + " · /system/status 正常", "ok");
- } catch (e) {
- state.connecting = false;
- state.connected = false;
- updateConnUi();
- $("#netBanner").classList.add("show");
- $("#netBannerText").textContent =
- "无法连接 " + state.ip + " — 自动重试中。请先到 OTA 主页配置 IP 并点「测试」";
- connToast("自动连接失败:" + (e.message || "无法访问 " + state.ip) + " — 将持续探测", "warn");
- startProbe();
- }
+ "自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP";
+ /* HTTP 探测驱动连接:/system/status 可用 → startPolling */
+ startProbe();
}
function applySystemStatus(data) {
@@ -762,36 +726,37 @@ function stopTimer(key) {
* 双链路轮询:motor 和 feedback 各自独立定时器,互不阻塞。
*
* 历史问题:原来 motor → feedback 串行,任一挂起(ESP32 端 mutex 阻塞 /
- * WiFi 抖动)会拖累另一个,表现为"age 不动 + 数据卡顿"。
- * 现在:motor 走锁相定时器,feedback 走固定 250ms 定时器,
+ * WiFi 抖动)会拖累另一个,表现为数据卡顿。
+ * 现在:motor 和 feedback 各走独立固定节拍定时器,
* - motor 挂起 3s 超时期间,feedback 仍正常刷
- * - feedback 挂起不影响 motor 锁相
+ * - feedback 挂起不影响 motor
* 两者共用 ESP32 端 httpJsonWorkMutex,ESP32 串行处理即可。
*/
function stopDataPolling() {
- state.pollGen += 1; /* 使在途 pollLoop 退出后不再续约 */
+ state.pollGen += 1;
stopTimer("tick");
stopTimer("feedback");
stopTimer("status");
state.tickInFlight = false;
state.feedbackInFlight = false;
}
+
+/**
+ * 启动数据链路:HTTP 三链(motor tick / feedback 1Hz / status 1Hz)立即开车。
+ */
function startPolling() {
- stopDataPolling();
+ stopDataPolling(); /* 递增 pollGen,清残留定时器/在途链 */
state.failCount = 0;
state.tickN = 0;
+ state.statusTickN = 0;
const gen = state.pollGen;
schedulePollLoop(gen, 0);
- scheduleFeedbackPoll(gen, 100);
- /* status 独立定时器:与 motor/feedback 解耦,避免 ESP32 端 httpJsonWorkMutex
- * 被 system_status_handler 长时间占用时拖慢 motor/feedback 轮询。
- * - lite(?lite=1):轻量路径,~150B,只返回 wifi + gd32_features,
- * 不调 heap_caps_get_info / 任务栈遍历。1s 一次(高频)。
- * - full:完整路径,~3KB,含 OTA/heap/SPI 等。5s 一次(低频)。
- * 两条路径交替:lite, lite, lite, lite, full, lite, lite, lite, lite, full, …
- * 这样完整 status 阻塞 httpd 的频率从 2s/次 降到 5s/次,pending 显著减少。 */
+ scheduleFeedbackPoll(gen, 0);
scheduleStatusPoll(gen, 1000);
+ /* 连上后把持久化的刷新速度同步给 GD32,使 0xC5 推送周期与本地轮询一致(无需用户重新拨动) */
+ sendMotorPeriod(state.refreshIntervalMs || 250);
}
+
function scheduleStatusPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("status");
@@ -835,54 +800,13 @@ function scheduleFeedbackPoll(gen, delayMs) {
function schedulePollLoop(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("tick");
- /* 锁相模式:delayMs 省略时,根据最近一次 age_ms 反推下一帧推送时刻,
- * 把 poll 沿钉到「GD32 推送后沿 + servoMs」处。任何异常都退回固定节拍。 */
- let delay = delayMs;
- if (delay == null) {
- delay = computePhaseLockDelayMs();
- }
+ const delay = delayMs != null ? delayMs : (state.refreshIntervalMs || TICK_INTERVAL_MS);
state.poll.tick = setTimeout(function () {
state.poll.tick = null;
pollLoop(gen);
}, delay);
}
-/**
- * 根据最近一次 /motor/state 的 age_ms + 本地时钟,计算下一轮 motor 轮询延迟。
- *
- * 数据源 GD32 0xC5 固定 ~250ms 推送,浏览器无法拿到比源更新的数据:
- * - 用户间隔 ≤ 推送周期:按 1×period 锁相(最快 250ms)
- * - 用户间隔 > 推送周期:按 N×period 抽稀(N = round(user/period))
- * 这样改「刷新速度」到 500/1000/2000 会真实变慢,而不是被锁相钉死在 250。
- * 上一帧推送本地时刻 = lastRecvPerfMs - lastAgeMs。
- * 无同步样本时退回 max(userMs, period)。
- */
-function computePhaseLockDelayMs() {
- const ph = state.phase;
- const period = ph.motorPeriodMs || TICK_INTERVAL_MS;
- const userMs = state.refreshIntervalMs || TICK_INTERVAL_MS;
- /* 不能比 GD32 推送更快;更慢则取整到 N 个推送周期 */
- const n = Math.max(1, Math.round(userMs / period));
- const targetPeriod = n * period;
- if (!ph.haveSync || ph.lastAgeMs == null || ph.lastRecvPerfMs <= 0) {
- return targetPeriod;
- }
- const now = performance.now();
- const lastPushPerfMs = ph.lastRecvPerfMs - ph.lastAgeMs;
- let nextPushPerfMs = lastPushPerfMs + targetPeriod;
- while (nextPushPerfMs <= now) {
- nextPushPerfMs += targetPeriod;
- }
- const target = nextPushPerfMs + ph.servoMs;
- let delay = target - now;
- /* clamp:太小会贴着推送沿被抖到沿前;太大说明相位估计已漂远,
- * 退回固定节拍等下一轮重新对齐。 */
- if (!Number.isFinite(delay) || delay < 30 || delay > targetPeriod + 200) {
- return targetPeriod;
- }
- return delay;
-}
-
/**
* motor 轮询主链:只跑 fetchMotorState,feedback/status 已剥离独立定时器。
* motor 挂起 3s 超时期间,feedback 仍正常刷 CAN 统计(不再被 motor 拖)。
@@ -901,8 +825,6 @@ async function pollLoop(gen) {
} finally {
state.tickInFlight = false;
if (gen === state.pollGen && state.connected && !state.probing) {
- /* 不传 delayMs → schedulePollLoop 用锁相计算;
- * 失败轮(noteHardFailure 已处理)或尚未同步时退回固定节拍。 */
schedulePollLoop(gen);
}
}
@@ -1042,74 +964,45 @@ async function fetchFeedback(gen) {
}
/* ═══════════════════════ /motor/state 处理 ═══════════════════════ */
+/* 后端表已改 node×channel 双键(同节点号可同时在多通道在线),
+ * motors[] 可能含同 node 不同 ch 的多条记录,decoded 缓存键也必须带通道。 */
+function motorKey(m) { return m.ch + ":" + m.node; }
+
function onMotorState(data) {
const motors = (data && data.motors) ? data.motors : [];
state.motors = motors;
- /* 锁相采样:记录 age_ms 与本地高精度时钟,供 schedulePollLoop 反推
- * GD32 下一次推送时刻。age 异常(null / UINT32_MAX 上界 / 负数)时
- * 不更新基准,schedulePollLoop 会自动回退固定节拍。
- * 上界 600 ≈ 2.4 × motorPeriodMs:覆盖 1 拍丢失后 age 跳到 ~500ms 的情形,
- * 仍能维持锁相基准;超过 600 视为长时通信故障,退回固定节拍重学。 */
- if (data && data.age_ms != null) {
- const a = Number(data.age_ms);
- if (Number.isFinite(a) && a >= 0 && a < 600) {
- state.phase.lastAgeMs = a;
- state.phase.lastRecvPerfMs = performance.now();
- state.phase.haveSync = true;
- }
- }
-
- /* age 显示平滑:滚动窗口采样 + 丢帧近似计数。
- * age > motorPeriodMs*1.5 视为丢帧(GD32 ring 超时 / SPI 抢占 / ESP32 忙)。 */
- if (data && data.age_ms != null) {
- const a = Number(data.age_ms);
- if (Number.isFinite(a) && a >= 0) {
- const w = state.ageWindow;
- w.push(a);
- if (w.length > 12) w.shift();
- if (a > state.phase.motorPeriodMs * 1.5) {
- state.ageLossCount = (state.ageLossCount || 0) + 1;
- }
- }
- }
-
- /* 解码每个节点的 STATUS 帧 */
+ /* 解码每个 节点×通道 的 STATUS 帧 */
const decoded = new Map();
for (const m of motors) {
const bytes = hexToBytes(m.data);
const d = decodeStatus(bytes);
- if (d) decoded.set(m.node, d);
+ if (d) decoded.set(motorKey(m), d);
}
state.decoded = decoded;
+ /* 按实际通道统计在线电机数:驱动通道下拉框信号标记 + 本页通道过滤视图 */
+ const chOnline = [0, 0, 0];
+ for (const m of motors) {
+ if (m.online && m.node >= 1 && m.node <= 9 && m.ch >= 0 && m.ch <= 2) chOnline[m.ch]++;
+ }
+ state.chOnline = chOnline;
+ updateChSelectOptions();
+
/* 固定节点 1–9:始终可选(含离线),便于电机已进 BOOT / 无 STATUS 时仍能刷写。
- * 自动选择:当前选中已是 1–9 则保持;否则优先第一个在线,否则默认 1。 */
+ * 自动选择:当前选中已是 1–9 则保持;否则优先当前通道的在线节点,其次任意在线,否则默认 1。 */
let autoSelected = false;
if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) {
- const online = motors.find((m) => m.online && m.node >= 1 && m.node <= 9);
+ const online = motors.find((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9)
+ || motors.find((m) => m.online && m.node >= 1 && m.node <= 9);
state.selectedNode = online ? online.node : 1;
autoSelected = true;
}
- renderNodeList();
- renderStatusPanel();
+ refreshChannelViews();
updateNodeLabels();
if (autoSelected) restoreVersionUi(state.selectedNode); /* 自动切节点时回填缓存版本 */
- /* age 显示:用滚动窗口中位数过滤丢帧瞬时跳变。
- * 原始 age 在 GD32 丢一帧时会跳到 ~500ms,但稳态相位没变;
- * 中位数反映「最近典型 age」,对单点跳变免疫。
- * 丢帧计数单独显示,让用户能区分「相位漂移」和「丢帧」。 */
- const onlineCount = motors.filter((m) => m.online && m.node >= 1 && m.node <= 9).length;
- const w = state.ageWindow;
- const medAge = w.length ? median(w) : null;
- const lossN = state.ageLossCount || 0;
- const ageText = medAge != null
- ? `age≈${Math.round(medAge)}ms` + (lossN ? ` · 丢帧${lossN}` : "")
- : (data && data.age_ms != null ? `age ${data.age_ms}ms` : "age --");
- $("#nodeSummary").textContent = `${onlineCount}/9 在线 · ${ageText}`;
-
/* BOOT 徽章:总线上有电机在 Bootloader(0x103 心跳)时显示,独立于 N1-N9。
* 0x103 固定 ID 不带节点号,故不归属任何 Nx,只作总线级 BOOT 提示。
* 同时写 class + style,避免任何残留内联 display 把 .show 盖掉。 */
@@ -1124,33 +1017,41 @@ function onMotorState(data) {
}
}
-/* 滚动窗口中位数:复制后排序取中,避免污染原数组顺序。 */
-function median(arr) {
- if (!arr || !arr.length) return null;
- const s = arr.slice().sort((a, b) => a - b);
- const n = s.length;
- return (n % 2) ? s[(n - 1) >> 1] : (s[n / 2 - 1] + s[n / 2]) / 2;
-}
-
/* ─── 节点列表渲染 ───
* ★ 固定显示 N1–N9:即使离线/无 0xC5 推送也可见可选,方便电机在 BOOT 时刷写。
* 性能:首次全量重建 DOM;之后只增量更新文本/class。 */
const FIXED_MOTOR_NODES = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const _nodeCards = new Map(); // node -> { card, led, modeEl, stTxt, angleVal, velVal, curVal, faultsEl }
-/** 把后端 motors[] 叠到固定 1–9 槽位;无数据的槽位记为 offline。 */
+/** 把后端 motors[] 叠到固定 1–9 槽位;无数据的槽位记为 offline。
+ * ★ node×channel 双键:同节点号可能在多个通道各有一条记录。
+ * 优先级:当前通道在线 > 其他通道在线(otherCh 标注,如 "0/2")>
+ * 当前通道离线记录 > 空槽位。 */
function getDisplayMotors() {
- const byNode = new Map();
- for (const m of state.motors || []) {
- if (m && m.node >= 1 && m.node <= 9) byNode.set(m.node, m);
- }
+ const motors = state.motors || [];
return FIXED_MOTOR_NODES.map((node) => {
- const m = byNode.get(node);
- if (m) return m;
+ const entries = motors.filter((m) => m && m.node === node);
+ const cur = entries.find((m) => m.ch === state.ch);
+ if (cur && cur.online) return cur;
+ const others = entries.filter((m) => m.online && m.ch !== state.ch);
+ if (others.length) {
+ const chs = others.map((o) => o.ch).sort().join("/");
+ return { node, ch: others[0].ch, online: false, otherCh: chs, dlc: 0, rx_count: 0, seq: 0, data: "" };
+ }
+ if (cur) return cur;
return { node, ch: state.ch || 0, online: false, dlc: 0, rx_count: 0, seq: 0, data: "" };
});
}
+/** 通道切换或电机数据更新后,按当前通道刷新节点列表 / STATUS 面板 / 在线汇总 */
+function refreshChannelViews() {
+ renderNodeList();
+ renderStatusPanel();
+ const onlineCount = (state.motors || [])
+ .filter((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9).length;
+ $("#nodeSummary").textContent = `${onlineCount}/9 在线 · CH${state.ch}`;
+}
+
function renderNodeList() {
const motors = getDisplayMotors();
@@ -1185,7 +1086,8 @@ function renderNodeList() {
for (const m of motors) {
const ref = _nodeCards.get(m.node);
if (!ref) continue;
- const d = state.decoded.get(m.node);
+ /* otherCh 卡片不取解码数据:其 STATUS 属于其他通道,本通道视图不展示/不标故障 */
+ const d = m.otherCh != null ? null : state.decoded.get(motorKey(m));
const faultCount = faultCountOf(d);
const faultLabel = faultShortLabel(d);
const isSel = m.node === state.selectedNode;
@@ -1199,19 +1101,22 @@ function renderNodeList() {
if (ref.card.className !== cls) ref.card.className = cls;
ref.card.setAttribute("aria-selected", String(isSel));
if (faultLabel) ref.card.title = "故障: " + faultLabel;
+ else if (m.otherCh != null) ref.card.title = `节点在 CH${m.otherCh} 在线,切换顶部通道查看`;
else if (!m.online) ref.card.title = "离线也可选中刷写";
else ref.card.title = "在线";
- const ledCls = m.online ? (faultCount > 0 ? "led-err" : "led-on") : "";
+ const ledCls = m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : "");
const ledFull = "led " + ledCls;
if (ref.led.className !== ledFull) ref.led.className = ledFull;
const sentMode = state.lastSentMode.get(m.node);
const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
- /* motStatus 是报错位(1=故障),不是运行标志;无最近 CMD 时用 待机 */
- const modeLabel = !m.online ? "离线" : (mode ? mode.zh : "待机");
+ /* motStatus 是报错位(1=故障),不是运行标志;无最近 CMD 时用 待机。
+ * otherCh:节点在其他通道在线,标注实际通道供用户切换。 */
+ const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
if (ref.modeEl.textContent !== modeLabel) ref.modeEl.textContent = modeLabel;
- const modeCls = !m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode");
+ const modeCls = m.otherCh != null ? "nc-mode mode-other-ch"
+ : (!m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode"));
if (ref.modeEl.className !== modeCls) ref.modeEl.className = modeCls;
const stTxt = !m.online ? "--" : (faultLabel || "OK");
@@ -1236,7 +1141,8 @@ function renderNodeList() {
/* 构建单节点卡片的 HTML(仅集合变化时调用) */
function buildNodeCardHtml(m) {
- const d = state.decoded.get(m.node);
+ /* otherCh 卡片不取解码数据(同 renderNodeList 增量路径) */
+ const d = m.otherCh != null ? null : state.decoded.get(motorKey(m));
const faultCount = faultCountOf(d);
const faultLabel = faultShortLabel(d);
const isSel = m.node === state.selectedNode;
@@ -1248,17 +1154,19 @@ function buildNodeCardHtml(m) {
].join(" ").trim();
const sentMode = state.lastSentMode.get(m.node);
const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
- const modeLabel = !m.online ? "离线" : (mode ? mode.zh : "待机");
- const modeCls = !m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : "");
+ const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
+ const modeCls = m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : ""));
const stTxt = !m.online ? "--" : (faultLabel || "OK");
- const cardTitle = faultLabel ? ("故障: " + faultLabel) : (m.online ? "在线" : "离线也可选中刷写");
+ const cardTitle = faultLabel ? ("故障: " + faultLabel)
+ : m.otherCh != null ? `节点在 CH${m.otherCh} 在线,切换顶部通道查看`
+ : (m.online ? "在线" : "离线也可选中刷写");
const angle = d && m.online ? d.rtAngle.toFixed(1) + "°" : "--";
const vel = d && m.online ? d.rtVelocity.toFixed(0) : "--";
const cur = d && m.online ? d.rtCurrent.toFixed(0) : "--";
return `
${faultCount > 0 ? `
${"".repeat(faultCount)}
` : ""}
-
+ 0 ? "led-err" : "led-on") : "")}">
N${m.node}
${modeLabel}
${stTxt}
@@ -1310,8 +1218,13 @@ function flashEl(el) {
function renderStatusPanel() {
const n = state.selectedNode;
- const motor = n != null ? state.motors.find((m) => m.node === n) : null;
- const d = n != null ? state.decoded.get(n) : null;
+ /* ★ node×channel 双键:只展示当前通道的记录;节点在其他通道在线时给出切换提示 */
+ const entries = n != null ? state.motors.filter((m) => m.node === n) : [];
+ const cur = entries.find((m) => m.ch === state.ch);
+ const others = entries.filter((m) => m.online && m.ch !== state.ch);
+ const onOtherCh = !(cur && cur.online) && others.length > 0;
+ const motor = onOtherCh ? null : cur;
+ const d = (motor && motor.online) ? state.decoded.get(motorKey(motor)) : null;
const offline = !motor || !motor.online || !d;
$("#offlineOverlay").classList.toggle("show", offline);
@@ -1320,6 +1233,10 @@ function renderStatusPanel() {
if (!state.connected) {
$("#offlineTitle").textContent = "设备未连接";
$("#offlineSub").textContent = "本页共用 OTA 主页 IP,请到主页配置并测试连接";
+ } else if (onOtherCh) {
+ const chs = others.map((o) => "CH" + o.ch).sort().join(" / ");
+ $("#offlineTitle").textContent = `节点 ${n} 在 ${chs} 有信号`;
+ $("#offlineSub").textContent = `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`;
} else if (!motor || !motor.online) {
$("#offlineTitle").textContent = n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)";
$("#offlineSub").textContent = "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写";
@@ -1382,17 +1299,25 @@ function busStateText(bs) {
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
-/** 根据通道启用状态更新下拉框选项样式,禁用通道贴 "(禁用)" 标签并置灰 */
+/** 根据通道启用/信号状态更新下拉框选项:
+ * - 禁用通道贴 "(禁用)" 标签并置灰
+ * - 有信号通道(存在在线电机)贴 "●N" 标记并着色 */
function updateChSelectOptions() {
const sel = $("#chSelect");
if (!sel) return;
for (let i = 0; i < sel.options.length; i++) {
const opt = sel.options[i];
const en = state.chEnabled[i];
+ const cnt = state.chOnline[i] || 0;
const base = "CH" + i;
- opt.textContent = en ? base : base + " (禁用)";
- opt.style.color = en ? "" : "var(--fg-dim,#666)";
+ opt.textContent = !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base);
+ opt.style.color = !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : "");
}
+ /* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效),
+ * 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白 */
+ const curEn = state.chEnabled[state.ch];
+ const curCnt = state.chOnline[state.ch] || 0;
+ sel.style.color = !curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "");
}
function onFeedback(data) {
@@ -2398,40 +2323,56 @@ function otaDone(sendCancel = true) {
$("#chSelect").addEventListener("change", () => {
state.ch = parseInt($("#chSelect").value, 10) || 0;
updateConnUi();
+ updateChSelectOptions(); /* 同步选框字色:切到有信号通道则绿色,否则默认 */
+ /* 按新通道重刷节点列表 / STATUS 面板 / 在线汇总(通道视图过滤立即生效) */
+ refreshChannelViews();
/* 通道切换不额外拉 feedback:等下一轮 pollLoop,避免与单飞链并发 */
});
/* ═══════════════════════ 刷新速度(motor 轮询间隔) ═══════════════════════ */
/* 用户改 #refreshRate 后:clamp、回填、写 state,并按「N×GD32周期」立即重排。
- * 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。
- * 注意:旧实现只把 userMs 当锁相失败 fallback,成功锁相后永远 ~250ms,
- * 导致改刷新速度“看起来没反应”。现在 delay 真正跟随 N×period。 */
+ * 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。 */
const refreshRateInput = $("#refreshRate");
+
+/* 下发 GD32 0xC5 主动推送周期:复用通用 SPI 透传口 /spi/send(不新增 HTTP 路由)。
+ * 命令码 0xC5 + 子操作码 0xFE,data 十六进制 = FE (毫秒 LE)。
+ * GD32 端钳制 [50,5000]ms 并回 ACK。失败静默(不影响本地轮询)。 */
+async function sendMotorPeriod(ms) {
+ const lo = ms & 0xFF, hi = (ms >> 8) & 0xFF;
+ const toHex = (b) => b.toString(16).padStart(2, "0").toUpperCase();
+ try {
+ await fetch(getBaseUrl() + "/spi/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type: "C5", data: "FE" + toHex(lo) + toHex(hi), timeout_ms: 2000 }),
+ signal: AbortSignal.timeout(3000),
+ });
+ } catch (e) { /* 周期下发失败静默:GD32 保持旧周期,本地轮询不受影响 */ }
+}
function applyRefreshRate() {
- const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
- const v = clampNum(parseInt(refreshRateInput.value, 10) || period, period, 5000);
+ const v = clampNum(parseInt(refreshRateInput.value, 10) || 250, 50, 5000);
refreshRateInput.value = v;
state.refreshIntervalMs = v;
- const n = Math.max(1, Math.round(v / period));
- const effective = n * period;
- /* 取消在途 tick,按新间隔重排;本轮 in-flight 完成后 finally 也会走新 computePhaseLock。 */
if (state.poll.tick) {
clearTimeout(state.poll.tick);
state.poll.tick = null;
}
- if (state.connected && !state.probing && !state.tickInFlight) {
- schedulePollLoop(state.pollGen, effective);
+ /* HTTP 轮询驱动:清掉旧节拍后必须立即按新间隔重排 motor 链,
+ * 否则轮询链断死、STATUS 面板停更。
+ * tickInFlight 时在途 pollLoop 的 finally 也会重排,schedulePollLoop 内部
+ * stopTimer 保证不会双链并发。 */
+ if (state.connected && !state.probing) {
+ schedulePollLoop(state.pollGen, 0);
+ sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
}
- const hz = 1000 / effective;
- const note = (effective !== v) ? ` → 实际 ${effective}ms` : "";
- toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz,${n}×${period}ms)${note}`, "ok");
+ const hz = 1000 / v;
+ toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
}
refreshRateInput.addEventListener("change", applyRefreshRate);
refreshRateInput.addEventListener("input", () => {
- /* 输入过程中只 clamp 显示,松手/change 再应用节拍,避免每个键都 toast */
- const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
+ /* 输入过程中只 clamp 下限显示,松手/change 再应用节拍,避免每个键都 toast */
const raw = parseInt(refreshRateInput.value, 10);
- if (Number.isFinite(raw) && raw < period) refreshRateInput.value = period;
+ if (Number.isFinite(raw) && raw < 50) refreshRateInput.value = 50;
});
/* ═══════════════════════ 示波器系统 ═══════════════════════ */
@@ -2648,11 +2589,12 @@ const scopeSys = (() => {
card.addEventListener("click", () => card.classList.toggle("open"));
});
- /* 采样:用真实解码的 STATUS 数据填充历史缓冲(1Hz 数据 → 平滑插值) */
+ /* 采样:用真实解码的 STATUS 数据填充历史缓冲(1Hz 数据 → 平滑插值)。
+ * node×channel 双键:只采当前通道的记录,避免波形混入其他通道同号电机数据。 */
function sample() {
const n = state.selectedNode;
- const d = n != null ? state.decoded.get(n) : null;
- const motor = n != null ? state.motors.find((m) => m.node === n) : null;
+ const motor = n != null ? state.motors.find((m) => m.node === n && m.ch === state.ch) : null;
+ const d = motor ? state.decoded.get(motorKey(motor)) : null;
const online = d && motor && motor.online;
if (!online) {
hist.u[head] = hist.v[head] = hist.w[head] = 0;
@@ -2714,33 +2656,19 @@ bindAutoSave();
initCmdCommit(); /* CMD 输入提交制:必须在 loadUiState 回填之后初始化,否则初始提交值是 HTML 默认值 */
otaRestorePersistedFirmware(); /* 异步恢复上次固件(无阻塞) */
-/* 暴露诊断对象到 window,供控制台排查 age 波动 / 锁相状态:
- * window.phaseDebug.history // 最近 12 次 raw age_ms
- * window.phaseDebug.lossCount // 累计丢帧次数
- * window.phaseDebug.lastSyncAge // 锁相基准用的 age
- * window.phaseDebug.haveSync // 是否已建立锁相
- * window.phaseDebug.lastSlowReq // 上次慢请求(>200ms)path+耗时,定位"age 不动"卡顿
- * window.phaseDebug.reset() // 强制重新学习锁相 */
+/* 暴露诊断对象到 window,供控制台排查慢请求:
+ * window.phaseDebug.lastSlowReq // 上次慢请求(>200ms)path+耗时
+ * window.phaseDebug.slowStats // 累计慢请求统计
+ * window.phaseDebug.reset() // 清空慢请求统计 */
window.phaseDebug = {
- get history() { return state.ageWindow.slice(); },
- get lossCount() { return state.ageLossCount || 0; },
- get lastSyncAge() { return state.phase.lastAgeMs; },
- get haveSync() { return state.phase.haveSync; },
- get motorPeriodMs() { return state.phase.motorPeriodMs; },
- get servoMs() { return state.phase.servoMs; },
get lastSlowReq() { return state.phaseDebug && state.phaseDebug.lastSlowReq; },
get slowStats() { return state.phaseDebug && state.phaseDebug.slowStats; },
reset() {
- state.phase.haveSync = false;
- state.phase.lastAgeMs = null;
- state.phase.lastRecvPerfMs = 0;
- state.ageWindow.length = 0;
- state.ageLossCount = 0;
if (state.phaseDebug) {
state.phaseDebug.lastSlowReq = null;
state.phaseDebug.slowStats = {};
}
- console.log("[phaseDebug] 锁相基准 + 慢请求统计已清空");
+ console.log("[phaseDebug] 慢请求统计已清空");
},
};
diff --git a/tools/motor.html b/tools/motor.html
index fdc8f50..54f3ac4 100644
--- a/tools/motor.html
+++ b/tools/motor.html
@@ -4,7 +4,9 @@
电机控制台 — Motor CAN FD Console
-
+
+
+
@@ -36,9 +38,9 @@
-
-
+