Files
ESP32_COMM/tools/js/motor/main.js
T
小龙虾 33b41fd50a docs(frontend): 添加前端架构约定与回归清单文档
添加了完整的前端架构约定文档 (frontend_architecture.md),
包含以下主要内容:

- 架构总览:Web资源本地托管方式和零构建方案
- 页面薄入口范式:HTML结构与ES Modules入口约定
- JS分层架构:common/ota/ble/can/motor等目录结构说明
- 缓存戳约定:版本管理强制要求
- 公共层API契约:baseUrl/toast/polling等接口规范
- motor页特殊性:独立主题和轮询链路处理
- 红线区域:不可动代码范围说明
- 回归清单:各页面功能验证checklist

同时移除了build相关的临时文件
2026-07-27 01:50:48 +08:00

2651 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* motor/main.js - 电机控制台页面逻辑(ES Module 入口)
* 由 motor.html 内联 <script>(原 L1014-L3660)机械外提,逻辑未改。
* 原脚本已是 "use strict",转 module 行为中性;唯一对外暴露 window.phaseDebug。
*/
/* ═══════════════════════════════════════════════════════════════
电机控制台 — 共用 OTA 主页设备 IP(localStorage dcu_esp_ip),本页无连接配置
数据源:
GET /motor/state 1Hz → 节点列表 + STATUS 解码
POST /can/send_batch 唯一发送入口(CMD / CONFIG / VERSION / OTA)
GET /can/feedback 1Hz → 状态栏 RX/TX/ERR/TEC/REC/BUS
GET /system/status 进入页自动探测 + 5s 健康检查 / 断线 2s 探测
CAN ID(29-bit 扩展,物理 ID,不含 0x80000000 标志):
STATUS 0x000N0100 · CMD 0x000N0200(DLC32) · CMD_CONFIG 0x000N0301(DLC24)
GET_VERSION 请求/回报共用 0x000N00FF(同 ID, DLC24)
CANFD_BOOT 0x000N0000 · NODE_ID SET/GET 0x000N0400/0401
═══════════════════════════════════════════════════════════════ */
"use strict";
/* IP 存取公共实现(localStorage dcu_esp_ip,与 OTA/CAN 页共用) */
import {
DEFAULT_ESP_IP, getDeviceIp, saveDeviceIp
} from "../common/base-url.js";
/* ─── 控制模式定义 ─── */
const MODES = {
0: { name: "Disable", zh: "关闭", targets: [] },
1: { name: "Angle", zh: "位置控制", targets: ["angle"] },
2: { name: "Velocity", zh: "速度控制", targets: ["velocity"] },
3: { name: "Velocity_Angle", zh: "速度+位置", targets: ["velocity", "angle"] },
4: { name: "MIT", zh: "MIT Mode", targets: ["current", "velocity", "angle"] },
5: { name: "Current", zh: "电流/力矩", targets: ["current"] },
6: { name: "OpenLoop", zh: "开环速度", targets: ["velocity"] },
7: { name: "OpenLoop2", zh: "开环速度(2)", targets: ["velocity"] },
};
const MODE_DESCS = {
0: "电机关闭输出,进入自由旋转状态",
1: "闭环位置控制,目标角度生效",
2: "闭环速度控制,目标速度生效",
3: "速度+位置环联合控制,目标速度与目标角度同时生效",
4: "MIT 力控模式,电流/速度/角度目标均参与计算",
5: "电流环直接控制(力矩模式),目标电流生效",
6: "开环速度控制,无编码器反馈",
7: "开环速度控制(备用通道)",
};
/* ─── 全局状态 ─── */
/* LS_ESP_IP / DEFAULT_ESP_IP 由 common/base-url.js 导入(与 OTA / CAN 页共用) */
/* 本页输入持久化 key:v2 扩展了 nodeIdInput + 按节点版本缓存 + OTA 固件元数据。
* 保存内容:modeSelect / inVoltage / inVelocity / inAngle / pidCurP / pidVelP /
* pidAngP / repeatPeriod / chSelect / selectedNode / nodeIdInput /
* versions(按 node 缓存 {soft,hard,boot})/ otaFwMeta(固件名+大小)。
* 不保存:实时状态(motor 数组、解码、波形缓冲、连接态)、固件 bytes(用 IndexedDB handle 恢复)。 */
const LS_MOTOR_UI = "dcu_motor_ui_v2";
const state = {
connected: false, // 连续成功探测后视为在线
connecting: false,
ip: DEFAULT_ESP_IP,
ch: 0,
chEnabled: [true, true, true], // 各通道启用状态(由 onFeedback 更新)
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
sendInFlight: false, // send_batch 互斥
pendingCmd: null, // latest-wins CMD 合并槽:发送在途时新 CMD 覆盖此帧,完成后立即发出
busCooldownUntil: 0, // 0xE2/0xE3 冷却截止时间戳
txCount: 0,
poll: { tick: null, probe: null, status: null, feedback: null },
pollGen: 0, // 递增使在途 tick 失效
tickInFlight: false,
feedbackInFlight: false, // /can/feedback 独立链路占用标记
statusInFlight: false, // /system/status 独立链路占用标记
failCount: 0, // 连续硬失败计数,达阈值才断线
probing: false, // 已在断线探测,避免重复 startProbe/toast
lastConnToastAt: 0, // toast 去抖
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)
};
/**
* 连续「硬失败」多少次才判离线。
* HTTP 500/503 busy、超时 一律视为瞬时,不计入、不改 LED。
*/
const FAIL_THRESHOLD = 5;
const CONN_TOAST_GAP_MS = 6000;
/** 离线不足此时长就恢复:不弹「连接已恢复」(过滤毛刺) */
const RECOVER_TOAST_MIN_OFFLINE_MS = 8000;
/* 前端固定节拍:与 GD32 0xC5 推送周期对齐(250ms)。
* 锁相成功时由 computePhaseLockDelayMs() 覆盖;此值仅作未同步/异常回退。 */
const TICK_INTERVAL_MS = 250;
const PROBE_INTERVAL_MS = 2000;
const MIN_CMD_PERIOD_MS = 80;
/* ─── DOM refs ─── */
const $ = (s) => document.querySelector(s);
const nodeListEl = $("#nodeList");
/* ─── 工具函数 ─── */
/** 读取主页写入的共享设备 IP(localStorage dcu_esp_ip) */
function getSharedDeviceIp() {
return getDeviceIp();
}
/* ─── 输入持久化:刷新/重开页面后恢复上次输入 ───
* 保存 strategy:用户改输入项时,把所有需保存项打包成 JSON 写 localStorage。
* 恢复 strategy:页面加载时读出,按字段类型回填到对应 DOM + state。
* 版本号按节点缓存(versions[node] = {soft,hard,boot}),切节点时回填。 */
const PERSIST_FIELDS = [
"modeSelect", "inVoltage", "inVelocity", "inAngle",
"pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect",
"nodeIdInput", "refreshRate",
];
function saveUiState() {
try {
const o = {};
for (const id of PERSIST_FIELDS) {
const el = document.getElementById(id);
if (el) o[id] = el.value;
}
if (state.selectedNode != null) o.selectedNode = state.selectedNode;
/* 版本缓存:state.versions 是 Map<node, {soft,hard,boot}>,序列化为普通对象 */
if (state.versions && state.versions.size > 0) {
const vo = {};
for (const [n, v] of state.versions) {
if (v && (v.soft || v.hard || v.boot)) vo[n] = { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null };
}
o.versions = vo;
}
localStorage.setItem(LS_MOTOR_UI, JSON.stringify(o));
} catch (_) {}
}
function loadUiState() {
try {
const raw = localStorage.getItem(LS_MOTOR_UI);
if (!raw) return;
const o = JSON.parse(raw);
if (!o || typeof o !== "object") return;
for (const id of PERSIST_FIELDS) {
const el = document.getElementById(id);
if (el && o[id] != null) el.value = o[id];
}
if (o.selectedNode != null) state.selectedNode = o.selectedNode;
/* 恢复版本缓存到 state.versions Map */
if (o.versions && typeof o.versions === "object") {
for (const [n, v] of Object.entries(o.versions)) {
const node = parseInt(n, 10);
if (Number.isFinite(node) && v) state.versions.set(node, { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null });
}
}
/* 恢复刷新速度到 state(loadUiState 在 initPage 阶段调用,
* 此时 polling 尚未启动,直接写 state 即可,无需重排定时器) */
const rr = parseInt(o.refreshRate, 10);
if (Number.isFinite(rr)) {
/* 与 #refreshRate min/max 一致:最快对齐 GD32 250ms,最慢 5s */
const clamped = clampNum(rr, 250, 5000);
state.refreshIntervalMs = clamped;
const el = document.getElementById("refreshRate");
if (el) el.value = clamped;
}
} catch (_) {}
}
/* 在 PERSIST_FIELDS 上挂 change/input 监听,自动保存。 */
function bindAutoSave() {
for (const id of PERSIST_FIELDS) {
const el = document.getElementById(id);
if (el) {
el.addEventListener("change", saveUiState);
if (el.tagName === "INPUT") {
el.addEventListener("input", debounceSaveUi);
}
}
}
}
/* input 事件高频触发,debounce 300ms 避免每次按键都写 localStorage。 */
let _saveTimer = null;
function debounceSaveUi() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(saveUiState, 300);
}
/** status 回报的设备 IP 写回共享存储(与 CAN/OTA 页一致) */
function setSharedDeviceIp(ip) {
if (!ip || !String(ip).trim()) return;
const v = String(ip).trim();
state.ip = v;
saveDeviceIp(v);
const label = $("#ipLabel");
if (label) label.textContent = "IP " + v;
}
function getBaseUrl() {
return "http://" + state.ip;
}
function pad2(n) { return n.toString(16).padStart(2, "0").toUpperCase(); }
function hexId(id) { return "0x" + id.toString(16).toUpperCase().padStart(8, "0"); }
function nodeHex(n) { return n.toString(16).toUpperCase(); }
function nowMs() { return Date.now(); }
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
function clampNum(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
/* CAN ID 构造(物理 ID,node 位于 bits[19:16])— 对齐 DBC Motor_CAN_FD v0.70 */
function canId(node, suffix) { return ((node & 0xF) << 16) | (suffix & 0xFFFF); }
const ID_STATUS = (n) => canId(n, 0x0100); /* 0x000N0100 */
const ID_CMD = (n) => canId(n, 0x0200); /* 0x000N0200 */
const ID_CMD_CONFIG = (n) => canId(n, 0x0301); /* 0x000N0301 */
/* 版本:主机与电机共用 0x000N00FF(同 ID 应答,DBC CANFD_GET_VERSION,发送方 Motor)。
* 电机 APP 仅在收到 base_id=0x00FF 时回报(CANFD_VERSION_ID),DBC 无 0x05FF 消息。
* GD32 为解析核心:捕获电机应答后缓存,经 0xC6 同步给 ESP32,前端读 /motor/version。 */
const ID_GET_VERSION_REQ = (n) => canId(n, 0x00FF); /* 请求(与应答同 ID) */
const ID_SET_NODE_ID = (n) => canId(n, 0x0400); /* 0x000N0400 DLC 2 */
const ID_GET_NODE_ID = (n) => canId(n, 0x0401); /* 0x000N0401 DLC 2 */
/* hex 字符串 → Uint8Array */
function hexToBytes(hex) {
const clean = (hex || "").replace(/[^0-9a-fA-F]/g, "");
const out = new Uint8Array(Math.floor(clean.length / 2));
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.substr(i * 2, 2), 16);
}
return out;
}
/* Uint8Array → 空格分隔 hex(用于状态栏预览) */
function bytesToHexSpaced(bytes, maxBytes) {
const n = maxBytes ? Math.min(bytes.length, maxBytes) : bytes.length;
let s = "";
for (let i = 0; i < n; i++) s += pad2(bytes[i]) + " ";
return s.trim();
}
/* ─── Toast ─── */
function toast(msg, type = "ok") {
const el = document.createElement("div");
el.className = `toast ${type}`;
const ledCls = type === "ok" ? "led-on" : type === "warn" ? "led-warn" : "led-err";
el.innerHTML = `<span class="led ${ledCls}" style="width:7px;height:7px"></span><span></span>`;
el.lastElementChild.textContent = msg;
$("#toastContainer").appendChild(el);
setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 220); }, 3000);
}
/* ─── Modal ─── */
let modalResolve = null;
function showModal(title, desc) {
return new Promise((resolve) => {
modalResolve = resolve;
$("#modalTitle").textContent = title;
$("#modalDesc").textContent = desc;
$("#modalMask").classList.add("show");
});
}
$("#modalCancel").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(false); };
$("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(true); };
/* ═══════════════════════ STATUS 帧解码(对齐 DBC Motor_CAN_FD v0.70) ═══════════════════════
* BO_ STATUS DLC=32,信号起止与字节(Intel @1,除非注明 Motorola @0):
* MCU_temp 0|16@1- ×0.01 → B0-1
* Motor_temp 16|16@1- ×0.01 → B2-3
* U/V/W_cur 32/48/64|16@1- mA → B4-5 / B6-7 / B8-9
* motStatus 80|1@1+ 0正常/1报错 → B10.0
* magStatus 81|1@1+ → B10.1
* thermal 82|1@1+ → B10.2
* EEPROMStatus 85|3@0+ Motorola → B10 bits 85,84,83 → (B10>>3)&7
* Power_Voltage 88|16@1+ ×0.001 V → B11-12 ★非对齐
* RT_Angle 104|32@1- float ° → B13-16 ★非对齐
* RT_Velocity 136|32@1- float °/s → B17-20
* RT_Current 168|32@1- float mA → B21-24
* test_float 200|32@1- float → B25-28
* test_U16 232|16@1+ → B29-30
* CPU_Usage 248|8@1+ % → B31
*
* 旧实现误把 Power 当 B12、float 当 B16/20/24/28,导致角度/速度/电流整体错位乱码。
*/
function decodeStatus(bytes) {
if (!bytes || bytes.length < 32) return null;
/* 拷到独立 ArrayBuffer,避免 SharedArrayBuffer/子视图偏移干扰 DataView */
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const copy = new Uint8Array(32);
copy.set(u8.subarray(0, 32));
const dv = new DataView(copy.buffer);
const b10 = copy[10];
const f32 = (off) => {
const v = dv.getFloat32(off, true);
return Number.isFinite(v) ? v : 0;
};
return {
mcuTemp: dv.getInt16(0, true) * 0.01,
motTemp: dv.getInt16(2, true) * 0.01,
uCurrent: dv.getInt16(4, true),
vCurrent: dv.getInt16(6, true),
wCurrent: dv.getInt16(8, true),
motStatus: (b10 & 0x01) ? 1 : 0, /* 0=正常 1=报错(DBC VAL_) */
magStatus: (b10 & 0x02) ? 1 : 0,
thermalStatus: (b10 & 0x04) ? 1 : 0,
/* Motorola 3bit start=85 → 位序 85,84,83 = B10[5:3] */
eepromStatus: (b10 >> 3) & 0x07,
powerVoltage: dv.getUint16(11, true) * 0.001, /* bit88 → B11 */
rtAngle: f32(13), /* bit104 → B13 */
rtVelocity: f32(17), /* bit136 → B17 */
rtCurrent: f32(21), /* bit168 → B21 */
testFloat: f32(25), /* bit200 → B25 */
testU16: dv.getUint16(29, true), /* bit232 → B29 */
cpuUsage: copy[31], /* bit248 → B31 */
};
}
/**
* STATUS 故障位简写(节点卡片右上角,空间紧凑)。
* 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit)。
* 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3"。
*/
function faultShortParts(d) {
if (!d) return [];
const parts = [];
if (d.motStatus) parts.push("电机");
if (d.magStatus) parts.push("磁编");
if (d.thermalStatus) parts.push("过温");
if (d.eepromStatus) parts.push("EE" + d.eepromStatus);
return parts;
}
function faultShortLabel(d) {
const parts = faultShortParts(d);
return parts.length ? parts.join("·") : "";
}
function faultCountOf(d) {
return faultShortParts(d).length;
}
/* ═══════════════════════ CMD 帧编码(对齐 DBC Motor_CAN_FD v0.70,DLC 32) ═══════════════════════
* Control_Mode 0|8@1+
* Limit_Voltage_cmd 8|16@1+ ×0.001 → B1-2
* Target_Current_cmd 24|32@1- float → B3-6
* Target_Veloctiy_cmd 56|32@1- float → B7-10
* Target_Angle_cmd 88|32@1- float → B11-14
* PID_current_loop_P 120|16@1- → B15-16
* PID_vel_loop_P 136|16@1- → B17-18
* PID_angle_loop_P 152|16@1- → B19-20
*/
function buildCmdBytes() {
const buf = new ArrayBuffer(32);
const dv = new DataView(buf);
const mode = parseInt($("#modeSelect").value, 10) || 0;
dv.setUint8(0, mode);
dv.setUint16(1, Math.round((parseFloat($("#inVoltage").value) || 0) * 1000) & 0xFFFF, true);
dv.setFloat32(3, parseFloat($("#inCurrent").value) || 0, true);
dv.setFloat32(7, parseFloat($("#inVelocity").value) || 0, true);
dv.setFloat32(11, parseFloat($("#inAngle").value) || 0, true);
dv.setInt16(15, parseInt($("#pidCurP").value, 10) || 0, true);
dv.setInt16(17, parseInt($("#pidVelP").value, 10) || 0, true);
dv.setInt16(19, parseInt($("#pidAngP").value, 10) || 0, true);
return new Uint8Array(buf);
}
function bytesToHexStr(bytes) {
let s = "";
for (let i = 0; i < bytes.length; i++) s += pad2(bytes[i]);
return s;
}
/* ═══════════════════════ 发送核心 /can/send_batch(互斥 + 流控) ═══════════════════════ */
const GD32_ERR_DESC = {
0xE0: "payload 过短", 0xE1: "通道号非法",
0xE2: "BUS_OFF(总线离线)", 0xE3: "TEC 过高",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xEE: "SPI 帧解析失败",
0xFF: "无有效 SPI 响应(陈旧/空帧,非总线故障)",
};
/* -3 仅表示“GD32 未回 ACK”;是否真为总线故障看 gd32_err:0xE2/0xE3 才是总线,0xFF 是陈旧 SPI 帧 */
const CAN_SEND_ERR_DESC = { "-1": "参数错误", "-2": "SPI 通信失败", "-3": "GD32 未确认发送" };
function describeResult(result) {
if (result && result.status === "ok") {
return result.recv ? ("recv 0x" + result.recv + " (" + result.recv_len + "B)") : "ok";
}
const code = result ? result.code : -99;
let desc = CAN_SEND_ERR_DESC[String(code)] || ("code=" + code);
if (result && result.gd32_err != null && result.gd32_err !== 0) {
desc += " · " + (GD32_ERR_DESC[result.gd32_err] || ("gd32_err=0x" + result.gd32_err.toString(16).toUpperCase()));
}
return desc;
}
/**
* 发送一批帧(唯一入口)。互斥:同一时刻只允许一个请求在途。
* @returns {Promise<{ok:boolean, results?:object[], networkError?:boolean, busFault?:number, desc?:string}>}
*/
async function sendBatch(frames, 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 n = frames.length;
/* 单帧 3s:ESP32 handler 内部单帧超时 1200ms + HTTP 排队余量;
* 旧值 8s 会在 SPI 卡顿时长时间霸占互斥锁,阻塞后续周期 CMD */
const timeoutMs = (n <= 1) ? 3000 : Math.max(15000, n * 500);
try {
const resp = await fetch(getBaseUrl() + "/can/send_batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ch: state.ch, 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");
let results = json.results.slice();
const need = n - results.length;
if (need > 0) {
const pad = { status: "error", code: json.abort_code != null ? json.abort_code : -2, gd32_err: json.abort_gd32_err || 0 };
for (let p = 0; p < need; p++) results.push(pad);
}
/* 流控:检查首帧 gd32_err */
const first = results[0] || {};
const gerr = first.gd32_err;
if (gerr === 0xE4 && !opts.retried) {
/* 邮箱满:随机退避 50~150ms 重试一次 */
state.sendInFlight = false;
const backoff = 50 + Math.floor(Math.random() * 101);
await sleep(backoff);
return sendBatch(frames, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
stopRepeat(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: describeResult(first) };
} catch (e) {
/* 发送失败:瞬时错误静默;真断线累计,达阈值才离线 */
noteHardFailure(e, "send");
return { ok: false, networkError: true, desc: e.message || "网络错误" };
} finally {
state.sendInFlight = false;
/* latest-wins 排空:发送期间新到的 CMD 已合并到 pendingCmd(仅保留最新帧),
* 此处立即发出,保证周期指令不因互斥而丢失。E4 重试路径(已提前清锁)不触发。 */
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 });
}
}
}
/* 单帧便捷封装 */
async function sendOneFrame(id, dlc, dataHex, opts) {
return sendBatch([{ id: id, dlc: dlc, data: dataHex }], opts);
}
/** 手动一次性发送(CONFIG / VERSION / 节点 ID 等):先等通道空闲再发。
* 周期 CMD 及其排空链通常 <100ms 完成,短暂等待即可避免 busy 拒绝;
* 超过 1.5s 仍忙则照发(sendBatch 返回 busy 并 toast 提示)。 */
async function sendOneFrameWhenFree(id, dlc, dataHex, opts) {
const deadline = nowMs() + 1500;
while (state.sendInFlight && nowMs() < deadline) await sleep(25);
const res = await sendOneFrame(id, dlc, dataHex, opts);
if (res.busy) toast("发送通道仍忙(上一包未完成),请稍后重试", "warn");
return res;
}
/* ═══════════════════════ 连接稳健性 ═══════════════════════
* 设备端 /system/status、/motor/state、/can/feedback 共用 httpJsonWorkMutex。
* 任一并发 → 500 busy;旧逻辑还把瞬时失败变成 LED 黄灯/断线 toast → 「偶尔闪一下」。
*
* 现策略:
* 1) 全链路单飞:递归 setTimeout,同一时刻只有 1 个 HTTP
* 2) 顺序:motor → feedback →(每 5 轮)status
* 3) 500/503/超时 = 瞬时,静默跳过,不改 LED、不计数
* 4) 仅「真断线」类错误累计,达阈值才 banner/toast
* 5) 短离线(<8s)恢复不弹「连接已恢复」
*/
function connToast(msg, type) {
const now = nowMs();
if (now - state.lastConnToastAt < CONN_TOAST_GAP_MS) return;
state.lastConnToastAt = now;
toast(msg, type);
}
/** 500/503 busy、超时:设备忙,不是链路掉线 */
function isTransientError(e) {
if (!e) return false;
if (e.transient === true) return true;
const st = Number(e.status);
if (st === 500 || st === 503 || st === 429) return true;
const name = e.name || "";
if (name === "TimeoutError" || name === "AbortError") return true;
const msg = String(e.message || e);
if (/timed?\s*out|aborted|AbortError|signal is aborted/i.test(msg)) return true;
if (/^HTTP\s+(500|503|429)\b/.test(msg)) return true;
return false;
}
/**
* 统一 GET JSON。失败抛 Error,附带 status / transient。
* @returns {Promise<object>}
*/
async function fetchDeviceJson(path, timeoutMs) {
let resp;
const t0 = performance.now();
try {
resp = await fetch(getBaseUrl() + path, {
signal: AbortSignal.timeout(timeoutMs || 3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransientError(err);
recordSlowReq(path, performance.now() - t0, err.message);
throw err;
}
recordSlowReq(path, performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return resp.json();
}
/* 慢请求统计:>200ms 记入 phaseDebug.slowStats[path] = {count, maxMs, lastMs, lastAt}。
* 用于诊断"卡在哪个 endpoint"——不是看单次 lastSlowReq,而是看各 path 的卡顿分布。
* 控制台 phaseDebug.slowStats 直接看哪个 path 累计最多。 */
function recordSlowReq(path, durMs, errMsg) {
if (durMs <= 200) return;
const s = state.phaseDebug.slowStats;
const key = path.split("?")[0]; /* /motor/state?bin=1 → /motor/state */
if (!s[key]) s[key] = { count: 0, maxMs: 0, lastMs: 0, lastAt: 0, lastErr: null };
s[key].count++;
s[key].maxMs = Math.max(s[key].maxMs, Math.round(durMs));
s[key].lastMs = Math.round(durMs);
s[key].lastAt = Date.now();
if (errMsg) s[key].lastErr = errMsg;
state.phaseDebug.lastSlowReq = {
path, dur: Math.round(durMs), at: Date.now(), err: errMsg
};
}
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;
}
enterDisconnected(msg, source);
}
function enterDisconnected(msg, source) {
if (state.probing) return;
const wasConnected = state.connected;
state.connected = false;
state.connecting = false;
state.probing = true;
state.failCount = 0;
state.offlineSince = nowMs();
stopRepeat(true);
stopDataPolling();
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent =
"连接中断" + (source ? " (" + source + ")" : "") +
" — 使用主页共享 IP (" + state.ip + ") 自动重连中…";
if (wasConnected) {
connToast("网络异常:" + (msg || "请求失败") + " — 已停止周期发送,自动重连中", "err");
}
startProbe();
}
function startProbe() {
stopTimer("probe");
state.probing = true;
if (!state.offlineSince) state.offlineSince = nowMs();
scheduleProbe(0);
}
function stopProbe() {
stopTimer("probe");
state.probing = false;
}
function scheduleProbe(delayMs) {
stopTimer("probe");
state.poll.probe = setTimeout(function () {
state.poll.probe = null;
probeOnce();
}, delayMs == null ? PROBE_INTERVAL_MS : delayMs);
}
async function probeOnce() {
if (!state.probing) return;
state.ip = getSharedDeviceIp();
const label = $("#ipLabel");
if (label) label.textContent = "IP " + state.ip;
try {
const data = await fetchDeviceJson("/system/status", 2500);
const offlineMs = state.offlineSince ? (nowMs() - state.offlineSince) : 0;
stopProbe();
state.connected = true;
state.connecting = false;
state.failCount = 0;
state.offlineSince = 0;
$("#netBanner").classList.remove("show");
applySystemStatus(data);
updateConnUi();
/* 短离线恢复不弹 toast,避免毛刺「闪一下」 */
if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) {
connToast("连接已恢复 — " + state.ip, "ok");
}
startPolling();
} catch (e) {
if (state.probing) scheduleProbe(PROBE_INTERVAL_MS);
}
}
/**
* 使用主页共享 IP 自动连接(本页无连接按钮)。
* 失败不阻断页面,进入探测直到 /system/status 可用。
*/
async function connectShared() {
state.ip = getSharedDeviceIp();
state.connecting = true;
state.probing = false;
state.failCount = 0;
state.offlineSince = nowMs();
state.ch = parseInt($("#chSelect").value, 10) || 0;
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();
}
}
function applySystemStatus(data) {
const wifi = (data && data.wifi) || {};
if (wifi.ip) setSharedDeviceIp(wifi.ip);
let parts = [];
if (data && data.gd32_features) {
const f = data.gd32_features;
if (f.known) parts.push("feat 0x" + (f.flags >>> 0).toString(16).toUpperCase());
}
if (data && data.app_version) parts.push("APP " + data.app_version);
if (data && data.boot_version) parts.push("BOOT " + data.boot_version);
if (wifi.mode) parts.unshift(wifi.mode + (wifi.connected ? " 在线" : " 离线"));
const el = $("#devStatus");
if (el) el.textContent = parts.length ? parts.join(" · ") : "在线";
}
function updateConnUi() {
const led = $("#connLed");
const ipLabel = $("#ipLabel");
if (ipLabel) ipLabel.textContent = "IP " + state.ip;
/* 连接灯只反映稳定状态:在线绿 / 探测黄 / 离线红,中间失败不闪黄 */
if (state.connecting || state.probing) {
if (led) led.className = "led led-warn led-pulse";
if (!state.connected) {
const ds = $("#devStatus");
if (ds) ds.textContent = "探测中…";
}
} else if (state.connected) {
if (led) led.className = "led led-on";
} else {
if (led) led.className = "led led-err";
const ds = $("#devStatus");
if (ds) ds.textContent = "未连接";
}
const sendHint = $("#sendHint");
if (sendHint) sendHint.textContent = "POST /can/send_batch \u00b7 CH" + state.ch;
const sbCh = $("#sbCh");
if (sbCh) sbCh.textContent = "CH" + state.ch;
}
/* ═══════════════════════ 轮询调度(单飞递归,永不并发) ═══════════════════════ */
function stopTimer(key) {
if (state.poll[key]) {
clearTimeout(state.poll[key]);
clearInterval(state.poll[key]);
state.poll[key] = null;
}
}
/**
* 双链路轮询:motor 和 feedback 各自独立定时器,互不阻塞。
*
* 历史问题:原来 motor → feedback 串行,任一挂起(ESP32 端 mutex 阻塞 /
* WiFi 抖动)会拖累另一个,表现为"age 不动 + 数据卡顿"。
* 现在:motor 走锁相定时器,feedback 走固定 250ms 定时器,
* - motor 挂起 3s 超时期间,feedback 仍正常刷
* - feedback 挂起不影响 motor 锁相
* 两者共用 ESP32 端 httpJsonWorkMutex,ESP32 串行处理即可。
*/
function stopDataPolling() {
state.pollGen += 1; /* 使在途 pollLoop 退出后不再续约 */
stopTimer("tick");
stopTimer("feedback");
stopTimer("status");
state.tickInFlight = false;
state.feedbackInFlight = false;
}
function startPolling() {
stopDataPolling();
state.failCount = 0;
state.tickN = 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 显著减少。 */
scheduleStatusPoll(gen, 1000);
}
function scheduleStatusPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("status");
state.poll.status = setTimeout(function () {
state.poll.status = null;
if (gen !== state.pollGen || !state.connected || state.probing) return;
/* lite/full 交替:4 次 lite 后 1 次 full,循环。
* lite ~150B 不调重活;full ~3KB 含 OTA/heap/SPI,5s 一次。 */
const wantFull = (state.statusTickN % 5) === 0;
state.statusTickN = (state.statusTickN || 0) + 1;
fetchSystemStatus(gen, wantFull).finally(() => {
if (gen === state.pollGen && state.connected && !state.probing) {
scheduleStatusPoll(gen, 1000);
}
});
}, delayMs == null ? 1000 : delayMs);
}
/* feedback 独立定时器:固定 1000ms(与 GD32 0xC1 CAN 反馈 1Hz 推送对齐)。
* feedback 是 CAN 错误计数/总线状态快照,GD32 1Hz 推送,前端 1Hz 拉即可,
* 加快只会拿到陈旧数据还跟 motor 抢 ESP32 端 mutex。
* 与 motor 解耦后,motor 挂起不拖 feedback,feedback 挂起不拖 motor。 */
function scheduleFeedbackPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("feedback");
state.poll.feedback = setTimeout(function () {
state.poll.feedback = null;
if (gen !== state.pollGen || !state.connected || state.probing) return;
if (state.feedbackInFlight) {
scheduleFeedbackPoll(gen, 1000);
return;
}
state.feedbackInFlight = true;
fetchFeedback(gen).finally(() => {
state.feedbackInFlight = false;
if (gen === state.pollGen && state.connected && !state.probing) {
scheduleFeedbackPoll(gen, 1000);
}
});
}, delayMs == null ? 1000 : 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();
}
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 拖)。
*/
async function pollLoop(gen) {
if (gen !== state.pollGen || !state.connected || state.probing) return;
if (state.tickInFlight) {
schedulePollLoop(gen);
return;
}
state.tickInFlight = true;
try {
await fetchMotorState(gen);
if (gen !== state.pollGen || !state.connected || state.probing) return;
state.tickN += 1;
} finally {
state.tickInFlight = false;
if (gen === state.pollGen && state.connected && !state.probing) {
/* 不传 delayMs → schedulePollLoop 用锁相计算;
* 失败轮(noteHardFailure 已处理)或尚未同步时退回固定节拍。 */
schedulePollLoop(gen);
}
}
}
async function fetchSystemStatus(gen, wantFull) {
if (gen !== state.pollGen || !state.connected || state.probing) return;
try {
/* wantFull=false(默认)走轻量路径 ?lite=1:~150B,
* 不调 ESP32 端 heap_caps_get_info / 任务栈遍历 / OTA snapshot,
* 避免 httpd 单线程被重 handler 阻塞几十~上百 ms。
* wantFull=true 走完整路径,5s 一次,含 OTA/heap/SPI。 */
const path = wantFull ? "/system/status" : "/system/status?lite=1";
const data = await fetchDeviceJson(path, wantFull ? 3500 : 1500);
if (gen !== state.pollGen) return;
applySystemStatus(data);
noteHardSuccess();
} catch (e) {
if (gen !== state.pollGen) return;
noteHardFailure(e, "status");
}
}
async function fetchMotorState(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
try {
/* 二进制模式:体积 ~408B vs JSON ~1200B,省 66%。
* ESP32 端 memcpy 拼包,无 snprintf,CPU 开销降一个数量级。 */
const data = await fetchMotorStateBinary();
if (gen != null && gen !== state.pollGen) return false;
onMotorState(data);
noteHardSuccess();
return true;
} catch (e) {
if (gen != null && gen !== state.pollGen) return false;
noteHardFailure(e, "motor");
return false;
}
}
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]},
* 让 onMotorState 不用改。data 字段用 32 字节 hex 字符串保持兼容(前端
* hexToBytes 解析 STATUS 帧)。 */
async function fetchMotorStateBinary() {
const t0 = performance.now();
let resp;
try {
resp = await fetch(getBaseUrl() + "/motor/state?bin=1", {
signal: AbortSignal.timeout(3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransientError(err);
recordSlowReq("/motor/state?bin=1", performance.now() - t0, err.message);
throw err;
}
recordSlowReq("/motor/state?bin=1", performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
const ab = await resp.arrayBuffer();
return parseMotorStateBinary(ab);
}
/* 解析 ESP32 二进制 /motor/state?bin=1 响应。
* Header 12B: magic u16 LE=0x4D53, ver u8, flags u8, age_ms u32 LE,
* active u8, count u8, rsvd u16
* Entry 44B: node u8, ch u8, online u8, dlc u8,
* rx_count u32 LE, seq u32 LE, data[32]
* 无效响应(magic 不匹配/长度不足)回退到 have_data:false。 */
function parseMotorStateBinary(ab) {
const out = { have_data: false, active_count: 0, age_ms: null, boot_active: false, motors: [] };
if (!ab || ab.byteLength < 12) return out;
const dv = new DataView(ab);
const magic = dv.getUint16(0, true);
if (magic !== 0x4D53) return out; /* 'MS' LE */
const ver = dv.getUint8(2);
if (ver !== 1) return out;
const flags = dv.getUint8(3); /* flags bit0 = boot_active */
const age = dv.getUint32(4, true);
const active = dv.getUint8(8);
const count = dv.getUint8(9);
/* rsvd u16 @10..11 */
/* age == 0xFFFFFFFF 表示 ESP32 端无数据 */
const have = (age !== 0xFFFFFFFF);
out.have_data = have;
out.active_count = active;
out.age_ms = have ? age : null;
out.boot_active = (flags & 0x01) !== 0; /* 总线有电机在 BOOT(0x103 心跳) */
const u8 = new Uint8Array(ab);
let off = 12;
for (let i = 0; i < count && off + 44 <= ab.byteLength; i++) {
const node = u8[off];
const ch = u8[off + 1];
const online = u8[off + 2] !== 0;
const dlc = u8[off + 3];
const rx_count = dv.getUint32(off + 4, true);
const seq = dv.getUint32(off + 8, true);
/* data[32] @ off+12..off+43 转 hex 字符串(兼容 onMotorState 的 hexToBytes) */
let hex = "";
for (let b = 0; b < 32; b++) {
hex += u8[off + 12 + b].toString(16).padStart(2, "0").toUpperCase();
}
out.motors.push({
node, ch, online, dlc, rx_count, seq, data: hex,
});
off += 44;
}
return out;
}
async function fetchFeedback(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
try {
/* ?lite=1:跳过 entries 数组(~3KB → ~200B)。motor.html 只用 ch/stats 字段,
* 不需要 CAN 帧快照(那是 index.html/can.html 监控页用的)。
* ESP32 端 lite 路径不走 chunked + httpJsonWorkMutex,handler <1ms。 */
const data = await fetchDeviceJson("/can/feedback?lite=1", 1500);
if (gen != null && gen !== state.pollGen) return false;
onFeedback(data);
noteHardSuccess();
return true;
} catch (e) {
if (gen != null && gen !== state.pollGen) return false;
noteHardFailure(e, "feedback");
return false;
}
}
/* ═══════════════════════ /motor/state 处理 ═══════════════════════ */
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 帧 */
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);
}
state.decoded = decoded;
/* 固定节点 1–9:始终可选(含离线),便于电机已进 BOOT / 无 STATUS 时仍能刷写。
* 自动选择:当前选中已是 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);
state.selectedNode = online ? online.node : 1;
autoSelected = true;
}
renderNodeList();
renderStatusPanel();
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 盖掉。 */
const bootEl = $("#bootBadge");
if (bootEl) {
const on = !!(data && data.boot_active);
bootEl.classList.toggle("show", on);
bootEl.style.display = on ? "flex" : "none";
bootEl.setAttribute("aria-hidden", on ? "false" : "true");
const sub = $("#bootBadgeSub");
if (sub) sub.textContent = on ? "0x103 心跳" : "0x103";
}
}
/* 滚动窗口中位数:复制后排序取中,避免污染原数组顺序。 */
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。 */
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);
}
return FIXED_MOTOR_NODES.map((node) => {
const m = byNode.get(node);
if (m) return m;
return { node, ch: state.ch || 0, online: false, dlc: 0, rx_count: 0, seq: 0, data: "" };
});
}
function renderNodeList() {
const motors = getDisplayMotors();
/* 固定 9 槽:仅首次或 DOM 被清空时全量重建 */
let needRebuild = _nodeCards.size !== FIXED_MOTOR_NODES.length;
if (!needRebuild) {
for (const n of FIXED_MOTOR_NODES) {
if (!_nodeCards.has(n)) { needRebuild = true; break; }
}
}
if (needRebuild) {
_nodeCards.clear();
nodeListEl.innerHTML = motors.map(buildNodeCardHtml).join("");
for (const m of motors) {
const card = nodeListEl.querySelector(`.node-card[data-node="${m.node}"]`);
if (!card) continue;
_nodeCards.set(m.node, {
card,
led: card.querySelector(".led"),
modeEl: card.querySelector(".nc-mode"),
stTxt: card.querySelector(".nc-status-text"),
angleVal: card.querySelector(".nc-reading:nth-child(1) .val"),
velVal: card.querySelector(".nc-reading:nth-child(2) .val"),
curVal: card.querySelector(".nc-reading:nth-child(3) .val"),
faultsEl: card.querySelector(".nc-faults"),
});
}
return;
}
/* 槽位稳定 → 增量更新文本/class */
for (const m of motors) {
const ref = _nodeCards.get(m.node);
if (!ref) continue;
const d = state.decoded.get(m.node);
const faultCount = faultCountOf(d);
const faultLabel = faultShortLabel(d);
const isSel = m.node === state.selectedNode;
const cls = [
"node-card",
isSel ? "active" : "",
faultCount > 0 ? "fault" : "",
!m.online ? "offline" : "",
].join(" ").trim();
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.online) ref.card.title = "离线也可选中刷写";
else ref.card.title = "在线";
const ledCls = 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 : "待机");
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");
if (ref.modeEl.className !== modeCls) ref.modeEl.className = modeCls;
const stTxt = !m.online ? "--" : (faultLabel || "OK");
if (ref.stTxt.textContent !== stTxt) ref.stTxt.textContent = stTxt;
if (ref.stTxt) ref.stTxt.title = faultLabel ? ("故障: " + faultLabel) : (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) : "--";
if (ref.angleVal.textContent !== angle) ref.angleVal.textContent = angle;
if (ref.velVal.textContent !== vel) ref.velVal.textContent = vel;
if (ref.curVal.textContent !== cur) ref.curVal.textContent = cur;
if (ref.faultsEl) {
const dots = ref.faultsEl.querySelectorAll(".fault-dot").length;
if (dots !== faultCount) {
ref.faultsEl.innerHTML = faultCount > 0 ? "<span class='fault-dot'></span>".repeat(faultCount) : "";
}
}
}
}
/* 构建单节点卡片的 HTML(仅集合变化时调用) */
function buildNodeCardHtml(m) {
const d = state.decoded.get(m.node);
const faultCount = faultCountOf(d);
const faultLabel = faultShortLabel(d);
const isSel = m.node === state.selectedNode;
const cls = [
"node-card",
isSel ? "active" : "",
faultCount > 0 ? "fault" : "",
!m.online ? "offline" : "",
].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 stTxt = !m.online ? "--" : (faultLabel || "OK");
const cardTitle = faultLabel ? ("故障: " + faultLabel) : (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="nc-id">N${m.node}</span>
<span class="nc-mode ${modeCls}">${modeLabel}</span>
<span class="nc-status-text" title="${faultLabel ? ("故障: " + faultLabel) : ""}">${stTxt}</span>
</div>
<div class="nc-readings">
<div class="nc-reading"><span class="lbl">角度</span><span class="val">${angle}</span></div>
<div class="nc-reading"><span class="lbl">速度</span><span class="val">${vel}</span></div>
<div class="nc-reading"><span class="lbl">电流</span><span class="val">${cur}</span></div>
</div>
</div>`;
}
nodeListEl.addEventListener("click", (e) => {
const card = e.target.closest(".node-card");
if (!card) return;
state.selectedNode = parseInt(card.dataset.node, 10);
saveUiState(); /* 选中节点变化时持久化,刷新后仍选中此节点 */
renderNodeList();
updateNodeLabels();
renderStatusPanel();
restoreVersionUi(state.selectedNode); /* 回填该节点缓存的版本号 */
});
nodeListEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { const card = e.target.closest(".node-card"); if (card) card.click(); }
});
/* ─── 节点相关标签更新 ─── */
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";
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`;
document.querySelectorAll(".cfgNodeNibble").forEach((el) => (el.textContent = hex));
}
/* ═══════════════════════ STATUS 面板渲染 ═══════════════════════ */
function flashEl(el) {
if (!el) return;
el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 350);
}
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;
const offline = !motor || !motor.online || !d;
$("#offlineOverlay").classList.toggle("show", offline);
$("#statusLive").classList.toggle("hide", offline);
if (offline) {
if (!state.connected) {
$("#offlineTitle").textContent = "设备未连接";
$("#offlineSub").textContent = "本页共用 OTA 主页 IP,请到主页配置并测试连接";
} else if (!motor || !motor.online) {
$("#offlineTitle").textContent = n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)";
$("#offlineSub").textContent = "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写";
} else {
$("#offlineTitle").textContent = `节点 ${n} 暂无解码数据`;
$("#offlineSub").textContent = "等待 STATUS 帧;OTA 刷写不受影响";
}
return;
}
$("#rtAngle").textContent = d.rtAngle.toFixed(2);
$("#rtVelocity").textContent = d.rtVelocity.toFixed(1);
$("#rtCurrent").textContent = d.rtCurrent.toFixed(0);
$("#mcuTemp").textContent = d.mcuTemp.toFixed(2) + " °C";
$("#motTemp").textContent = d.motTemp.toFixed(2) + " °C";
const mcuPct = clampNum(d.mcuTemp, 0, 100);
const motPct = clampNum(d.motTemp, 0, 100);
$("#mcuTempBar").style.width = mcuPct + "%";
$("#mcuTempBar").className = "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : "");
$("#motTempBar").style.width = motPct + "%";
$("#motTempBar").className = "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : "");
$("#valU").textContent = `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`;
$("#valV").textContent = `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`;
$("#valW").textContent = `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`;
$("#pwrVolt").textContent = d.powerVoltage.toFixed(3) + " V";
$("#testFloat").textContent = d.testFloat.toFixed(3);
$("#testU16").textContent = String(d.testU16);
$("#cpuPct").textContent = Math.round(d.cpuUsage) + "%";
$("#cpuSub").textContent = `seq ${motor.seq} · rx ${motor.rx_count}`;
const circumference = 131.9;
$("#cpuRingFg").style.strokeDashoffset = (circumference * (1 - clampNum(d.cpuUsage, 0, 100) / 100)).toFixed(1);
/* 状态标志位 */
const setFlag = (itemId, ledId, stateId, active, isFault, okText, faultText) => {
$("#" + itemId).className = "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : "");
$("#" + ledId).className = "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : "");
$("#" + stateId).textContent = active ? (isFault ? faultText : okText) : "—";
};
/* motStatus:DBC 0=正常 / 1=报错(不是使能位) */
setFlag("flagItemMot", "flagMot", "flagMotState", true, d.motStatus === 1, "NORMAL", "FAULT");
setFlag("flagItemMag", "flagMag", "flagMagState", true, d.magStatus === 1, "NORMAL", "FAULT");
setFlag("flagItemThermal", "flagThermal", "flagThermalState", true, d.thermalStatus === 1, "NORMAL", "过温!");
setFlag("flagItemEeprom", "flagEeprom", "flagEepromState", true, d.eepromStatus !== 0, "NORMAL", "ERR=" + d.eepromStatus);
flashEl($("#rtAngle")); flashEl($("#rtVelocity")); flashEl($("#rtCurrent"));
}
/* ═══════════════════════ /can/feedback 处理(状态栏) ═══════════════════════ */
function busStateText(bs) {
if (bs == null) return "--";
if (bs === 0) return "ACTIVE";
const parts = [];
if (bs & 1) parts.push("INIT");
if (bs & 2) parts.push("SLEEP");
if (bs & 4) parts.push("ERR_PASSIVE");
if (bs & 8) parts.push("BUS_OFF");
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
/** 根据通道启用状态更新下拉框选项样式,禁用通道贴 "(禁用)" 标签并置灰 */
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 base = "CH" + i;
opt.textContent = en ? base : base + " (禁用)";
opt.style.color = en ? "" : "var(--fg-dim,#666)";
}
}
function onFeedback(data) {
const channels = (data && data.channels) || [];
/* 更新各通道启用状态,禁用通道在下拉框标灰 + 阻止发送 */
for (let i = 0; i < 3; i++) {
const c = channels.find((x) => x.ch === i) || channels[i];
state.chEnabled[i] = !c || c.enabled === undefined || c.enabled === null || !!c.enabled;
}
updateChSelectOptions();
const ch = channels.find((c) => c.ch === state.ch) || channels[state.ch] || {};
const valid = ch.valid && ch.enabled !== false;
$("#sbRx").textContent = "RX " + (valid && ch.rx != null ? ch.rx.toLocaleString() : "--");
$("#sbTx").textContent = valid && ch.tx != null ? ch.tx.toLocaleString() : "--";
$("#sbErr").textContent = valid && ch.err != null ? ch.err.toLocaleString() : "--";
$("#sbTec").textContent = valid && ch.tec != null ? ch.tec : "--";
$("#sbRec").textContent = valid && ch.rec != null ? ch.rec : "--";
const bsText = valid ? busStateText(ch.bus_state) : "--";
const busEl = $("#sbBus");
busEl.textContent = bsText;
const isBusOff = valid && ch.bus_state != null && (ch.bus_state & 8);
const isPassive = valid && ch.bus_state != null && (ch.bus_state & 4);
busEl.style.color = isBusOff ? "var(--danger)" : isPassive ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const sbLed = $("#sbLed");
sbLed.className = "led " + (isBusOff ? "led-err" : (valid ? "led-on" : ""));
}
/* ═══════════════════════ 控制模式联动 ═══════════════════════ */
const modeSelect = $("#modeSelect");
function applyModeVisibility() {
const mode = parseInt(modeSelect.value, 10);
const targets = MODES[mode].targets;
$("#fldCurrent").classList.toggle("target-hidden", !targets.includes("current"));
$("#fldVelocity").classList.toggle("target-hidden", !targets.includes("velocity"));
$("#fldAngle").classList.toggle("target-hidden", !targets.includes("angle"));
$("#modeDesc").textContent = MODE_DESCS[mode];
}
modeSelect.addEventListener("change", applyModeVisibility);
/* ═══════════════════════ 发送 CMD ═══════════════════════ */
function requireNode() {
if (state.selectedNode == null) { toast("请先在左侧选择一个节点", "warn"); return false; }
return true;
}
function inBusCooldown() {
if (nowMs() < state.busCooldownUntil) {
toast("总线冷却中,请稍候…", "warn");
return true;
}
return false;
}
async function doSendCmd(silent) {
if (!state.connected) { if (!silent) toast("设备未连接", "err"); return; }
if (!state.chEnabled[state.ch]) { if (!silent) toast("CH" + state.ch + " 已禁用(GD32 固件未启用此通道)", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const node = state.selectedNode;
const bytes = buildCmdBytes();
const id = ID_CMD(node);
const dataHex = bytesToHexStr(bytes);
const mode = parseInt($("#modeSelect").value, 10) || 0;
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
if (state.sendInFlight) {
/* latest-wins 合并:上一包仍在途 → 新帧覆盖合并槽(不排队、不丢弃),
* sendBatch 的 finally 排空时立即发出最新帧,周期指令始终收敛到最新值 */
state.pendingCmd = { id: id, dlc: 32, data: dataHex };
$("#sbLastSend").textContent = `最近发送: CMD → ${hexId(id)} [32B] 已合并(上一包在途) ${time}`;
return;
}
$("#sbLastSend").textContent = `最近发送: CMD → ${hexId(id)} [32B] ${bytesToHexSpaced(bytes, 8)} … ${time}`;
const res = await sendOneFrame(id, 32, dataHex, { silent: !!silent });
if (res.ok) {
state.lastSentMode.set(node, mode);
state.txCount++;
renderNodeList();
if (!silent) {
toast(`CMD → Node ${node} · ${MODES[mode].zh} 发送成功`, "ok");
} else if (state.pendingCmd) {
/* 周期模式:仅在合并发生时轻提示,让用户知道发送速率跟不上周期 */
$("#sbLastSend").textContent = `最近发送: CMD → ${hexId(id)} [32B] 周期发送中(部分帧已合并) ${time}`;
}
} else if (!res.busy && !res.networkError && !silent) {
toast(`CMD 发送失败: ${res.desc}`, "err");
}
}
$("#sendCmdBtn").addEventListener("click", () => doSendCmd(false));
/* ═══════════════════════ 周期发送(最小 80ms) ═══════════════════════ */
let repeatTimer = null;
const repeatBtn = $("#sendRepeatBtn");
const periodInput = $("#repeatPeriod");
function getPeriod() {
return clampNum(parseInt(periodInput.value, 10) || 100, MIN_CMD_PERIOD_MS, 10000);
}
function startRepeat() {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
const period = getPeriod();
periodInput.value = period;
stopTimer("repeat");
repeatTimer = setInterval(() => doSendCmd(true), period);
state.poll.repeat = repeatTimer;
repeatBtn.textContent = "停止周期"; repeatBtn.classList.add("btn-primary");
const hz = 1000 / period;
toast(`周期发送已启动 · ${period} ms(${hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)} Hz)`, "ok");
}
function stopRepeat(silent) {
if (repeatTimer) { clearInterval(repeatTimer); repeatTimer = null; state.poll.repeat = null; }
state.pendingCmd = null; /* 丢弃合并槽残留帧,避免停止后仍发出 */
repeatBtn.textContent = "周期发送"; repeatBtn.classList.remove("btn-primary");
if (!silent) toast("周期发送已停止", "warn");
}
repeatBtn.addEventListener("click", () => (repeatTimer ? stopRepeat() : startRepeat()));
periodInput.addEventListener("change", () => {
const p = getPeriod(); periodInput.value = p;
if (repeatTimer) { stopRepeat(true); startRepeat(); }
});
/* ═══════════════════════ 配置操作(CMD_CONFIG,DLC 24) ═══════════════════════ */
const CONFIG_ACTIONS = {
motReset: { title: "电机复位", bit: 0, desc: "将向节点发送 motReset 脉冲,电机立即停止并清除故障标志。运行中的电机将失去力矩输出,请确认机械安全。" },
saveConfig: { title: "保存配置", bit: 2, desc: "将当前控制模式和 PID 参数写入 EEPROM。写入期间约 50ms 不可发送新指令。" },
angleReset: { title: "角度置零", bit: 3, desc: "将当前机械位置设为电气零点。置零后目标角度将以新零点为参考,请确认电机处于期望的零位。" },
sleep: { title: "休眠", bit: 56, desc: "电机进入低功耗休眠模式,关闭驱动输出。需发送 CANFD_BOOT 或重新上电才能唤醒。" },
};
/* 构造 CMD_CONFIG 24 字节脉冲帧:仅置位对应 bit,其余为 0 */
function buildConfigBytes(bit) {
const bytes = new Uint8Array(24);
bytes[Math.floor(bit / 8)] = 1 << (bit % 8);
return bytes;
}
document.querySelectorAll(".config-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const action = CONFIG_ACTIONS[btn.dataset.action];
if (!action) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const ok = await showModal(action.title, `目标节点 ${state.selectedNode}。` + action.desc);
if (!ok) return;
const node = state.selectedNode;
const id = ID_CMD_CONFIG(node);
const bytes = buildConfigBytes(action.bit);
const res = await sendOneFrameWhenFree(id, 24, bytesToHexStr(bytes));
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: ${action.title} → ${hexId(id)} [24B] ${bytesToHexSpaced(bytes, 8)} … ${time}`;
if (res.ok) {
state.txCount++;
toast(`${action.title} → Node ${node} 已发送 (CMD_CONFIG)`, "ok");
} else if (!res.networkError && !res.busy) {
toast(`${action.title} 发送失败: ${res.desc}`, "err");
}
});
});
/* ═══════════════════════ 版本查询 ═══════════════════════
* 协议:主机与电机共用 0x000N00FF(同 ID 应答,DLC 24)。
* GD32 为解析核心:捕获电机应答缓存后经 0xC6 同步,前端读 /motor/version。
* 载荷布局(DBC v0.71 = 电机 APP sendVersionInfoViaCan 实际发送):
* soft@0 hard@8 boot@16,每组 major|minor|patch|build 4×u16 LE,24B 无空洞。
* 旧 v0.70 把字段误当 YYYY.MM.DD 日期解析(3 信号+2B 空洞),年份校验
* 把 major=26 判非法 → 全部标 "(?)" 且丢 build 字段,已废弃。
*/
const VERSION_FULL_BYTES = 24; /* 3 组 × 4 字段 × u16,满帧 24B */
function readVersionQuadAt(dv, n, off) {
if (off + 8 > n) return null;
const major = dv.getUint16(off, true);
const minor = dv.getUint16(off + 2, true);
const patch = dv.getUint16(off + 4, true);
const build = dv.getUint16(off + 6, true);
/* 与电机 versionToString 同格式:major.minor.patch.build,不补零 */
return `${major}.${minor}.${patch}.${build}`;
}
/**
* 解析 0x00FF 载荷。以 hex 实际字节数为准(勿被 dlc 声明截短)。
* 固定布局 soft@0 hard@8 boot@16,不再做双布局猜测。
*/
function parseVersionPayload(hex) {
const rb = hexToBytes(hex);
if (!rb || rb.length < 8) return null;
const n = Math.min(rb.length, 24);
const copy = new Uint8Array(24);
copy.set(rb.subarray(0, n));
const dv = new DataView(copy.buffer);
const soft = readVersionQuadAt(dv, n, 0);
const hard = readVersionQuadAt(dv, n, 8);
const boot = readVersionQuadAt(dv, n, 16);
return {
soft,
hard,
boot,
rawHex: bytesToHexStr(copy.subarray(0, n)),
byteLen: n,
complete: n >= VERSION_FULL_BYTES && !!(soft && hard && boot),
};
}
function setVer(id, text) {
const el = $("#" + id);
if (!el) return;
if (text) {
el.textContent = text;
el.classList.remove("ver-empty");
} else {
el.textContent = "--";
el.classList.add("ver-empty");
}
}
/** 回填节点缓存的版本号到版本卡片(切节点/刷新后调用)。
* 未缓存时显示 "--",不覆盖正在查询中的状态。 */
function restoreVersionUi(node) {
if (node == null) {
setVer("verSoft", null);
setVer("verHard", null);
setVer("verBoot", null);
return;
}
const v = state.versions.get(node);
if (!v) {
setVer("verSoft", null);
setVer("verHard", null);
setVer("verBoot", null);
return;
}
/* 只在卡片仍为初始 "--" 或已有缓存值时回填,不覆盖"查询中…/失败/无回报"等瞬时态 */
const softEl = $("#verSoft"), hardEl = $("#verHard"), bootEl = $("#verBoot");
const isStale = (el) => !el || el.textContent === "--" || el.textContent.includes(".");
if (isStale(softEl)) setVer("verSoft", v.soft || null);
if (isStale(hardEl)) setVer("verHard", v.hard || null);
if (isStale(bootEl)) setVer("verBoot", v.boot || null);
}
/* ── 版本查询:GD32 为解析核心,电机 0x00FF 应答由 GD32 缓存后经 0xC6 推送,
* ESP32 以 /motor/version 提供缓存。前端发请求后轮询 /motor/version,
* 按 seq 增长判定是否为本次查询的新应答(不再从 /can/feedback 取原始帧)。 ── */
async function fetchMotorVersionNode(node) {
try {
const data = await fetchDeviceJson("/motor/version", 2000);
if (!data || !data.have_data || !Array.isArray(data.nodes)) return null;
return data.nodes.find((n) => n.node === node) || null;
} catch (e) {
return null; /* 瞬时 busy 忽略 */
}
}
/** 轮询直到 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);
}
return null;
}
$("#queryVerBtn").addEventListener("click", async () => {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const node = state.selectedNode;
const reqId = ID_GET_VERSION_REQ(node); /* 0x000N00FF 请求(与应答同 ID) */
const bytes = new Uint8Array(24);
["verSoft", "verHard", "verBoot"].forEach((k) => {
const el = $("#" + k);
if (el) { el.textContent = "查询中…"; el.classList.add("ver-empty"); }
});
/* 记录查询前该节点的版本 seq,用于判定电机是否给出新应答 */
const before = await fetchMotorVersionNode(node);
const prevSeq = before ? before.seq : null;
const res = await sendOneFrameWhenFree(reqId, 24, bytesToHexStr(bytes));
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent =
`最近发送: GET_VERSION 请求 ${hexId(reqId)} → 等 GD32 0xC6 同步 … ${time}`;
if (!res.ok) {
if (!res.networkError && !res.busy) {
["verSoft", "verHard", "verBoot"].forEach((k) => {
const el = $("#" + k);
if (el) { el.textContent = "失败"; el.classList.add("ver-empty"); }
});
toast(`GET_VERSION 发送失败: ${res.desc}`, "err");
}
return;
}
state.txCount++;
const v = await waitMotorVersion(node, prevSeq, 5000);
if (v && v.version) {
const parsed = parseVersionPayload(v.version);
if (parsed && (parsed.soft || parsed.hard || parsed.boot)) {
setVer("verSoft", parsed.soft || "--");
setVer("verHard", parsed.hard || "--");
setVer("verBoot", parsed.boot || "--");
/* 缓存到 state.versions 并持久化,刷新/切节点后回填 */
state.versions.set(node, { soft: parsed.soft || null, hard: parsed.hard || null, boot: parsed.boot || null });
saveUiState();
const bl = parsed.byteLen || 0;
const full = parsed.complete;
toast(
full
? `版本已更新 · Node ${node} · ${bl}B · seq ${v.seq}`
: `版本部分更新 · 仅 ${bl}B(需≥${VERSION_FULL_BYTES}B 才有 hard/boot)`,
full ? "ok" : "warn"
);
return;
}
}
["verSoft", "verHard", "verBoot"].forEach((k) => {
const el = $("#" + k);
if (el) { el.textContent = "无回报"; el.classList.add("ver-empty"); }
});
toast(
`已发 ${hexId(reqId)},5s 内未收到电机新版本的 0xC6 同步`,
"warn"
);
});
/* ═══════════════════════ 节点 ID(DBC:SET 0x000N0400 / GET 0x000N0401,DLC 2) ═══════════════════════ */
$("#setNodeIdBtn").addEventListener("click", async () => {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const newId = parseInt($("#nodeIdInput").value, 10);
if (!Number.isFinite(newId) || newId < 1 || newId > 9) { toast("Node ID 范围 1-9", "err"); return; }
const cur = state.selectedNode;
const ok = await showModal("设置节点 ID", `将节点 ${cur} 的地址修改为 ${newId},CAN ID 前缀将变为 0x000${nodeHex(newId)}xxxx。修改立即生效并写入 EEPROM。`);
if (!ok) return;
/* CANFD_SET_NODE_ID:NodeID_Set 0|16@1+ = 新节点号 u16 LE */
const bytes = new Uint8Array(2);
bytes[0] = newId & 0xFF;
bytes[1] = (newId >> 8) & 0xFF;
const id = ID_SET_NODE_ID(cur);
const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes));
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: SET_NODE_ID → ${hexId(id)} [2B] new=${newId} … ${time}`;
if (res.ok) {
state.txCount++;
state.selectedNode = newId;
$("#nodeIdResult").textContent = `设置成功: Node ID = ${newId} · 新 CMD ID: ${hexId(ID_CMD(newId))}`;
toast(`节点 ID 已更新为 ${newId}`, "ok");
updateNodeLabels();
} else if (!res.networkError && !res.busy) {
$("#nodeIdResult").textContent = `设置失败: ${res.desc}`;
toast(`SET_NODE_ID 失败: ${res.desc}`, "err");
}
});
$("#getNodeIdBtn").addEventListener("click", async () => {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const cur = state.selectedNode;
/* CANFD_GET_NODE_ID 0x000N0401 DLC2:NodeID_Request 0|16@1+ */
const bytes = new Uint8Array(2);
bytes[0] = cur & 0xFF;
bytes[1] = 0;
const id = ID_GET_NODE_ID(cur);
const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes));
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] cur=${cur} … ${time}`;
if (res.ok) {
state.txCount++;
const r = res.results && res.results[0];
let reported = cur;
if (r && r.recv) {
const rb = hexToBytes(r.recv);
if (rb.length >= 2) reported = rb[0] | (rb[1] << 8);
else if (rb.length >= 1) reported = rb[0];
}
$("#nodeIdResult").textContent =
`节点 ${cur} 回报 ID: ${reported} · ${hexId(id)}`;
toast(`GET_NODE_ID → Node ${cur} 已读取: ${reported}`, "ok");
} else if (!res.networkError && !res.busy) {
$("#nodeIdResult").textContent = `读取失败: ${res.desc}`;
toast(`GET_NODE_ID 失败: ${res.desc}`, "err");
}
});
/* ═══════════════════════ Tab 切换 ═══════════════════════ */
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".tab-page").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
$("#tab-" + btn.dataset.tab).classList.add("active");
});
});
/* ═══════════════════════ 电机 OTA(relay 模式)═══════════════════════
* 两段式(与 BLE 刷 GD32 对齐):
* 步1 POST /motor/ota 收固件写 ESP32 flash(不碰 SPI,避免与 GD32 按铃竞态)
* 步2 POST /motor/ota/relay 触发 SPI START + feedTask 喂 GD32 → GD32 本地 AES 握手 + CAN stop-and-wait
* 前端负责:选文件 → 进BOOT → 上传 → 触发转发 → 轮询进度 → 显示结果。 */
const OTA_MAX_FW = 0xC000; // 48 KB 上限
const OTA_POLL_MS = 500; // 进度轮询间隔
const OTA_TOTAL_TIMEOUT_MS = 120000; // OTA 整体超时(含进 BOOT + 上传 + 传输),超时自动取消
const ota = {
running: false,
file: null,
bytes: null,
pollTimer: null,
watchdogTimer: null, /* 整体超时看门狗 */
startTime: 0, /* OTA 开始时间戳 */
fileHandle: null, /* FileSystemFileHandle(FSAPI),用于刷新后恢复固件 */
pendingHandle: null, /* 待重新授权的 handle(权限丢失时暂存,点上传区再授权) */
};
/* ─── 固件文件持久化(仿主页面 file-handle.js 模式)───
* localStorage 存文件元数据(名/大小),IndexedDB 存 FileSystemFileHandle。
* 刷新后:权限仍 granted → 静默恢复 bytes;否则展示文件名,点"选择固件"区重新授权。
* File 对象本身不可序列化,bytes 不入 localStorage;IndexedDB 仅存 handle(可结构化克隆)。 */
const LS_OTA_FW_META = "dcu_motor_ota_fw_meta";
const IDB_OTA_NAME = "dcu_motor_ota_fw_v1";
const IDB_OTA_STORE = "file_handle";
let _otaIdbDb = null;
function otaIdbOpen() {
if (_otaIdbDb) return Promise.resolve(_otaIdbDb);
if (!window.indexedDB) return Promise.reject(new Error("no IDB"));
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_OTA_NAME, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(IDB_OTA_STORE)) req.result.createObjectStore(IDB_OTA_STORE);
};
req.onsuccess = () => { _otaIdbDb = req.result; resolve(_otaIdbDb); };
req.onerror = () => { _otaIdbDb = null; reject(req.error); };
});
}
function otaIdbPutHandle(handle) {
return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(IDB_OTA_STORE, "readwrite");
if (handle) tx.objectStore(IDB_OTA_STORE).put(handle, "motor");
else tx.objectStore(IDB_OTA_STORE).delete("motor");
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
})).catch(() => {});
}
function otaIdbGetHandle() {
return otaIdbOpen().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(IDB_OTA_STORE, "readonly");
const r = tx.objectStore(IDB_OTA_STORE).get("motor");
r.onsuccess = () => resolve(r.result || null);
r.onerror = () => reject(r.error);
})).catch(() => null);
}
function otaSaveFwMeta(file) {
try {
if (file && file.name) {
localStorage.setItem(LS_OTA_FW_META, JSON.stringify({ name: file.name, size: file.size || 0, lastModified: file.lastModified || 0 }));
} else {
localStorage.removeItem(LS_OTA_FW_META);
}
} catch (_) {}
}
function otaLoadFwMeta() {
try {
const raw = localStorage.getItem(LS_OTA_FW_META);
return raw ? JSON.parse(raw) : null;
} catch (_) { return null; }
}
/** 查询 handle 权限(不 request,刷新无手势时 request 会失败) */
async function otaQueryPermission(handle) {
if (!handle || typeof handle.getFile !== "function") return "denied";
try {
if (typeof handle.queryPermission === "function") return await handle.queryPermission({ mode: "read" });
await handle.getFile();
return "granted";
} catch (_) { return "denied"; }
}
/** 把恢复的 File 应用到 ota 对象 + UI */
async function otaApplyRestoredFile(handle, file, opts) {
opts = opts || {};
ota.fileHandle = handle || null;
ota.file = file;
ota.bytes = new Uint8Array(await file.arrayBuffer());
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
otaSaveFwMeta(file);
if (!opts.silent) otaLog(`<span class="log-info">[固件]</span> ${file.name} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
}
/** 无权限时展示文件名,提示点上传区重新授权 */
function otaShowPendingReauth(handle, meta) {
ota.fileHandle = handle || null;
ota.pendingHandle = handle || null;
const name = (meta && meta.name) || (handle && handle.name) || "上次固件";
$("#otaFileName").textContent = `${name} — 点击「选择固件」重新授权恢复`;
if (meta && meta.size) $("#otaPktTotal").textContent = String(Math.ceil(meta.size / 48));
otaLog(`<span class="log-info">[固件]</span> 上次固件「${name}」需点击「选择固件」重新授权(浏览器安全限制)`);
}
/** 页面加载后恢复上次固件(权限 granted → 静默恢复;否则提示重新授权) */
async function otaRestorePersistedFirmware() {
let handle;
try { handle = await otaIdbGetHandle(); } catch (_) { handle = null; }
const meta = otaLoadFwMeta();
if (!handle) {
/* 无 handle(非 FSAPI 选择的旧文件或首次):仅展示元数据提示 */
if (meta && meta.name) {
$("#otaFileName").textContent = `${meta.name} — ${meta.size ? meta.size.toLocaleString() + " B · " : ""}请重新选择`;
otaLog(`<span class="log-info">[固件]</span> 上次固件「${meta.name}」需重新选择(无 FSAPI handle)`);
}
return;
}
const perm = await otaQueryPermission(handle);
if (perm === "granted") {
try {
const file = await handle.getFile();
await otaApplyRestoredFile(handle, file, { silent: true });
otaLog(`<span class="log-ok">[固件]</span> 已恢复刷新前的固件: ${file.name}`);
} catch (e) {
otaShowPendingReauth(handle, meta);
}
} else {
otaShowPendingReauth(handle, meta);
}
}
/** 用户手势中:对 pending handle 重新授权并恢复(点"选择固件"时先尝试此路径) */
async function otaResumeWithGesture() {
const handle = ota.pendingHandle;
if (!handle || typeof handle.getFile !== "function") return false;
try {
let perm = "granted";
if (typeof handle.queryPermission === "function") perm = await handle.queryPermission({ mode: "read" });
if (perm !== "granted" && typeof handle.requestPermission === "function") perm = await handle.requestPermission({ mode: "read" });
if (perm !== "granted") { toast("未授予读取权限,请在弹窗中允许或重新选择", "warn"); return false; }
const file = await handle.getFile();
if (file.size > OTA_MAX_FW) { toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err"); return false; }
await otaApplyRestoredFile(handle, file, {});
ota.pendingHandle = null;
toast(`已恢复上次固件: ${file.name}`, "ok");
return true;
} catch (e) {
otaLog(`<span class="log-err">[固件]</span> 恢复失败: ${e.message || e}`);
return false;
}
}
function otaLog(html) {
const el = $("#otaLog");
const div = document.createElement("div");
div.innerHTML = html;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
}
/* OTA 日志工具栏:清空 / 复制 */
$("#otaLogClearBtn").addEventListener("click", function () {
const el = $("#otaLog");
el.innerHTML = '<div class="log-info">[已清空] 日志已清除</div>';
});
$("#otaLogCopyBtn").addEventListener("click", function () {
const el = $("#otaLog");
const text = el.innerText.trim();
if (!text) { toast("日志为空", "warn"); return; }
const btn = this;
function onDone() {
btn.classList.add("copied");
setTimeout(function () { btn.classList.remove("copied"); }, 1200);
toast("已复制日志", "ok");
}
function fallback() {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
});
function setOtaStage(stageIdx) {
document.querySelectorAll(".ota-stage").forEach((el, i) => {
el.classList.toggle("done", i < stageIdx);
el.classList.toggle("current", i === stageIdx);
});
}
function resetOtaUi() {
setOtaStage(-1);
$("#otaProgressFill").style.width = "0%";
$("#otaPktNum").textContent = "0";
}
/* 状态名 → 阶段索引映射(6 阶段:进入BOOT → 上传固件 → 转发握手 → 擦除 → 传输 → 完成)
* 后端 motor_ota_state_t 不含 boot/upload 阶段(纯前端驱动),后端状态从 handshake_req 起。
* 两段式:上传固件(stage1)完成 → 触发转发 → GD32 握手(stage2)/擦除(stage3)/传输(stage4)/完成(stage5)。 */
const OTA_STATE_STAGE = {
idle: -1, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2,
erase: 3, transfer: 4, finish_ota: 5, finished: 5, error: -1,
};
const OTA_STATE_ZH = {
idle: "空闲", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输",
finish_ota: "等待跳转…", finished: "完成", error: "错误",
};
$("#otaFileBtn").addEventListener("click", async () => {
/* 优先尝试恢复待授权的 handle(FSAPI),成功则不再弹文件选择框 */
if (ota.pendingHandle && await otaResumeWithGesture()) return;
/* FSAPI:showOpenFilePicker 可拿到 handle 用于刷新后恢复 */
if (window.showOpenFilePicker) {
try {
const [handle] = await window.showOpenFilePicker({
types: [{ description: "固件 (.bin)", accept: { "application/octet-stream": [".bin"] } }],
multiple: false,
});
const file = await handle.getFile();
if (file.size > OTA_MAX_FW) {
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
return;
}
ota.fileHandle = handle;
ota.pendingHandle = null;
ota.file = file;
ota.bytes = new Uint8Array(await file.arrayBuffer());
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
$("#otaCrc").textContent = "—";
otaSaveFwMeta(file);
otaIdbPutHandle(handle);
otaLog(`<span class="log-info">[固件]</span> ${file.name} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
toast(`固件已加载: ${file.name}`, "ok");
return;
} catch (e) {
if (e && e.name === "AbortError") return; /* 用户取消,静默 */
/* FSAPI 失败则回退到 input[type=file] */
}
}
$("#otaFileInput").click();
});
$("#otaFileInput").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > OTA_MAX_FW) {
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
e.target.value = "";
return;
}
const buf = await file.arrayBuffer();
ota.file = file;
ota.bytes = new Uint8Array(buf);
ota.fileHandle = null; /* input 选择无 handle,刷新后只能展示文件名 */
ota.pendingHandle = null;
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
$("#otaCrc").textContent = "—";
otaSaveFwMeta(file);
/* input 选择无 FSAPI handle,清掉旧 handle 避免恢复到旧文件 */
otaIdbPutHandle(null);
otaLog(`<span class="log-info">[固件]</span> ${file.name} · ${file.size.toLocaleString()} B · ${Math.ceil(file.size / 48)} 包`);
toast(`固件已加载: ${file.name}`, "ok");
});
$("#otaStartBtn").addEventListener("click", startOta);
$("#otaCancelBtn").addEventListener("click", cancelOta);
async function startOta() {
if (ota.running) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
/* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
* node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
const node = state.selectedNode;
if (node == null || node < 1) {
toast("请先在左侧节点列表选中目标电机(node ≥ 1)", "warn");
return;
}
const ch = state.ch || 0;
/* ★ 每次刷写都重新从磁盘读取固件,确保用最新文件内容,不依赖内存缓存。
* 之前只读一次存 ota.bytes,之后多次刷写复用缓存——若磁盘文件被重新编译
* 覆盖(改代码 rebuild),前端仍发旧数据,导致刷进电机的不是最新固件。
* 有 FSAPI handle 时从 handle 重新读(handle 总是指向同一文件路径);
* 无 handle(input 选择)时从 ota.file 重新读 File 对象。
* 读取失败提示用户重新选择固件,绝不带着旧/坏缓存刷写。 */
try {
let file = ota.file;
if (ota.fileHandle && typeof ota.fileHandle.getFile === "function") {
file = await ota.fileHandle.getFile();
}
if (file.size > OTA_MAX_FW) {
toast(`固件过大 (${file.size} B),上限 ${OTA_MAX_FW} B (48 KB)`, "err");
return;
}
ota.file = file;
ota.bytes = new Uint8Array(await file.arrayBuffer());
$("#otaFileName").textContent = `${file.name} — ${file.size.toLocaleString()} B`;
$("#otaPktTotal").textContent = String(Math.ceil(file.size / 48));
} catch (e) {
otaLog(`<span class="log-err">[固件]</span> 重新读取失败:${e.message || e},请重新选择固件`);
toast("固件读取失败,请重新选择", "err");
return;
}
const ok = await showModal("开始电机 OTA",
`将先发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` +
`再上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备,` +
`由 GD32 本地完成 AES 握手与 CAN 传输。OTA 期间请勿操作电机。`);
if (!ok) return;
stopRepeat(true);
ota.running = true;
ota.startTime = performance.now();
$("#otaStartBtn").disabled = true;
$("#otaCancelBtn").disabled = false;
resetOtaUi();
setOtaStage(0); /* 进入 BOOT 阶段 */
/* 整体超时看门狗:进 BOOT + 上传 + 传输 + 跳转 总计不应超过 OTA_TOTAL_TIMEOUT_MS。
* 超时后自动取消,避免卡死时前端无限轮询 + ESP32 feedTask 残留。 */
if (ota.watchdogTimer) clearTimeout(ota.watchdogTimer);
ota.watchdogTimer = setTimeout(() => {
if (!ota.running) return;
otaLog(`<span class="log-err">[超时]</span> OTA 整体超时 (${OTA_TOTAL_TIMEOUT_MS / 1000}s),自动取消…`);
cancelOta();
}, OTA_TOTAL_TIMEOUT_MS);
/* OTA 期间暂停 motor/feedback/status 三链路轮询:
* 电机已跳 BOOT,motor 状态无意义;三条链各占一个 HTTP socket + httpd 线程时间,
* 叠加 OTA poll(500ms)+ cancel 需要的新连接,6 个 max_open_sockets 会紧张,
* 导致 cancel "Failed to fetch"(连接建立失败/被 lru_purge 踢掉)。
* OTA 完成后 otaDone→startPolling 恢复。 */
stopDataPolling();
/* ── 步骤 1:发 0x000N0000 进 BOOT 帧(DLC=0,fire-and-forget)──
* APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。
* 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK,
* 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败——
* 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。
* 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */
const bootId = canId(node, 0x0000); /* 0x000N0000 */
otaLog(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
}
} catch (e) {
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${e.message || e}(继续,由握手确认是否进 BOOT)`);
}
otaLog(`<span class="log-ok">[进BOOT]</span> 等待电机 jumpToBOOT() 重启进入 Bootloader…`);
/* ── 等待电机跳转 + BOOT 初始化 ──
* jumpToBOOT() 含 can_deinit + 时钟重置 + MSP 跳转,实测 <100ms;
* 给 1000ms 余量覆盖 APP 保存配置 + flash 写 + 重启稳定 + CAN 恢复。
* 原 2000ms 过保守(GD32 kBootSettleMs 已降到 800ms,总链路无需 2s 等待)。 */
await sleep(1000);
otaLog(`<span class="log-info">[上传]</span> 开始上传固件到 ESP32 (CH${ch})…`);
setOtaStage(1); /* 上传固件阶段 */
try {
/* ── 步骤 2:POST /motor/ota 只收固件写 flash(不触发 SPI,避免与 GD32 按铃竞态)── */
const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: ota.bytes,
signal: AbortSignal.timeout(15000),
});
const upJson = await upResp.json().catch(() => ({}));
if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`);
otaLog(`<span class="log-ok">[上传]</span> 固件已存储 (${upJson.size || ota.bytes.length} B),触发 GD32 转发…`);
/* ── 步骤 3:前端算固件 SHA-256,发给 ESP32 与 flash 实际 SHA 比对 ──
* ★ 关键:之前 motor OTA 没有 SHA-256 比对,ESP32 flash 写入位错误无法被发现。
* 前端用 Web Crypto 算 ota.bytes 的 SHA-256,relay 触发时用 JSON body 发给 ESP32,
* ESP32 调 metaVerifyChecksum 复核 flash 实际 SHA,不匹配则拒绝刷写。
* 这样从源头确认"前端发的 = ESP32 flash 存的",堵住 flash 存储环节位错误。 */
const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes);
const fwShaHex = Array.from(new Uint8Array(fwSha256))
.map(b => b.toString(16).padStart(2, "0")).join("");
otaLog(`<span class="log-info">[校验]</span> 固件 SHA-256: ${fwShaHex}`);
/* ── 步骤 4:POST /motor/ota/relay 触发 SPI START + 喂数据 ── */
const rResp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sha256: fwShaHex }),
signal: AbortSignal.timeout(8000),
});
const rJson = await rResp.json().catch(() => ({}));
if (!rResp.ok) throw new Error(rJson.error || `HTTP ${rResp.status}`);
otaLog(`<span class="log-ok">[转发]</span> GD32 开始握手…`);
startOtaPoll();
} catch (e) {
otaLog(`<span class="log-err">[失败]</span> ${e.message || e}`);
toast("OTA 启动失败:" + (e.message || e), "err");
otaDone();
}
}
function startOtaPoll() {
const startTime = performance.now();
let lastState = "";
let lastLoggedPkt = -1;
ota.pollTimer = setInterval(async () => {
try {
const resp = await fetch(getBaseUrl() + "/motor/ota/status", {
signal: AbortSignal.timeout(2000), cache: "no-store",
});
const st = await resp.json();
updateOtaUi(st, startTime);
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
if (st.state && st.state !== lastState) {
const zh = OTA_STATE_ZH[st.state] || st.state;
otaLog(`<span class="log-info">[状态]</span> ${zh}` +
(st.total_packets ? ` · ${st.packets_acked || 0}/${st.total_packets}` : ""));
lastState = st.state;
} else if (st.state === "transfer" && st.total_packets > 0) {
const acked = st.packets_acked || 0;
/* 传输期每约 64 包补一条进度,避免只有握手日志 */
if (acked - lastLoggedPkt >= 64 || acked >= st.total_packets) {
otaLog(`<span class="log-info">[传输]</span> ${acked}/${st.total_packets}`);
lastLoggedPkt = acked;
}
}
if (st.state === "finished" || st.state === "error" || st.state === "idle") {
clearInterval(ota.pollTimer);
ota.pollTimer = null;
if (st.state === "finished") {
otaLog(`<span class="log-ok">[完成]</span> OTA 成功 · ${st.packets_acked} 包 · 电机 APP 已启动`);
toast("电机 OTA 完成", "ok");
} else if (st.state === "error") {
otaLog(`<span class="log-err">[错误]</span> 代码 0x${(st.error || 0).toString(16).toUpperCase()}`);
toast("电机 OTA 失败 (0x" + (st.error || 0).toString(16) + ")", "err");
}
/* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel
* 会让 GD32 发 0x00000000(kOtaCancelId),被刚启动的电机 APP 当作
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s → APP 看似卡死。
* 只有 error/idle/异常退出才发 cancel 兜底清理设备端残留状态。 */
otaDone(st.state !== "finished");
}
} catch (e) { /* 轮询瞬时失败静默 */ }
}, OTA_POLL_MS);
}
function updateOtaUi(st, startTime) {
const stage = OTA_STATE_STAGE[st.state] ?? -1;
setOtaStage(stage);
const acked = st.packets_acked || 0;
const total = st.total_packets || 1;
$("#otaPktNum").textContent = String(acked) + "/" + String(total);
$("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
const elapsed = (performance.now() - startTime) / 1000;
if (elapsed > 0.5 && acked > 0) {
$("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
}
$("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
}
async function cancelOta() {
if (!ota.running) return;
otaLog(`<span class="log-err">[取消]</span> 正在取消…`);
/* 先停 OTA 进度轮询,腾出 HTTP socket 给 cancel 请求本身。
* motor/feedback/status 三链路在 startOta 时已 stopDataPolling,
* 这里只剩 OTA poll(500ms)可能正占用一个 socket。
* 先停 poll 再发 cancel,避免 cancel 被挤导致 "Failed to fetch"。 */
if (ota.pollTimer) { clearInterval(ota.pollTimer); ota.pollTimer = null; }
/* 取消请求重试 3 次:单次失败(网络抖动/ESP32 忙)时不能静默吞掉,
* 否则 ESP32 侧 feedTask 不会退出,motorOtaIsActive() 残留为 true,
* 导致后续 ESP32/GD32 OTA 全被"电机 OTA 进行中"拒绝。 */
let ok = false;
for (let i = 1; i <= 3 && !ok; i++) {
try {
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(5000) });
ok = r.ok;
} catch (e) {
otaLog(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${e.message || e}`);
}
if (!ok && i < 3) await sleep(500);
}
if (!ok) otaLog(`<span class="log-err">[取消]</span> 3 次均失败,设备端 feedTask 可能残留,建议刷新页面或等待自动超时`);
otaDone();
}
/* otaDone(sendCancel): 清理前端 OTA 状态。
* sendCancel=true 时发 POST /motor/ota/cancel 兜底清理设备端残留状态。
* sendCancel=false(正常 finished)时不发 cancel——电机 APP 已跳转启动,
* 此时发 cancel 会让 GD32 发 0x00000000(kOtaCancelId),被电机 APP 当作
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s,APP 看似卡死。
* 调用方:
* - startOtaPoll 正常完成 → otaDone(false)
* - startOtaPoll error/idle → otaDone(true)
* - cancelOta(用户主动取消)→ otaDone(true)
* - startOta 启动失败 → otaDone(true) */
function otaDone(sendCancel = true) {
ota.running = false;
$("#otaStartBtn").disabled = false;
$("#otaCancelBtn").disabled = true;
if (ota.watchdogTimer) { clearTimeout(ota.watchdogTimer); ota.watchdogTimer = null; }
/* OTA 期间暂停了 motor/feedback/status 三链路轮询,完成后恢复。
* startPolling 内部先 stopDataPolling 再重建,安全可重复调用。 */
if (state.connected && !state.probing) {
startPolling();
}
/* 仅在需要时发 cancel 兜底清理设备端 motorOtaIsActive。
* 正常完成(finished)时 feedTask 已自行退出 + GD32 已进 kFinished,
* 不需要 cancel;发了反而干扰刚跳转的电机 APP(见上注释)。 */
if (sendCancel && state.connected) {
fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(3000) })
.catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
}
}
/* ═══════════════════════ 通道切换(无连接按钮) ═══════════════════════ */
$("#chSelect").addEventListener("change", () => {
state.ch = parseInt($("#chSelect").value, 10) || 0;
updateConnUi();
/* 通道切换不额外拉 feedback:等下一轮 pollLoop,避免与单飞链并发 */
});
/* ═══════════════════════ 刷新速度(motor 轮询间隔) ═══════════════════════ */
/* 用户改 #refreshRate 后:clamp、回填、写 state,并按「N×GD32周期」立即重排。
* 仅影响 motor 链路;feedback/status 保持 1Hz 独立节拍。
* 注意:旧实现只把 userMs 当锁相失败 fallback,成功锁相后永远 ~250ms,
* 导致改刷新速度“看起来没反应”。现在 delay 真正跟随 N×period。 */
const refreshRateInput = $("#refreshRate");
function applyRefreshRate() {
const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
const v = clampNum(parseInt(refreshRateInput.value, 10) || period, period, 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);
}
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");
}
refreshRateInput.addEventListener("change", applyRefreshRate);
refreshRateInput.addEventListener("input", () => {
/* 输入过程中只 clamp 显示,松手/change 再应用节拍,避免每个键都 toast */
const period = (state.phase && state.phase.motorPeriodMs) || TICK_INTERVAL_MS;
const raw = parseInt(refreshRateInput.value, 10);
if (Number.isFinite(raw) && raw < period) refreshRateInput.value = period;
});
/* ═══════════════════════ 示波器系统 ═══════════════════════ */
const scopeSys = (() => {
const BUF = 300;
const dprOf = () => window.devicePixelRatio || 1;
const hist = {
u: new Float32Array(BUF), v: new Float32Array(BUF), w: new Float32Array(BUF),
angle: new Float32Array(BUF), velocity: new Float32Array(BUF), current: new Float32Array(BUF),
mcuTemp: new Float32Array(BUF), motTemp: new Float32Array(BUF),
};
let head = 0;
function colors() {
const cs = getComputedStyle(document.documentElement);
return {
u: cs.getPropertyValue("--seed-primary").trim() || "#22d3ee",
v: "#34d399",
w: cs.getPropertyValue("--seed-accent").trim() || "#f59e0b",
};
}
let C = colors();
setInterval(() => { C = colors(); }, 2000);
function fit(canvas) {
const dpr = dprOf();
const r = canvas.getBoundingClientRect();
if (r.width === 0) return false;
if (canvas.width !== Math.round(r.width * dpr) || canvas.height !== Math.round(r.height * dpr)) {
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
}
canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
return true;
}
function grid(ctx, w, h) {
ctx.strokeStyle = "rgba(255,255,255,0.05)";
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) { const y = h / 4 * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
for (let i = 1; i < 6; i++) { const x = w / 6 * i; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
ctx.strokeStyle = "rgba(255,255,255,0.13)";
ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
}
function trace(ctx, buf, color, range, w, h, lw, fill) {
const mid = h / 2, gain = (h / 2 - 4) / range;
if (fill) {
ctx.beginPath();
for (let i = 0; i < BUF; i++) {
const idx = (head + i) % BUF;
const x = i / (BUF - 1) * w;
const y = mid - Math.max(-range, Math.min(range, buf[idx])) * gain;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.lineTo(w, mid); ctx.lineTo(0, mid); ctx.closePath();
ctx.fillStyle = color + "16";
ctx.fill();
}
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = "round";
ctx.shadowColor = color; ctx.shadowBlur = 5;
for (let i = 0; i < BUF; i++) {
const idx = (head + i) % BUF;
const x = i / (BUF - 1) * w;
const y = mid - Math.max(-range, Math.min(range, buf[idx])) * gain;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
}
function traceUni(ctx, buf, color, min, max, w, h, lw, fill) {
const span = max - min;
const yOf = (v) => h - 4 - Math.max(0, Math.min(1, (v - min) / span)) * (h - 8);
if (fill) {
ctx.beginPath();
for (let i = 0; i < BUF; i++) {
const idx = (head + i) % BUF;
const x = i / (BUF - 1) * w;
i === 0 ? ctx.moveTo(x, yOf(buf[idx])) : ctx.lineTo(x, yOf(buf[idx]));
}
ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath();
ctx.fillStyle = color + "16";
ctx.fill();
}
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = "round";
ctx.shadowColor = color; ctx.shadowBlur = 5;
for (let i = 0; i < BUF; i++) {
const idx = (head + i) % BUF;
const x = i / (BUF - 1) * w;
i === 0 ? ctx.moveTo(x, yOf(buf[idx])) : ctx.lineTo(x, yOf(buf[idx]));
}
ctx.stroke();
ctx.shadowBlur = 0;
}
/* 大三相示波器 */
const big = $("#scopeCanvas");
const bigCtx = big.getContext("2d");
function drawBig() {
if (!fit(big)) return;
const w = big.width / dprOf(), h = big.height / dprOf();
bigCtx.clearRect(0, 0, w, h);
grid(bigCtx, w, h);
trace(bigCtx, hist.u, C.u, 5000, w, h, 1.6, false);
trace(bigCtx, hist.v, C.v, 5000, w, h, 1.6, false);
trace(bigCtx, hist.w, C.w, 5000, w, h, 1.6, false);
}
/* 迷你示波器(每卡片独立)
* angle 用 auto: true 自适应 Y 轴(角度可能 >360 多圈累计或为负,
* 固定 0~360 会让波形贴顶/贴底或越界不可见)。
* 其余卡片量纲固定(velocity/current 对称范围,temp 单极性),保留 uni/range。 */
const MINIS = {
angle: { el: $("#miniAngle"), buf: hist.angle, auto: true, unit: "\u00B0", color: () => C.u },
velocity: { el: $("#miniVelocity"), buf: hist.velocity, auto: true, unit: "deg/s", color: () => C.v },
current: { el: $("#miniCurrent"), buf: hist.current, auto: true, unit: "mA", color: () => C.w },
mcuTemp: { el: $("#miniMcuTemp"), buf: hist.mcuTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.v },
motTemp: { el: $("#miniMotTemp"), buf: hist.motTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.w },
};
for (const m of Object.values(MINIS)) m.ctx = m.el.getContext("2d");
const MONO = '"Cascadia Code","Fira Code",Consolas,monospace';
const fmtTick = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "k" : String(v));
/* 自适应 Y 轴:扫 buf 取实际数据范围,加 10% padding,生成 3 个 nice ticks。
* 用于 angle/velocity/current(各自范围不确定,固定轴会让波形拟合成直线或越界)。
* 空缓冲/全零时用小范围居中显示,避免空白卡片无刻度。 */
function autoScale(buf) {
let lo = Infinity, hi = -Infinity, hasNonZero = false;
for (let i = 0; i < buf.length; i++) {
const v = buf[i];
if (!Number.isFinite(v)) continue;
if (v < lo) lo = v;
if (v > hi) hi = v;
if (v !== 0) hasNonZero = true;
}
/* 无有效数据或全零:小范围居中,等有数据后自然扩展 */
if (!hasNonZero || !Number.isFinite(lo) || !Number.isFinite(hi)) {
return { min: -1, max: 1, ticks: [1, 0, -1] };
}
/* 平坦线(lo===hi):围绕该值拓展小范围 */
if (lo === hi) {
const c = lo;
const pad = Math.abs(c) * 0.2 || 1;
return { min: c - pad, max: c + pad, ticks: [c + pad, c, c - pad] };
}
/* 留 10% padding,避免波形贴顶/贴底 */
const span = hi - lo;
const pad = span * 0.1 || 1;
let min = lo - pad, max = hi + pad;
/* nice 化:把范围对齐到 1/2/5 整数倍刻度,ticks 取三等分点 */
const niceStep = niceStepSize(max - min, 3);
min = Math.floor(min / niceStep) * niceStep;
max = Math.ceil(max / niceStep) * niceStep;
const ticks = [max, (max + min) / 2, min];
return { min, max, ticks };
}
/* 计算 nice 刻度步长:让 (max-min)/step ≈ targetTicks,并对齐到 1/2/5×10^k。 */
function niceStepSize(range, targetTicks) {
const rawStep = range / targetTicks;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
const norm = rawStep / mag;
let step;
if (norm < 1.5) step = 1;
else if (norm < 3) step = 2;
else if (norm < 7) step = 5;
else step = 10;
return step * mag;
}
function drawYScale(ctx, m, w, h) {
/* auto 模式:drawMini 已把 m.uni/ticks 算好填入;其余模式用静态配置 */
const yOf = m.uni
? (v) => h - 4 - Math.max(0, Math.min(1, (v - m.uni.min) / (m.uni.max - m.uni.min))) * (h - 8)
: (v) => h / 2 - Math.max(-m.range, Math.min(m.range, v)) * ((h / 2 - 4) / m.range);
ctx.save();
ctx.font = "8px " + MONO;
ctx.textBaseline = "middle";
for (const t of m.ticks) {
const y = yOf(t);
ctx.strokeStyle = "rgba(226,233,242,0.35)";
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(5, y); ctx.stroke();
ctx.fillStyle = "rgba(226,233,242,0.5)";
ctx.fillText(fmtTick(Math.round(t)), 7, y);
}
if (m.unit) {
ctx.textBaseline = "top";
ctx.fillStyle = "rgba(226,233,242,0.35)";
ctx.fillText(m.unit, w - ctx.measureText(m.unit).width - 4, 3);
}
ctx.restore();
}
function drawMini(m) {
if (!fit(m.el)) return;
const w = m.el.width / dprOf(), h = m.el.height / dprOf();
/* auto 模式:每帧根据 buf 实际数据重算 Y 轴范围 + ticks */
if (m.auto) {
const sc = autoScale(m.buf);
m.uni = { min: sc.min, max: sc.max };
m.ticks = sc.ticks;
}
m.ctx.clearRect(0, 0, w, h);
grid(m.ctx, w, h);
if (m.uni) traceUni(m.ctx, m.buf, m.color(), m.uni.min, m.uni.max, w, h, 1.8, true);
else trace(m.ctx, m.buf, m.color(), m.range, w, h, 1.8, true);
drawYScale(m.ctx, m, w, h);
}
/* 点击卡片 → 只切换自己的波形 */
document.querySelectorAll(".big-reading, .temp-block").forEach((card) => {
card.addEventListener("click", () => card.classList.toggle("open"));
});
/* 采样:用真实解码的 STATUS 数据填充历史缓冲(1Hz 数据 → 平滑插值) */
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 online = d && motor && motor.online;
if (!online) {
hist.u[head] = hist.v[head] = hist.w[head] = 0;
hist.angle[head] = hist.velocity[head] = hist.current[head] = 0;
hist.mcuTemp[head] = hist.motTemp[head] = 0;
} else {
/* 三相电流:用解码的 U/V/W 瞬时值(mA) */
const targets = { u: d.uCurrent, v: d.vCurrent, w: d.wCurrent };
for (const ch of ["u", "v", "w"]) {
const prev = hist[ch][(head + BUF - 1) % BUF];
hist[ch][head] = prev + (clampNum(targets[ch], -5000, 5000) - prev) * 0.5;
}
const pA = hist.angle[(head + BUF - 1) % BUF];
hist.angle[head] = pA + (d.rtAngle - pA) * 0.4;
const pV = hist.velocity[(head + BUF - 1) % BUF];
hist.velocity[head] = pV + (d.rtVelocity - pV) * 0.4;
const pC = hist.current[(head + BUF - 1) % BUF];
hist.current[head] = pC + (d.rtCurrent - pC) * 0.4;
const pMcu = hist.mcuTemp[(head + BUF - 1) % BUF];
hist.mcuTemp[head] = pMcu + (d.mcuTemp - pMcu) * 0.1;
const pMot = hist.motTemp[(head + BUF - 1) % BUF];
hist.motTemp[head] = pMot + (d.motTemp - pMot) * 0.1;
}
head = (head + 1) % BUF;
}
let lastSample = 0;
function loop(ts) {
/* 采样节流到 ~30Hz,避免 1Hz 数据下过度密集 */
if (ts - lastSample > 33) { sample(); lastSample = ts; }
drawBig();
for (const [sig, m] of Object.entries(MINIS)) {
const card = document.querySelector(`[data-signal="${sig}"]`);
if (card && card.classList.contains("open")) drawMini(m);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
return {};
})();
/* ═══════════════════════ 初始化 ═══════════════════════ */
/* 本页不提供连接配置:读主页 localStorage dcu_esp_ip,进入后自动探测 */
state.ip = getSharedDeviceIp();
/* 先恢复用户上次输入(modeSelect / 目标值 / PID / 通道 / 选中节点),
* 再 applyModeVisibility 才能让保存的 mode 决定字段可见性。 */
loadUiState();
/* 固定节点 1–9:恢复后若无有效选中,默认 N1,保证离线也能直接刷写 */
if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) {
state.selectedNode = 1;
}
applyModeVisibility();
updateNodeLabels();
updateConnUi();
renderNodeList();
renderStatusPanel();
restoreVersionUi(state.selectedNode); /* 回填上次缓存的版本号 */
bindAutoSave();
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.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] 锁相基准 + 慢请求统计已清空");
},
};
/* 延迟一帧再连,避免阻塞首屏渲染 */
setTimeout(connectShared, 80);