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 @@
-
+
- + ms
@@ -343,6 +345,6 @@
- + \ No newline at end of file