feat(motor): 支持多通道同节点号电机状态管理

支持同一节点号在多个CAN通道上的电机状态管理,采用node×channel双键存储机制。

- 修改motor_state.cpp中的槽位索引逻辑,从单节点索引改为(node-1)*MOTOR_CHANNELS+channel索引
- 增加MOTOR_CHANNELS和MOTOR_MAX_SLOTS宏定义,支持9节点×3通道共27个槽位
- 更新http_handlers.cpp中的二进制响应缓冲区为static类型,避免大缓冲区占用栈空间
- 调整循环遍历范围从MOTOR_MAX_NODES改为MOTOR_MAX_SLOTS
- 添加HTML页面的空favicon配置,避免浏览器404请求
- 新增CSS样式用于显示节点在其他通道在线的状态提示
This commit is contained in:
小龙虾
2026-07-30 11:57:21 +08:00
parent 74eb68b140
commit e9cb7342f6
9 changed files with 190 additions and 245 deletions
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 接收监控</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
+2
View File
@@ -247,6 +247,8 @@ label { display: block; }
}
.nc-mode.mode-off { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg-muted); }
.nc-mode.mode-fault { background: var(--danger-dim); color: var(--danger); }
/* 节点在其他通道在线:橙色通道标签,提示切换顶部通道查看 */
.nc-mode.mode-other-ch { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.nc-status-text {
margin-left: auto; font-size: 0.68rem; color: var(--fg-muted);
font-family: var(--font-mono); max-width: 7.5em;
+2
View File
@@ -10,6 +10,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继控制台</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
+157 -229
View File
@@ -58,12 +58,12 @@ const state = {
ip: DEFAULT_ESP_IP,
ch: 0,
chEnabled: [true, true, true], // 各通道启用状态(由 onFeedback 更新)
chOnline: [0, 0, 0], // 各通道在线电机数(由 onMotorState 按 m.ch 统计,驱动下拉框信号标记)
selectedNode: null, // 当前选中节点号
motors: [], // 最近一次 /motor/state 的 motors 数组
decoded: new Map(), // node -> 解码后的 STATUS 对象
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
* 锁相计算 computePhaseLockDelayMs() 失败时回退到此值,避免硬编码 250ms。
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。 */
refreshIntervalMs: 250,
lastSentMode: new Map(), // node -> 最近发送的 Control_Mode
@@ -82,25 +82,15 @@ const state = {
offlineSince: 0, // 进入离线时刻,短离线恢复不 toast
tickN: 0, // 轮询序号(每 5 轮附带 status)
statusTickN: 0, // status lite/full 交替计数(4 lite + 1 full 循环)
/* ── 锁相参数:把轮询沿对齐到 GD32 0xC5 推送后沿 ──
* GD32 由 CONFIG_TASK_PERIOD_MOTOR_STATE_TICKS 节拍驱动 250ms 推送(独立于 1Hz heartbeat),
* 与浏览器 setTimeout 不同源、不锁相,age_ms 会在 0~250 间拍频扫描。
* 这里用最近一次 age_ms + 本地 performance.now() 反推下一帧推送时刻,
* 把下一轮 poll 目标时刻钉在「GD32 刚推完 + 安全余量」处。 */
phase: {
motorPeriodMs: 250, // GD32 推送周期(motor state = 2500 × 100µs)
servoMs: 60, // 推送后沿安全余量(吸收 SPI/HTTP/mutex 抖动;周期短,余量相应减小)
haveSync: false, // 是否已建立相位基准
lastAgeMs: null, // 最近一次 /motor/state 返回的 age_ms
lastRecvPerfMs: 0, // 对应响应到达时的 performance.now()
},
/* ── age 显示平滑:原始 age 对丢帧极敏感(丢 1 帧 → age 跳到 ~500ms),
* 用滚动窗口中位数显示,反映「稳态相位」而非瞬时抖动。原始值仍可在
* state.phaseDebug.history 查看 / 控制台诊断。 */
ageWindow: [], // 最近 N 次原始 age_ms(按到达顺序)
ageLossCount: 0, // 窗口内 age > motorPeriodMs*1.5 的次数(近似丢帧计数)
phaseDebug: { lastSlowReq: null, slowStats: {} }, // 慢请求诊断(>200ms)
};
/* 控制台诊断入口:main.js 以 ES module 加载,state 等顶层变量不会挂到 window,
* 控制台直接敲 state 会报 "state is not defined"。这里显式暴露只读诊断快照。
* 用法:控制台敲 motorDbg.state 。 */
window.motorDbg = {
state, // 完整 state 引用,方便深挖
};
/**
* 连续「硬失败」多少次才判离线。
* HTTP 500/503 busy、超时 一律视为瞬时,不计入、不改 LED。
@@ -109,8 +99,7 @@ const FAIL_THRESHOLD = 5;
const CONN_TOAST_GAP_MS = 6000;
/** 离线不足此时长就恢复:不弹「连接已恢复」(过滤毛刺) */
const RECOVER_TOAST_MIN_OFFLINE_MS = 8000;
/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。
* 锁相成功时由 computePhaseLockDelayMs() 覆盖;此值仅作未同步/异常回退。 */
/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。 */
const TICK_INTERVAL_MS = 250;
const PROBE_INTERVAL_MS = 2000;
@@ -178,8 +167,8 @@ function loadUiState() {
* 此时 polling 尚未启动,直接写 state 即可,无需重排定时器) */
const rr = parseInt(o.refreshRate, 10);
if (Number.isFinite(rr)) {
/* 与 #refreshRate min/max 一致:最快对齐 GD32 250ms,最慢 5s */
const clamped = clampNum(rr, 250, 5000);
/* 与 #refreshRate min/max 一致:最快 50ms(20Hz),最慢 5s */
const clamped = clampNum(rr, 50, 5000);
state.refreshIntervalMs = clamped;
const el = document.getElementById("refreshRate");
if (el) el.value = clamped;
@@ -591,21 +580,15 @@ function recordSlowReq(path, durMs, errMsg) {
};
}
function noteHardSuccess() {
state.failCount = 0;
}
/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */
function noteHardSuccess() { state.failCount = 0; }
function noteHardFailure(e, source) {
if (state.probing || state.connecting) return;
/* 瞬时错误:完全静默,连 failCount 都不动,避免 LED/toast 毛刺 */
if (isTransientError(e)) return;
state.failCount += 1;
const msg = (e && e.message) ? e.message : String(e || "请求失败");
if (state.connected && state.failCount < FAIL_THRESHOLD) {
/* 在线且未达阈值:UI 完全不动(不再把 LED 打成黄灯) */
return;
if (e && e.transient) return; /* 500/503/超时等瞬时错误不计入 */
state.failCount++;
if (state.failCount >= FAIL_THRESHOLD && state.connected) {
enterDisconnected(e && e.message, source);
}
enterDisconnected(msg, source);
}
function enterDisconnected(msg, source) {
@@ -677,7 +660,7 @@ async function probeOnce() {
* 使用主页共享 IP 自动连接(本页无连接按钮)。
* 失败不阻断页面,进入探测直到 /system/status 可用。
*/
async function connectShared() {
function connectShared() {
state.ip = getSharedDeviceIp();
state.connecting = true;
state.probing = false;
@@ -687,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 `<div class="${cls}" role="option" tabindex="0" data-node="${m.node}" aria-selected="${isSel}" title="${cardTitle}">
${faultCount > 0 ? `<div class="nc-faults">${"<span class='fault-dot'></span>".repeat(faultCount)}</div>` : ""}
<div class="nc-top">
<span class="led ${m.online ? (faultCount > 0 ? "led-err" : "led-on") : ""}"></span>
<span class="led ${m.otherCh != null ? "led-warn" : (m.online ? (faultCount > 0 ? "led-err" : "led-on") : "")}"></span>
<span class="nc-id">N${m.node}</span>
<span class="nc-mode ${modeCls}">${modeLabel}</span>
<span class="nc-status-text" title="${faultLabel ? ("故障: " + faultLabel) : ""}">${stTxt}</span>
@@ -1310,8 +1218,13 @@ function flashEl(el) {
function renderStatusPanel() {
const n = state.selectedNode;
const motor = n != null ? state.motors.find((m) => m.node === n) : null;
const d = n != null ? state.decoded.get(n) : null;
/* ★ node×channel 双键:只展示当前通道的记录;节点在其他通道在线时给出切换提示 */
const entries = n != null ? state.motors.filter((m) => m.node === n) : [];
const cur = entries.find((m) => m.ch === state.ch);
const others = entries.filter((m) => m.online && m.ch !== state.ch);
const onOtherCh = !(cur && cur.online) && others.length > 0;
const motor = onOtherCh ? null : cur;
const d = (motor && motor.online) ? state.decoded.get(motorKey(motor)) : null;
const offline = !motor || !motor.online || !d;
$("#offlineOverlay").classList.toggle("show", offline);
@@ -1320,6 +1233,10 @@ function renderStatusPanel() {
if (!state.connected) {
$("#offlineTitle").textContent = "设备未连接";
$("#offlineSub").textContent = "本页共用 OTA 主页 IP,请到主页配置并测试连接";
} else if (onOtherCh) {
const chs = others.map((o) => "CH" + o.ch).sort().join(" / ");
$("#offlineTitle").textContent = `节点 ${n} 在 ${chs} 有信号`;
$("#offlineSub").textContent = `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`;
} else if (!motor || !motor.online) {
$("#offlineTitle").textContent = n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)";
$("#offlineSub").textContent = "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写";
@@ -1382,17 +1299,25 @@ function busStateText(bs) {
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
/** 根据通道启用状态更新下拉框选项样式,禁用通道贴 "(禁用)" 标签并置灰 */
/** 根据通道启用/信号状态更新下拉框选项:
* - 禁用通道贴 "(禁用)" 标签并置灰
* - 有信号通道(存在在线电机)贴 "●N" 标记并着色 */
function updateChSelectOptions() {
const sel = $("#chSelect");
if (!sel) return;
for (let i = 0; i < sel.options.length; i++) {
const opt = sel.options[i];
const en = state.chEnabled[i];
const cnt = state.chOnline[i] || 0;
const base = "CH" + i;
opt.textContent = en ? base : base + " (禁用)";
opt.style.color = en ? "" : "var(--fg-dim,#666)";
opt.textContent = !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base);
opt.style.color = !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : "");
}
/* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效),
* 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白 */
const curEn = state.chEnabled[state.ch];
const curCnt = state.chOnline[state.ch] || 0;
sel.style.color = !curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "");
}
function onFeedback(data) {
@@ -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 <ms_lo> <ms_hi>(毫秒 LE)。
* GD32 端钳制 [50,5000]ms 并回 ACK。失败静默(不影响本地轮询)。 */
async function sendMotorPeriod(ms) {
const lo = ms & 0xFF, hi = (ms >> 8) & 0xFF;
const toHex = (b) => b.toString(16).padStart(2, "0").toUpperCase();
try {
await fetch(getBaseUrl() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "C5", data: "FE" + toHex(lo) + toHex(hi), timeout_ms: 2000 }),
signal: AbortSignal.timeout(3000),
});
} catch (e) { /* 周期下发失败静默:GD32 保持旧周期,本地轮询不受影响 */ }
}
function applyRefreshRate() {
const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
const v = clampNum(parseInt(refreshRateInput.value, 10) || period, period, 5000);
const v = clampNum(parseInt(refreshRateInput.value, 10) || 250, 50, 5000);
refreshRateInput.value = v;
state.refreshIntervalMs = v;
const n = Math.max(1, Math.round(v / period));
const effective = n * period;
/* 取消在途 tick,按新间隔重排;本轮 in-flight 完成后 finally 也会走新 computePhaseLock。 */
if (state.poll.tick) {
clearTimeout(state.poll.tick);
state.poll.tick = null;
}
if (state.connected && !state.probing && !state.tickInFlight) {
schedulePollLoop(state.pollGen, effective);
/* HTTP 轮询驱动:清掉旧节拍后必须立即按新间隔重排 motor 链,
* 否则轮询链断死、STATUS 面板停更。
* tickInFlight 时在途 pollLoop 的 finally 也会重排,schedulePollLoop 内部
* stopTimer 保证不会双链并发。 */
if (state.connected && !state.probing) {
schedulePollLoop(state.pollGen, 0);
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
}
const hz = 1000 / effective;
const note = (effective !== v) ? ` → 实际 ${effective}ms` : "";
toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz,${n}×${period}ms)${note}`, "ok");
const hz = 1000 / v;
toast(`刷新速度 ${v} ms(≈${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
}
refreshRateInput.addEventListener("change", applyRefreshRate);
refreshRateInput.addEventListener("input", () => {
/* 输入过程中只 clamp 显示,松手/change 再应用节拍,避免每个键都 toast */
const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
/* 输入过程中只 clamp 下限显示,松手/change 再应用节拍,避免每个键都 toast */
const raw = parseInt(refreshRateInput.value, 10);
if (Number.isFinite(raw) && raw < period) refreshRateInput.value = period;
if (Number.isFinite(raw) && raw < 50) refreshRateInput.value = 50;
});
/* ═══════════════════════ 示波器系统 ═══════════════════════ */
@@ -2648,11 +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] 慢请求统计已清空");
},
};
+6 -4
View File
@@ -4,7 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>电机控制台 — Motor CAN FD Console</title>
<link rel="stylesheet" href="css/motor.css?v=20260727b">
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/motor.css?v=20260730a">
</head>
<body>
@@ -36,9 +38,9 @@
</select>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="电机状态轮询间隔(ms)。GD32 0xC5 推送周期 250ms:设 ≤250 按 250ms 锁相;设更大则按 N×250ms 抽稀(例如 1000≈每 4 次推送拉一次)">
<div class="conn-group" title="电机状态刷新间隔(ms,50~5000)。同时驱动前端 HTTP 轮询与 GD32 0xC5 SPI 主动推送周期,端到端节拍一致">
<label for="refreshRate" class="refresh-hint">刷新速度</label>
<input type="number" id="refreshRate" class="refresh-input" value="250" min="250" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒)" />
<input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒)" />
<span class="refresh-hint">ms</span>
</div>
<div class="toolbar-right">
@@ -343,6 +345,6 @@
</div>
</div>
<script type="module" src="js/motor/main.js?v=20260727l"></script>
<script type="module" src="js/motor/main.js?v=20260730g"></script>
</body>
</html>